diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000000..2408084cf9 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them. + ignore_usernames: + - "dependabot[bot]" diff --git a/.config/nextest.toml b/.config/nextest.toml index 2585577ec1..83ee9cd6f6 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,5 +1,6 @@ # cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test -# binaries. Optional for local runs; `cargo test` keeps working and stays what CI runs (#2204). +# binaries. CI runs it with the `ci` profile below (#6117); locally it is optional and `cargo test` +# keeps working (#2204). # # cargo nextest run --workspace # all tests except doctests # cargo test --doc --workspace # nextest does not run doctests @@ -11,5 +12,13 @@ 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`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its +# runner, fails a test that hangs instead of letting it hold the runner, and repeats every failure +# at the end of the log. The default profile's GPU override still applies. +[profile.ci] +fail-fast = true +slow-timeout = { period = "60s", terminate-after = 10 } +failure-output = "immediate-final" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..2c4b8cb1a4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bec29abaf5..ba8243353d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: inputs: fuzz: @@ -22,8 +23,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 @@ -39,10 +40,11 @@ jobs: # platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile. # macOS is tested only by release.yml. platforms: + timeout-minutes: 10 name: select platforms runs-on: ubuntu-latest outputs: - os: ${{ steps.pick.outputs.os }} + legs: ${{ steps.pick.outputs.legs }} web: ${{ steps.pick.outputs.web }} steps: - uses: actions/checkout@v4 @@ -56,34 +58,46 @@ 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: 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)]" 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 # The browser build (a fat-LTO wasm build) runs only when something it compiles or # packages changed: the web app and its packaging, crate sources and manifests (the web # build compiles nearly every crate), the workspace manifests and this workflow. - if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then + if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web(-boot)?/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then echo "web=true" >> "$GITHUB_OUTPUT" else 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 }}) + timeout-minutes: 100 + 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 + # 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 @@ -106,49 +120,44 @@ 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 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 == 'all' || matrix.part == 'integration') }} 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 - # `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate. - - name: Test - run: cargo test --workspace --locked --features heif - - # The same codecs without the optional decoder: HEIC must stay a clear "not in this build" - # error for distributors who leave it out. - - name: Test without heif - if: runner.os == 'Linux' - run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked - - # 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). - - 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 test --workspace --locked --features heif - + # Keep formatting, strict Clippy, layering and scorecard checks ahead of tests. - name: Clippy + 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' run: cargo xtask layers # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors, @@ -157,7 +166,131 @@ jobs: if: runner.os == 'Linux' run: cargo xtask scorecard --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@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22 + 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. + # + # 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 fails fast and terminates hung tests + # (.config/nextest.toml); assertion failures are not retried. + - name: Test + env: + PART: ${{ matrix.part }} + run: | + case "$PART" in + unit) targets=(--lib --bins) ;; + integration) targets=(--test '*') ;; + *) targets=(--lib --bins --tests) ;; + esac + 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 + + # 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 and both Linux variants passed. `needs` reports one result + # for all matrix legs; when a PR runs no Windows legs it still checks every Linux result. + windows-gate: + timeout-minutes: 5 + name: test (windows-latest) + needs: [platforms, test, test-variants] + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + steps: + - env: + PLATFORMS: ${{ needs.platforms.result }} + TEST: ${{ needs.test.result }} + VARIANTS: ${{ needs.test-variants.result }} + run: | + echo "platforms: $PLATFORMS, test: $TEST, variants: $VARIANTS" + [ "$PLATFORMS" = success ] && [ "$TEST" = success ] && [ "$VARIANTS" = 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 + # tight (#2688). + test-variants: + timeout-minutes: 100 + 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@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22 + 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 nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci + + # 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. + # Filter execution to the three crates that read the optional font input (#6127). + - 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 --profile ci --workspace --locked --features heif --lib --bins --tests + -E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)' + corpus: + timeout-minutes: 30 # Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are # pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache # restore and a download; a missing corpus fails the tests. The cache key changes only when a @@ -190,6 +323,7 @@ jobs: run: cargo xtask test-corpus wasm: + timeout-minutes: 30 name: wasm32 check runs-on: ubuntu-latest steps: @@ -206,6 +340,7 @@ jobs: run: cargo xtask wasm web: + timeout-minutes: 60 name: web build and wasm size gate # PRs run it only when web-relevant files change (see `platforms`); main always does. needs: platforms @@ -229,8 +364,8 @@ jobs: curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ | tar -xz -C "$HOME/.cargo/bin" trunk trunk --version - # package.sh runs Trunk's release build, validates relative URLs, checks the 24 MiB - # wasm limit and exercises the same packaging step used by release.yml. + # package.sh runs Trunk's release builds, validates relative URLs, cuts the wasm into + # parts, checks the 24 MiB per-file limit and exercises the packaging step release.yml uses. - name: Build and verify browser release run: packaging/web/package.sh - name: Report wasm size @@ -241,15 +376,19 @@ jobs: echo echo "| File | Bytes | MiB | Limit |" echo "| --- | ---: | ---: | ---: |" + row() { echo "| $1 | $2 | $(awk -v n="$2" 'BEGIN { printf "%.2f", n / 1048576 }') | $3 |"; } + # The app's wasm as built; it ships gzipped in parts, so only the download grows with it. for wasm in dist/web/*.wasm; do [ -f "$wasm" ] || continue - bytes=$(wc -c < "$wasm" | tr -d ' ') - mib=$(awk -v n="$bytes" 'BEGIN { printf "%.2f", n / 1048576 }') - echo "| $(basename "$wasm") | $bytes | $mib | 24 MiB |" + row "$(basename "$wasm")" "$(wc -c < "$wasm" | tr -d ' ')" "none" done + # What a host sees: the largest file of the packaged site. + largest=$(find target/web-package -type f -printf '%s %f\n' | sort -n | tail -1) + row "largest file in the site: ${largest#* }" "${largest%% *}" "24 MiB" } >> "$GITHUB_STEP_SUMMARY" fuzz: + timeout-minutes: 60 name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }}) # Placeholder: manual trigger only until a nightly schedule is enabled. if: github.event_name == 'workflow_dispatch' && inputs.fuzz diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1d6c4c1f56..67e6b31894 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -27,6 +27,7 @@ concurrency: jobs: build: + timeout-minutes: 15 name: mdBook build runs-on: ubuntu-latest steps: diff --git a/.github/workflows/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/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 272715dc81..1d110e28f2 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -23,6 +23,7 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main verified-appimage-inputs: + timeout-minutes: 10 name: Verified AppImage inputs runs-on: ubuntu-latest steps: diff --git a/AGENTS.md b/AGENTS.md index e44dbe32a8..97b5ccdbc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ apps/ photocraft desktop app (eframe/wgpu), TCP control server photocraft-cli headless CLI (convert/info/run/batch/commands/mcp) photocraft-web the same app in the browser (trunk + wasm-bindgen) + photocraft-web-boot starts the packaged browser app from compressed parts of its wasm (no file over a host's size cap) xtask/ cargo xtask layers | wasm | ci | stats | corpus | test-corpus | parity | perf | scorecard ``` @@ -131,7 +132,6 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still - `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics. - `plan/` (local, gitignored): research, parity plan, execution plan, estimates. - `log/` (local, gitignored): the dev log. -- ## 8. Keeping the native format complete diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 369aa638fe..ea0edeb6cf 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -13,6 +13,7 @@ next to them. | `crates/codecs/src/codecs/xpm/x11_colors.rs` | X11 `rgb.txt` color-name lookup table (normalized X.Org RGB values) | X.Org Foundation and X11 contributors | | MIT (X.Org app/rgb) | | `crates/ui-egui/src/i18n/el.tsv` | Greek UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/de.tsv` | German UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/es.tsv` | Spanish UI translations | @gorevyoneticisi; AI contributor: OpenAI Codex | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/it.tsv` | Italian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/ko.tsv` | Korean UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/nl.tsv` | Dutch UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | @@ -26,7 +27,7 @@ next to them. | `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | @@ -37,10 +38,12 @@ next to them. | `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush`, scaled to 70%, inside a dashed upright rounded "C" selection | Lucide Icons and Contributors; selection by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | @@ -55,6 +58,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) @@ -113,6 +117,6 @@ next to the files: | `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI | | `corpus/atn/` (16 files, 0.9 MB) | Photoshop Actions sets, one folder per repository: `sbaril/` (Frequency Separation, Firefly, Dimension, Harmonize, Photo, Quick Object Extraction, Generative Upscale), `bjango/` (Bjango Actions, Adjustment Actions, Scale Actions), `bkwld/` (Save layers for web), `atntools/` (test.atn), `trimmer/` (Trimmer), `alpha-utility/` (AlphaUtilityAdditions), `gif-creator/` (BannerActions) | Stéphane Baril; Bjango and Marc Edwards; BKWLD; Maurits Rijk; damsmcfly; UCLA Game Lab; overbyte | [sbaril/Photoshop-Scripts](https://github.com/sbaril/Photoshop-Scripts), [bjango/Bjango-Actions](https://github.com/bjango/Bjango-Actions), [BKWLD/photoshop-actions](https://github.com/BKWLD/photoshop-actions), [mrijk/atntools](https://github.com/mrijk/atntools), [damsmcfly/photoshop-cleaner-trimmer](https://github.com/damsmcfly/photoshop-cleaner-trimmer), [uclagamelab/AlphaUtilityAdditions](https://github.com/uclagamelab/AlphaUtilityAdditions), [overbyte/photoshop-gif-creator-script](https://github.com/overbyte/photoshop-gif-creator-script), pinned in [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | MIT (sbaril, BKWLD, atntools, gif-creator), BSD (bjango, per `Help/License.md`), BSD-2-Clause (alpha-utility), Unlicense (trimmer); each licence file sits next to its files | | `corpus/pngsuite/` | PngSuite | Willem van Schaik | | Public domain | -| `corpus/pixls/` (7 files, 226 MB) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked | +| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked | Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0. diff --git a/Cargo.lock b/Cargo.lock index 6820d6dee5..6b5a7bbf9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,12 +550,48 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bindgen" version = "0.72.1" @@ -645,7 +681,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.1", ] [[package]] @@ -654,6 +690,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -885,7 +930,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -896,8 +941,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -973,6 +1020,39 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmpv2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" +dependencies = [ + "crmf", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid", + "der", + "spki", + "x509-cert", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -1028,6 +1108,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -1103,6 +1189,12 @@ dependencies = [ "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "core_maths" version = "0.1.1" @@ -1112,6 +1204,15 @@ dependencies = [ "libm", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -1121,12 +1222,22 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" + [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5" +source = "git+https://github.com/storytold/craft-libs?rev=3d37da389a3618ab9b76109da11416e9ca4ea719#3d37da389a3618ab9b76109da11416e9ca4ea719" dependencies = [ + "craft-winprint-gdi", + "miniz_oxide 0.8.9", + "roxmltree", "tempfile", + "winprint", + "winsafe", ] [[package]] @@ -1138,6 +1249,14 @@ dependencies = [ "serde", ] +[[package]] +name = "craft-winprint-gdi" +version = "0.1.0" +source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1181,6 +1300,18 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "crmf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" +dependencies = [ + "cms", + "der", + "spki", + "x509-cert", +] + [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -1221,6 +1352,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -1267,6 +1408,30 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "dify" version = "0.8.0" @@ -1280,6 +1445,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1394,6 +1569,12 @@ dependencies = [ "linux-raw-sys 0.9.4", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1578,6 +1759,20 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "endi" version = "1.1.1" @@ -1739,6 +1934,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exacl" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de9e551ced9d700a31dacb8b168129640240fcd7a279a3fa4d59608b84ebcae" +dependencies = [ + "bitflags 2.13.2", + "log", + "scopeguard", + "uuid", +] + [[package]] name = "exr" version = "1.74.2" @@ -1797,6 +2004,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -1809,6 +2026,12 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.10" @@ -1826,6 +2049,28 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "fmt-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb4a4b45c26455885f8b462db12978e51f60cc914337bccb9a36faf93410169" +dependencies = [ + "fmt-derive-proc", +] + +[[package]] +name = "fmt-derive-proc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704da0760734344ce8753f15b21e97c6be85dc5a8f2c3a963fbd042b3380d23" +dependencies = [ + "proc-macro-crate", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "fmv-macos-events" version = "0.1.0" @@ -1948,6 +2193,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.34" @@ -2073,6 +2324,16 @@ dependencies = [ "libc", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -2092,6 +2353,19 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -2111,9 +2385,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -2279,6 +2555,25 @@ dependencies = [ "euclid", ] +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -2359,6 +2654,107 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -3188,6 +3584,12 @@ dependencies = [ "imgref", ] +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "maybe-owned" version = "0.3.4" @@ -3237,6 +3639,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3302,6 +3710,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "naga" version = "30.0.1" @@ -3997,6 +4411,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -4116,6 +4536,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "peniko" version = "0.6.1" @@ -4191,7 +4630,7 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.8.2" +version = "0.11.1" dependencies = [ "arboard", "async-io", @@ -4224,6 +4663,7 @@ dependencies = [ "rfd", "ron", "serde_json", + "tempfile", "winit", "winresource", "winsafe", @@ -4235,7 +4675,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.8.2" +version = "0.11.1" dependencies = [ "crc32fast", "flate2", @@ -4245,7 +4685,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.8.2" +version = "0.11.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4258,9 +4698,9 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.8.2" +version = "0.11.1" dependencies = [ - "base64", + "base64 0.22.1", "cap-std", "getrandom 0.3.4", "photocraft-codecs", @@ -4271,6 +4711,7 @@ dependencies = [ "photocraft-geom", "photocraft-io", "rmcp", + "rustix 1.1.5", "schemars", "serde", "serde_json", @@ -4280,7 +4721,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.8.2" +version = "0.11.1" dependencies = [ "photocraft-automation", "photocraft-codecs", @@ -4294,7 +4735,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.8.2" +version = "0.11.1" dependencies = [ "moxcms", "rayon", @@ -4302,7 +4743,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.8.2" +version = "0.11.1" dependencies = [ "exr", "flate2", @@ -4323,7 +4764,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.8.2" +version = "0.11.1" dependencies = [ "photocraft-cms", "serde", @@ -4331,7 +4772,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.8.2" +version = "0.11.1" dependencies = [ "criterion", "photocraft-color", @@ -4346,7 +4787,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.8.2" +version = "0.11.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4357,7 +4798,7 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.8.2" +version = "0.11.1" dependencies = [ "blake3", "craft-print", @@ -4373,6 +4814,7 @@ dependencies = [ "photocraft-format", "photocraft-geom", "photocraft-io", + "photocraft-ipp", "photocraft-ops", "photocraft-paint", "photocraft-plugins", @@ -4383,6 +4825,7 @@ dependencies = [ "photocraft-vector", "rayon", "roxmltree", + "same-file", "serde", "serde_json", "tempfile", @@ -4392,10 +4835,11 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.8.2" +version = "0.11.1" dependencies = [ "blake3", "crc32fast", + "exacl", "flate2", "libc", "photocraft-codecs", @@ -4409,18 +4853,19 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "winsafe", ] [[package]] name = "photocraft-geom" -version = "0.8.2" +version = "0.11.1" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.8.2" +version = "0.11.1" dependencies = [ "half", "log", @@ -4437,16 +4882,17 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.8.2" +version = "0.11.1" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.8.2" +version = "0.11.1" dependencies = [ "blake3", + "craft-docpath", "crc32fast", "flate2", "photocraft-affinity", @@ -4471,16 +4917,20 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "photocraft-ipp" +version = "0.11.1" + [[package]] name = "photocraft-ops" -version = "0.8.2" +version = "0.11.1" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.8.2" +version = "0.11.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4491,7 +4941,7 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.8.2" +version = "0.11.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4506,7 +4956,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.8.2" +version = "0.11.1" dependencies = [ "flate2", "photocraft-psd", @@ -4517,17 +4967,18 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.8.2" +version = "0.11.1" dependencies = [ "photocraft-color", "photocraft-geom", "proptest", + "rayon", "serde", ] [[package]] name = "photocraft-raw" -version = "0.8.2" +version = "0.11.1" dependencies = [ "flate2", "image", @@ -4542,7 +4993,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.8.2" +version = "0.11.1" dependencies = [ "block2 0.6.2", "objc2 0.6.4", @@ -4554,7 +5005,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.8.2" +version = "0.11.1" dependencies = [ "memory-stats", "photocraft-color", @@ -4565,7 +5016,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.8.2" +version = "0.11.1" dependencies = [ "flate2", "parley", @@ -4574,14 +5025,16 @@ dependencies = [ "photocraft-geom", "photocraft-psd", "photocraft-raster", + "rayon", "skrifa", ] [[package]] name = "photocraft-ui-egui" -version = "0.8.2" +version = "0.11.1" dependencies = [ - "base64", + "base64 0.22.1", + "craft-print", "craft-ui", "eframe", "egui", @@ -4614,12 +5067,13 @@ dependencies = [ "serde_json", "sys-locale", "toml", + "unicode-bidi", "winsafe", ] [[package]] name = "photocraft-vector" -version = "0.8.2" +version = "0.11.1" dependencies = [ "photocraft-color", "photocraft-doc", @@ -4630,7 +5084,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.8.2" +version = "0.11.1" dependencies = [ "eframe", "egui", @@ -4650,6 +5104,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "photocraft-web-boot" +version = "0.11.1" +dependencies = [ + "flate2", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -4850,6 +5315,30 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -4956,6 +5445,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -5008,6 +5554,12 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -5023,6 +5575,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -5233,6 +5794,49 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "resvg" version = "0.45.1" @@ -5286,6 +5890,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + [[package]] name = "rmcp" version = "3.5.0" @@ -5413,6 +6031,81 @@ dependencies = [ "rustix 1.1.5", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -5451,13 +6144,25 @@ dependencies = [ [[package]] name = "ruzstd" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888" +checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5" dependencies = [ "twox-hash", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "same-file" version = "1.0.6" @@ -5467,6 +6172,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "1.2.2" @@ -5505,6 +6219,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -5571,6 +6308,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -5591,6 +6339,40 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -5613,6 +6395,175 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "sigstore-bundle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7dc53c8a941858d01479622dbb7650aaa2ee0ca1fb58fb6cdddbfc3064abbb" +dependencies = [ + "base64 0.22.1", + "hex", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-crypto" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "const-oid", + "der", + "digest", + "pem", + "rand_core 0.10.1", + "sha2", + "signature", + "sigstore-types", + "spki", + "thiserror 2.0.21", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c0d53558825c77716855c13794306fff766854c1bf92948e77d7890da3f2455" +dependencies = [ + "base64 0.22.1", + "hex", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-rekor" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "641fdaaa40d0cd6e249cf1671c2240beff1a3ece578062cf43e750779e8db2b3" +dependencies = [ + "base64 0.22.1", + "hex", + "reqwest", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6b0d2550f05c096400066e858db13d91f9de3b750a7541fb1291c1dc80ca290" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "rustls-pki-types", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "x509-cert", +] + +[[package]] +name = "sigstore-tsa" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bc86c8abc1ece6832236e7e34e9baebb5d4d40490c0332b814a0d8624ca7255" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "chrono", + "cmpv2", + "cms", + "const-oid", + "der", + "hex", + "rand 0.10.3", + "reqwest", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "tracing", + "x509-cert", + "x509-tsp", +] + +[[package]] +name = "sigstore-types" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8d870c9bcfdf83396ac2bd2d6a23c5d09ec02cc9fda50fa1cbb3dd71a9bee53" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "pem", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-verify" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d30aff69036e4579b416956e9ba68cec45b793116f78f10842d04c7f8b924bab" +dependencies = [ + "base64 0.22.1", + "chrono", + "cms", + "const-oid", + "hex", + "pem", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -5790,6 +6741,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5833,6 +6794,33 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgtypes" version = "0.15.3" @@ -5843,6 +6831,16 @@ dependencies = [ "siphasher", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -5865,6 +6863,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -5885,6 +6892,27 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "7.0.8" @@ -5898,6 +6926,17 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -6033,6 +7072,27 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.53.1" @@ -6059,6 +7119,16 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -6135,6 +7205,51 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -6188,6 +7303,12 @@ dependencies = [ "petgraph", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -6218,6 +7339,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" @@ -6295,6 +7422,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -6313,7 +7452,7 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -6421,6 +7560,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -6755,6 +7903,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -7042,6 +8199,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -7294,6 +8462,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "winprint" +version = "0.2.1" +source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49" +dependencies = [ + "flate2", + "fmt-derive", + "reqwest", + "scopeguard", + "sigstore-trust-root", + "sigstore-types", + "sigstore-verify", + "strum", + "tar", + "thiserror 2.0.21", + "windows", + "windows-core", + "windows-implement", + "xml", +] + [[package]] name = "winresource" version = "0.1.31" @@ -7382,6 +8571,41 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "sha1", + "signature", + "spki", + "tls_codec", +] + +[[package]] +name = "x509-tsp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" +dependencies = [ + "cmpv2", + "cms", + "der", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.5", +] + [[package]] name = "xcap" version = "0.9.8" @@ -7449,6 +8673,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" + [[package]] name = "xml-rs" version = "0.8.29" @@ -7463,7 +8693,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.8.2" +version = "0.11.1" dependencies = [ "serde", "serde_json", @@ -7646,6 +8876,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index f96b5cb468..b3f49802e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"] [workspace.package] -version = "0.8.2" +version = "0.11.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -36,9 +36,11 @@ 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 = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" } +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" @@ -75,9 +77,18 @@ 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 +# 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 @@ -127,17 +138,32 @@ opt-level = "z" [profile.wasm-release.package.naga] opt-level = "z" -# Pixel processing keeps its speed-oriented optimization level. +# 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), +# so the CPU compositor is not the browser's hot path there; "z" for compose keeps the wasm +# under the 24 MiB gate (measured −122 KB vs opt-level 3). algo's filters run per pixel on +# every interactive filter application; "z" there is the size lever of last resort (−331 KB), +# taken because the gate is enforced in CI: if browser filters feel too slow, restore 3 here +# and reclaim the bytes from a feature-gated decoder instead. [profile.wasm-release.package.photocraft-raster] opt-level = 3 [profile.wasm-release.package.photocraft-color] opt-level = 3 [profile.wasm-release.package.photocraft-cms] -opt-level = 3 +opt-level = "z" [profile.wasm-release.package.photocraft-compose] -opt-level = 3 +opt-level = "z" [profile.wasm-release.package.photocraft-algo] -opt-level = 3 +opt-level = "z" [profile.wasm-release.package.photocraft-paint] opt-level = 3 diff --git a/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/README.md b/README.md index 5fd2650415..7554fcd8f2 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ macOS · Windows · Linux · FreeBSD · Web License: MIT OR Apache-2.0 Status: early alpha +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

@@ -187,7 +190,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off

🗃️ Layers, done properly

- Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 Photoshop blend modes plus six Paint.NET variants, opacity and fill, locks, colour labels, layer filters, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into. + Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 Photoshop blend modes plus six Paint.NET variants, opacity and fill, locks, colour labels, a layer kind filter, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into. @@ -275,9 +278,11 @@ 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. +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). On macOS, the command-line tool comes as `photocraft-cli--macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself: @@ -300,6 +305,19 @@ Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are > [!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. + +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 +``` + ## Documentation Developer, architecture, automation, format, and security documentation is maintained in the [PhotoCraft documentation book](book/). diff --git a/ROADMAP.md b/ROADMAP.md index 98e8c1184c..f41b71bfc9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026 PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md) @@ -40,7 +40,7 @@ the one above. About 80% parallelizes (independent crates and command modules); architecture and the PSD writer are the serial parts. **Why alpha.** Core workflows (open, layer, select, paint, adjust, filter, type, save) work end to -end, and breadth is high, but users of 0.5/0.6 have 579 open issues, 22 of 25 performance budgets +end, and breadth is high, but 953 issues are open (2026-10-13), 22 of 25 performance budgets are missed, and PSDs we write are not yet reliably accepted by Photoshop (#1281, #2469). Beta needs ~75% ready and no blocking PSD gap. The [alpha gate](docs/roadmap.md#alpha-gate) passes: all six core workflows (retouch, composite, cut-out, design, paint, PSD exchange) complete end to end and save and reopen; @@ -110,7 +110,7 @@ Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dut | Rank | Milestone | Estimate | |---|---|---:| -| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 133 → 200+ of 256 | 150–250 h | +| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 145 → 200+ of 258 | 150–250 h | | 2 | Performance budgets: the 22 over-budget scenarios and the 150-layer GPU crash | 200–350 h | | 3 | User bug backlog to under 100 open | 250–400 h | | 4 | Brush and transform feel (brush-parity, ui-parity rows) | 160–290 h | @@ -135,6 +135,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) | | 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) | | 2026-10-10 | minor | Readiness by audience with hours per tier | | 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added | 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 a81cce61ce..0e3429d074 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,22 +12,24 @@ 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>] + photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …). TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data). --quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless. --tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24. --dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16. + --dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1 + (DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain. photocraft-cli info [--compact] Print the document as JSON (size, mode, depth, layer tree). - photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] + photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] Open a file, run engine commands in order, save the result. Each --params applies to the preceding --cmd. Prints each command's JSON result. - photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] + photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a set: its action --action names plays, as below. JSON steps are [id, params] pairs, {\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them @@ -69,18 +71,23 @@ struct Subcommand { } const SUBCOMMANDS: &[Subcommand] = &[ - Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert }, + Subcommand { + name: "convert", + values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"], + bare: &["--tiff-layers", "--dds-mipmaps"], + run: convert, + }, Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) }, Subcommand { name: "run", - values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"], - bare: &["--tiff-layers"], + values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"], + bare: &["--tiff-layers", "--dds-mipmaps"], run: run_cmds, }, Subcommand { name: "batch", - values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"], - bare: &["--in-place", "--tiff-layers"], + values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"], + bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"], run: batch, }, Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet }, @@ -219,6 +226,11 @@ fn export_opts(a: &Args) -> Result { o.encode.dpx_bits = Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?); } + if let Some(c) = a.get("--dds-compression") { + o.encode.dds.compression = + photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?; + } + o.encode.dds.mipmaps = a.has("--dds-mipmaps"); Ok(o) } @@ -541,9 +553,7 @@ fn commands(a: &Args, out: &mut dyn Write) -> R { .map(|v| v.iter().collect::>()) .unwrap_or_default() .into_iter() - .filter(|c| { - needle.as_ref().is_none_or(|n| format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")).to_lowercase().contains(n)) - }) + .filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n))) .collect(); if a.has("--json") { return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false); diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index 231eb2c321..6c62e40804 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -90,6 +90,11 @@ fn unknown_flags_are_usage_errors() { // Every flag still works in both forms. let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"])); assert!(out.contains("filter.blur.gaussianBlur"), "{out}"); + // The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060). + for needle in ["ellipse", "marquee"] { + let (out, _) = ok(bin().args(["commands", "--filter", needle])); + assert!(out.contains("select.rect"), "{needle}: {out}"); + } ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([ "--format=jpg", "--quality", @@ -489,6 +494,39 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() { } } +/// DDS converts both ways; `--dds-compression` picks the FourCC (bytes 84–87; none writes +/// uncompressed pixels) and `--dds-mipmaps` the mip count (bytes 28–31). A compression that +/// isn't none, bc1, bc2 or bc3 is refused and nothing is written (#1327). +#[test] +fn dds_converts_both_ways_with_the_compression_asked_for() { + let d = tmp("dds"); + let a = d.join("a.png"); + write_png(&a, 8, 4, 3); + for (args, fourcc, mips) in [ + (&[][..], [0; 4], 1), + (&["--dds-compression", "bc1"][..], *b"DXT1", 1), + (&["--dds-compression", "DXT3", "--dds-mipmaps"][..], *b"DXT3", 4), + (&["--dds-compression", "bc3"][..], *b"DXT5", 1), + (&["--dds-mipmaps"][..], [0; 4], 4), + ] { + let out = d.join("a.dds"); + ok(bin().arg("convert").arg(&a).arg(&out).args(args)); + let bytes = std::fs::read(&out).unwrap(); + let mip_count = u32::from_le_bytes(bytes[28..32].try_into().unwrap()); + assert_eq!((&bytes[..4], &bytes[84..88], mip_count), (&b"DDS "[..], &fourcc[..], mips), "{args:?}"); + let back = d.join("back.png"); + ok(bin().arg("convert").arg(&out).arg(&back)); + assert!(back.exists(), "{args:?}"); + } + for bad in ["bc7", "dxt2", ""] { + let out = d.join("bad.dds"); + let o = bin().arg("convert").arg(&a).arg(&out).args(["--dds-compression", bad]).output().unwrap(); + assert_eq!(o.status.code(), Some(1), "--dds-compression {bad}"); + assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dds-compression `{bad}`")), "--dds-compression {bad}"); + assert!(!out.exists(), "--dds-compression {bad} wrote a file"); + } +} + /// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is /// refused and nothing is written. #[test] diff --git a/apps/photocraft-web-boot/Cargo.toml b/apps/photocraft-web-boot/Cargo.toml new file mode 100644 index 0000000000..dd12341af2 --- /dev/null +++ b/apps/photocraft-web-boot/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "photocraft-web-boot" +description = "Starts Photocraft in the browser from compressed parts of its WebAssembly" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lints] +workspace = true + +[[bin]] +name = "photocraft-web-boot" +path = "src/main.rs" + +[dependencies] +# Inflates the parts in browsers without `DecompressionStream`. +flate2 = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] } diff --git a/apps/photocraft-web-boot/Trunk.toml b/apps/photocraft-web-boot/Trunk.toml new file mode 100644 index 0000000000..56192a9921 --- /dev/null +++ b/apps/photocraft-web-boot/Trunk.toml @@ -0,0 +1,6 @@ +# Built by packaging/web/package.sh: trunk build --release (from apps/photocraft-web-boot) +[build] +target = "index.html" +dist = "../../dist/web-boot" +public_url = "./" +filehash = true diff --git a/apps/photocraft-web-boot/index.html b/apps/photocraft-web-boot/index.html new file mode 100644 index 0000000000..8ed9a6961e --- /dev/null +++ b/apps/photocraft-web-boot/index.html @@ -0,0 +1,30 @@ + + + + + + PhotoCraft + + + + + +
+

Loading PhotoCraft…

+

This is taking longer than usual. If PhotoCraft doesn't start, its files may have failed to + download or come from a different version of this page. Reload to try again; the browser + console lists what failed.

+
+ + diff --git a/apps/photocraft-web-boot/src/main.rs b/apps/photocraft-web-boot/src/main.rs new file mode 100644 index 0000000000..56c2bf1517 --- /dev/null +++ b/apps/photocraft-web-boot/src/main.rs @@ -0,0 +1,356 @@ +//! Starts Photocraft in the browser from compressed parts of its WebAssembly. +//! +//! Static hosts cap the size of one file (Cloudflare Pages: 25 MiB), and the app's wasm is at +//! that size. `packaging/web/package.sh` gzips it and cuts it into parts listed in +//! `app/parts.txt`. This small module is what the packaged page loads: it fetches the parts in +//! order, joins and inflates them as they arrive, and hands the stream to the app's own generated +//! loader, so the browser still compiles while downloading. No file on the host is larger than +//! one part, however large the app grows. +//! +//! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80). +//! In one that can't, this module inflates them itself, a little slower. +//! +//! A download that fails is tried again, the later tries past the browser's cache (a bad copy +//! there would otherwise fail forever). When every try fails the page says so and offers a reload. +//! +//! `trunk build` in `apps/photocraft-web` still gives the app as one `.wasm`, for development and +//! for hosts without a per-file cap; only the packaged site starts through this module. + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +/// First line of `parts.txt`. Some hosts answer a missing file with the home page and HTTP 200; +/// without this an HTML page would be read as a list of parts. +#[cfg(any(target_arch = "wasm32", test))] +const MARKER: &str = "photocraft-parts 1"; + +/// The part names in a `parts.txt`, in order, or `None` when the text isn't one. A name is a +/// file next to `parts.txt`: anything that could point elsewhere is refused. +#[cfg(any(target_arch = "wasm32", test))] +fn parts(list: &str) -> Option> { + let mut lines = list.lines().map(str::trim).filter(|l| !l.is_empty()); + if lines.next() != Some(MARKER) { + return None; + } + let parts: Vec = lines.map(str::to_owned).collect(); + let plain = |p: &String| !p.starts_with('.') && !p.contains(['/', '\\', '?', '#', ':', '%']); + (!parts.is_empty() && parts.iter().all(plain)).then_some(parts) +} + +/// Gzip inflated a chunk at a time, for browsers that can't do it themselves. +#[cfg(any(target_arch = "wasm32", test))] +struct Inflater(flate2::write::GzDecoder>); + +#[cfg(any(target_arch = "wasm32", test))] +impl Inflater { + fn new() -> Self { + Inflater(flate2::write::GzDecoder::new(Vec::new())) + } + + /// What the next `chunk` of the gzip stream inflates to (often nothing: the decoder holds on + /// to input until it has a whole block). + fn push(&mut self, chunk: &[u8]) -> std::io::Result> { + std::io::Write::write_all(&mut self.0, chunk)?; + Ok(std::mem::take(self.0.get_mut())) + } + + /// The rest, once the stream has ended. An error when it ended early or its checksum is + /// wrong, the same as the browser's inflater gives. + fn finish(&mut self) -> std::io::Result> { + self.0.try_finish()?; + Ok(std::mem::take(self.0.get_mut())) + } +} + +#[cfg(target_arch = "wasm32")] +mod boot { + use std::cell::RefCell; + use std::rc::Rc; + + use wasm_bindgen::prelude::*; + use wasm_bindgen_futures::{JsFuture, spawn_local}; + use web_sys::{ + Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream, + TransformStreamDefaultController, Transformer, WritableStream, + }; + + use super::Inflater; + + /// Where packaging puts the app, next to this module's own files. + const PARTS: &str = "./app/parts.txt"; + /// Seconds to wait before each further try; one more than this many tries are made. + const RETRY_AFTER: [u32; 2] = [1, 3]; + + // The app's `wasm-bindgen` loader. Its default export instantiates the module it is given and + // runs the app's `main`. The path is fixed at compile time, so packaging gives the file this + // name (Trunk's own name for it carries a content hash). + #[wasm_bindgen(raw_module = "./app/photocraft-web.js")] + extern "C" { + #[wasm_bindgen(js_name = default, catch)] + async fn start_app(module: JsValue) -> Result; + } + + // The browser's own inflater. `web-sys` has it only behind its unstable-API flag, which would + // have to be set for the whole build, so the three members used are declared here. + #[wasm_bindgen] + extern "C" { + type DecompressionStream; + #[wasm_bindgen(constructor, catch)] + fn new(format: &str) -> Result; + #[wasm_bindgen(method, getter)] + fn readable(this: &DecompressionStream) -> ReadableStream; + #[wasm_bindgen(method, getter)] + fn writable(this: &DecompressionStream) -> WritableStream; + } + + fn window() -> Result { + web_sys::window().ok_or_else(|| JsValue::from_str("no window")) + } + + /// `fresh` skips the browser's cache. + async fn fetch(url: &str, fresh: bool) -> Result { + let init = RequestInit::new(); + if fresh { + init.set_cache(RequestCache::Reload); + } + let response: Response = JsFuture::from(window()?.fetch_with_str_and_init(url, &init)).await?.dyn_into()?; + if !response.ok() { + return Err(JsValue::from_str(&format!("{url}: HTTP {}", response.status()))); + } + if response.headers().get("Content-Type")?.is_some_and(|t| t.starts_with("text/html")) { + return Err(JsValue::from_str(&format!("{url}: the server sent a web page, not the file"))); + } + Ok(response) + } + + /// Copies each part into `sink` in order, then closes it. + async fn feed(parts: Vec, sink: WritableStream, fresh: bool) -> Result<(), JsValue> { + let keep_open = StreamPipeOptions::new(); + keep_open.set_prevent_close(true); + for part in &parts { + let body = fetch(&format!("./app/{part}"), fresh).await?.body().ok_or_else(|| JsValue::from_str("part without a body"))?; + JsFuture::from(body.pipe_to_with_options(&sink, &keep_open)).await?; + } + JsFuture::from(sink.close()).await?; + Ok(()) + } + + /// Passes inflated bytes on to the stream's reader. + fn emit(inflated: std::io::Result>, out: &TransformStreamDefaultController) -> Result<(), JsValue> { + let inflated = inflated.map_err(|e| JsValue::from_str(&format!("the download is damaged ({e})")))?; + if inflated.is_empty() { Ok(()) } else { out.enqueue_with_chunk(&js_sys::Uint8Array::from(inflated.as_slice())) } + } + + /// A gzip inflater with the shape of `DecompressionStream`, running in this module. + fn own_inflater() -> Result { + let inflater = Rc::new(RefCell::new(Inflater::new())); + let last = inflater.clone(); + let busy = || JsValue::from_str("the inflater is in use"); + let transform = Closure:: Result<(), JsValue>>::new( + move |chunk: JsValue, out: TransformStreamDefaultController| { + let chunk = js_sys::Uint8Array::new(&chunk).to_vec(); + emit(inflater.try_borrow_mut().map_err(|_| busy())?.push(&chunk), &out) + }, + ); + let flush = Closure:: Result<(), JsValue>>::new(move |out: TransformStreamDefaultController| { + emit(last.try_borrow_mut().map_err(|_| busy())?.finish(), &out) + }); + let transformer = Transformer::new(); + transformer.set_transform(&transform.into_js_value().unchecked_into()); + transformer.set_flush(&flush.into_js_value().unchecked_into()); + TransformStream::new_with_transformer(&transformer) + } + + /// `compressed` inflated: by the browser where it can, otherwise here. + fn inflated(compressed: &ReadableStream) -> Result { + let pair = if js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("DecompressionStream"))? { + let inflater = DecompressionStream::new("gzip")?; + ReadableWritablePair::new(&inflater.readable(), &inflater.writable()) + } else { + let inflater = own_inflater()?; + ReadableWritablePair::new(&inflater.readable(), &inflater.writable()) + }; + Ok(compressed.pipe_through(&pair)) + } + + async fn run(fresh: bool) -> Result<(), JsValue> { + let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default(); + let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?; + let joined = TransformStream::new()?; + let wasm = inflated(&joined.readable())?; + // Why the download stopped: the app's loader only reports that its stream broke. + let cause = Rc::new(RefCell::new(None)); + let failed = cause.clone(); + let sink = joined.writable(); + spawn_local(async move { + if let Err(e) = feed(parts, sink.clone(), fresh).await { + let _ = sink.abort_with_reason(&e); + failed.replace(Some(e)); + } + }); + let headers = Headers::new()?; + headers.set("Content-Type", "application/wasm")?; + let init = ResponseInit::new(); + init.set_headers(&headers); + let response = Response::new_with_opt_readable_stream_and_init(Some(&wasm), &init)?; + let arg = js_sys::Object::new(); + js_sys::Reflect::set(&arg, &JsValue::from_str("module_or_path"), &response)?; + match start_app(arg.into()).await { + Ok(_) => Ok(()), + Err(e) => Err(cause.take().unwrap_or(e)), + } + } + + async fn sleep(seconds: u32) { + let wait = js_sys::Promise::new(&mut |done, _| { + let ms = i32::try_from(seconds.saturating_mul(1000)).unwrap_or(i32::MAX); + if window().and_then(|w| w.set_timeout_with_callback_and_timeout_and_arguments_0(&done, ms)).is_err() { + let _ = done.call0(&JsValue::NULL); + } + }); + let _ = JsFuture::from(wait).await; + } + + /// Replaces the page's "Loading" text. `reload` adds a link that reloads the page. + fn say(text: &str, reload: bool) { + let Some(document) = web_sys::window().and_then(|w| w.document()) else { return }; + let Some(loading) = document.get_element_by_id("photocraft_loading") else { return }; + loading.set_text_content(None); + let Ok(line) = document.create_element("p") else { return }; + line.set_text_content(Some(text)); + if reload && let Ok(link) = document.create_element("a") { + link.set_text_content(Some("Reload")); + let _ = link.set_attribute("href", ""); + let _ = line.append_child(&link); + } + let _ = loading.append_child(&line); + } + + pub fn start() { + spawn_local(async { + let mut attempt = 0; + loop { + let error = match run(attempt > 0).await { + Ok(()) => return, + Err(e) => e, + }; + web_sys::console::error_2(&JsValue::from_str("photocraft-web-boot: could not start PhotoCraft:"), &error); + let Some(&wait) = RETRY_AFTER.get(attempt) else { + say("PhotoCraft couldn't be downloaded. Check your connection, then try again: ", true); + return; + }; + say("Loading PhotoCraft\u{2026} (the download failed, trying again)", false); + sleep(wait).await; + attempt += 1; + } + }); + } +} + +#[cfg(target_arch = "wasm32")] +fn main() { + boot::start(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn main() { + eprintln!("photocraft-web-boot only runs in the browser: packaging/web/package.sh builds it with `trunk build --release` in apps/photocraft-web-boot"); +} + +#[cfg(test)] +mod tests { + use super::{Inflater, MARKER, parts}; + + fn gzip(bytes: &[u8]) -> Vec { + let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut z, bytes).expect("gzip"); + z.finish().expect("gzip") + } + + /// Bytes that don't compress to nothing, so the gzip stream spans many chunks. + fn sample() -> Vec { + let mut x = 0x2545_f491_4f6c_dd1d_u64; + (0..200_000) + .map(|i| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + if i % 3 == 0 { x as u8 } else { (i / 7) as u8 } + }) + .collect() + } + + /// The parts arrive in whatever pieces the network gives; the inflater must not care where + /// they are cut. + #[test] + fn the_inflater_gives_back_the_bytes_however_the_stream_is_cut() { + let (plain, packed) = (sample(), gzip(&sample())); + for cut in [1, 7, 4096, 65_536, packed.len()] { + let mut inflater = Inflater::new(); + let mut out = Vec::new(); + for chunk in packed.chunks(cut) { + out.extend(inflater.push(chunk).expect("push")); + } + out.extend(inflater.finish().expect("finish")); + assert_eq!(out, plain, "chunks of {cut}"); + } + } + + /// A download that stops early, or whose bytes changed on the way, must fail (the boot module + /// then tries again), never start a damaged app. + #[test] + fn the_inflater_refuses_a_short_or_damaged_stream() { + let packed = gzip(&sample()); + let run = |bytes: &[u8]| -> std::io::Result> { + let mut inflater = Inflater::new(); + let mut out = inflater.push(bytes)?; + out.extend(inflater.finish()?); + Ok(out) + }; + assert!(run(&packed).is_ok()); + assert!(run(&packed[..packed.len() / 2]).is_err(), "cut short"); + assert!(run(&packed[..packed.len() - 4]).is_err(), "trailer cut"); + let mut damaged = packed.clone(); + let middle = damaged.len() / 2; + damaged[middle] ^= 0x55; + assert!(run(&damaged).is_err(), "a changed byte"); + assert!(run(b"not gzip").is_err(), "a web page"); + assert!(run(b"").is_err(), "nothing at all"); + } + + #[test] + fn a_parts_list_gives_its_names_in_order() { + let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n"); + assert_eq!(parts(&list), Some(vec!["photocraft-web-0123abcd.part-aa".to_owned(), "photocraft-web-0123abcd.part-ab".to_owned()])); + } + + /// A host that answers a missing file with its home page must not have that page read as a + /// list of parts. + #[test] + fn a_web_page_is_not_a_parts_list() { + assert_eq!(parts(include_str!("../index.html")), None); + assert_eq!(parts(""), None); + assert_eq!(parts("photocraft-web.part-aa\n"), None, "no marker"); + assert_eq!(parts(&format!("{MARKER}\n")), None, "no parts"); + assert_eq!(parts("photocraft-parts 2\na.part-aa\n"), None, "a later layout this build doesn't know"); + } + + /// Part names are joined onto `./app/`, so one that could leave that folder, or name another + /// origin, refuses the whole list. + #[test] + fn a_part_name_cannot_point_outside_the_app_folder() { + for bad in ["../index.html", "a/b.part-aa", "a\\b", "https://example.com/x", "//example.com/x", "x?y", "x#y", ".hidden", "%2e%2e"] { + assert_eq!(parts(&format!("{MARKER}\ngood.part-aa\n{bad}\n")), None, "{bad}"); + } + } + + /// The packaged site shows this crate's page, the development build the app's own. They are + /// the same page apart from which program the `` line builds. + #[test] + fn the_page_is_the_apps_page() { + let trunk = |html: &str| html.lines().filter(|l| !l.contains("data-trunk")).collect::>().join("\n"); + let (boot, app) = (include_str!("../index.html"), include_str!("../../photocraft-web/index.html")); + assert_eq!(trunk(boot), trunk(app)); + assert!(boot.contains(r#"data-bin="photocraft-web-boot""#) && boot.contains(r#"data-cargo-profile="wasm-release""#), "{boot}"); + assert!(boot.contains(r#"id="photocraft_loading""#), "the element the failure message replaces"); + } +} diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index a4ded64181..4ecad80ae4 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 @@ -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/main.rs b/apps/photocraft-web/src/main.rs index 5ba75f5254..0802a02ebf 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` @@ -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/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index a5c9d8a565..a248ecbb13 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}; @@ -18,8 +20,8 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", - "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", + "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; @@ -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,27 +381,14 @@ 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| { photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| { - let mut opts = photocraft_io::ExportOptions::default(); - if let Some(q) = settings.jpeg_quality { - opts.encode.jpeg_quality = q; - } - opts.encode.webp_lossless = settings.webp_lossless; - if let Some(q) = settings.webp_quality { - opts.encode.webp_quality = q; - } - opts.tiff_layers = settings.tiff_layers; - opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; - opts.tga_bits = settings.tga_bits; - opts.image_previews = settings.image_previews; - opts.psd_maximize_compatibility = settings.psd_maximize_compatibility; - photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) + photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) })), file_dialog: Some(Box::new(|request, _parent, reply| match request { // The browser's file picker hands over the files' contents, not paths. File › Open @@ -392,11 +427,15 @@ fn services(inbox: Inbox) -> Services { } })), write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))), + // A download may be cancelled and never reports back: it doesn't save the document. + write_unconfirmed: true, encode_png: Some(Box::new(|w, h, rgba| { let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?; 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/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index e5af663471..505947e222 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] @@ -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/app_dirs.rs b/apps/photocraft/src/app_dirs.rs index 2897bebdb0..a75520f3c4 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: the platform convention keeps crash-recovery autosaves out of what is synced +//! with the settings: the Windows roaming profile and the Linux config folder ([`recovery_dir`]). use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -60,6 +63,396 @@ 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`. Autosaves hold whole documents and are rewritten every few +/// minutes, so with the platform convention they stay out of what is synced with the settings: +/// - Windows (#5648): the machine-local `%LOCALAPPDATA%\Photocraft\Recovery`; the roaming +/// `%APPDATA%` may be synced at sign-in and sign-out or capped by policy. +/// - Linux and other XDG systems (#6612): `$XDG_STATE_HOME/photocraft/Recovery`, by default +/// `~/.local/state/photocraft/Recovery`, where the XDG Base Directory spec puts state that +/// should outlive a restart but isn't configuration; dotfile managers and config sync often copy +/// `~/.config` whole. As the spec asks, a relative `XDG_STATE_HOME` is ignored. +/// - macOS: `~/Library/Application Support/Photocraft/Recovery` as before. Apple's guidelines put +/// app-created data that can't be regenerated there; `~/Library/Caches` may be purged by the +/// system. +/// +/// They are never in a temp or cache folder, which cleanup tools may empty before a crashed +/// document is recovered. The override and portable folders keep everything in one place. +pub fn resolve_recovery(data: &DataDir, env: impl Fn(&str) -> Option) -> Option { + if data.mode == Mode::Platform { + if cfg!(windows) { + if let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty()) { + return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY)); + } + } else if !cfg!(target_os = "macos") { + let state = env("XDG_STATE_HOME") + .map(PathBuf::from) + .filter(|s| s.is_absolute()) + .or_else(|| env("HOME").filter(|h| !h.is_empty()).map(|h| PathBuf::from(h).join(".local").join("state"))); + if let Some(state) = state { + return Some(state.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 +616,330 @@ 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); + } + + /// #6612: on Linux (and other XDG systems) autosaves go to `$XDG_STATE_HOME`, by default + /// `~/.local/state`, not the config folder; a relative `XDG_STATE_HOME` is ignored. Windows + /// and macOS don't read it, and an override or portable folder keeps them inside itself. + #[test] + fn autosaves_go_to_the_xdg_state_folder() { + let root = temp("recovery-xdg"); + let settings = root.join("home").join(".config").join("photocraft"); + let home = root.join("home"); + let state = root.join("state"); + let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None }; + let xdg = !cfg!(windows) && !cfg!(target_os = "macos"); + let expect = |xdg_path: PathBuf| Some(if xdg { xdg_path } else { settings.join("Recovery") }); + assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), expect(state.join("photocraft").join("Recovery"))); + let default = home.join(".local").join("state").join("photocraft").join("Recovery"); + assert_eq!(resolve_recovery(&platform, env_of(&[("HOME", &home)])), expect(default.clone())); + let relative = PathBuf::from("relative/state"); + assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &relative), ("HOME", &home)])), expect(default.clone())); + let empty = PathBuf::new(); + assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &empty), ("HOME", &home)])), expect(default)); + // No home at all: beside the settings, as before. + assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery"))); + for mode in [Mode::Override, Mode::Portable] { + let d = DataDir { dir: Some(settings.clone()), mode, warning: None }; + assert_eq!(resolve_recovery(&d, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), Some(settings.join("Recovery")), "{mode:?}"); + } + 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 _spawns = crate::no_child_spawns(); + 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 _spawns = crate::no_child_spawns(); + 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/app_icon.rs b/apps/photocraft/src/app_icon.rs index 90ce94ea04..54eea2d6b5 100644 --- a/apps/photocraft/src/app_icon.rs +++ b/apps/photocraft/src/app_icon.rs @@ -26,6 +26,25 @@ pub fn window_icon() -> egui::IconData { } } +/// Whether the window gets [`window_icon`] at runtime: everywhere except inside a macOS app +/// bundle. There the Dock shows the bundle's `PhotoCraft.icns` (`CFBundleIconFile`), which macOS +/// renders in its own style (Liquid Glass and its tinted and dark variants); a runtime PNG +/// replaced it with a flat picture (#5849). `exe` is the running executable. +pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool { + !(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle)) +} + +/// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`. +fn in_app_bundle(exe: &std::path::Path) -> bool { + let name = |p: Option<&std::path::Path>| p.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned()); + let macos = exe.parent(); + let contents = macos.and_then(std::path::Path::parent); + let bundle = contents.and_then(std::path::Path::parent); + name(macos).as_deref() == Some("MacOS") + && name(contents).as_deref() == Some("Contents") + && name(bundle).is_some_and(|n| n.len() > 4 && n.to_ascii_lowercase().ends_with(".app")) +} + #[cfg(target_os = "macos")] const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png"); #[cfg(not(target_os = "macos"))] @@ -45,6 +64,22 @@ mod tests { u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize } + /// #5849: a macOS app bundle keeps its .icns in the Dock; unbundled runs (cargo run, the + /// Linux and Windows builds) still set the runtime icon. + #[test] + fn only_a_macos_app_bundle_skips_the_runtime_icon() { + use std::path::Path; + assert!(in_app_bundle(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))); + assert!(in_app_bundle(Path::new("/Users/a/Downloads/PhotoCraft Beta.APP/Contents/MacOS/photocraft"))); + assert!(!in_app_bundle(Path::new("/Users/a/src/photocraft/target/release/photocraft"))); + assert!(!in_app_bundle(Path::new("/opt/Contents/MacOS/photocraft")), "not inside a .app"); + assert!(!in_app_bundle(Path::new("/.app/Contents/MacOS/photocraft")), "a bare `.app` is no bundle name"); + assert!(!in_app_bundle(Path::new("photocraft"))); + assert!(sets_runtime_icon(None)); + assert!(sets_runtime_icon(Some(Path::new("/usr/bin/photocraft")))); + assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos")); + } + #[test] fn window_icon_decodes() { let icon = window_icon(); diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs index 258d89e1ec..30ae610b70 100644 --- a/apps/photocraft/src/control_server.rs +++ b/apps/photocraft/src/control_server.rs @@ -5,11 +5,12 @@ use std::io::{BufReader, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender, channel}; -use std::time::Instant; +use std::time::{Duration, Instant}; use photocraft_automation::budgets::write_reply; use photocraft_automation::security::{ - ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply, + AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, + read_bounded_line, repeat_auth_reply, }; use photocraft_ui_egui::ControlRequest; use photocraft_ui_egui::control::REPLY_DEADLINE; @@ -47,11 +48,17 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver, ctx: egui::Context) { + serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE); +} + +/// [`serve`] for a connection that must complete `auth` within `auth_deadline` of connecting, +/// however much it sends meanwhile (#4965); after that only the idle timeout applies. +fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context, auth_deadline: Duration) { if configure_stream(&stream).is_err() { return; } let Ok(read) = stream.try_clone() else { return }; - let mut reader = BufReader::new(read); + let mut reader = BufReader::new(AuthDeadlineStream::new(read, auth_deadline)); let mut out = stream; let mut line = String::new(); let mut authenticated = false; @@ -74,6 +81,9 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui:: if !authenticated { let (reply, ok) = authentication_reply(&line, token); authenticated = ok; + if ok && reader.get_mut().authenticated().is_err() { + break; + } if write_reply(&mut out, &reply).is_err() || out.flush().is_err() || !authenticated { break; } @@ -120,7 +130,34 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui:: mod tests { use super::*; use std::io::BufRead; - use std::time::Duration; + + /// #4965: a connection that keeps sending without authenticating is closed at the deadline + /// (releasing its connection permit), and nothing it sent is dispatched. + #[test] + fn an_unauthenticated_connection_is_closed_at_the_deadline_whatever_it_sends() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = channel::(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let started = Instant::now(); + serve_with_auth_deadline(stream, "unused", tx, egui::Context::default(), Duration::from_millis(300)); + started.elapsed() + }); + // Blank lines and partial frames, one byte every 20 ms: each read succeeds long before + // the idle timeout. + let mut stream = TcpStream::connect(addr).unwrap(); + for i in 0..150 { + let byte: &[u8] = if i % 5 == 0 { b"\n" } else { b" " }; + if stream.write_all(byte).is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + let served = server.join().unwrap(); + assert!(served < Duration::from_secs(3), "the trickle did not extend the deadline: {served:?}"); + assert!(rx.try_recv().is_err(), "nothing was dispatched"); + } #[test] fn oversized_reply_preserves_framing_id_and_the_next_control_request() { diff --git a/apps/photocraft/src/file_dialog_probe.rs b/apps/photocraft/src/file_dialog_probe.rs index 9ee14c0afb..eed74a58f2 100644 --- a/apps/photocraft/src/file_dialog_probe.rs +++ b/apps/photocraft/src/file_dialog_probe.rs @@ -70,25 +70,61 @@ pub fn start() { /// Whether the portal's `FileChooser` interface is on the session bus: `Some(false)` only when the /// bus or the interface is definitely absent, `None` for anything inconclusive (a slow reply). +/// +/// Asks the portal object to introspect itself rather than reading the interface's `version` +/// property: xdg-desktop-portal exports `FileChooser` only when a backend offers it, and without +/// one a property read fails with `InvalidArgs` ("No such interface"), an error the old check +/// didn't count as absent (#5739). The interface list answers the question directly. #[cfg(target_os = "linux")] fn file_chooser_portal() -> Option { let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build()) else { return Some(false); }; - let proxy = zbus::blocking::proxy::Builder::>::new(&conn) - .destination("org.freedesktop.portal.Desktop") - .and_then(|b| b.path("/org/freedesktop/portal/desktop")) - .and_then(|b| b.interface("org.freedesktop.portal.FileChooser")) - .and_then(|b| b.cache_properties(zbus::proxy::CacheProperties::No).build()) - .ok()?; - match proxy.get_property::("version") { - Ok(_) => Some(true), - Err(zbus::Error::MethodError(name, _, _)) => { - let name = name.as_str(); - let absent = ["ServiceUnknown", "UnknownObject", "UnknownInterface", "UnknownProperty"].iter().any(|n| name.ends_with(n)); - absent.then_some(false) - } - Err(_) => None, + let reply = conn.call_method( + Some("org.freedesktop.portal.Desktop"), + "/org/freedesktop/portal/desktop", + Some("org.freedesktop.DBus.Introspectable"), + "Introspect", + &(), + ); + match reply { + Ok(message) => match message.body().deserialize::() { + Ok(xml) => Some(interface_listed(&xml)), + Err(_) => None, + }, + Err(e) => classify_error(&e), + } +} + +/// Whether introspection XML lists the portal's `FileChooser` interface (not a longer name such as +/// `FileChooser2`). +#[cfg(target_os = "linux")] +fn interface_listed(xml: &str) -> bool { + ["\"", "'"].iter().any(|quote| xml.contains(&format!(" Option { + use zbus::fdo::Error as Fdo; + // Names a bus daemon or portal answers with when the thing asked about is absent. + const ABSENT: [&str; 7] = ["ServiceUnknown", "NameHasNoOwner", "UnknownObject", "UnknownInterface", "UnknownMethod", "UnknownProperty", "InvalidArgs"]; + match e { + zbus::Error::FDO(fdo) => matches!( + **fdo, + Fdo::ServiceUnknown(_) + | Fdo::NameHasNoOwner(_) + | Fdo::UnknownObject(_) + | Fdo::UnknownInterface(_) + | Fdo::UnknownMethod(_) + | Fdo::UnknownProperty(_) + | Fdo::InvalidArgs(_) + ) + .then_some(false), + zbus::Error::MethodError(name, _, _) => ABSENT.iter().any(|n| name.as_str().ends_with(n)).then_some(false), + _ => None, } } @@ -126,4 +162,69 @@ mod tests { assert!(!on_path("kdialog", &path)); std::fs::remove_dir_all(&dir).unwrap(); } + + #[cfg(target_os = "linux")] + mod portal { + use super::super::*; + + const WITH_FILE_CHOOSER: &str = r#" + + + + + + +"#; + const WITHOUT_FILE_CHOOSER: &str = r#" + + +"#; + + fn method_error(name: &'static str) -> zbus::Error { + let call = zbus::message::Message::method_call("/a", "M").unwrap().build(&()).unwrap(); + let reply = zbus::message::Message::error(&call.header(), name).unwrap().build(&"detail").unwrap(); + zbus::Error::MethodError(name.try_into().unwrap(), Some("detail".into()), reply) + } + + #[test] + fn the_interface_list_says_whether_a_file_chooser_is_offered() { + assert!(interface_listed(WITH_FILE_CHOOSER)); + assert!(interface_listed(""), "single quotes"); + assert!(!interface_listed(WITHOUT_FILE_CHOOSER), "portal without a backend"); + assert!(!interface_listed(r#""#), "longer name"); + assert!(!interface_listed("")); + } + + #[test] + fn dbus_errors_for_a_missing_interface_mean_absent() { + // zbus's fdo proxies hand D-Bus errors back as `Error::FDO`, a plain method call as + // `MethodError`; both forms count. The portal answers `InvalidArgs` ("No such interface") + // when no backend offers the interface (#5739). + for e in [ + zbus::fdo::Error::InvalidArgs("No such interface".into()), + zbus::fdo::Error::UnknownInterface("x".into()), + zbus::fdo::Error::ServiceUnknown("x".into()), + zbus::fdo::Error::NameHasNoOwner("x".into()), + zbus::fdo::Error::UnknownObject("x".into()), + ] { + let desc = format!("{e:?}"); + assert_eq!(classify_error(&zbus::Error::FDO(Box::new(e))), Some(false), "{desc}"); + } + } + + #[test] + fn method_error_names_still_count_as_absent() { + for name in ["org.freedesktop.DBus.Error.ServiceUnknown", "org.freedesktop.DBus.Error.InvalidArgs", "org.freedesktop.DBus.Error.UnknownMethod"] { + assert_eq!(classify_error(&method_error(name)), Some(false), "{name}"); + } + assert_eq!(classify_error(&method_error("org.freedesktop.DBus.Error.AccessDenied")), None); + } + + #[test] + fn inconclusive_errors_are_not_absent() { + assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::NoReply("slow".into())))), None); + assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::Failed("x".into())))), None); + assert_eq!(classify_error(&zbus::Error::InputOutput(std::sync::Arc::new(std::io::Error::other("gone")))), None); + } + } } diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index 8632fd2a55..e79b4bafac 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -233,9 +233,10 @@ pub struct Candidate { /// Rank of an adapter (lower is better): high-performance device types first (egui's power /// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for /// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback -/// for presenting the window (see [`backends`]). +/// for presenting the window (see [`backends`]). On Windows GL comes after every other adapter, +/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788). fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) { - let fallback = u8::from(backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan); + let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl)); let ty = match a.device_type { wgpu::DeviceType::DiscreteGpu => 0, wgpu::DeviceType::IntegratedGpu => 1, @@ -442,6 +443,14 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin .unwrap_or_default() } +/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless +/// turned on), read before the window opens: it picks the surface's present mode. +pub fn read_low_latency(path: Option<&Path>) -> bool { + let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null); + let default = photocraft_engine::prefs::Preferences::default().performance.low_latency_drawing; + v.get("performance").and_then(|p| p.get("lowLatencyDrawing")).and_then(Value::as_bool).unwrap_or(default) +} + /// Read the policy leniently at startup, including old settings without renderingMode. pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) { let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null); @@ -462,6 +471,23 @@ mod tests { Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() } } + #[test] + fn low_latency_drawing_is_read_before_the_window_opens_and_defaults_off() { + let dir = std::env::temp_dir().join(format!("photocraft-low-latency-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("preferences.json"); + let read = |json: &str| { + std::fs::write(&file, json).unwrap(); + read_low_latency(Some(&file)) + }; + assert!(read(r#"{"performance":{"lowLatencyDrawing":true}}"#)); + assert!(!read(r#"{"performance":{"lowLatencyDrawing":false}}"#)); + assert!(!read(r#"{"performance":{"lowLatencyDrawing":"nope"}}"#), "garbage keeps the default (off)"); + assert!(!read("not json")); + assert!(!read_low_latency(None), "no settings file: the default (off)"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn display_server_preference_is_read_before_the_window_opens() { use photocraft_engine::prefs::LinuxDisplayServer; @@ -635,6 +661,20 @@ mod tests { assert_eq!(pick(&[nvidia_gl, nvidia_vk], Auto, Os::Other), Some(1)); } + /// #5191, #1788: a Windows VM offers VMware's SVGA3D over GL (`Other`) and WARP (`Cpu`, DX12). + /// GL drew a black window; WARP works. GL stays the last resort. + #[test] + fn windows_auto_prefers_warp_over_gl() { + use wgpu::{Backend as B, DeviceType as T}; + let svga_gl = cand(0x15ad, T::Other, B::Gl); + let warp = cand(0x1414, T::Cpu, B::Dx12); + assert_eq!(pick(&[svga_gl, warp], Auto, Os::Windows), Some(1)); + assert_eq!(pick(&[cand(INTEL, T::IntegratedGpu, B::Gl), warp], Auto, Os::Windows), Some(1)); + assert_eq!(pick(&[svga_gl], Auto, Os::Windows), Some(0)); + // Elsewhere GL keeps its rank. + assert_eq!(pick(&[svga_gl, warp], Auto, Os::Other), Some(0)); + } + #[test] fn adapters_must_present_even_when_none_support_the_surface() { let mut a = cand(INTEL, wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12); diff --git a/apps/photocraft/src/logging.rs b/apps/photocraft/src/logging.rs index 292428b8ce..5c8f0697dd 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) } } @@ -316,9 +420,10 @@ mod tests { #[test] fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let _spawns = crate::no_child_spawns(); 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 +435,66 @@ 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 _spawns = crate::no_child_spawns(); + 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 _spawns = crate::no_child_spawns(); + 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 +520,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 +533,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 +549,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/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 1739568159..b0b00a865c 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -1,8 +1,23 @@ -//! Photocraft desktop app. +//! PhotoCraft desktop app. //! //! Usage: `photocraft [--control ] [--control-token <64-hex> | //! --control-token-file ] [--automation-read-root ] -//! [--automation-write-root ] [--safe-gpu] [--in-window-menus] [files…]` +//! [--automation-write-root ] [--safe-gpu] [--in-window-menus] [--present-mode ] +//! [--frame-latency ] [--stroke-trace ] [--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 +//! `--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`; +//! `presentation`); `--stroke-trace` times each brush stroke's frames (`PHOTOCRAFT_STROKE_TRACE`; +//! `photocraft_ui_egui::stroke_trace`). //! //! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window //! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that @@ -44,8 +59,18 @@ 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; +// 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). +#[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; @@ -78,9 +103,11 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool { /// different offset every launch (#419). A later launch restores the saved position instead /// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves. fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { + let viewport = egui::ViewportBuilder::default(); + // Not inside a macOS app bundle: its Dock icon is the bundle's .icns (`app_icon`, #5849). + let viewport = if app_icon::sets_runtime_icon(std::env::current_exe().ok().as_deref()) { viewport.with_icon(app_icon::window_icon()) } else { viewport }; eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_icon(app_icon::window_icon()) + viewport: viewport .with_app_id(APP_ID) .with_title("PhotoCraft") .with_inner_size([1440.0, 900.0]) @@ -130,21 +157,188 @@ 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()); +} + +/// Everything the desktop launcher takes from the command line, on top of the environment +/// defaults handed in to `parse_launch_args`. +#[derive(Debug, Default, PartialEq, Eq)] +struct LaunchArgs { + control_port: Option, + control_token: Option, + control_token_file: Option, + automation_read_root: Option, + automation_write_root: Option, + files: Vec, + /// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1 + /// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are + /// reported in a notice once the app is up; a control token is a usage error like a bad port. + unreadable_paths: Vec, + safe_gpu: bool, + new_instance: bool, + /// Options that apply only to the process they're given to; a launch with any keeps its own. + launch_options: Vec<&'static str>, + present_mode_arg: Option, + frame_latency_arg: Option, + stroke_trace_arg: Option, + in_window_menus: bool, + /// `--help` / `-h`: print [`USAGE`] and exit 0; parsing stops there. + help: bool, + /// `--version`: print the version and exit 0; parsing stops there. + version: bool, + /// Usage errors (a bad or missing control/automation value, an unknown option); a non-empty + /// list fails the launch with exit status 2 (issues #701, #6429, #6431). + errors: Vec, +} + +/// Parse the desktop launch arguments over the environment defaults in `out`. A control or +/// automation flag with no value is a usage error naming the flag (issue #6429), never a silent +/// fall back to a generated token or to no root: like `--control` with a bad port, it exits 2. +/// So is an unknown option (#6431); a file whose name starts with `-` goes after `--`. +fn parse_launch_args(args: impl Iterator, mut out: LaunchArgs) -> LaunchArgs { + let mut args = args; + // 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) => out.files.push(s), + Err(raw) => out.unreadable_paths.push(raw.to_string_lossy().into_owned()), + } + continue; + } + match a.to_str() { + Some("--") => only_files = true, + Some("--help" | "-h") => { + out.help = true; + break; + } + Some("--control") => match args.next() { + Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") { + Ok(port) => out.control_port = Some(port), + Err(error) => out.errors.push(error), + }, + None => out.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)) => out.control_token = Some(token), + Some(Err(raw)) => out.errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())), + None => out.errors.push("--control-token: missing token value (expected `--control-token `)".to_string()), + }, + Some("--control-token-file") => match args.next() { + Some(value) => out.control_token_file = Some(std::path::PathBuf::from(value)), + None => out.errors.push("--control-token-file: missing file path value (expected `--control-token-file `)".to_string()), + }, + Some("--automation-read-root") => { + out.launch_options.push("--automation-read-root"); + match args.next() { + Some(value) => out.automation_read_root = Some(std::path::PathBuf::from(value)), + None => out.errors.push("--automation-read-root: missing directory value (expected `--automation-read-root `)".to_string()), + } + } + Some("--automation-write-root") => { + out.launch_options.push("--automation-write-root"); + match args.next() { + Some(value) => out.automation_write_root = Some(std::path::PathBuf::from(value)), + None => out.errors.push("--automation-write-root: missing directory value (expected `--automation-write-root `)".to_string()), + } + } + Some("--safe-gpu") => { + out.launch_options.push("--safe-gpu"); + out.safe_gpu = true; + } + Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => { + out.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() { + "--present-mode" => out.present_mode_arg = Some(v), + "--frame-latency" => out.frame_latency_arg = Some(v), + _ => out.stroke_trace_arg = Some(v), + }, + // A bad value is reported and ignored; it never fails the launch. + _ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"), + } + } + Some("--in-window-menus") => { + out.launch_options.push("--in-window-menus"); + out.in_window_menus = true; + } + Some("--new-instance") => out.new_instance = true, + Some("--version") => { + out.version = true; + break; + } + // 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('-') => out.errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")), + Some(s) => out.files.push(s.to_owned()), + None => out.unreadable_paths.push(a.to_string_lossy().into_owned()), + } + } + out +} + #[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] @@ -165,71 +359,152 @@ mod control_port_tests { } } +#[cfg(test)] +mod launch_args_tests { + use super::{LaunchArgs, parse_launch_args, usage_exit_code}; + use std::ffi::OsString; + + fn parse(values: &[&str]) -> LaunchArgs { + parse_launch_args(values.iter().map(OsString::from), LaunchArgs::default()) + } + + #[test] + fn flags_without_a_value_are_usage_errors() { + // Issue #6429: a missing value must fail the launch, not silently fall back to a + // generated token or to no root. + for flag in ["--control", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"] { + let parsed = parse(&[flag]); + assert_eq!(parsed.errors.len(), 1, "{flag}: {:?}", parsed.errors); + assert!(parsed.errors[0].starts_with(flag), "{}", parsed.errors[0]); + assert!(parsed.errors[0].contains("missing"), "{}", parsed.errors[0]); + assert_eq!(usage_exit_code(&parsed.errors), Some(2)); + } + } + + #[test] + fn missing_value_does_not_clear_an_environment_default() { + // `--automation-read-root` with no value must not drop a root set through the env var. + let defaults = LaunchArgs { automation_read_root: Some("/env/read".into()), automation_write_root: Some("/env/write".into()), ..Default::default() }; + let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults); + assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read"))); + assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write"))); + assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors); + } + + #[test] + fn values_are_parsed_and_no_errors_remain() { + let parsed = parse(&[ + "--control", + "50494", + "--control-token", + "abcd", + "--control-token-file", + "/tmp/token", + "--automation-read-root", + "/r", + "--automation-write-root", + "/w", + "--safe-gpu", + "--in-window-menus", + "--new-instance", + "--present-mode", + "mailbox", + "photo.psd", + ]); + assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); + assert_eq!(parsed.control_port, Some(50494)); + assert_eq!(parsed.control_token.as_deref(), Some("abcd")); + assert_eq!(parsed.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token"))); + assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/r"))); + assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/w"))); + assert!(parsed.safe_gpu); + assert!(parsed.in_window_menus); + assert!(parsed.new_instance); + assert_eq!(parsed.present_mode_arg.as_deref(), Some("mailbox")); + assert_eq!(parsed.launch_options, vec!["--automation-read-root", "--automation-write-root", "--safe-gpu", "--in-window-menus", "--present-mode"]); + assert_eq!(parsed.files, vec!["photo.psd".to_string()]); + } + + #[test] + fn version_stops_parsing() { + let parsed = parse(&["--version", "--control"]); + assert!(parsed.version); + assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); + } + + #[test] + fn help_stops_parsing_and_double_dash_ends_options() { + let parsed = parse(&["-h", "--safegpu", "--control"]); + assert!(parsed.help); + assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); + + let parsed = parse(&["--safegpu", "--", "--help", "-x"]); + assert!(!parsed.help); + assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors); + assert!(parsed.errors[0].contains("unknown option `--safegpu`"), "{}", parsed.errors[0]); + assert_eq!(parsed.files, vec!["--help".to_string(), "-x".to_string()]); + } +} + fn main() -> eframe::Result { // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); crash_guard::install_hook(); // 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 launch = LaunchArgs { + automation_read_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from), + automation_write_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from), + in_window_menus: std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"), + ..Default::default() + }; 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), + Ok(port) => launch.control_port = Some(port), + Err(error) => launch.errors.push(error), } } - let mut control_token = None; - let mut control_token_file = None; - let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from); - let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from); - let mut files = Vec::new(); - // Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1 - // file name on Linux, and `std::env::args()` would panic before any window opened. Paths are - // 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 in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); - let mut args = std::env::args_os().skip(1); - while let Some(a) = args.next() { - match a.to_str() { - 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), - }, - None => control_arg_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())), - 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("--in-window-menus") => in_window_menus = true, - Some("--version") => { - println!("photocraft {}", 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_") => {} - Some(s) => files.push(s.to_owned()), - None => unreadable_paths.push(a.to_string_lossy().into_owned()), - } + launch = parse_launch_args(std::env::args_os().skip(1), launch); + if launch.help { + print_stdout(USAGE); + return Ok(()); + } + if launch.version { + print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version())); + return Ok(()); } - // 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), a control + // or automation flag without its value must not silently fall back to a generated token or + // to no root (issue #6429), 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(&launch.errors) { + for error in &launch.errors { eprintln!("photocraft: {error}"); } + eprintln!("Run `photocraft --help` for usage."); std::process::exit(code); } + let LaunchArgs { + control_port, + control_token, + control_token_file, + automation_read_root, + automation_write_root, + files, + unreadable_paths, + safe_gpu, + new_instance, + launch_options, + present_mode_arg, + frame_latency_arg, + stroke_trace_arg, + in_window_menus, + help: _, + version: _, + errors: _, + } = launch; // The log file lives under the settings directory; opened after the arguments, so `--version` // and usage errors leave no file behind. Records logged until now are written to it first. @@ -241,6 +516,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")] @@ -362,6 +679,21 @@ fn main() -> eframe::Result { let gpu_note: std::sync::Arc>> = Default::default(); // The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU. gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone()); + // Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about + // 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers. + if gpu_startup::read_low_latency(services::prefs_file().as_deref()) { + options.wgpu_options.surface.present_mode = eframe::wgpu::PresentMode::AutoNoVsync; + } + // Testers' present mode and frame latency overrides (#1343), after the preference. + let present = presentation::Overrides::from_args_or_env(present_mode_arg.as_deref(), frame_latency_arg.as_deref()); + options.wgpu_options.surface = present.apply(options.wgpu_options.surface); + let present_desc = presentation::describe(&options.wgpu_options.surface, &present); + // Without vsync, frames are capped at 240 fps (#4472) so repaint loops don't spin flat out. + let uncapped = !matches!( + options.wgpu_options.surface.present_mode, + eframe::wgpu::PresentMode::Fifo | eframe::wgpu::PresentMode::FifoRelaxed | eframe::wgpu::PresentMode::AutoVsync + ); + log::info!("presentation: {present_desc}"); let sentinel_ms = t_sentinel.elapsed().as_secs_f64() * 1000.0; log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan); let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu; @@ -389,7 +721,16 @@ 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; // Only the title bar's free gap drags the window, never the menus (mac_window.rs). @@ -421,6 +762,10 @@ fn main() -> eframe::Result { // composites on the CPU). let info = &mut app.perf.gpu_info; info.preference = pref.name().to_string(); + info.present = present_desc.clone(); + if let Some(v) = &stroke_trace_arg { + app.stroke_trace.set(v); + } info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() }; info.fallback = match (plan.reason.clone(), gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()) { (Some(reason), Some(note)) => Some(format!("{reason}. {note}")), @@ -483,6 +828,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 @@ -520,6 +870,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. @@ -563,6 +920,19 @@ fn main() -> eframe::Result { result } +/// Tests that spawn child processes hold this for reading, tests that check who holds a file lock +/// for writing ([`no_child_spawns`]): until its `exec`, a child holds a copy of every open file, +/// `flock`s included, so a spawn in another test could keep a lock this process just dropped held +/// for a moment. +#[cfg(test)] +pub(crate) static CHILD_SPAWNS: std::sync::RwLock<()> = std::sync::RwLock::new(()); + +/// Held by a test that checks who holds a file lock: no other test spawns a child meanwhile. +#[cfg(test)] +pub(crate) fn no_child_spawns() -> std::sync::RwLockWriteGuard<'static, ()> { + CHILD_SPAWNS.write().unwrap_or_else(std::sync::PoisonError::into_inner) +} + #[cfg(test)] mod tests { #[test] diff --git a/apps/photocraft/src/monitor_profile.rs b/apps/photocraft/src/monitor_profile.rs index 8e33869696..c210e096ec 100644 --- a/apps/photocraft/src/monitor_profile.rs +++ b/apps/photocraft/src/monitor_profile.rs @@ -495,6 +495,7 @@ mod tests { #[cfg(unix)] #[test] fn a_hung_helper_is_stopped_and_big_replies_dont_block() { + let _spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner); let t0 = std::time::Instant::now(); let e = run_helper(std::process::Command::new("sleep").arg("30"), std::time::Duration::from_millis(300)).unwrap_err(); assert!(e.contains("took longer than"), "{e}"); 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..5a839b6025 --- /dev/null +++ b/apps/photocraft/src/portal_save/tests.rs @@ -0,0 +1,237 @@ +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 spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut daemon = + std::process::Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(std::process::Stdio::piped()).spawn().ok()?; + drop(spawns); + 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(); + // A stream only gets the messages that arrive after it exists: made in the thread, it could + // miss a `SaveFile` sent before the thread got going, and the call would wait forever. + let messages = zbus::blocking::MessageIterator::from(&server); + std::thread::spawn(move || { + let mut script = script.into_iter(); + for msg in messages { + 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/presentation.rs b/apps/photocraft/src/presentation.rs new file mode 100644 index 0000000000..c6f7e4196a --- /dev/null +++ b/apps/photocraft/src/presentation.rs @@ -0,0 +1,121 @@ +//! Launch overrides for how frames reach the display, so testers can compare input-to-pixel +//! latency without a rebuild (#1343): `PHOTOCRAFT_PRESENT_MODE` (`fifo`, `autovsync`, or `novsync`, +//! also spelled `immediate`, `mailbox` or `autonovsync`: no vsync, wgpu's Immediate → Mailbox → Fifo +//! by what the driver offers) and `PHOTOCRAFT_FRAME_LATENCY` (`1` to `3`, frames the swap chain may +//! queue). Unset, the platform default applies. A value that isn't one of these is ignored with a +//! warning in the log, never a failed start; a mode the driver may not offer is never requested. +//! The result is logged at start and shown in the stroke trace. + +use eframe::egui_wgpu::SurfaceConfig; +use eframe::wgpu::PresentMode; + +/// The present mode and frame latency this launch asked for (`None`: the default). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Overrides { + pub mode: Option, + pub latency: Option, +} + +/// Parse the two variables' values. Returns the overrides and a warning per rejected value. +pub fn parse(mode: Option<&str>, latency: Option<&str>) -> (Overrides, Vec) { + let mut warnings = Vec::new(); + let mode = mode.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| { + let m = match v.to_ascii_lowercase().as_str() { + // Only modes every surface takes: wgpu crashes on an explicit Immediate, Mailbox or + // FifoRelaxed the driver doesn't offer (X11/NVIDIA has no Mailbox, #1343). Without + // vsync, wgpu picks Immediate, else Mailbox, else Fifo. + "fifo" | "vsync" => PresentMode::Fifo, + "autovsync" => PresentMode::AutoVsync, + "novsync" | "immediate" | "mailbox" | "autonovsync" => PresentMode::AutoNoVsync, + _ => { + warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored")); + return None; + } + }; + Some(m) + }); + let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| match v.parse::() { + Ok(n @ 1..=3) => Some(n), + _ => { + warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored")); + None + } + }); + (Overrides { mode, latency }, warnings) +} + +impl Overrides { + /// `--present-mode` / `--frame-latency` when given, else `PHOTOCRAFT_PRESENT_MODE` / + /// `PHOTOCRAFT_FRAME_LATENCY`; rejected values are logged. + pub fn from_args_or_env(mode: Option<&str>, latency: Option<&str>) -> Self { + let env_mode = std::env::var("PHOTOCRAFT_PRESENT_MODE").ok(); + let env_latency = std::env::var("PHOTOCRAFT_FRAME_LATENCY").ok(); + let (o, warnings) = parse(mode.or(env_mode.as_deref()), latency.or(env_latency.as_deref())); + for w in warnings { + log::warn!("{w}"); + } + o + } + + /// `surface` with the overrides applied. + pub fn apply(&self, surface: SurfaceConfig) -> SurfaceConfig { + SurfaceConfig { + present_mode: self.mode.unwrap_or(surface.present_mode), + desired_maximum_frame_latency: self.latency.or(surface.desired_maximum_frame_latency), + } + } +} + +/// How `surface` presents, for the stroke trace and System Info: "fifo, latency 2", with +/// "(override)" after a value an environment variable set. wgpu's GL backend ignores the latency. +pub fn describe(surface: &SurfaceConfig, overrides: &Overrides) -> String { + let mode = match surface.present_mode { + PresentMode::AutoVsync => "autovsync", + PresentMode::AutoNoVsync => "autonovsync", + PresentMode::Fifo => "fifo", + PresentMode::FifoRelaxed => "fiforelaxed", + PresentMode::Immediate => "immediate", + PresentMode::Mailbox => "mailbox", + }; + let latency = surface.desired_maximum_frame_latency.map_or("default".to_string(), |n| n.to_string()); + let tag = |set: bool| if set { " (override)" } else { "" }; + format!("{mode}{}, latency {latency}{}", tag(overrides.mode.is_some()), tag(overrides.latency.is_some())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn values_parse_and_bad_ones_are_ignored_with_a_warning() { + assert_eq!(parse(None, None), (Overrides::default(), vec![])); + assert_eq!(parse(Some(""), Some(" ")), (Overrides::default(), vec![])); + let (o, w) = parse(Some(" Mailbox "), Some("1")); + assert_eq!((o.mode, o.latency, w.len()), (Some(PresentMode::AutoNoVsync), Some(1), 0)); + assert_eq!(parse(Some("vsync"), None).0.mode, Some(PresentMode::Fifo)); + // Never a mode a driver may lack (wgpu crashes on it): no vsync falls back by support. + for v in ["IMMEDIATE", "mailbox", "novsync", "autonovsync"] { + assert_eq!(parse(Some(v), None).0.mode, Some(PresentMode::AutoNoVsync), "{v}"); + } + for (mode, latency) in + [(Some("fast"), None), (None, Some("0")), (None, Some("4")), (None, Some("-1")), (None, Some("one")), (None, Some("99999999999"))] + { + let (o, w) = parse(mode, latency); + assert_eq!((o, w.len()), (Overrides::default(), 1), "{mode:?} {latency:?}"); + } + } + + #[test] + fn overrides_replace_only_what_they_set_and_are_described() { + let default = SurfaceConfig::HIGH_THROUGHPUT; + assert_eq!(Overrides::default().apply(default), default); + let latency_only = Overrides { mode: None, latency: Some(1) }; + let s = latency_only.apply(default); + assert_eq!((s.present_mode, s.desired_maximum_frame_latency), (PresentMode::AutoVsync, Some(1))); + assert_eq!(describe(&s, &latency_only), "autovsync, latency 1 (override)"); + let novsync = Overrides { mode: Some(PresentMode::AutoNoVsync), latency: None }; + assert_eq!(describe(&novsync.apply(default), &novsync), "autonovsync (override), latency 2"); + let none = SurfaceConfig { present_mode: PresentMode::Fifo, desired_maximum_frame_latency: None }; + assert_eq!(describe(&none, &Overrides::default()), "fifo, latency default"); + } +} diff --git a/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/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f31..437f5cee12 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -6,7 +6,7 @@ use photocraft_doc::{Document, Layer, Size}; use photocraft_format::RecoveryStore; use photocraft_geom::Rect; use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services}; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::future::Future; use std::path::{Path, PathBuf}; use std::pin::Pin; @@ -21,8 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", - "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", + "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; @@ -36,16 +36,47 @@ fn open_filter_extensions(extensions: &[&str]) -> Vec { extensions.iter().map(|ext| if case_sensitive_globs { ext.chars().map(class).collect() } else { ext.to_string() }).collect() } -/// An explicit SVG filter keeps vector artwork discoverable even when a native picker defaults to -/// the PhotoCraft-only filter. SVG is also included in All Formats for normal multi-format opens. +/// File › Open's file types after All Formats, as Photoshop lists them: one per format, so a folder +/// of mixed files can be narrowed to one kind (#6337). Together they cover every extension of +/// [`OPEN_EXTS`]. An explicit SVG filter also keeps vector artwork discoverable when a native +/// picker defaults to another filter. +const OPEN_FILTERS: &[(&str, &[&str])] = &[ + ("PhotoCraft", &["pcraft"]), + ("Photoshop", &["psd", "psb", "psdt"]), + ("Affinity", &["af", "afdesign", "afphoto", "afpub"]), + ("Krita", &["kra", "krz"]), + ("OpenRaster", &["ora"]), + ("Paint.NET", &["pdn"]), + ("SVG", SVG_EXTS), + ("PNG", &["png"]), + ("JPEG", &["jpg", "jpeg"]), + ("HEIF", &["heic", "heif", "hif"]), + ("TIFF", &["tif", "tiff"]), + ("WebP", &["webp"]), + ("GIF", &["gif"]), + ("BMP", &["bmp"]), + ("Targa", &["tga"]), + ("ICO", &["ico"]), + ("QOI", &["qoi"]), + ("PCX", &["pcx"]), + ("DDS", &["dds"]), + ("XPM", &["xpm"]), + ("Portable Bitmap", &["pbm", "pgm", "ppm", "pam", "pfm"]), + ("OpenEXR", &["exr"]), + ("Radiance HDR", &["hdr"]), + ("FITS", &["fits", "fit", "fts"]), + ("DPX and Cineon", &["dpx", "cin"]), + ("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]), + ("Brushes", &["abr"]), + ("Gradients", &["grd"]), + ("Swatches", &["aco", "ase"]), + ("Keyboard Shortcuts", &["kys"]), +]; + fn open_filters() -> Vec<(&'static str, Vec)> { - vec![ - ("All Formats", open_filter_extensions(OPEN_EXTS)), - ("PhotoCraft", open_filter_extensions(&["pcraft"])), - ("OpenRaster", open_filter_extensions(&["ora"])), - ("Krita", open_filter_extensions(&["kra", "krz"])), - ("SVG", open_filter_extensions(SVG_EXTS)), - ] + std::iter::once(("All Formats", open_filter_extensions(OPEN_EXTS))) + .chain(OPEN_FILTERS.iter().map(|(name, exts)| (*name, open_filter_extensions(exts)))) + .collect() } /// File › Save As formats: (filter name, extensions). The filter matching the suggested name's @@ -53,6 +84,7 @@ fn open_filters() -> Vec<(&'static str, Vec)> { /// keeps defaulting to PSD. const SAVE_FILTERS: &[(&str, &[&str])] = &[ ("PSD Document", &["psd", "psb"]), + ("PDF", &["pdf"]), ("PhotoCraft", &["pcraft"]), ("OpenRaster", &["ora"]), ("PNG", &["png"]), @@ -67,6 +99,8 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[ ("OpenEXR", &["exr"]), ("FITS", &["fits", "fit"]), ("DPX", &["dpx"]), + // Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327). + ("DDS", &["dds"]), ]; /// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog @@ -94,18 +128,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 }) } @@ -126,10 +193,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, let dialog = if let Some(exts) = extensions { dialog.add_filter("Supported Files", &open_filter_extensions(&exts.iter().map(String::as_str).collect::>())) } else { - open_filters() - .into_iter() - .fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions)) - .add_filter("Paint.NET", &open_filter_extensions(&["pdn"])) + open_filters().into_iter().fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions)) }; if multiple { let picked = dialog.pick_files(); @@ -147,17 +211,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. @@ -195,8 +294,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 and Linux, crash-recovery autosaves; see [`crate::app_dirs`]. pub fn config_dir() -> Option { crate::app_dirs::config_dir() } @@ -211,8 +310,11 @@ pub fn presets_dir() -> Option { config_dir().map(|d| d.join("Presets")) } +/// Crash-recovery autosaves: under the settings directory, except where that is synced with the +/// settings: the Windows roaming profile and the Linux config folder (see +/// [`crate::app_dirs::resolve_recovery`]). 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 @@ -228,20 +330,7 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R } fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions { - let mut opts = photocraft_io::ExportOptions::default(); - if let Some(q) = settings.jpeg_quality { - opts.encode.jpeg_quality = q; - } - opts.encode.webp_lossless = settings.webp_lossless; - if let Some(q) = settings.webp_quality { - opts.encode.webp_quality = q; - } - opts.tiff_layers = settings.tiff_layers; - opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; - opts.tga_bits = settings.tga_bits; - opts.image_previews = settings.image_previews; - opts.psd_maximize_compatibility = settings.psd_maximize_compatibility; - opts + settings.io_options() } /// The share of a save's progress bar that encoding fills; writing the file fills the rest. A @@ -406,6 +495,7 @@ fn shell_word(s: &str) -> String { pub fn native(automation: Option) -> Services { let clip: Rc>> = Rc::default(); + let clip_unreadable: Rc> = Rc::default(); let automation_read = automation.clone().map(|workspace| { Box::new(move |path: &str| { let bytes = workspace.read(path).map_err(|error| error.to_string())?; @@ -448,6 +538,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, @@ -474,11 +569,19 @@ pub fn native(automation: Option) -> }), clipboard_get_image: Some({ let clip = clip.clone(); + let unreadable = clip_unreadable.clone(); Box::new(move || { let mut slot = clip.try_borrow_mut().ok()?; let cb = match slot.as_mut() { Some(c) => c, - None => slot.insert(arboard::Clipboard::new().ok()?), + None => { + // arboard opens only when some backend works. In a Flatpak on Wayland none + // does (#5031): the sandbox hides the data-control protocol from it, and + // `--socket=fallback-x11` gives no X server while a compositor runs. + let opened = arboard::Clipboard::new(); + unreadable.set(opened.is_err()); + slot.insert(opened.ok()?) + } }; // Copied files first: Finder also puts the file's icon on the clipboard as an // image, which would otherwise paste instead of the file. @@ -494,6 +597,7 @@ pub fn native(automation: Option) -> None }) }), + clipboard_unreadable: Some(Box::new(move || clip_unreadable.get())), load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())), photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))), save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))), @@ -597,6 +701,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() { @@ -631,6 +770,26 @@ mod tests { assert!(svg_filter.iter().any(|glob| matches(glob, "svg")) && svg_filter.iter().any(|glob| matches(glob, "svgz"))); } + /// #6337: after All Formats, every format File › Open reads has its own filter, and no filter + /// offers an extension All Formats doesn't. + #[test] + fn every_open_format_has_its_own_filter() { + let filters = open_filters(); + assert_eq!(filters[0], ("All Formats", open_filter_extensions(OPEN_EXTS)), "All Formats comes first"); + let mut named: Vec<&str> = OPEN_FILTERS.iter().flat_map(|(_, exts)| exts.iter().copied()).collect(); + let total = named.len(); + named.sort_unstable(); + named.dedup(); + assert_eq!(named.len(), total, "an extension is listed under two filters"); + let mut all = OPEN_EXTS.to_vec(); + all.sort_unstable(); + assert_eq!(named, all, "the named filters cover All Formats exactly"); + let names: Vec<&str> = filters.iter().map(|(name, _)| *name).collect(); + for name in ["Photoshop", "Affinity", "HEIF", "PNG", "JPEG", "TIFF", "Portable Bitmap", "OpenEXR", "Radiance HDR", "Paint.NET"] { + assert!(names.contains(&name), "{name}"); + } + } + #[cfg(not(all(unix, not(target_os = "macos"))))] #[test] fn open_filters_keep_literal_extensions_on_windows_and_macos() { @@ -738,14 +897,15 @@ mod tests { /// Every flat format Save As offers is one PhotoCraft writes, and ICO is among them (#4357). #[test] fn save_as_offers_only_writable_formats_including_ico() { - let layered = ["psd", "psb", "pcraft", "ora"]; + // Layered formats, and PDF, are written by `photocraft-io`, not a codec. + let layered = ["psd", "psb", "pcraft", "ora", "pdf"]; for (name, exts) in SAVE_FILTERS { for ext in exts.iter().filter(|e| !layered.contains(e)) { let format = photocraft_codecs::from_extension(ext).unwrap_or_else(|| panic!("{name}: .{ext} is no known format")); assert!(format.caps().write, "{name}: .{ext} can't be written"); } } - for ext in ["ico", "gif", "bmp"] { + for ext in ["ico", "gif", "bmp", "dds"] { assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered"); } assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type"); @@ -907,6 +1067,7 @@ mod tests { let mut app = launch(&recovery); new_doc(&mut app, "#ff0000"); let revision = app.session.active().unwrap().revision; + assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0"); autosave(&mut app, &ctx); let mut failed = false; @@ -927,12 +1088,16 @@ mod tests { std::fs::remove_file(&recovery).unwrap(); std::fs::create_dir(&recovery).unwrap(); autosave(&mut app, &ctx); + // The worker commits the bundle's manifest before it publishes the sidecar, and + // `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so + // the first non-empty listing can come from the middle of the write. Wait for the write + // to finish: the entry then carries the sidecar's revision. let mut recovered = Vec::new(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - if !recovered.is_empty() { + if recovered.iter().any(|e| e.info.revision == revision) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); 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/apps/photocraft/src/tablet.rs b/apps/photocraft/src/tablet.rs index 75c1388f94..fc063d2fbc 100644 --- a/apps/photocraft/src/tablet.rs +++ b/apps/photocraft/src/tablet.rs @@ -64,17 +64,35 @@ pub fn pen_needs_xwayland(session: DisplaySession, var: impl Fn(&str) -> Option< #[cfg(any(target_os = "linux", test))] const BTN_TOOL_PEN: u32 = 0x140; -/// Is a pen among the input devices `/proc/bus/input/devices` lists? Each device's `B: KEY=` line -/// is its key bitmap as hex words of the kernel's `long`, most significant first. +/// `ABS_X` and `ABS_Y` (linux/input-event-codes.h): the absolute axes every pen reports. +#[cfg(any(target_os = "linux", test))] +const ABS_X: u32 = 0x00; +#[cfg(any(target_os = "linux", test))] +const ABS_Y: u32 = 0x01; + +/// Is a pen among the input devices `/proc/bus/input/devices` lists? A pen is a device whose +/// `B: KEY=` bitmap has `BTN_TOOL_PEN` *and* whose `B: ABS=` bitmap has `ABS_X` and `ABS_Y` +/// (libinput asks the same of a tablet tool). The key alone is not enough: a virtual keyboard +/// made with uinput (RustDesk's, for one) claims every key code, `BTN_TOOL_PEN` included, but +/// has no absolute axes. Taking it for a pen sent the window through Xwayland, where a +/// fractionally scaled Wayland desktop shows it upscaled and blurred (#1072, #639). #[cfg(any(target_os = "linux", test))] pub fn has_pen(devices: &str) -> bool { - let bits = usize::BITS; - let word = usize::try_from(BTN_TOOL_PEN / bits).unwrap_or(usize::MAX); - devices.lines().filter_map(|l| l.strip_prefix("B: KEY=")).any(|keys| { - keys.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (BTN_TOOL_PEN % bits)) & 1 == 1) + devices.split("\n\n").any(|device| { + let bitmap = |name: &str| device.lines().find_map(|l| l.strip_prefix(name)); + bitmap("B: KEY=").is_some_and(|keys| bit_set(keys, BTN_TOOL_PEN)) && bitmap("B: ABS=").is_some_and(|axes| bit_set(axes, ABS_X) && bit_set(axes, ABS_Y)) }) } +/// Bit `bit` of a `/proc/bus/input/devices` bitmap: hex words of the kernel's `long`, most +/// significant first. Short or malformed bitmaps have no bits set. +#[cfg(any(target_os = "linux", test))] +fn bit_set(bitmap: &str, bit: u32) -> bool { + let bits = usize::BITS; + let word = usize::try_from(bit / bits).unwrap_or(usize::MAX); + bitmap.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (bit % bits)) & 1 == 1) +} + /// Start the XInput2 reader when eframe runs on X11 (Xwayland included). On native Wayland, /// `$DISPLAY` is Xwayland, which sees none of this window's input (and a pen sample from another /// X app would outlive a mouse stroke here), so nothing starts. @@ -238,6 +256,49 @@ mod tests { for keys in ["", "B: KEY=", "B: KEY=1", "B: KEY=zz 0 0 0 0 0", "B: KEY=ffffffffffffffffff 0 0 0 0 0", "B: KEY=0 0 0 0 0 0 0 0"] { assert!(!has_pen(keys), "{keys:?}"); } + for abs in ["B: ABS=", "B: ABS=zz", "B: ABS=ffffffffffffffffff", "B: ABS=0 0"] { + assert!(!has_pen(&format!("B: KEY=1c03 0 0 0 0 0\n{abs}")), "{abs:?}"); + } + } + + /// #1072: RustDesk's uinput keyboard claims every key code, `BTN_TOOL_PEN` included, and has + /// no absolute axes (`EV=20013`: no `EV_ABS`, no `B: ABS=` line). It is no pen, so the window + /// stays on native Wayland, where the fractional display scale renders sharp. The bitmaps are + /// the device's own, from `/proc/bus/input/devices`. + const VIRTUAL_KEYBOARD: &str = "I: Bus=0003 Vendor=1234 Product=5678 Version=0111\n\ + N: Name=\"RustDesk UInput Keyboard\"\n\ + P: Phys=\n\ + S: Sysfs=/devices/virtual/input/input54\n\ + U: Uniq=\n\ + H: Handlers=sysrq rfkill kbd event256 leds \n\ + B: PROP=0\n\ + B: EV=20013\n\ + B: KEY=ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe\n\ + B: MSC=10\n\ + B: LED=7\n"; + + #[test] + fn a_virtual_keyboard_claiming_btn_tool_pen_is_no_pen() { + assert!( + bit_set( + "ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe", + BTN_TOOL_PEN + ), + "the fixture claims the key" + ); + assert!(!has_pen(VIRTUAL_KEYBOARD)); + // Beside a real pen, the pen is still found; the keyboard's key bits never leak into the + // pen's device and the pen's axes never into the keyboard's. + let both = format!("{VIRTUAL_KEYBOARD}\n{WITH_PEN}"); + assert!(has_pen(&both)); + let pen_without_axes = WITH_PEN.lines().filter(|l| !l.starts_with("B: ABS=1000f")).collect::>().join("\n"); + assert!(!has_pen(&pen_without_axes), "a pen reports absolute positions"); + // A pen display or pen-enabled touchscreen reports the pen with its own axes. + let pen_display = "N: Name=\"Wacom One Pen\"\nB: KEY=1c03 0 0 0 0 0\nB: ABS=3\n"; + assert!(has_pen(pen_display)); + // Axes without `ABS_X` or `ABS_Y` don't count. + assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000002\n")); + assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000001\n")); } #[test] diff --git a/apps/photocraft/src/window_frame.rs b/apps/photocraft/src/window_frame.rs index 1b814746b5..e418049d07 100644 --- a/apps/photocraft/src/window_frame.rs +++ b/apps/photocraft/src/window_frame.rs @@ -81,17 +81,16 @@ mod platform { HwndPlace::None, POINT::new(), SIZE::new(), - co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE, + co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE, ); } - /// How far a parked maximize reaches past its monitor on the left, top and right: the resize + /// How far a parked maximize reaches past a monitor/work-area edge: the resize /// border (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not /// parked but being dragged or snapped, and is left alone. const FRAME: i32 = 32; - /// [`clamped_height`] for this frame's window: trim a maximized window whose rect runs under - /// the taskbar back to the work area plus its frame (#3144). No-op otherwise. + /// Keep a parked maximize inside all four work-area edges, including a top taskbar. fn keep_clear_of_the_taskbar(hwnd: &HWND) { if !hwnd.IsZoomed() { return; @@ -100,34 +99,58 @@ mod platform { return; }; let (m, w) = (mi.rcMonitor, mi.rcWork); - let Some(height) = clamped_height((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)) else { + let Some((left, top, right, bottom)) = + clamped_rect((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)) + else { return; }; + let (Some(width), Some(height)) = (right.checked_sub(left), bottom.checked_sub(top)) else { + return; + }; + // Moving the outer frame while winit pins the client rect to the work area must + // not copy the old frame's pixels into the custom title bar. let _ = hwnd.SetWindowPos( HwndPlace::None, - POINT::new(), - SIZE { cx: r.right - r.left, cy: height }, - co::SWP::NOMOVE | co::SWP::NOZORDER | co::SWP::NOACTIVATE, + POINT { x: left, y: top }, + SIZE { cx: width, cy: height }, + co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOZORDER | co::SWP::NOACTIVATE, ); + let _ = hwnd.InvalidateRect(None, false); } - /// The height to trim a maximize to so its rect stops at the taskbar, or `None` when it - /// already does or `rect` (`left, top, right, bottom`, desktop coordinates) is not parked at - /// its maximize place. `monitor` is the monitor's full rect and `work` its work area (the - /// monitor less the taskbar) in the same coordinates. The parked check is against the full - /// monitor, because that is where Windows puts a maximize; the trim is against the work area. - fn clamped_height(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option { + /// Return the corrected outer rect in desktop coordinates, preserving the resize border. + /// Each edge must be parked just outside either the monitor or work-area edge. Accepting + /// both makes the guard idempotent and handles Windows already correcting some edges. + /// Dragged/snapped windows are left alone; no monitor-origin or DPI assumptions are needed. + fn clamped_rect(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<(i32, i32, i32, i32)> { let (l, t, r, b) = rect; - let (ml, mt, mr, _) = monitor; - let (_, _, _, wb) = work; - let side = ml - l; - if !(0..=FRAME).contains(&side) || !(0..=FRAME).contains(&(mt - t)) || !(0..=FRAME).contains(&(r - mr)) { + let (ml, mt, mr, mb) = monitor; + let (wl, wt, wr, wb) = work; + if ml >= mr || mt >= mb || wl < ml || wt < mt || wr > mr || wb > mb || wl >= wr || wt >= wb { return None; } - if b <= wb + side { + // Infer the frame from an edge without a taskbar. On an obstructed edge, the + // monitor and work-area offsets can both look like valid borders (e.g. auto-hide). + let border = [ + (ml == wl).then(|| ml.checked_sub(l)).flatten(), + (mt == wt).then(|| mt.checked_sub(t)).flatten(), + (mr == wr).then(|| r.checked_sub(mr)).flatten(), + (mb == wb).then(|| b.checked_sub(mb)).flatten(), + ] + .into_iter() + .flatten() + .filter(|n| (0..=FRAME).contains(n)) + .min()?; + let parked = |monitor: Option, work: Option| monitor == Some(border) || work == Some(border); + if !parked(ml.checked_sub(l), wl.checked_sub(l)) + || !parked(mt.checked_sub(t), wt.checked_sub(t)) + || !parked(r.checked_sub(mr), r.checked_sub(wr)) + || !parked(b.checked_sub(mb), b.checked_sub(wb)) + { return None; } - Some(wb + side - t) + let corrected = (wl.checked_sub(border)?, wt.checked_sub(border)?, wr.checked_add(border)?, wb.checked_add(border)?); + (corrected != rect).then_some(corrected) } /// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes @@ -139,7 +162,7 @@ mod platform { #[cfg(test)] mod tests { - use super::{borderless, clamped_height}; + use super::{borderless, clamped_rect}; use winsafe::co; #[test] @@ -156,34 +179,47 @@ mod platform { } #[test] - fn a_maximize_covering_the_taskbar_is_trimmed_to_the_work_area() { - // 1920 × 1080 with a 34 pt taskbar: a caption-less maximize covers the whole - // monitor, taskbar included (#3144). Trimmed so the rect ends at the work area - // plus the frame the window was parked with. + fn a_maximize_respects_taskbars_on_every_edge() { let monitor = (0, 0, 1920, 1080); - let work = (0, 0, 1920, 1046); - assert_eq!(clamped_height((-7, -7, 1927, 1087), monitor, work), Some(1046 + 7 - (-7))); + let rect = (-8, -8, 1928, 1088); + for (work, expected) in [ + ((0, 0, 1920, 1046), (-8, -8, 1928, 1054)), + ((0, 48, 1920, 1080), (-8, 40, 1928, 1088)), + ((0, 2, 1920, 1080), (-8, -6, 1928, 1088)), + ((0, 0, 1920, 1078), (-8, -8, 1928, 1086)), + ((48, 0, 1920, 1080), (40, -8, 1928, 1088)), + ((0, 0, 1872, 1080), (-8, -8, 1880, 1088)), + ] { + assert_eq!(clamped_rect(rect, monitor, work), Some(expected)); + assert_eq!(clamped_rect(expected, monitor, work), None, "already corrected"); + } } #[test] - fn a_maximize_already_clear_of_the_taskbar_is_left_alone() { - // What Windows sizes a framed maximize to: the work area plus the frame. - assert_eq!(clamped_height((-8, -8, 1928, 1054), (0, 0, 1920, 1080), (0, 0, 1920, 1046)), None); + fn a_top_taskbar_keeps_its_offset_at_other_origins_and_frame_sizes() { + let monitor = (-1920, -1080, 0, 0); + let work = (-1920, -1032, 0, 0); + for border in [0, 8, 16, 32] { + let rect = (-1920 - border, -1080 - border, border, border); + let expected = (-1920 - border, -1032 - border, border, border); + assert_eq!(clamped_rect(rect, monitor, work), Some(expected)); + assert_eq!(clamped_rect(expected, monitor, work), None); + } } #[test] - fn a_maximize_beside_a_side_taskbar_is_left_alone() { - // Taskbar on the left: the work area starts further right, but the monitor's bottom - // is still clear, so the rect reaches nothing. - assert_eq!(clamped_height((-8, -8, 1928, 1088), (0, 0, 1920, 1080), (48, 0, 1920, 1080)), None); - } - - #[test] - fn a_maximize_being_dragged_away_is_left_alone() { + fn a_maximize_without_a_reserved_taskbar_is_left_alone() { let monitor = (0, 0, 1920, 1080); - let work = (0, 0, 1920, 1046); - assert_eq!(clamped_height((100, 200, 1100, 900), monitor, work), None, "moved"); - assert_eq!(clamped_height((-500, -7, 1427, 1087), monitor, work), None, "resized"); + assert_eq!(clamped_rect((-8, -8, 1928, 1088), monitor, monitor), None); + } + + #[test] + fn a_maximize_being_dragged_or_snapped_is_left_alone() { + let monitor = (0, 0, 1920, 1080); + let work = (0, 48, 1920, 1080); + assert_eq!(clamped_rect((100, 200, 1100, 900), monitor, work), None, "moved"); + assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged"); + assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen"); } } } 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}"); +} 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(); diff --git a/assets/icons/ellipse-dashed.svg b/assets/icons/ellipse-dashed.svg new file mode 100644 index 0000000000..f852dcf3a7 --- /dev/null +++ b/assets/icons/ellipse-dashed.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/quick-selection.svg b/assets/icons/quick-selection.svg new file mode 100644 index 0000000000..7e63dc66b9 --- /dev/null +++ b/assets/icons/quick-selection.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/book/src/automation/mcp.md b/book/src/automation/mcp.md index 4f3d02496a..2c36b464c2 100644 --- a/book/src/automation/mcp.md +++ b/book/src/automation/mcp.md @@ -5,6 +5,9 @@ - **Headless:** an in-process `Headless` session owns the engine and performs file I/O. - **Bridge:** `BridgeClient` forwards tools to a desktop application started with `photocraft --control `. +For a source checkout, first [install the CLI on PATH and register the server](../../../docs/mcp.md#install-and-register). +The installed binary survives `cargo clean`; a symlink into `target/release` does not. + Start the headless server with: ```sh diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index 2e65d51550..c333cbc737 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -19,6 +19,7 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_ - Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink. - Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened. - TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit. +- DDS, for game and 3D textures: Save As and Save a Copy ask (DDS Options) for the compression (None, uncompressed and lossless; BC1/DXT1, whose alpha is on or off; BC2/DXT3; BC3/DXT5) and whether to generate mipmaps (the full chain down to 1×1, each level a box filter of the one above). Scripts pass `ddsCompression` (`none`, `bc1`, `bc2`, `bc3`) and `ddsMipmaps` (`--dds-compression`, `--dds-mipmaps` on the CLI). Opening one reads its top mip level. Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed diff --git a/contributors/people.toml b/contributors/people.toml index 6f630a0c12..99d81059e9 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -158,3 +158,30 @@ sync_github_name = false [people.yeniufuklar-com] real_name = "Ufuk Efe" sync_github_name = true + +[people.nicolasfalesy] +sync_github_name = true + +[people.sidney] +real_name = "Sidney Markowitz" +sync_github_name = true + +[people.TaylorBurke] +sync_github_name = true + +[people.1Git2Clone] +sync_github_name = true + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false + +[people.Byxis] +sync_github_name = true + +[people.00xJS] +sync_github_name = true + +[people.guillehr2] +real_name = "Guillem Hermida" +sync_github_name = true diff --git a/crates/affinity/Cargo.toml b/crates/affinity/Cargo.toml index e686e737ef..49d859c2f8 100644 --- a/crates/affinity/Cargo.toml +++ b/crates/affinity/Cargo.toml @@ -16,7 +16,7 @@ synth = [] [dependencies] crc32fast = "1.5" flate2 = { workspace = true } -ruzstd = "0.9" +ruzstd = "0.9.1" [dev-dependencies] proptest = { workspace = true } diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 4f0f9afe52..535c3736c6 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -132,16 +132,25 @@ test fixtures, not an exporter. ## Safety limits -Every size is checked before it is allocated: 256 MiB per archive entry and 1 GiB per import by -default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no longer than the +Every size is checked before it is allocated: 256 MiB per archive entry, 1 GiB extracted per import +and 1 GiB of decoded pixels per import by default (`Limits`: extracted bytes and materialized pixels +are separate budgets, since a bitmap's declared size costs memory whatever its tiles hold), a 64 MiB +zstd window, 4096 saved revisions, array lengths no longer than the bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve -nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient +nodes per curve, 4 levels of spot colours defined through spot colours, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient stops; `photocraft-io` adds 300 000 pixels a side per document and 64 megapixels per image on the canvas. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the crate has no panics outside tests. +Compound outlines have a separate active-reference cycle check and a depth cap of 64. Across +the document, geometry expansion admits at most 500 000 combined outline calls and operand-slot +visits (including nulls and non-object values), and four million points created or copied into +compounds. Operand slots are charged before filtering, without first collecting an operand list. +A compound that crosses a limit or contains a cycle is omitted +with a warning as a whole; ordinary shared operands remain valid and keep each occurrence's transform. + ## Privacy Affinity documents can hold the folder they were saved in, user names, original image paths and diff --git a/crates/affinity/fuzz/fuzz_targets/preview.rs b/crates/affinity/fuzz/fuzz_targets/preview.rs index 31e2bf973a..63858fe717 100644 --- a/crates/affinity/fuzz/fuzz_targets/preview.rs +++ b/crates/affinity/fuzz/fuzz_targets/preview.rs @@ -6,5 +6,5 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { let _ = photocraft_affinity::container::header(data); let _ = photocraft_affinity::preview(data); - let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 }); + let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 }); }); diff --git a/crates/affinity/fuzz/fuzz_targets/stream.rs b/crates/affinity/fuzz/fuzz_targets/stream.rs index fd6128a66a..1f3895279f 100644 --- a/crates/affinity/fuzz/fuzz_targets/stream.rs +++ b/crates/affinity/fuzz/fuzz_targets/stream.rs @@ -7,5 +7,5 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|stream: &[u8]| { let _ = photocraft_affinity::stream::parse(stream); let file = photocraft_affinity::synth::container(&[("doc.dat", stream, photocraft_affinity::synth::Method::Stored)], None); - let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 }); + let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 }); }); diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs index b1a60ed430..880b7236d1 100644 --- a/crates/affinity/src/container.rs +++ b/crates/affinity/src/container.rs @@ -26,11 +26,16 @@ pub struct Limits { pub max_entry: usize, /// Total bytes extracted over the whole import. pub max_total: usize, + /// Bytes of decoded pixels one import may hold: every layer's RGBA together, plus the + /// temporary planes the layer being read decodes through. The entry limits bound what is + /// extracted, not what the layers materialized out of it cost (#4960). + pub max_pixel_bytes: usize, } impl Default for Limits { fn default() -> Self { - Self { max_entry: 256 << 20, max_total: 1 << 30 } + // The gigabyte the other importers allow decoded pixels (`io`'s `MAX_PIXEL_BYTES`). + Self { max_entry: 256 << 20, max_total: 1 << 30, max_pixel_bytes: 1 << 30 } } } diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs index 08554134db..6c8ea602c9 100644 --- a/crates/affinity/src/geometry.rs +++ b/crates/affinity/src/geometry.rs @@ -6,10 +6,103 @@ use crate::stream::{ObjId, Tag, Value}; /// Nodes per curve object. const MAX_NODES: usize = 4_000_000; +const MAX_COMPOUND_DEPTH: usize = 64; +const MAX_OUTLINE_VISITS: usize = 500_000; +const MAX_OUTLINE_POINTS: usize = 4_000_000; + +/// Geometry is expanded per occurrence, since parametric outlines can depend on their world +/// transform. Bound work across the entire document instead of memoizing by object id alone. +pub(crate) struct Budget { + visits_left: usize, + points_left: usize, +} + +impl Default for Budget { + fn default() -> Self { + Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS } + } +} + +impl Budget { + /// Charge an outline call or a raw compound operand slot before inspecting it. + fn visit(&mut self) -> Result<(), &'static str> { + self.visits_left = self.visits_left.checked_sub(1).ok_or("compound geometry budget exhausted")?; + Ok(()) + } + + /// Charge both leaf geometry and each copy into a compound before allocating that copy. + fn path(&mut self, path: &Path) -> Result<(), &'static str> { + let points = path.subpaths.iter().fold(0usize, |n, s| n.saturating_add(1).saturating_add(s.segments.len().saturating_mul(3))); + let Some(left) = self.points_left.checked_sub(points) else { + // Do not rebuild an expensive leaf for every subsequent root after exhaustion. + self.visits_left = 0; + return Err("compound geometry budget exhausted"); + }; + self.points_left = left; + Ok(()) + } +} /// The node-space outline of a curve, shape or compound node, and whether it fills even-odd. /// Instances (master-page or symbol copies) take the geometry of their master. pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> { + match outline_inner(r, id, class, world, &mut Vec::new()) { + Ok(path) => path, + Err(reason) => { + r.warn(reason); + None + } + } +} + +fn outline_inner(r: &mut Reader, id: ObjId, class: Tag, world: Affine, active: &mut Vec) -> Result, &'static str> { + r.geometry_budget.visit()?; + if active.contains(&id) { + return Err("cyclic compound shape"); + } + // Independent of the layer-tree limit: Comp operands do not pass through Reader::node. + if active.len() >= MAX_COMPOUND_DEPTH { + return Err("compound depth exceeds 64 levels"); + } + if class != Tag::of(b"Comp") { + let path = leaf_outline(r, id, class, world); + if let Some((path, _)) = &path { + r.geometry_budget.path(path)?; + } + return Ok(path); + } + active.push(id); + let result = compound(r, id, world, active); + active.pop(); + result +} + +fn compound(r: &mut Reader, id: ObjId, world: Affine, active: &mut Vec) -> Result, &'static str> { + let s = r.s; + // One path of all operands' contours, filled even-odd, as before. A safety failure rejects + // the whole outline: a partial add/subtract shape could silently change its meaning. + let mut path = Path::default(); + // Charge every slot before filtering nulls or malformed values. Stream::objs would scan + // and allocate the whole list first, repeating uncharged work for each shared occurrence. + let children = if let Some(Value::Array(children)) = s.field(id, b"Chld") { children.as_slice() } else { &[] }; + for child in children { + r.geometry_budget.visit()?; + let Value::Obj(Some(c)) = child else { continue }; + let c = *c; + let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); + let Some(class) = s.class(c) else { return Ok(None) }; + if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) { + r.warn("compound shapes that intersect or exclude (imported as add)"); + } + if let Some((p, _)) = outline_inner(r, c, class, local.then(world), active)? { + r.geometry_budget.path(&p)?; + path.subpaths.extend(p.transformed(local).subpaths); + } + } + Ok(Some((path, true))) +} + +fn leaf_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> { let s = r.s; match &class.0.to_be_bytes() { b"PCrv" | b"TxtC" => { @@ -18,23 +111,6 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O let multi = path.subpaths.len() > 1; Some((path, multi)) } - b"Comp" => { - // One path of all the operands' contours in the compound's space, filled even-odd: this - // matches add and subtract operands in every public sample; a true union of overlapping - // added operands would differ. - let mut path = Path::default(); - for c in s.objs(id, b"Chld") { - let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); - let class = s.class(c)?; - if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) { - r.warn("compound shapes that intersect or exclude (imported as add)"); - } - if let Some((p, _)) = outline(r, c, class, local.then(world)) { - path.subpaths.extend(p.transformed(local).subpaths); - } - } - Some((path, true)) - } _ => { let (shape, box_owner) = match s.obj(id, b"Shpe") { Some(sh) => (sh, id), diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index 07d0adf788..7c05072419 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -288,7 +288,7 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result { let mut archive = Archive::open(bytes, limits)?; let doc = archive.read("doc.dat")?; let s = stream::parse(&doc)?; - Reader::new(&s, &mut archive).document() + Reader::new(&s, &mut archive, limits).document() } pub(crate) struct Reader<'s, 'a, 'b> { @@ -298,11 +298,37 @@ pub(crate) struct Reader<'s, 'a, 'b> { warnings: BTreeMap, nodes: usize, active: HashSet, + pub(crate) geometry_budget: crate::geometry::Budget, + /// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so + /// far hold). + pixels_left: usize, } impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { - fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self { - Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() } + fn new(s: &'s Stream, archive: &'b mut Archive<'a>, limits: Limits) -> Self { + Self { + s, + archive, + dpi: 72.0, + warnings: BTreeMap::new(), + nodes: 0, + active: HashSet::new(), + geometry_budget: crate::geometry::Budget::default(), + pixels_left: limits.max_pixel_bytes, + } + } + + /// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's + /// output, which stays with the document; `planes` is the working set it decodes through and + /// releases. Every layer read so far is still live while this one decodes, so the peak is all + /// three together. + pub(crate) fn admit_pixels(&mut self, rgba: usize, planes: usize) -> Result<(), Error> { + let peak = rgba.saturating_add(planes); + if peak > self.pixels_left { + return Err(Error::Limit("decoded pixel layers exceed the pixel budget")); + } + self.pixels_left -= rgba; + Ok(()) } pub(crate) fn warn(&mut self, what: impl Into) { diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index c4ebff8446..343c276b60 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -134,8 +134,19 @@ fn floats(v: &Value) -> Option> { } } +/// Spot colours defined through spot colours. A spot colour (`Pant`) is read through its RGB +/// equivalent, else its base colour, which in real files hold plain colours. Colours are shared +/// objects, so one can link to itself, and with both links followed at every level a colour read +/// costs up to 2^(depth+1) lookups. +const MAX_SPOT_DEPTH: usize = 4; + /// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`). pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option { + color_at(r, id, 0) +} + +/// [`color`] of an object `depth` spot colours down. +fn color_at(r: &mut Reader, id: ObjId, depth: usize) -> Option { let s = r.s; let class = s.class(id)?; let col = || -> Option> { @@ -166,8 +177,12 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option { Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? } } b"Pant" => { + if depth >= MAX_SPOT_DEPTH { + r.warn("spot colours nested deeper than 4 levels (left empty)"); + return None; + } r.warn("spot colours (imported as their RGB equivalent)"); - return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c))); + return s.obj(id, b"srgb").and_then(|c| color_at(r, c, depth + 1)).or_else(|| s.obj(id, b"base").and_then(|c| color_at(r, c, depth + 1))); } b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 }, _ => { 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/affinity/src/raster.rs b/crates/affinity/src/raster.rs index 8cdf9b833a..bb1ecc112f 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -117,6 +117,11 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi return Ok(None); } }; + // A layer is materialized from its declared size, whatever the tiles hold: admit the output + // and the planes it decodes through before either is allocated (#4960). + let pixels = (w as usize).checked_mul(h as usize).ok_or(Error::Limit("pixel layer size"))?; + let scratch = pixels.checked_mul(channels * bps).ok_or(Error::Limit("pixel layer size"))?; + r.admit_pixels(pixels.checked_mul(4).ok_or(Error::Limit("pixel layer size"))?, scratch)?; let mut planes = Vec::with_capacity(channels); let mut cache = HashMap::new(); for c in 1..=channels { diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index b28e30f3d6..2c68a064a3 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -251,9 +251,9 @@ mod archive { broken[90] ^= 0x55; let mut a = Archive::open(&broken, Limits::default()).unwrap(); assert!(a.read("doc.dat").is_err()); - let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap(); + let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX, ..Limits::default() }).unwrap(); assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); - let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap(); + let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1, ..Limits::default() }).unwrap(); a.read("doc.dat").unwrap(); assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); } @@ -459,3 +459,253 @@ mod shapes { assert_eq!(sub.segments[3][2], p(5.0, 20.0)); } } + +/// Aggregate materialized-pixel admission (#4960). The entry limits bound what the archive +/// extracts; these cover what the decoded layers and their working planes cost, which is declared +/// by each bitmap's `BmpW`/`BmpH` and needs no tile data at all. +mod pixels { + use crate::Error; + use crate::container::Limits; + use crate::model::{Document, Kind}; + use crate::synth::{self, F, Method, tag}; + + /// A `Rstr` pixel layer whose bitmap declares `w`×`h` pixels and holds no tiles. + fn layer(id: u32, w: i32, h: i32) -> F { + F::Def( + id, + vec![tag(b"Rstr")], + vec![ + (tag(b"Desc"), F::Str(format!("Layer {id}"))), + (tag(b"Bitm"), F::Def(id + 1, vec![tag(b"DyBm")], vec![(tag(b"BmpW"), F::I32(w)), (tag(b"BmpH"), F::I32(h)), (tag(b"Frmt"), F::Enum(0, 0))])), + ], + ) + } + + /// A document whose one page holds a pixel layer per `sizes` entry. + fn document(sizes: &[(i32, i32)]) -> Vec { + let children: Vec = sizes.iter().enumerate().map(|(i, &(w, h))| layer(10 + 2 * i as u32, w, h)).collect(); + let data = synth::stream(&[( + tag(b"DocR"), + F::Def( + 1, + vec![tag(b"DocN")], + vec![( + tag(b"Chld"), + F::Shared(vec![F::Def( + 2, + vec![tag(b"Sprd")], + vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 256.0, 256.0])), (tag(b"Chld"), F::Shared(children))], + )]), + )], + ), + )]); + synth::container(&[("doc.dat", &data, Method::Zlib)], None) + } + + fn imported_layers(d: &Document) -> usize { + d.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count() + } + + /// A 64×64 layer costs 16 KiB of RGBA and 16 KiB of planes, so 40 KiB admits one and not two. + const ONE_LAYER: usize = 40 << 10; + + #[test] + fn a_pixel_layer_within_the_budget_imports() { + let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() }; + let d = crate::model::read(&document(&[(64, 64)]), limits).unwrap(); + assert_eq!(imported_layers(&d), 1, "{:?}", d.warnings); + } + + #[test] + fn layers_past_the_pixel_budget_are_rejected_before_they_are_allocated() { + let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() }; + // The document is a few hundred bytes: the sizes are in the headers, not the entries. + let bytes = document(&[(64, 64), (64, 64)]); + assert!(bytes.len() < 4096, "the fixture is {} bytes", bytes.len()); + assert!(matches!(crate::model::read(&bytes, limits), Err(Error::Limit(_))), "the second layer materialized"); + // Two layers fit once the budget covers their pixels together. + let room = Limits { max_pixel_bytes: ONE_LAYER + (32 << 10), ..Limits::default() }; + assert_eq!(imported_layers(&crate::model::read(&bytes, room).unwrap()), 2); + } + + #[test] + fn a_declared_size_larger_than_the_budget_is_rejected_on_its_own() { + // 2048×2048 is 16 MiB of RGBA and 16 MiB of planes, past a 1 MiB budget. + let limits = Limits { max_pixel_bytes: 1 << 20, ..Limits::default() }; + assert!( + matches!(crate::model::read(&document(&[(2048, 2048)]), limits), Err(Error::Limit(_))), + "16 MiB of declared pixels materialized under a 1 MiB budget" + ); + } +} + +/// Spot colours that link to themselves or to each other. Each document is read on a 1 MiB +/// stack, the smallest main-thread stack PhotoCraft runs on (Windows), first in a child process +/// that must finish within 20 s. A stack overflow aborts the process it happens in, so unbounded +/// recursion fails the one test that caused it instead of taking the other tests in the binary +/// down with it. +mod spot_colours { + use std::io::{Read, Write}; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + use crate::container::Limits; + use crate::model::{Document, Kind}; + use crate::paint::{Color, Paint}; + use crate::stream::Tag; + use crate::synth::{self, F, Method, tag}; + + /// A one-page document whose page holds `top`, read in a child process first and then here. + fn read(top: Vec) -> Document { + let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(top))]); + let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]); + let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None); + let test = std::thread::current().name().unwrap_or("reader").to_owned(); + read_in_child(&bytes, &test); + read_on_small_stack(bytes, &test).unwrap() + } + + /// Reads on a 1 MiB thread named after the test, so a stack overflow message names the test. + fn read_on_small_stack(bytes: Vec, test: &str) -> Result { + let reader = std::thread::Builder::new().name(test.to_owned()).stack_size(1 << 20).spawn(move || crate::read(&bytes, Limits::default())).unwrap(); + reader.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic)) + } + + /// Set in the child process to the name of the test it reads for. + const CHILD: &str = "PHOTOCRAFT_AFFINITY_READ_CHILD"; + + /// Runs `read_child` in a new process of this test binary on `bytes` and fails the test if + /// that process crashes, panics or takes over 20 s, or never read the document. + fn read_in_child(bytes: &[u8], test: &str) { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["tests::spot_colours::read_child", "--exact", "--ignored", "--nocapture", "--test-threads=1"]) + .env(CHILD, test) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let drain = |mut pipe: Box| { + std::thread::spawn(move || { + let mut text = String::new(); + let _ = pipe.read_to_string(&mut text); + text + }) + }; + let stdout = drain(Box::new(child.stdout.take().unwrap())); + let stderr = drain(Box::new(child.stderr.take().unwrap())); + // Fed from a thread, so a child that never reads cannot block the deadline below; a child + // that dies first breaks the pipe and is reported by its exit status. + let (mut stdin, input) = (child.stdin.take().unwrap(), bytes.to_vec()); + let feed = std::thread::spawn(move || { + let _ = stdin.write_all(&input); + }); + let deadline = Instant::now() + Duration::from_secs(20); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break Some(status); + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + std::thread::sleep(Duration::from_millis(10)); + }; + feed.join().unwrap(); + let output = format!("{}{}", stdout.join().unwrap(), stderr.join().unwrap()); + match status { + None => panic!("reading the document did not finish within 20 s:\n{output}"), + Some(status) if !status.success() => panic!("reading the document failed in a child process ({status}):\n{output}"), + // A filter that matched no test would also exit successfully, having read nothing. + Some(_) => assert!(output.contains(&format!("read {} bytes", bytes.len())), "the child process did not read the document:\n{output}"), + } + } + + /// Not a test of its own: `read_in_child` runs it in a child process to read the document on + /// its stdin. Without the variable, as when run with `--ignored`, it does nothing. + #[test] + #[ignore = "run by read_in_child in a child process"] + fn read_child() { + let Some(test) = std::env::var_os(CHILD) else { return }; + let mut bytes = Vec::new(); + std::io::stdin().read_to_end(&mut bytes).unwrap(); + let len = bytes.len(); + let _ = read_on_small_stack(bytes, &test.to_string_lossy()); + println!("read {len} bytes"); + } + + /// An ellipse filled with `colour`. + fn filled(id: u32, colour: F) -> F { + F::Def( + id, + vec![tag(b"ShpN")], + vec![ + (tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 1.0, 1.0])), + (tag(b"Shpe"), F::Def(id + 1_000_000, vec![tag(b"ShpE")], vec![])), + (tag(b"BFFl"), F::Shared(vec![F::Def(id + 2_000_000, vec![tag(b"FilS")], vec![(tag(b"Colr"), colour)])])), + ], + ) + } + + fn spot(id: u32, fields: Vec<(Tag, F)>) -> F { + F::Def(id, vec![tag(b"Pant")], fields) + } + + fn fills(doc: &Document) -> &[Paint] { + match &doc.spreads[0].nodes[0].kind { + Kind::Shape { fills, .. } => fills, + other => panic!("expected a shape, got {other:?}"), + } + } + + fn warned(doc: &Document, what: &str) -> bool { + doc.warnings.iter().any(|w| w.contains(what)) + } + + #[test] + fn a_spot_colour_that_is_its_own_rgb_equivalent_is_refused() { + let doc = read(vec![filled(10, spot(20, vec![(tag(b"srgb"), F::Ref(20))]))]); + assert!(fills(&doc).is_empty(), "{:?}", fills(&doc)); + assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings); + } + + #[test] + fn spot_colours_that_are_each_others_base_and_rgb_equivalent_are_refused() { + let doc = read(vec![filled(10, spot(20, vec![(tag(b"base"), spot(21, vec![(tag(b"srgb"), F::Ref(20))]))]))]); + assert!(fills(&doc).is_empty(), "{:?}", fills(&doc)); + assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings); + } + + #[test] + fn spot_colours_of_spot_colours_do_not_take_exponential_time() { + // Each level's RGB equivalent and base are the next level, and the last is in an unknown + // model, so both are followed everywhere: 2^41 colour reads before the fix. + fn level(id: u32, left: u32) -> F { + if left == 0 { + return F::Def(id, vec![tag(b"XXXX")], vec![]); + } + spot(id, vec![(tag(b"srgb"), level(id + 1, left - 1)), (tag(b"base"), F::Ref(id + 1))]) + } + let doc = read(vec![filled(10, level(100, 40))]); + assert!(fills(&doc).is_empty(), "{:?}", fills(&doc)); + } + + /// `spots` spot colours, each defined through the next, around an opaque RGB colour. + fn spots(id: u32, spots: u32) -> F { + if spots == 0 { + let rgba: Vec = [1.0f32, 0.5, 0.25, 1.0].iter().flat_map(|c| c.to_le_bytes()).collect(); + return F::Def(id, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct(rgba))]); + } + spot(id, vec![(tag(b"srgb"), self::spots(id + 1, spots - 1))]) + } + + #[test] + fn spot_colours_nest_four_levels_and_no_deeper() { + let doc = read(vec![filled(10, spots(100, 4))]); + assert_eq!(fills(&doc), [Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.25, a: 1.0 })]); + let doc = read(vec![filled(10, spots(100, 5))]); + assert!(fills(&doc).is_empty(), "{:?}", fills(&doc)); + assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings); + } +} diff --git a/crates/affinity/tests/compound_limits.rs b/crates/affinity/tests/compound_limits.rs new file mode 100644 index 0000000000..76f2e6e4a1 --- /dev/null +++ b/crates/affinity/tests/compound_limits.rs @@ -0,0 +1,212 @@ +//! #6423: compound operands form a graph, not necessarily a tree. +#![cfg(feature = "synth")] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use photocraft_affinity::model::{Document, Kind}; +use photocraft_affinity::synth::{self, F, Method, tag}; +use photocraft_affinity::{Limits, read}; +use std::time::{Duration, Instant}; + +fn compound(id: u32, children: Vec) -> F { + F::Def(id, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::Shared(children))]) +} + +fn rectangle() -> F { + F::Def(100, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(101, vec![tag(b"ShNR")], vec![]))]) +} + +fn document_bytes(definitions: Vec, roots: Vec) -> Vec { + let data = synth::stream(&[ + (tag(b"Defs"), F::Shared(definitions)), + ( + tag(b"DocR"), + F::Def( + 1, + vec![tag(b"DocN")], + vec![( + tag(b"Chld"), + F::Shared(vec![F::Def( + 2, + vec![tag(b"Sprd")], + vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(roots))], + )]), + )], + ), + ), + ]); + synth::container(&[("doc.dat", &data, Method::Zlib)], None) +} + +fn document(definitions: Vec, roots: Vec) -> Document { + read(&document_bytes(definitions, roots), Limits::default()).unwrap() +} + +// A stack overflow aborts rather than unwinds. Keep that failure (and an exponential hang) +// inside a child process, so the regression fails without killing the entire test runner. +fn isolated(name: &str, check: impl FnOnce()) { + if std::env::var("PHOTOCRAFT_COMPOUND_CASE").as_deref() == Ok(name) { + check(); + return; + } + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", name, "--nocapture"]) + .env("PHOTOCRAFT_COMPOUND_CASE", name) + .spawn() + .unwrap(); + let start = Instant::now(); + loop { + if let Some(status) = child.try_wait().unwrap() { + assert!(status.success(), "compound import failed: {status}"); + break; + } + if start.elapsed() > Duration::from_secs(10) { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("compound import exceeded ten seconds"); + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +fn unsupported(doc: &Document, warning: &str) { + assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported)); + assert!(doc.warnings.iter().any(|w| w.contains(warning)), "{:?}", doc.warnings); +} + +#[test] +fn self_referencing_compound_is_reported_without_aborting() { + isolated("self_referencing_compound_is_reported_without_aborting", || { + let doc = document(vec![], vec![compound(10, vec![F::Ref(10)]), rectangle()]); + unsupported(&doc, "cyclic compound"); + assert!(matches!(doc.spreads[0].nodes[1].kind, Kind::Shape { .. }), "a cycle must not poison unrelated shapes"); + }); +} + +#[test] +fn indirectly_cyclic_compound_is_not_imported_as_a_partial_shape() { + isolated("indirectly_cyclic_compound_is_not_imported_as_a_partial_shape", || { + let doc = document(vec![rectangle()], vec![compound(10, vec![F::Ref(100), compound(11, vec![F::Ref(10)])])]); + unsupported(&doc, "cyclic compound"); + }); +} + +fn nested(levels: u32, repeated: bool) -> Document { + // Flat definitions bypass the stream parser's nesting cap: only references form the depth. + let mut definitions = vec![rectangle()]; + let mut previous = 100; + for id in 200..200 + levels { + let children = if repeated { vec![F::Ref(previous), F::Ref(previous)] } else { vec![F::Ref(previous)] }; + definitions.push(compound(id, children)); + previous = id; + } + document(definitions, vec![F::Ref(previous)]) +} + +#[test] +fn deep_compound_graph_is_bounded_on_a_small_stack() { + isolated("deep_compound_graph_is_bounded_on_a_small_stack", || { + std::thread::Builder::new().stack_size(512 * 1024).spawn(|| unsupported(&nested(1000, false), "compound depth")).unwrap().join().unwrap(); + }); +} + +#[test] +fn shared_operands_cannot_expand_exponentially() { + isolated("shared_operands_cannot_expand_exponentially", || unsupported(&nested(40, true), "compound geometry budget")); +} + +#[test] +fn shared_empty_operands_also_have_a_work_limit() { + isolated("shared_empty_operands_also_have_a_work_limit", || { + let mut definitions = vec![compound(100, vec![])]; + let mut previous = 100; + for id in 200..240 { + definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)])); + previous = id; + } + unsupported(&document(definitions, vec![F::Ref(previous)]), "compound geometry budget"); + }); +} + +#[test] +fn geometry_budget_is_shared_across_layer_roots() { + isolated("geometry_budget_is_shared_across_layer_roots", || { + let mut definitions = vec![rectangle()]; + let mut previous = 100; + for id in 200..214 { + definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)])); + previous = id; + } + let mut roots = vec![F::Ref(previous); 12]; + roots.push(F::Ref(100)); + let doc = document(definitions, roots); + let nodes = &doc.spreads[0].nodes; + assert!(matches!(nodes[0].kind, Kind::Shape { .. }), "a modest shared graph still imports"); + assert!(matches!(nodes.last().unwrap().kind, Kind::Unsupported), "later roots cannot reset the exhausted budget"); + assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget"))); + }); +} + +#[test] +fn ordinary_shared_operands_keep_both_transformed_copies() { + let mut translated = compound(201, vec![F::Ref(100)]); + let F::Def(_, _, fields) = &mut translated else { unreachable!() }; + fields.push((tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 30.0, 0.0, 1.0, 40.0]))); + let doc = document(vec![rectangle(), compound(200, vec![F::Ref(100)]), translated], vec![compound(202, vec![F::Ref(200), F::Ref(201)])]); + assert!(doc.warnings.is_empty(), "{:?}", doc.warnings); + let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") }; + assert_eq!(path.subpaths.len(), 2); + for (a, b) in path.subpaths[0].segments.iter().flatten().zip(path.subpaths[1].segments.iter().flatten()) { + assert_eq!(b.x, a.x + 30.0); + assert_eq!(b.y, a.y + 40.0); + } +} + +#[test] +fn null_operand_slots_cannot_bypass_geometry_budget() { + isolated("null_operand_slots_cannot_bypass_geometry_budget", || { + let bytes = document_bytes(vec![compound(10, vec![F::Null; 1_000_000])], vec![F::Ref(10); 20_000]); + let start = Instant::now(); + let doc = read(&bytes, Limits::default()).unwrap(); + eprintln!("one million nulls / 20,000 placements: {} bytes, read {:.3}s", bytes.len(), start.elapsed().as_secs_f64()); + unsupported(&doc, "compound geometry budget"); + assert_eq!(doc.spreads[0].nodes.len(), 20_000); + assert!(doc.spreads[0].nodes.iter().all(|n| matches!(n.kind, Kind::Unsupported))); + }); +} + +#[test] +fn null_operand_slots_share_budget_across_layer_roots() { + isolated("null_operand_slots_share_budget_across_layer_roots", || { + let doc = document(vec![compound(10, vec![F::Null; 1_000])], vec![F::Ref(10); 1_000]); + assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Shape { .. }), "a small empty compound remains valid"); + assert!(matches!(doc.spreads[0].nodes.last().unwrap().kind, Kind::Unsupported)); + assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget"))); + }); +} + +#[test] +fn null_operand_slots_cannot_leave_a_partial_shape() { + isolated("null_operand_slots_cannot_leave_a_partial_shape", || { + let mut slots = vec![F::Ref(100)]; + slots.extend(vec![F::Null; 1_000_000]); + let doc = document(vec![rectangle(), compound(10, slots)], vec![F::Ref(10)]); + unsupported(&doc, "compound geometry budget"); + }); +} + +#[test] +fn non_object_operand_slots_also_consume_budget() { + isolated("non_object_operand_slots_also_consume_budget", || { + let comp = F::Def(10, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::U8s(vec![0; 1_000_000]))]); + unsupported(&document(vec![comp], vec![F::Ref(10)]), "compound geometry budget"); + }); +} + +#[test] +fn ordinary_null_operand_slots_preserve_shared_contours() { + let doc = document(vec![rectangle(), compound(10, vec![F::Null, F::Ref(100), F::Null, F::Ref(100), F::Null])], vec![F::Ref(10)]); + assert!(doc.warnings.is_empty(), "{:?}", doc.warnings); + let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") }; + assert_eq!(path.subpaths.len(), 2); + assert_eq!(path.subpaths[0], path.subpaths[1]); +} 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 0000000000..0941c4f689 Binary files /dev/null and b/crates/algo/data/libfacedetection.bin differ diff --git a/crates/algo/examples/bench_multiband.rs b/crates/algo/examples/bench_multiband.rs new file mode 100644 index 0000000000..9d58b81a54 --- /dev/null +++ b/crates/algo/examples/bench_multiband.rs @@ -0,0 +1,42 @@ +//! Time and peak memory of the Photomerge multi-band blend: `n` RGB sources of `mp` megapixels +//! (3:2), each overlapping the next by half its width. +//! `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]` (default 4 × 24 MP). +//! Peak memory is the growth of the process's high-water mark during the blend (Linux only). +use std::time::Instant; + +use photocraft_algo::panorama::{self, RoiImage}; + +/// The process's peak resident set in MiB (`VmHWM`), where `/proc` has it. +fn peak_mib() -> Option { + let s = std::fs::read_to_string("/proc/self/status").ok()?; + let kb: f64 = s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?; + Some(kb / 1024.0) +} + +fn main() { + let mut args = std::env::args().skip(1); + let n: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(4).max(1); + let mp: f64 = args.next().and_then(|a| a.parse().ok()).unwrap_or(24.0); + let sh = ((mp * 1e6 / 1.5).sqrt().round() as usize).max(2); + let sw = sh * 3 / 2; + let step = sw / 2; + let (cw, ch) = (step * (n - 1) + sw, sh); + let rois: Vec = (0..n) + .map(|k| { + let px = (0..sw * sh * 3).map(|i| ((i / 3 % sw * 7 + i / 3 / sw * 3 + k * 31 + i % 3 * 50) % 251) as f32 / 251.0).collect(); + RoiImage { x0: (step * k) as i32, y0: 0, w: sw, h: sh, ch: 3, px, alpha: vec![1.0; sw * sh] } + }) + .collect(); + // Each canvas column belongs to the image whose centre is nearest, as a seam would split it. + let wts: Vec> = (0..n) + .map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(n - 1) == k { 1.0 } else { 0.0 }).collect()) + .collect(); + let levels = panorama::blend_levels(cw, ch).min(7); + let before = peak_mib(); + let t0 = Instant::now(); + let (px, cov) = panorama::multiband(cw, ch, &rois, &wts, levels); + let ms = t0.elapsed().as_secs_f64() * 1000.0; + let peak = peak_mib().zip(before).map_or("n/a".to_string(), |(a, b)| format!("{:.0} MiB", a - b)); + println!("{n} × {sw}×{sh} ({mp} MP) RGB, canvas {cw}×{ch}, {levels} levels: {ms:.0} ms, peak +{peak} during the blend"); + std::hint::black_box((px, cov)); +} diff --git a/crates/algo/src/blur2.rs b/crates/algo/src/blur2.rs index e917405083..bb6b47656c 100644 --- a/crates/algo/src/blur2.rs +++ b/crates/algo/src/blur2.rs @@ -335,7 +335,7 @@ pub(crate) fn lens_blur(src: &Image, out: Rect, ctx: &Ctx, spec: &LensSpec) -> V } src.get(x, y, n - 1) } - DepthSource::LayerMask => spec.depth_map?.get(x, y, 0), + DepthSource::LayerMask | DepthSource::Channel => spec.depth_map?.get(x, y, 0), }; Some(if spec.invert { 1.0 - d } else { d }) }; diff --git a/crates/algo/src/camera_raw.rs b/crates/algo/src/camera_raw.rs index f855b08bfe..3cadd1d20c 100644 --- a/crates/algo/src/camera_raw.rs +++ b/crates/algo/src/camera_raw.rs @@ -67,22 +67,16 @@ impl Encoding { pub(crate) fn of(gamma: f32) -> Self { Encoding(if gamma.is_finite() && gamma > 0.0 { gamma.clamp(0.5, 4.0) } else { 0.0 }) } + /// A value in this encoding as linear light. pub(crate) fn decode(self, v: f32) -> f32 { if self.0 > 0.0 { v.max(0.0).powf(self.0) } else { srgb_to_linear(v) } } + /// Linear light back into this encoding, the inverse of [`Encoding::decode`]. pub(crate) fn encode(self, v: f32) -> f32 { if self.0 > 0.0 { v.max(0.0).powf(1.0 / self.0) } else { linear_to_srgb(v) } } } -fn srgb_encode(v: f32) -> f32 { - linear_to_srgb(v) -} - -fn srgb_decode(v: f32) -> f32 { - srgb_to_linear(v) -} - fn luma_linear(c: [f32; 3]) -> f32 { luma(c) } diff --git a/crates/algo/src/camera_raw/local_tone.rs b/crates/algo/src/camera_raw/local_tone.rs index 7844f8d74b..1dbf58421f 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,78 @@ 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(); + } + }); + } + 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 } +} + +/// The source rows of [`up`]`(p, w, h)` at output row `y`, one per tap (`None` where the +/// zero-stuffed row is all zeros). +fn up_rows(p: &Plane, h: usize, y: usize) -> [Option; 5] { + let lh = p.h.min(h.div_ceil(2)); + std::array::from_fn(|k| { + let m = mirror(y as isize + k as isize - 2, h); + (m.is_multiple_of(2) && m / 2 < lh).then_some(m / 2) + }) +} + +/// One sample of [`up`]`(p, w, h)`: column `x` of the output row whose [`up_rows`] are `rows`. +/// It adds the same terms in the same order as `up`, so it gives the same bits. +fn up_at(p: &Plane, w: usize, rows: &[Option; 5], x: usize) -> f32 { + let lw = p.w.min(w.div_ceil(2)); + let col = |j: usize| -> f32 { (0..5).filter_map(|k| rows[k].map(|r| TAPS[k] * (p.v[r * p.w + j] * 4.0))).sum() }; + 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] * col(i / 2)) + }) + .sum() + } else { + (0..5) + .filter_map(|k| { + let m = mirror(x as isize + k as isize - 2, w); + (m.is_multiple_of(2) && m / 2 < lw).then(|| TAPS[k] * col(m / 2)) + }) + .sum() } - blur_step(&z, 1) } /// Levels down to about 2 pixels on the short side. @@ -154,21 +243,21 @@ 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); } }); let gr = gaussian(m, n); 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| { + let (gl, fine, lw, lh, coarse) = (&g[l].v, &gr[l].v, g[l].w, g[l].h, &gr[l + 1]); + rows(&mut lap[l], lw, |y, row| { + let taps = up_rows(coarse, lh, y); 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); if wgt > 0.0 { - *o += wgt * (fine[i] - coarse.v[i]); + *o += wgt * (fine[i] - up_at(coarse, lw, &taps, x)); } } }); @@ -188,8 +277,10 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane { /// The `q` quantile (0 … 1) of `v`. fn quantile(v: &[f32], q: f32) -> f32 { let mut s = v.to_vec(); - s.sort_by(f32::total_cmp); - s[((s.len() - 1) as f32 * q).round() as usize] + // Selecting the one element is linear, a full sort is not (5 sorts of the work copy were a + // fifteenth of a render). `total_cmp` ties are bit-identical, so it is the sorted element. + let i = ((s.len() - 1) as f32 * q).round() as usize; + *s.select_nth_unstable_by(i, f32::total_cmp).1 } /// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across. @@ -337,6 +428,84 @@ 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)] { + let full = up(&p, uw, uh); + assert_eq!(full.v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}"); + // The filter samples `up` one pixel at a time; every sample has the same bits. + for y in 0..uh { + let taps = up_rows(&p, uh, y); + for x in 0..uw { + assert_eq!(up_at(&p, uw, &taps, x).to_bits(), full.v[y * uw + x].to_bits(), "up_at {w}×{h} → {uw}×{uh} at {x},{y}"); + } + } + } + } + } + + #[test] + fn quantile_is_the_sorted_element() { + let p = noise_plane(301, 211, 9); + let mut v = p.v.clone(); + // Ties and signed zeros, which `total_cmp` orders by their bits. + v.extend([0.0, -0.0, 0.0, -0.0, 3.5, 3.5, f32::MIN_POSITIVE]); + let mut sorted = v.clone(); + sorted.sort_by(f32::total_cmp); + for q in [0.0, 0.001, 0.01, 0.25, 0.5, 0.99, 1.0] { + let want = sorted[((sorted.len() - 1) as f32 * q).round() as usize]; + assert_eq!(quantile(&v, q).to_bits(), want.to_bits(), "q {q}"); + } + } + 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() } diff --git a/crates/algo/src/camera_raw/whites_blacks.rs b/crates/algo/src/camera_raw/whites_blacks.rs index 382ade50e9..93bb9cff31 100644 --- a/crates/algo/src/camera_raw/whites_blacks.rs +++ b/crates/algo/src/camera_raw/whites_blacks.rs @@ -369,14 +369,14 @@ pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Enco if per_channel { for c in 0..3 { let over = (lin[c] - 1.0).max(0.0); - q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(lin[c].min(1.0)))) + over); + q[c] = enc.encode(super::srgb_to_linear(grey(super::linear_to_srgb(lin[c].min(1.0)))) + over); } } else { let y = super::luma_linear(lin).max(0.0); if y <= 1e-7 { continue; } - let y2 = super::srgb_decode(grey(super::srgb_encode(y.min(1.0)))) + (y - 1.0).max(0.0); + let y2 = super::srgb_to_linear(grey(super::linear_to_srgb(y.min(1.0)))) + (y - 1.0).max(0.0); let k = y2 / y; for c in 0..3 { q[c] = enc.encode(lin[c] * k); diff --git a/crates/algo/src/content_aware.rs b/crates/algo/src/content_aware.rs index ecdb308624..981e854cc5 100644 --- a/crates/algo/src/content_aware.rs +++ b/crates/algo/src/content_aware.rs @@ -474,8 +474,10 @@ pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bo let mut cost_fg = vec![0.0f32; sw * sh]; let mut cost_bg = vec![0.0f32; sw * sh]; let mut fixed = vec![FREE; sw * sh]; + // Always the hole's own edge, and around a thin selection each part's surroundings. + let keep = thin_band(sw, sh, &shole); for i in 0..sw * sh { - if shole[i] || dist[i] <= 1.5 { + if shole[i] || dist[i] <= keep { fixed[i] = HARD_FG; continue; } @@ -508,6 +510,22 @@ pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bo Ok(out) } +/// How far around a thin selection (strings, wires, and any small parts selected with them, all +/// at most a quarter as thick as the selection is long) the Auto area always reaches, in cells of +/// a `w × h` grid: as far as the selection is thick, so that each part, however far from the +/// rest, keeps its own surroundings to copy from. 1.5 cells for any other selection. +fn thin_band(w: usize, h: usize, hole: &[bool]) -> f32 { + let inside = crate::selection::edt(&hole.iter().map(|h| !h).collect::>(), w, h); + let thickness = 2.0 * inside.iter().zip(hole).filter(|(_, h)| **h).map(|(d, _)| *d).fold(0.0f32, f32::max); + let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0); + for (i, _) in hole.iter().enumerate().filter(|(_, h)| **h) { + let (x, y) = (i % w, i / w); + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1)); + } + let extent = x1.saturating_sub(x0).max(y1.saturating_sub(y0)) as f32; + if 4.0 * thickness <= extent { thickness.max(1.5) } else { 1.5 } +} + /// The Patch Tool's and the Content-Aware Move Tool's Color option (0–10) as the colour adaptation /// of the copied content. Measured on Photoshop 25.4 with content brighter than its new place: 0 /// keeps it as it is; 3 moves it about 0.12 (32 of 42 levels, and 31 of 102: a limit, not a @@ -548,6 +566,133 @@ pub fn level_transform(ch: usize, from: &[f32], to: &[f32], a: Adapt) -> Vec<(f3 .collect() } +/// One stroke of the Content-Aware Fill workspace's Sampling Brush: a hard round brush of +/// diameter `size` along `points` (document px), adding to the sampling area or taking from it. +#[derive(Clone, Debug, PartialEq)] +pub struct SamplingStroke { + pub add: bool, + pub size: f64, + pub points: Vec<[f64; 2]>, +} + +impl SamplingStroke { + /// Bounds of the pixels the stroke covers. + pub fn bounds(&self) -> Rect { + let r = (self.size / 2.0).max(0.5); + self.points.iter().fold(Rect::EMPTY, |acc, p| { + let b = Rect::new( + (p[0] - r).floor() as i32, + (p[1] - r).floor() as i32, + ((p[0] + r).ceil() as i32).saturating_add(1), + ((p[1] + r).ceil() as i32).saturating_add(1), + ); + if acc.is_empty() { b } else { acc.union(&b) } + }) + } +} + +/// Squared distance from `p` to the segment `a`–`b`. +fn seg_dist2(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l2 = dx * dx + dy * dy; + let t = if l2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let (qx, qy) = (a[0] + t * dx - p[0], a[1] + t * dy - p[1]); + qx * qx + qy * qy +} + +/// Paint `strokes` in order into `mask` (row-major over `area`): a pixel whose centre lies within +/// half the brush size of a stroke's path is set (adding) or cleared (subtracting). A mask that +/// does not cover `area` exactly is left as it is. +pub fn paint_strokes(mask: &mut [bool], area: Rect, strokes: &[SamplingStroke]) { + let w = area.width() as usize; + if mask.len() != w * area.height() as usize { + return; + } + for s in strokes { + let r = (s.size / 2.0).max(0.5); + let r2 = r.powi(2); + let segs: Vec<([f64; 2], [f64; 2])> = match s.points.as_slice() { + [] => continue, + [p] => vec![(*p, *p)], + pts => pts.windows(2).map(|p| (p[0], p[1])).collect(), + }; + // A stroke covers the union of its segments' pixels: each segment only looks at its own + // bounds. + for (a, b) in segs { + let bounds = Rect::new( + (a[0].min(b[0]) - r).floor() as i32, + (a[1].min(b[1]) - r).floor() as i32, + ((a[0].max(b[0]) + r).ceil() as i32).saturating_add(1), + ((a[1].max(b[1]) + r).ceil() as i32).saturating_add(1), + ) + .intersect(&area); + for y in bounds.y0..bounds.y1 { + for x in bounds.x0..bounds.x1 { + let c = [f64::from(x) + 0.5, f64::from(y) + 0.5]; + if seg_dist2(c, a, b) <= r2 + && let Some(m) = mask.get_mut((y - area.y0) as usize * w + (x - area.x0) as usize) + { + *m = s.add; + } + } + } + } + } +} + +/// The pixels of `area` (row-major) whose centres lie inside the polygon `pts` (even-odd rule): +/// what a lasso outlines. +pub fn polygon_mask(area: Rect, pts: &[[f64; 2]]) -> Vec { + let (w, h) = (area.width() as usize, area.height() as usize); + let mut out = vec![false; w * h]; + if pts.len() < 3 { + return out; + } + let mut xs: Vec = Vec::new(); + for row in 0..h { + let cy = f64::from(area.y0) + row as f64 + 0.5; + xs.clear(); + for i in 0..pts.len() { + let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); + if (a[1] <= cy) != (b[1] <= cy) { + xs.push(a[0] + (cy - a[1]) / (b[1] - a[1]) * (b[0] - a[0])); + } + } + xs.sort_by(f64::total_cmp); + for pair in xs.as_chunks::<2>().0 { + // Pixel centres x + 0.5 within [pair0, pair1). + let x0 = ((pair[0] - 0.5).ceil() as i64 - i64::from(area.x0)).clamp(0, w as i64) as usize; + let x1 = ((pair[1] - 0.5).ceil() as i64 - i64::from(area.x0)).clamp(0, w as i64) as usize; + out[row * w + x0..row * w + x1.max(x0)].iter_mut().for_each(|v| *v = true); + } + } + out +} + +/// Grow (`by` > 0) or shrink (`by` < 0) a mask by `|by|` pixels (Euclidean): the lasso's Expand +/// and Contract. +pub fn grow_mask(mask: &[bool], w: usize, h: usize, by: i32) -> Vec { + if by == 0 || w == 0 || h == 0 { + return mask.to_vec(); + } + let r = f32::from(u16::try_from(by.unsigned_abs()).unwrap_or(u16::MAX)); + if by > 0 { + let d = crate::selection::edt(mask, w, h); + d.iter().map(|v| *v <= r).collect() + } else { + let outside: Vec = mask.iter().map(|v| !v).collect(); + let d = crate::selection::edt(&outside, w, h); + d.iter().map(|v| *v > r).collect() + } +} + +/// The Sampling Brush's starting size for a selection with bounds `hole`: a tenth of the sampling +/// window's side, as Photoshop opens the workspace (20 px for a 50 px selection, 40 for 100). +pub fn default_brush_size(hole: Rect) -> f64 { + let (w, h) = (f64::from(hole.width().max(50)), f64::from(hole.height().max(50))); + (0.4 * (w * h).sqrt()).round() +} + #[cfg(test)] mod tests { use super::*; @@ -559,6 +704,43 @@ mod tests { (img, hole) } + #[test] + fn strokes_paint_their_path_and_leave_a_mismatched_mask_alone() { + let area = Rect::new(10, 20, 50, 40); + let (w, h) = (40, 20); + let strokes = [ + SamplingStroke { add: true, size: 6.0, points: vec![[12.0, 25.0], [40.0, 25.0], [45.0, 35.0]] }, + SamplingStroke { add: false, size: 3.0, points: vec![[30.0, 25.0]] }, + SamplingStroke { add: true, size: 2.0, points: Vec::new() }, + ]; + let mut mask = vec![false; w * h]; + paint_strokes(&mut mask, area, &strokes); + // The same pixels as testing every pixel against every segment of each stroke in turn. + let mut want = vec![false; w * h]; + for s in &strokes { + let r2 = (s.size / 2.0).max(0.5).powi(2); + let segs: Vec<_> = if s.points.len() == 1 { vec![(s.points[0], s.points[0])] } else { s.points.windows(2).map(|p| (p[0], p[1])).collect() }; + for (i, m) in want.iter_mut().enumerate() { + let c = [f64::from(area.x0) + (i % w) as f64 + 0.5, f64::from(area.y0) + (i / w) as f64 + 0.5]; + if segs.iter().any(|(a, b)| seg_dist2(c, *a, *b) <= r2) { + *m = s.add; + } + } + } + assert_eq!(mask, want); + assert!(mask[5 * w + 10] && !mask[5 * w + 20], "added along the path, cleared under the subtracting dab"); + // A mask shorter or longer than the area is not touched (and nothing panics). + for len in [0, 7, w * h - 1, w * h + 3] { + let mut short = vec![false; len]; + paint_strokes(&mut short, area, &strokes); + assert!(short.iter().all(|v| !v), "len {len}"); + } + // Hostile coordinates stay inside the area. + let mut m = vec![false; w * h]; + let wild = [SamplingStroke { add: true, size: 5000.0, points: vec![[f64::MAX, -1e300], [f64::NAN, 1e7]] }]; + paint_strokes(&mut m, area, &wild); + } + #[test] fn cancelled_fill_stops_and_progress_reaches_one() { use std::sync::atomic::{AtomicU32, Ordering}; @@ -751,6 +933,31 @@ mod tests { } } + #[test] + fn auto_sampling_keeps_the_surroundings_of_every_part_of_a_thin_selection() { + // A long thin selection across a grey ramp, and a small knob selected with it in a red + // corner. Each part keeps its own surroundings; a compact selection is unchanged. + let (w, h) = (900, 600); + let tex = noise(w, h, 4); + let img: Vec = (0..w * h * 3) + .map(|i| { + let (x, y, c) = ((i / 3) % w, (i / 3) / w, i % 3); + let base = if x > 690 && y > 420 { [0.7, 0.25, 0.2][c] } else { 0.2 + 0.5 * x as f32 / w as f32 }; + base + (tex[i] - 0.5) * 0.04 + }) + .collect(); + let line = |i: usize| (30..870).contains(&(i % w)) && (180..192).contains(&(i / w)); + let knob = |i: usize| ((i % w) as f32 - 795.0).hypot((i / w) as f32 - 510.0) < 10.0; + let hole: Vec = (0..w * h).map(|i| line(i) || knob(i)).collect(); + let area = auto_sampling(w, h, 3, &img, &hole); + let around_knob = (0..w * h).filter(|&i| !hole[i] && ((i % w) as f32 - 795.0).hypot((i / w) as f32 - 510.0) < 24.0); + let (n, kept) = around_knob.fold((0, 0), |(n, k), i| (n + 1, k + usize::from(area[i]))); + assert!(kept as f32 > 0.9 * n as f32, "{kept} of {n} pixels around the knob"); + assert!(thin_band(w, h, &hole) >= 20.0, "{}", thin_band(w, h, &hole)); + let disc: Vec = (0..w * h).map(|i| ((i % w) as f32 - 450.0).hypot((i / w) as f32 - 300.0) < 90.0).collect(); + assert_eq!(thin_band(w, h, &disc), 1.5); + } + #[test] fn without_colour_adaptation_the_fill_copies_pixels_exactly() { // A smooth texture with fine noise, so that every value is unique: a filled pixel equal to @@ -798,6 +1005,116 @@ mod tests { assert!((shift(10) - 0.4).abs() < 0.01, "{}", shift(10)); } + /// A soft background (gradients, broad out-of-focus blobs of colour, fine noise) with thin, long bright + /// objects over it: three coiled strings 3 to 7 px wide and two straighter ones, each with an + /// anti-aliased edge and a faint halo. Returns the image; what removing the coils should give + /// (the background and the straighter strings, which stay); and the selection a user would draw + /// around the coils, `margin` px wide. + fn strings_scene(w: usize, h: usize, margin: f32) -> (Vec, Vec, Vec) { + let fine = noise(w, h, 11); + // Out-of-focus colour: broad blobs over two gradients. + let blob = noise(3, 3, 5); + let bg: Vec = (0..w * h * 3) + .map(|i| { + let (x, y, c) = (((i / 3) % w) as f32 / w as f32, ((i / 3) / w) as f32 / h as f32, i % 3); + let mut v = [0.18 + 0.25 * x, 0.22 + 0.1 * y, 0.3 - 0.1 * x][c]; + for b in blob.as_chunks::<3>().0 { + let (bx, by, s) = (b[0] * 1.6 - 0.3, b[1] * 1.6 - 0.3, 0.1 + 0.25 * b[2]); + v += (b[(c + 1) % 3] - 0.2) * 0.5 * (-((x - bx).powi(2) + (y - by).powi(2) * 0.56) / (2.0 * s * s)).exp(); + } + v + (fine[i] - 0.5) * 0.03 + }) + .collect(); + // Signed distance to each object's surface, stamped from points along it. + let stamp = |sd: &mut [f32], pts: &mut dyn Iterator, half: f32| { + for (px, py) in pts { + let reach = (half + 4.0) as i32 + 1; + for dy in -reach..=reach { + for dx in -reach..=reach { + let (x, y) = (px as i32 + dx, py as i32 + dy); + if x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h { + let i = y as usize * w + x as usize; + sd[i] = sd[i].min((x as f32 - px).hypot(y as f32 - py) - half); + } + } + } + } + }; + let mut coils = vec![f32::INFINITY; w * h]; + for (k, half) in [1.5f32, 2.5, 3.5].into_iter().enumerate() { + let (y0, r) = ((0.22 + 0.28 * k as f32) * h as f32, 10.0 + 2.0 * k as f32); + let mut pts = (0..4000).map(|j| { + let t = j as f32 / 4000.0; + let a = 8.0 * std::f32::consts::TAU * t; + (18.0 + t * (w as f32 - 60.0) + r * a.cos(), y0 + 6.0 * (6.0 * t).sin() + 0.7 * r * a.sin()) + }); + stamp(&mut coils, &mut pts, half); + } + let mut straight = vec![f32::INFINITY; w * h]; + for y in [0.07, 0.93] { + stamp(&mut straight, &mut (0..2000).map(|j| (j as f32 / 2000.0 * w as f32, y * h as f32 + 3.0 * (j as f32 / 300.0).sin())), 2.0); + } + let draw = |base: &[f32], sd: &[f32]| -> Vec { + let mut img = base.to_vec(); + for i in 0..w * h { + let cover = (0.5 - sd[i]).clamp(0.0, 1.0); + let halo = if sd[i] > 0.0 { 0.08 * (-sd[i] * sd[i] / 2.9).exp() } else { 0.0 }; + for (c, metal) in [0.9f32, 0.85, 0.7].into_iter().enumerate() { + let v = &mut img[i * 3 + c]; + *v = *v * (1.0 - cover) + metal * cover + halo * (1.0 - cover); + } + } + img + }; + let clean = draw(&bg, &straight); + let both: Vec = coils.iter().zip(&straight).map(|(a, b)| a.min(*b)).collect(); + let img = draw(&bg, &both); + let hole = coils.iter().map(|d| *d <= margin).collect(); + (img, clean, hole) + } + + /// RMS over the hole of the error averaged over 7 × 7 pixels (in levels of 255): the noise + /// cancels out, a ghost of the removed outline or a blotch of another shade does not. + fn blotch_error(w: usize, h: usize, out: &[f32], clean: &[f32], hole: &[bool]) -> f32 { + let (mut sum, mut n) = (0.0f64, 0usize); + for y in 3..h - 3 { + for x in 3..w - 3 { + if !hole[y * w + x] { + continue; + } + let mut e = [0.0f32; 3]; + for j in (y - 3..=y + 3).flat_map(|yy| (x - 3..=x + 3).map(move |xx| yy * w + xx)) { + for (c, ec) in e.iter_mut().enumerate() { + *ec += out[j * 3 + c] - clean[j * 3 + c]; + } + } + sum += f64::from(e.iter().map(|v| (v / 49.0).powi(2)).sum::() / 3.0); + n += 1; + } + } + ((sum / n.max(1) as f64).sqrt() * 255.0) as f32 + } + + /// Removing thin, long objects (guitar strings, wires) from a soft background (#1366): the + /// fill is the background, with no ghost of their outline and nothing of their colour copied + /// back in. Their selection is long but only a few pixels thick, so the fill is worked out at + /// a scale where it is still a few pixels thick rather than one where the strings' surroundings + /// have merged into a blur. + #[test] + fn thin_objects_leave_no_ghost_and_none_of_their_colour() { + let (w, h) = (360, 270); + let (img, clean, hole) = strings_scene(w, h, 2.0); + let source = auto_sampling(w, h, 3, &img, &hole); + let out = fill(w, h, 3, &img, &hole, &source, &FillOptions::default()); + let blotch = blotch_error(w, h, &out, &clean, &hole); + let luma = |v: &[f32], i: usize| (v[i * 3] + v[i * 3 + 1] + v[i * 3 + 2]) / 3.0; + let bright = (0..w * h).filter(|&i| hole[i] && luma(&out, i) > luma(&clean, i) + 0.06).count(); + // Before #1366 the outlines stayed as darker and lighter blotches (13 levels of 255) and + // 1121 pixels took the strings' colour. + assert!(blotch < 2.5, "ghosts and blotches: {blotch:.2} levels of 255"); + assert_eq!(bright, 0, "filled pixels with the strings' colour"); + } + #[test] fn variant_geometry() { let img = vec![0.5f32; 10 * 6]; diff --git a/crates/algo/src/distort.rs b/crates/algo/src/distort.rs index 664169afa4..fb6e3e69c3 100644 --- a/crates/algo/src/distort.rs +++ b/crates/algo/src/distort.rs @@ -28,32 +28,47 @@ pub(crate) fn centre(b: Rect) -> (f32, f32, f32) { (b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w.min(h) / 2.0) } +/// The bounds' centre and half width and height: Twirl, Pinch and Spherize act inside the ellipse +/// inscribed in the bounds, as Photoshop's do, so on a wide or tall image the effect reaches all +/// four edges instead of stopping at a circle fitted to the shorter side (#5643). A point maps to +/// unit-circle coordinates `(u, v)` and back. +fn ellipse(b: Rect) -> (f32, f32, f32, f32) { + let (w, h) = (b.width() as f32, b.height() as f32); + (b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w / 2.0, h / 2.0) +} + pub(crate) fn twirl(src: &Image, out: Rect, ctx: &Ctx, angle: f32) -> Vec { - let (cx, cy, rr) = centre(ctx.bounds); + let (cx, cy, hx, hy) = ellipse(ctx.bounds); let a = angle.to_radians(); remap(src, out, ctx, Edge::Repeat, |x, y| { - let (dx, dy) = (x - cx, y - cy); - let r = (dx * dx + dy * dy).sqrt(); - if r >= rr || rr <= 0.0 { + if hx <= 0.0 || hy <= 0.0 { return (x, y); } - let t = a * (1.0 - r / rr); + let (u, v) = ((x - cx) / hx, (y - cy) / hy); + let r = (u * u + v * v).sqrt(); + if r >= 1.0 { + return (x, y); + } + let t = a * (1.0 - r); let (s, c) = t.sin_cos(); - (cx + dx * c - dy * s, cy + dx * s + dy * c) + (cx + (u * c - v * s) * hx, cy + (u * s + v * c) * hy) }) } pub(crate) fn pinch(src: &Image, out: Rect, ctx: &Ctx, amount: f32) -> Vec { - let (cx, cy, rr) = centre(ctx.bounds); + let (cx, cy, hx, hy) = ellipse(ctx.bounds); let a = (amount / 100.0).clamp(-1.0, 1.0); remap(src, out, ctx, Edge::Repeat, |x, y| { - let (dx, dy) = (x - cx, y - cy); - let r = (dx * dx + dy * dy).sqrt(); - if r >= rr || r <= 0.0 { + if hx <= 0.0 || hy <= 0.0 { return (x, y); } - let rs = rr * (r / rr).powf(1.0 - a * 0.5); - (cx + dx * rs / r, cy + dy * rs / r) + let (u, v) = ((x - cx) / hx, (y - cy) / hy); + let r = (u * u + v * v).sqrt(); + if r >= 1.0 || r <= 0.0 { + return (x, y); + } + let k = r.powf(1.0 - a * 0.5) / r; + (cx + u * k * hx, cy + v * k * hy) }) } @@ -64,20 +79,22 @@ fn sphere_1d(rn: f32, a: f32) -> f32 { } pub(crate) fn spherize(src: &Image, out: Rect, ctx: &Ctx, amount: f32, mode: SpherizeMode) -> Vec { - let b = ctx.bounds; - let (cx, cy, rr) = centre(b); - let (hx, hy) = (b.width() as f32 / 2.0, b.height() as f32 / 2.0); + let (cx, cy, hx, hy) = ellipse(ctx.bounds); let a = (amount / 100.0).clamp(-1.0, 1.0); remap(src, out, ctx, Edge::Repeat, |x, y| { let (dx, dy) = (x - cx, y - cy); match mode { SpherizeMode::Normal => { - let r = (dx * dx + dy * dy).sqrt(); - if r >= rr || r <= 0.0 { + if hx <= 0.0 || hy <= 0.0 { return (x, y); } - let rs = rr * sphere_1d(r / rr, a); - (cx + dx * rs / r, cy + dy * rs / r) + let (u, v) = (dx / hx, dy / hy); + let r = (u * u + v * v).sqrt(); + if r >= 1.0 || r <= 0.0 { + return (x, y); + } + let k = sphere_1d(r, a) / r; + (cx + u * k * hx, cy + v * k * hy) } SpherizeMode::HorizontalOnly => { if dx.abs() >= hx { 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/features.rs b/crates/algo/src/features.rs index ab98de757e..80de7b993e 100644 --- a/crates/algo/src/features.rs +++ b/crates/algo/src/features.rs @@ -45,12 +45,9 @@ pub const PATCH_RADIUS: usize = 15; struct Rng(u64); impl Rng { + /// The next value of the SplitMix64 sequence. fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) + crate::photo_util::splitmix64(&mut self.0) } fn unit(&mut self) -> f64 { (self.next() >> 11) as f64 / (1u64 << 53) as f64 @@ -60,25 +57,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 +158,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 +207,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 +260,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 +644,124 @@ 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. 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 { + 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/inpaint.rs b/crates/algo/src/inpaint.rs index d7d43c032f..2dd40198d8 100644 --- a/crates/algo/src/inpaint.rs +++ b/crates/algo/src/inpaint.rs @@ -16,6 +16,15 @@ //! source (the usual case), the vote copies the source pixels exactly; only along the seams //! between copied pieces does it blend. //! +//! A thin hole (a selected wire, string or hair: long, but only a few patches thick) is +//! measured by its thickness, not its bounds (#1366): the pyramid stops where it is still about +//! two patches thick. That level is big, so the onion peel starts it only where its exhaustive +//! search stays affordable; otherwise a membrane across the hole does, which over so short a +//! distance is the surroundings themselves. Shrunk to where its bounds are four patches across, +//! a long thin hole swallows its surroundings, the matches found there copy whatever happens to +//! look like the blur (a dark blotch, or a piece of another string), and the finer levels, +//! which only refine those matches, keep them: the removed object's outline stays visible. +//! //! This is what Photoshop's Content-Aware Fill does, measured as a black box on Photoshop 25.4 //! (see `content_aware`): with colour adaptation off its fill is pieces of the image copied pixel //! for pixel, typically 15–40 px across, joined by seams a few pixels wide; with colour @@ -56,15 +65,13 @@ const SOURCE_MOMENTS_MAX: usize = 1 << 20; #[derive(Clone)] pub(crate) struct Rng(u64); impl Rng { + /// A generator whose sequence is fixed by `seed`. pub(crate) fn new(seed: u64) -> Self { Rng(seed ^ 0x9E37_79B9_7F4A_7C15) } + /// The next value of the SplitMix64 sequence. pub(crate) fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) + crate::photo_util::splitmix64(&mut self.0) } /// Skip `n` numbers (the same state as after `n` calls of [`Rng::next`]). pub(crate) fn skip(&mut self, n: u64) { @@ -278,6 +285,38 @@ fn hole_bbox(w: usize, h: usize, hole: &[bool]) -> Option<(usize, usize, usize, b } +/// A hole at most `1 / THIN_RATIO` as thick as its bounds are long counts as thin (a wire, a +/// string, a hair, a long brush stroke): see the module docs. +const THIN_RATIO: usize = 4; + +/// How thick the hole within bounds `hb` of a `w`-wide role grid is: twice the largest distance +/// from a hole pixel to the nearest pixel that is not one (a long, thin hole is thin however long +/// its bounds). +fn hole_thickness(w: usize, hb: Bounds, px: &[Px]) -> usize { + // The bounds and a ring of non-hole cells around them (also past the grid's edge). + let (gw, gh) = (hb.width() + 2, hb.height() + 2); + let outside: Vec = par::map(gw * gh, gw * gh >= PAR_MIN, |i| { + let (gx, gy) = (i % gw, i / gw); + if gx == 0 || gy == 0 || gx > hb.width() || gy > hb.height() { + return true; + } + px.get((hb.y0 + gy - 1) * w + hb.x0 + gx - 1) != Some(&Px::Hole) + }); + let deepest = crate::selection::edt(&outside, gw, gh).into_iter().fold(0.0f32, f32::max); + (2.0 * deepest).ceil() as usize +} + +/// Most target pixels × sources the onion peel of a thin hole may compare (its search is +/// exhaustive, though most comparisons stop early: about a second at this many). +const PEEL_BUDGET: usize = 1 << 28; + +/// Whether the onion peel of the hole cells within `hb` of a `w`-wide role grid from `sources` +/// source centres stays within [`PEEL_BUDGET`]. +fn peel_affordable(w: usize, px: &[Px], hb: Bounds, sources: usize) -> bool { + let holes: usize = (hb.y0..hb.y1).map(|y| px.get(y * w + hb.x0..y * w + hb.x1).map_or(0, |row| row.iter().filter(|k| **k == Px::Hole).count())).sum(); + holes.saturating_mul(sources) <= PEEL_BUDGET +} + /// Bounds of the hole pixels of a role grid. fn hole_bounds(w: usize, h: usize, px: &[Px]) -> Option { // Per row the first and last hole column; the union of the rows' spans. @@ -1575,14 +1614,20 @@ fn complete_cow<'a>( let r = p.patch_radius.max(1); let psz = 2 * r + 1; // Pyramid: shrink until the hole is about four patches across (a deeper pyramid decides the - // structure where the texture has averaged out, and fills flat). + // structure where the texture has averaged out, and fills flat). A thin hole stops while it + // is still about two patches thick: through it, not along it, is what counts (#1366). let mut levels = vec![Level { w, h, img, px: Cow::Borrowed(px) }]; let mut boxes = vec![hb0]; let mut ext = hb0.width().max(hb0.height()); + let thickness = hole_thickness(w, hb0, px); + let thin = thickness.saturating_mul(THIN_RATIO) <= ext; + if thin { + ext = thickness; + } let mut exts = vec![ext]; while let Some(l) = levels.last() { ctl.check()?; - if ext <= 4 * psz || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz { + if ext <= if thin { 2 * psz } else { 4 * psz } || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz { break; } let d = downsample(l, ch); @@ -1638,9 +1683,21 @@ fn complete_cow<'a>( } } } - None if sources.len() > 0 => onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)?, + // A thin hole, when peeling it would take too long, takes the membrane below: a few + // patches thick here, a membrane across it is its surroundings. + None if sources.len() > 0 && (!thin || peel_affordable(lw, &lpx, hb, sources.len())) => { + onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)? + } None => { - let unknown: Vec = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect(); + let mut unknown: Vec = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect(); + // Across a thin hole the membrane starts a patch radius out, past the + // anti-aliased fringe or halo that a selection drawn close to the object leaves. + if thin { + let hole: Vec = lpx.iter().map(|k| *k == Px::Hole).collect(); + for (u, d) in unknown.iter_mut().zip(crate::selection::edt(&hole, lw, lh)) { + *u |= d <= r as f32; + } + } let m = membrane_fill(lw, lh, ch, &limg, &unknown); for (i, k) in lpx.iter().enumerate() { if *k == Px::Hole { @@ -2114,6 +2171,43 @@ mod tests { .collect() } + #[test] + fn a_hole_is_as_thick_as_it_is_through_not_along() { + let (w, h) = (240, 120); + let all = Bounds { x0: 0, y0: 0, x1: w, y1: h }; + let thickness = |hole: &[bool]| { + let px: Vec = hole.iter().map(|&o| if o { Px::Hole } else { Px::Source }).collect(); + hole_thickness(w, hole_bounds(w, h, &px).unwrap_or(all), &px) + }; + // A band 200 px long and 9 px thick; a disc of radius 20; the band touching the edge + // counts the outside of the image as not hole. + let band: Vec = (0..w * h).map(|i| (20..220).contains(&(i % w)) && (50..59).contains(&(i / w))).collect(); + assert!((9..=11).contains(&thickness(&band)), "{}", thickness(&band)); + assert!((39..=42).contains(&thickness(&disc(w, h, 120.0, 60.0, 20.0)))); + let edge: Vec = (0..w * h).map(|i| i / w < 9).collect(); + assert!((9..=11).contains(&thickness(&edge)), "{}", thickness(&edge)); + } + + /// A long thin hole goes the thin way (a shallow pyramid that starts from a membrane): it is + /// filled, only it changes, and the result does not depend on the thread count either. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn thin_holes_are_filled_the_same_on_any_number_of_threads() { + let (w, h) = (300, 200); + let img = busy(w, h, 3, 5); + // A wavy line 6 px thick across the image. + let hole: Vec = (0..w * h).map(|i| (((i % w) as f32 * 0.05).sin() * 30.0 + 100.0 - (i / w) as f32).abs() < 3.0).collect(); + let px = roles(w, h, &hole); + let run = |threads: usize| { + let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap(); + pool.install(|| complete_masked(w, h, 3, &img, &px, &CompleteParams::default(), &photocraft_raster::Interrupt::NONE)).unwrap().unwrap() + }; + let one = run(1); + assert_eq!(bits(&one), bits(&run(7))); + assert!((0..w * h).all(|i| hole[i] || (0..3).all(|c| one[i * 3 + c].to_bits() == img[i * 3 + c].to_bits()))); + assert!((0..w * h).filter(|&i| hole[i]).all(|i| (0..3).all(|c| one[i * 3 + c].is_finite()))); + } + #[test] #[cfg(not(target_arch = "wasm32"))] fn results_do_not_depend_on_the_thread_count() { diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 78dae7b06f..6cefbbd4c1 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; @@ -481,7 +482,8 @@ pub enum FilterParams { distribution: Distribution, monochromatic: bool, seed: u32, - /// Depth for [`DepthSource::LayerMask`] (channel 0, document coordinates), supplied by the caller. + /// Depth for [`DepthSource::LayerMask`] and [`DepthSource::Channel`] (channel 0, document + /// coordinates), supplied by the caller. #[serde(skip)] depth_map: Option>, }, @@ -600,6 +602,13 @@ impl FilterParams { self.halo() == Halo::Bounds } + /// Whether the filter renders new pixels without reading the layer's own (Fibers): it fills + /// the reference bounds even where the layer is transparent, as Photoshop's does on an + /// empty layer, while still reading no neighbourhood. + pub fn renders_from_nothing(&self) -> bool { + matches!(self, FilterParams::Fibers { .. }) + } + /// Human-readable name. pub fn label(&self) -> &'static str { match self { @@ -736,8 +745,8 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec noise::add(src, out, ctx, *amount, *distribution, *monochromatic, *seed), FilterParams::Median { radius } => noise::median(src, out, *radius, None), FilterParams::DustAndScratches { radius, threshold } => noise::median(src, out, *radius, Some(*threshold)), - FilterParams::Minimum { radius, preserve } => other::min_max_preserve(src, out, *radius, false, *preserve), - FilterParams::Maximum { radius, preserve } => other::min_max_preserve(src, out, *radius, true, *preserve), + FilterParams::Minimum { radius, preserve } => min_max_ink(src, out, ctx, *radius, false, *preserve), + FilterParams::Maximum { radius, preserve } => min_max_ink(src, out, ctx, *radius, true, *preserve), FilterParams::Offset { horizontal, vertical, undefined } => other::offset(src, out, ctx, *horizontal, *vertical, *undefined), FilterParams::Mosaic { cell_size } => stylize::mosaic(src, out, ctx, *cell_size), FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount), @@ -901,16 +910,34 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec Vec { + if !fxutil::subtractive(ctx) { + return other::min_max_preserve(src, out, radius, max, preserve); + } + let n = src.ch.max(1); + let cc = if ctx.alpha { n - 1 } else { n }; + let flip = |data: &mut [f32]| data.chunks_exact_mut(n).for_each(|px| px.iter_mut().take(cc).for_each(|v| *v = 1.0 - *v)); + let mut inv = src.clone(); + flip(&mut inv.data); + let mut res = other::min_max_preserve(&inv, out, radius, max, preserve); + flip(&mut res); + res +} + /// Output tile size used by [`apply`]. pub const TILE: i32 = 256; /// The area a filter writes for a layer whose pixels cover `content`: -/// global filters write the reference bounds; others the content grown by -/// the halo (blurs spread into transparent areas). Clipped to the -/// selection's bounds when there is one. +/// global filters and renders that ignore the layer's pixels (Fibers) write the reference +/// bounds; others the content grown by the halo (blurs spread into transparent areas). +/// Clipped to the selection's bounds when there is one. pub fn output_area(params: &FilterParams, content: Rect, bounds: Rect, selection_bounds: Option) -> Rect { let mut area = match params.halo_for(bounds) { Halo::Bounds => bounds, + Halo::Radius(_) if params.renders_from_nothing() => bounds, Halo::Radius(r) => { if content.is_empty() { Rect::EMPTY @@ -963,6 +990,7 @@ fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 { } /// [`apply`] / [`apply_in`] with an explicit tile size (tests use it to check tile independence). +/// Nonpositive tile sizes are treated as one pixel. pub fn apply_tiled( surface: &Surface, params: &FilterParams, @@ -1005,6 +1033,8 @@ fn apply_tiled_with_impl( ctl: &photocraft_raster::Interrupt, prepare_fast_paths: bool, ) -> Option { + // Every dispatch path needs a positive step, including Motion Blur's fallback. + let tile = tile.max(1); if ctl.cancelled() { return None; } diff --git a/crates/algo/src/other2.rs b/crates/algo/src/other2.rs index b5a16f06ea..61ccfc0038 100644 --- a/crates/algo/src/other2.rs +++ b/crates/algo/src/other2.rs @@ -8,7 +8,8 @@ use crate::image::Image; use crate::{Ctx, HsbModel}; /// Custom: `Σ kernel·pixel / scale + offset/255` per colour channel (alpha -/// kept). Kernels shorter than 25 entries are zero-padded. +/// kept). Kernels shorter than 25 entries are zero-padded. CMYK samples are +/// ink, so there it runs on `1 - ink` and a positive offset brightens. pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f32, offset: f32) -> Vec { let n = src.ch; let cc = ncol(ctx, n); @@ -16,6 +17,7 @@ pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f (0..25).filter_map(|i| kernel.get(i).copied().filter(|v| *v != 0.0).map(|v| (i as i32 % 5 - 2, i as i32 / 5 - 2, v))).collect(); let s = if scale == 0.0 { 1.0 } else { scale }; let off = offset / 255.0; + let sub = crate::fxutil::subtractive(ctx); let mut res = src.crop(out); if k.is_empty() { return res; @@ -23,8 +25,10 @@ pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f for (i, px) in res.chunks_exact_mut(n).enumerate() { let (x, y) = xy(out, i); for (c, v) in px.iter_mut().enumerate().take(cc) { - let sum: f32 = k.iter().map(|&(dx, dy, w)| src.get(x + dx, y + dy, c) * w).sum(); - *v = sum / s + off; + // CMYK: convolve brightness (1 - ink), then back to ink. + let lum = |v: f32| if sub { 1.0 - v } else { v }; + let sum: f32 = k.iter().map(|&(dx, dy, w)| lum(src.get(x + dx, y + dy, c)) * w).sum(); + *v = lum(sum / s + off); } } res diff --git a/crates/algo/src/panorama.rs b/crates/algo/src/panorama.rs index fd7ccbc591..75fa9a4bfb 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,47 +1099,99 @@ 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]) + }) +} + +/// Estimated bytes of band construction [`multiband`] keeps in flight at once. Images are built +/// and accumulated in batches under it, so its peak memory doesn't grow with the image count +/// (#7758). Four 24 MP RGB sources (~1.2 GB each) fit in one batch: building bands side by side +/// uses a many-core machine better than one band's row-parallel work, and one at a time made the +/// usual 2–4 image Photomerge 12–36% slower on a 32-thread desktop. +#[cfg(not(target_arch = "wasm32"))] +const BAND_BUDGET: usize = 5 << 30; +/// Without threads the bands of a batch are built one after another anyway, so each is added as +/// soon as it is built (the browser's address space is 4 GB). +#[cfg(target_arch = "wasm32")] +const BAND_BUDGET: usize = 0; + /// Multi-band blend of `imgs` into a `w × h` panorama (origin 0,0) with one weight map per image /// over the same grid as its ROI (`weights[i]` has `w_i × h_i` entries). Returns the colour /// channels (`ch` per pixel) and the total coverage. pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], levels: usize) -> (Vec, Vec) { + multiband_batched(w, h, imgs, weights, levels, BAND_BUDGET) +} + +/// [`multiband`], building at most `budget` estimated bytes of bands at a time (at least one +/// image). Each pixel still adds the bands in image order, so the result doesn't depend on it. +fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], levels: usize, budget: usize) -> (Vec, Vec) { let ch = imgs.first().map_or(1, |i| i.ch); let levels = levels.max(1); let align = 1i32 << (levels - 1); @@ -1112,87 +1205,144 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], le 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 grid = |im: &RoiImage| { 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); + (gx0, gy0, (gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize) + }; + // Building one band holds its grid's colour and weight planes about three times over: the + // pyramids are 4/3 of a plane, and the grid copy, coverage and `expand` scratch come on top. + let cost = |im: &RoiImage| { + let (_, _, gw, gh) = grid(im); + gw.saturating_mul(gh).saturating_mul(ch + 1).saturating_mul(4 * 3) + }; + let band = |k: usize| -> PyramidBand { + let im = &imgs[k]; + let (gx0, gy0, gw, gh) = grid(im); + // 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 }); + drop(cov); push_pull(gw, gh, ch, &mut px, &bin); - // Laplacian of the colours, Gaussian of the weights. + drop(bin); + // Laplacian of the colours, Gaussian of the weights. Planes move into the pyramids rather + // than being copied: with one image built at a time, a copy is a serial pass over it. 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()); + lap.push((cw, chh, cur)); + gau.push(cwt); break; } 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())); - gau.push(cwt.clone()); + lap.push((cw, chh, par_zip(&cur, &up, |a, b| a - b))); let (_, _, wdown) = reduce(cw, chh, 1, &cwt); + gau.push(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]; - } - } - } + }; + // Build the bands a batch at a time and add each batch before building the next. Each pixel + // adds the bands in the same order as the serial loop (per level, bands one after another; + // batches follow each other in image order), but rows of a level are independent, so they + // run in parallel: every row walks the batch's bands itself. + let mut start = 0; + while start < imgs.len() { + let mut end = start + 1; + let mut bytes = cost(&imgs[start]); + while let Some(im) = imgs.get(end) + && bytes.saturating_add(cost(im)) <= budget + { + bytes = bytes.saturating_add(cost(im)); + end += 1; } + let bands = par_map(end - start, |j| band(start + j)); + 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 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; + } + for c in 0..ch { + row[px * ch + c] += k * band[x * ch + c]; + } + } + } + }); + } + start = end; } 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 +1351,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 +1586,411 @@ 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()); + } + + /// Batching the bands (#7758) changes when they are built, not the result. Budgets from none + /// (one image per batch) up to everything in steps of a fifth pass through every split of the + /// three images. + #[test] + fn multiband_batches_match_the_serial_code() { + let budgets: Vec = + std::iter::successors(Some(1024.0f64), |b| Some(b * 1.2)).take_while(|b| *b < 4e6).map(|b| b as usize).chain([0, usize::MAX]).collect(); + for (n, &(w, h)) in [(40usize, 30usize), (97, 61), (201, 129)].iter().enumerate() { + for ch in [1usize, 3] { + let (imgs, wts) = rois(w, h, ch, n as u64 * 17 + ch as u64); + for levels in [1usize, 3, 5] { + let want = ref_multiband(w, h, &imgs, &wts, levels); + for &budget in &budgets { + let got = multiband_batched(w, h, &imgs, &wts, levels, budget); + assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels} budget {budget}"); + assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels} budget {budget}"); + } + } + } + } + } + + /// 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"); + } +} diff --git a/crates/algo/src/params_ext.rs b/crates/algo/src/params_ext.rs index 3a9572fb46..2349604196 100644 --- a/crates/algo/src/params_ext.rs +++ b/crates/algo/src/params_ext.rs @@ -182,6 +182,9 @@ pub enum DepthSource { None, Transparency, LayerMask, + /// An alpha channel of the document (Photoshop lists them after Layer Mask), supplied by the + /// caller like the layer mask. + Channel, } /// Built-in Shape Blur kernels. diff --git a/crates/algo/src/photo_util.rs b/crates/algo/src/photo_util.rs index bf65bc4459..7316088079 100644 --- a/crates/algo/src/photo_util.rs +++ b/crates/algo/src/photo_util.rs @@ -159,15 +159,21 @@ pub(crate) fn par_map(n: usize, f: impl Fn(usize) -> T + Sync + Send) - (0..n).map(f).collect() } +/// Advances `state` one SplitMix64 step and returns the mixed output. +pub(crate) fn splitmix64(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + /// Deterministic 64-bit generator (SplitMix64). pub(crate) struct Rng(pub u64); impl Rng { + /// The next value of the SplitMix64 sequence. pub fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) + splitmix64(&mut self.0) } pub fn below(&mut self, n: usize) -> usize { (self.next() % n.max(1) as u64) as usize @@ -183,15 +189,7 @@ pub(crate) fn hash01(x: i64, y: i64, seed: u64) -> f32 { (z >> 40) as f32 / (1u64 << 24) as f32 } -/// sRGB transfer function, decode (extended linearly below 0 and as a power above 1). -pub(crate) fn srgb_to_linear(v: f32) -> f32 { - if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } -} - -/// sRGB transfer function, encode. -pub(crate) fn linear_to_srgb(v: f32) -> f32 { - if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } -} +pub(crate) use photocraft_color::convert::{linear_to_srgb, srgb_to_linear}; /// Bilinear sample of channel `c` of a `w × h × ch` buffer, clamped to the edges. pub(crate) fn bilinear(buf: &[f32], w: usize, h: usize, ch: usize, c: usize, x: f32, y: f32) -> f32 { @@ -217,6 +215,41 @@ pub(crate) fn catmull_rom(t: f32) -> [f32; 4] { mod tests { use super::*; + /// The shared step and [`Rng`] reproduce the inlined copies they replaced, bit for bit. + #[test] + fn splitmix64_output_is_unchanged() { + /// The SplitMix64 step as each generator inlined it before. + fn inlined(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + let mut zero = 0; + assert_eq!([0; 3].map(|_| splitmix64(&mut zero)), [0xE220_A839_7B1D_CDAF, 0x6E78_9E6A_A1B9_65F4, 0x06C4_5D18_8009_454F]); + for seed in [0, 1, 42, 0x2545_F491_4F6C_DD1D, u64::MAX] { + let (mut shared, mut copy, mut rng) = (seed, seed, Rng(seed)); + for _ in 0..10_000 { + let want = inlined(&mut copy); + assert_eq!(splitmix64(&mut shared), want); + assert_eq!(rng.next(), want); + } + } + } + + /// The shared sRGB functions match the removed copies bit for bit. + #[test] + fn srgb_transfer_output_is_unchanged() { + let decode = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; + let encode = |v: f32| if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }; + for i in -2000..=6000 { + let v = i as f32 / 4000.0; + assert_eq!(srgb_to_linear(v).to_bits(), decode(v).to_bits(), "decode {v}"); + assert_eq!(linear_to_srgb(v).to_bits(), encode(v).to_bits(), "encode {v}"); + } + } + #[test] fn dense_and_sparse_solvers_agree() { // Overdetermined line fit y = 2x + 1. diff --git a/crates/algo/src/render.rs b/crates/algo/src/render.rs index baba88f93e..d40ff9cda8 100644 --- a/crates/algo/src/render.rs +++ b/crates/algo/src/render.rs @@ -146,8 +146,9 @@ pub(crate) fn lens_flare(src: &Image, out: Rect, ctx: &Ctx, brightness: f32, cen let mut c = rgba(ctx, px); for k in 0..3 { let a = (f[k] * bright).max(0.0); - // Screen keeps highlights below white for display-referred values. - c[k] = 1.0 - (1.0 - c[k]) * (1.0 - a.min(1.0)); + // Screen (c + a·(1 − c)) keeps display-referred values below white; a 32-bit + // value already above white is left as is rather than pulled down towards it. + c[k] += a.min(1.0) * (1.0 - c[k]).max(0.0); } set_rgba(ctx, px, c); } diff --git a/crates/algo/src/render2.rs b/crates/algo/src/render2.rs index ed6567ca5c..19a4b8218d 100644 --- a/crates/algo/src/render2.rs +++ b/crates/algo/src/render2.rs @@ -274,15 +274,13 @@ pub fn composite(surface: &mut Surface, prims: &[Prim], clip: Rect, selection: O pub struct Rng(u64); impl Rng { + /// A generator whose sequence is fixed by `seed` and `salt`. pub fn new(seed: u32, salt: u64) -> Rng { Rng(((seed as u64) << 32) ^ salt.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0x2545_F491_4F6C_DD1D) } + /// The next value of the SplitMix64 sequence. pub fn next_u64(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) + crate::photo_util::splitmix64(&mut self.0) } /// Uniform in [0, 1). pub fn f(&mut self) -> f32 { diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index 573c09079e..e1c1d89de0 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -126,7 +126,9 @@ pub fn object_select(sampler: &dyn Sampler, canvas: Rect, rect: Rect, work_px: u } let (fg, bg) = grabcut(&img, &mut trimap, 8)?; let low: Vec = trimap.iter().map(|t| is_fg(*t)).collect(); - let low = super::clean_mask(&low, lw, lh, 0.1, 0.002); + let low = super::clean_mask(&low, lw, lh, 0.1, 0.0); + // Every enclosed hole, however small, fills only when it looks like the object, so background seen through it stays open. + let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05); super::finish_region(sampler, window, step, &low, lw, lh, Some((&fg, &bg))) } @@ -209,7 +211,9 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u let mut seeds = vec![false; w * h]; seeds[sy * w + sx] = true; let low = super::keep_seeded(&low, &seeds, w, h); - let low = super::clean_mask(&low, w, h, 0.1, 0.01); + let low = super::clean_mask(&low, w, h, 0.1, 0.0); + // As in the rectangle mode: enclosed holes fill only when they look like the object. + let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05); if !low.iter().any(|v| *v) { return None; } diff --git a/crates/algo/src/segment/mod.rs b/crates/algo/src/segment/mod.rs index 5c190e643a..b30ce909b7 100644 --- a/crates/algo/src/segment/mod.rs +++ b/crates/algo/src/segment/mod.rs @@ -448,6 +448,51 @@ pub fn clean_mask(mask: &[bool], w: usize, h: usize, frac: f32, hole_frac: f32) out } +/// Fills the holes of `mask` (4-connected background areas not touching the border) by what +/// they look like: a hole up to `speck_frac` of the mask's area always fills; a larger one, up +/// to `hole_frac`, only when its pixels fit the foreground model `fg` better than `bg` on +/// average, so an enclosed patch of backdrop stays open. +pub fn fill_holes_by_model(mask: &[bool], img: &RgbImage, fg: &Gmm, bg: &Gmm, speck_frac: f32, hole_frac: f32) -> Vec { + let (w, h) = (img.w, img.h); + let mut out = mask.to_vec(); + let area = out.iter().filter(|v| **v).count(); + let max_speck = (area as f32 * speck_frac) as usize; + let max_hole = (area as f32 * hole_frac) as usize; + let inv: Vec = out.iter().map(|v| !v).collect(); + let (hl, hs) = components4(&inv, w, h); + let mut touches = vec![false; hs.len()]; + for x in 0..w { + touches[hl[x] as usize] = true; + touches[hl[(h - 1) * w + x] as usize] = true; + } + for y in 0..h { + touches[hl[y * w] as usize] = true; + touches[hl[y * w + w - 1] as usize] = true; + } + // Summed data costs per hole; only holes small enough to be candidates are evaluated. + let mut cost = vec![(0.0f64, 0.0f64); hs.len()]; + for (i, l) in hl.iter().enumerate() { + let l = *l as usize; + if l == 0 || touches[l] || hs[l] > max_hole || hs[l] <= max_speck { + continue; + } + if let Some(p) = img.px.get(i) { + cost[l].0 += f64::from(fg.neg_log(*p)); + cost[l].1 += f64::from(bg.neg_log(*p)); + } + } + for (o, l) in out.iter_mut().zip(&hl) { + let l = *l as usize; + if l == 0 || touches[l] || hs[l] > max_hole { + continue; + } + if hs[l] <= max_speck || cost[l].0 <= cost[l].1 { + *o = true; + } + } + out +} + fn components4(mask: &[bool], w: usize, h: usize) -> (Vec, Vec) { let mut lab = vec![0u32; w * h]; let mut sizes = vec![0usize]; diff --git a/crates/algo/src/segment/subject.rs b/crates/algo/src/segment/subject.rs index b117bb5ef6..75a1a47a56 100644 --- a/crates/algo/src/segment/subject.rs +++ b/crates/algo/src/segment/subject.rs @@ -13,8 +13,17 @@ //! S. Süsstrunk, "Frequency-tuned Salient Region Detection", CVPR 2009): distance of the //! lightly blurred CIELAB image from its mean colour. //! 4. Otsu's threshold on the saliency map seeds a GrabCut trimap (low-saliency border pixels -//! are definite background); the result is cleaned (small islands dropped, small holes -//! filled) and re-cut at full resolution. +//! are definite background); the result is cleaned (small islands dropped). +//! 5. A second GrabCut whose background model only knows the backdrop: a colour model fitted +//! to the image's frame marks every pixel it explains as probable background, and the rest +//! (the blob, plus whatever the backdrop cannot account for) starts as probable foreground. +//! Parts of the subject whose colour is close to the backdrop (a cream shirt on a near-white +//! wall) have low saliency and in step 4 land in the background model, which then explains +//! them away; here they start as foreground and the cut keeps them (#2788). Only what +//! touches the first pass's result survives, so the pass cannot pull in unrelated things. +//! 6. Holes are filled only when their colour fits the foreground model better than the +//! background's, so a patch of backdrop enclosed by the subject (between an arm and the +//! torso) stays open; specks still fill. Then the mask is re-cut at full resolution. //! //! Works well for a distinct object on a comparatively plain background; it is not a semantic //! segmenter. @@ -25,6 +34,7 @@ use std::collections::BinaryHeap; use photocraft_geom::Rect; use photocraft_raster::{Cancelled, Interrupt}; +use super::gmm::Gmm; use super::grabcut::{self, BG, PR_BG, PR_FG}; use super::slic::{rgb_to_lab, slic_lab}; use super::{Region, Sampler}; @@ -72,13 +82,65 @@ pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt) } let Some((fg, bg)) = grabcut::grabcut_with(&img, &mut trimap, 6, ctl)? else { return Ok(None) }; let low: Vec = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect(); - let low = super::clean_mask(&low, w, h, 0.2, 0.05); + let low = super::clean_mask(&low, w, h, 0.2, 0.0); if !low.iter().any(|v| *v) { return Ok(None); } + let (low, fg, bg) = backdrop_pass(&img, &low, ctl)?.unwrap_or((low, fg, bg)); + ctl.check()?; + let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.002, 0.05); super::finish_region_with(sampler, canvas, step, &low, w, h, Some((&fg, &bg)), ctl) } +/// Step 5: GrabCut again with a background model that only knows the backdrop. A colour model +/// is fitted to the image's frame (the outer pixels, minus a margin around `blob` in case the +/// subject runs out of the picture); the frame is definite background, every other pixel it +/// explains well is probable background, and the rest (the blob, and anything the backdrop +/// cannot account for, such as a cream shirt on a near-white wall) starts as probable +/// foreground. `Ok(None)` when the frame cannot be modelled or the cut keeps nothing that +/// touches the blob; `Err(Cancelled)` once `ctl` is cancelled, as in the first pass. +fn backdrop_pass(img: &super::RgbImage, blob: &[bool], ctl: &Interrupt) -> Result, Gmm, Gmm)>, Cancelled> { + let (w, h) = (img.w, img.h); + let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); + for (i, on) in blob.iter().enumerate() { + if *on { + let (x, y) = (i % w, i / w); + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + } + } + if x1 < x0 || y1 < y0 { + return Ok(None); + } + let margin = (((x1 - x0 + 1).max(y1 - y0 + 1) as f32 * 0.15).ceil() as usize).max(4); + let (bx0, by0) = (x0.saturating_sub(margin), y0.saturating_sub(margin)); + let (bx1, by1) = ((x1 + margin).min(w - 1), (y1 + margin).min(h - 1)); + let frame = ((w.min(h) as f32 * 0.06).round() as usize).max(2); + let in_frame = |x: usize, y: usize| x < frame || y < frame || x + frame >= w || y + frame >= h; + let near_blob = |x: usize, y: usize| (bx0..=bx1).contains(&x) && (by0..=by1).contains(&y); + let samples: Vec<[f32; 3]> = (0..w * h).filter(|i| in_frame(i % w, i / w) && !near_blob(i % w, i / w)).map(|i| img.px[i]).collect(); + let Some(backdrop) = Gmm::fit(&super::subsample(&samples, 40_000), grabcut::K, super::gmm::DEFAULT_REG) else { return Ok(None) }; + // What the frame itself costs under its own model bounds "explained well". + let mut frame_costs: Vec = samples.iter().map(|p| backdrop.neg_log(*p)).collect(); + frame_costs.sort_by(|a, b| a.total_cmp(b)); + let Some(limit) = frame_costs.get(frame_costs.len() * 99 / 100).map(|c| c + 4.0) else { return Ok(None) }; + let mut trimap: Vec = (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + if in_frame(x, y) && !near_blob(x, y) { + BG + } else if blob[i] || backdrop.neg_log(img.px[i]) > limit { + PR_FG + } else { + PR_BG + } + }) + .collect(); + let Some((fg, bg)) = grabcut::grabcut_with(img, &mut trimap, 4, ctl)? else { return Ok(None) }; + let cut: Vec = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect(); + let low = super::keep_seeded(&cut, blob, w, h); + Ok(low.iter().any(|v| *v).then_some((low, fg, bg))) +} + /// Per-pixel saliency in `0..=1` (see the module docs). pub fn saliency(img: &super::RgbImage) -> Vec { let (w, h) = (img.w, img.h); diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 55ddc6cafd..e060913c9b 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -761,3 +761,212 @@ fn object_at_on_the_background_or_off_canvas_finds_nothing() { assert!(grabcut::object_at(&s, canvas, f32::NAN, 10.0, 20_000).is_none()); assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none()); } + +// ---------- Select Subject on the cases reported in #2788 ---------- + +/// A figure whose upper part is close to the background colour (a cream shirt on a near-white +/// backdrop): the whole figure is the subject. Returns (image, truth, the low-contrast part). +pub(crate) fn low_contrast_figure(seed: u64) -> (RgbImage, Vec, Vec) { + let (w, h) = (240usize, 200usize); + let mut rng = Rng::new(seed); + let mut img = RgbImage::new(w, h); + let mut truth = vec![false; w * h]; + let mut cream = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let body = (90..150).contains(&x) && (70..190).contains(&y); + let shoulders = (80..160).contains(&x) && (40..70).contains(&y); + let print = (105..135).contains(&x) && (100..130).contains(&y); + let n = 0.012 * rng.normal(); + let i = y * w + x; + truth[i] = body || shoulders; + cream[i] = shoulders; + img.px[i] = if print { + [0.9 + n, 0.15 + n, 0.1 + n] + } else if body { + [0.12 + n, 0.2 + n, 0.45 + n] + } else if shoulders { + // Cream, a few levels off the backdrop. + [0.90 + n, 0.88 + n, 0.80 + n] + } else { + [0.96 + n, 0.96 + n, 0.95 + n] + } + .map(|v| v.clamp(0.0, 1.0)); + } + } + (img, truth, cream) +} + +fn coverage(region: &Region, part: &[bool], w: usize, h: usize) -> f32 { + let (mut hit, mut n) = (0usize, 0usize); + for y in 0..h { + for x in 0..w { + if part[y * w + x] { + n += 1; + hit += (region.at(x as i32, y as i32) >= 0.5) as usize; + } + } + } + hit as f32 / n.max(1) as f32 +} + +#[test] +fn select_subject_keeps_parts_close_to_the_background_colour() { + let (img, truth, cream) = low_contrast_figure(7); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200)).unwrap(); + let v = iou(®, &truth, 240, 200); + let c = coverage(®, &cream, 240, 200); + assert!(c > 0.85, "the cream shoulders are part of the subject: covered {c}"); + assert!(v > 0.9, "IoU {v}"); +} + +#[test] +fn select_subject_leaves_a_background_coloured_gap_open() { + // A dark ring (arms on hips) around a patch of plain backdrop: the patch is not the subject. + let (w, h) = (220usize, 200usize); + let mut rng = Rng::new(11); + let mut img = RgbImage::new(w, h); + let mut truth = vec![false; w * h]; + let mut gap = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let d = (x as f32 - 110.0).hypot(y as f32 - 100.0); + let ring = (17.0..80.0).contains(&d); + let hole = d < 17.0; + let n = 0.012 * rng.normal(); + let i = y * w + x; + truth[i] = ring; + gap[i] = hole; + img.px[i] = if ring { [0.2 + n, 0.15 + n, 0.35 + n] } else { [0.95 + n, 0.94 + n, 0.92 + n] }.map(|v| v.clamp(0.0, 1.0)); + } + } + let s = ImageSampler { img: &img, origin: (0, 0) }; + let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32)).unwrap(); + let g = coverage(®, &gap, w, h); + assert!(g < 0.1, "the backdrop inside the ring stays unselected: covered {g}"); + let v = iou(®, &truth, w, h); + assert!(v > 0.9, "IoU {v}"); +} + +/// Timing on a 6 MP figure, for before/after numbers (`cargo test --release -- --ignored select_subject_timing --nocapture`). +#[test] +#[ignore = "timing only"] +fn select_subject_timing() { + let (w, h) = (3000usize, 2000usize); + let img = RgbImage::from_fn(w, h, |x, y| { + let body = (1100..1900).contains(&x) && (700..1900).contains(&y); + let shoulders = (1000..2000).contains(&x) && (400..700).contains(&y); + let t = ((x * 7 + y * 13) % 17) as f32 / 1700.0; + if body { + [0.12 + t, 0.2, 0.45] + } else if shoulders { + [0.90, 0.88 + t, 0.80] + } else { + [0.96, 0.96 + t, 0.95] + } + }); + let s = ImageSampler { img: &img, origin: (0, 0) }; + for _ in 0..2 { + let t0 = std::time::Instant::now(); + let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32)).unwrap(); + eprintln!("select_subject 6 MP: {:?} bbox {:?}", t0.elapsed(), reg.bbox); + } +} + +/// Writes the #2788 scenes and the Select Subject mask over them as PPM files into +/// `$SUBJECT_DUMP` (for the PR's before/after pictures). +#[test] +#[ignore = "writes pictures"] +fn select_subject_dump_pictures() { + let Ok(dir) = std::env::var("SUBJECT_DUMP") else { return }; + let write = |name: &str, img: &RgbImage, reg: Option<&Region>| { + let mut out = format!("P6\n{} {}\n255\n", img.w, img.h).into_bytes(); + for y in 0..img.h { + for x in 0..img.w { + let mut c = img.at(x, y); + if let Some(r) = reg + && r.at(x as i32, y as i32) >= 0.5 + { + c = [c[0] * 0.5 + 0.5, c[1] * 0.5, c[2] * 0.5 + 0.5]; + } + out.extend(c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)); + } + } + std::fs::write(format!("{dir}/{name}.ppm"), out).unwrap(); + }; + let (img, _, _) = low_contrast_figure(7); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200)); + write("figure", &img, None); + write("figure-selected", &img, reg.as_ref()); + let (w, h) = (220usize, 200usize); + let mut rng = Rng::new(11); + let img = RgbImage::from_fn(w, h, |x, y| { + let d = (x as f32 - 110.0).hypot(y as f32 - 100.0); + let n = 0.012 * rng.normal(); + if (17.0..80.0).contains(&d) { [0.2 + n, 0.15 + n, 0.35 + n] } else { [0.95 + n, 0.94 + n, 0.92 + n] }.map(|v| v.clamp(0.0, 1.0)) + }); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32)); + write("ring", &img, None); + write("ring-selected", &img, reg.as_ref()); +} + +/// A dark square on a light background with a square hole that shows the background colour. +fn square_with_hole(hole: usize) -> RgbImage { + let (w, h) = (400usize, 300usize); + let mut rng = Rng::new(11); + let (x0, y0) = (100usize, 50usize); + RgbImage::from_fn(w, h, |x, y| { + let n = 0.02 * rng.normal(); + let in_square = x >= x0 && x < x0 + 200 && y >= y0 && y < y0 + 200; + let in_hole = x >= x0 + 96 && x < x0 + 96 + hole && y >= y0 + 96 && y < y0 + 96 + hole; + let c = if in_square && !in_hole { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] }; + c.map(|v: f32| (v + n).clamp(0.0, 1.0)) + }) +} + +#[test] +fn object_select_keeps_small_background_hole_open() { + let img = square_with_hole(8); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 400, 300); + let reg = grabcut::object_select(&s, canvas, Rect::new(80, 30, 320, 270), 1_000_000).unwrap(); + assert!(reg.at(150, 100) >= 0.5, "the square itself is selected"); + assert!(reg.at(200, 150) < 0.5, "background seen through the object stays unselected"); +} + +#[test] +fn object_at_keeps_small_background_hole_open() { + let img = square_with_hole(16); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 400, 300); + let reg = grabcut::object_at(&s, canvas, 130.0, 80.0, 1_000_000).unwrap(); + assert!(reg.at(150, 100) >= 0.5, "the square itself is selected"); + assert!(reg.at(203, 153) < 0.5, "background seen through the object stays unselected"); +} + +#[test] +fn hole_fill_without_specks_fills_object_coloured_holes_only() { + // The hole check Object Selection uses: no hole fills just for being small, a speck of the object's colour still does. + let (w, h) = (40usize, 40usize); + let mut rng = Rng::new(5); + let in_square = |x: usize, y: usize| (5..35).contains(&x) && (5..35).contains(&y); + let bg_hole = |x: usize, y: usize| (10..12).contains(&x) && (10..12).contains(&y); + let fg_hole = |x: usize, y: usize| (25..27).contains(&x) && (25..27).contains(&y); + let img = RgbImage::from_fn(w, h, |x, y| { + let n = 0.02 * rng.normal(); + let c = if in_square(x, y) && !bg_hole(x, y) { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] }; + c.map(|v: f32| (v + n).clamp(0.0, 1.0)) + }); + let mask: Vec = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w) && !fg_hole(i % w, i / w)).collect(); + // The colour models, as GrabCut would fit them: the object colour vs. the backdrop colour. + let dark: Vec = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w)).collect(); + let (fs, bs): (Vec<_>, Vec<_>) = img.px.iter().zip(&dark).partition(|(_, m)| **m); + let fit = |v: Vec<(&[f32; 3], &bool)>| Gmm::fit(&v.iter().map(|(p, _)| **p).collect::>(), 3, gmm::DEFAULT_REG).unwrap(); + let (fg, bg) = (fit(fs), fit(bs)); + let out = fill_holes_by_model(&mask, &img, &fg, &bg, 0.0, 0.05); + assert!(out[25 * w + 25], "a speck of the object's colour fills"); + assert!(!out[10 * w + 10], "a speck of the background's colour stays open"); +} diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index ad858df2a2..94f744d0be 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -682,18 +682,23 @@ fn hue_chroma([r, g, b]: [f32; 3]) -> (f32, f32) { /// Color Range › Select: Reds, Yellows, Greens, Cyans, Blues or Magentas (`center` = 0, 60, /// 120, 180, 240, 300°). A pixel belongs to a family by how close its hue is (full at the -/// family's hue, none 60° away, so an orange is half red, half yellow) times its chroma, so -/// neutrals are never selected and muted colours only partly. +/// family's hue, none 60° away, so an orange is half red, half yellow) times its chroma +/// (max − min), so neutrals are never selected and muted colours only partly. Darker colours +/// weigh more, by `2 − max`: measured against Photoshop 2025's presets on 126 colours each for +/// Reds, Yellows and Blues (hue ±40°, saturation 5..100 %, brightness 33..100 %), this matches +/// its selection to within one level, where chroma alone fell up to 61 levels short (#5243). pub fn hue_range(px: &[[f32; 4]], center: f32) -> Vec { px.iter() .map(|&[r, g, b, a]| { if a <= 0.0 { return 0.0; } - let (h, chroma) = hue_chroma([r, g, b].map(|v| v.clamp(0.0, 1.0))); + let rgb = [r, g, b].map(|v| v.clamp(0.0, 1.0)); + let (h, chroma) = hue_chroma(rgb); + let max = rgb[0].max(rgb[1]).max(rgb[2]); let dh = (h - center).rem_euclid(360.0); let dh = dh.min(360.0 - dh); - ((1.0 - dh / 60.0).clamp(0.0, 1.0) * chroma).clamp(0.0, 1.0) + ((1.0 - dh / 60.0).clamp(0.0, 1.0) * chroma * (2.0 - max)).clamp(0.0, 1.0) }) .collect() } @@ -1180,6 +1185,17 @@ mod tests { assert!((hue_range(&[[1.0, 0.0, 0.5, 1.0]], 300.0)[0] - 0.5).abs() < 1e-5); } + #[test] + fn hue_families_weigh_dark_colours_like_photoshop() { + // Photoshop 2025's Blues on an 8-bit sRGB document (selection levels 0..255, #5243): the + // muted navy #3d3f54, a dark blue (0, 0, 84) and a mid blue (61, 61, 153). + let c = |r: u8, g: u8, b: u8| [f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0, 1.0]; + let blues = hue_range(&[c(0x3d, 0x3f, 0x54), c(0, 0, 84), c(61, 61, 153)], 240.0); + for (got, want) in blues.iter().zip([35.0, 140.0, 129.0]) { + assert!((got * 255.0 - want).abs() <= 1.0, "{} vs Photoshop {want}", got * 255.0); + } + } + #[test] fn tonal_ranges() { let g = |v: f32| [v / 255.0, v / 255.0, v / 255.0, 1.0]; diff --git a/crates/algo/src/stack.rs b/crates/algo/src/stack.rs index 2eec57e598..dbb5b22e65 100644 --- a/crates/algo/src/stack.rs +++ b/crates/algo/src/stack.rs @@ -23,85 +23,184 @@ 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() +} + +/// `x` to the power `k` by repeated squaring. Every step is one IEEE 754 multiplication, so the +/// result has the same bits on every target. On Windows (MSVC), `f32::powi` with an exponent the +/// compiler cannot see as a constant calls the C runtime's `powf`, which rounds differently. +fn ipow(x: f32, k: u32) -> f32 { + let (mut base, mut k, mut acc) = (x, k, 1.0f32); + loop { + if k & 1 != 0 { + acc *= base; + } + k >>= 1; + if k == 0 { + return acc; + } + base *= base; + } +} + +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; + let m = |k: u32| v.iter().map(|x| ipow(x - mean, 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 => { let sd = m(2).sqrt(); // Map −2..2 to 0..1 (0.5 = symmetric). - if sd < 1e-6 { 0.5 } else { (m(3) / sd.powi(3) / 4.0 + 0.5).clamp(0.0, 1.0) } + if sd < 1e-6 { 0.5 } else { (m(3) / ipow(sd, 3) / 4.0 + 0.5).clamp(0.0, 1.0) } } Stat::Kurtosis => { let var = m(2); // 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]> { + // Pick the statistic once, outside the pixel loop: each arm passes `stat` a constant mode, so + // the compiler builds one loop per statistic with the `match` folded away. Matching on `s` + // three times per pixel inside one shared loop made one thread 5–15% slower than the old + // serial loop (Mean, Variance, StdDev, Skewness, Kurtosis); this alone cuts Mean's + // instructions by a fifth. + match s { + Stat::Entropy => combine_with(layers, |v, sc| stat(v, Stat::Entropy, sc)), + Stat::Kurtosis => combine_with(layers, |v, sc| stat(v, Stat::Kurtosis, sc)), + Stat::Maximum => combine_with(layers, |v, sc| stat(v, Stat::Maximum, sc)), + Stat::Mean => combine_with(layers, |v, sc| stat(v, Stat::Mean, sc)), + Stat::Median => combine_with(layers, |v, sc| stat(v, Stat::Median, sc)), + Stat::Minimum => combine_with(layers, |v, sc| stat(v, Stat::Minimum, sc)), + Stat::Range => combine_with(layers, |v, sc| stat(v, Stat::Range, sc)), + Stat::Skewness => combine_with(layers, |v, sc| stat(v, Stat::Skewness, sc)), + Stat::StandardDeviation => combine_with(layers, |v, sc| stat(v, Stat::StandardDeviation, sc)), + Stat::Summation => combine_with(layers, |v, sc| stat(v, Stat::Summation, sc)), + Stat::Variance => combine_with(layers, |v, sc| stat(v, Stat::Variance, sc)), + } +} + +fn combine_with(layers: &[Vec<[f32; 4]>], f: impl Fn(&mut [f32], &mut EntropyScratch) -> f32 + Sync + Send) -> 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]); + // 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. + // Sized to the layer count up front and filled by index (`k` samples per pixel): a + // `push` per sample re-checked the capacity and stored the length every time; indexing + // cuts the loop's instructions by another third. + let mut samples: [Vec; 3] = [0, 1, 2].map(|_| vec![0.0; layers.len()]); + let mut scratch = EntropyScratch::new(); + let base = chunk * CHUNK; + for (j, o) in outs.iter_mut().enumerate() { + let i = base + j; + let mut k = 0; + 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][k] = p[c]; + } + k += 1; + alpha = alpha.max(p[3]); } - alpha = alpha.max(p[3]); } + if alpha <= 0.0 { + continue; + } + for c in 0..3 { + o[c] = f(&mut samples[c][..k], &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 +267,246 @@ 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()); + } + + /// [`ipow`] multiplies in the order `x·x²` for the cube and `x²·x²` for the fourth power. + #[test] + fn ipow_squares_and_multiplies() { + for x in [0.58f32, -0.12, -2.6, 2.4, -0.6, 1.0e-3, 0.0] { + assert_eq!(ipow(x, 0).to_bits(), 1.0f32.to_bits()); + assert_eq!(ipow(x, 1).to_bits(), x.to_bits()); + assert_eq!(ipow(x, 2).to_bits(), (x * x).to_bits()); + assert_eq!(ipow(x, 3).to_bits(), (x * (x * x)).to_bits()); + assert_eq!(ipow(x, 4).to_bits(), ((x * x) * (x * x)).to_bits()); + } + } } diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index 9a90012df9..b7fc92113c 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -90,6 +90,27 @@ fn radial_blur_quality_defaults_for_older_serialized_parameters() { assert_eq!(decoded, FilterParams::RadialBlur { amount: 20.0, method: RadialMethod::Spin, quality: RadialQuality::Good, center_x: 0.5, center_y: 0.5 }); } +#[test] +fn nonpositive_tile_sizes_match_one_pixel_tiles() { + let area = Rect::new(0, 0, 4, 3); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let source = pattern(depth, area); + for params in all_filters().into_iter().chain([FilterParams::ShapeBlur { radius: 3.0, shape: BlurShape::Star }]) { + let expected = apply_tiled(&source, ¶ms, area, area, None, 1, Some(area)); + for tile in [0, -64, i32::MIN] { + let actual = apply_tiled(&source, ¶ms, area, area, None, tile, Some(area)); + assert_eq!(actual.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label()); + let cancellable = apply_tiled_with(&source, ¶ms, area, area, None, tile, Some(area), &photocraft_raster::Interrupt::NONE) + .expect("a nonpositive tile size is normalized, not rejected"); + assert_eq!(cancellable.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label()); + let cancelled = photocraft_raster::Interrupt::cancel_only(&|| true); + assert!(apply_tiled_with(&source, ¶ms, area, area, None, tile, Some(area), &cancelled).is_none()); + } + } + assert_eq!(source.read_region(area), pattern(depth, area).read_region(area)); + } +} + #[test] fn every_filter_is_tile_independent() { let s = pattern(SampleType::U8, R); @@ -248,6 +269,32 @@ fn minimum_maximum_spread() { assert!((mn.pixel(10, 10)[0] - 0.5).abs() < 1e-6); } +/// CMYK samples are ink, so Maximum spreads paper white and Minimum spreads ink, as Photoshop +/// does: Maximum r 1 removes a 1-px C 100% line on white, Minimum widens it to 3 px. Alpha keeps +/// the usual polarity (Maximum grows opaque areas, Minimum grows transparent ones). +#[test] +fn minimum_maximum_treat_cmyk_as_ink() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut s = Surface::new(PixelFormat::new(ColorMode::Cmyk, depth, true)); + s.fill_rect(R, &[0.0, 0.0, 0.0, 0.0, 1.0]); + s.fill_rect(Rect::new(20, 0, 21, 30), &[1.0, 0.0, 0.0, 0.0, 1.0]); + s.write_pixel(5, 5, &[0.0, 0.0, 0.0, 0.0, 0.0]); + for preserve in [Preserve::Squareness, Preserve::Roundness] { + let mx = run(&s, &FilterParams::Maximum { radius: 1.0, preserve }); + for x in 17..24 { + assert!(mx.pixel(x, 15)[0].abs() < 1e-6, "{depth:?} {preserve:?} Maximum C at x {x}: {:?}", mx.pixel(x, 15)); + } + assert!((mx.pixel(5, 5)[4] - 1.0).abs() < 1e-6, "{depth:?} {preserve:?} Maximum grows alpha"); + let mn = run(&s, &FilterParams::Minimum { radius: 1.0, preserve }); + for x in 17..24 { + let want = if (19..=21).contains(&x) { 1.0 } else { 0.0 }; + assert!((mn.pixel(x, 15)[0] - want).abs() < 1e-6, "{depth:?} {preserve:?} Minimum C at x {x}: {:?}", mn.pixel(x, 15)); + } + assert!(mn.pixel(6, 5)[4].abs() < 1e-6, "{depth:?} {preserve:?} Minimum shrinks alpha"); + } + } +} + #[test] fn maximum_roundness_grows_a_point_into_a_disc() { // One white pixel on black: Maximum spreads it over the structuring element itself. @@ -414,6 +461,24 @@ fn twirl_keeps_centre_and_outside() { assert_ne!(out.pixel(20, 12), s.pixel(20, 12)); } +/// #5643: on a wide image Twirl, Pinch and Spherize fill the ellipse inscribed in the bounds, as +/// Photoshop's do, not just a circle fitted to the height: a point near the left and right edges +/// on the middle row changes, the corners don't, and a square still uses its inscribed circle. +#[test] +fn round_distortions_fill_the_inscribed_ellipse() { + let b = Rect::new(0, 0, 80, 40); + let s = pattern(SampleType::F32, b); + for p in [FilterParams::Twirl { angle: 180.0 }, FilterParams::Pinch { amount: 80.0 }, FilterParams::Spherize { amount: 80.0, mode: SpherizeMode::Normal }] { + let out = apply(&s, &p, b, b, None); + // 30 px left of centre on the middle row: outside the old 20 px circle, inside the ellipse. + assert_ne!(out.pixel(10, 21), s.pixel(10, 21), "{p:?}"); + assert_ne!(out.pixel(69, 18), s.pixel(69, 18), "{p:?}"); + for (x, y) in [(0, 0), (79, 0), (0, 39), (79, 39)] { + assert_eq!(out.pixel(x, y), s.pixel(x, y), "{p:?}: corner ({x}, {y}) is outside the ellipse"); + } + } +} + #[test] fn motion_blur_spreads_along_angle_only() { let mut s = flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]); @@ -446,6 +511,10 @@ fn halos_and_output_area() { assert_eq!(output_area(&FilterParams::Twirl { angle: 1.0 }, content, R, None), R); assert_eq!(output_area(&FilterParams::BoxBlur { radius: 1.0 }, content, R, Some(Rect::new(0, 0, 12, 12))), Rect::new(8, 8, 12, 12)); assert!(output_area(&FilterParams::Invert, Rect::EMPTY, R, None).is_empty()); + // Fibers renders without reading the layer: an empty layer still fills the bounds (#5241). + let fibers = FilterParams::Fibers { variance: 16.0, strength: 4.0, seed: 0, foreground: [0.0; 4], background: [1.0; 4] }; + assert_eq!(output_area(&fibers, Rect::EMPTY, R, None), R); + assert_eq!(output_area(&fibers, content, R, Some(Rect::new(0, 0, 12, 12))), Rect::new(0, 0, 12, 12)); } #[test] diff --git a/crates/algo/src/tests_ext.rs b/crates/algo/src/tests_ext.rs index fd35f2742a..7c28485f1f 100644 --- a/crates/algo/src/tests_ext.rs +++ b/crates/algo/src/tests_ext.rs @@ -435,6 +435,35 @@ fn lens_flare_is_brightest_at_its_centre() { assert!(out.pixel(38, 2)[0] >= 0.2 - 1e-6, "screen never darkens"); } +#[test] +fn lens_flare_never_darkens_32_bit_values_above_one() { + // #5785: the screen formula turned 2.0 into 1.5 (and 1.0 at the core). + let p = FilterParams::LensFlare { brightness: 150.0, center_x: 0.25, center_y: 0.5, lens: LensType::Zoom }; + let out = run(&flat(SampleType::F32, R, [2.0, 2.0, 2.0, 1.0]), &p); + for px in out.read_region(R).as_chunks::<4>().0 { + assert!(px[..3].iter().all(|&v| v >= 2.0), "{px:?}"); + } +} + +#[test] +fn lens_flare_8_bit_result_is_the_screen_blend() { + // The flare amount per pixel, read from a black 32-bit image (screen over 0 is the amount). + let p = FilterParams::LensFlare { brightness: 150.0, center_x: 0.25, center_y: 0.5, lens: LensType::Zoom }; + let amount = run(&flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]), &p).read_region(R); + let src = pattern(SampleType::U8, R); + let base = src.read_region(R); + let expected: Vec = base + .as_chunks::<4>() + .0 + .iter() + .zip(amount.as_chunks::<4>().0) + .flat_map(|(c, a)| [0, 1, 2].map(|k| 1.0 - (1.0 - c[k]) * (1.0 - a[k].min(1.0))).into_iter().chain([c[3]])) + .collect(); + let mut want = Surface::new(fmt(SampleType::U8)); + want.write_region(R, &expected); + assert_eq!(run(&src, &p).read_region(R), want.read_region(R)); +} + #[test] fn lighting_point_light_falls_off() { let s = flat(SampleType::F32, R, [0.8, 0.8, 0.8, 1.0]); @@ -608,6 +637,28 @@ fn custom_kernel_shifts_and_offsets() { assert!((off.pixel(5, 5)[0] - 0.4).abs() < 1e-5); } +/// CMYK samples are ink: Custom's positive offset brightens (removes ink), as in Photoshop +/// (identity, scale 1, offset +50: white stays white, C=100% becomes C≈80%). Alpha is untouched. +/// Photoshop's CMYK depths; float is unclamped on both polarities (RGB white + offset exceeds 1). +#[test] +fn custom_offset_brightens_cmyk() { + let mut id = vec![0.0; 25]; + id[12] = 1.0; + let p = FilterParams::Custom { kernel: id, scale: 1.0, offset: 50.0 }; + for depth in [SampleType::U8, SampleType::U16] { + let f = PixelFormat::new(ColorMode::Cmyk, depth, true); + let mut s = Surface::new(f); + s.fill_rect(R, &[0.0, 0.0, 0.0, 0.0, 0.6]); + let w = run(&s, &p).pixel(5, 5); + assert!(w[..4].iter().all(|v| v.abs() < 1e-6) && (w[4] - 0.6).abs() < 0.01, "{depth:?} white: {w:?}"); + let mut s = Surface::new(f); + s.fill_rect(R, &[1.0, 0.5, 0.0, 0.0, 1.0]); + let c = run(&s, &p).pixel(5, 5); + let want = [1.0 - 50.0 / 255.0, 0.5 - 50.0 / 255.0, 0.0, 0.0, 1.0]; + assert!(c.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{depth:?} C100: {c:?}"); + } +} + #[test] fn hsb_filter_encodes_and_decodes() { let s = flat(SampleType::F32, R, [1.0, 0.0, 0.0, 1.0]); diff --git a/crates/algo/src/tone.rs b/crates/algo/src/tone.rs index 2632d4a29a..5a0f752fac 100644 --- a/crates/algo/src/tone.rs +++ b/crates/algo/src/tone.rs @@ -65,6 +65,35 @@ pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue: mask } +/// Replace Color's Result swatch: `color` after the hue (±180°), saturation and lightness (±100) +/// shift, as a fully matched pixel of that colour becomes. +pub fn replace_color_result(color: [f32; 3], hue: f32, saturation: f32, lightness: f32) -> [f32; 3] { + let mut px = [[color[0], color[1], color[2], 1.0]]; + replace_color_masked(&mut px, &[1.0], hue, saturation, lightness); + [px[0][0], px[0][1], px[0][2]] +} + +/// The hue (±180°), saturation and lightness (±100) shift that turns `from` into `to`, as +/// Photoshop's Result Color Picker sets the sliders. A shift the sliders can't reach (a grey has +/// no hue to turn, black or white no saturation to give) goes as far as they do. +pub fn replace_color_shift(from: [f32; 3], to: [f32; 3]) -> (f32, f32, f32) { + let [fh, fs, fl] = rgb_to_hsl(from); + let [th, ts, tl] = rgb_to_hsl(to); + // Hue turns the short way round; a grey target keeps the hue where it is. + let hue = if ts <= 0.0 { 0.0 } else { ((th - fh + 0.5).rem_euclid(1.0) - 0.5) * 360.0 }; + // Inverse of the shift in `replace_color_masked`: up moves toward 1, down toward 0. + let amount = |from: f32, to: f32| { + if to >= from { + if from >= 1.0 { 0.0 } else { (to - from) / (1.0 - from) * 100.0 } + } else if from <= 0.0 { + 0.0 + } else { + (to / from - 1.0) * 100.0 + } + }; + (hue.clamp(-180.0, 180.0), amount(fs, ts).clamp(-100.0, 100.0), amount(fl, tl).clamp(-100.0, 100.0)) +} + /// Replace Color's hue, saturation and lightness shift, applied to each pixel as far as `mask` /// (`0..=1`, one per pixel: the colour match, localized or not) covers it. pub fn replace_color_masked(px: &mut [[f32; 4]], mask: &[f32], hue: f32, saturation: f32, lightness: f32) { @@ -277,6 +306,28 @@ pub fn hdr_toning(px: &mut [[f32; 4]], w: usize, h: usize, p: &HdrToning) { mod tests { use super::*; + /// #6278: the Result swatch is the sampled colour after the shift, and a picked Result sets + /// the sliders that give it. + #[test] + fn replace_color_result_and_shift_round_trip() { + let from = [0.8, 0.2, 0.2]; + let near = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 2e-3); + assert!(near(replace_color_result(from, 0.0, 0.0, 0.0), from), "no shift, no change"); + for to in [[0.2, 0.3, 0.9], [0.9, 0.85, 0.1], [0.4, 0.1, 0.1], [0.95, 0.6, 0.6], [0.1, 0.6, 0.3]] { + let (h, s, l) = replace_color_shift(from, to); + assert!((-180.0..=180.0).contains(&h) && (-100.0..=100.0).contains(&s) && (-100.0..=100.0).contains(&l)); + assert!(near(replace_color_result(from, h, s, l), to), "{to:?}: ({h}, {s}, {l}) gives {:?}", replace_color_result(from, h, s, l)); + } + // Red to blue turns the short way (−120°, not +240°). + assert!((replace_color_shift([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]).0 + 120.0).abs() < 0.5); + // A grey target: saturation all the way down, the hue left alone. + let (h, s, _) = replace_color_shift(from, [0.5, 0.5, 0.5]); + assert_eq!((h, s), (0.0, -100.0)); + // Black has no saturation to raise: the slider stays put rather than dividing by zero. + let (h, s, l) = replace_color_shift([0.0, 0.0, 0.0], [0.2, 0.6, 0.3]); + assert!(h.is_finite() && s.is_finite() && l > 0.0); + } + fn ramp(w: usize, h: usize) -> Vec<[f32; 4]> { (0..w * h) .map(|i| { diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 7d1dd0fe6e..91260bd285 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -231,14 +231,37 @@ pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option { /// The destination rectangle [`warp_surface`] fills for `src_rect` under `h`: the warped corners /// plus a pixel for filter support, each side clamped to ±2²⁰. `None` when a corner maps to -/// infinity (the result is then empty). Lets a caller price a warp before allocating it. +/// infinity (the result is then empty). Use [`warp_output_size`] to price the full result before +/// admitting a transform: clipping these bounds must not hide an excessive scale. pub fn warp_output_bounds(src_rect: Rect, h: &Homography) -> Option { + corner_bounds(&warp_corners(src_rect, h)?) +} + +/// Unclipped output dimensions, including the same filter support as [`warp_output_bounds`]. +/// Position does not affect the price, so translating a small layer far away stays cheap. +/// Unrepresentable or non-finite dimensions saturate at `u64::MAX`, allowing a caller to refuse +/// the warp with a saturating pixel-budget comparison. Empty and singular inputs have no output. +pub fn warp_output_size(src_rect: Rect, h: &Homography) -> Option<(u64, u64)> { + let corners = warp_corners(src_rect, h)?; + if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite()) { + return Some((u64::MAX, u64::MAX)); + } + let x0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor(); + let y0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor(); + let x1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil(); + let y1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil(); + // Float-to-integer casts saturate, including when the finite endpoints' difference overflows. + Some((((x1 - x0) as u64).saturating_add(2), ((y1 - y0) as u64).saturating_add(2))) +} + +fn warp_corners(src_rect: Rect, h: &Homography) -> Option<[(f64, f64); 4]> { if src_rect.is_empty() || h.inverse().is_none() { return None; } - let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)] - .map(|(x, y)| h.apply(x as f64, y as f64)); - corner_bounds(&corners) + Some( + [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)] + .map(|(x, y)| h.apply(x as f64, y as f64)), + ) } fn corner_bounds(corners: &[(f64, f64); 4]) -> Option { @@ -281,7 +304,7 @@ fn warp( // that matters is the one at the source point landing in the clip's centre. let (cx, cy) = match clip.and_then(|c| Some((c, h.inverse()?))) { Some((c, inv)) => inv.apply((c.x0 as f64 + c.x1 as f64) / 2.0, (c.y0 as f64 + c.y1 as f64) / 2.0), - None => ((src_rect.x0 + src_rect.x1) as f64 / 2.0, (src_rect.y0 + src_rect.y1) as f64 / 2.0), + None => ((f64::from(src_rect.x0) + f64::from(src_rect.x1)) / 2.0, (f64::from(src_rect.y0) + f64::from(src_rect.y1)) / 2.0), }; let scale = h.local_scale(cx, cy); let reduced; @@ -310,7 +333,12 @@ fn warp( // A source straddling the horizon draws only its front side: the inverse w at a pixel has the // sign of the forward w at the source point it samples, and the other side would show as a // mirrored copy wrapped round infinity (#4863). - let front = side.is_none().then(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64])); + // A clip wholly on one side of the inverse horizon only samples that side, so that side is drawn + // (a Perspective Crop low on a converging photo has most of the layer beyond its horizon, #7557). + let clip_w = clip.map(|c| [(c.x0, c.y0), (c.x1, c.y0), (c.x1, c.y1), (c.x0, c.y1)].map(|(x, y)| inv.w(x as f64, y as f64))); + let clip_side = clip_w.and_then(|cw| [1.0, -1.0].into_iter().find(|&s| cw.iter().all(|&w| s * w > 0.0))); + let front = + side.is_none().then(|| clip_side.unwrap_or_else(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64]))); let bounds = corner_bounds(&corners); let dst = match (clip, bounds) { // The warped corners only bound the result while the whole source is in front of the horizon. @@ -366,10 +394,10 @@ fn warp( return Ok(None); } let fp: Vec<(f64, f64)> = poly.iter().map(|&(x, y)| inv.apply(x, y)).collect(); - let fx0 = fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32 - 3; - let fy0 = fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32 - 3; - let fx1 = fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32 + 3; - let fy1 = fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32 + 3; + let fx0 = (fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3); + let fy0 = (fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3); + let fx1 = (fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3); + let fy1 = (fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3); // Without a one-signed forward w, a tile straddling the inverse horizon has no bounded footprint. let straddles = side.is_none() && wc.iter().any(|w| *w <= 0.0) && wc.iter().any(|w| *w >= 0.0); let foot = if straddles { src_rect } else { Rect::new(fx0, fy0, fx1, fy1).intersect(&src_rect) }; @@ -392,6 +420,15 @@ fn warp( px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c] as f64 } }; + // Samples past the source rect repeat its edge, so a stretched edge row or column keeps its + // colour and opacity (the "pixel stretch", #3773); the outline is antialiased by coverage + // instead. `foot_u`/`foot_v` are a destination pixel's extent in source pixels. + let clamped = |x: i32, y: i32, c: usize| at(x.clamp(src_rect.x0, src_rect.x1 - 1), y.clamp(src_rect.y0, src_rect.y1 - 1), c); + let (mx, my) = ((t.x0 + t.x1) as f64 / 2.0, (t.y0 + t.y1) as f64 / 2.0); + let (o, dx, dy) = (inv.apply(mx, my), inv.apply(mx + 1.0, my), inv.apply(mx, my + 1.0)); + let extent = |e: f64| if e.is_finite() && e > 1e-12 { e } else { 1.0 }; + let foot_u = extent((dx.0 - o.0).abs() + (dy.0 - o.0).abs()); + let foot_v = extent((dx.1 - o.1).abs() + (dy.1 - o.1).abs()); let w = t.width() as usize; let len = w.checked_mul(t.height() as usize).and_then(|v| v.checked_mul(n)).ok_or(photocraft_raster::AllocationError::SizeOverflow)?; let bytes = len.checked_mul(std::mem::size_of::()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?; @@ -424,7 +461,7 @@ fn warp( for (dy, wy) in [(0, 1.0 - fy), (1, fy)] { for (dx, wx) in [(0, 1.0 - fx), (1, fx)] { for (c, s) in acc.iter_mut().enumerate().take(n) { - *s += at(ix + dx, iy + dy, c) * wx * wy; + *s += clamped(ix + dx, iy + dy, c) * wx * wy; } } } @@ -436,12 +473,21 @@ fn warp( for (i, wxi) in wx.iter().enumerate() { let k = wxi * wyj; for (c, s) in acc.iter_mut().enumerate().take(n) { - *s += at(ix - 1 + i as i32, iy - 1 + j as i32, c) * k; + *s += clamped(ix - 1 + i as i32, iy - 1 + j as i32, c) * k; } } } } } + if interp != Interp::Nearest { + // How much of this pixel the warped source rect covers, per axis. + let (uc, vc) = (u + 0.5, v + 0.5); + let cu = ((uc - src_rect.x0 as f64).min(src_rect.x1 as f64 - uc) / foot_u + 0.5).clamp(0.0, 1.0); + let cv = ((vc - src_rect.y0 as f64).min(src_rect.y1 as f64 - vc) / foot_v + 0.5).clamp(0.0, 1.0); + for s in acc.iter_mut().take(n) { + *s *= cu * cv; + } + } let al = acc[a].clamp(0.0, 1.0); if al <= 0.0 { continue; @@ -492,6 +538,31 @@ fn warp( mod tests { use super::*; + #[test] + fn output_size_prices_the_unclipped_extent_independently_of_position() { + let src = Rect::new(0, 0, 24, 16); + for dx in [0.0, 900_000.0, 10_000_000.0] { + let h = Homography([1.0, 0.0, dx, 0.0, 1.0, -dx, 0.0, 0.0, 1.0]); + assert_eq!(warp_output_size(src, &h), Some((26, 18))); + } + for sx in [1e7, -1e7] { + let h = Homography([sx, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]); + assert_eq!(warp_output_size(src, &h), Some((240_000_002, 18))); + } + } + + #[test] + fn output_size_saturates_overflows_and_preserves_filter_support() { + let src = Rect::new(0, 0, 24, 16); + let fractional = Homography([0.5, 0.0, 0.25, 0.0, 1.0, 0.75, 0.0, 0.0, 1.0]); + let bounds = warp_output_bounds(src, &fractional).unwrap(); + assert_eq!(warp_output_size(src, &fractional), Some((u64::from(bounds.width()), u64::from(bounds.height())))); + let huge = Homography([1e308, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]); + assert_eq!(warp_output_size(src, &huge), Some((u64::MAX, u64::MAX))); + assert_eq!(warp_output_size(Rect::EMPTY, &Homography::IDENTITY), None); + assert_eq!(warp_output_size(src, &Homography([0.0; 9])), None); + } + #[test] fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() { let mut s = Surface::new(PixelFormat::RGBA8); @@ -539,6 +610,24 @@ mod tests { Surface::new(PixelFormat::RGBA8) } + /// The "pixel stretch" (#3773): a 1 px column scaled 100x stays solid across the stretch, as in + /// Photoshop. Samples past the source edge repeat the edge, and only the outline is antialiased. + #[test] + fn stretched_single_column_stays_opaque() { + let mut s = rgba(); + s.fill_rect(Rect::new(0, 0, 1, 4), &[0.0, 1.0, 0.0, 1.0]); + for interp in [Interp::Bicubic, Interp::Bilinear] { + let h = Homography::rect_to_quad([0.0, 0.0, 1.0, 4.0], [[0.0, 0.0], [100.0, 0.0], [100.0, 4.0], [0.0, 4.0]]).unwrap(); + let w = warp_surface(&s, Rect::new(0, 0, 1, 4), &h, interp).unwrap(); + assert_eq!(w.content_bounds(), Rect::new(0, 0, 100, 4), "{interp:?}"); + for x in [0, 1, 25, 50, 75, 99] { + for y in 0..4 { + assert!((w.sample_channel(x, y, 3) - 1.0).abs() < 1e-6, "{interp:?} alpha at ({x}, {y}) = {}", w.sample_channel(x, y, 3)); + } + } + } + } + /// Photoshop 27.11's Free Transform, Bicubic, 200 % about the centre of a 64 px image whose left /// half is grey 64 and right half grey 192: the row across the edge, columns 26..36. #[test] @@ -629,6 +718,22 @@ mod tests { assert_eq!(ok.front_side([0.0, 0.0, 100.0, 100.0]), ok.w(50.0, 50.0).signum()); } + /// #7557: a Perspective Crop quad low on a strongly converging photo puts the horizon across + /// the layer with most of it (and its centre) on the far side. The clip maps wholly into the + /// quad, so its side is the one drawn, not the source's majority. + #[test] + fn a_clip_wholly_on_one_side_draws_that_side() { + let mut s = rgba(); + s.fill_rect(Rect::new(0, 0, 100, 100), &[1.0, 0.0, 0.0, 1.0]); + let q = [[40.0, 70.0], [60.0, 70.0], [90.0, 95.0], [10.0, 95.0]]; + let h = quad_to_rect(&q, 50.0, 39.0).unwrap(); + assert!(h.front_side([0.0, 0.0, 100.0, 100.0]) * h.w(50.0, 80.0) < 0.0, "the source's majority is behind the quad's side"); + for interp in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] { + let w = warp_surface_clipped(&s, s.content_bounds(), &h, interp, Rect::new(0, 0, 50, 39)).unwrap(); + assert_eq!(w.rgba(25, 20), [1.0, 0.0, 0.0, 1.0], "{interp:?}"); + } + } + #[test] fn identity_and_translation_are_exact() { let mut s = rgba(); diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs index 01c54e7643..54a6c7c868 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -4,16 +4,19 @@ //! The source rectangle is subdivided into a fine grid whose vertices are mapped forward; each //! cell's two triangles are rasterized in destination space with the source position //! interpolated barycentrically, then the source is sampled there (premultiplied, nearest / -//! bilinear / Photoshop's Keys bicubic, like Free Transform). Cells are a few source pixels across, so -//! the piecewise-affine inverse is within a small fraction of a pixel of the true one; affine +//! bilinear / Photoshop's Keys bicubic, like Free Transform). Cells are a few pixels across in +//! both the source and the destination (a thin layer stretched far gets as many cells as its +//! warped size needs), so the piecewise-affine inverse is within a small fraction of a pixel of +//! the true one; affine //! maps (the identity included) are reproduced exactly. Where the warp shrinks the source, each //! pixel instead averages a grid of bilinear subsamples over its source footprint (about one per //! source pixel), so thin lines don't alias. Where the warp folds over itself the //! later (lower-right) cell wins. Work is split per destination tile (parallel on native). use photocraft_color::PixelFormat; -use photocraft_geom::Rect; -use photocraft_raster::Surface; +use photocraft_geom::{Rect, TileCoord}; +use photocraft_raster::{Surface, Tile}; +use std::sync::Arc; use crate::transform::{Interp, bicubic}; @@ -77,8 +80,11 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> return Surface::new(PixelFormat::new(fmt.mode, fmt.sample, true)); } let (sw, sh) = (f64::from(src_rect.width()), f64::from(src_rect.height())); - let nx = ((sw / CELL).ceil() as usize).clamp(1, MAX_CELLS); - let ny = ((sh / CELL).ceil() as usize).clamp(1, MAX_CELLS); + // Cells are a few pixels across on both sides of the map: a thin source stretched far (a + // 3 px column scaled into a wide band, then bent) needs many cells, or it comes out straight. + let (lu, lv) = mapped_lengths(src_rect, f); + let cells = |s: f64, d: f64| ((s.max(d) / CELL).ceil() as usize).clamp(1, MAX_CELLS); + let (nx, ny) = (cells(sw, lu), cells(sh, lv)); // Forward-mapped vertices (and their source positions). let mut verts = Vec::with_capacity((nx + 1) * (ny + 1)); for j in 0..=ny { @@ -101,6 +107,43 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> warp_triangles(src, src_rect, &verts, &tris, interp) } +/// Probe lines per axis [`mapped_lengths`] follows through the map. +const PROBE: usize = 16; + +/// The longest destination length of a source row and of a source column under `f`, measured +/// along polylines through a [`PROBE`]-step grid. Non-finite steps count as zero. +fn mapped_lengths(src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync)) -> (f64, f64) { + let (x0, y0) = (f64::from(src_rect.x0), f64::from(src_rect.y0)); + let (sw, sh) = (f64::from(src_rect.width()), f64::from(src_rect.height())); + let n = PROBE + 1; + let mut pts = Vec::with_capacity(n * n); + for j in 0..n { + for i in 0..n { + let (dx, dy) = f(x0 + sw * i as f64 / PROBE as f64, y0 + sh * j as f64 / PROBE as f64); + pts.push([dx, dy]); + } + } + let step = |a: [f64; 2], b: [f64; 2]| { + let d = (b[0] - a[0]).hypot(b[1] - a[1]); + if d.is_finite() { d } else { 0.0 } + }; + let (mut lu, mut lv) = (0.0f64, 0.0f64); + for k in 0..n { + let (mut row, mut col) = (0.0, 0.0); + for m in 1..n { + if let (Some(&a), Some(&b)) = (pts.get(k * n + m - 1), pts.get(k * n + m)) { + row += step(a, b); + } + if let (Some(&a), Some(&b)) = (pts.get((m - 1) * n + k), pts.get(m * n + k)) { + col += step(a, b); + } + } + lu = lu.max(row); + lv = lv.max(col); + } + (lu, lv) +} + /// Rasterizes textured triangles: each vertex is `(destination, source)` in document pixels and /// each triangle samples `src` (restricted to `src_rect`) through the barycentric /// interpolation of its source positions. Later triangles in `tris` draw over earlier ones (the @@ -165,7 +208,7 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 } let n = fmt.channels(); let a = n - 1; - let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(Rect, Vec)> { + let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(TileCoord, Arc)> { if tris.is_empty() { return None; } @@ -254,6 +297,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 f64::from(px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c]) } }; + // Offset of pixel (x, y) when the `taps` x `taps` block from it lies inside `foot`, so + // the samplers can index `px` directly instead of bounds-checking every tap. + let base = |x: i32, y: i32, taps: i32| -> Option { + (x >= foot.x0 && y >= foot.y0 && x + taps <= foot.x1 && y + taps <= foot.y1).then(|| ((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n) + }; let mut outp = vec![0.0f32; w * h * n]; let mut any = false; let mut acc = [0.0f64; 8]; @@ -271,6 +319,17 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 Interp::Bilinear => { let (ix, iy) = (u.floor() as i32, v.floor() as i32); let (fx, fy) = (u - f64::from(ix), v - f64::from(iy)); + if let Some(o) = base(ix, iy, 2) { + for (dy, wy) in [(0, 1.0 - fy), (1, fy)] { + for (dx, wx) in [(0, 1.0 - fx), (1, fx)] { + let p = &px[o + (dy * fw + dx) * n..][..n]; + for (s, v) in acc.iter_mut().zip(p) { + *s += f64::from(*v) * wx * wy; + } + } + } + return; + } for (dy, wy) in [(0, 1.0 - fy), (1, fy)] { for (dx, wx) in [(0, 1.0 - fx), (1, fx)] { for (c, s) in acc.iter_mut().enumerate().take(n) { @@ -285,6 +344,21 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 let (u, v) = (snap(u), snap(v)); let (ix, iy) = (u.floor() as i32, v.floor() as i32); let (wx, wy) = (bicubic(u - f64::from(ix)), bicubic(v - f64::from(iy))); + if let Some(o) = base(ix - 1, iy - 1, 4) { + for (j, wyj) in wy.iter().enumerate() { + for (i, wxi) in wx.iter().enumerate() { + let k = wxi * wyj; + if k == 0.0 { + continue; + } + let p = &px[o + (j * fw + i) * n..][..n]; + for (s, v) in acc.iter_mut().zip(p) { + *s += f64::from(*v) * k; + } + } + } + return; + } for (j, wyj) in wy.iter().enumerate() { for (i, wxi) in wx.iter().enumerate() { let k = wxi * wyj; @@ -336,19 +410,27 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 } outp[b + a] = al as f32; } - any.then_some((*t, outp)) + if !any { + return None; + } + // Encoded (and dropped if fully transparent) here, in parallel, rather than one tile at + // a time once all are done. `t` lies in one tile. + let mut one = Surface::new(fmt); + one.write_region(*t, &outp); + one.prune(); + if one.tile_count() == 0 { + return None; + } + one.take_tiles(*t).pop() }; #[cfg(not(target_arch = "wasm32"))] - let done: Vec<(Rect, Vec)> = { + let done: Vec<(TileCoord, Arc)> = { use rayon::prelude::*; tiles.par_iter().zip(bins.par_iter()).filter_map(work).collect() }; #[cfg(target_arch = "wasm32")] - let done: Vec<(Rect, Vec)> = tiles.iter().zip(bins.iter()).filter_map(work).collect(); - for (r, v) in done { - out.write_region(r, &v); - } - out.prune(); + let done: Vec<(TileCoord, Arc)> = tiles.iter().zip(bins.iter()).filter_map(work).collect(); + out.put_tiles(done); out } @@ -399,6 +481,28 @@ mod tests { surf } + #[test] + fn a_thin_layer_stretched_into_a_curve_keeps_the_curve() { + // #5793: a 3 px wide column stretched 100x and bent into an arch. The grid was sized in + // source pixels (one cell across), so the arch came out as a straight band. + let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); + src.fill_rect(Rect::new(0, 0, 3, 40), &[1.0, 0.0, 0.0, 1.0]); + let arch = |x: f64, y: f64| { + let dx = x * 100.0; + let t = (dx - 150.0) / 150.0; + (dx, y + 40.0 * (1.0 - t * t)) + }; + let out = warp_mesh_surface(&src, Rect::new(0, 0, 3, 40), &arch, Interp::Bilinear); + let alpha = |x: i32, y: i32| { + let mut p = [0.0f32; 4]; + out.read_pixel(x, y, &mut p); + p[3] + }; + // The band follows the arch: 40 px lower in the middle, about 36 px a third of the way in. + assert!(alpha(150, 60) > 0.99 && alpha(150, 20) < 0.01, "mid {} {}", alpha(150, 60), alpha(150, 20)); + assert!(alpha(100, 55) > 0.99 && alpha(100, 20) < 0.01, "third {} {}", alpha(100, 55), alpha(100, 20)); + } + #[test] fn a_mesh_warp_far_off_canvas_writes_only_the_two_regions() { // #995: the old and warped regions were cleared as one dense `src ∪ warped` buffer, about diff --git a/crates/algo/tests/warp_clipped.rs b/crates/algo/tests/warp_clipped.rs new file mode 100644 index 0000000000..ac6497f13f --- /dev/null +++ b/crates/algo/tests/warp_clipped.rs @@ -0,0 +1,59 @@ +use photocraft_algo::transform::{Homography, Interp, warp_surface, warp_surface_clipped}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +#[test] +fn clipped_warp_matches_the_existing_kernel_at_every_depth() { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true)); + source.fill_rect(Rect::new(8, 12, 40, 44), &[0.8, 0.2, 0.4, 0.7]); + source.fill_rect(Rect::new(20, 24, 32, 36), &[0.1, 0.7, 0.3, 0.4]); + let clip = Rect::new(10, 10, 52, 52); + for interpolation in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] { + for h in [Homography([1.2, -0.3, 7.0, 0.3, 1.2, -5.0, 0.0, 0.0, 1.0]), Homography([0.125, 0.0, 24.0, 0.0, 0.125, 30.0, 0.0, 0.0, 1.0])] { + let full = warp_surface(&source, source.content_bounds(), &h, interpolation).unwrap(); + let bounded = warp_surface_clipped(&source, source.content_bounds(), &h, interpolation, clip).unwrap(); + assert_eq!(full.read_region(clip), bounded.read_region(clip)); + assert!(clip.contains_rect(&bounded.content_bounds())); + assert!(bounded.tiles().count() <= clip.tiles().count()); + } + } + } +} + +#[test] +fn huge_scaled_warp_allocates_only_the_destination_clip() { + let mut source = Surface::new(PixelFormat::RGBA8); + source.fill_rect(Rect::new(8, 8, 12, 12), &[0.2, 0.7, 0.4, 1.0]); + let clip = Rect::new(0, 0, 64, 64); + let h = Homography([10_000.0, 0.0, -99_968.0, 0.0, 10_000.0, -99_968.0, 0.0, 0.0, 1.0]); + let output = warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Bicubic, clip).unwrap(); + assert!(clip.contains_rect(&output.content_bounds())); + assert!(output.tiles().count() <= clip.tiles().count()); + assert_eq!(output.pixel(32, 32), source.pixel(10, 10)); + assert!(warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Bicubic, Rect::EMPTY).unwrap().tiles().next().is_none()); +} + +#[test] +fn inverse_footprint_handles_extreme_finite_affines() { + let mut source = Surface::new(PixelFormat::RGBA8); + source.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 1.0]); + // A strongly skewed inverse maps a small visible tile beyond i32 limits. + let h = Homography([1.0, 1e12, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]); + let clip = Rect::new(0, 0, 32, 32); + let output = warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Nearest, clip).unwrap(); + assert!(clip.contains_rect(&output.content_bounds())); + assert!(output.tiles().count() <= clip.tiles().count()); +} + +#[test] +fn source_center_does_not_add_extreme_coordinates_in_i32() { + let mut source = Surface::new(PixelFormat::RGBA8); + let origin = 1_500_000_000; + let bounds = Rect::new(origin, origin, origin + 4, origin + 4); + source.fill_rect(bounds, &[0.2, 0.7, 0.4, 1.0]); + let h = Homography([1.0, 0.0, -f64::from(origin), 0.0, 1.0, -f64::from(origin), 0.0, 0.0, 1.0]); + let output = warp_surface_clipped(&source, bounds, &h, Interp::Nearest, Rect::new(0, 0, 8, 8)).unwrap(); + assert_eq!(output.pixel(2, 2), source.pixel(origin + 2, origin + 2)); +} diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 0738140a41..f5e88b3591 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 @@ -30,3 +30,7 @@ cap-std = "4" [dev-dependencies] rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] } + +# Reading back an extended attribute the write path should have preserved (#7095). +[target.'cfg(unix)'.dev-dependencies] +rustix = { version = "1.1", default-features = false, features = ["std", "fs"] } diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs index cd34ab3410..fc4526eedb 100644 --- a/crates/automation/src/files.rs +++ b/crates/automation/src/files.rs @@ -19,7 +19,8 @@ pub struct Opened { /// Decode a document that was read through a filesystem capability. pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { if Path::new(name).extension().is_some_and(|extension| extension.eq_ignore_ascii_case(photocraft_format::EXTENSION)) { - return Ok(Opened { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false }); + let document = photocraft_format::load_from_bytes(bytes)?; + return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false }); } let r = photocraft_io::import(name, bytes)?; Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only }) @@ -29,7 +30,8 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { /// natively; everything else goes through `photocraft-io` (PSD, PNG, …). pub fn open(path: &Path) -> Result { if path.is_dir() { - return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new(), source_read_only: false }); + let document = photocraft_format::load_path(path)?; + return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false }); } let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 507010b9c8..3db25c4fdd 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -29,8 +29,8 @@ use serde_json::{Value, json}; use crate::budgets::{BatchReplyBudget, write_reply}; use crate::security::{ - ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, discard_rest_of_line, - read_bounded_line, repeat_auth_reply, + AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, + configure_stream, discard_rest_of_line, read_bounded_line, repeat_auth_reply, }; use crate::{AutomationError, Headless}; @@ -91,17 +91,9 @@ impl Headless { let all = self.command_list(); Ok(match str_of(&p, "filter").map(str::to_lowercase) { None => all, - Some(n) => Value::Array( - all.as_array() - .into_iter() - .flatten() - .filter(|c| { - let hay = format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")); - hay.to_lowercase().contains(&n) - }) - .cloned() - .collect(), - ), + Some(n) => { + Value::Array(all.as_array().into_iter().flatten().filter(|c| photocraft_engine::commands::list_entry_matches(c, &n)).cloned().collect()) + } }) } "session.list" => Ok(self.session_list()), @@ -122,6 +114,7 @@ impl Headless { let mut opts = photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() }; opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?; + opts.encode.dds = photocraft_engine::file_cmds::dds_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?.unwrap_or_default(); if let Some(q) = p.get("quality").and_then(Value::as_u64) { opts.encode.jpeg_quality = q.clamp(1, 100) as u8; opts.encode.webp_quality = q.clamp(1, 100) as u8; @@ -270,10 +263,12 @@ pub fn serve_lines(h: &Mutex, mut r: impl BufRead, mut w: impl Write) } } -fn serve_tcp_connection(h: &Mutex, stream: std::net::TcpStream, token: &str) -> std::io::Result<()> { +/// Serve one connection. It must complete `auth` within `auth_deadline` of connecting, however +/// much it sends meanwhile (#4965); after that only the idle timeout applies. +fn serve_tcp_connection(h: &Mutex, stream: std::net::TcpStream, token: &str, auth_deadline: std::time::Duration) -> std::io::Result<()> { configure_stream(&stream)?; let read = stream.try_clone()?; - let mut reader = std::io::BufReader::new(read); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(read, auth_deadline)); let mut out = stream; let mut line = String::new(); let mut authenticated = false; @@ -298,6 +293,9 @@ fn serve_tcp_connection(h: &Mutex, stream: std::net::TcpStream, token: } else { let (reply, ok) = authentication_reply(&line, token); authenticated = ok; + if ok { + reader.get_mut().authenticated()?; + } reply }; write_reply(&mut out, &reply)?; @@ -330,7 +328,7 @@ pub fn serve_tcp(addr: &str, h: Arc>, token: String, ready: impl let token = Arc::clone(&token); std::thread::spawn(move || { let _permit = permit; - let _ = serve_tcp_connection(&h, stream, &token); + let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE); }); } Ok(()) @@ -365,7 +363,7 @@ mod tests { /// Pins the reply shape documented in docs/control-protocol.md › Command list replies /// (#3684): a bare JSON array (not `{"commands": [...]}`), one object per command with - /// exactly these keys, from `engine.commands` (filtered or not) and `command.list`. + /// exactly these keys (plus an optional `keywords` string), from `engine.commands` (filtered or not) and `command.list`. #[test] fn command_list_replies_are_documented_array_of_objects() { let h = session(); @@ -377,8 +375,10 @@ mod tests { let items = reply.as_array().unwrap_or_else(|| panic!("{name} must reply with a bare array, got {reply}")); assert!(!items.is_empty(), "{name}"); for c in items { - let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).collect(); + // `keywords` is optional: only commands with other search names carry it (#5060). + let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).filter(|k| *k != "keywords").collect(); keys.sort_unstable(); + assert!(c.get("keywords").is_none_or(Value::is_string), "{name}: {c}"); assert_eq!(keys, ["disabledReason", "enabled", "id", "label", "menu", "needsDocument", "params", "shortcut"], "{name}: {c}"); assert!(c["id"].is_string() && c["label"].is_string() && c["params"].is_string() && c["enabled"].is_boolean(), "{c}"); assert!(c["needsDocument"].is_boolean(), "{c}"); @@ -820,6 +820,60 @@ mod tests { assert!(v["result"].as_array().unwrap().iter().any(|m| m == "batch")); } + /// #4965: a connection that keeps sending without authenticating is closed at the deadline, + /// and one that authenticated in time keeps being served after it. + #[test] + fn tcp_connections_must_authenticate_before_the_deadline_whatever_they_send() { + use std::io::{BufReader, Write as _}; + use std::time::{Duration, Instant}; + const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let h = Mutex::new(Headless::new()); + let mut results = Vec::new(); + for _ in 0..2 { + let (stream, _) = listener.accept().unwrap(); + let started = Instant::now(); + let r = serve_tcp_connection(&h, stream, TOKEN, Duration::from_millis(300)); + results.push((r.map_err(|e| e.kind()), started.elapsed())); + } + results + }); + + // Never a newline, a byte every 20 ms: each read succeeds long before the idle timeout. + let mut slow = std::net::TcpStream::connect(addr).unwrap(); + for _ in 0..150 { + if slow.write_all(b" ").is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + drop(slow); + + // Authenticates at once, then asks again 300 ms after the deadline. + let mut s = std::net::TcpStream::connect(addr).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + writeln!(s, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap(); + let mut reader = BufReader::new(s.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap()["result"]["authenticated"], true); + std::thread::sleep(Duration::from_millis(600)); + writeln!(s, r#"{{"id":"a","method":"methods"}}"#).unwrap(); + line.clear(); + reader.read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap()["id"], "a"); + drop(reader); + drop(s); + + let results = server.join().unwrap(); + let (slow_result, slow_time) = &results[0]; + assert!(matches!(slow_result, Err(std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock)), "{slow_result:?}"); + assert!(*slow_time < Duration::from_secs(3), "the trickle did not extend the deadline: {slow_time:?}"); + assert_eq!(results[1].0, Ok(()), "the authenticated connection ended with its client"); + } + #[test] fn tcp_repeat_auth_reports_already_authenticated_and_keeps_serving() { use std::io::{BufReader, Write as _}; diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs index 4659a3ff17..0735511f20 100644 --- a/crates/automation/src/security.rs +++ b/crates/automation/src/security.rs @@ -1,11 +1,11 @@ //! Shared security primitives for the loopback JSON control transports. use std::fs::OpenOptions; -use std::io::{BufRead, Write}; +use std::io::{BufRead, Read, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::Duration; +use std::time::{Duration, Instant}; use serde_json::{Value, json}; @@ -21,6 +21,10 @@ pub const MAX_CONNECTIONS: usize = 16; pub const MAX_BATCH_STEPS: usize = 256; /// Idle/read and write timeout for loopback TCP connections. pub const IO_TIMEOUT: Duration = Duration::from_secs(30); +/// How long a new TCP connection has to authenticate, however active it is. [`IO_TIMEOUT`] starts +/// again with every read, so a client that kept sending without ever completing `auth` held one +/// of the [`MAX_CONNECTIONS`] permits for as long as it liked (#4965). +pub const AUTH_DEADLINE: Duration = Duration::from_secs(10); const TOKEN_BYTES: usize = 32; const TOKEN_HEX_LEN: usize = TOKEN_BYTES * 2; @@ -93,11 +97,15 @@ pub fn validate_token(token: &str) -> Result<(), AutomationError> { } /// Compare fixed-width tokens without an early exit on a mismatching byte. +/// +/// Hex letters match in either case: [`validate_token`] accepts uppercase, and the server keeps +/// the token lowercased, so a client sending the token exactly as it was configured must still +/// authenticate (#6425). Only `A`–`Z` fold, so a non-hex byte never matches a hex digit. pub fn token_matches(expected: &str, supplied: &str) -> bool { if expected.len() != TOKEN_HEX_LEN || supplied.len() != TOKEN_HEX_LEN { return false; } - expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a ^ b)) == 0 + expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a.to_ascii_lowercase() ^ b.to_ascii_lowercase())) == 0 } /// Validate the first TCP frame without exposing any control method before authentication. @@ -131,7 +139,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) @@ -242,6 +270,44 @@ pub fn configure_stream(stream: &std::net::TcpStream) -> std::io::Result<()> { stream.set_write_timeout(Some(IO_TIMEOUT)) } +/// The read half of a control connection. Until [`Self::authenticated`] is called, reads fail +/// with `TimedOut` once the authentication deadline has passed, whatever arrived in the meantime; +/// the caller then drops the connection and its [`ConnectionPermit`]. +pub struct AuthDeadlineStream { + stream: std::net::TcpStream, + /// `None` once authenticated. + deadline: Option, +} + +impl AuthDeadlineStream { + /// `stream` must authenticate within `limit` from now (normally [`AUTH_DEADLINE`]). + pub fn new(stream: std::net::TcpStream, limit: Duration) -> Self { + // A limit too large to represent expires at once rather than never. + let now = Instant::now(); + Self { stream, deadline: Some(now.checked_add(limit).unwrap_or(now)) } + } + + /// The connection authenticated: only the idle timeout applies from here on. + pub fn authenticated(&mut self) -> std::io::Result<()> { + self.deadline = None; + self.stream.set_read_timeout(Some(IO_TIMEOUT)) + } +} + +impl Read for AuthDeadlineStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if let Some(deadline) = self.deadline { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "not authenticated in time")); + } + // Wait no longer than what is left, so a silent client is dropped at the deadline too. + self.stream.set_read_timeout(Some(left.min(IO_TIMEOUT)))?; + } + self.stream.read(buf) + } +} + /// Environment-aware token inputs shared by the desktop app and CLI. pub fn token_inputs(supplied: Option, token_file: Option) -> (Option, Option) { let supplied = supplied.or_else(|| std::env::var("PHOTOCRAFT_CONTROL_TOKEN").ok()); @@ -306,6 +372,45 @@ mod tests { assert_eq!(reply["result"]["authenticated"], true); } + fn auth_line(token: &str) -> String { + json!({"id": 1, "method": AUTH_METHOD, "params": {"token": token}}).to_string() + } + + /// #6425: `validate_token` accepts uppercase hex and the server keeps the token lowercased, so a + /// client sending the token exactly as it was configured was refused. + #[test] + fn a_token_authenticates_in_any_letter_case() { + let configured = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + let mixed = "0123456789AbCdEf0123456789aBcDeF0123456789ABCDEF0123456789abcdef"; + let expected = server_token(Some(configured), None).unwrap(); + for supplied in [configured, &configured.to_ascii_lowercase(), mixed] { + let (reply, authenticated) = authentication_reply(&auth_line(supplied), &expected); + assert!(authenticated, "{supplied}: {reply}"); + assert_eq!(reply["result"]["authenticated"], true); + } + // The comparison is case-insensitive in both directions. + assert!(token_matches(configured, &configured.to_ascii_lowercase())); + + // A different token, or a non-hex character that only folds to a hex one, still fails. + let other = "1123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + let (_, authenticated) = authentication_reply(&auth_line(other), &expected); + assert!(!authenticated); + let not_hex = "0123456789ABCDEG0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + assert!(!token_matches(&expected, not_hex)); + } + + /// #6425: the same for a token file whose contents are uppercase. + #[test] + fn an_uppercase_token_file_authenticates_its_exact_contents() { + let path = std::env::temp_dir().join(format!("photocraft-control-token-upper-{}-{}.txt", std::process::id(), generate_token().unwrap())); + let contents = generate_token().unwrap().to_ascii_uppercase(); + create_token_file(&path, &contents).unwrap(); + let expected = server_token(None, Some(&path)); + std::fs::remove_file(&path).unwrap(); + let (reply, authenticated) = authentication_reply(&auth_line(&contents), &expected.unwrap()); + assert!(authenticated, "{reply}"); + } + #[test] fn repeat_auth_on_an_authenticated_connection_says_so() { let line = json!({"id": 3, "method": AUTH_METHOD, "params": {"token": "anything"}}).to_string(); @@ -327,6 +432,98 @@ mod tests { std::fs::remove_file(path).unwrap(); } + /// A connected loopback pair: the accepted (server) end and the client end. + fn tcp_pair() -> (std::net::TcpStream, std::net::TcpStream) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server, _) = listener.accept().unwrap(); + configure_stream(&server).unwrap(); + (server, client) + } + + /// #4965: activity must not extend the time a connection may stay unauthenticated. + #[test] + fn an_active_connection_is_dropped_at_the_authentication_deadline() { + let (server, mut client) = tcp_pair(); + // A byte every 20 ms and never a newline: each read succeeds long before the idle timeout. + let trickle = std::thread::spawn(move || { + for _ in 0..150 { + if client.write_all(b"x").is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + }); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(300))); + let mut line = String::new(); + let started = Instant::now(); + let err = read_bounded_line(&mut reader, &mut line).unwrap_err(); + assert!(matches!(err.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock), "{err}"); + assert!(started.elapsed() >= Duration::from_millis(250), "not before the deadline: {:?}", started.elapsed()); + assert!(started.elapsed() < Duration::from_secs(3), "the trickle did not extend it: {:?}", started.elapsed()); + // It stays expired. + assert!(read_bounded_line(&mut reader, &mut line).is_err()); + drop(reader); + trickle.join().unwrap(); + } + + #[test] + fn a_silent_connection_is_dropped_at_the_authentication_deadline() { + let (server, _client) = tcp_pair(); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200))); + let mut line = String::new(); + let started = Instant::now(); + assert!(read_bounded_line(&mut reader, &mut line).is_err()); + assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed()); + } + + #[test] + fn an_authenticated_connection_outlives_the_authentication_deadline() { + let (server, mut client) = tcp_pair(); + // The test thread writes the first line before the deadline starts. The sender writes the + // second line once the connection has authenticated and the deadline has passed. + client.write_all(b"one\n").unwrap(); + let limit = Duration::from_secs(1); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, limit)); + let deadline = Instant::now() + limit; + let (tx, rx) = std::sync::mpsc::channel(); + let sender = std::thread::spawn(move || { + rx.recv().unwrap(); + std::thread::sleep((deadline + Duration::from_millis(300)).saturating_duration_since(Instant::now())); + client.write_all(b"two\n").unwrap(); + }); + let mut line = String::new(); + assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line); + assert_eq!(line, "one\n"); + reader.get_mut().authenticated().unwrap(); + tx.send(()).unwrap(); + // Sent 300 ms after the deadline: only the idle timeout applies now. + assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line); + assert_eq!(line, "two\n"); + sender.join().unwrap(); + } + + #[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); diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index b31507b005..567d0b4837 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -170,6 +170,13 @@ pub struct SaveParams { /// DPX: 8, 10, 12 or 16 bits per sample. Unset: 8 for an 8-bit document, else 16. #[serde(default, rename = "dpxBits")] pub dpx_bits: Option, + /// DDS: "none" (uncompressed, the default), "bc1" (DXT1: alpha on or off), "bc2" (DXT3) or + /// "bc3" (DXT5). + #[serde(default, rename = "ddsCompression")] + pub dds_compression: Option, + /// DDS: also write the full mip chain, each level a box filter of the one above. + #[serde(default, rename = "ddsMipmaps")] + pub dds_mipmaps: bool, #[serde(default)] pub index: Option, } @@ -205,7 +212,7 @@ pub struct SelectParams { #[derive(Debug, Default, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ListParams { - /// Only commands whose id or label contains this text (case-insensitive). + /// Only commands whose id, label or keywords contain this text (case-insensitive). #[serde(default)] pub filter: Option, /// Only commands that can run right now. @@ -320,11 +327,16 @@ pub struct UiSetParams { /// grid, diagonal, triangle, goldenRatio, goldenSpiral), cropOverlayShow (auto, always, never), /// cropOverlayOrientation (0..3), cropShield (an object patching show_cropped_area, enabled, /// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100), - /// auto_adjust, classic_mode and auto_center_preview), shapeStroke (an object patching width, align, cap, join, miterLimit, + /// auto_adjust, classic_mode and auto_center_preview), cropFill (background or contentAware, + /// the Crop tool's Fill), shapeStroke (an object patching width, align, cap, join, miterLimit, /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, /// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the - /// active layer's storage tiles). Other fields are an error. + /// active layer's storage tiles). layerFilter (an object patching by ("kind", "name" or + /// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a + /// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off": + /// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not + /// over this channel). Other fields are an error. pub fields: Value, } @@ -821,9 +833,8 @@ impl PhotocraftMcp { .unwrap_or_else(|| all.get("commands").and_then(Value::as_array).cloned().unwrap_or_default()) .into_iter() .filter(|c| { - let hay = - format!("{} {}", c.get("id").and_then(Value::as_str).unwrap_or(""), c.get("label").and_then(Value::as_str).unwrap_or("")).to_lowercase(); - needle.as_ref().is_none_or(|n| hay.contains(n)) && (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true)) + needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n)) + && (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true)) }) .collect(); Ok(ok_json(&Value::Array(items))) @@ -997,10 +1008,15 @@ impl PhotocraftMcp { Some((_, Some(bits))) => Some(bits), Some((b, None)) => return Ok(fail(format!("`dpxBits` must be 8, 10, 12 or 16, got {b}"))), }; + let dds_compression = match p.dds_compression.as_deref().map(|c| (c, photocraft_io::DdsCompression::from_name(c))) { + None => None, + Some((_, Some(c))) => Some(c), + Some((c, None)) => return Ok(fail(format!("`ddsCompression` must be none, bc1, bc2 or bc3, got {c:?}"))), + }; if let Some(b) = self.bridge_client() { // The bridge forwards to the running app's `app.save`, which takes only an optional // path (without one it writes back to the document's own layered file, as headless - // does) and the Targa bits, so the other options are headless-only. Saying so beats + // does), the Targa bits and the DDS options, so the other options are headless-only. Saying so beats // saving with defaults while the caller believes their quality or format was applied. let unsupported: Vec<&str> = [ p.format.is_some().then_some("format"), @@ -1022,12 +1038,19 @@ impl PhotocraftMcp { if let Some(bits) = tga_bits { params["tgaBits"] = json!(bits.bits()); } + if let Some(c) = dds_compression { + params["ddsCompression"] = json!(c.name()); + } + if p.dds_mipmaps { + params["ddsMipmaps"] = json!(true); + } return to_result(b.call("app.save", params).await); } let Some(r) = self .headless_op(move |h| { let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() }; opts.encode.dpx_bits = dpx_bits; + opts.encode.dds = photocraft_io::DdsOptions { compression: dds_compression.unwrap_or_default(), mipmaps: p.dds_mipmaps }; if let Some(q) = p.quality { opts.encode.jpeg_quality = q.clamp(1, 100); opts.encode.webp_quality = q.clamp(1, 100); diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index cde3a3a702..99dedac77d 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -74,6 +74,13 @@ impl AuthorizedWorkspace { /// as [`photocraft_format::atomic_write`], through the directory capability). On failure /// the previous file is untouched and the temporary file is removed. /// + /// Replacing a file carries its extended attributes — macOS Finder tags, Linux `user.*` — + /// onto the file that takes its place (#7095), through the two open descriptors rather than + /// their paths, so nothing here reaches outside the write root to finish a write. The rest + /// of what [`photocraft_format::atomic_write`] preserves does not follow: its permission + /// step is tracked in #4963, and the Windows `ReplaceFileW` swap is path-based, which this + /// path may not use. + /// /// The parent directory must already exist. `cap-std` performs path /// resolution and file creation relative to the held directory handle, so /// a non-existent final target is supported without ambient path access. @@ -85,10 +92,20 @@ impl AuthorizedWorkspace { let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf)); let mut options = OpenOptions::new(); options.write(true).create_new(true); - let written = root.dir.open_with(&tmp, &options).and_then(|mut file| { + let written = root.dir.open_with(&tmp, &options).and_then(|file| { + let mut file = file.into_std(); file.write_all(bytes)?; + #[cfg(unix)] + { + use std::os::fd::AsFd; + // Best effort, and only when there is a file being replaced: a save still + // succeeds for a document whose Finder tag could not come along. + if let Ok(old) = root.dir.open(&relative) { + let _ = photocraft_format::atomic::meta::copy_xattrs(old.as_fd(), file.as_fd()); + } + } // Falls back to a plain fsync on shares that refuse a full flush (#1336). - photocraft_format::atomic::sync_file(&file.into_std()) + photocraft_format::atomic::sync_file(&file) }); let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative))); if let Err(e) = renamed { @@ -363,12 +380,16 @@ fn preference_uses_ambient_filesystem(path: &str) -> bool { fn command_uses_ambient_path(id: &str, params: &Value) -> bool { match id { - "actions.load" | "brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => { + "actions.load" | "brush.presets.importAbr" | "brush.presets.importImage" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => { // `data` wins over `path` in these commands; any `path` without it reads the filesystem. params.get("data").is_none() && params.get("path").is_some() } // With a `path` the file is written there; without one the bytes come back as `data`. "actions.save" | "swatches.export" => params.get("path").is_some(), + // Shortcut sets: a .kys read or written by path, or a Summarize page written by path. + "edit.keyboardShortcuts" => { + params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some()) + } "plugin.reload" => true, _ => false, } @@ -568,6 +589,32 @@ mod tests { assert!(temp_files(&inside).is_empty()); } + /// #7095: replacing a file through the automation path keeps the old file's extended + /// attributes, the same as a save through `atomic_write` does. + #[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] + #[test] + fn write_keeps_the_replaced_files_extended_attributes() { + #[cfg(target_os = "macos")] + const TAG: &str = "com.apple.metadata:_kMDItemUserTags"; + #[cfg(not(target_os = "macos"))] + const TAG: &str = "user.photocraft.test"; + + let (inside, _, workspace) = roots("xattr"); + let path = inside.join("doc.psd"); + workspace.write("doc.psd", b"original").unwrap(); + if rustix::fs::setxattr(&path, TAG, b"tagged", rustix::fs::XattrFlags::empty()).is_err() { + return; // a file system without extended attributes: nothing to assert + } + + workspace.write("doc.psd", b"replacement, longer").unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), b"replacement, longer"); + let mut buf = [0u8; 64]; + let len = rustix::fs::getxattr(&path, TAG, buf.as_mut_slice()).expect("the attribute was dropped"); + assert_eq!(&buf[..len], b"tagged"); + assert!(temp_files(&inside).is_empty()); + } + #[cfg(unix)] #[test] fn write_into_read_only_folder_keeps_the_original() { @@ -712,6 +759,12 @@ mod tests { assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err()); assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err()); assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok()); + // Shortcut sets: text in and out is fine; a path reads or writes the filesystem. + let ks = |p: serde_json::Value| authorize_engine_command("edit.keyboardShortcuts", &p); + assert!(ks(serde_json::json!({"exportKys": true, "summarize": true, "importKys": ""})).is_ok()); + for key in ["importKys", "exportKys", "summarize"] { + assert!(ks(serde_json::json!({ key: {"path": "/outside/set.kys"} })).is_err(), "{key}"); + } assert!(authorize_engine_command("filter.distort.displace", &serde_json::json!({"mapPath": "outside.png"})).is_err()); assert!(authorize_engine_command("layer.setAdjustment", &serde_json::json!({"file": "outside.cube"})).is_err()); assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "colorSettings.workingRgb", "value": "outside.icc"})).is_err()); @@ -758,6 +811,7 @@ mod tests { fn automation_rejects_ambient_path_commands_and_preferences() { for (id, params) in [ ("brush.presets.importAbr", serde_json::json!({"path": "/outside/set.abr"})), + ("brush.presets.importImage", serde_json::json!({"path": "/outside/tip.png"})), ("gradient.presets.importGrd", serde_json::json!({"path": "/outside/set.grd"})), ("plugin.install", serde_json::json!({"path": "/outside/plugin.wasm"})), ("plugin.reload", serde_json::json!({"path": "/outside/plugins"})), @@ -772,6 +826,7 @@ mod tests { } for (id, params) in [ ("brush.presets.importAbr", serde_json::json!({"data": "QUJD"})), + ("brush.presets.importImage", serde_json::json!({"data": "QUJD"})), ("gradient.presets.importGrd", serde_json::json!({"data": "QUJD"})), ("plugin.install", serde_json::json!({"data": "QUJD"})), ("swatches.import", serde_json::json!({"data": "QUJD"})), diff --git a/crates/automation/tests/agent_tasks.rs b/crates/automation/tests/agent_tasks.rs index 271993d2bb..e1585e2040 100644 --- a/crates/automation/tests/agent_tasks.rs +++ b/crates/automation/tests/agent_tasks.rs @@ -166,7 +166,7 @@ async fn agent_completes_ten_scripted_tasks() { let outside = pixel(&c, 1, 1).await; assert!(inside[0] > 0.99 && inside[1] < 0.01, "task 5: centre is red {inside:?}"); assert!(outside[0] < 0.1, "task 5: corner shows the photo {outside:?}"); - run(&c, "select.deselect", json!({})).await; + assert_eq!(inspect(&c).await["hasSelection"], false, "task 5: the selection became the mask"); // 6. Save a selection to a channel and load it back. run(&c, "select.rect", json!({"x": 4, "y": 6, "width": 20, "height": 10})).await; @@ -413,7 +413,7 @@ async fn agent_completes_ten_more_scripted_tasks() { assert_eq!(top_layer(&doc)["hasMask"], true, "task 17"); let boundary = pixel(&c, 20, 40).await; assert!(boundary[3] > 0.05 && boundary[3] < 0.95, "task 17: the feathered edge keeps partial alpha {boundary:?}"); - run(&c, "select.deselect", json!({})).await; + assert_eq!(doc["hasSelection"], false, "task 17: the selection became the mask"); // 18. Expand a selection and read its bounds back. run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 20, "height": 20})).await; diff --git a/crates/automation/tests/krita.rs b/crates/automation/tests/krita.rs index 85bde4f5fc..44de53ff2a 100644 --- a/crates/automation/tests/krita.rs +++ b/crates/automation/tests/krita.rs @@ -4,14 +4,15 @@ use photocraft_automation::Headless; use serde_json::json; -/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0). +/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0). Krita writes a raw tile +/// (flag 0) as plain interleaved pixels; only LZF tiles are split into byte planes. fn tiles(bgra: [u8; 4]) -> Vec { let n = 64 * 64; let mut out = b"VERSION 2\nTILEWIDTH 64\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 1\n".to_vec(); out.extend(format!("0,0,LZF,{}\n", 1 + n * 4).into_bytes()); out.push(0); - for b in bgra { - out.extend(std::iter::repeat_n(b, n)); + for _ in 0..n { + out.extend(bgra); } out } diff --git a/crates/automation/tests/mcp.rs b/crates/automation/tests/mcp.rs index ca34d9f915..5a9fa7c56e 100644 --- a/crates/automation/tests/mcp.rs +++ b/crates/automation/tests/mcp.rs @@ -167,6 +167,9 @@ async fn command_list_filters() { let blur = json_of(&call(&client, "command_list", json!({"filter": "blur"})).await); assert!(!blur.as_array().unwrap().is_empty() && blur.as_array().unwrap().len() < n_all); assert!(blur.as_array().unwrap().iter().all(|c| c["params"].is_string())); + // The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060). + let marquee = json_of(&call(&client, "command_list", json!({"filter": "ellipse"})).await); + assert!(marquee.as_array().unwrap().iter().any(|c| c["id"] == "select.rect"), "{marquee}"); let enabled = json_of(&call(&client, "command_list", json!({"enabled_only": true})).await); assert!(enabled.as_array().unwrap().iter().any(|c| c["id"] == "file.new")); assert!(!enabled.as_array().unwrap().iter().any(|c| c["id"] == "layer.new.layer"), "needs a document"); @@ -412,6 +415,45 @@ async fn headless_doc_save_writes_targa_bits() { cleanup(&dir); } +/// `ddsCompression` and `ddsMipmaps` reach the app's `app.save` through the bridge; an unknown +/// compression is refused before anything is sent. +#[tokio::test(flavor = "multi_thread")] +async fn bridge_doc_save_forwards_dds_options() { + let (addr, app) = fake_app().await; + let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await; + let saved = json_of(&call(&client, "doc_save", json!({"path": "out.dds", "ddsCompression": "DXT5", "ddsMipmaps": true})).await); + assert_eq!(saved, json!({"saved": {"path": "out.dds", "ddsCompression": "bc3", "ddsMipmaps": true}})); + let r = call(&client, "doc_save", json!({"path": "out.dds", "ddsCompression": "bc7"})).await; + assert_eq!(r.is_error, Some(true), "{}", text(&r)); + assert!(text(&r).contains("ddsCompression"), "{}", text(&r)); + client.cancel().await.unwrap(); + app.abort(); +} + +/// Headless `doc_save`/`doc_export` write the DDS compression (the FourCC, bytes 84–87) and mip +/// count (bytes 28–31) asked for; unset, an uncompressed texture without mipmaps. +#[tokio::test(flavor = "multi_thread")] +async fn headless_doc_save_writes_dds_options() { + let dir = tmp("dds-options"); + let client = connect(headless_in(&dir)).await; + call(&client, "doc_new", json!({"width": 16, "height": 8, "background": "white"})).await; + for (tool, params, fourcc, mips) in [ + ("doc_export", json!({"path": "plain.dds"}), [0; 4], 1), + ("doc_export", json!({"path": "bc1.dds", "ddsCompression": "bc1"}), *b"DXT1", 1), + ("doc_save", json!({"path": "bc3.dds", "ddsCompression": "bc3", "ddsMipmaps": true}), *b"DXT5", 5), + ] { + let r = call(&client, tool, params.clone()).await; + assert_ne!(r.is_error, Some(true), "{params}: {}", text(&r)); + let bytes = std::fs::read(dir.join(params["path"].as_str().unwrap())).unwrap(); + assert_eq!((&bytes[84..88], u32::from_le_bytes(bytes[28..32].try_into().unwrap())), (&fourcc[..], mips), "{params}"); + } + let r = call(&client, "doc_export", json!({"path": "bad.dds", "ddsCompression": "bc7"})).await; + assert_eq!(r.is_error, Some(true), "{}", text(&r)); + assert!(!dir.join("bad.dds").exists()); + client.cancel().await.unwrap(); + cleanup(&dir); +} + #[tokio::test(flavor = "multi_thread")] async fn bridge_response_budget_drops_connection_without_retrying_the_operation() { use photocraft_automation::{BridgeClient, budgets::MAX_RESPONSE_BYTES}; diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/smart_filter_authorization.rs new file mode 100644 index 0000000000..30f17b795b --- /dev/null +++ b/crates/automation/tests/smart_filter_authorization.rs @@ -0,0 +1,259 @@ +//! Stored smart-filter parameters must not grant automation ambient filesystem access. +use std::path::{Path, PathBuf}; +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; + +use photocraft_automation::{AuthorizedWorkspace, Headless, workspace::authorize_desktop_engine_step}; +use photocraft_doc::{Document, LayerContent, LayerId}; +use photocraft_engine::{Session, actions_cmds::Action, jobs::Started, smart_cmds}; +use serde_json::{Value, json}; + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let base = std::env::temp_dir().join(format!("pc-filter-auth-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir(&base).unwrap(); + std::fs::create_dir(base.join("workspace")).unwrap(); + std::fs::create_dir(base.join("outside")).unwrap(); + let f = Self(base); + std::fs::write(f.root().join("stripes.png"), png(|x| if (x / 2) % 2 == 0 { 0 } else { 255 })).unwrap(); + std::fs::write(f.outside("black.png"), png(|_| 0)).unwrap(); + std::fs::write(f.outside("white.png"), png(|_| 255)).unwrap(); + std::fs::write(f.outside("black.cube"), cube(0)).unwrap(); + std::fs::write(f.outside("white.cube"), cube(1)).unwrap(); + f + } + + fn root(&self) -> PathBuf { + self.0.join("workspace") + } + fn outside(&self, name: &str) -> PathBuf { + self.0.join("outside").join(name) + } + fn headless(&self) -> Headless { + Headless::with_workspace(AuthorizedWorkspace::new(Some(&self.root()), Some(&self.root())).unwrap()) + } + fn setup(&self, lookup: bool) -> (Headless, LayerId) { + let mut h = self.headless(); + h.handle("doc.new", json!({"width": 32, "height": 32, "background": "#808080"})).unwrap(); + h.handle("doc.new", json!({"width": 32, "height": 32})).unwrap(); + let layer = LayerId(h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap()); + let (id, params) = if lookup { + ("image.adjustments.colorLookup", json!({"layer": layer.0, "lut": "none"})) + } else { + ("filter.distort.displace", json!({"layer": layer.0, "mapDocument": 0, "horizontal": 50, "vertical": 0})) + }; + h.session.execute(id, params).unwrap(); + (h, layer) + } + fn saved_filter(&self, lookup: bool) -> (Document, LayerId) { + let (h, layer) = self.setup(lookup); + let mut doc = (*h.session.active().unwrap().doc).clone(); + let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!("smart object") }; + sm.smart_filters[0].params = path_params(lookup, &self.outside(if lookup { "white.cube" } else { "white.png" })); + let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap(); + std::fs::write(self.root().join("stored.pcraft"), bytes).unwrap(); + (doc, layer) + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn png(value: impl Fn(u32) -> u8) -> Vec { + let mut pixels = Vec::new(); + for _ in 0..32 { + for x in 0..32 { + pixels.extend([value(x); 3]); + } + } + let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, pixels).unwrap(); + photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap() +} +fn cube(value: u8) -> String { + format!("LUT_3D_SIZE 2\n{}", format!("{value} {value} {value}\n").repeat(8)) +} +fn path_params(lookup: bool, path: &Path) -> Value { + if lookup { json!({"file": path}) } else { json!({"mapPath": path, "mapDocument": null}) } +} +fn assert_denied(result: Result) { + match result { + Err(error) => assert!(error.to_string().contains("ambient filesystem"), "{error}"), + Ok(value) => panic!("ambient file access was accepted: {value:?}"), + } +} + +#[test] +fn parameter_edits_reject_outside_images_and_luts_without_changing_the_document() { + for lookup in [false, true] { + let f = Fixture::new(); + let (mut h, layer) = f.setup(lookup); + let before = h.render_png(None, 64).unwrap(); + let doc = h.session.active().unwrap().doc.clone(); + let journal = h.session.journal.len(); + for name in if lookup { ["black.cube", "white.cube"] } else { ["black.png", "white.png"] } { + let params = path_params(lookup, &f.outside(name)); + let id = if lookup { "image.adjustments.colorLookup" } else { "filter.distort.displace" }; + assert_denied(h.session.execute(id, params.clone())); + assert_denied(h.handle("engine.execute", json!({"command": "layer.smartFilter.setParams", "params": {"layer": layer.0, "params": params}}))); + assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc)); + assert_eq!(h.session.journal.len(), journal); + assert_eq!(h.render_png(None, 64).unwrap(), before); + } + // Safe numeric and embedded-data edits remain usable. + let safe = if lookup { json!({"data": cube(0), "fileName": "embedded.cube"}) } else { json!({"horizontal": 10}) }; + h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap(); + } +} + +#[test] +fn action_playback_cannot_hide_a_smart_filter_path_edit() { + let f = Fixture::new(); + let (mut h, layer) = f.setup(false); + let doc = h.session.active().unwrap().doc.clone(); + h.session.actions.list.push(Action::new( + "edit filter", + vec![("layer.smartFilter.setParams".into(), json!({"layer": layer.0, "params": path_params(false, &f.outside("white.png"))}))], + )); + let result = h.session.execute("actions.play", json!({"action": "edit filter"})).unwrap(); + assert!(result["failed"]["error"].as_str().unwrap().contains("ambient filesystem"), "{result}"); + assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc)); +} + +#[test] +fn loaded_filters_are_checked_after_merging_even_when_the_edit_contains_no_path() { + for lookup in [false, true] { + let f = Fixture::new(); + let (_, layer) = f.saved_filter(lookup); + let mut h = f.headless(); + h.open(Path::new("stored.pcraft")).unwrap(); + let doc = h.session.active().unwrap().doc.clone(); + assert_denied(h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": {"horizontal": 10, "dither": false}}))); + assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc)); + // Removing an ambient input makes the filter usable again. + let safe = if lookup { json!({"file": "", "lut": "none"}) } else { json!({"mapPath": ""}) }; + h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap(); + } +} + +#[test] +fn loaded_filters_cannot_read_on_rerender_or_reuse_a_trusted_cached_stage() { + for lookup in [false, true] { + let f = Fixture::new(); + let (doc, layer) = f.saved_filter(lookup); + for warm_cache in [false, true] { + if warm_cache { + let mut trusted = doc.clone(); + assert!(smart_cmds::refresh(&mut trusted, layer).unwrap()); + } + let mut h = f.headless(); + h.open(Path::new("stored.pcraft")).unwrap(); + let before = h.session.active().unwrap().doc.clone(); + assert_denied(h.session.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true}))); + assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); + } + } +} + +#[test] +fn background_rerender_keeps_the_desktop_request_authority() { + for lookup in [false, true] { + let f = Fixture::new(); + let (doc, layer) = f.saved_filter(lookup); + let mut s = Session::new(); + s.add_document(doc, None); + s.authorize = Some(authorize_desktop_engine_step); + let before = s.active().unwrap().doc.clone(); + let started = s.start("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap(); + // The desktop restores UI authority as soon as the automation request returns. + s.authorize = None; + let Started::Job(job) = started else { panic!("expected a background render") }; + assert_denied(s.wait_job(job)); + assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc)); + // The worker's restriction must not leak into subsequent trusted UI work. + s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap(); + } +} + +#[test] +fn transferring_stored_filters_cannot_read_outside_files() { + for lookup in [false, true] { + let f = Fixture::new(); + let (_, from) = f.saved_filter(lookup); + let mut h = f.headless(); + h.open(Path::new("stored.pcraft")).unwrap(); + let to = h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap(); + let before = h.session.active().unwrap().doc.clone(); + for copy in [false, true] { + assert_denied(h.session.execute("layer.smartFilter.transfer", json!({"from": from.0, "to": to, "copy": copy}))); + assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); + } + } +} + +#[test] +fn trusted_parameter_edits_still_read_maps_and_luts_after_a_denied_request() { + for lookup in [false, true] { + let f = Fixture::new(); + let (mut h, layer) = f.setup(lookup); + let edit = |path: &Path| json!({"layer": layer.0, "params": path_params(lookup, path)}); + assert_denied(h.session.execute("layer.smartFilter.setParams", edit(&f.outside("white.png")))); + let mut trusted = Headless::trusted_local(); + trusted.session.add_document((*h.session.active().unwrap().doc).clone(), None); + let mut renders = Vec::new(); + for name in if lookup { ["black.cube", "white.cube", "black.cube"] } else { ["black.png", "white.png", "black.png"] } { + trusted.session.execute("layer.smartFilter.setParams", edit(&f.outside(name))).unwrap(); + renders.push(trusted.render_png(None, 64).unwrap()); + } + assert_ne!(renders[0], renders[1], "trusted path-based filter still works"); + assert_eq!(renders[0], renders[2], "trusted rendering remains deterministic"); + } +} + +#[test] +fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_unfiltered() { + // link-watch and transform paths re-render through Session::edit, not Session::execute or + // jobs::run; the gate must reach them and the denial must not silently pass pixels through + // into the process-wide stack cache. + for lookup in [false, true] { + let f = Fixture::new(); + let (doc, layer) = f.saved_filter(lookup); + let mut h = f.headless(); + h.open(Path::new("stored.pcraft")).unwrap(); + let before = h.session.active().unwrap().doc.clone(); + // Session::edit is the outside-execute mutation path (an edit closure the UI or a + // linked-file change runs); the stored filter must be denied there too. + assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) { + Ok(_) => Ok(()), + Err(e) => Err(e), + })); + assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); + // A trusted session (no gate) still renders the same document. + let mut trusted = Session::new(); + trusted.add_document(doc, None); + let mut d = (*trusted.active().unwrap().doc).clone(); + assert!(smart_cmds::refresh(&mut d, layer).is_ok()); + } +} + +#[test] +fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() { + // The denial path must fail the render before any stage is cached process-wide. + for lookup in [false, true] { + let f = Fixture::new(); + let (doc, layer) = f.saved_filter(lookup); + let mut s = Session::new(); + s.add_document(doc, None); + s.authorize = Some(authorize_desktop_engine_step); + let bytes = smart_cmds::stack_cache_bytes(); + assert_denied(s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true}))); + assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage"); + } +} diff --git a/crates/cms/README.md b/crates/cms/README.md index 6ae6a76cfb..c48d5366fd 100644 --- a/crates/cms/README.md +++ b/crates/cms/README.md @@ -1,6 +1,6 @@ # photocraft-cms -Pure-Rust ICC colour management for Photocraft. No workspace dependencies (L0, publishable), +Pure-Rust ICC colour management for PhotoCraft. No workspace dependencies (L0, publishable), no C, no `unsafe`. Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications, CIE 15, and Adobe's published black point compensation paper. @@ -53,13 +53,13 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou | `rec2020` | Rec. 2020 primaries, Rec. 709 OETF | | `gray-gamma-2.2`, `sgray` | gray gamma 2.2; gray with the sRGB curve (default gray working space) | | `lab-d50` | Lab identity (v4 encoding) | -| `coated-cmyk` | **Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)** — default CMYK | +| `coated-cmyk` | **PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)** — default CMYK | ### The CMYK profile Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39) are proprietary, and the freely downloadable characterisation-based profiles (ECI/FOGRA, colord, Ghostscript) carry -redistribution conditions or unclear licences, so Photocraft generates its own +redistribution conditions or unclear licences, so PhotoCraft generates its own (`src/synth.rs`): a Yule–Nielsen modified Neugebauer model (n = 2) over the 16 overprints with ISO 12647-2 coated-paper Lab aims for paper/C/M/Y/RGB overprints, parabolic dot gain (14 % CMY, 17 % K at 50 %), GCR black generation (starts at 20 % gray component, K max 95 %), total ink diff --git a/crates/cms/profiles/photocraft-coated-cmyk.icc b/crates/cms/profiles/photocraft-coated-cmyk.icc index 0d2d1da16d..b553fe9cec 100644 Binary files a/crates/cms/profiles/photocraft-coated-cmyk.icc and b/crates/cms/profiles/photocraft-coated-cmyk.icc differ diff --git a/crates/cms/src/builtin.rs b/crates/cms/src/builtin.rs index 9d42fef3f6..8f3dc88a15 100644 --- a/crates/cms/src/builtin.rs +++ b/crates/cms/src/builtin.rs @@ -26,6 +26,8 @@ pub enum Builtin { ProPhotoCompat, /// sRGB primaries with a linear transfer function. LinearSrgb, + /// ACEScg: AP1 primaries, the ACES white point (~D60), linear transfer. The ACES working space for CG renders. + AcesCg, /// Rec. 2020 primaries, D65, Rec. 709 transfer function. Rec2020, /// Gray, gamma 2.2. @@ -43,12 +45,13 @@ pub enum Builtin { } impl Builtin { - pub const ALL: [Builtin; 11] = [ + pub const ALL: [Builtin; 12] = [ Builtin::Srgb, Builtin::DisplayP3, Builtin::AdobeRgbCompat, Builtin::ProPhotoCompat, Builtin::LinearSrgb, + Builtin::AcesCg, Builtin::Rec2020, Builtin::GrayGamma22, Builtin::SGray, @@ -65,6 +68,7 @@ impl Builtin { Builtin::AdobeRgbCompat => "adobe-rgb-compat", Builtin::ProPhotoCompat => "prophoto-compat", Builtin::LinearSrgb => "linear-srgb", + Builtin::AcesCg => "aces-cg", Builtin::Rec2020 => "rec2020", Builtin::GrayGamma22 => "gray-gamma-2.2", Builtin::SGray => "sgray", @@ -79,14 +83,15 @@ impl Builtin { match self { Builtin::Srgb => "sRGB IEC61966-2.1", Builtin::DisplayP3 => "Display P3", - Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (Photocraft)", - Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, Photocraft)", - Builtin::LinearSrgb => "Linear sRGB (Photocraft)", - Builtin::Rec2020 => "Rec. ITU-R BT.2020 (Photocraft)", - Builtin::GrayGamma22 => "Gray Gamma 2.2 (Photocraft)", - Builtin::SGray => "sGray (sRGB tone curve, Photocraft)", - Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, Photocraft)", - Builtin::LabD50 => "Lab D50 identity (Photocraft)", + Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (PhotoCraft)", + Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, PhotoCraft)", + Builtin::LinearSrgb => "Linear sRGB (PhotoCraft)", + Builtin::AcesCg => "ACEScg (AP1, linear, PhotoCraft)", + Builtin::Rec2020 => "Rec. ITU-R BT.2020 (PhotoCraft)", + Builtin::GrayGamma22 => "Gray Gamma 2.2 (PhotoCraft)", + Builtin::SGray => "sGray (sRGB tone curve, PhotoCraft)", + Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, PhotoCraft)", + Builtin::LabD50 => "Lab D50 identity (PhotoCraft)", Builtin::CoatedCmyk => crate::synth::DESCRIPTION, } } @@ -104,6 +109,7 @@ impl Builtin { "gray" | "graygamma22" | "gamma22" => Some(Builtin::GrayGamma22), "sgray" | "graysrgb" => Some(Builtin::SGray), "dotgain20" | "dotgain20compat" | "dotgain" => Some(Builtin::DotGain20Compat), + "aces-cg" | "acescg" => Some(Builtin::AcesCg), "lab" | "labd50" | "cielab" => Some(Builtin::LabD50), "cmyk" | "coatedcmyk" | "defaultcmyk" | "photocraftcoatedcmyk" => Some(Builtin::CoatedCmyk), _ => None, @@ -113,7 +119,7 @@ impl Builtin { /// The profile (built once per process). pub fn profile(self) -> &'static Profile { - static CELLS: [OnceLock; 11] = [const { OnceLock::new() }; 11]; + static CELLS: [OnceLock; 12] = [const { OnceLock::new() }; 12]; // `ALL` lists the variants in declaration order (checked by `all_in_declaration_order`), // so the discriminant is the cell index. CELLS[self as usize].get_or_init(|| build(self)) @@ -140,7 +146,7 @@ pub fn srgb() -> &'static Profile { /// only assigned to such files, never offered as a working space. pub fn linear_gray() -> &'static Profile { static PROFILE: OnceLock = OnceLock::new(); - PROFILE.get_or_init(|| gray("Linear Gray (Photocraft)", Curve::Gamma(1.0))) + PROFILE.get_or_init(|| gray("Linear Gray (PhotoCraft)", Curve::Gamma(1.0))) } /// The ICC D65 used for the RGB spaces (chromaticity 0.3127, 0.3290). @@ -256,12 +262,13 @@ fn lab() -> Profile { pub const COATED_CMYK_ICC: &[u8] = include_bytes!("../profiles/photocraft-coated-cmyk.icc"); fn build(b: Builtin) -> Profile { - let (srgb_p, p3, adobe, romm, bt2020) = ( + let (srgb_p, p3, adobe, romm, bt2020, ap1) = ( [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]], [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]], [[0.64, 0.33], [0.21, 0.71], [0.15, 0.06]], [[0.7347, 0.2653], [0.1596, 0.8404], [0.0366, 0.0001]], [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]], + [[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]], ); match b { Builtin::Srgb => rgb(b.description(), srgb_p, d65(), srgb_trc()), @@ -269,6 +276,9 @@ fn build(b: Builtin) -> Profile { Builtin::AdobeRgbCompat => rgb(b.description(), adobe, d65(), Curve::Gamma(563.0 / 256.0)), Builtin::ProPhotoCompat => rgb(b.description(), romm, math::D50, Curve::Gamma(1.8)), Builtin::LinearSrgb => rgb(b.description(), srgb_p, d65(), Curve::Gamma(1.0)), + // ACEScg is scene-linear on the AP1 primaries with the ACES white point (~D60, + // xy 0.32168/0.33767, the ACES 2065-1 neutral). + Builtin::AcesCg => rgb(b.description(), ap1, math::xy_to_xyz([0.32168, 0.33767]), Curve::Gamma(1.0)), Builtin::Rec2020 => { // Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below. let a = 1.099; 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/profile.rs b/crates/cms/src/profile.rs index 99f5710e5d..cdd7d5273a 100644 --- a/crates/cms/src/profile.rs +++ b/crates/cms/src/profile.rs @@ -429,10 +429,18 @@ impl<'a> Reader<'a> { Ok(self.u32(off)? as i32 as f64 / 65536.0) } fn xyz(&self, off: usize) -> Result<[f64; 3], CmsError> { - Ok([self.s15f16(off)?, self.s15f16(off + 4)?, self.s15f16(off + 8)?]) + let r = Reader { b: self.slice(off, 12)? }; + Ok([r.s15f16(0)?, r.s15f16(4)?, r.s15f16(8)?]) } } +/// `base + n * size` for a count or offset read from the file, or `Truncated` when it does not +/// fit a `usize`. Such fields reach `u32::MAX`, which overflows on 32-bit targets (wasm32): +/// a panic with overflow checks on, a wrapped offset that reads the wrong bytes without (#4976). +fn extent(base: usize, n: usize, size: usize) -> Result { + n.checked_mul(size).and_then(|len| base.checked_add(len)).ok_or(CmsError::Truncated) +} + fn sig(s: &[u8; 4]) -> u32 { u32::from_be_bytes(*s) } @@ -572,14 +580,15 @@ fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> { match r.slice(0, 4)? { b"curv" => { let n = r.u32(8)? as usize; + // Before any arithmetic on it: `n * 2` overflows a 32-bit `usize` (#4976). + if n > 1 << 20 { + return Err(CmsError::Invalid("curve too long".into())); + } let len = 12 + n * 2; match n { 0 => Ok((Curve::Identity, len)), 1 => Ok((Curve::Gamma(r.u16(12)? as f64 / 256.0), len)), _ => { - if n > 1 << 20 { - return Err(CmsError::Invalid("curve too long".into())); - } let s = r.slice(12, n * 2)?; let t = s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(); Ok((Curve::Table(t), len)) @@ -684,36 +693,8 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result { return Err(CmsError::Invalid("bad lutAB dimensions".into())); } let (ob, om, omc, oc, oa) = (r.u32(12)? as usize, r.u32(16)? as usize, r.u32(20)? as usize, r.u32(24)? as usize, r.u32(28)? as usize); - let matrix = |off: usize| -> Result { - let mut m = vec![0.0; 9]; - for (k, v) in m.iter_mut().enumerate() { - *v = r.s15f16(off + k * 4)?; - } - let offset = vec![r.s15f16(off + 36)?, r.s15f16(off + 40)?, r.s15f16(off + 44)?]; - Ok(Stage::Matrix { rows: 3, cols: 3, m, offset }) - }; - let clut = |off: usize, cin: usize, cout: usize| -> Result { - let mut grid = Vec::with_capacity(cin); - for k in 0..cin { - let g = r.u8(off + k)? as usize; - if g < 1 { - return Err(CmsError::Invalid("zero grid points".into())); - } - grid.push(g); - } - let prec = r.u8(off + 16)? as usize; - let nodes: u128 = grid.iter().map(|g| *g as u128).product(); - if nodes * cout as u128 > MAX_CLUT_VALUES as u128 { - return Err(CmsError::Invalid("CLUT too large".into())); - } - let total = nodes as usize * cout; - let data: Vec = match prec { - 1 => r.slice(off + 20, total)?.iter().map(|b| *b as f32 / 255.0).collect(), - 2 => r.slice(off + 20, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(), - _ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))), - }; - Ok(Stage::Clut(Clut::new(grid, cout, data))) - }; + let matrix = |off: usize| lut_ab_matrix(&r, off); + let clut = |off: usize, cin: usize, cout: usize| lut_ab_clut(&r, off, cin, cout); let mut stages = Vec::new(); if ty == b"mAB " { // A curves → CLUT → M curves → matrix → B curves. @@ -760,6 +741,45 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result { } } +/// The 3×3 matrix and offset of a `mAB `/`mBA ` element at `off`, an offset stored in the file. +fn lut_ab_matrix(r: &Reader, off: usize) -> Result { + // One bounds-checked slice, then offsets inside it: `off + 44` need not fit a `usize`. + let e = Reader { b: r.slice(off, 48)? }; + let mut m = vec![0.0; 9]; + for (k, v) in m.iter_mut().enumerate() { + *v = e.s15f16(k * 4)?; + } + let offset = vec![e.s15f16(36)?, e.s15f16(40)?, e.s15f16(44)?]; + Ok(Stage::Matrix { rows: 3, cols: 3, m, offset }) +} + +/// The CLUT of a `mAB `/`mBA ` element at `off`, an offset stored in the file. +fn lut_ab_clut(r: &Reader, off: usize, cin: usize, cout: usize) -> Result { + // Grid points (16 bytes), precision and padding; the samples follow. + let head = Reader { b: r.slice(off, 20)? }; + let mut grid = Vec::with_capacity(cin); + for k in 0..cin { + let g = head.u8(k)? as usize; + if g < 1 { + return Err(CmsError::Invalid("zero grid points".into())); + } + grid.push(g); + } + let prec = head.u8(16)? as usize; + let nodes: u128 = grid.iter().map(|g| *g as u128).product(); + if nodes * cout as u128 > MAX_CLUT_VALUES as u128 { + return Err(CmsError::Invalid("CLUT too large".into())); + } + let total = nodes as usize * cout; + let samples = extent(off, 20, 1)?; + let data: Vec = match prec { + 1 => r.slice(samples, total)?.iter().map(|b| *b as f32 / 255.0).collect(), + 2 => r.slice(samples, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(), + _ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))), + }; + Ok(Stage::Clut(Clut::new(grid, cout, data))) +} + /// Text from `desc` (textDescriptionType), `mluc` (English or first record) or `text`. fn parse_text(d: &[u8]) -> String { let r = Reader { b: d }; @@ -775,8 +795,10 @@ fn parse_text(d: &[u8]) -> String { let (Ok(n), Ok(size)) = (r.u32(8), r.u32(12)) else { return String::new() }; let mut best: Option<(usize, usize)> = None; for k in 0..(n as usize).min(256) { - let o = 16 + k * size as usize; - let (Ok(lang), Ok(len), Ok(off)) = (r.u16(o), r.u32(o + 4), r.u32(o + 8)) else { break }; + // The record size comes from the file: `k * size` can overflow a 32-bit `usize`. + let Ok(rec) = extent(16, k, size as usize).and_then(|o| r.slice(o, 12)) else { break }; + let rec = Reader { b: rec }; + let (Ok(lang), Ok(len), Ok(off)) = (rec.u16(0), rec.u32(4), rec.u32(8)) else { break }; if best.is_none() || lang == u16::from_be_bytes(*b"en") { best = Some((off as usize, len as usize)); if lang == u16::from_be_bytes(*b"en") { @@ -792,3 +814,93 @@ fn parse_text(d: &[u8]) -> String { _ => String::new(), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn be(v: u32) -> [u8; 4] { + v.to_be_bytes() + } + + /// #4976: offsets and counts stored in a profile reach `u32::MAX`, which is `usize::MAX` on + /// 32-bit targets. The arithmetic on them must report a truncated profile, not overflow. + #[test] + fn offsets_at_the_top_of_the_address_space_are_truncated_not_overflowed() { + let bytes = [0u8; 64]; + let r = Reader { b: &bytes }; + for off in [usize::MAX, usize::MAX - 3, usize::MAX - 11, usize::MAX - 19, usize::MAX - 47] { + assert_eq!(r.xyz(off), Err(CmsError::Truncated), "xyz at {off}"); + assert!(matches!(lut_ab_matrix(&r, off), Err(CmsError::Truncated)), "matrix at {off}"); + assert!(matches!(lut_ab_clut(&r, off, 3, 3), Err(CmsError::Truncated)), "clut at {off}"); + } + assert_eq!(extent(16, usize::MAX, 12), Err(CmsError::Truncated)); + assert_eq!(extent(usize::MAX, 1, 1), Err(CmsError::Truncated)); + assert_eq!(extent(16, 3, 12), Ok(52)); + } + + #[test] + fn a_curve_count_is_checked_before_its_length_is_computed() { + let mut d = b"curv\0\0\0\0".to_vec(); + d.extend(be(u32::MAX)); + assert_eq!(parse_curve(&d).map(|c| c.1), Err(CmsError::Invalid("curve too long".into()))); + // Controls: the identity and a two-point table still parse with their lengths. + let mut d = b"curv\0\0\0\0".to_vec(); + d.extend(be(0)); + assert_eq!(parse_curve(&d).map(|c| c.1), Ok(12)); + let mut d = b"curv\0\0\0\0".to_vec(); + d.extend(be(2)); + d.extend([0, 0, 0xFF, 0xFF]); + assert_eq!(parse_curve(&d).map(|c| c.1), Ok(16)); + } + + #[test] + fn lut_ab_elements_at_hostile_offsets_are_rejected() { + // A 3→3 lutAtoB with identity B curves at byte 32; then its B-curve, matrix and CLUT + // offsets in turn point at the end of a 32-bit file. + let lut = |b: u32, matrix: u32, m: u32, clut: u32, a: u32| { + let mut d = b"mAB \0\0\0\0".to_vec(); + d.extend([3, 3, 0, 0]); + for v in [b, matrix, m, clut, a] { + d.extend(be(v)); + } + for _ in 0..3 { + d.extend(b"curv\0\0\0\0"); + d.extend(be(0)); + } + d + }; + assert!(parse_lut(&lut(32, 0, 0, 0, 0), true).is_ok(), "the control parses"); + let top = u32::MAX - 8; + assert!(parse_lut(&lut(top, 0, 0, 0, 0), true).is_err(), "B curves offset"); + assert!(parse_lut(&lut(32, top, 32, 0, 0), true).is_err(), "matrix offset"); + assert!(parse_lut(&lut(32, 0, 0, top, 32), true).is_err(), "CLUT offset"); + } + + #[test] + fn multilingual_records_with_a_huge_record_size_give_the_first_record() { + let record = |lang: &[u8; 2], len: u32, off: u32| { + let mut r = lang.to_vec(); + r.extend(b"US"); + r.extend(be(len)); + r.extend(be(off)); + r + }; + // Two records of the standard 12 bytes: the English one wins. + let mut d = b"mluc\0\0\0\0".to_vec(); + d.extend(be(2)); + d.extend(be(12)); + d.extend(record(b"de", 4, 40)); + d.extend(record(b"en", 4, 44)); + d.extend([0, b'D', 0, b'E', 0, b'E', 0, b'N']); + assert_eq!(parse_text(&d), "EN"); + // A record size of u32::MAX: the second record lies past any file, so the first is used. + d[12..16].copy_from_slice(&be(u32::MAX)); + assert_eq!(parse_text(&d), "DE"); + // No records at all. + let mut d = b"mluc\0\0\0\0".to_vec(); + d.extend(be(u32::MAX)); + d.extend(be(u32::MAX)); + assert_eq!(parse_text(&d), ""); + } +} diff --git a/crates/cms/src/synth.rs b/crates/cms/src/synth.rs index 657c829f31..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 @@ -29,7 +29,7 @@ use crate::math; use crate::pipeline::Stage; use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass}; -pub const DESCRIPTION: &str = "Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)"; +pub const DESCRIPTION: &str = "PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)"; /// Parameters of the synthetic profile. #[derive(Clone, Debug)] diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 7486d46504..539365c95b 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -38,15 +38,16 @@ 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 | +| DDS | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | yes (BC1–BC3, when `EncodeOptions::dds` asks for one) | 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 | | QOI | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` | | OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` | | Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` | | FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in | | DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in | +| Cineon | yes | **no** | U16 (10-bit code values) | Gray, RGB | no | no | no | no | no | no | n/a | built in | | AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` | | HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` | @@ -72,7 +73,15 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them (8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5), with the legacy header or the DX10 one. Everything opens as U8; the other mip levels are ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats - (BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing is not provided. + (BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing (`EncodeOptions::dds`) makes + a 2D texture with the legacy header: uncompressed by default (BGRA, BGRX, or luminance with or + without alpha, so it reads back exactly), or BC1/BC2/BC3. The block encoder is our own, from + Microsoft's public block-compression documentation: RGB565 endpoints fitted along each block's + principal axis and refined by least squares, indices picked against the palette the reader + decodes; BC1 turns texels under half opacity into its transparent black, and BC3 picks + whichever alpha mode fits. `mipmaps` adds the full chain down to 1×1, each level a box filter + of the one above (the last column and row of an odd-sized level average three pixels, so none + is dropped). Cubemaps, arrays, BC4–BC7 and sRGB (DX10-only) formats aren't written. * **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3` opens as RGB and any other plane count opens the first plane with `DecodeWarning::MorePages`. @@ -93,7 +102,15 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma. Other descriptors, signed or run-length-encoded data and other bit depths return - `CodecError::Unsupported` naming the field. Cineon (`.cin`) is not supported. + `CodecError::Unsupported` naming the field. + +* **Cineon (read-only).** Kodak's 10-bit logarithmic film-scan format (the one DPX grew from), + read from the same Kodak header family: the generic 1024-byte header, big-endian, one (Gray) + or three (RGB) channels, three 10-bit samples packed into each 32-bit word, lines on a word + boundary. The 10-bit code values open as U16 `round(c × 65535 / 1023)`; the file's black/white + reference points are a log-conversion choice and are not applied. A depth other than 10 bits or + a channel count other than 1/3 returns `CodecError::Unsupported`; truncation errors rather than + panicking. There is no Cineon writer (`ASYMMETRIC_EXCEPTIONS`); save as DPX, TIFF or EXR. * **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes diff --git a/crates/codecs/src/codecs/dds.rs b/crates/codecs/src/codecs/dds.rs index efe93c7f2e..d35c7d1092 100644 --- a/crates/codecs/src/codecs/dds.rs +++ b/crates/codecs/src/codecs/dds.rs @@ -1,13 +1,24 @@ -//! DirectDraw Surface (DDS) reader: the top mip level of a 2D texture, either uncompressed -//! (8 to 32 bits per pixel described by channel masks, RGB or luminance) or block-compressed -//! BC1/BC2/BC3 (DXT1/DXT3/DXT5), with or without the DX10 header. Read-only. Cubemaps, -//! texture arrays and volume textures are refused rather than flattened to one face or slice. +//! DirectDraw Surface (DDS) textures, after Microsoft's public DDS documentation ("DDS", +//! "DDS_HEADER", "DDS_PIXELFORMAT", "DDS_HEADER_DXT10"). +//! +//! Reading: the top mip level of a 2D texture, either uncompressed (8 to 32 bits per pixel +//! described by channel masks, RGB or luminance) or block-compressed BC1/BC2/BC3 +//! (DXT1/DXT3/DXT5), with or without the DX10 header. Cubemaps, texture arrays and volume +//! textures are refused rather than flattened to one face or slice. +//! +//! Writing: a 2D texture, uncompressed (BGRA or BGRX, luminance with or without alpha) or +//! BC1/BC2/BC3, optionally with its full mip chain. The legacy header describes all of these, +//! so no DX10 header is written; formats only DXGI can name (BC4–BC7, sRGB) aren't written. +use std::borrow::Cow; + +use super::dds_bc::{self, Block, Mask}; use crate::SampleType; use crate::error::CodecError; +use crate::fidelity::Plan; use crate::format::Format; use crate::image::{ChannelLayout, Image}; -use crate::options::Limits; +use crate::options::{DdsCompression, EncodeOptions, Limits}; /// `DDS ` plus the 124-byte DDS_HEADER. const HEADER: usize = 128; @@ -224,27 +235,12 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { Image::from_u8(width, height, layout, pixels) } -fn rgb565(c: u16) -> [u8; 3] { - let scale = |v: u16, max: u16| ((u32::from(v) * 255 + u32::from(max) / 2) / u32::from(max)) as u8; - [scale(c >> 11, 31), scale((c >> 5) & 63, 63), scale(c & 31, 31)] -} - /// A BC1 colour block. BC2 and BC3 colour blocks always have four colours; only BC1's /// `c0 <= c1` order selects three colours and transparent black. fn bc1(block: &[u8], one_bit_alpha: bool) -> [[u8; 4]; 16] { let byte = |i: usize| block.get(i).copied().unwrap_or(0); let (c0, c1) = (u16::from_le_bytes([byte(0), byte(1)]), u16::from_le_bytes([byte(2), byte(3)])); - let (a, b) = (rgb565(c0), rgb565(c1)); - let mix = |wa: u32, wb: u32| { - let d = wa + wb; - let at = |i: usize| ((wa * u32::from(a[i]) + wb * u32::from(b[i]) + d / 2) / d) as u8; - [at(0), at(1), at(2), 255] - }; - let palette = if c0 > c1 || !one_bit_alpha { - [[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(2, 1), mix(1, 2)] - } else { - [[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(1, 1), [0; 4]] - }; + let palette = dds_bc::colour_palette(c0, c1, c0 > c1 || !one_bit_alpha); let indices = u32::from_le_bytes([byte(4), byte(5), byte(6), byte(7)]); std::array::from_fn(|t| palette[(indices >> (2 * t)) as usize & 3]) } @@ -259,21 +255,9 @@ fn bc2_alpha(block: &[u8]) -> [u8; 16] { /// 0 and 255 when `a0 <= a1`. fn bc3_alpha(block: &[u8]) -> [u8; 16] { let byte = |i: usize| block.get(i).copied().unwrap_or(0); - let (a0, a1) = (u32::from(byte(0)), u32::from(byte(1))); - let mut palette = [a0, a1, 0, 0, 0, 0, 0, 255]; - if a0 > a1 { - for (k, v) in palette.iter_mut().enumerate().skip(2) { - let k = k as u32; - *v = ((8 - k) * a0 + (k - 1) * a1 + 3) / 7; - } - } else { - for (k, v) in palette.iter_mut().enumerate().take(6).skip(2) { - let k = k as u32; - *v = ((6 - k) * a0 + (k - 1) * a1 + 2) / 5; - } - } + let palette = dds_bc::alpha_palette(byte(0), byte(1)); let bits = u64::from_le_bytes([byte(2), byte(3), byte(4), byte(5), byte(6), byte(7), 0, 0]); - std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize] as u8) + std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize]) } fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] { @@ -282,3 +266,260 @@ fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] { } texels } + +const DDSD_CAPS: u32 = 0x1; +const DDSD_HEIGHT: u32 = 0x2; +const DDSD_WIDTH: u32 = 0x4; +const DDSD_PITCH: u32 = 0x8; +const DDSD_PIXELFORMAT: u32 = 0x1000; +const DDSD_MIPMAPCOUNT: u32 = 0x2_0000; +const DDSD_LINEARSIZE: u32 = 0x8_0000; +const DDSCAPS_COMPLEX: u32 = 0x8; +const DDSCAPS_TEXTURE: u32 = 0x1000; +const DDSCAPS_MIPMAP: u32 = 0x40_0000; + +fn encode_err(reason: impl std::fmt::Display) -> CodecError { + CodecError::encode(Format::Dds, reason) +} + +/// How a written texture stores its pixels. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Store { + /// 8-bit luminance. + Luminance, + /// 8-bit luminance, then 8-bit alpha. + LuminanceAlpha, + /// Blue, green, red and an unused byte (written as 255, so a reader that takes it for + /// alpha still sees an opaque texture). + Bgrx, + Bgra, + Bc1, + Bc2, + Bc3, +} + +impl Store { + /// The storage for an image of `layout`, as [`crate::fidelity::plan`] chose it. + fn new(compression: DdsCompression, layout: ChannelLayout) -> Result { + Ok(match (compression, layout) { + (DdsCompression::None, ChannelLayout::Gray) => Store::Luminance, + (DdsCompression::None, ChannelLayout::GrayA) => Store::LuminanceAlpha, + (DdsCompression::None, ChannelLayout::Rgb) => Store::Bgrx, + (DdsCompression::None, ChannelLayout::Rgba) => Store::Bgra, + (DdsCompression::Bc1, ChannelLayout::Rgba) => Store::Bc1, + (DdsCompression::Bc2, ChannelLayout::Rgba) => Store::Bc2, + (DdsCompression::Bc3, ChannelLayout::Rgba) => Store::Bc3, + (_, layout) => return Err(encode_err(format!("{layout:?} pixels can't be written as `{}`", compression.name()))), + }) + } + + /// Bytes per pixel, or per 4×4 block for a block compression. + fn unit(self) -> usize { + match self { + Store::Luminance => 1, + Store::LuminanceAlpha => 2, + Store::Bgrx | Store::Bgra => 4, + Store::Bc1 => 8, + Store::Bc2 | Store::Bc3 => 16, + } + } + + fn is_block(self) -> bool { + matches!(self, Store::Bc1 | Store::Bc2 | Store::Bc3) + } + + /// Bytes of one row of pixels (or of 4×4 blocks) of a level `width` wide. + fn row_bytes(self, width: usize) -> Option { + if self.is_block() { width.div_ceil(4).checked_mul(self.unit()) } else { width.checked_mul(self.unit()) } + } + + fn level_bytes(self, width: usize, height: usize) -> Option { + let rows = if self.is_block() { height.div_ceil(4) } else { height }; + self.row_bytes(width)?.checked_mul(rows) + } + + /// DDS_PIXELFORMAT: flags, FourCC, bits per pixel and the red, green, blue and alpha masks. + fn pixel_format(self) -> (u32, [u8; 4], u32, [u32; 4]) { + match self { + Store::Luminance => (DDPF_LUMINANCE, [0; 4], 8, [0xff, 0, 0, 0]), + Store::LuminanceAlpha => (DDPF_LUMINANCE | DDPF_ALPHAPIXELS, [0; 4], 16, [0xff, 0, 0, 0xff00]), + Store::Bgrx => (DDPF_RGB, [0; 4], 32, [0xff_0000, 0xff00, 0xff, 0]), + Store::Bgra => (DDPF_RGB | DDPF_ALPHAPIXELS, [0; 4], 32, [0xff_0000, 0xff00, 0xff, 0xff00_0000]), + Store::Bc1 => (DDPF_FOURCC, *b"DXT1", 0, [0; 4]), + Store::Bc2 => (DDPF_FOURCC, *b"DXT3", 0, [0; 4]), + Store::Bc3 => (DDPF_FOURCC, *b"DXT5", 0, [0; 4]), + } + } +} + +/// Levels in a full mip chain: the larger side halves down to 1. +fn mip_levels(width: u32, height: u32) -> u32 { + 32 - width.max(height).max(1).leading_zeros() +} + +fn half((w, h): (usize, usize)) -> (usize, usize) { + ((w / 2).max(1), (h / 2).max(1)) +} + +pub(crate) fn encode(image: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { + let img = image.converted(plan.layout, SampleType::U8); + let pixels = img.as_u8().ok_or_else(|| encode_err("the image was not converted to 8 bits per channel"))?; + let store = Store::new(opts.dds.compression, img.layout())?; + let (width, height) = img.dimensions(); + let levels = if opts.dds.mipmaps { mip_levels(width, height) } else { 1 }; + let top = (width as usize, height as usize); + // Every level's size, checked before anything is allocated. + let mut sizes = Vec::new(); + let mut size = top; + for _ in 0..levels { + sizes.push(store.level_bytes(size.0, size.1).ok_or_else(|| encode_err("texture size overflow"))?); + size = half(size); + } + let total = sizes.iter().try_fold(HEADER, |total, &s| total.checked_add(s)).ok_or_else(|| encode_err("texture size overflow"))?; + let header = header(width, height, store, levels, sizes.first().copied().unwrap_or(0))?; + let mut out = crate::options::alloc_zeroed::(total)?; + out.get_mut(..HEADER).ok_or_else(|| encode_err("output buffer too small"))?.copy_from_slice(&header); + + let channels = img.layout().channels(); + let mut level = Cow::Borrowed(pixels); + let (mut size, mut at) = (top, HEADER); + for (i, &bytes) in sizes.iter().enumerate() { + let dst = out.get_mut(at..at + bytes).ok_or_else(|| encode_err("output buffer too small"))?; + write_level(store, &level, size, dst)?; + at += bytes; + if i + 1 < sizes.len() { + let next = half(size); + level = Cow::Owned(downsample(&level, size, next, channels)?); + size = next; + } + } + Ok(out) +} + +/// The `DDS ` signature and DDS_HEADER. `top` is the top level's size in bytes. +fn header(width: u32, height: u32, store: Store, levels: u32, top: usize) -> Result<[u8; HEADER], CodecError> { + let mut b = [0u8; HEADER]; + let mut put = |at: usize, v: u32| { + if let Some(d) = b.get_mut(at..at + 4) { + d.copy_from_slice(&v.to_le_bytes()); + } + }; + // Uncompressed: the pitch of one row. Compressed: the whole top level (its "linear size"). + let (size_flag, pitch_or_size) = if store.is_block() { (DDSD_LINEARSIZE, Some(top)) } else { (DDSD_PITCH, store.row_bytes(width as usize)) }; + let pitch_or_size = + pitch_or_size.and_then(|v| u32::try_from(v).ok()).ok_or_else(|| encode_err(format!("a {width}x{height} level is too large for a DDS header")))?; + let mipmapped = levels > 1; + put(4, 124); + put(8, DDSD_CAPS | DDSD_HEIGHT | DDSD_WIDTH | DDSD_PIXELFORMAT | size_flag | if mipmapped { DDSD_MIPMAPCOUNT } else { 0 }); + put(12, height); + put(16, width); + put(20, pitch_or_size); + put(28, levels); + let (flags, fourcc, bits, masks) = store.pixel_format(); + put(76, 32); + put(80, flags); + put(84, u32::from_le_bytes(fourcc)); + put(88, bits); + for (i, mask) in masks.into_iter().enumerate() { + put(92 + 4 * i, mask); + } + put(108, DDSCAPS_TEXTURE | if mipmapped { DDSCAPS_COMPLEX | DDSCAPS_MIPMAP } else { 0 }); + b[..4].copy_from_slice(b"DDS "); + Ok(b) +} + +/// Runs `f` on each `len`-byte row of `out` with its index, in parallel on native targets. +fn rows(out: &mut [u8], len: usize, f: impl Fn(usize, &mut [u8]) + Send + Sync) { + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_mut(len.max(1)).enumerate().for_each(|(i, row)| f(i, row)); + } + #[cfg(target_arch = "wasm32")] + for (i, row) in out.chunks_mut(len.max(1)).enumerate() { + f(i, row); + } +} + +/// Writes one level of `width` × `height` pixels (in the image's layout) into `dst`. +fn write_level(store: Store, pixels: &[u8], (width, height): (usize, usize), dst: &mut [u8]) -> Result<(), CodecError> { + let row_bytes = store.row_bytes(width).ok_or_else(|| encode_err("texture size overflow"))?; + match store { + Store::Luminance | Store::LuminanceAlpha => { + // The image's gray (and alpha) samples, in the same order. + let src = pixels.get(..dst.len()).ok_or_else(|| encode_err("the level is smaller than its size"))?; + dst.copy_from_slice(src); + } + Store::Bgrx | Store::Bgra => { + let channels = if store == Store::Bgra { 4 } else { 3 }; + rows(dst, row_bytes, |y, row| { + let src = pixels.get(y * width * channels..).unwrap_or_default(); + for (d, s) in row.as_chunks_mut::<4>().0.iter_mut().zip(src.chunks_exact(channels)) { + let byte = |i: usize| s.get(i).copied().unwrap_or(255); + *d = [byte(2), byte(1), byte(0), byte(3)]; + } + }); + } + Store::Bc1 | Store::Bc2 | Store::Bc3 => { + let (texels, _) = pixels.as_chunks::<4>(); + rows(dst, row_bytes, |by, row| { + for (bx, d) in row.chunks_exact_mut(store.unit()).enumerate() { + let (block, inside) = gather(texels, (width, height), bx, by); + let encoded: &[u8] = match store { + Store::Bc1 => &dds_bc::encode_bc1(&block, inside), + Store::Bc2 => &dds_bc::encode_bc2(&block, inside), + _ => &dds_bc::encode_bc3(&block, inside), + }; + for (d, s) in d.iter_mut().zip(encoded) { + *d = *s; + } + } + }); + } + } + Ok(()) +} + +/// Block (`bx`, `by`)'s texels, and which of them lie inside the image. +fn gather(texels: &[[u8; 4]], (width, height): (usize, usize), bx: usize, by: usize) -> (Block, Mask) { + let mut block = [[0u8; 4]; 16]; + let mut inside: Mask = 0; + for (t, texel) in block.iter_mut().enumerate() { + let (x, y) = (bx * 4 + t % 4, by * 4 + t / 4); + if x < width + && y < height + && let Some(p) = texels.get(y * width + x) + { + *texel = *p; + inside |= 1 << t; + } + } + (block, inside) +} + +/// The next mip level: each pixel the average of the 2×2 pixels it covers, or of 2×3, 3×2 or +/// 3×3 along the last column and row of an odd-sized level, so no pixel is dropped. +fn downsample(src: &[u8], (w, h): (usize, usize), (nw, nh): (usize, usize), channels: usize) -> Result, CodecError> { + let len = nw.checked_mul(nh).and_then(|n| n.checked_mul(channels)).ok_or_else(|| encode_err("mip level size overflow"))?; + let mut dst = crate::options::alloc_zeroed::(len)?; + let span = |i: usize, n: usize, size: usize| 2 * i..=if i + 1 == n { size.saturating_sub(1) } else { 2 * i + 1 }; + rows(&mut dst, nw * channels, |y, row| { + let ys = span(y, nh, h); + for (x, px) in row.chunks_exact_mut(channels.max(1)).enumerate() { + let xs = span(x, nw, w); + for (c, v) in px.iter_mut().enumerate() { + let (mut sum, mut n) = (0u32, 0u32); + for sy in ys.clone() { + for sx in xs.clone() { + if let Some(&s) = src.get((sy * w + sx) * channels + c) { + sum += u32::from(s); + n += 1; + } + } + } + *v = ((sum + n / 2) / n.max(1)) as u8; + } + } + }); + Ok(dst) +} diff --git a/crates/codecs/src/codecs/dds_bc.rs b/crates/codecs/src/codecs/dds_bc.rs new file mode 100644 index 0000000000..bbd55b4bf3 --- /dev/null +++ b/crates/codecs/src/codecs/dds_bc.rs @@ -0,0 +1,368 @@ +//! BC1, BC2 and BC3 (DXT1, DXT3, DXT5) blocks, after Microsoft's public block compression +//! documentation ("Block Compression (Direct3D 10)" and "Texture Block Compression in Direct3D +//! 11"): the palettes the DDS reader decodes with, and the block encoder the DDS writer uses. +//! +//! The encoder fits a block's colours with two RGB565 endpoints along their principal axis, +//! refines them by least squares, and scores every candidate against the exact palette the +//! reader rebuilds, so the indices it picks are the best ones for what a decoder shows. + +/// The 16 texels of a 4×4 block, row by row, as RGBA. +pub(crate) type Block = [[u8; 4]; 16]; + +/// Bit `t` set for each texel `t` of a block that counts: edge blocks of a texture whose size is +/// not a multiple of four have texels outside the image. +pub(crate) type Mask = u16; + +pub(crate) fn rgb565(c: u16) -> [u8; 3] { + let scale = |v: u16, max: u16| ((u32::from(v) * 255 + u32::from(max) / 2) / u32::from(max)) as u8; + [scale(c >> 11, 31), scale((c >> 5) & 63, 63), scale(c & 31, 31)] +} + +/// The colours a colour block's indices select. Four-colour mode (BC1 with `c0 > c1`, and every +/// BC2 and BC3 block) adds two colours at thirds; three-colour mode adds the midpoint and +/// transparent black. +pub(crate) fn colour_palette(c0: u16, c1: u16, four: bool) -> [[u8; 4]; 4] { + let (a, b) = (rgb565(c0), rgb565(c1)); + let mix = |wa: u32, wb: u32| { + let d = wa + wb; + let at = |i: usize| ((wa * u32::from(a[i]) + wb * u32::from(b[i]) + d / 2) / d) as u8; + [at(0), at(1), at(2), 255] + }; + if four { + [[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(2, 1), mix(1, 2)] + } else { + [[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(1, 1), [0; 4]] + } +} + +/// A BC3 alpha block's eight values: six interpolated at sevenths when `a0 > a1`, else four +/// interpolated at fifths followed by 0 and 255. +pub(crate) fn alpha_palette(a0: u8, a1: u8) -> [u8; 8] { + let (a0, a1) = (u32::from(a0), u32::from(a1)); + let mut palette = [a0, a1, 0, 0, 0, 0, 0, 255]; + if a0 > a1 { + for (k, v) in palette.iter_mut().enumerate().skip(2) { + let k = k as u32; + *v = ((8 - k) * a0 + (k - 1) * a1 + 3) / 7; + } + } else { + for (k, v) in palette.iter_mut().enumerate().take(6).skip(2) { + let k = k as u32; + *v = ((6 - k) * a0 + (k - 1) * a1 + 2) / 5; + } + } + palette.map(|v| v as u8) +} + +/// A BC1 block. Texels under half opacity become BC1's transparent black, which needs the +/// three-colour mode; an opaque block takes whichever mode fits its colours better. +pub(crate) fn encode_bc1(block: &Block, inside: Mask) -> [u8; 8] { + let transparent = select(block, inside, |t| t[3] < 128); + colour_block(block, inside & !transparent, transparent, true) +} + +/// A BC2 block: four bits of alpha per texel, then a four-colour block. +pub(crate) fn encode_bc2(block: &Block, inside: Mask) -> [u8; 16] { + let bits = block.iter().enumerate().fold(0u64, |bits, (t, texel)| bits | ((u64::from(texel[3]) * 15 + 127) / 255) << (4 * t)); + join(bits.to_le_bytes(), colour_block(block, inside, 0, false)) +} + +/// A BC3 block: an interpolated alpha block, then a four-colour block. +pub(crate) fn encode_bc3(block: &Block, inside: Mask) -> [u8; 16] { + join(alpha_block(block, inside), colour_block(block, inside, 0, false)) +} + +fn join(a: [u8; 8], b: [u8; 8]) -> [u8; 16] { + std::array::from_fn(|i| if i < 8 { a.get(i) } else { b.get(i - 8) }.copied().unwrap_or(0)) +} + +/// The texels of `inside` for which `f` holds. +fn select(block: &Block, inside: Mask, f: impl Fn(&[u8; 4]) -> bool) -> Mask { + block.iter().enumerate().filter(|(t, texel)| inside >> t & 1 != 0 && f(texel)).fold(0, |m, (t, _)| m | 1 << t) +} + +fn rgb_of(texel: &[u8; 4]) -> [f32; 3] { + [f32::from(texel[0]), f32::from(texel[1]), f32::from(texel[2])] +} + +/// RGB565 of a colour, rounding each channel to the nearest level. +fn to565(c: [f32; 3]) -> u16 { + let q = |v: f32, max: f32| (v.clamp(0.0, 255.0) * max / 255.0).round() as u16; + q(c[0], 31.0) << 11 | q(c[1], 63.0) << 5 | q(c[2], 31.0) +} + +fn distance(a: &[u8; 4], b: &[u8; 4]) -> u32 { + (0..3).map(|i| u32::from(a[i].abs_diff(b[i])).pow(2)).sum() +} + +/// A colour block (endpoints, then 2-bit indices) for the `opaque` texels' colours, with the +/// `transparent` ones on BC1's transparent index. `bc1` allows the three-colour mode; BC2 and +/// BC3 always decode four colours. +fn colour_block(block: &Block, opaque: Mask, transparent: Mask, bc1: bool) -> [u8; 8] { + let colours: Vec<[f32; 3]> = block.iter().enumerate().filter(|(t, _)| opaque >> t & 1 != 0).map(|(_, texel)| rgb_of(texel)).collect(); + if colours.is_empty() { + // Nothing visible: every texel on the transparent index (or index 0 of black). + let indices = if transparent != 0 { u32::MAX } else { 0 }; + return pack(0, 0, indices); + } + let (lo, hi) = principal_endpoints(&colours); + let mut candidates = vec![(to565(lo), to565(hi))]; + let modes: &[bool] = if transparent != 0 { + &[false] + } else if bc1 { + &[true, false] + } else { + &[true] + }; + let mut best: Option<(u32, [u8; 8])> = None; + // Each refinement starts from the best block so far: its indices give the least-squares + // endpoints, which become one more candidate. + for round in 0..3 { + for &(e0, e1) in &candidates { + for &four in modes { + let (bytes, error) = fit(block, opaque, transparent, e0, e1, four); + if best.is_none_or(|(e, _)| error < e) { + best = Some((error, bytes)); + } + } + } + let Some((error, bytes)) = best else { break }; + if error == 0 || round == 2 { + break; + } + candidates = least_squares(block, opaque, &bytes).into_iter().collect(); + if candidates.is_empty() { + break; + } + } + best.map_or([0; 8], |(_, bytes)| bytes) +} + +/// Orders endpoints `e0`, `e1` for the mode (`c0 > c1` for four colours, `c0 <= c1` for three) +/// and picks each texel's nearest palette colour. Returns the block and its squared error. +fn fit(block: &Block, opaque: Mask, transparent: Mask, e0: u16, e1: u16, four: bool) -> ([u8; 8], u32) { + let (c0, c1) = if four == (e0 >= e1) { (e0, e1) } else { (e1, e0) }; + // Equal endpoints can't be in four-colour order; the palette is then one colour either way. + let palette = colour_palette(c0, c1, four && c0 > c1); + let choices = if four && c0 > c1 { 4 } else { 3 }; + let mut indices = 0u32; + let mut error = 0u32; + for (t, texel) in block.iter().enumerate() { + let index = if transparent >> t & 1 != 0 { + 3 + } else if opaque >> t & 1 != 0 { + let (i, d) = palette.iter().take(choices).enumerate().map(|(i, p)| (i, distance(texel, p))).min_by_key(|&(_, d)| d).unwrap_or((0, 0)); + error += d; + i as u32 + } else { + 0 + }; + indices |= index << (2 * t); + } + (pack(c0, c1, indices), error) +} + +fn pack(c0: u16, c1: u16, indices: u32) -> [u8; 8] { + let (c0, c1, i) = (c0.to_le_bytes(), c1.to_le_bytes(), indices.to_le_bytes()); + [c0[0], c0[1], c1[0], c1[1], i[0], i[1], i[2], i[3]] +} + +/// The ends of the colours' spread along their principal axis (the covariance's dominant +/// eigenvector, by power iteration), clamped to the RGB cube. +fn principal_endpoints(colours: &[[f32; 3]]) -> ([f32; 3], [f32; 3]) { + let n = colours.len().max(1) as f32; + let mean = [0, 1, 2].map(|i| colours.iter().map(|c| c[i]).sum::() / n); + let mut cov = [[0f32; 3]; 3]; + for c in colours { + let d = [c[0] - mean[0], c[1] - mean[1], c[2] - mean[2]]; + for (i, row) in cov.iter_mut().enumerate() { + for (j, v) in row.iter_mut().enumerate() { + *v += d[i] * d[j]; + } + } + } + let times = |a: [f32; 3]| [0, 1, 2].map(|i| cov[i][0] * a[0] + cov[i][1] * a[1] + cov[i][2] * a[2]); + let iterate = |start: usize| { + let mut axis = [0f32; 3]; + axis[start] = 1.0; + for _ in 0..8 { + let next = times(axis); + let len = next.iter().map(|v| v * v).sum::().sqrt(); + if !len.is_normal() { + break; + } + axis = next.map(|v| v / len); + } + axis + }; + // From each channel's axis, keeping the one the colours spread along most: a channel that + // varies on its own is already an eigenvector, so a single start there would never leave it + // even when the others vary more together. + let spread = |a: [f32; 3]| times(a).iter().zip(a).map(|(x, y)| x * y).sum::(); + let axis = [0, 1, 2].map(iterate).into_iter().max_by(|&a, &b| spread(a).total_cmp(&spread(b))).unwrap_or([1.0, 0.0, 0.0]); + let along = |c: &[f32; 3]| (c[0] - mean[0]) * axis[0] + (c[1] - mean[1]) * axis[1] + (c[2] - mean[2]) * axis[2]; + let (lo, hi) = colours.iter().map(along).fold((f32::MAX, f32::MIN), |(lo, hi), t| (lo.min(t), hi.max(t))); + let at = |t: f32| [0, 1, 2].map(|i| (mean[i] + t * axis[i]).clamp(0.0, 255.0)); + if lo > hi { (mean, mean) } else { (at(lo), at(hi)) } +} + +/// The endpoints that minimise the squared error for the indices `bytes` already chose: each +/// texel's index fixes its weights between the endpoints, giving 2×2 normal equations per +/// channel. `None` when the indices don't constrain both endpoints. +fn least_squares(block: &Block, opaque: Mask, bytes: &[u8; 8]) -> Option<(u16, u16)> { + let (c0, c1) = (u16::from_le_bytes([bytes[0], bytes[1]]), u16::from_le_bytes([bytes[2], bytes[3]])); + let indices = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]); + let four = c0 > c1; + // How much of c0 each index holds. + let weight = |i: u32| match (four, i) { + (_, 0) => Some(1.0f32), + (_, 1) => Some(0.0), + (true, 2) => Some(2.0 / 3.0), + (true, _) => Some(1.0 / 3.0), + (false, 2) => Some(0.5), + (false, _) => None, + }; + let (mut aa, mut ab, mut bb) = (0f32, 0f32, 0f32); + let (mut ax, mut bx) = ([0f32; 3], [0f32; 3]); + for (t, texel) in block.iter().enumerate() { + if opaque >> t & 1 == 0 { + continue; + } + let Some(a) = weight(indices >> (2 * t) & 3) else { continue }; + let b = 1.0 - a; + let c = rgb_of(texel); + aa += a * a; + ab += a * b; + bb += b * b; + for ((x0, x1), v) in ax.iter_mut().zip(bx.iter_mut()).zip(c) { + *x0 += a * v; + *x1 += b * v; + } + } + let det = aa * bb - ab * ab; + if det.abs() < 1e-3 { + return None; + } + let e0 = [0, 1, 2].map(|i| (ax[i] * bb - bx[i] * ab) / det); + let e1 = [0, 1, 2].map(|i| (bx[i] * aa - ax[i] * ab) / det); + Some((to565(e0), to565(e1))) +} + +/// A BC3 alpha block: whichever of the two modes (eight values between the extremes, or six +/// between the extremes other than 0 and 255, plus exact 0 and 255) fits better. +fn alpha_block(block: &Block, inside: Mask) -> [u8; 8] { + let alphas = || block.iter().enumerate().filter(move |(t, _)| inside >> t & 1 != 0).map(|(_, texel)| texel[3]); + let range = |values: &mut dyn Iterator| values.fold(None, |r: Option<(u8, u8)>, a| Some(r.map_or((a, a), |(lo, hi)| (lo.min(a), hi.max(a))))); + let (lo, hi) = range(&mut alphas()).unwrap_or((0, 0)); + // Eight values need a0 > a1; equal ends fall to the six-value mode, which starts with a0 too. + let eight = alpha_fit(block, inside, hi, lo); + let (mid_lo, mid_hi) = range(&mut alphas().filter(|a| !matches!(a, 0 | 255))).unwrap_or((0, 0)); + let six = alpha_fit(block, inside, mid_lo, mid_hi); + if six.1 < eight.1 { six.0 } else { eight.0 } +} + +fn alpha_fit(block: &Block, inside: Mask, a0: u8, a1: u8) -> ([u8; 8], u32) { + let palette = alpha_palette(a0, a1); + let mut bits = 0u64; + let mut error = 0u32; + for (t, texel) in block.iter().enumerate() { + if inside >> t & 1 == 0 { + continue; + } + let (i, d) = palette.iter().enumerate().map(|(i, &p)| (i, u32::from(p.abs_diff(texel[3])).pow(2))).min_by_key(|&(_, d)| d).unwrap_or((0, 0)); + error += d; + bits |= (i as u64) << (3 * t); + } + let b = bits.to_le_bytes(); + ([a0, a1, b[0], b[1], b[2], b[3], b[4], b[5]], error) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn decode_colour(bytes: &[u8; 8], bc1: bool) -> Block { + let (c0, c1) = (u16::from_le_bytes([bytes[0], bytes[1]]), u16::from_le_bytes([bytes[2], bytes[3]])); + let palette = colour_palette(c0, c1, c0 > c1 || !bc1); + let indices = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]); + std::array::from_fn(|t| palette[(indices >> (2 * t)) as usize & 3]) + } + + #[test] + fn solid_blocks_land_on_the_nearest_565_colour() { + for colour in [[0, 0, 0, 255], [255, 255, 255, 255], [12, 200, 77, 255]] { + let block = [colour; 16]; + let out = decode_colour(&encode_bc1(&block, u16::MAX), true); + for texel in out { + assert!(texel.iter().zip(colour).all(|(a, b)| a.abs_diff(b) <= 4), "{texel:?} for {colour:?}"); + } + } + } + + #[test] + fn two_colour_blocks_are_exact_when_the_colours_are_565() { + let (a, b) = ([255, 0, 0, 255], [0, 0, 255, 255]); + let block: Block = std::array::from_fn(|t| if t % 3 == 0 { a } else { b }); + for bc1 in [true, false] { + let bytes = if bc1 { encode_bc1(&block, u16::MAX) } else { colour_block(&block, u16::MAX, 0, false) }; + assert_eq!(decode_colour(&bytes, bc1), block); + } + } + + #[test] + fn bc1_punches_through_texels_under_half_alpha() { + let block: Block = std::array::from_fn(|t| if t < 8 { [200, 100, 50, 255] } else { [9, 9, 9, 127] }); + let bytes = encode_bc1(&block, u16::MAX); + let (c0, c1) = (u16::from_le_bytes([bytes[0], bytes[1]]), u16::from_le_bytes([bytes[2], bytes[3]])); + assert!(c0 <= c1, "three-colour mode"); + let out = decode_colour(&bytes, true); + assert!(out[..8].iter().all(|t| t[3] == 255) && out[8..].iter().all(|t| *t == [0; 4]), "{out:?}"); + } + + #[test] + fn four_colour_blocks_keep_c0_above_c1() { + // A ramp needs all four colours; BC2 and BC3 blocks must not rely on the order, but + // older decoders read c0 <= c1 as three colours, so the encoder never writes that. + let block: Block = std::array::from_fn(|t| [(t * 16) as u8, 0, 0, 255]); + let bytes = colour_block(&block, u16::MAX, 0, false); + assert!(u16::from_le_bytes([bytes[0], bytes[1]]) > u16::from_le_bytes([bytes[2], bytes[3]])); + } + + #[test] + fn alpha_blocks_pick_the_mode_that_keeps_0_and_255() { + // 0, 255 and a cluster in between: the six-value mode stores 0 and 255 exactly. + let block: Block = std::array::from_fn(|t| [0, 0, 0, [0, 255, 100, 110][t % 4]]); + let bytes = alpha_block(&block, u16::MAX); + assert!(bytes[0] <= bytes[1], "six-value mode"); + let palette = alpha_palette(bytes[0], bytes[1]); + let bits = u64::from_le_bytes([bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], 0, 0]); + for (t, texel) in block.iter().enumerate() { + let got = palette[(bits >> (3 * t)) as usize & 7]; + assert!(got.abs_diff(texel[3]) <= 2, "texel {t}: {got} for {}", texel[3]); + } + } + + #[test] + fn the_principal_axis_is_found_when_one_channel_varies_on_its_own() { + // Red alternates on its own (variance 64); green and blue alternate together (variance 49 + // each, 98 along their shared axis), uncorrelated with red. Starting on red alone would + // keep red's axis and flatten green and blue; the block must do better than its bounding box. + let block: Block = std::array::from_fn(|t| { + let gb = if (t / 2) % 2 == 0 { 121 } else { 135 }; + [if t % 2 == 0 { 120 } else { 136 }, gb, gb, 255] + }); + let error = |bytes: &[u8; 8]| decode_colour(bytes, false).iter().zip(&block).map(|(a, b)| distance(a, b)).sum::(); + let (_, bounding_box) = fit(&block, u16::MAX, 0, to565([120.0, 121.0, 121.0]), to565([136.0, 135.0, 135.0]), true); + let ours = error(&colour_block(&block, u16::MAX, 0, false)); + assert!(ours < bounding_box, "{ours} vs the bounding box's {bounding_box}"); + } + + #[test] + fn texels_outside_the_image_are_ignored() { + // Only the top-left texel is inside: its colour alone decides the endpoints. + let mut block: Block = [[0, 255, 0, 255]; 16]; + block[0] = [255, 0, 255, 255]; + let out = decode_colour(&encode_bc1(&block, 1), true); + assert_eq!(out[0], [255, 0, 255, 255]); + } +} diff --git a/crates/codecs/src/codecs/deep_exr.rs b/crates/codecs/src/codecs/deep_exr.rs index 0726228be5..29f3762ebc 100644 --- a/crates/codecs/src/codecs/deep_exr.rs +++ b/crates/codecs/src/codecs/deep_exr.rs @@ -23,7 +23,7 @@ use half::f16; use crate::Format; use crate::error::CodecError; use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, SampleType}; -use crate::options::Limits; +use crate::options::{Limits, alloc_zeroed}; const F: Format = Format::OpenExr; @@ -39,9 +39,15 @@ fn unsup(reason: impl Into) -> CodecError { // Every length and offset comes from the file, so every read is bounds-checked and a short // read is a malformed file, never a panic. -fn read_exact(cur: &mut Cursor<&[u8]>, n: usize, what: &str) -> Result, CodecError> { - let mut v = vec![0u8; n]; - cur.read_exact(&mut v).map_err(|_| err(format!("truncated file while reading {what}")))?; +fn read_exact<'a>(cur: &mut Cursor<&'a [u8]>, n: usize, what: &str) -> Result<&'a [u8], CodecError> { + // Borrow packed payloads from the input. Copying them here allocates before count-table + // admission and needlessly duplicates the table even when the second pass just skips it. + let truncated = || err(format!("truncated file while reading {what}")); + let start = usize::try_from(cur.position()).map_err(|_| truncated())?; + let end = start.checked_add(n).ok_or_else(truncated)?; + let bytes = *cur.get_ref(); + let v = bytes.get(start..end).ok_or_else(truncated)?; + cur.set_position(end as u64); Ok(v) } @@ -81,10 +87,11 @@ fn differences_to_samples(buf: &mut [u8]) { } /// Undo the ZIP half-split: the stream is `[first half][second half]` with the second half -/// holding every second byte of the original; weave them back together. -fn interleave_byte_blocks(buf: &mut [u8]) { +/// holding every second byte of the original; weave them back together. The scratch copy is +/// allocated fallibly, so a buffer that passed `max_alloc` but not the allocator is an error. +fn interleave_byte_blocks(buf: &mut [u8]) -> Result<(), CodecError> { let half = buf.len().div_ceil(2); - let mut out = vec![0u8; buf.len()]; + let mut out = alloc_zeroed::(buf.len())?; let mut k = 0usize; for i in 0..buf.len() / 2 { let (a, b) = (buf.get(i).copied(), buf.get(half + i).copied()); @@ -105,12 +112,29 @@ fn interleave_byte_blocks(buf: &mut [u8]) { } } buf.copy_from_slice(&out); + Ok(()) +} + +/// Make room in `out` for `extra` more bytes, `limit` in all. Grows by doubling like `Vec`, +/// but never reserves past `limit`: callers admit exactly that size against `max_alloc`, and +/// plain doubling reserved up to twice as much. Fallible, so a failed allocation is an error. +fn reserve_up_to(out: &mut Vec, extra: usize, limit: usize, what: &str) -> Result<(), CodecError> { + let need = out.len().saturating_add(extra); + if need > limit { + return Err(err(format!("{what}: data expands past {limit} bytes"))); + } + if need > out.capacity() { + let target = out.capacity().saturating_mul(2).max(need).min(limit); + out.try_reserve_exact(target.saturating_sub(out.len())).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {target} bytes for {what}")))?; + } + Ok(()) } /// OpenEXR RLE tokens: a negative byte `n` means `-n` literal bytes follow, a non-negative /// byte `n` means the next byte repeats `n + 1` times (as `ImfRle.cpp` and the `exr` crate). fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result, CodecError> { - let mut out = Vec::with_capacity(expected.min(1 << 20)); + let mut out = Vec::new(); + reserve_up_to(&mut out, expected.min(1 << 20), expected, what)?; let mut i = 0; while i < src.len() { let count = src[i] as i8; @@ -119,6 +143,7 @@ fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result, CodecE if out.len().saturating_add(n) > expected { return Err(err(format!("{what}: RLE data expands past {expected} bytes"))); } + reserve_up_to(&mut out, n, expected, what)?; if count < 0 { let end = i.saturating_add(n); let run = src.get(i..end.min(src.len())).ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?; @@ -143,7 +168,10 @@ fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result, CodecE /// has the uncompressed length is stored raw whatever the declared compression. fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &str) -> Result, CodecError> { if packed.len() == expected { - return Ok(packed.to_vec()); + let mut out = Vec::new(); + reserve_up_to(&mut out, expected, expected, what)?; + out.extend_from_slice(packed); + return Ok(out); } match compression { Compression::Uncompressed => Err(err(format!("{what}: expected {expected} uncompressed bytes, found {}", packed.len()))), @@ -151,20 +179,38 @@ fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &s // Like ZIPS, RLE runs over the predicted, byte-split stream. let mut out = rle_decode(packed, expected, what)?; differences_to_samples(&mut out); - interleave_byte_blocks(&mut out); + interleave_byte_blocks(&mut out)?; Ok(out) } Compression::ZIP1 => { - let mut out = Vec::with_capacity(expected.min(1 << 20)); - flate2::read::ZlibDecoder::new(packed) - .take(expected as u64 + 1) - .read_to_end(&mut out) - .map_err(|_| err(format!("{what}: zlib data is malformed")))?; + // Inflate straight into `out`, grown with `reserve_up_to` rather than by + // `read_to_end`, whose doubling reserves past `expected`. Once `expected` bytes are + // in, a one-byte read checks that the stream ends there. + let malformed = || err(format!("{what}: zlib data is malformed")); + let mut zlib = flate2::read::ZlibDecoder::new(packed); + let mut out = Vec::new(); + let mut filled = 0; + while filled < expected { + if filled == out.len() { + let grow = out.len().max(1 << 13).min(expected - filled); + reserve_up_to(&mut out, grow, expected, what)?; + out.resize(filled + grow, 0); + } + let n = zlib.read(out.get_mut(filled..).unwrap_or_default()).map_err(|_| malformed())?; + if n == 0 { + break; + } + filled += n; + } + out.truncate(filled); + if filled == expected && zlib.read(&mut [0u8; 1]).map_err(|_| malformed())? != 0 { + return Err(err(format!("{what}: zlib data expands past {expected} bytes"))); + } if out.len() != expected { return Err(err(format!("{what}: zlib data expands to {} bytes, expected {expected}", out.len()))); } differences_to_samples(&mut out); - interleave_byte_blocks(&mut out); + interleave_byte_blocks(&mut out)?; Ok(out) } other => Err(unsup(format!("deep data with {other:?} compression (NONE, RLE and ZIPS are supported)"))), @@ -340,13 +386,41 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) { return Err(err("deep chunk sizes point past the end of the file")); } - let table_bytes = read_exact(&mut cur, packed_table as usize, "the sample-count table")?; + let what = "the sample-count table"; + let table_bytes = read_exact(&mut cur, packed_table as usize, what)?; let expected_table = block_pixels.len().checked_mul(4).ok_or_else(|| err("count table overflows"))?; + // A compressed clipped table also needs its scratch copy. The data-window check only + // budgets four bytes per channel per pixel, insufficient for a one-channel image. + // An equal-sized payload takes decompress's raw path, which allocates no scratch. + let buffers = if table_bytes.len() == expected_table { 1 } else { 2 }; + let clipped_need = (expected_table as u64).saturating_mul(buffers); + if clipped_need > limits.max_alloc { + return Err(CodecError::LimitExceeded(format!( + "{what} needs {clipped_need} bytes including decompression scratch, exceeding max_alloc {}", + limits.max_alloc + ))); + } // An edge tile's table may still be stored at the full tile size (its pixels first, in // rows of the clipped width, then unused entries), as the OpenEXR library writes it. - let full_table = tile_w.saturating_mul(tile_h).saturating_mul(4).max(expected_table); - let raw = decompress(compression, &table_bytes, full_table, "the sample-count table") - .or_else(|_| decompress(compression, &table_bytes, expected_table, "the sample-count table"))?; + // That size comes from the header's tile size alone, which nothing ties to the data + // window, so it is tried only when `max_alloc` has room for the table and for the copy + // that undoes its byte split. Otherwise only the clipped size is read. + let full_table = tile_w.checked_mul(tile_h).and_then(|n| n.checked_mul(4)); + let raw = match full_table { + // Scan lines and interior tiles: the full size is the clipped size. + Some(full) if full <= expected_table => decompress(compression, table_bytes, expected_table, what)?, + Some(full) if (full as u64).saturating_mul(2) <= limits.max_alloc => { + decompress(compression, table_bytes, full, what).or_else(|_| decompress(compression, table_bytes, expected_table, what))? + } + _ => decompress(compression, table_bytes, expected_table, what).map_err(|_| { + // Four bytes per pixel of the tile, twice: the table and its scratch copy. + let need = (tile_w as u64).saturating_mul(tile_h as u64).saturating_mul(8); + CodecError::LimitExceeded(format!( + "{what} of a {tile_w}x{tile_h} deep tile does not decode at the clipped size, and at the full tile size it needs {need} bytes with its scratch copy, exceeding max_alloc {}", + limits.max_alloc + )) + })?, + }; // The table is cumulative within each row of the block (a tile restarts at every row); // `prefix` is cumulative over the whole block, and each pixel's own count is the difference. @@ -423,7 +497,7 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res // The count table sits between the sizes and the sample data; skip its bytes. read_exact(&mut cur, packed_table as usize, "the sample-count table")?; let data_bytes = read_exact(&mut cur, chunk.packed_data as usize, "the deep sample data")?; - let raw = decompress(compression, &data_bytes, chunk.unpacked_data as usize, "the deep sample data")?; + let raw = decompress(compression, data_bytes, chunk.unpacked_data as usize, "the deep sample data")?; // Row by row (a scan line block is one row), and within a row channel by channel, as // flat tiles are laid out: each pixel's samples are contiguous within its channel. let mut in_off = 0usize; @@ -611,3 +685,82 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result { img.warnings.push(DecodeWarning::DeepFlattened { format: F, max_samples_per_pixel: max_samples }); Ok(img) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + + #[test] + fn packed_reads_borrow_input_and_reject_bad_sizes_before_allocation() { + let bytes = [1u8, 2, 3, 4]; + let mut cur = Cursor::new(bytes.as_slice()); + let prefix = read_exact(&mut cur, 3, "x").expect("three input bytes"); + assert_eq!(prefix, &[1, 2, 3]); + assert_eq!(prefix.as_ptr(), bytes.as_ptr()); + assert_eq!(cur.position(), 3); + assert!(matches!(read_exact(&mut cur, usize::MAX, "x"), Err(CodecError::Malformed { .. }))); + assert_eq!(cur.position(), 3); + assert_eq!(read_exact(&mut cur, 1, "x").expect("last byte"), &[4]); + assert!(matches!(read_exact(&mut cur, 1, "x"), Err(CodecError::Malformed { .. }))); + cur.set_position(u64::MAX); + assert!(matches!(read_exact(&mut cur, 1, "x"), Err(CodecError::Malformed { .. }))); + } + + /// What the writer stores before compressing: the byte split, then the +128 predictor. + fn predicted(raw: &[u8]) -> Vec { + let half = raw.len().div_ceil(2); + let mut out = vec![0u8; raw.len()]; + for (i, &v) in raw.iter().enumerate() { + out[if i % 2 == 0 { i / 2 } else { half + i / 2 }] = v; + } + let mut prev = 0i16; + for (i, b) in out.iter_mut().enumerate() { + let v = i16::from(*b); + if i > 0 { + *b = (v - prev + 128) as u8; + } + prev = v; + } + out + } + + /// #4958: callers admit `expected` bytes for a decompressed buffer (twice that for a count + /// table, with the copy that undoes its byte split), so decompression must not reserve + /// more. `Vec`'s doubling gave a 4 MiB ZIPS table 8 MiB of capacity and a 3 MiB RLE table + /// 4 MiB, so the "admitted" table and its copy took up to three times the table size. + #[test] + fn decompressed_buffers_reserve_no_more_than_expected() { + for n in [1usize << 20, 2 << 20, (3 << 20) + 1, 4 << 20, 5 << 20] { + let raw = vec![0u8; n]; + let p = predicted(&raw); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(&p).expect("write to a Vec"); + let zips = z.finish().expect("finish a Vec"); + // RLE: the first byte as a literal, then runs of up to 128 copies of the next. + let mut rle = vec![0xFF, p[0]]; + for run in p[1..].chunks(128) { + rle.extend_from_slice(&[(run.len() - 1) as u8, run[0]]); + } + for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] { + let out = decompress(compression, packed, n, "the table").expect("decompresses"); + assert_eq!(out, raw, "{compression:?}, {n} bytes"); + assert!(out.capacity() <= n, "{compression:?}: a {n} byte buffer reserved {} bytes", out.capacity()); + } + // One byte short, the same data expands past the expected size and is refused. + for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] { + assert!(decompress(compression, packed, n - 1, "the table").is_err(), "{compression:?}, {n} bytes"); + } + } + } + + /// A size past the limit is malformed data, and a reservation the allocator refuses is an + /// error rather than an abort. + #[test] + fn reservations_past_the_limit_or_the_allocator_are_errors() { + let mut v = Vec::new(); + assert!(matches!(reserve_up_to(&mut v, 2, 1, "x"), Err(CodecError::Malformed { .. }))); + assert!(matches!(reserve_up_to(&mut v, isize::MAX as usize + 1, usize::MAX, "x"), Err(CodecError::LimitExceeded(_)))); + assert_eq!(v.capacity(), 0); + } +} diff --git a/crates/codecs/src/codecs/dpx.rs b/crates/codecs/src/codecs/dpx.rs index 8b772707cc..091403d641 100644 --- a/crates/codecs/src/codecs/dpx.rs +++ b/crates/codecs/src/codecs/dpx.rs @@ -337,6 +337,78 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result Ok(img) } +// ---------------------------------------------------------------- Cineon (Kodak .cin) +// +// Cineon is the 10-bit film-scan format DPX grew from (same Kodak header family, big-endian). +// The generic header is 1024 bytes: the file information fields, then at offset 192 the image +// orientation (1 byte), the channel count (1 byte) and up to eight 28-byte channel descriptors +// (the first carries the bits per pixel and the line/image dimensions). Image data is three +// 10-bit samples packed into each 32-bit word, lines on a word boundary — the same layout as +// DPX's 10-bit packing method A, so the unpacking is shared. + +/// The Cineon magic number (`802A5FD7` hex), big-endian. +pub(crate) fn is_cineon(b: &[u8]) -> bool { + b.get(..4) == Some(&[0x80, 0x2A, 0x5F, 0xD7]) +} + +/// The EXIF orientation (1–8) for a Cineon header's orientation code (the same 0–7 order as DPX). +pub(crate) fn cineon_orientation(b: &[u8]) -> u16 { + let Some(code) = b.get(192).copied() else { return 1 }; + match code { + 1 => 2, + 2 => 4, + 3 => 3, + 4 => 5, + 5 => 6, + 6 => 8, + 7 => 7, + _ => 1, + } +} + +/// Decode a Cineon (`.cin`) frame: 10-bit logarithmic film scans, big-endian, one (gray) or +/// three (RGB) channels. The 10-bit code values become U16 as `round(c × 65535 / 1023)`; the +/// file's black/white reference points are not applied (a log conversion is a display choice). +pub(crate) fn decode_cineon(bytes: &[u8], limits: &Limits) -> Result { + if !is_cineon(bytes) { + return Err(err("missing the Cineon magic number (802A5FD7)")); + } + let o = Order::Big; + let missing = |what: &str| err(format!("the header ends before {what}")); + let image_offset = usize::try_from(o.u32(bytes, 4).ok_or_else(|| missing("the image data offset"))?).map_err(|_| err("image data offset out of range"))?; + let channels = *bytes.get(193).ok_or_else(|| missing("the channel count"))?; + let bits = *bytes.get(198).ok_or_else(|| missing("the bits per pixel"))?; + if bits != 10 { + return Err(unsupported(format!("{bits}-bit samples (Cineon frames are 10-bit)"))); + } + let layout = match channels { + 1 => ChannelLayout::Gray, + 3 => ChannelLayout::Rgb, + n => return Err(unsupported(format!("{n} channels (Cineon frames have 1 or 3)"))), + }; + let width = o.u32(bytes, 200).ok_or_else(|| missing("the pixels per line"))?; + let height = o.u32(bytes, 204).ok_or_else(|| missing("the lines per image"))?; + limits.check(width, height, layout, SampleType::U16)?; + + let (w, h) = (width as usize, height as usize); + let per_line = w.checked_mul(layout.channels()).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?; + // Three 10-bit samples per 32-bit word, lines on a word boundary (DPX packing 1). + let line_len = line_bytes(per_line, 10, 1).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?; + let data = bytes.get(image_offset..).ok_or_else(|| err("the image data offset is past the end of the file"))?; + let need = line_len.checked_mul(h).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?; + if data.len() < need { + return Err(err("truncated image data")); + } + let mut codes = vec![0u16; per_line.checked_mul(h).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?]; + for (y, row) in codes.chunks_exact_mut(per_line).enumerate() { + let start = y * line_len; + let line = data.get(start..start + line_len).ok_or_else(|| err("truncated image data"))?; + unpack_line(line, o, 10, 1, row)?; + } + let wide: Vec = codes.iter().map(|&c| widen(c, 1023)).collect(); + Image::from_u16(width, height, layout, &wide) +} + /// The kept header fields as text metadata, in [`KEYS`] order; undefined ones are left out. fn header_text(b: &[u8], o: Order, e: Element) -> Vec<(String, String)> { let mut text = Vec::new(); @@ -653,4 +725,60 @@ mod tests { let img = decode(&b, &Limits::default()).unwrap(); assert_eq!(img.data(), &[1, 2, 3, 4, 5, 6]); } + + /// A minimal Cineon: a 1024-byte generic header, then `w × h` 10-bit samples packed three + /// to a 32-bit word (RGB in word order, or gray three pixels per word). + fn cineon(w: u32, h: u32, channels: u8, px: &[[u16; 3]]) -> Vec { + let mut b = vec![0u8; 1024]; + b[0..4].copy_from_slice(&[0x80, 0x2A, 0x5F, 0xD7]); // magic (802A5FD7) + b[4..8].copy_from_slice(&1024u32.to_be_bytes()); // image data offset + b[192] = 0; // orientation: left-to-right, top-to-bottom + b[193] = channels; + b[198] = 10; // bits per pixel (channel 1) + b[200..204].copy_from_slice(&w.to_be_bytes()); // pixels per line + b[204..208].copy_from_slice(&h.to_be_bytes()); // lines per image + let word = |a: u16, c: u16, e: u16| ((u32::from(a) << 22) | (u32::from(c) << 12) | (u32::from(e) << 2)).to_be_bytes(); + if channels == 3 { + for p in px { + b.extend_from_slice(&word(p[0], p[1], p[2])); + } + } else { + let g = |i: usize| px.get(i).map_or(0, |p| p[0]); + for i in (0..px.len()).step_by(3) { + b.extend_from_slice(&word(g(i), g(i + 1), g(i + 2))); + } + } + b + } + + #[test] + fn cineon_reads_ten_bit_rgb() { + let px = [[0, 512, 1023], [1023, 0, 256]]; + let b = cineon(2, 1, 3, &px); + assert!(is_cineon(&b)); + assert_eq!(order(&b), None, "Cineon is not DPX"); + let img = decode_cineon(&b, &Limits::default()).unwrap(); + assert_eq!((img.width(), img.height(), img.layout()), (2, 1, ChannelLayout::Rgb)); + assert_eq!( + img.to_u16_samples().unwrap(), + vec![widen(0, 1023), widen(512, 1023), widen(1023, 1023), widen(1023, 1023), widen(0, 1023), widen(256, 1023)] + ); + } + + #[test] + fn cineon_reads_grayscale_and_rejects_other_depths() { + let px = [[10, 0, 0], [20, 0, 0], [30, 0, 0], [40, 0, 0]]; + let b = cineon(4, 1, 1, &px); + let img = decode_cineon(&b, &Limits::default()).unwrap(); + assert_eq!(img.layout(), ChannelLayout::Gray); + assert_eq!(img.to_u16_samples().unwrap(), vec![widen(10, 1023), widen(20, 1023), widen(30, 1023), widen(40, 1023)]); + // A depth other than 10 is unsupported, and truncation errors rather than panicking. + let mut bad = cineon(1, 1, 3, &[[0, 0, 0]]); + bad[198] = 8; + assert!(matches!(decode_cineon(&bad, &Limits::default()), Err(CodecError::Unsupported { .. }))); + let full = cineon(4, 4, 3, &[[0; 3]; 16]); + for cut in [0, 4, 200, 1024, full.len() - 1] { + assert!(decode_cineon(&full[..cut.min(full.len())], &Limits::default()).is_err(), "cut {cut}"); + } + } } diff --git a/crates/codecs/src/codecs/exr.rs b/crates/codecs/src/codecs/exr.rs index 2506dd062b..1b268ac559 100644 --- a/crates/codecs/src/codecs/exr.rs +++ b/crates/codecs/src/codecs/exr.rs @@ -15,7 +15,7 @@ use half::f16; use crate::Format; use crate::error::CodecError; use crate::fidelity::Plan; -use crate::image::{ChannelLayout, DecodeWarning, ExrChannelInfo, ExrPartInfo, Image, SampleType, base_channel_name}; +use crate::image::{ChannelLayout, DecodeWarning, ExrChannelInfo, ExrPartInfo, Image, SampleType, base_channel_name, exr_color_channels}; use crate::options::{EncodeOptions, ExrCompression, Limits}; const F: Format = Format::OpenExr; @@ -123,20 +123,9 @@ pub(super) fn read_layer(bytes: &[u8], index: usize) -> Result>) -> Result { let (w, h) = (layer.size.0, layer.size.1); let channels = &layer.channel_data.list; - let find = |n: &str| channels.iter().find(|c| base_channel_name(&c.name.to_string()) == n); - let (color, layout): (Vec<_>, ChannelLayout) = if let (Some(r), Some(g), Some(b)) = (find("R"), find("G"), find("B")) { - match find("A") { - Some(a) => (vec![r, g, b, a], ChannelLayout::Rgba), - None => (vec![r, g, b], ChannelLayout::Rgb), - } - } else if let Some(y) = find("Y").or_else(|| (channels.len() == 1).then(|| &channels[0])) { - match find("A") { - Some(a) => (vec![y, a], ChannelLayout::GrayA), - None => (vec![y], ChannelLayout::Gray), - } - } else { - return Err(CodecError::unsupported(F, "no R/G/B or Y channels")); - }; + let names: Vec<_> = channels.iter().map(|c| c.name.to_string()).collect(); + let (layout, selected) = exr_color_channels(names.iter().map(String::as_str)).ok_or_else(|| CodecError::unsupported(F, "no R/G/B or Y channels"))?; + let color: Vec<_> = selected.iter().map(|i| channels.get(*i).ok_or_else(|| err("selected channel is missing"))).collect::>()?; let all_f16 = color.iter().all(|c| matches!(c.sample_data, FlatSamples::F16(_))); let sample = if all_f16 { SampleType::F16 } else { SampleType::F32 }; let npx = w * h; @@ -187,6 +176,21 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result Ok(img) } +/// The size of the part [`decode`] opens, from the headers alone. Deep files are refused (their +/// flattened size comes from the deep decoder), as is a part [`decode`] cannot show. +pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32), CodecError> { + let meta = read_meta(bytes, limits)?; + if meta.requirements.has_deep_data { + return Err(CodecError::unsupported(F, "no header-only size probe for deep data")); + } + let pick = pick_part(&meta); + let info = meta.headers.get(pick).map(|h| part_info(pick, h)).ok_or_else(|| err("no parts"))?; + if info.color_rank() == 0 { + return Err(CodecError::unsupported(F, "no R/G/B or Y channels")); + } + Ok((info.width, info.height)) +} + /// Decode one part of a multi-part file by its index into [`info`]'s list. A file with /// deep parts is not mixed: the deep decoder composites those instead. pub(crate) fn decode_part(bytes: &[u8], index: usize, limits: &Limits) -> Result { diff --git a/crates/codecs/src/codecs/fits.rs b/crates/codecs/src/codecs/fits.rs index 3aa769fb01..7e3f92ae67 100644 --- a/crates/codecs/src/codecs/fits.rs +++ b/crates/codecs/src/codecs/fits.rs @@ -530,7 +530,11 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result>, pub xmp: Option, pub dpi: Option<(f32, f32)>, + /// The APP13 Photoshop image resource blocks (`8BIM` ...), segments joined, without the header. + pub photoshop: Option>, pub adobe_transform: Option, /// (width, height, components) from the first SOFn. pub frame: Option<(u32, u32, u8)>, @@ -108,6 +110,7 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta { .or_else(|| m.xmp.as_deref().and_then(xmp_resolution)) .or_else(|| m.exif.as_deref().and_then(exif_resolution)) .or(jfif_dpi); + m.photoshop = Some(photoshop).filter(|p| !p.is_empty()); if !icc_parts.is_empty() { icc_parts.sort_by_key(|p| p.0); let total = icc_parts[0].1 as usize; @@ -226,7 +229,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result } let mut img = Image::from_raw(w, h, layout, SampleType::U8, px)?; img.icc = meta.icc; - img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, ..Default::default() }; + img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, photoshop_resources: meta.photoshop, ..Default::default() }; if end == DataEnd::Truncated { img.warnings.push(DecodeWarning::Truncated { format: F }); } diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index afb7f96791..d9905ab978 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod dds; +pub(crate) mod dds_bc; pub(crate) mod deep_exr; pub(crate) mod dpx; pub(crate) mod exr; @@ -10,6 +11,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/png.rs b/crates/codecs/src/codecs/png.rs index 7c1a633d17..f894b3a30d 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -7,7 +7,7 @@ use crate::Format; use crate::error::CodecError; use crate::fidelity::Plan; use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType}; -use crate::options::{EncodeOptions, Limits, PngCompression, alloc_zeroed}; +use crate::options::{EncodeOptions, Limits, PngCompression, PngStereo, alloc_zeroed}; const F: Format = Format::Png; pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp"; @@ -127,6 +127,38 @@ pub fn palette(bytes: &[u8]) -> Result, CodecError> { Ok(Some(PngPalette { colors, transparent })) } +/// The stereo pairing a PNG declares in its `sTER` chunk (PNG Extensions 1.4.0, §3.6), read +/// from the headers without decoding pixels. `None` when the file has no CRC-valid `sTER` with a +/// defined mode byte. The first valid chunk wins, as a later one would contradict it. +pub fn stereo(bytes: &[u8]) -> Option { + let mut rest = bytes.get(8..)?; + while let Some((length, tail)) = rest.split_first_chunk::<4>() { + let Some((kind, tail)) = tail.split_first_chunk::<4>() else { break }; + let length = u32::from_be_bytes(*length); + if length > i32::MAX as u32 { + break; + } + let Ok(length) = usize::try_from(length) else { break }; + let Some(data) = tail.get(..length) else { break }; + let Some((checksum, next)) = tail.get(length..).and_then(|t| t.split_first_chunk::<4>()) else { break }; + rest = next; + if kind == b"IEND" { + break; + } + if kind != b"sTER" || data.len() != 1 { + continue; + } + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + if crc.sum() != u32::from_be_bytes(*checksum) { + continue; + } + return PngStereo::from_mode(data[0]); + } + None +} + fn map_png_err(e: png::DecodingError) -> CodecError { match e { png::DecodingError::LimitsExceeded => CodecError::LimitExceeded("PNG decoder memory limit".into()), @@ -346,6 +378,10 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, bpp: usize, scratch: &mut [u8], o } }; let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::(); - let mut best = (cost(row), 0u8); + let mut best = cost(row); + let start = out.len(); + out.push(0); + out.extend_from_slice(row); for kind in 1u8..=4 { + if best == 0 { + break; + } + let mut c = 0u64; for i in 0..n { let x = row[i]; scratch[i] = match kind { @@ -515,27 +558,17 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), }; + c += u64::from((scratch[i] as i8).unsigned_abs()); + if c >= best { + break; + } } - let c = cost(&scratch[..n]); - if c < best.0 { - best = (c, kind); + if c < best { + best = c; + out[start] = kind; + out[start + 1..].copy_from_slice(&scratch[..n]); } } - out.push(best.1); - if best.1 == 0 { - out.extend_from_slice(row); - return; - } - for i in 0..n { - let x = row[i]; - scratch[i] = match best.1 { - 1 => x.wrapping_sub(left(i)), - 2 => x.wrapping_sub(up(i)), - 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), - _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), - }; - } - out.extend_from_slice(&scratch[..n]); } const ADLER_MOD: u32 = 65_521; @@ -574,6 +607,7 @@ pub fn encode_indexed( palette: &[[u8; 3]], transparent: Option, dpi: Option<(f32, f32)>, + stereo: Option, ) -> Result, CodecError> { if width == 0 || height == 0 || indices.len() != width as usize * height as usize { return Err(CodecError::InvalidImage("index data does not match the size".into())); @@ -603,12 +637,95 @@ pub fn encode_indexed( { let encoder = png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?; let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?; + if let Some(stereo) = stereo { + writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?; + } writer.write_image_data(indices).map_err(|e| CodecError::encode(F, e))?; writer.finish().map_err(|e| CodecError::encode(F, e))?; } Ok(out) } +#[cfg(test)] +mod stereo_tests { + use super::*; + + /// One CRC-valid chunk as it appears in a file. + fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(kind); + out.extend_from_slice(data); + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + out.extend_from_slice(&crc.sum().to_be_bytes()); + out + } + + fn png_with(stereo: &[u8]) -> Vec { + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + out.extend_from_slice(&chunk(b"IHDR", &[0, 0, 0, 2, 0, 0, 0, 1, 8, 2, 0, 0, 0])); + if !stereo.is_empty() { + out.extend_from_slice(&chunk(b"sTER", stereo)); + } + out.extend_from_slice(&chunk(b"IDAT", &[0x78, 0x01, 0x01, 0x00, 0xff, 0xff, 0, 3, 1, 0, 0])); + out.extend_from_slice(&chunk(b"IEND", &[])); + out + } + + #[test] + fn reads_both_defined_views() { + assert_eq!(stereo(&png_with(&[0])), Some(PngStereo::Horizontal)); + assert_eq!(stereo(&png_with(&[1])), Some(PngStereo::Vertical)); + assert_eq!(stereo(&png_with(&[])), None); + // Reserved and unknown mode bytes are not a pairing we can keep. + assert_eq!(stereo(&png_with(&[2])), None); + assert_eq!(stereo(&png_with(&[255])), None); + // A two-byte payload is not sTER data. + assert_eq!(stereo(&png_with(&[0, 0])), None); + assert_eq!(stereo(b"not a png"), None); + } + + #[test] + fn a_broken_crc_is_not_a_stereo_mark() { + let mut bytes = png_with(&[0]); + let kind = bytes.windows(4).position(|w| w == b"sTER").unwrap(); + // The CRC trails the type and the one-byte payload of the chunk. + bytes[kind + 8] ^= 0xff; + assert_eq!(stereo(&bytes), None); + } + + #[test] + fn encode_writes_the_chunk_before_idat_and_it_reads_back() { + let img = Image::from_raw(2, 1, ChannelLayout::Rgb, SampleType::U8, vec![9; 6]).unwrap(); + for view in [PngStereo::Horizontal, PngStereo::Vertical] { + let opts = EncodeOptions { png_stereo: Some(view), ..Default::default() }; + let bytes = crate::encode(&img, Format::Png, &opts).unwrap(); + assert_eq!(stereo(&bytes), Some(view)); + let back = decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(back.data(), img.data()); + let ter = bytes.windows(4).position(|w| w == b"sTER").unwrap(); + let idat = bytes.windows(4).position(|w| w == b"IDAT").unwrap(); + assert!(ter < idat, "sTER before IDAT"); + } + let plain = crate::encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + assert_eq!(stereo(&plain), None); + } + + #[test] + fn interlaced_and_indexed_encoders_keep_the_mark() { + let img = Image::from_raw(2, 2, ChannelLayout::Rgb, SampleType::U8, vec![9; 12]).unwrap(); + let opts = EncodeOptions { png_stereo: Some(PngStereo::Vertical), png_interlaced: true, ..Default::default() }; + let bytes = crate::encode(&img, Format::Png, &opts).unwrap(); + assert_eq!(stereo(&bytes), Some(PngStereo::Vertical)); + let pal = [[255, 0, 0], [0, 0, 255]]; + let bytes = encode_indexed(2, 1, &[0, 1], &pal, None, None, Some(PngStereo::Horizontal)).unwrap(); + assert_eq!(stereo(&bytes), Some(PngStereo::Horizontal)); + assert_eq!(palette(&bytes).unwrap().map(|p| p.colors), Some(pal.to_vec())); + } +} + #[cfg(test)] mod indexed_tests { use super::*; @@ -617,14 +734,14 @@ mod indexed_tests { fn png8_roundtrips_through_the_decoder() { let pal = [[255, 0, 0], [0, 0, 255], [0, 0, 0]]; let idx = [0u8, 1, 2, 1, 0, 2]; - let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None).unwrap(); + let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None, None).unwrap(); let img = decode(&bytes, &Limits::default()).unwrap(); assert_eq!(img.dimensions(), (3, 2)); let px = img.convert(ChannelLayout::Rgba, SampleType::U8); assert_eq!(&px.data()[..8], &[255, 0, 0, 255, 0, 0, 255, 255]); assert_eq!(px.data()[11], 0, "transparent entry"); - assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None).is_err()); - assert!(encode_indexed(3, 3, &idx, &pal, None, None).is_err()); + assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None, None).is_err()); + assert!(encode_indexed(3, 3, &idx, &pal, None, None, None).is_err()); } fn png(color: png::ColorType, trns: Option<&[u8]>) -> Vec { @@ -643,7 +760,7 @@ mod indexed_tests { #[test] fn palette_reads_the_table_and_its_one_transparent_entry() { - let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), None).unwrap(); + let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), None, None).unwrap(); let colors = vec![[1, 2, 3], [4, 5, 6]]; assert_eq!(palette(&bytes).unwrap(), Some(PngPalette { colors: colors.clone(), transparent: Some(1) })); assert_eq!(palette(&png(png::ColorType::Indexed, None)).unwrap(), Some(PngPalette { colors, transparent: None })); @@ -659,6 +776,109 @@ mod indexed_tests { mod parallel_tests { use super::*; + #[test] + #[ignore = "manual 24 MP release benchmark"] + fn benchmark_filter_selection() { + use std::{hint::black_box, time::Instant}; + for pattern in ["zero", "identical", "varied"] { + let row: Vec = (0..6000 * 4).map(|i| if pattern == "zero" { 0 } else { (i * 17 + i / 13) as u8 }).collect(); + let prev: Vec<_> = row.iter().map(|v| if pattern == "identical" { *v } else { v.wrapping_add(3) }).collect(); + let mut scratch = vec![0; row.len()]; + let mut out = Vec::with_capacity(row.len() + 1); + for trial in 0..6 { + type Filter = fn(&[u8], Option<&[u8]>, usize, &mut [u8], &mut Vec); + let mut paths: [(&str, Filter); 2] = [("main", previous_filter_row), ("split", filter_row)]; + if trial % 2 == 1 { + paths.reverse(); + } + for (name, path) in paths { + let t = Instant::now(); + for _ in 0..4000 { + out.clear(); + black_box(path)(black_box(&row), Some(black_box(&prev)), 4, &mut scratch, &mut out); + black_box(&out); + } + println!("filter,{pattern},{trial},{name},{:.6}", t.elapsed().as_secs_f64() * 1000.0); + } + } + } + } + + // The main-branch implementation before this refactor. + fn previous_filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], out: &mut Vec) { + let n = row.len(); + let up = |i: usize| prev.map_or(0, |p| p[i]); + let left = |i: usize| if i >= bpp { row[i - bpp] } else { 0 }; + let ul = |i: usize| if i >= bpp { prev.map_or(0, |p| p[i - bpp]) } else { 0 }; + let paeth = |a: u8, b: u8, c: u8| { + let p = i16::from(a) + i16::from(b) - i16::from(c); + let (pa, pb, pc) = ((p - i16::from(a)).abs(), (p - i16::from(b)).abs(), (p - i16::from(c)).abs()); + if pa <= pb && pa <= pc { + a + } else if pb <= pc { + b + } else { + c + } + }; + let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::(); + let mut best = (cost(row), 0u8); + for kind in 1u8..=4 { + for i in 0..n { + let x = row[i]; + scratch[i] = match kind { + 1 => x.wrapping_sub(left(i)), + 2 => x.wrapping_sub(up(i)), + 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), + _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), + }; + } + let c = cost(&scratch[..n]); + if c < best.0 { + best = (c, kind); + } + } + out.push(best.1); + if best.1 == 0 { + out.extend_from_slice(row); + return; + } + for i in 0..n { + let x = row[i]; + scratch[i] = match best.1 { + 1 => x.wrapping_sub(left(i)), + 2 => x.wrapping_sub(up(i)), + 3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8), + _ => x.wrapping_sub(paeth(left(i), up(i), ul(i))), + }; + } + out.extend_from_slice(&scratch[..n]); + } + + #[test] + fn adaptive_filter_preserves_minimum_cost_and_ties() { + for bpp in [1, 2, 3, 4, 6, 8] { + for seed in 0..32usize { + let row: Vec = (0..137).map(|i| (i * seed + i / 5) as u8).collect(); + let shifted: Vec<_> = row.iter().map(|v| v.wrapping_add(seed as u8)).collect(); + for prev in [None, Some(row.as_slice()), Some(shifted.as_slice())] { + let mut expected = vec![99]; + let mut actual = expected.clone(); + let mut scratch = vec![0; row.len()]; + previous_filter_row(&row, prev, bpp, &mut scratch, &mut expected); + filter_row(&row, prev, bpp, &mut scratch, &mut actual); + assert_eq!(actual, expected, "bpp={bpp}, seed={seed}"); + } + } + } + let mut out = Vec::new(); + filter_row(&[0; 16], None, 4, &mut [0; 16], &mut out); + assert_eq!(out, vec![0; 17]); + out.clear(); + filter_row(&[1; 16], Some(&[0; 16]), 1, &mut [0; 16], &mut out); + assert_eq!(out, [vec![1, 1], vec![0; 15]].concat()); + } + #[test] fn adler_combine_matches_one_pass() { let a: Vec = (0..100_000u32).map(|i| (i * 7 + i / 13) as u8).collect(); 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/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/tiff.rs b/crates/codecs/src/codecs/tiff.rs index 5e09a68ece..9528fec9c8 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -532,17 +532,24 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result 3, + 2 | 8 | 9 => 3, 5 => 4, _ => 1, }; let alpha_at = extra.iter().position(|&e| e == 1 || e == 2).filter(|&a| (color + a as u64) < spp); - let all_samples = palette.is_none() && matches!(photometric, 2 | 5) && alpha_at.is_some_and(|a| a > 0); + let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && alpha_at.is_some_and(|a| a > 0); let mut patches: Vec<(u64, Vec)> = Vec::new(); let encode_u = |v: u64, len: usize| -> Vec { let b = if f.le { v.to_le_bytes() } else { v.to_be_bytes() }; @@ -557,14 +564,14 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result 2, 4 if e.count == 1 => 4, _ => return Err(err("malformed PhotometricInterpretation")), }; - patches.push((e.value_at, encode_u(1, len))); + patches.push((e.value_at, encode_u(if lab && !all_samples { 2 } else { 1 }, len))); } let tl = { @@ -585,7 +592,7 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result (ChannelLayout::Rgb, 1), tiff::ColorType::Gray(_) => (ChannelLayout::Gray, 1), tiff::ColorType::Multiband { num_samples, .. } if all_samples => { - (if photometric == 2 { ChannelLayout::Rgba } else { ChannelLayout::CmykA }, usize::from(num_samples)) + (if photometric == 5 { ChannelLayout::CmykA } else { ChannelLayout::Rgba }, usize::from(num_samples)) } tiff::ColorType::Multiband { num_samples, .. } if photometric <= 1 => { (if gray_alpha { ChannelLayout::GrayA } else { ChannelLayout::Gray }, usize::from(num_samples)) @@ -596,6 +603,12 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result (ChannelLayout::CmykA, 5), other => return Err(CodecError::unsupported(F, format!("colour type {other:?}"))), }; + let out_layout = match out_layout { + ChannelLayout::Rgb if lab => ChannelLayout::Lab, + ChannelLayout::Rgba if lab => ChannelLayout::LabA, + _ if lab => return Err(err("Lab image that does not read as three colour samples")), + l => l, + }; let chunk_spp = if planar { 1 } else { chunk_spp }; // The sample (chunky) or plane (planar) that holds the alpha: the colour samples come first. let alpha_sample = out_layout.color_channels() + alpha_at.unwrap_or(0); @@ -676,6 +689,9 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result 0 { return Err(err("the image data is missing (cut off or past the end of the file)")); } + if lab { + convert_ab(&mut data, out_layout.channels(), out, photometric == 8, false); + } let mut img = Image::from_raw(w, h, out_layout, out, data)?; if tally.missing > 0 { img.warnings.push(DecodeWarning::Truncated { format: F }); @@ -683,6 +699,35 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result data.chunks_exact_mut(channels).for_each(|px| { + px[1] ^= 0x80; + px[2] ^= 0x80; + }), + SampleType::U16 => data.chunks_exact_mut(channels * 2).for_each(|px| { + for c in [1, 2] { + let v = u16::from_ne_bytes([px[2 * c], px[2 * c + 1]]); + // The file's value as `a · 256` (signed) and back. + let file_a256 = |v: u16| if signed { f64::from(v as i16) } else { f64::from(v) - 32768.0 }; + let out = if to_file { + let a256 = (f64::from(v) - 32896.0) * 256.0 / 257.0; + let a256 = a256.round().clamp(-32768.0, 32767.0); + if signed { (a256 as i16) as u16 } else { (a256 + 32768.0) as u16 } + } else { + (file_a256(v) * 257.0 / 256.0 + 32896.0).round().clamp(0.0, 65535.0) as u16 + }; + px[2 * c..2 * c + 2].copy_from_slice(&out.to_ne_bytes()); + } + }), + _ => {} + } +} + /// A zero-filled buffer, allocated fallibly. On native targets the zeroing (and the first /// touch of every page, which dominates on a fresh multi-hundred-megabyte buffer) is spread over /// all cores. @@ -1021,6 +1066,11 @@ custom_colortype!(GrayA16, u16, PI::BlackIsZero, &[16, 16], &[SampleFormat::Uint custom_colortype!(GrayA32F, f32, PI::BlackIsZero, &[32, 32], &[SampleFormat::IEEEFP; 2], float); custom_colortype!(CmykA16, u16, PI::CMYK, &[16; 5], &[SampleFormat::Uint; 5], int); custom_colortype!(CmykA32F, f32, PI::CMYK, &[32; 5], &[SampleFormat::IEEEFP; 5], float); +// CIELab as Photoshop writes it: a* and b* signed, the sample format left unsigned. +custom_colortype!(Lab8, u8, PI::CIELab, &[8; 3], &[SampleFormat::Uint; 3], int); +custom_colortype!(Lab16, u16, PI::CIELab, &[16; 3], &[SampleFormat::Uint; 3], int); +custom_colortype!(LabA8, u8, PI::CIELab, &[8; 4], &[SampleFormat::Uint; 4], int); +custom_colortype!(LabA16, u16, PI::CIELab, &[16; 4], &[SampleFormat::Uint; 4], int); struct TagSet<'a> { icc: Option<&'a [u8]>, @@ -1119,6 +1169,20 @@ fn write_image(enc: &mut TiffEncoder<&mut Cursor>, K>, img: (L::CmykA, S::U8) => write_one::(enc, w, h, d8, tags), (L::CmykA, S::U16) => write_one::(enc, w, h, &u16s(), tags), (L::CmykA, S::F32) => write_one::(enc, w, h, &f32s(), tags), + (L::Lab | L::LabA, _) => { + // CIELab has no float form: 32-bit Lab is written with 16 bits. + let sample = if img.sample_type() == S::U8 { S::U8 } else { S::U16 }; + let mut file = img.convert(img.layout(), sample); + let channels = file.layout().channels(); + convert_ab(file.data_mut(), channels, sample, true, true); + let u16s = || file.to_u16_samples().unwrap_or_default(); + match (file.layout(), sample) { + (L::Lab, S::U8) => write_one::(enc, w, h, file.data(), tags), + (L::Lab, _) => write_one::(enc, w, h, &u16s(), tags), + (_, S::U8) => write_one::(enc, w, h, file.data(), tags), + _ => write_one::(enc, w, h, &u16s(), tags), + } + } (l, s) => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unsupported {l:?} {s:?}")).into()), } } diff --git a/crates/codecs/src/codecs/tiff_exif.rs b/crates/codecs/src/codecs/tiff_exif.rs index d07f555f30..00d47d0702 100644 --- a/crates/codecs/src/codecs/tiff_exif.rs +++ b/crates/codecs/src/codecs/tiff_exif.rs @@ -6,7 +6,9 @@ //! the same reader ([`super::tiff_ifd::File`]): reading a TIFF builds a block from the file's own //! directories, writing one copies the block's directories into the file. Values are copied as //! bytes (re-ordered when the byte orders differ), so tags PhotoCraft doesn't know survive. -//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets. +//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets. What is copied is +//! bounded by the stream's own size and a fixed cap, because entries of a hostile file can +//! overlap (see [`read`]). use tiff::encoder::{DirectoryEncoder, TiffEncoder, TiffKind}; use tiff::tags::{Tag, Type}; @@ -27,6 +29,11 @@ const TAG_INTEROP_IFD: u16 = 40965; const IFD0_TAGS: [u16; 7] = [270, 271, 272, 305, 306, 315, 33432]; /// At most this many entries are copied from one directory. const MAX_FIELDS: usize = 1024; +/// At most this many value bytes are copied from one stream, over all its directories. Real EXIF +/// is far smaller (the largest Nikon maker note seen is under 1 MB); the cap leaves room for +/// that and for a few such notes. A stream never yields more than its own length either (see +/// [`read`]). +const MAX_COPIED_BYTES: usize = 4 * 1024 * 1024; /// One entry with its value bytes in the byte order of the stream it came from. #[derive(Clone, Debug, PartialEq)] @@ -53,13 +60,22 @@ impl ExifDirs { } } -fn fields(f: &File<'_>, ifd: &Ifd, keep: impl Fn(u16) -> bool) -> Vec { - ifd.entries - .iter() - .filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD)) - .filter_map(|e| Some(Field { tag: e.tag, ty: e.ty, count: u32::try_from(e.count).ok()?, value: f.data(e)?.to_vec() })) - .take(MAX_FIELDS) - .collect() +/// The entries of `ifd` that `keep` accepts, with their values copied. An entry's value only has +/// to lie inside the file, so entries may overlap: `budget` is the number of value bytes that may +/// still be copied (shared by all the directories of one stream), and an entry whose value does +/// not fit in what is left is skipped, so later, smaller entries can still be kept. +fn fields(f: &File<'_>, ifd: &Ifd, budget: &mut usize, keep: impl Fn(u16) -> bool) -> Vec { + let mut out = Vec::new(); + for e in ifd.entries.iter().filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD)) { + if out.len() >= MAX_FIELDS { + break; + } + let (Ok(count), Some(value)) = (u32::try_from(e.count), f.data(e)) else { continue }; + let Some(left) = budget.checked_sub(value.len()) else { continue }; + *budget = left; + out.push(Field { tag: e.tag, ty: e.ty, count, value: value.to_vec() }); + } + out } fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option { @@ -69,14 +85,24 @@ fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option { /// The EXIF parts of the classic TIFF stream `b` (a TIFF file, or an EXIF block without its /// `Exif\0\0` prefix): IFD0's descriptive tags and the Exif and GPS directories. +/// +/// The values copied add up to at most [`MAX_COPIED_BYTES`] and to at most `b.len()`: the values +/// of a well-formed stream are distinct bytes of it, while a hostile one can have many entries +/// point at the same bytes and so ask for far more than its size. Entries that do not fit in what +/// is left are left out (the small descriptive directories come first, so a huge maker note +/// cannot crowd out the GPS position). pub(crate) fn read(b: &[u8]) -> Option { let f = File::parse(b)?; if f.big { return None; } let ifd0 = f.ifd(f.first_ifd)?; - let dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, |_| true)).unwrap_or_default(); - Some(ExifDirs { le: f.le, ifd0: fields(&f, &ifd0, |t| IFD0_TAGS.contains(&t)), exif: dir(TAG_EXIF_IFD), gps: dir(TAG_GPS_IFD) }) + let mut budget = MAX_COPIED_BYTES.min(b.len()); + let ifd0_fields = fields(&f, &ifd0, &mut budget, |t| IFD0_TAGS.contains(&t)); + let mut dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, &mut budget, |_| true)).unwrap_or_default(); + let gps = dir(TAG_GPS_IFD); + let exif = dir(TAG_EXIF_IFD); + Some(ExifDirs { le: f.le, ifd0: ifd0_fields, exif, gps }) } /// Bytes per element whose order a byte-order change reverses: a RATIONAL is two LONGs. diff --git a/crates/codecs/src/codecs/via_image.rs b/crates/codecs/src/codecs/via_image.rs index c91be5baf7..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; @@ -37,7 +38,8 @@ pub(crate) fn bmp_palette(bytes: &[u8]) -> Result>, CodecErr Ok(decoder.get_palette().map(|palette| palette.to_vec())) } -pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result { +/// The `image` decoder for `f` with our limits, its header read and its size checked. +fn open<'a>(f: Format, bytes: &'a [u8], limits: &Limits) -> Result { let fmt = image_format(f).ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?; let mut reader = image::ImageReader::with_format(Cursor::new(bytes), fmt); let mut il = image::Limits::default(); @@ -45,9 +47,14 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result Result { + let mut dec = open(f, bytes, limits)?; let icc = dec.icc_profile().ok().flatten(); let exif = dec.exif_metadata().ok().flatten(); let dynimg = DynamicImage::from_decoder(dec).map_err(|e| map_err(f, e))?; @@ -60,6 +67,18 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result Result<(u32, u32, Option>), CodecError> { + if f == Format::Avif { + return Err(CodecError::unsupported(f, "no header-only size probe")); + } + let mut dec = open(f, bytes, limits)?; + let (w, h) = dec.dimensions(); + Ok((w, h, dec.exif_metadata().ok().flatten())) +} + /// Counts a GIF's frames (image descriptors) by walking its blocks, without decompressing any. /// The total is unknown when the blocks end before the trailer or break off. fn gif_more_frames(b: &[u8]) -> Option { @@ -145,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/src/codecs/webp.rs b/crates/codecs/src/codecs/webp.rs index 36473041dd..b7b6e2a668 100644 --- a/crates/codecs/src/codecs/webp.rs +++ b/crates/codecs/src/codecs/webp.rs @@ -17,7 +17,10 @@ fn err(e: impl std::fmt::Display) -> CodecError { CodecError::malformed(F, e) } -pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { +type Decoder<'a> = image_webp::WebPDecoder>; + +/// The decoder with its chunks read, the stored size and the output layout, the size checked. +fn open<'a>(bytes: &'a [u8], limits: &Limits) -> Result<(Decoder<'a>, u32, u32, ChannelLayout), CodecError> { let mut dec = image_webp::WebPDecoder::new(Cursor::new(bytes)).map_err(err)?; dec.set_memory_limit(limits.alloc_usize()); let (w, h) = dec.dimensions(); @@ -26,6 +29,11 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result // VP8L back-references and VP8 skipped macroblocks compress flat areas extremely well, so the // ratio is generous; it still stops a header-only file from asking for a gigabyte. Limits::check_plausible(u64::from(w) * u64::from(h) * layout.channels() as u64, bytes.len(), 1 << 16)?; + Ok((dec, w, h, layout)) +} + +pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { + let (mut dec, w, h, layout) = open(bytes, limits)?; let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?; let mut buf = alloc_zeroed::(size)?; dec.read_image(&mut buf).map_err(err)?; @@ -41,6 +49,13 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result Ok(img) } +/// The stored size and EXIF block [`decode`] would find, from the container's chunks alone: the +/// same header reads and size checks, without decoding the bitstream. +pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32, Option>), CodecError> { + let (mut dec, w, h, _) = open(bytes, limits)?; + Ok((w, h, dec.exif_metadata().ok().flatten())) +} + pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { let img = src.converted(plan.layout, plan.sample); if !opts.webp_lossless { diff --git a/crates/codecs/src/fidelity.rs b/crates/codecs/src/fidelity.rs index d5654d27a0..a6937b571c 100644 --- a/crates/codecs/src/fidelity.rs +++ b/crates/codecs/src/fidelity.rs @@ -6,7 +6,7 @@ use std::fmt; use crate::format::{Format, caps}; use crate::image::{ChannelLayout, Image, SampleType}; -use crate::options::EncodeOptions; +use crate::options::{DdsCompression, EncodeOptions}; /// Something that will be lost or altered when writing an image to a format. #[derive(Debug, Clone, PartialEq)] @@ -30,6 +30,10 @@ pub enum FidelityWarning { CmykConverted { to: ChannelLayout, }, + /// L*a*b* data will be converted to another model (sRGB, D50 to D65). + LabConverted { + to: ChannelLayout, + }, /// Colour data will be reduced to grayscale. ColorToGray, /// The image will be quantized to a 256-colour palette. @@ -84,6 +88,7 @@ impl fmt::Display for FidelityWarning { CmykConverted { to } => { write!(f, "CMYK will be converted to {to:?} (not colour-managed)") } + LabConverted { to } => write!(f, "Lab will be converted to {to:?} (sRGB)"), ColorToGray => write!(f, "colour will be converted to grayscale"), PaletteQuantized => write!(f, "colours will be quantized to a 256-colour palette"), LossyCompression => write!(f, "lossy compression"), @@ -129,8 +134,8 @@ fn pick_layout(src: ChannelLayout, layouts: &[ChannelLayout], alpha: bool) -> Ch if layouts.contains(&cand) { return cand; } - // CMYK falls back to RGB; gray expands to RGB. - if cand.is_cmyk() || cand.is_gray() { + // CMYK and Lab fall back to RGB; gray expands to RGB. + if cand.is_cmyk() || cand.is_lab() || cand.is_gray() { cand = if cand.has_alpha() { ChannelLayout::Rgba } else { ChannelLayout::Rgb }; } if layouts.contains(&cand) { @@ -158,6 +163,10 @@ pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan layout: if layout == ChannelLayout::GrayA { ChannelLayout::Gray } else { pick_layout(layout, c.layouts, c.alpha) }, sample: crate::codecs::dpx::plan_sample(opts.dpx_bits, sample), }, + // CIELab has no float form: float Lab is written with 16 bits. + Format::Tiff if layout.is_lab() && sample.is_float() => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: SampleType::U16 }, + // A block-compressed DDS stores RGBA whatever the source; uncompressed keeps gray and RGB. + Format::Dds if opts.dds.compression.is_block() => Plan { layout: ChannelLayout::Rgba, sample: SampleType::U8 }, _ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) }, } } @@ -195,7 +204,8 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption if lossy_depth { w.push(W::DepthReduced { from: ss, to: p.sample }); } - if ss.is_float() && !p.sample.is_float() && image.has_out_of_range() { + // FITS stores floats as 0..1 (see `codecs::fits`), so out-of-range values are clipped too. + if ss.is_float() && (!p.sample.is_float() || format == Format::Fits) && image.has_out_of_range() { w.push(W::RangeClipped); } @@ -203,12 +213,16 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption if sl.has_alpha() && !p.layout.has_alpha() && image.has_translucency() { w.push(W::AlphaDiscarded); } - if format == Format::Gif && p.layout.has_alpha() && image.has_translucency() { + let binary_alpha = format == Format::Gif || (format == Format::Dds && opts.dds.compression == DdsCompression::Bc1); + if binary_alpha && p.layout.has_alpha() && image.has_translucency() { w.push(W::AlphaBinarized); } if sl.is_cmyk() && !p.layout.is_cmyk() { w.push(W::CmykConverted { to: p.layout }); } + if sl.is_lab() && !p.layout.is_lab() { + w.push(W::LabConverted { to: p.layout }); + } if !sl.is_gray() && p.layout.is_gray() { w.push(W::ColorToGray); } @@ -216,7 +230,7 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption // Compression if format == Format::Gif { w.push(W::PaletteQuantized); - } else if c.lossy || (format == Format::WebP && !opts.webp_lossless) { + } else if c.lossy || (format == Format::WebP && !opts.webp_lossless) || (format == Format::Dds && opts.dds.compression.is_block()) { w.push(W::LossyCompression); } diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index d3730c08fb..24611bd90d 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -19,7 +19,8 @@ pub enum Format { Pcx, /// X PixMap icons (XPM2 plain text and XPM3 C-string arrays). Xpm, - /// DirectDraw Surface: the top mip level of a 2D texture, uncompressed or BC1–BC3. + /// DirectDraw Surface: a 2D texture, uncompressed or BC1–BC3. Reading takes the top mip + /// level; writing can add the full mip chain ([`crate::DdsOptions`]). Dds, Qoi, OpenExr, @@ -33,6 +34,8 @@ pub enum Format { Fits, /// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames. Dpx, + /// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from): read-only. + Cineon, } /// What a format can hold **and** what this crate reads/writes for it. @@ -69,15 +72,16 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ Format::Pcx, "PCX is supported for opening legacy paint and clipart assets. New files should use PNG; writing obsolete indexed and planar variants is intentionally not provided.", ), - ( - Format::Dds, - "DDS is supported for opening game and 3D textures. Writing one well means choosing a block compression and generating mip levels, which belongs in a texture export, not Save As.", - ), ( Format::Avif, "AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \ until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.", ), + ( + Format::Cineon, + "Cineon frames are imported for film work; this crate has no Cineon writer, so save an \ + imported frame as DPX, TIFF or EXR instead.", + ), ( Format::Heif, "HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \ @@ -92,10 +96,12 @@ use SampleType as S; const RGB_GRAY: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba]; const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::Cmyk, L::CmykA]; +/// TIFF also stores CIE L*a*b* (CIELab). +const TIFF_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::Cmyk, L::CmykA, L::Lab, L::LabA]; impl Format { /// Every format known to the crate (enabled or not). - pub const ALL: [Format; 19] = [ + pub const ALL: [Format; 20] = [ Format::Png, Format::Jpeg, Format::Tiff, @@ -115,6 +121,7 @@ impl Format { Format::Heif, Format::Fits, Format::Dpx, + Format::Cineon, ]; pub fn caps(self) -> FormatCaps { @@ -148,6 +155,7 @@ impl Format { Format::Heif => "HEIF", Format::Fits => "FITS", Format::Dpx => "DPX", + Format::Cineon => "Cineon", } } @@ -174,6 +182,7 @@ impl Format { Format::Heif => &["heic", "heif", "hif"], Format::Fits => &["fits", "fit", "fts"], Format::Dpx => &["dpx"], + Format::Cineon => &["cin"], } } @@ -198,6 +207,7 @@ impl Format { Format::Heif => "image/heif", Format::Fits => "image/fits", Format::Dpx => "image/x-dpx", + Format::Cineon => "image/x-cin", } } @@ -233,7 +243,7 @@ pub fn caps(format: Format) -> FormatCaps { FormatCaps { depths: &[S::U8, S::U16], layouts: RGB_GRAY, icc: true, exif: true, xmp: true, dpi: true, text: true, animation: true, ..base } } Format::Jpeg => FormatCaps { layouts: &[L::Gray, L::Rgb, L::Cmyk], alpha: false, icc: true, exif: true, xmp: true, dpi: true, lossy: true, ..base }, - Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: true, ..base }, + Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: TIFF_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: true, ..base }, Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, animation: true, ..base }, Format::Gif => FormatCaps { layouts: &[L::Rgba], animation: true, lossy: true, ..base }, Format::Bmp => base, @@ -242,7 +252,8 @@ pub fn caps(format: Format) -> FormatCaps { Format::Pnm => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, ..base }, Format::Pcx => FormatCaps { read: true, write: false, layouts: &[L::Rgb], alpha: false, ..base }, Format::Xpm => FormatCaps { read: true, write: false, layouts: &[L::Rgba], ..base }, - Format::Dds => FormatCaps { read: true, write: false, layouts: RGB_GRAY, ..base }, + // Uncompressed by default; a block compression is lossy (see `fidelity_warnings_with`). + Format::Dds => FormatCaps { layouts: RGB_GRAY, ..base }, Format::Qoi => base, Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base }, Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base }, @@ -253,6 +264,7 @@ pub fn caps(format: Format) -> FormatCaps { Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base }, // `text`: the DPX header fields only (see `fidelity_warnings_with`). Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base }, + Format::Cineon => FormatCaps { read: true, write: false, depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base }, } } @@ -307,6 +319,9 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(b"SDPX") || b.starts_with(b"XPDS") { return Some(Format::Dpx); } + if crate::codecs::dpx::is_cineon(b) { + return Some(Format::Cineon); + } if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index db59d4fa6b..5a03e7d283 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -21,19 +21,31 @@ pub enum ChannelLayout { /// Ink amounts, `0 = no ink`, `max = full ink` (not Adobe-inverted). Cmyk, CmykA, + /// CIE L*a*b* (D50), as PhotoCraft's Lab documents hold it: L* 0..100 as 0..1, a* and b* + /// −128..127 as `(v + 128) / 255` (Photoshop's unsigned encoding). + Lab, + LabA, } impl ChannelLayout { - pub const ALL: [ChannelLayout; 6] = - [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba, ChannelLayout::Cmyk, ChannelLayout::CmykA]; + pub const ALL: [ChannelLayout; 8] = [ + ChannelLayout::Gray, + ChannelLayout::GrayA, + ChannelLayout::Rgb, + ChannelLayout::Rgba, + ChannelLayout::Cmyk, + ChannelLayout::CmykA, + ChannelLayout::Lab, + ChannelLayout::LabA, + ]; /// Total number of channels including alpha. pub const fn channels(self) -> usize { match self { ChannelLayout::Gray => 1, ChannelLayout::GrayA => 2, - ChannelLayout::Rgb => 3, - ChannelLayout::Rgba | ChannelLayout::Cmyk => 4, + ChannelLayout::Rgb | ChannelLayout::Lab => 3, + ChannelLayout::Rgba | ChannelLayout::Cmyk | ChannelLayout::LabA => 4, ChannelLayout::CmykA => 5, } } @@ -44,7 +56,7 @@ impl ChannelLayout { } pub const fn has_alpha(self) -> bool { - matches!(self, ChannelLayout::GrayA | ChannelLayout::Rgba | ChannelLayout::CmykA) + matches!(self, ChannelLayout::GrayA | ChannelLayout::Rgba | ChannelLayout::CmykA | ChannelLayout::LabA) } pub const fn is_gray(self) -> bool { @@ -59,12 +71,17 @@ impl ChannelLayout { matches!(self, ChannelLayout::Cmyk | ChannelLayout::CmykA) } + pub const fn is_lab(self) -> bool { + matches!(self, ChannelLayout::Lab | ChannelLayout::LabA) + } + /// The same colour model with alpha added. pub const fn with_alpha(self) -> Self { match self { ChannelLayout::Gray | ChannelLayout::GrayA => ChannelLayout::GrayA, ChannelLayout::Rgb | ChannelLayout::Rgba => ChannelLayout::Rgba, ChannelLayout::Cmyk | ChannelLayout::CmykA => ChannelLayout::CmykA, + ChannelLayout::Lab | ChannelLayout::LabA => ChannelLayout::LabA, } } @@ -74,12 +91,13 @@ impl ChannelLayout { ChannelLayout::Gray | ChannelLayout::GrayA => ChannelLayout::Gray, ChannelLayout::Rgb | ChannelLayout::Rgba => ChannelLayout::Rgb, ChannelLayout::Cmyk | ChannelLayout::CmykA => ChannelLayout::Cmyk, + ChannelLayout::Lab | ChannelLayout::LabA => ChannelLayout::Lab, } } /// `true` if both layouts share a colour model (ignoring alpha). pub const fn same_model(self, other: Self) -> bool { - (self.is_gray() && other.is_gray()) || (self.is_rgb() && other.is_rgb()) || (self.is_cmyk() && other.is_cmyk()) + (self.is_gray() && other.is_gray()) || (self.is_rgb() && other.is_rgb()) || (self.is_cmyk() && other.is_cmyk()) || (self.is_lab() && other.is_lab()) } } @@ -303,20 +321,75 @@ pub struct ExrPartInfo { impl ExrPartInfo { /// How much this part looks like a colour image the plain [`Image`] decode can show: - /// 4 RGBA, 3 RGB, 2 YA, 1 any-single-channel, 0 anything else (e.g. `Z` beside `A`). + /// 4 RGBA, 3 RGB, 2 YA, 1 Y or any-single-channel, 0 anything else (e.g. `Z` beside `A`). + /// The colour and alpha channels must share a complete layer prefix. The highest-ranked + /// group wins; equal groups prefer unprefixed channels, then the first prefix alphabetically. /// The auto-pick takes the highest rank; ties go to the earlier part. pub fn color_rank(&self) -> u8 { - let has = |n: &str| self.channels.iter().any(|c| base_channel_name(&c.name) == n); - if has("R") && has("G") && has("B") { - return if has("A") { 4 } else { 3 }; - } - if has("Y") { - return if has("A") { 2 } else { 1 }; - } - u8::from(self.channels.len() == 1) + exr_color_channels(self.channels.iter().map(|c| c.name.as_str())).map_or(0, |(layout, _)| layout.channels() as u8) } } +/// One selection policy for header ranking/probing and flat decoding: never synthesize +/// RGB(A) or YA from separate layers. OpenEXR layers use the full prefix before the last +/// dot, including nested layers; a leading or trailing dot alone does not name a layer. +pub(crate) fn exr_color_channels<'a>(names: impl IntoIterator) -> Option<(ChannelLayout, Vec)> { + #[derive(Default)] + struct Group { + r: Option, + g: Option, + b: Option, + a: Option, + y: Option, + } + + // Sorted prefixes make rank ties deterministic independently of channel order, with + // the empty (unprefixed) group first. Unsupported channels cannot complete a group. + let mut groups = std::collections::BTreeMap::<&str, Group>::new(); + let mut count = 0usize; + for (index, name) in names.into_iter().enumerate() { + count = count.saturating_add(1); + let (prefix, base) = name.rsplit_once('.').filter(|(prefix, base)| !prefix.is_empty() && !base.is_empty()).unwrap_or(("", name)); + if !matches!(base, "R" | "G" | "B" | "A" | "Y") { + continue; + } + let group = groups.entry(prefix).or_default(); + let component = match base { + "R" => &mut group.r, + "G" => &mut group.g, + "B" => &mut group.b, + "A" => &mut group.a, + "Y" => &mut group.y, + _ => continue, + }; + component.get_or_insert(index); + } + + let mut best = None; + let mut best_rank = 0; + for group in groups.values() { + let candidate = if let (Some(r), Some(g), Some(b)) = (group.r, group.g, group.b) { + match group.a { + Some(a) => (ChannelLayout::Rgba, vec![r, g, b, a]), + None => (ChannelLayout::Rgb, vec![r, g, b]), + } + } else if let Some(y) = group.y { + match group.a { + Some(a) => (ChannelLayout::GrayA, vec![y, a]), + None => (ChannelLayout::Gray, vec![y]), + } + } else { + continue; + }; + let rank = candidate.0.channels(); + if rank > best_rank { + best = Some(candidate); + best_rank = rank; + } + } + best.or_else(|| (count == 1).then(|| (ChannelLayout::Gray, vec![0]))) +} + /// Strip an optional `layer.` prefix from a channel name (`diffuse.R` → `R`). pub(crate) fn base_channel_name(name: &str) -> &str { name.rsplit('.').next().unwrap_or(name) @@ -723,6 +796,7 @@ pub(crate) fn convert_layout(src: &[f32], from: ChannelLayout, to: ChannelLayout ChannelLayout::Gray | ChannelLayout::GrayA => [color[0]; 3], ChannelLayout::Rgb | ChannelLayout::Rgba => [color[0], color[1], color[2]], ChannelLayout::Cmyk | ChannelLayout::CmykA => cmyk_to_rgb(color[0], color[1], color[2], color[3]), + ChannelLayout::Lab | ChannelLayout::LabA => crate::lab::lab_to_srgb([color[0], color[1], color[2]]), }; match to { ChannelLayout::Gray | ChannelLayout::GrayA => { @@ -730,6 +804,7 @@ pub(crate) fn convert_layout(src: &[f32], from: ChannelLayout, to: ChannelLayout } ChannelLayout::Rgb | ChannelLayout::Rgba => out.extend_from_slice(&rgb), ChannelLayout::Cmyk | ChannelLayout::CmykA => out.extend_from_slice(&rgb_to_cmyk(rgb[0], rgb[1], rgb[2])), + ChannelLayout::Lab | ChannelLayout::LabA => out.extend_from_slice(&crate::lab::srgb_to_lab(rgb)), } } if to.has_alpha() { diff --git a/crates/codecs/src/lab.rs b/crates/codecs/src/lab.rs new file mode 100644 index 0000000000..c53ab4c33f --- /dev/null +++ b/crates/codecs/src/lab.rs @@ -0,0 +1,56 @@ +//! CIE L*a*b* (D50) ⇄ encoded sRGB for [`crate::ChannelLayout::Lab`] samples, the same formulas +//! and constants as `photocraft-color` (this crate stays standalone). Samples are normalized as +//! PhotoCraft holds Lab: L* 0..100 as 0..1, a* and b* −128..127 as `(v + 128) / 255`. + +const D50: [f32; 3] = [0.964_22, 1.0, 0.825_21]; +/// Linear sRGB → D50 XYZ (Bradford-adapted, as in the ICC sRGB profile). +const SRGB_TO_XYZ_D50: [[f32; 3]; 3] = [[0.436_074, 0.385_064, 0.143_080], [0.222_504, 0.716_878, 0.060_618], [0.013_932, 0.097_104, 0.714_173]]; +/// D50 XYZ → linear sRGB. +const XYZ_D50_TO_SRGB: [[f32; 3]; 3] = [[3.133_856, -1.616_867, -0.490_615], [-0.978_768, 1.916_142, 0.033_454], [0.071_945, -0.228_991, 1.405_243]]; + +fn mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] { + m.map(|r| r[0] * v[0] + r[1] * v[1] + r[2] * v[2]) +} + +fn to_linear(v: f32) -> f32 { + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } +} + +fn to_encoded(v: f32) -> f32 { + if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } +} + +/// Normalized Lab → encoded sRGB in 0..1 (out-of-gamut colours clipped). +pub(crate) fn lab_to_srgb(n: [f32; 3]) -> [f32; 3] { + let (l, a, b) = (n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0); + let fy = (l + 16.0) / 116.0; + let (fx, fz) = (fy + a / 500.0, fy - b / 200.0); + let finv = |t: f32| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f32 / 29.0).powi(2) * (t - 4.0 / 29.0) }; + let xyz = [finv(fx) * D50[0], finv(fy) * D50[1], finv(fz) * D50[2]]; + mul(&XYZ_D50_TO_SRGB, xyz).map(|v| to_encoded(v.clamp(0.0, 1.0))) +} + +/// Encoded sRGB → normalized Lab. +pub(crate) fn srgb_to_lab(rgb: [f32; 3]) -> [f32; 3] { + let xyz = mul(&SRGB_TO_XYZ_D50, rgb.map(|v| to_linear(v.clamp(0.0, 1.0)))); + let f = |t: f32| if t > (6.0f32 / 29.0).powi(3) { t.cbrt() } else { t / (3.0 * (6.0f32 / 29.0).powi(2)) + 4.0 / 29.0 }; + let (fx, fy, fz) = (f(xyz[0] / D50[0]), f(xyz[1] / D50[1]), f(xyz[2] / D50[2])); + let (l, a, b) = (116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)); + [(l / 100.0).clamp(0.0, 1.0), ((a + 128.0) / 255.0).clamp(0.0, 1.0), ((b + 128.0) / 255.0).clamp(0.0, 1.0)] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn white_black_and_round_trips() { + let w = srgb_to_lab([1.0; 3]); + assert!((w[0] - 1.0).abs() < 1e-3 && (w[1] - 128.0 / 255.0).abs() < 1e-3 && (w[2] - 128.0 / 255.0).abs() < 1e-3, "{w:?}"); + assert!(srgb_to_lab([0.0; 3])[0].abs() < 1e-4); + for rgb in [[0.2, 0.5, 0.8], [0.9, 0.1, 0.3], [0.5; 3]] { + let back = lab_to_srgb(srgb_to_lab(rgb)); + assert!(rgb.iter().zip(back).all(|(a, b)| (a - b).abs() < 2e-3), "{rgb:?} → {back:?}"); + } + } +} diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 438e1857e4..a5675c9823 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -19,6 +19,7 @@ pub mod exif_color; mod fidelity; mod format; mod image; +mod lab; mod options; pub mod orientation; pub mod resolution; @@ -27,7 +28,7 @@ pub mod web; pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color}; pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES}; -pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette}; +pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo}; pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian}; pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind}; pub use crate::error::CodecError; @@ -37,7 +38,9 @@ pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, pub use crate::image::{ ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType, }; -pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression}; +pub use crate::options::{ + DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, PngStereo, TiffCompression, +}; pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp}; pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution}; pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; @@ -79,6 +82,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Dds => dds::decode(bytes, l), Format::Fits => fits::decode(bytes, l), Format::Dpx => dpx::decode(bytes, l), + Format::Cineon => dpx::decode_cineon(bytes, l), Format::OpenExr => exr::decode(bytes, l), Format::Heif => heif::decode(bytes, l, opts.keep_orientation), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l), @@ -97,11 +101,33 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Tiff => tiff::orientation(bytes, None), Format::Heif => 1, Format::Dpx => dpx::orientation(bytes), + Format::Cineon => dpx::cineon_orientation(bytes), _ => img.meta.exif.as_deref().map_or(1, exif_orientation), }; finish_decode(img, o, opts) } +/// The upright pixel size [`decode`] would return, read from the file's header without decoding +/// pixels, for WebP, OpenEXR (flat) and the GIF/BMP/TGA/ICO/QOI/HDR family. It runs the same +/// header reads and size checks as the decoder (default [`Limits`]) and swaps the size for an +/// EXIF orientation of 5–8, so wherever [`decode`] succeeds the two agree. Other formats (and +/// deep EXR or AVIF) return [`CodecError::Unsupported`]: decode those to learn their size. +pub fn probe_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> { + let format = detect(bytes).ok_or(CodecError::UnknownFormat)?; + if !caps(format).read { + return Err(CodecError::unsupported(format, "decoding is not available for this format")); + } + let l = &Limits::default(); + let (w, h, exif) = match format { + Format::WebP => webp::probe(bytes, l)?, + Format::OpenExr => exr::probe(bytes, l).map(|(w, h)| (w, h, None))?, + Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::probe(format, bytes, l)?, + _ => return Err(CodecError::unsupported(format, "no header-only size probe")), + }; + let o = exif.as_deref().map_or(1, exif_orientation); + Ok(if (5..=8).contains(&o) { (h, w) } else { (w, h) }) +} + /// Returns the embedded colour table for an indexed BMP, if it has one. pub fn bmp_palette(bytes: &[u8]) -> Result>, CodecError> { via_image::bmp_palette(bytes) @@ -228,9 +254,10 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result pnm::encode(image, plan, opts), Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")), Format::Xpm => Err(CodecError::unsupported(format, "XPM is import-only; save as PNG instead")), - Format::Dds => Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")), + Format::Dds => dds::encode(image, plan, opts), Format::Fits => fits::encode(image, plan, opts), Format::Dpx => dpx::encode(image, plan, opts), + Format::Cineon => Err(CodecError::unsupported(format, "Cineon is import-only; save as DPX, TIFF or EXR instead")), Format::OpenExr => exr::encode(image, plan, opts), Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts), diff --git a/crates/codecs/src/options.rs b/crates/codecs/src/options.rs index eee5b38c0a..08de328574 100644 --- a/crates/codecs/src/options.rs +++ b/crates/codecs/src/options.rs @@ -100,6 +100,34 @@ pub enum PngCompression { Best, } +/// How a PNG's `sTER` chunk pairs its two views (PNG Extensions 1.4.0, §3.6). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PngStereo { + /// Left and right views side by side, left first. + Horizontal, + /// Left and right views stacked, left on top. + Vertical, +} + +impl PngStereo { + /// The view for an `sTER` mode byte; reserved and unknown values are `None`. + pub fn from_mode(mode: u8) -> Option { + match mode { + 0 => Some(Self::Horizontal), + 1 => Some(Self::Vertical), + _ => None, + } + } + + /// The `sTER` mode byte. + pub fn mode(self) -> u8 { + match self { + Self::Horizontal => 0, + Self::Vertical => 1, + } + } +} + /// TIFF strip compression. The default is none, as in Photoshop: uncompressed strips are the /// only kind every TIFF reader opens (Deflate and the predictor are not baseline TIFF, #4484). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -154,6 +182,61 @@ impl DpxBits { } } +/// How a DDS texture stores its pixels. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DdsCompression { + /// Uncompressed, 8 bits per channel: BGRA (BGRX without alpha), or luminance with or + /// without alpha for grayscale. Lossless. + #[default] + None, + /// BC1 (DXT1): 4 bits per pixel. Alpha is on or off: texels under half opacity become + /// transparent black. + Bc1, + /// BC2 (DXT3): 8 bits per pixel, with 4 bits of explicit alpha per texel. + Bc2, + /// BC3 (DXT5): 8 bits per pixel, with smooth interpolated alpha. + Bc3, +} + +impl DdsCompression { + pub const ALL: [DdsCompression; 4] = [DdsCompression::None, DdsCompression::Bc1, DdsCompression::Bc2, DdsCompression::Bc3]; + + /// The name scripts and the CLI use: `none`, `bc1`, `bc2` or `bc3`. + pub fn name(self) -> &'static str { + match self { + DdsCompression::None => "none", + DdsCompression::Bc1 => "bc1", + DdsCompression::Bc2 => "bc2", + DdsCompression::Bc3 => "bc3", + } + } + + /// [`Self::name`] back to a compression, also taking the DXT names (`dxt1`, `dxt3`, + /// `dxt5`), in any case. Anything else is `None`. + pub fn from_name(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "none" => Some(DdsCompression::None), + "bc1" | "dxt1" => Some(DdsCompression::Bc1), + "bc2" | "dxt3" => Some(DdsCompression::Bc2), + "bc3" | "dxt5" => Some(DdsCompression::Bc3), + _ => None, + } + } + + /// A block compression (lossy), not uncompressed pixels. + pub fn is_block(self) -> bool { + self != DdsCompression::None + } +} + +/// What a DDS file is written with. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct DdsOptions { + pub compression: DdsCompression, + /// Write the full mip chain down to 1×1, each level a box filter of the one above. + pub mipmaps: bool, +} + /// Options for [`crate::encode`]. #[derive(Debug, Clone)] pub struct EncodeOptions { @@ -164,6 +247,9 @@ pub struct EncodeOptions { pub png_compression: PngCompression, /// Write Adam7-interlaced PNG. pub png_interlaced: bool, + /// Write an `sTER` chunk marking the image as a stereo pair (PNG Extensions 1.4.0, §3.6), + /// so a stereo PNG keeps its mark when saved again (#6063). + pub png_stereo: Option, /// WebP: lossless (VP8L, via `image-webp`) or lossy (our VP8 encoder, see /// `codecs::vp8`) with [`Self::webp_quality`]. pub webp_lossless: bool, @@ -176,6 +262,8 @@ pub struct EncodeOptions { pub exr_compression: ExrCompression, /// DPX bits per sample. `None` follows the image: 8 for 8-bit, 16 otherwise. pub dpx_bits: Option, + /// DDS compression and mip chain. + pub dds: DdsOptions, /// Embed the ICC profile when the format supports it. pub embed_icc: bool, /// Embed EXIF/XMP/DPI/text when the format supports it. @@ -189,12 +277,14 @@ impl Default for EncodeOptions { jpeg_chroma_subsampling: true, png_compression: PngCompression::Default, png_interlaced: false, + png_stereo: None, webp_lossless: true, webp_quality: 80, tiff_compression: TiffCompression::None, tiff_bigtiff: false, exr_compression: ExrCompression::Zip16, dpx_bits: None, + dds: DdsOptions::default(), embed_icc: true, embed_metadata: true, } diff --git a/crates/codecs/src/web.rs b/crates/codecs/src/web.rs index 584ceb9d6e..dfd0cd9b90 100644 --- a/crates/codecs/src/web.rs +++ b/crates/codecs/src/web.rs @@ -285,7 +285,8 @@ pub fn decode_wbmp(bytes: &[u8]) -> Result<(u32, u32, Vec), CodecError> { } /// JPEG from 8-bit RGB (`rgb.len() == 3·w·h`): quality 1..=100, optional progressive scans and -/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density and XMP. +/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density, EXIF and +/// XMP. EXIF that doesn't fit in one segment is left out (see [`jpeg_exif_fits`]). #[allow(clippy::too_many_arguments)] pub fn encode_jpeg_rgb8( width: u32, @@ -296,6 +297,7 @@ pub fn encode_jpeg_rgb8( optimized: bool, icc: Option<&[u8]>, dpi: Option, + exif: Option<&[u8]>, xmp: Option<&str>, ) -> Result, CodecError> { let e = |e: jpeg_encoder::EncodingError| CodecError::encode(Format::Jpeg, e); @@ -319,9 +321,14 @@ pub fn encode_jpeg_rgb8( let v = d.round().min(65535.0) as u16; enc.set_density(jpeg_encoder::Density::Inch { x: v, y: v }); } + let ppi = dpi.filter(|d| *d >= 1.0).map(|d| (d, d)); + // Upright, without the source's thumbnail, at the JFIF resolution, as Save As writes it. + if let Some(seg) = exif.and_then(|x| crate::codecs::jpeg::exif_segment(x, ppi)) { + enc.add_app_segment(1, &seg).map_err(e)?; + } if let Some(x) = xmp { // Its `tiff:` resolution, if any, says what the JFIF density says (#1691). - let x = crate::resolution::export_xmp(x, dpi.filter(|d| *d >= 1.0).map(|d| (d, d))); + let x = crate::resolution::export_xmp(x, ppi); let mut seg = b"http://ns.adobe.com/xap/1.0/\0".to_vec(); seg.extend_from_slice(x.as_bytes()); enc.add_app_segment(1, &seg).map_err(e)?; @@ -333,6 +340,11 @@ pub fn encode_jpeg_rgb8( Ok(out) } +/// Whether [`encode_jpeg_rgb8`] can write `exif` at `dpi` (it must fit in one APP1 segment). +pub fn jpeg_exif_fits(exif: &[u8], dpi: Option) -> bool { + crate::codecs::jpeg::exif_segment(exif, dpi.filter(|d| *d >= 1.0).map(|d| (d, d))).is_some() +} + #[cfg(test)] mod tests { use super::*; @@ -359,7 +371,7 @@ mod tests { let rgb: Vec = (0..16 * 16 * 3).map(|i| (i * 37 % 256) as u8).collect(); for (q, want) in [(1, (2, 2)), (50, (2, 2)), (51, (1, 1)), (60, (1, 1)), (89, (1, 1)), (100, (1, 1))] { for progressive in [false, true] { - let bytes = encode_jpeg_rgb8(16, 16, &rgb, q, progressive, false, None, None, None).unwrap(); + let bytes = encode_jpeg_rgb8(16, 16, &rgb, q, progressive, false, None, None, None, None).unwrap(); assert_eq!(luma_sampling(&bytes), Some(want), "quality {q} progressive {progressive}"); } } @@ -428,8 +440,8 @@ mod tests { #[test] fn progressive_jpeg_decodes() { let rgb: Vec = (0..64 * 48 * 3).map(|i| (i % 251) as u8).collect(); - let base = encode_jpeg_rgb8(64, 48, &rgb, 80, false, false, None, Some(72.0), Some("")).unwrap(); - let prog = encode_jpeg_rgb8(64, 48, &rgb, 80, true, true, None, None, None).unwrap(); + let base = encode_jpeg_rgb8(64, 48, &rgb, 80, false, false, None, Some(72.0), None, Some("")).unwrap(); + let prog = encode_jpeg_rgb8(64, 48, &rgb, 80, true, true, None, None, None, None).unwrap(); assert_ne!(base, prog); assert_eq!(crate::decode(&base).unwrap().meta.xmp.as_deref(), Some("")); // SOF2 marks a progressive frame. @@ -442,7 +454,7 @@ mod tests { fn web_jpeg_xmp_resolution_follows_the_density() { let rgb = vec![100u8; 8 * 8 * 3]; let xmp = r#""#; - let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), Some(xmp)).unwrap(); + let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), None, Some(xmp)).unwrap(); let img = crate::decode(&out).unwrap(); assert_eq!(img.meta.dpi, Some((72.0, 72.0))); assert_eq!(img.meta.xmp.as_deref().and_then(crate::xmp_resolution), Some((72.0, 72.0))); @@ -461,7 +473,7 @@ mod subsampling_tests { let rgb: Vec = (0..w * h).flat_map(|i| [((i % w) * 4) as u8, 76, ((i / w) * 5) as u8]).collect(); for q in [30, 60, 95] { for (prog, opt) in [(false, false), (false, true), (true, false), (true, true)] { - let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None).unwrap(); + let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None, None).unwrap(); let ours = crate::decode(&bytes).unwrap().convert(crate::ChannelLayout::Rgb, crate::SampleType::U8); let err = ours.data().iter().zip(&rgb).map(|(a, b)| i32::from(*a).abs_diff(i32::from(*b))).max().unwrap(); assert!(err < 24, "q{q} progressive={prog} optimized={opt}: max error {err}"); diff --git a/crates/codecs/tests/dds.rs b/crates/codecs/tests/dds.rs index 766555f6e5..ebf4042f48 100644 --- a/crates/codecs/tests/dds.rs +++ b/crates/codecs/tests/dds.rs @@ -59,11 +59,12 @@ const WHITE: u16 = 0xffff; const INDICES_0123: [u8; 4] = [0x04, 0x0e, 0, 0]; #[test] -fn dds_is_readable_but_not_writable() { +fn dds_is_readable_and_writable() { assert_eq!(from_extension("texture.DDS"), Some(Format::Dds)); - assert!(caps(Format::Dds).read && !caps(Format::Dds).write); - let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgba, vec![0, 0, 0, 0]).unwrap(); - assert!(matches!(encode(&img, Format::Dds, &Default::default()), Err(CodecError::Unsupported { .. }))); + assert!(caps(Format::Dds).read && caps(Format::Dds).write); + let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgba, vec![1, 2, 3, 0]).unwrap(); + let b = encode(&img, Format::Dds, &Default::default()).unwrap(); + assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 0]); } #[test] diff --git a/crates/codecs/tests/dds_write.rs b/crates/codecs/tests/dds_write.rs new file mode 100644 index 0000000000..3225dd14d6 --- /dev/null +++ b/crates/codecs/tests/dds_write.rs @@ -0,0 +1,372 @@ +//! Writing DDS textures: every storage read back through the reader, the header fields, sizes +//! that aren't a multiple of four, the mip chain, and the block-compression error on ramps. + +use photocraft_codecs::{ChannelLayout, DdsCompression, DdsOptions, EncodeOptions, FidelityWarning, Format, Image, decode, encode, fidelity_warnings_with}; + +const DDSD_PITCH: u32 = 0x8; +const DDSD_MIPMAPCOUNT: u32 = 0x2_0000; +const DDSD_LINEARSIZE: u32 = 0x8_0000; +const REQUIRED: u32 = 0x1007; // CAPS | HEIGHT | WIDTH | PIXELFORMAT +const DDSCAPS_COMPLEX: u32 = 0x8; +const DDSCAPS_TEXTURE: u32 = 0x1000; +const DDSCAPS_MIPMAP: u32 = 0x40_0000; +const DDPF_ALPHAPIXELS: u32 = 0x1; +const DDPF_FOURCC: u32 = 0x4; +const DDPF_RGB: u32 = 0x40; +const DDPF_LUMINANCE: u32 = 0x2_0000; + +const BLOCKS: [DdsCompression; 3] = [DdsCompression::Bc1, DdsCompression::Bc2, DdsCompression::Bc3]; + +fn opts(compression: DdsCompression, mipmaps: bool) -> EncodeOptions { + EncodeOptions { dds: DdsOptions { compression, mipmaps }, ..Default::default() } +} + +fn le32(b: &[u8], at: usize) -> u32 { + u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) +} + +/// A ramp along one line through RGB (red up, green up half as fast, blue down) across the +/// texture, and alpha falling from opaque down it: every block's colours lie on a line, which +/// is what a colour block stores. +fn ramp(w: u32, h: u32, layout: ChannelLayout) -> Image { + let ch = layout.channels(); + let at = |v: u32, n: u32| (v * 255 / (n - 1).max(1)) as u8; + let mut data = Vec::new(); + for y in 0..h { + for x in 0..w { + let r = at(x, w); + let rgba = [r, 64 + r / 2, 255 - r, 255 - at(y, h)]; + match ch { + 1 => data.push(rgba[0]), + 2 => data.extend([rgba[0], rgba[3]]), + _ => data.extend(&rgba[..ch]), + } + } + } + Image::from_u8(w, h, layout, data).unwrap() +} + +/// The same RGBA image, fully opaque. +fn opaque(img: &Image) -> Image { + let data = img.data().chunks(4).flat_map(|p| [p[0], p[1], p[2], 255]).collect(); + Image::from_u8(img.width(), img.height(), ChannelLayout::Rgba, data).unwrap() +} + +/// Largest difference in channel `c` (of four) between RGBA images. +fn max_diff_channel(a: &Image, b: &Image, c: usize) -> u8 { + a.data().chunks(4).zip(b.data().chunks(4)).map(|(x, y)| x[c].abs_diff(y[c])).max().unwrap_or(0) +} + +/// Largest colour difference between RGBA images where `b` is visible (BC1 turns texels under +/// half opacity into transparent black). +fn max_colour_diff(a: &Image, b: &Image) -> u8 { + a.data().chunks(4).zip(b.data().chunks(4)).filter(|(_, y)| y[3] != 0).flat_map(|(x, y)| (0..3).map(|c| x[c].abs_diff(y[c]))).max().unwrap_or(0) +} + +/// A 2D gradient: red across, green down and blue along the diagonal, opaque. +fn gradient(w: u32, h: u32) -> Image { + let data = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x * 4).min(255) as u8, (y * 4).min(255) as u8, ((x + y) * 2).min(255) as u8, 255])).collect(); + Image::from_u8(w, h, ChannelLayout::Rgba, data).unwrap() +} + +/// PSNR of the colour (not the alpha) of two RGBA images. +fn psnr(a: &Image, b: &Image) -> f64 { + let rgb = |i: &Image| i.data().chunks(4).flat_map(|p| [p[0], p[1], p[2]]).map(f64::from).collect::>(); + let (a, b) = (rgb(a), rgb(b)); + let mse = a.iter().zip(&b).map(|(x, y)| (x - y).powi(2)).sum::() / a.len() as f64; + if mse == 0.0 { f64::INFINITY } else { 10.0 * (255.0f64.powi(2) / mse).log10() } +} + +/// Bytes of one level as a reader computes them from the header's pixel format. +fn level_bytes(file: &[u8], w: usize, h: usize) -> usize { + if le32(file, 80) & DDPF_FOURCC != 0 { + let block = if &file[84..88] == b"DXT1" { 8 } else { 16 }; + w.div_ceil(4) * h.div_ceil(4) * block + } else { + w * h * le32(file, 88) as usize / 8 + } +} + +/// Mip level `i` of a written texture, cut out as a single-level texture and decoded. +fn level(file: &[u8], i: u32) -> Image { + let (w, h) = (le32(file, 16) as usize, le32(file, 12) as usize); + let size = |i: u32| ((w >> i).max(1), (h >> i).max(1)); + let start = 128 + (0..i).map(|j| level_bytes(file, size(j).0, size(j).1)).sum::(); + let (lw, lh) = size(i); + let mut b = file[..128].to_vec(); + b[12..16].copy_from_slice(&(lh as u32).to_le_bytes()); + b[16..20].copy_from_slice(&(lw as u32).to_le_bytes()); + b[28..32].copy_from_slice(&1u32.to_le_bytes()); + b.extend(&file[start..start + level_bytes(file, lw, lh)]); + decode(&b).unwrap() +} + +#[test] +fn uncompressed_textures_round_trip_every_layout_exactly() { + for (layout, pf_flags, bits, masks) in [ + (ChannelLayout::Gray, DDPF_LUMINANCE, 8, [0xff, 0, 0, 0]), + (ChannelLayout::GrayA, DDPF_LUMINANCE | DDPF_ALPHAPIXELS, 16, [0xff, 0, 0, 0xff00]), + (ChannelLayout::Rgb, DDPF_RGB, 32, [0xff_0000, 0xff00, 0xff, 0]), + (ChannelLayout::Rgba, DDPF_RGB | DDPF_ALPHAPIXELS, 32, [0xff_0000, 0xff00, 0xff, 0xff00_0000]), + ] { + for (w, h) in [(1, 1), (5, 3), (17, 9)] { + let img = ramp(w, h, layout); + let b = encode(&img, Format::Dds, &EncodeOptions::default()).unwrap(); + let back = decode(&b).unwrap(); + assert_eq!(back.layout(), layout, "{layout:?} {w}x{h}"); + assert_eq!(back.data(), img.data(), "{layout:?} {w}x{h}"); + + assert_eq!((&b[..4], le32(&b, 4), le32(&b, 76)), (&b"DDS "[..], 124, 32)); + assert_eq!(le32(&b, 8), REQUIRED | DDSD_PITCH, "pitch, not linear size, and no mip count"); + assert_eq!((le32(&b, 12), le32(&b, 16)), (h, w)); + assert_eq!(le32(&b, 20), w * bits / 8, "pitch of {layout:?} {w}x{h}"); + assert_eq!((le32(&b, 28), le32(&b, 108)), (1, DDSCAPS_TEXTURE)); + assert_eq!((le32(&b, 80), le32(&b, 88)), (pf_flags, bits)); + assert_eq!([le32(&b, 92), le32(&b, 96), le32(&b, 100), le32(&b, 104)], masks); + assert_eq!(b.len(), 128 + (w * h * bits / 8) as usize); + } + } +} + +#[test] +fn block_compressed_textures_round_trip_through_the_reader() { + for (compression, fourcc, block) in [(DdsCompression::Bc1, b"DXT1", 8), (DdsCompression::Bc2, b"DXT3", 16), (DdsCompression::Bc3, b"DXT5", 16)] { + // Not multiples of four, and the 1×1 and 2×2 textures smaller than one block. + for (w, h) in [(1, 1), (2, 2), (4, 4), (5, 3), (7, 13), (33, 17)] { + let img = ramp(w, h, ChannelLayout::Rgba); + let b = encode(&img, Format::Dds, &opts(compression, false)).unwrap(); + let linear = w.div_ceil(4) * h.div_ceil(4) * block; + assert_eq!(&b[84..88], fourcc); + assert_eq!((le32(&b, 80), le32(&b, 88)), (DDPF_FOURCC, 0)); + assert_eq!(le32(&b, 8), REQUIRED | DDSD_LINEARSIZE, "linear size, not pitch"); + assert_eq!(le32(&b, 20), linear, "{compression:?} {w}x{h}: the top level's size"); + assert_eq!(b.len(), 128 + linear as usize); + + let back = decode(&b).unwrap(); + assert_eq!((back.dimensions(), back.layout()), ((w, h), ChannelLayout::Rgba)); + // Steep ramps across tiny textures put three colours where a block interpolates at + // thirds, so this bound is loose; it still catches swapped channels or misplaced + // blocks. `block_compression_error_on_gradients_is_bounded` is the tight one. A BC1 + // block with transparent texels has only three colours, so BC1's colours are + // checked on the opaque ramp. + let (src, back_colour) = if compression == DdsCompression::Bc1 { + let o = opaque(&img); + let back = decode(&encode(&o, Format::Dds, &opts(compression, false)).unwrap()).unwrap(); + (o, back) + } else { + (img.clone(), back.clone()) + }; + let colour = max_colour_diff(&src, &back_colour); + assert!(colour <= 24, "{compression:?} {w}x{h}: colour off by {colour}"); + let alpha = max_diff_channel(&img, &back, 3); + match compression { + DdsCompression::Bc1 => assert!(back.data().chunks(4).zip(img.data().chunks(4)).all(|(b, i)| b[3] == if i[3] < 128 { 0 } else { 255 })), + _ => assert!(alpha <= 8, "{compression:?} {w}x{h}: alpha off by {alpha}"), + } + } + } +} + +#[test] +fn gray_and_rgb_sources_are_written_as_rgba_blocks() { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb] { + for compression in BLOCKS { + let img = ramp(8, 8, layout); + let back = decode(&encode(&img, Format::Dds, &opts(compression, false)).unwrap()).unwrap(); + let want = img.convert(ChannelLayout::Rgba, img.sample_type()); + assert_eq!(back.layout(), ChannelLayout::Rgba); + let colour = max_colour_diff(&want, &back); + assert!(colour <= 8, "{layout:?} {compression:?}: colour off by {colour}"); + } + } +} + +#[test] +fn block_compression_error_on_gradients_is_bounded() { + let (line, plane) = (ramp(64, 64, ChannelLayout::Rgba), gradient(64, 64)); + for compression in BLOCKS { + let roundtrip = |img: &Image| decode(&encode(img, Format::Dds, &opts(compression, false)).unwrap()).unwrap(); + let (line_back, plane_back) = (roundtrip(&line), roundtrip(&plane)); + let (line_err, plane_err) = (max_colour_diff(&line, &line_back), max_colour_diff(&plane, &plane_back)); + let p = psnr(&plane, &plane_back); + let alpha = max_diff_channel(&line, &line_back, 3); + // Colours on a line only lose the RGB565 rounding of the endpoints (up to 4 levels in + // red and blue). A gradient in two directions is not on a line: the spread off a + // block's best line alone limits this one to about 40 dB, and RGB565 costs a little + // more (the encoder measures 38.6 dB and 9 levels at most). + assert!(line_err <= 5, "{compression:?}: colour ramp off by {line_err}"); + assert!(plane_err <= 10, "{compression:?}: 2D gradient off by {plane_err}"); + assert!(p >= 38.0, "{compression:?}: 2D gradient PSNR {p:.1} dB"); + match compression { + DdsCompression::Bc1 => assert!(line_back.data().chunks(4).zip(line.data().chunks(4)).all(|(b, i)| b[3] == if i[3] < 128 { 0 } else { 255 })), + // Four bits: within half a step of 17. + DdsCompression::Bc2 => assert!(alpha <= 8, "BC2 alpha off by {alpha}"), + // Eight values across a block's few levels of alpha. + _ => assert!(alpha <= 2, "BC3 alpha off by {alpha}"), + } + } +} + +#[test] +fn solid_and_cutout_textures_keep_their_values() { + // RGB565-exact colours and BC2's 4-bit alpha steps come back exactly. + let solid = Image::from_u8(6, 5, ChannelLayout::Rgba, [255, 0, 255, 170].repeat(30)).unwrap(); + for compression in BLOCKS { + let back = decode(&encode(&solid, Format::Dds, &opts(compression, false)).unwrap()).unwrap(); + let want = if compression == DdsCompression::Bc1 { [255, 0, 255, 255] } else { [255, 0, 255, 170] }; + assert!(back.data().chunks(4).all(|p| p == want), "{compression:?}: {:?}", &back.data()[..4]); + } + // A cutout: BC1's transparent texels decode as transparent black, the rest stay opaque. + let cutout: Vec = (0..36).flat_map(|i| if (i / 6 + i % 6) % 2 == 0 { [200, 40, 40, 255] } else { [10, 10, 10, 0] }).collect(); + let cutout = Image::from_u8(6, 6, ChannelLayout::Rgba, cutout).unwrap(); + for compression in BLOCKS { + let back = decode(&encode(&cutout, Format::Dds, &opts(compression, false)).unwrap()).unwrap(); + assert_eq!(max_diff_channel(&cutout, &back, 3), 0, "{compression:?}"); + if compression == DdsCompression::Bc1 { + assert!(back.data().chunks(4).filter(|p| p[3] == 0).all(|p| p == [0, 0, 0, 0])); + } + } +} + +#[test] +fn mipmaps_write_the_full_chain_box_filtered() { + // 16×8 halves to 8×4, 4×2, 2×1 and 1×1: five levels. + let img = ramp(16, 8, ChannelLayout::Rgba); + let b = encode(&img, Format::Dds, &opts(DdsCompression::None, true)).unwrap(); + assert_eq!(le32(&b, 8), REQUIRED | DDSD_PITCH | DDSD_MIPMAPCOUNT); + assert_eq!(le32(&b, 28), 5); + assert_eq!(le32(&b, 108), DDSCAPS_TEXTURE | DDSCAPS_COMPLEX | DDSCAPS_MIPMAP); + assert_eq!(b.len(), 128 + 4 * (16 * 8 + 8 * 4 + 4 * 2 + 2 + 1)); + assert_eq!(decode(&b).unwrap().data(), img.data(), "the reader still reads the top level"); + let mut above = img.clone(); + for i in 1..5 { + let got = level(&b, i); + assert_eq!(got.dimensions(), ((16 >> i).max(1), (8 >> i).max(1))); + let want = box_filter(&above); + assert_eq!(got.data(), want.data(), "level {i}"); + above = got; + } + // 1×1 has a single level. + let one = encode(&ramp(1, 1, ChannelLayout::Rgba), Format::Dds, &opts(DdsCompression::None, true)).unwrap(); + assert_eq!((le32(&one, 28), le32(&one, 8) & DDSD_MIPMAPCOUNT), (1, 0)); +} + +/// The documented filter, written independently: each pixel averages the 2×2 pixels it covers, +/// and the last column and row of an odd-sized level take in the leftover pixel too. +fn box_filter(src: &Image) -> Image { + let (w, h) = (src.width() as usize, src.height() as usize); + let (nw, nh) = ((w / 2).max(1), (h / 2).max(1)); + let ch = src.layout().channels(); + let span = |i: usize, n: usize, size: usize| (2 * i, if i + 1 == n { size } else { 2 * i + 2 }); + let mut out = Vec::new(); + for y in 0..nh { + for x in 0..nw { + let ((x0, x1), (y0, y1)) = (span(x, nw, w), span(y, nh, h)); + for c in 0..ch { + let values: Vec = + (y0..y1).flat_map(|sy| (x0..x1).map(move |sx| (sy, sx))).map(|(sy, sx)| u32::from(src.data()[(sy * w + sx) * ch + c])).collect(); + let n = values.len() as u32; + out.push(((values.iter().sum::() + n / 2) / n) as u8); + } + } + } + Image::from_u8(nw as u32, nh as u32, src.layout(), out).unwrap() +} + +#[test] +fn odd_sized_mip_chains_keep_every_pixel() { + // 5×3 → 2×1 → 1×1. The 2×1 level's right pixel averages columns 2–4 of all three rows. + let img = ramp(5, 3, ChannelLayout::Gray); + let b = encode(&img, Format::Dds, &opts(DdsCompression::None, true)).unwrap(); + assert_eq!(le32(&b, 28), 3); + assert_eq!(b.len(), 128 + 5 * 3 + 2 + 1); + let second = level(&b, 1); + assert_eq!(second.data(), box_filter(&img).data()); + let px = |x: usize, y: usize| u32::from(img.data()[y * 5 + x]); + let right: u32 = (0..3).flat_map(|y| (2..5).map(move |x| (x, y))).map(|(x, y)| px(x, y)).sum(); + assert_eq!(u32::from(second.data()[1]), (right + 4) / 9); +} + +#[test] +fn block_compressed_mip_chains_have_a_block_per_small_level() { + for compression in BLOCKS { + let block = if compression == DdsCompression::Bc1 { 8 } else { 16 }; + let img = ramp(12, 6, ChannelLayout::Rgba); + let b = encode(&img, Format::Dds, &opts(compression, true)).unwrap(); + // 12×6, 6×3, 3×1, 1×1: 3×2, 2×1, 1 and 1 blocks. + assert_eq!(le32(&b, 28), 4); + assert_eq!(le32(&b, 8), REQUIRED | DDSD_LINEARSIZE | DDSD_MIPMAPCOUNT); + assert_eq!(le32(&b, 20), 6 * block); + assert_eq!(b.len(), 128 + (6 + 2 + 1 + 1) * block as usize); + for i in 0..4 { + let got = level(&b, i); + assert_eq!(got.dimensions(), ((12 >> i).max(1), (6 >> i).max(1)), "{compression:?} level {i}"); + } + } +} + +#[test] +fn strips_and_tiny_textures_encode_with_every_option() { + for (w, h) in [(1, 1), (1, 9), (9, 1), (2, 3), (3, 2)] { + for compression in DdsCompression::ALL { + for mipmaps in [false, true] { + let img = ramp(w, h, ChannelLayout::Rgba); + let b = encode(&img, Format::Dds, &opts(compression, mipmaps)).unwrap_or_else(|e| panic!("{w}x{h} {compression:?} {mipmaps}: {e}")); + assert_eq!(decode(&b).unwrap().dimensions(), (w, h)); + } + } + } +} + +#[test] +fn fidelity_warnings_follow_the_compression() { + let translucent = ramp(8, 8, ChannelLayout::Rgba); + let warns = |c| fidelity_warnings_with(&translucent, Format::Dds, &opts(c, false)); + assert!(warns(DdsCompression::None).is_empty(), "uncompressed RGBA loses nothing"); + for c in BLOCKS { + assert!(warns(c).contains(&FidelityWarning::LossyCompression), "{c:?}"); + } + assert!(warns(DdsCompression::Bc1).contains(&FidelityWarning::AlphaBinarized)); + assert!(!warns(DdsCompression::Bc3).contains(&FidelityWarning::AlphaBinarized)); +} + +#[test] +fn compression_names_round_trip() { + for c in DdsCompression::ALL { + assert_eq!(DdsCompression::from_name(c.name()), Some(c)); + } + assert_eq!(DdsCompression::from_name("DXT5"), Some(DdsCompression::Bc3)); + assert_eq!(DdsCompression::from_name("dxt1"), Some(DdsCompression::Bc1)); + for bad in ["", "bc7", "dxt2", "uncompressed "] { + assert_eq!(DdsCompression::from_name(bad), None, "{bad:?}"); + } +} + +/// Encode timings on a synthetic ~30 MP texture (6336×4752). Ignored by default; run in release: +/// +/// ```sh +/// cargo test --release -p photocraft-codecs --test dds_write -- --ignored --nocapture +/// ``` +#[test] +#[ignore] +fn dds_encode_timings_on_30_mp() { + let (w, h) = (6336u32, 4752u32); + let mut s = 0x1234_5678u32; + let mut data = Vec::with_capacity(w as usize * h as usize * 4); + for y in 0..h { + for x in 0..w { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + data.extend([(x / 25 + y / 19) as u8, (x / 31) as u8 ^ (s as u8 & 3), (y / 23) as u8, (255 - x / 25) as u8]); + } + } + let img = Image::from_u8(w, h, ChannelLayout::Rgba, data).unwrap(); + for compression in DdsCompression::ALL { + for mipmaps in [false, true] { + let t = std::time::Instant::now(); + let b = encode(&img, Format::Dds, &opts(compression, mipmaps)).unwrap(); + eprintln!("{compression:?} mipmaps={mipmaps}: {:.0} ms, {} MB", t.elapsed().as_secs_f64() * 1e3, b.len() >> 20); + } + } +} diff --git a/crates/codecs/tests/deep_exr.rs b/crates/codecs/tests/deep_exr.rs index 5a6d6ae75e..f80a42f17b 100644 --- a/crates/codecs/tests/deep_exr.rs +++ b/crates/codecs/tests/deep_exr.rs @@ -118,6 +118,23 @@ fn gen_deep( tile: Option<(usize, usize)>, counts: &[u32], sample: &dyn Fn(usize, usize) -> Vec, +) -> Vec { + gen_deep_tables(w, h, channels, compression, tile, counts, sample, false) +} + +/// [`gen_deep`]; with `full_tile_tables` every tile's count table is stored at the full tile +/// size, as the OpenEXR library writes edge tiles: the block's entries first (in rows of its +/// clipped width), then zeros. +#[allow(clippy::too_many_arguments)] +fn gen_deep_tables( + w: usize, + h: usize, + channels: &[Chan], + compression: u8, + tile: Option<(usize, usize)>, + counts: &[u32], + sample: &dyn Fn(usize, usize) -> Vec, + full_tile_tables: bool, ) -> Vec { let mut out = Vec::new(); out.extend_from_slice(&0x0131_2f76u32.to_le_bytes()); @@ -216,6 +233,10 @@ fn gen_deep( table.extend_from_slice(&acc.to_le_bytes()); } } + assert_eq!(table.len(), table_len(b)); + if let (true, Some((tw, th))) = (full_tile_tables, tile) { + table.resize(tw * th * 4, 0); + } let mut data = Vec::new(); for row in b.pixels.chunks(b.row_w) { for (ci, ch) in channels.iter().enumerate() { @@ -237,7 +258,6 @@ fn gen_deep( c.extend_from_slice(&(data.len() as u64).to_le_bytes()); c.extend_from_slice(&ct); c.extend_from_slice(&cd); - assert_eq!(table.len(), table_len(b)); assert_eq!(data.len(), data_len(b)); chunks.push((bi, c)); } @@ -608,6 +628,136 @@ fn hostile_sizes_and_offsets_error() { assert!(matches!(decode_deep_exr(&far, &Limits::none()), Err(CodecError::Malformed { .. }))); } +/// #4958: a deep tile's count table may be stored at the full tile size, and that size comes +/// from the header alone: nothing ties it to the data window. One 1024×1024 tile over a 4×4 +/// image, its table stored at that size, makes a compressed file of tens of KB whose table +/// inflates to 4 MiB, plus as much again for the copy that undoes the byte split. Under a +/// budget smaller than both the table must be refused before it is allocated; with room for +/// both it decodes. Tables stored at the clipped size, and the full-size edge tables of +/// ordinary tiles, still decode under a small budget. +#[test] +fn deep_tile_count_table_is_admitted_against_max_alloc() { + let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() }; + let tight = budget(1 << 20); + let table: u64 = 1024 * 1024 * 4; // the full tile's count table, in bytes + let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines"); + for compression in [0u8, 1, 2] { + let big = gen_deep_tables(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba, true); + assert_eq!(decode_deep_exr(&big, &Limits::none()).expect("full-size table, no limits"), scan, "compression {compression}"); + let got = decode_deep_exr(&big, &tight); + assert!( + matches!(got, Err(CodecError::LimitExceeded(_))), + "compression {compression}: a 4 MiB count table under a 1 MiB budget gave {:?}", + got.map(|d| d.total_samples()) + ); + let flat = photocraft_codecs::decode_with(&big, &photocraft_codecs::DecodeOptions { limits: tight, ..Default::default() }); + assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "compression {compression}: the flat decode gave {:?}", flat.map(|i| i.width())); + // The same tile with its table stored at the clipped size fits the small budget. + let clipped = gen_deep(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba); + assert_eq!(decode_deep_exr(&clipped, &tight).expect("clipped table, 1 MiB budget"), scan, "compression {compression}"); + if compression != 0 { + assert!(big.len() < 1 << 20, "compression {compression}: the file is {} bytes", big.len()); + } + // The table and its scratch copy are admitted together: 8 MiB. + assert_eq!(decode_deep_exr(&big, &budget(2 * table)).expect("room for the table and its copy"), scan, "compression {compression}"); + let got = decode_deep_exr(&big, &budget(2 * table - 1)); + assert!(matches!(got, Err(CodecError::LimitExceeded(_))), "compression {compression}: one byte short gave {:?}", got.map(|d| d.total_samples())); + } + // Ordinary tiles: full-size edge tables decode under the small budget, like clipped ones. + let counts: Vec = (0..35).map(|p| (p * 3) % 4).collect(); + let sample = |p: usize, ci: usize| -> Vec { (0..counts[p]).map(|k| [0.5, 0.25, 0.125 * k as f32, 1.0, 10.0 - k as f32][ci]).collect() }; + for compression in [0u8, 1, 2] { + let full = gen_deep_tables(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample, true); + let clipped = gen_deep(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample); + assert_eq!( + decode_deep_exr(&full, &tight).expect("full-size edge tables"), + decode_deep_exr(&clipped, &tight).expect("clipped edge tables"), + "compression {compression}" + ); + } +} + +/// #4958, the edges of that admission. A tile of the largest size exr accepts (2^30 - 2 a side, +/// a 2^62 byte count table) decodes from a clipped table under every budget, including no limits, +/// where its full size passes the check and is only tried as the data arrives. Each tile is +/// admitted on its own, because its table is freed before the next chunk: three 16×16 tiles over +/// a 48×1 image are all edge tiles, so every full-size table reaches the check (an interior tile's +/// full size equals its clipped size, so it uses the clipped check). They need 2 KiB each for +/// the table and its copy, 6 KiB in all, and decode under a budget of 4096 bytes. Half and uint +/// channels decode after full-size tables like float ones. +#[test] +fn deep_tile_table_admission_edges() { + let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() }; + let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines"); + let side = (1 << 30) - 2; + for compression in [0u8, 1, 2] { + let bytes = gen_deep(4, 4, &RGBAZ, compression, Some((side, side)), COUNTS_ONE, &sample_rgba); + for limits in [Limits::none(), Limits::default(), budget(1 << 20)] { + assert_eq!(decode_deep_exr(&bytes, &limits).expect("the largest tile, clipped table"), scan, "compression {compression}, {limits:?}"); + } + } + let counts: Vec = (0..48).map(|p| (p % 3) as u32).collect(); + let half: [Chan; 5] = RGBAZ.map(|c| Chan { ty: 1, ..c }); + let [a, b, g, r, z] = RGBAZ; + let with_id = [a, b, g, r, z, Chan { name: "id", ty: 0 }]; + for channels in [&RGBAZ[..], &half[..], &with_id[..]] { + let sample = |p: usize, ci: usize| -> Vec { (0..counts[p]).map(|k| [0.5, 0.25, 0.125, 1.0, 2.0 + k as f32, 7.0][ci]).collect() }; + for compression in [0u8, 1, 2] { + let full = gen_deep_tables(48, 1, channels, compression, Some((16, 16)), &counts, &sample, true); + let clipped = gen_deep(48, 1, channels, compression, Some((16, 16)), &counts, &sample); + let what = format!("{} channels, compression {compression}", channels.len()); + let want = decode_deep_exr(&clipped, &Limits::none()).expect("clipped tables"); + assert_eq!(decode_deep_exr(&full, &budget(4096)).expect("full-size tables, admitted one at a time"), want, "{what}"); + } + } +} + +/// A one-channel data-window check admits only four bytes per pixel, but a compressed count +/// table and its scratch copy need eight. Cover scanlines, interior tiles and the clipped fallback; +/// the existing multichannel cases have enough pixel allowance to hide this gap. +#[test] +fn clipped_count_tables_include_scratch_in_the_budget() { + let channels = [Chan { name: "Y", ty: 2 }]; + let counts = vec![0u32; 64]; + let sample = |_: usize, _: usize| Vec::new(); + let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() }; + for tile in [None, Some((64, 1)), Some((128, 2))] { + for compression in [0u8, 1, 2] { + let bytes = gen_deep(64, 1, &channels, compression, tile, &counts, &sample); + let what = format!("tile {tile:?}, compression {compression}"); + let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode"); + let required = if compression == 0 { 256 } else { 512 }; + assert_eq!(decode_deep_exr(&bytes, &budget(required)).expect("table and any scratch fit exactly"), want, "{what}"); + assert!(matches!(decode_deep_exr(&bytes, &budget(required - 1)), Err(CodecError::LimitExceeded(_))), "{what}"); + let flat = photocraft_codecs::decode_with(&bytes, &photocraft_codecs::DecodeOptions { limits: budget(required - 1), ..Default::default() }); + assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "{what}"); + if compression == 0 { + assert_eq!(decode_deep_exr(&bytes, &budget(511)).expect("raw table needs no scratch"), want, "{what}"); + } + } + } +} + +/// RLE and ZIPS store incompressible tables raw too. Their actual payload, not the header's +/// compression tag, decides whether a scratch buffer is needed. +#[test] +fn raw_count_tables_need_no_scratch_even_with_compression() { + let channels = [Chan { name: "Y", ty: 2 }]; + let limits = Limits { max_alloc: 4, ..Limits::none() }; + for tile in [None, Some((1, 1)), Some((2, 2))] { + for compression in [0u8, 1, 2] { + let bytes = gen_deep(1, 1, &channels, compression, tile, &[0], &|_, _| Vec::new()); + let header_end = header_end_of(&bytes); + let chunk = u64::from_le_bytes(bytes[header_end..header_end + 8].try_into().expect("chunk offset")) as usize; + let table_size = chunk + if tile.is_some() { 16 } else { 4 }; + let packed = u64::from_le_bytes(bytes[table_size..table_size + 8].try_into().expect("table size")); + assert_eq!(packed, 4, "the four-byte count table is stored raw"); + let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode"); + assert_eq!(decode_deep_exr(&bytes, &limits).expect("raw table fits"), want, "tile {tile:?}, compression {compression}"); + } + } +} + /// Walk the attributes like the format prescribes: the offset table starts after the /// header's terminating zero byte. fn header_end_of(bytes: &[u8]) -> usize { diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/detect_caps.rs index 22bf227eb8..f2191e05f1 100644 --- a/crates/codecs/tests/detect_caps.rs +++ b/crates/codecs/tests/detect_caps.rs @@ -187,6 +187,17 @@ fn detect_magic_hdr() { assert_eq!(detect(b"#?RGBE\n"), Some(Format::Hdr)); } #[test] +fn detect_magic_cineon() { + let mut b = vec![0u8; 208]; + b[0..4].copy_from_slice(&[0x80, 0x2A, 0x5F, 0xD7]); + assert_eq!(detect(&b), Some(Format::Cineon)); + assert_eq!(from_extension("scan.CIN"), Some(Format::Cineon)); + assert!(caps(Format::Cineon).read && !caps(Format::Cineon).write, "Cineon is read-only"); + assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Cineon)); + // A bare magic is an error, never a panic. + assert!(decode_as(Format::Cineon, &b).is_err()); +} +#[test] fn detect_magic_avif() { assert_eq!(detect(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"), Some(Format::Avif)); assert_eq!(detect(b"\0\0\0\x1cftypavis\0\0\0\0avismsf1miaf"), Some(Format::Avif)); diff --git a/crates/codecs/tests/exr_multipart.rs b/crates/codecs/tests/exr_multipart.rs index f106d37791..421d494710 100644 --- a/crates/codecs/tests/exr_multipart.rs +++ b/crates/codecs/tests/exr_multipart.rs @@ -8,7 +8,9 @@ use exr::prelude::{ WritableImage, }; use photocraft_codecs::f16; -use photocraft_codecs::{ChannelLayout, DecodeOptions, DecodeWarning, Format, Limits, SampleType, decode, decode_as_with, decode_exr_part, exr_info}; +use photocraft_codecs::{ + ChannelLayout, DecodeOptions, DecodeWarning, Format, Limits, SampleType, decode, decode_as_with, decode_exr_part, exr_info, probe_size, +}; fn layer_f32(name: &str, comp: Compression, chans: &[(&str, Vec)]) -> Layer> { let n = chans.iter().find(|(_, v)| !v.is_empty()).map_or(0, |(_, v)| v.len()); @@ -226,3 +228,107 @@ fn decoding_one_part_does_not_decode_the_others() { assert_eq!((img.width(), img.height()), (4, 4)); assert!(img.to_normalized().iter().all(|v| near(*v, 0.75)), "wrong pixels"); } + +fn channel_group_file(channels: &[(&str, f32)]) -> Vec { + let channels: Vec<_> = channels.iter().map(|(name, value)| (*name, vec![*value; 4])).collect(); + write_image(vec![layer_f32("groups", Compression::ZIP1, &channels)]) +} + +fn assert_channel_group(bytes: &[u8], layout: ChannelLayout, expected: &[f32]) { + assert_eq!(exr_info(bytes, &Limits::default()).unwrap()[0].color_rank(), expected.len() as u8); + assert_eq!(probe_size(bytes).unwrap(), (2, 2)); + for img in [decode(bytes).unwrap(), decode_exr_part(bytes, 0, &DecodeOptions::default()).unwrap()] { + assert_eq!(img.layout(), layout); + assert_eq!(img.to_normalized(), expected.repeat(4)); + } +} + +#[test] +fn flat_decode_keeps_rgb_channels_in_one_group() { + // A partial group sorts before the nested beauty layer, but cannot donate its R. + let bytes = channel_group_file(&[("aaa.R", 9.0), ("beauty.diffuse.R", 0.25), ("beauty.diffuse.G", 0.5), ("beauty.diffuse.B", 2.0)]); + assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]); +} + +#[test] +fn flat_decode_does_not_borrow_another_groups_alpha() { + for alpha in ["A", "aaa.A", "beauty.other.A"] { + let bytes = channel_group_file(&[(alpha, 0.125), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0)]); + assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]); + let bytes = channel_group_file(&[(alpha, 0.125), ("beauty.Y", 0.5)]); + assert_channel_group(&bytes, ChannelLayout::Gray, &[0.5]); + } +} + +#[test] +fn flat_decode_rejects_rgb_split_between_groups() { + for channels in [ + [("aaa.R", 0.25), ("bbb.G", 0.5), ("ccc.B", 2.0)], + [("beauty.R", 0.25), ("beauty.diffuse.G", 0.5), ("beauty.diffuse.B", 2.0)], + [(".R", 0.25), ("G", 0.5), ("B", 2.0)], + ] { + let bytes = channel_group_file(&channels); + assert_eq!(exr_info(&bytes, &Limits::default()).unwrap()[0].color_rank(), 0); + assert!(probe_size(&bytes).is_err()); + assert!(decode(&bytes).is_err()); + assert!(decode_exr_part(&bytes, 0, &DecodeOptions::default()).is_err()); + } +} + +#[test] +fn auto_pick_and_probe_skip_incoherent_rgb_parts() { + let bytes = write_image(vec![ + layer_f32("split", Compression::ZIP1, &[("aaa.R", vec![9.0; 4]), ("bbb.G", vec![8.0; 4]), ("ccc.B", vec![7.0; 4])]), + layer_f32("luminance", Compression::ZIP1, &[("Y", vec![0.5; 16]), ("A", vec![0.75; 16])]), + ]); + let parts = exr_info(&bytes, &Limits::default()).unwrap(); + assert_eq!(parts.iter().map(|part| part.color_rank()).collect::>(), [0, 2]); + assert_eq!(probe_size(&bytes).unwrap(), (4, 4)); + let img = decode(&bytes).unwrap(); + assert_eq!((img.width(), img.height(), img.layout()), (4, 4, ChannelLayout::GrayA)); + assert_eq!(img.to_f32_samples().unwrap(), [0.5, 0.75].repeat(16)); +} + +#[test] +fn flat_decode_prefers_the_highest_ranked_complete_group() { + let bytes = + channel_group_file(&[("aaa.R", 9.0), ("aaa.G", 8.0), ("aaa.B", 7.0), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0), ("beauty.A", 0.75)]); + assert_channel_group(&bytes, ChannelLayout::Rgba, &[0.25, 0.5, 2.0, 0.75]); + let bytes = channel_group_file(&[("aaa.Y", 9.0), ("beauty.Y", 0.5), ("beauty.A", 0.75)]); + assert_channel_group(&bytes, ChannelLayout::GrayA, &[0.5, 0.75]); +} + +#[test] +fn flat_decode_breaks_group_ties_by_prefix() { + let bytes = channel_group_file(&[("z.R", 9.0), ("z.G", 8.0), ("z.B", 7.0), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0)]); + assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]); + // Even a prefix that sorts before the unprefixed channel names loses a rank tie. + let bytes = channel_group_file(&[("0.R", 9.0), ("0.G", 8.0), ("0.B", 7.0), ("R", 0.25), ("G", 0.5), ("B", 2.0)]); + assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]); +} + +#[test] +fn flat_decode_preserves_luminance_and_lone_channel_fallbacks() { + for prefix in ["", "beauty."] { + let y = format!("{prefix}Y"); + let a = format!("{prefix}A"); + assert_channel_group(&channel_group_file(&[(&y, 2.0), (&a, 0.75)]), ChannelLayout::GrayA, &[2.0, 0.75]); + assert_channel_group(&channel_group_file(&[(&y, 2.0)]), ChannelLayout::Gray, &[2.0]); + } + for name in ["depth.Z", "A", "beauty.A"] { + // A lone alpha is one grayscale channel, not the same samples duplicated as YA. + assert_channel_group(&channel_group_file(&[(name, 9.0)]), ChannelLayout::Gray, &[9.0]); + } +} + +#[test] +fn half_float_group_does_not_use_unrelated_float_channels() { + let mut layer = layer_f32("mixed", Compression::ZIP1, &[("aaa.R", vec![9.0; 4]), ("aaa.A", vec![0.125; 4])]); + for (name, value) in [("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0), ("beauty.A", 0.75)] { + layer.channel_data.list.push(AnyChannel::new(name, FlatSamples::F16(vec![f16::from_f32(value); 4]))); + } + layer.channel_data = AnyChannels::sort(layer.channel_data.list); + let bytes = write_image(vec![layer]); + assert_channel_group(&bytes, ChannelLayout::Rgba, &[0.25, 0.5, 2.0, 0.75]); + assert_eq!(decode(&bytes).unwrap().sample_type(), SampleType::F16); +} diff --git a/crates/codecs/tests/fidelity.rs b/crates/codecs/tests/fidelity.rs index 32d5e21bf0..93b0236fdf 100644 --- a/crates/codecs/tests/fidelity.rs +++ b/crates/codecs/tests/fidelity.rs @@ -21,7 +21,13 @@ fn png_u16_rgba_is_clean() { fn tiff_is_clean_for_all_integer_and_f32_layouts() { for l in ChannelLayout::ALL { for s in [SampleType::U8, SampleType::U16, SampleType::F32, SampleType::F16] { - assert!(warns(&test_image(l, s), Format::Tiff).is_empty(), "{l:?} {s:?}"); + let w = warns(&test_image(l, s), Format::Tiff); + if l.is_lab() && s.is_float() { + // CIELab has no float form: it is written with 16 bits, and says so. + assert_eq!(w, vec![FidelityWarning::DepthReduced { from: s, to: SampleType::U16 }], "{l:?} {s:?}"); + } else { + assert!(w.is_empty(), "{l:?} {s:?}"); + } } } } @@ -115,6 +121,12 @@ fn in_range_float_not_clipped() { assert!(!w.contains(&W::RangeClipped)); } #[test] +fn hdr_float_to_fits_clips() { + let img = Image::from_f32(2, 1, ChannelLayout::Gray, &[0.5, 3.0]).unwrap(); + assert!(warns(&img, Format::Fits).contains(&W::RangeClipped)); + assert!(!warns(&test_image(ChannelLayout::Gray, SampleType::F32), Format::Fits).contains(&W::RangeClipped)); +} +#[test] fn float_to_exr_and_tiff_is_clean() { for f in [Format::OpenExr, Format::Tiff, Format::Pnm] { assert!(warns(&test_image(ChannelLayout::Rgb, SampleType::F32), f).is_empty(), "{f:?}"); @@ -172,6 +184,7 @@ fn every_warning_has_a_message() { W::AlphaDiscarded, W::AlphaBinarized, W::CmykConverted { to: ChannelLayout::Rgb }, + W::LabConverted { to: ChannelLayout::Rgb }, W::ColorToGray, W::PaletteQuantized, W::LossyCompression, @@ -203,6 +216,8 @@ fn warnings_match_actual_roundtrip_behaviour() { assert_eq!(w.contains(&W::AlphaDiscarded), alpha_lost, "{f:?} {l:?} {s:?}"); let cmyk_lost = l.is_cmyk() && !back.layout().is_cmyk(); assert_eq!(w.iter().any(|w| matches!(w, W::CmykConverted { .. })), cmyk_lost, "{f:?} {l:?} {s:?}"); + let lab_lost = l.is_lab() && !back.layout().is_lab(); + assert_eq!(w.iter().any(|w| matches!(w, W::LabConverted { .. })), lab_lost, "{f:?} {l:?} {s:?}"); } } } diff --git a/crates/codecs/tests/orientation.rs b/crates/codecs/tests/orientation.rs index 9808330790..c1f176b87c 100644 --- a/crates/codecs/tests/orientation.rs +++ b/crates/codecs/tests/orientation.rs @@ -236,6 +236,7 @@ fn webp_opens_upright() { // WebP has no gray mode: it comes back as RGB. let img = decode(&out).unwrap().convert(ChannelLayout::Gray, SampleType::U8); assert_eq!((img.dimensions(), img.data()), ((w as u32, h as u32), &up[..]), "o={o}"); + assert_eq!(probe_size(&out).unwrap(), (w as u32, h as u32), "the header probe turns upright too, o={o}"); } } @@ -554,3 +555,24 @@ fn long_orientation_reads_and_rewrites_in_both_byte_orders() { assert_eq!(kept.strip_prefix(b"Exif\0\0").unwrap_or(kept), &ifd(big, &[long(1)], &[])[..]); } } + +/// `probe_size` reads the size `decode` returns from the header alone, for every format it +/// covers, and a file cut short inside its header is refused rather than given a size. +#[test] +fn probe_size_matches_decode() { + let img = synth(7, 5, ChannelLayout::Rgb, SampleType::U8, 3, 0.0); + let probed = [Format::WebP, Format::Gif, Format::Bmp, Format::Tga, Format::Qoi, Format::Hdr, Format::Ico, Format::OpenExr]; + assert!(probed.iter().all(|f| rw_formats().contains(f))); + for format in rw_formats() { + let bytes = encode(&img, format, &EncodeOptions::default()).unwrap(); + let decoded = decode(&bytes).unwrap().dimensions(); + match probe_size(&bytes) { + Ok(size) => assert_eq!(size, decoded, "{format:?}"), + Err(_) => assert!(!probed.contains(&format), "{format:?} should be probed"), + } + if probed.contains(&format) { + assert!(probe_size(&bytes[..10]).is_err(), "{format:?} cut inside its header"); + } + } + assert!(probe_size(&[]).is_err()); +} 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); + } + } +} diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index 4cb50c0057..9c6cb8cb6f 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -137,6 +137,9 @@ mod fits { mod dpx { cases!(Format::Dpx); } +mod dds { + cases!(Format::Dds); +} mod qoi { cases!(Format::Qoi); } @@ -196,6 +199,14 @@ fn tiff_all_compressions_lossless() { for comp in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] { let opts = EncodeOptions { tiff_compression: comp, ..Default::default() }; let back = decode(&encode(&img, Format::Tiff, &opts).unwrap()).unwrap(); + if layout.is_lab() && sample != SampleType::U8 { + // CIELab holds a* and b* as 256·a at 16 bits and has no float form: 16-bit and + // float Lab come back within one 16-bit step (the PSD's 16-bit Lab does the same). + let (a, b) = (img.to_normalized(), back.convert(layout, sample).to_normalized()); + let worst = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max); + assert!(worst <= 1.5 / 65535.0, "{layout:?} {sample:?} {comp:?}: {worst}"); + continue; + } assert_eq!(back, img.clone().with_meta(back.meta.clone()), "{layout:?} {sample:?} {comp:?}"); } } 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}"); + } + } +} diff --git a/crates/codecs/tests/tiff_exif.rs b/crates/codecs/tests/tiff_exif.rs index 92e8c91df0..7e06a0e5b8 100644 --- a/crates/codecs/tests/tiff_exif.rs +++ b/crates/codecs/tests/tiff_exif.rs @@ -166,3 +166,97 @@ fn no_exif_is_written_without_embed_metadata() { let tiff = encode(&img, Format::Tiff, &EncodeOptions { embed_metadata: false, ..Default::default() }).unwrap(); assert_eq!(decode(&tiff).unwrap().meta.exif, None); } + +// --------------------------------------------------------------------------- +// Bounded copying: an entry's value only has to lie inside the file, so a hostile file can have +// many entries point at the same bytes. +// --------------------------------------------------------------------------- + +/// A valid 1x1 gray TIFF whose Exif directory has `entries` UNDEFINED entries of `count` bytes +/// that all point at the same `count` bytes: the file is about `count` bytes long and asks to +/// copy `entries * count`. +fn overlapping_tiff(entries: usize, count: u32) -> Vec { + let entry = |tag: u16, ty: u16, count: u32, value: u32| { + [tag.to_le_bytes().to_vec(), ty.to_le_bytes().to_vec(), count.to_le_bytes().to_vec(), value.to_le_bytes().to_vec()].concat() + }; + let at_exif = 8 + 2 + 12 * 10 + 4; + let data_at = at_exif + 2 + 12 * entries + 4; + let mut out = b"II".to_vec(); + out.extend(42u16.to_le_bytes()); + out.extend(8u32.to_le_bytes()); + out.extend(10u16.to_le_bytes()); + for (tag, ty, value) in + [(256, 3, 1), (257, 3, 1), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 0), (277, 3, 1), (278, 3, 1), (279, 4, 1), (34665, 4, at_exif as u32)] + { + out.extend(entry(tag, ty, 1, value)); + } + out.extend(0u32.to_le_bytes()); + out.extend((entries as u16).to_le_bytes()); + for i in 0..entries { + out.extend(entry(i as u16 + 1, 7, count, data_at as u32)); + } + out.extend(0u32.to_le_bytes()); + out.extend(vec![0x5A; count as usize]); + out +} + +#[test] +fn overlapping_field_values_are_bounded() { + // About 62 KB on disk; copied entry by entry it would be 1,000 x 50,000 bytes. + let tiff = overlapping_tiff(1000, 50_000); + assert!(tiff.len() < 64 * 1024); + let img = decode(&tiff).expect("the image itself is fine"); + assert_eq!(img.dimensions(), (1, 1)); + let exif = img.meta.exif.expect("the first entries are kept"); + assert!(exif.len() <= 2 * tiff.len(), "{} bytes of EXIF from a {} byte file", exif.len(), tiff.len()); +} + +#[test] +fn overlapping_field_values_in_an_exif_block_are_bounded_on_export() { + // The same block as an image's EXIF: the writer reads it with the same code. + let block = overlapping_tiff(1000, 50_000); + let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap(); + img.meta.exif = Some(block.clone()); + let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + assert!(tiff.len() <= 2 * block.len(), "a {} byte block wrote a {} byte file", block.len(), tiff.len()); +} + +/// An EXIF block with a maker note of `len` bytes, an exposure time and a GPS latitude reference. +fn block_with_maker_note(le: bool, len: usize) -> Vec { + let b = Block { le }; + b.build( + vec![(271, 2, 4, b"Cam\0".to_vec())], + vec![(33434, 5, 1, b.rational(1, 125)), (37500, 7, len as u32, (0..len).map(|i| (i * 7) as u8).collect())], + vec![(1, 2, 2, b"N\0".to_vec())], + ) +} + +#[test] +fn large_maker_notes_still_survive() { + // Nikon maker notes run from 100 KB to nearly 1 MB. + for len in [200 * 1024, 1000 * 1024] { + let block = block_with_maker_note(true, len); + let mut img = Image::from_raw(4, 3, ChannelLayout::Rgb, SampleType::U8, vec![90; 36]).unwrap(); + img.meta.exif = Some(block.clone()); + let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + let back = decode(&tiff).unwrap().meta.exif.expect("EXIF read back"); + let (ble, _, bexif, bgps) = dirs(&back); + let (ole, _, oexif, ogps) = dirs(&block); + assert_eq!(normalized(&bexif, ble), normalized(&oexif, ole), "{len} byte maker note"); + assert_eq!(normalized(&bgps, ble), normalized(&ogps, ole)); + } +} + +#[test] +fn a_field_beyond_the_cap_is_dropped_and_the_rest_kept() { + let block = block_with_maker_note(false, 6 * 1024 * 1024); + let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap(); + img.meta.exif = Some(block); + let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + assert!(tiff.len() < 1024 * 1024, "{} bytes", tiff.len()); + let back = decode(&tiff).unwrap().meta.exif.expect("EXIF read back"); + let (_, ifd0, exif, gps) = dirs(&back); + assert_eq!(exif.iter().map(|e| e.0).collect::>(), [33434], "the exposure time stays, the 6 MiB maker note goes"); + assert_eq!(gps.iter().map(|e| e.0).collect::>(), [1]); + assert_eq!(ifd0.iter().map(|e| e.0).collect::>(), [271]); +} diff --git a/crates/codecs/tests/tiff_lab.rs b/crates/codecs/tests/tiff_lab.rs new file mode 100644 index 0000000000..7076633ef5 --- /dev/null +++ b/crates/codecs/tests/tiff_lab.rs @@ -0,0 +1,76 @@ +//! CIE L*a*b* TIFFs: CIELab (PhotometricInterpretation 8) as Photoshop writes it, with a* and b* +//! signed, and ICCLab (9) with them offset, both read into the Lab layout (a* and b* as +//! `(a + 128) / 255`). + +use photocraft_codecs::*; + +/// One Lab pixel in the codec's normalized form. +fn lab(l: f32, a: f32, b: f32) -> [f32; 3] { + [l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0] +} + +fn image(sample: SampleType) -> Image { + let px: Vec = [lab(50.0, 50.0, -30.0), lab(100.0, 0.0, 0.0)].concat(); + Image::from_normalized(2, 1, ChannelLayout::Lab, sample, &px).unwrap() +} + +fn uncompressed(img: &Image) -> Vec { + encode(img, Format::Tiff, &EncodeOptions { tiff_compression: TiffCompression::None, ..Default::default() }).unwrap() +} + +fn find(hay: &[u8], needle: &[u8]) -> Option { + hay.windows(needle.len()).position(|w| w == needle) +} + +#[test] +fn eight_bit_cielab_holds_a_and_b_signed() { + let file = uncompressed(&image(SampleType::U8)); + // L* 50 → 128, a* 50, b* −30 as a signed byte; then white with neutral a* and b*. + assert!(find(&file, &[128, 50, (-30i8) as u8, 255, 0, 0]).is_some(), "CIELab samples"); + let back = decode(&file).unwrap(); + assert_eq!((back.layout(), back.sample_type()), (ChannelLayout::Lab, SampleType::U8)); + assert_eq!(back.data(), image(SampleType::U8).data()); +} + +#[test] +fn sixteen_bit_cielab_holds_256_a() { + let file = uncompressed(&image(SampleType::U16)); + let le = &file[..2] == b"II"; + let u16b = |v: u16| if le { v.to_le_bytes() } else { v.to_be_bytes() }; + // a* 50 → 12800, b* −30 → −7680, white's a* and b* 0. + let want: Vec = [32768u16, 12800, (-7680i16) as u16, 65535, 0, 0].iter().flat_map(|v| u16b(*v)).collect(); + assert!(find(&file, &want).is_some(), "CIELab samples"); + let back = decode(&file).unwrap(); + assert_eq!((back.layout(), back.sample_type()), (ChannelLayout::Lab, SampleType::U16)); + let (a, b) = (image(SampleType::U16).to_normalized(), back.to_normalized()); + assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= 1.5 / 65535.0), "{a:?} {b:?}"); +} + +#[test] +fn icclab_reads_with_offset_a_and_b() { + // The CIELab file turned into ICCLab: PhotometricInterpretation 9, a* and b* + 128. + let mut file = uncompressed(&image(SampleType::U8)); + let at = find(&file, &[128, 50, (-30i8) as u8, 255, 0, 0]).unwrap(); + for i in [1, 2, 4, 5] { + file[at + i] ^= 0x80; + } + let le = &file[..2] == b"II"; + let entry: Vec = if le { [262u16.to_le_bytes(), 3u16.to_le_bytes()].concat() } else { [262u16.to_be_bytes(), 3u16.to_be_bytes()].concat() }; + let e = find(&file, &entry).expect("PhotometricInterpretation entry"); + let nine = if le { 9u16.to_le_bytes() } else { 9u16.to_be_bytes() }; + file[e + 8..e + 10].copy_from_slice(&nine); + let back = decode(&file).unwrap(); + assert_eq!(back.layout(), ChannelLayout::Lab); + assert_eq!(back.data(), image(SampleType::U8).data()); +} + +#[test] +fn lab_converts_to_rgb_for_other_formats() { + // White and a saturated orange-red; PNG has no Lab, so the encoder plan converts to RGB. + let img = image(SampleType::U8); + let png = decode(&encode(&img, Format::Png, &EncodeOptions::default()).unwrap()).unwrap(); + assert!(png.layout().is_rgb()); + let p = png.to_rgba8(); + assert_eq!(&p[4..7], &[255, 255, 255], "white"); + assert!(p[0] > 180 && p[1] < 120 && p[2] > 140, "L 50, a 50, b −30 is a magenta-ish purple: {:?}", &p[..3]); +} diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index 22a8cf063a..f91e7779d1 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -79,37 +79,29 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) { pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option) { match adj { Adjustment::Invert => map_rgb(buf, |c| [1.0 - c[0], 1.0 - c[1], 1.0 - c[2]]), - Adjustment::Threshold { level } => { - // Compared on the 8-bit grid like Photoshop (avoids float ties). - let t = (level * 255.0).round(); - map_rgb(buf, |c| { - let v = if (rgb_to_gray(c) * 255.0).round() >= t { 1.0 } else { 0.0 }; - [v; 3] - }) - } - Adjustment::Posterize { levels } => map_rgb(buf, |c| c.map(|v| posterize(v, *levels))), + Adjustment::Threshold { level } => map_rgb(buf, |c| [if threshold_white(c, *level, depth) { 1.0 } else { 0.0 }; 3]), + Adjustment::Posterize { levels } => map_rgb(buf, |c| c.map(|v| posterize_in(v, *levels, depth))), Adjustment::BrightnessContrast { brightness, contrast, legacy: true } => { // Legacy: contrast scales around 128/255, with brightness added before it when // contrast is raised and after it when contrast is lowered. Measured on Photoshop // 27.11, brightness +40 with contrast +30 maps 128 to 185 (brightness first), while // the corpus's brightness -23 with contrast -26 (psd-tools) needs contrast first; // contrast 80 keeps 128 at 128 (a 0.5 pivot moved it to 130). - let f = legacy_brightness_contrast(*brightness, *contrast); - map_rgb(buf, |px| px.map(&f)) + if depth == Some(SampleType::F32) { + let f = legacy_brightness_contrast_hdr(*brightness, *contrast); + map_rgb(buf, |px| px.map(&f)); + } else { + let f = legacy_brightness_contrast(*brightness, *contrast); + map_rgb(buf, |px| px.map(&f)) + } } Adjustment::BrightnessContrast { brightness, contrast, .. } => { - // Modern (CS3+) Brightness/Contrast, reverse-engineered from Photoshop ground truth - // (a 0..255 ramp pushed through the real app; see log/devlog.md). Unlike the legacy - // linear scale, both are smooth curves that pin pure black and white: - // • Brightness: a line of slope s = 1.375^(b/50) from the origin that rolls off to - // (1,1) via a `v^P` white-anchor term (P grows as |b| grows). - // • Contrast: a symmetric cubic-Hermite S-curve pivoting at 0.5, with endpoint slope - // 1 - c/128 and centre slope 1 + c/128 (near-exact: ≤0.5/255 vs Photoshop). - // Applied brightness-then-contrast. Exact at the sliders' zero and extremes still drift - // on the brightness side (the real curve is a spline); contrast matches closely. - let b = *brightness; - let c = *contrast; - map_rgb(buf, |px| px.map(|v| modern_contrast(modern_brightness(v, b), c).clamp(0.0, 1.0))) + // Modern (CS3+) Brightness/Contrast, reverse-engineered from Photoshop ground truth. + // Unlike the legacy linear scale, both are smooth curves that pin pure black and white + // (see `modern_brightness` and `modern_contrast`), applied brightness then contrast + // through one lookup table. + let table = modern_lut(*brightness, *contrast); + map_rgb(buf, |px| px.map(|v| lut(&table, v))) } Adjustment::Exposure { exposure, offset, gamma } => { // In linear light: (lin·2^exposure + offset)^(1/gamma), then back @@ -175,6 +167,17 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth let hi = if depth == Some(SampleType::F32) { f32::INFINITY } else { 1.0 }; map_rgb(buf, |c| vibrance_px(c, v, s, transfer, hi)) } + Adjustment::ChannelMixer { matrix, monochrome } if depth == Some(SampleType::F32) => { + // 32-bit: each source percentage is cut to 1/1024 (toward zero), the constant counts + // 255/512 of its value, and nothing clips (photoshop corpus rgb32 channel-mixer.psd: + // pure red, green, blue, black and white all exact). + let cut = |v: f32| (v * 1024.0).trunc() / 1024.0; + let m = matrix.map(|row| [cut(row[0]), cut(row[1]), cut(row[2]), row[3] * 255.0 / 512.0]); + map_rgb(buf, |c| { + let mix = |row: &[f32; 4]| row[0] * c[0] + row[1] * c[1] + row[2] * c[2] + row[3]; + if *monochrome { [mix(&m[0]); 3] } else { [mix(&m[0]), mix(&m[1]), mix(&m[2])] } + }) + } Adjustment::ChannelMixer { matrix, monochrome } => map_rgb(buf, |c| { let mix = |row: &[f32; 4]| (row[0] * c[0] + row[1] * c[1] + row[2] * c[2] + row[3]).clamp(0.0, 1.0); if *monochrome { [mix(&matrix[0]); 3] } else { [mix(&matrix[0]), mix(&matrix[1]), mix(&matrix[2])] } @@ -184,27 +187,39 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth let m = photo_filter_matrix(*color, *density, *preserve_luminosity && linear_doc); let set_lum = *preserve_luminosity && !linear_doc; map_rgb(buf, |c| { - let f = mat3_mul(&m, c.map(|v| transfer.decode(v))).map(|v| transfer.encode(v)); + // 32-bit samples are linear already, and may go negative. + let f = if linear_doc { mat3_mul(&m, c) } else { mat3_mul(&m, c.map(|v| transfer.decode(v))).map(|v| transfer.encode(v)) }; let f = if set_lum { photocraft_color::blend::set_lum(f, photocraft_color::blend::lum(c)) } else { f }; - f.map(|v| v.clamp(0.0, 1.0)) + // 32-bit results aren't clipped (photoshop corpus rgb32 photo-filter.psd: white + // filters to 1.19 red). + if linear_doc { f } else { f.map(|v| v.clamp(0.0, 1.0)) } + }) + } + Adjustment::BlackWhite { weights, tint } => { + let hdr = depth == Some(SampleType::F32); + map_rgb(buf, |c| { + let g = black_white_gray(c, weights); + match tint { + // Photoshop tints like the Color blend mode: the tint's hue and saturation at + // the grey's luminosity (SetLum + ClipColor), measured in Photoshop 27.11. In + // 32-bit documents nothing clips at 1, as with the blend mode there (photoshop + // corpus rgb32 black-white-tint.psd: white tints to 1.05, 1.00, 0.87). + Some(t) if hdr => photocraft_color::blend::blend_rgb_within(photocraft_color::blend::BlendMode::Color, [g; 3], *t, f32::MAX), + Some(t) => photocraft_color::blend::set_lum(*t, g).map(|v| v.clamp(0.0, 1.0)), + None => [g; 3], + } }) } - Adjustment::BlackWhite { weights, tint } => map_rgb(buf, |c| { - let g = black_white_gray(c, weights); - match tint { - // Photoshop tints like the Color blend mode: the tint's hue and saturation at the - // grey's luminosity (SetLum + ClipColor), measured in Photoshop 27.11. - Some(t) => photocraft_color::blend::set_lum(*t, g).map(|v| v.clamp(0.0, 1.0)), - None => [g; 3], - } - }), Adjustment::GradientMap { stops, reverse, dither } => { let (w, x0, y0) = (buf.rect.width().max(1) as usize, buf.rect.x0, buf.rect.y0); for (i, p) in buf.px.iter_mut().enumerate() { if p[3] <= 0.0 { continue; } - let mut t = rgb_to_gray([p[0], p[1], p[2]]); + // Driven by the W3C luminosity (0.3, 0.59, 0.11), not the Rec. 601 grey: photoshop + // corpus rgb8 and rgb16 gradient-map.psd match within a level (2 levels off with + // 0.299, 0.587, 0.114). + let mut t = photocraft_color::blend::lum([p[0], p[1], p[2]]); if *reverse { t = 1.0 - t; } @@ -239,13 +254,55 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth } } -/// Legacy Brightness/Contrast as one channel's tone curve (see [`apply_depth`]). -fn legacy_brightness_contrast(brightness: f32, contrast: f32) -> impl Fn(f32) -> f32 { +/// Legacy Brightness/Contrast on a 32-bit document's samples: unclipped, with contrast pivoting at +/// 127 of 255 (photoshop corpus rgb32 and gray32 brightness-contrast-legacy.psd: every pixel +/// exact, negative and over-white values included; the 8/16-bit pivot, 128, is 0.43 of a level +/// off there). +fn legacy_brightness_contrast_hdr(brightness: f32, contrast: f32) -> impl Fn(f32) -> f32 { + const PIVOT: f32 = 127.0 / 255.0; let b = brightness / 255.0; let c = contrast.clamp(-100.0, 99.0); let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 }; let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) }; - move |v: f32| ((v + pre - LEGACY_PIVOT) * k + LEGACY_PIVOT + post).clamp(0.0, 1.0) + move |v: f32| (v + pre - PIVOT) * k + PIVOT + post +} + +/// Legacy Brightness/Contrast as one channel's tone curve (see [`apply_depth`]). +fn legacy_brightness_contrast(brightness: f32, contrast: f32) -> impl Fn(f32) -> f32 { + legacy_brightness_contrast_at(brightness, contrast, LEGACY_PIVOT) +} + +/// [`legacy_brightness_contrast`] with contrast pivoting at `pivot`. +fn legacy_brightness_contrast_at(brightness: f32, contrast: f32, pivot: f32) -> impl Fn(f32) -> f32 { + let b = brightness / 255.0; + let c = contrast.clamp(-100.0, 99.0); + let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 }; + let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) }; + move |v: f32| ((v + pre - pivot) * k + pivot + post).clamp(0.0, 1.0) +} + +/// Whether Threshold at `level` turns colour `c` white in a document of `depth` (`None`: unknown, +/// treated as 8-bit). Photoshop splits on the luminosity 0.30 R + 0.59 G + 0.11 B (the blend +/// modes' `lum`), not the Rec. 601 grey, of the document's whole sample values: +/// - 8 bits: white when that luminosity, rounded to a level with halves down, reaches the level; +/// - 16 bits: white when it reaches the level converted to 16 bits (128 → 16448); +/// - 32 bits: white when it reaches the level. +/// +/// Exact on every pixel of psd-tools threshold_rgb.psd, threshold_16bits_rgb.psd and +/// threshold_16bits_grayscale.psd and of the photoshop corpus's rgb16 threshold-128.psd, where the +/// Rec. 601 grey split 28 and 100 pixels of the first two the other way. The sums of +/// `30 R + 59 G + 11 B` over whole samples are exact in f32 (at most 3 276 800). +pub fn threshold_white(c: [f32; 3], level: f32, depth: Option) -> bool { + let t = (level * 255.0).round(); + let sum = |q: f32| { + let [r, g, b] = c.map(|v| (v.clamp(0.0, 1.0) * q).round()); + 30.0 * r + 59.0 * g + 11.0 * b + }; + match depth { + None | Some(SampleType::U8) => sum(255.0) > 100.0 * t - 50.0, + Some(SampleType::U16) => sum(32768.0) >= 100.0 * (t * 32768.0 / 255.0).round(), + Some(SampleType::F32) => (0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]) * 255.0 >= t, + } } /// Applies an adjustment in a document of colour `mode`: [`apply_depth`], except that in Lab @@ -256,11 +313,25 @@ pub fn apply_in(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: O apply_lab(adj, buf, depth); } else if mode == ColorMode::Cmyk && cmyk_channel_adjustment(adj) { use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk}; - map_rgb(buf, |c| { - let mut ink = rgb_to_cmyk(c); - stored_channels(adj, mode, depth, &mut ink, rgb_to_gray(c)); - cmyk_to_rgb(ink) - }); + if let Adjustment::BrightnessContrast { brightness, contrast, legacy } = adj { + let table = ink_brightness_contrast_lut(*brightness, *contrast, *legacy); + map_rgb(buf, |c| cmyk_to_rgb(rgb_to_cmyk(c).map(|v| ink_through(&table, v)))); + } else if cmy_adjustment(adj) { + let mut inks: Vec<[f32; 4]> = buf.px.iter().map(|p| rgb_to_cmyk([p[0], p[1], p[2]])).collect(); + apply_cmy(adj, &mut inks); + for (p, ink) in buf.px.iter_mut().zip(&inks) { + if p[3] > 0.0 { + let [r, g, b] = cmyk_to_rgb(*ink); + (p[0], p[1], p[2]) = (r, g, b); + } + } + } else { + map_rgb(buf, |c| { + let mut ink = rgb_to_cmyk(c); + stored_channels(adj, mode, depth, &mut ink); + cmyk_to_rgb(ink) + }); + } } else { apply_depth(adj, buf, transfer, depth); } @@ -282,13 +353,55 @@ pub fn lab_channel_adjustment(adj: &Adjustment) -> bool { } /// Whether a CMYK document evaluates `adj` on its inks rather than on the screen colour: Invert -/// and Posterize (each ink, black included, so paper inverts to 400% ink) and Threshold (paper or -/// 100% black ink alone). Photoshop corpus cmyk8 and cmyk16 invert, posterize-4 and threshold-128 -/// follow these rules on the files' own inks on every pixel; here the inks are the screen colour -/// separated through the document's CMYK profile, so neutrals carry the black that profile -/// generates rather than the file's. +/// and Posterize (each ink, black included, so paper inverts to 400% ink), Threshold (paper or +/// 100% black ink alone) and Brightness/Contrast (each ink's stored value, 1 - ink, through the +/// tone curve; see [`ink_through`]). Photoshop corpus cmyk8 and cmyk16 invert, posterize-4, +/// threshold-128 and brightness-contrast(-legacy) follow these rules on the files' own inks on +/// every pixel; here the inks are the screen colour separated through the document's CMYK +/// profile, so neutrals carry the black that profile generates rather than the file's. pub fn cmyk_channel_adjustment(adj: &Adjustment) -> bool { - stored_channel_adjustment(adj) + stored_channel_adjustment(adj) || cmy_adjustment(adj) || matches!(adj, Adjustment::BrightnessContrast { .. }) +} + +/// Whether a CMYK document evaluates `adj` on C, M and Y as the complement of an RGB colour, +/// black kept ([`apply_cmy`]): Hue/Saturation (not Colorize, which Photoshop separates again, +/// black and all) and Color Balance (photoshop corpus cmyk16 hue-saturation.psd follows this on +/// every pixel within half a level, cmyk8 within two; color-balance within our RGB Color +/// Balance's own error). +pub fn cmy_adjustment(adj: &Adjustment) -> bool { + matches!(adj, Adjustment::HueSaturation { colorize: false, .. } | Adjustment::ColorBalance { .. }) +} + +/// A [`cmy_adjustment`] on straight inks (C, M, Y, K; 0 none): the RGB adjustment on +/// `(1 - c, 1 - m, 1 - y)`, black left alone. +pub fn apply_cmy(adj: &Adjustment, inks: &mut [[f32; 4]]) { + let width = i32::try_from(inks.len()).unwrap_or(i32::MAX); + let mut buf = Buffer { rect: crate::Rect::new(0, 0, width, 1), px: inks.iter().map(|i| [1.0 - i[0], 1.0 - i[1], 1.0 - i[2], 1.0]).collect() }; + apply_depth(adj, &mut buf, Transfer::Srgb, None); + for (ink, p) in inks.iter_mut().zip(&buf.px) { + for c in 0..3 { + ink[c] = (1.0 - p[c]).clamp(0.0, 1.0); + } + } +} + +/// Brightness/Contrast as the lookup table (4096 samples over 0..1) a CMYK ink's stored value, +/// `1 - ink`, goes through ([`ink_through`]): [`modern_lut`], or the legacy linear curve with its +/// contrast pivoting at 128 in ink, which is 127 of 255 stored (photoshop corpus cmyk16 +/// brightness-contrast-legacy.psd sits 0.43 of a level off around 128 stored, as a one-level +/// pivot shift times contrast +30's slope predicts). +pub fn ink_brightness_contrast_lut(brightness: f32, contrast: f32, legacy: bool) -> Vec { + if !legacy { + return modern_lut(brightness, contrast); + } + let curve = legacy_brightness_contrast_at(brightness, contrast, 1.0 - LEGACY_PIVOT); + (0..LUT_SIZE).map(|k| curve(k as f32 / (LUT_SIZE - 1) as f32)).collect() +} + +/// One CMYK ink (0: none) through a channel tone curve the way Photoshop applies one in CMYK: to +/// the stored value, `1 - ink`, so raising brightness takes ink away. +pub fn ink_through(table: &[f32], ink: f32) -> f32 { + 1.0 - lut(table, 1.0 - ink) } /// Whether [`stored_channels`] evaluates `adj`: Invert, Posterize and Threshold. @@ -298,10 +411,10 @@ pub fn stored_channel_adjustment(adj: &Adjustment) -> bool { /// Invert, Posterize or Threshold on one pixel's own channels in a Lab or CMYK document of /// `depth` (`None`: unknown, treated as 8-bit): `ch` holds L, a, b normalised as [`lab_norm`] -/// gives them, or the C, M, Y, K inks (0: none); `gray` is the pixel's luma, which CMYK Threshold -/// compares. Lab Invert turns 8-bit bytes `v` into `255 - v` (a becomes -1 - a) and mirrors a and -/// b around neutral at 16 bits (photoshop corpus lab16 invert.psd: neutral stays neutral). -pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option, ch: &mut [f32], gray: f32) { +/// gives them, or the C, M, Y, K inks (0: none). Lab Invert turns 8-bit bytes `v` into `255 - v` +/// (a becomes -1 - a) and mirrors a and b around neutral at 16 bits (photoshop corpus lab16 +/// invert.psd: neutral stays neutral). CMYK Threshold compares [`ink_luma`]. +pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option, ch: &mut [f32]) { let lab = mode == ColorMode::Lab; match adj { Adjustment::Invert => { @@ -310,7 +423,10 @@ pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option 0 { mirror } else { 1.0 } - v.clamp(0.0, 1.0); } } - Adjustment::Posterize { levels } => ch.iter_mut().for_each(|v| *v = posterize(*v, *levels)), + // CMYK posterizes the stored values (1 - ink), which its uneven levels make differ from + // posterizing the ink: psd-tools posterize_cmyk.psd (3 to 21 levels) is exact so. + Adjustment::Posterize { levels } if mode == ColorMode::Cmyk => ch.iter_mut().for_each(|v| *v = 1.0 - posterize_in(1.0 - *v, *levels, depth)), + Adjustment::Posterize { levels } => ch.iter_mut().for_each(|v| *v = posterize_in(*v, *levels, depth)), Adjustment::Threshold { level } => { // On the 8-bit grid, like the RGB threshold. let t = (level * 255.0).round(); @@ -318,7 +434,9 @@ pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option= t); [if white { 1.0 } else { 0.0 }, 128.0 / 255.0, 128.0 / 255.0, 0.0] } else { - [0.0, 0.0, 0.0, if (gray * 255.0).round() < t { 1.0 } else { 0.0 }] + let ink = |i: usize| ch.get(i).copied().unwrap_or(0.0); + let black = (ink_luma([ink(0), ink(1), ink(2), ink(3)]) * 255.0).round() < t; + [0.0, 0.0, 0.0, if black { 1.0 } else { 0.0 }] }; ch.iter_mut().zip(out).for_each(|(v, o)| *v = o); } @@ -326,6 +444,14 @@ pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option f32 { + let paper = 1.0 - ink[3].clamp(0.0, 1.0); + rgb_to_gray([0, 1, 2].map(|i| (1.0 - ink[i].clamp(0.0, 1.0)) * paper)) +} + /// Encoded sRGB → Lab normalised the way Photoshop's channels hold it: L/100, (a+128)/255, /// (b+128)/255. fn lab_norm(c: [f32; 3]) -> [f32; 3] { @@ -338,6 +464,49 @@ fn lab_denorm(n: [f32; 3]) -> [f32; 3] { lab_to_srgb([n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0]) } +/// Neutral a or b as Lab surfaces store it ((0 + 128) / 255). +pub const LAB_NEUTRAL: f32 = 128.0 / 255.0; + +/// An adjustment Photoshop evaluates on a Lab document's own channels, as an operation on one +/// pixel's stored L, a, b (normalised as Lab surfaces store them: L / 100, (a + 128) / 255, +/// (b + 128) / 255) in a document of `depth`: Invert and Posterize (each channel), Threshold +/// (split on L, neutral result), Brightness/Contrast (L) and Levels or Curves with Lab records. +/// `None` for any other adjustment. On the stored values colours outside the display gamut keep +/// theirs, where [`apply_in`] can only round-trip them through clipped display RGB. +pub fn lab_stored_op(adj: &Adjustment, depth: Option) -> Option> { + let op: LabOp<'_> = match adj { + Adjustment::Invert | Adjustment::Posterize { .. } => Box::new(move |ch: &mut [f32; 3]| stored_channels(adj, ColorMode::Lab, depth, ch)), + Adjustment::Threshold { level } => { + // Split on L on the 8-bit grid, like the display path (`apply_lab`). + let t = (level * 255.0).round(); + Box::new(move |ch: &mut [f32; 3]| { + let white = (ch[0].clamp(0.0, 1.0) * 255.0).round() >= t; + *ch = [if white { 1.0 } else { 0.0 }, LAB_NEUTRAL, LAB_NEUTRAL]; + }) + } + Adjustment::BrightnessContrast { brightness, contrast, legacy } => { + let (b, c, legacy) = (*brightness, *contrast, *legacy); + let legacy_curve = legacy_brightness_contrast(b, c); + Box::new(move |ch: &mut [f32; 3]| { + ch[0] = if legacy { legacy_curve(ch[0]) } else { modern_contrast(modern_brightness(ch[0], b), c).clamp(0.0, 1.0) }; + }) + } + Adjustment::Levels { space: ToneSpace::Lab, .. } | Adjustment::Curves { space: ToneSpace::Lab, .. } => { + let luts = tone_luts_depth(adj, depth); + Box::new(move |ch: &mut [f32; 3]| { + for (v, t) in ch.iter_mut().zip(&luts) { + *v = lut(t, *v); + } + }) + } + _ => return None, + }; + Some(op) +} + +/// One pixel's stored L, a, b through an adjustment ([`lab_stored_op`]). +pub type LabOp<'a> = Box; + /// The Lab evaluation of a [`lab_channel_adjustment`] in a document of `depth` (no-op for any /// other adjustment). fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option) { @@ -345,7 +514,7 @@ fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option) { // L → 100 - L and a, b mirrored about neutral; each channel posterized (see `stored_channels`). Adjustment::Invert | Adjustment::Posterize { .. } => map_rgb(buf, |c| { let mut n = lab_norm(c); - stored_channels(adj, ColorMode::Lab, depth, &mut n, 0.0); + stored_channels(adj, ColorMode::Lab, depth, &mut n); lab_denorm(n) }), Adjustment::Threshold { level } => { @@ -355,7 +524,11 @@ fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option) { } Adjustment::BrightnessContrast { brightness, contrast, legacy } => { let legacy_curve = legacy_brightness_contrast(*brightness, *contrast); - let curve = |v: f32| if *legacy { legacy_curve(v) } else { modern_contrast(modern_brightness(v, *brightness), *contrast).clamp(0.0, 1.0) }; + let modern = (!*legacy).then(|| modern_lut(*brightness, *contrast)); + let curve = |v: f32| match &modern { + Some(table) => lut(table, v), + None => legacy_curve(v), + }; // The tone curve on L only. Added as the difference of two round trips (like // `tone_in_space`) so a and b, and colours a round trip moves, stay put. map_rgb(buf, |c| { @@ -591,61 +764,63 @@ pub fn lut3d_sample(lut: &[f32], n: usize, c: [f32; 3], tetrahedral: bool) -> [f } } -/// How far one Color Balance slider at ±100 moves the black or white point (Photoshop 27.11: -/// shadows −100 maps 99.6 of 255 to black, highlights +100 maps 155.5 to white). -const BALANCE_LEVELS: f32 = 0.39; - /// Color Balance on channel `ch` (0 red, 1 green, 2 blue) of `v` (0..1); `tones` are the /// shadows, midtones and highlights slider triples (cyan–red, magenta–green, yellow–blue, /// −100..100). /// -/// Reverse-engineered from Photoshop 27.11 (a gray ramp and pure colours through each slider at -/// ±25, ±50 and ±100, with Preserve Luminosity on and off): every slider is a levels-and-gamma -/// curve per channel, exact within 1 level for a single slider. With `a` = slider / 100: -/// - Preserve Luminosity off: a slider only moves its own channel. Midtones is gamma 2^−a. -/// Shadows is gamma 2^(−a/2) after a black point at 0.39·|a| when lowered; highlights is -/// gamma 2^(−a/2) after a white point at 1 − 0.39·a when raised. -/// - Preserve Luminosity on: midtones uses gamma 2^((max(a) + min(a))/2 − a_ch), -/// preserving the one-slider behaviour without summing offsets when multiple sliders move. -/// Shadows and highlights go by the differences between a tone's three sliders: -/// each channel gets a plain black point at 0.39·(max − its own) for shadows and a plain white -/// point at 1 − 0.39·(its own − min) for highlights (so one raised shadows slider darkens the -/// other two channels, and two raised together darken only the third). +/// Reverse-engineered from Photoshop (a 16-bit gray ramp through every tone at ±10..±100, pairs of +/// tones, and random settings of all nine sliders, Preserve Luminosity on and off): the three +/// tones fold into one Levels per channel (input black point, input white point, midtone gamma), +/// within 0.8 of 255 everywhere. Folding matters: a lowered shadows and a raised highlights +/// slider cancel each other's gamma, which applying the tones one after another doesn't (#3423). +/// - Preserve Luminosity off: a slider only moves its own channel. Lowered shadows set the black +/// point to |shadows| levels, raised highlights set the white point to 255 − highlights levels, +/// and the gamma is 2^(m / 100) with m = midtones + (shadows + highlights) / 2. +/// - Preserve Luminosity on: each channel's black point is (max − own) shadows levels, its white +/// point 255 − (own − min) highlights levels, and m = own − (max + min) / 2 midtones. /// -/// Tones apply highlights, shadows, then midtones (measured with two and three tones on one -/// channel: within 1 level). +/// A gamma > 1 starts with slope 2^gamma at black ([`balance_gamma`]: up to 18 of 255 brighter +/// near black without it). When the black point reaches the white point, input +/// at or above the white point is white and the rest black. pub fn color_balance_channel(tones: [[f32; 3]; 3], preserve_luminosity: bool, ch: usize, v: f32) -> f32 { - let [shadows, midtones, highlights] = tones; - let black = |v: f32, b: f32| ((v - b) / (1.0 - b)).max(0.0); - let white = |v: f32, w: f32| (v / w).min(1.0); - let mut v = v.clamp(0.0, 1.0); - for (tone, sliders) in [(2, highlights), (0, shadows), (1, midtones)] { - let a: [f32; 3] = sliders.map(|s| (s / 100.0).clamp(-1.0, 1.0)); - if preserve_luminosity { - let own = a.get(ch).copied().unwrap_or(0.0); - let (lo, hi) = a.iter().fold((f32::MAX, f32::MIN), |m, x| (m.0.min(*x), m.1.max(*x))); - v = match tone { - 0 => black(v, BALANCE_LEVELS * (hi - own)), - 1 => v.powf(2f32.powf((hi + lo) / 2.0 - own)), - _ => white(v, 1.0 - BALANCE_LEVELS * (own - lo)), - }; - continue; - } - for (src, slider) in sliders.iter().enumerate() { - let a = (slider / 100.0).clamp(-1.0, 1.0); - if a == 0.0 || src != ch { - continue; - } - v = match tone { - 1 => v.powf(2f32.powf(-a)), - 0 if a < 0.0 => black(v, BALANCE_LEVELS * -a).powf(2f32.powf(-a / 2.0)), - 0 => v.powf(2f32.powf(-a / 2.0)), - 2 if a > 0.0 => white(v, 1.0 - BALANCE_LEVELS * a).powf(2f32.powf(-a / 2.0)), - _ => v.powf(2f32.powf(-a / 2.0)), - }; - } + let [shadows, midtones, highlights] = tones.map(|t| t.map(|s| if s.is_finite() { s.clamp(-100.0, 100.0) } else { 0.0 })); + let own = |t: [f32; 3]| t.get(ch).copied().unwrap_or(0.0); + let max = |t: [f32; 3]| t[0].max(t[1]).max(t[2]); + let min = |t: [f32; 3]| t[0].min(t[1]).min(t[2]); + let (black, white, m) = if preserve_luminosity { + (max(shadows) - own(shadows), own(highlights) - min(highlights), own(midtones) - (max(midtones) + min(midtones)) / 2.0) + } else { + ((-own(shadows)).max(0.0), own(highlights).max(0.0), own(midtones) + (own(shadows) + own(highlights)) / 2.0) + }; + let (b, w) = (black / 255.0, 1.0 - white / 255.0); + let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + if v >= w { + return 1.0; } - v + if v <= b { + return 0.0; + } + balance_gamma((v - b) / (w - b), 2f32.powf(m / 100.0)) +} + +/// The midtone gamma of Color Balance's per-channel Levels (`u` in 0..1): `u^(1/gamma)`, except +/// that a gamma > 1 (which would start with infinite slope) starts at slope 2^gamma and joins the +/// power curve in a cubic Hermite at twice the input where that line meets it (Photoshop, +/// measured on a 16-bit ramp at gamma 1.07..2: within 0.02 of 255). +fn balance_gamma(u: f32, gamma: f32) -> f32 { + let e = 1.0 / gamma.max(0.01); + let power = u.powf(e); + if gamma <= 1.0 { + return power; + } + let s0 = 2f32.powf(gamma); + let u0 = (2.0 * s0.powf(1.0 / (e - 1.0))).min(1.0); + if u0.is_nan() || u0 <= 0.0 || u >= u0 { + return power; + } + let (p, d, t) = (u0.powf(e), e * u0.powf(e - 1.0), u / u0); + let (t2, t3) = (t * t, t * t * t); + ((t3 - 2.0 * t2 + t) * u0 * s0 + (3.0 * t2 - 2.0 * t3) * p + (t3 - t2) * u0 * d).clamp(0.0, 1.0) } /// How much a colour belongs to each Selective Color range: reds, yellows, greens, cyans, @@ -823,20 +998,118 @@ fn hsv_hue(c: [f32; 3], mx: f32, mn: f32) -> f32 { h * 60.0 } -/// Photoshop posterize: `n` equal input bins over 0..=255, output levels -/// `floor(k * 255 / (n - 1))` (exact on the corpus for 3, 7, 13 and 21 levels). -/// Modern Brightness curve (one channel, `brightness` in [-150, 150]). A line of slope -/// `s = 1.375^(b/50)` from the origin, rolled off to (1, 1) by a `v^P` white-anchor term. Pins pure -/// black and white; `b = 0` is the identity. Fit to Photoshop ground truth (see modern B/C above). +/// Modern Brightness curve (one channel, `brightness` in [-150, 150]); pins pure black and white, +/// and `b = 0` is the identity. Raised, it is a line of slope `s = 1.375^(b/50)` from the origin +/// up to a knee at output 126/255, then a cubic Hermite to (1, 1) whose end slope is +/// `1 / (1 + 0.084 b)`; past about +110, where no such cubic stays below white, the roll-off is +/// `1 - (1 - y0)(1 - u)^α` instead (continuous slope `α` at the knee, flat at white). Lowered, it +/// is the inverse of the curve raised by as much. +/// +/// Fit to every Photoshop measurement we have, all within about a level and a half: its ±50 +/// samples (`modern_brightness_matches_photoshop`), the photoshop corpus's brightness-contrast.psd +/// (+25 with contrast +30) and psd-tools' brightnesscontrast_rgb.psd, whose masked layers give +/// +23, -31 and -95 (+150 within 6 levels there, where every curve family tried bottoms out). pub fn modern_brightness(v: f32, brightness: f32) -> f32 { - if brightness == 0.0 { + if brightness == 0.0 || !brightness.is_finite() { return v; } - let s = 1.375f32.powf(brightness / 50.0); - // Exponent of the white-anchor term grows with |b|; shapes the roll-off toward (1,1). - let p = if brightness >= 0.0 { (4.5 - 0.013 * brightness).max(2.0) } else { 5.0 - 0.072 * brightness }; - let v = v.clamp(0.0, 1.0); - (s * v + (1.0 - s) * v.powf(p)).clamp(0.0, 1.0) + Brightness::new(brightness).eval(v) +} + +/// [`modern_brightness`] for one slider value, its curve's constants worked out once. +struct Brightness { + raise: bool, + s: f32, + e: f32, + x0: f32, + /// The knee's slope over the roll-off's secant (`α` of Fritsch–Carlson); a power roll-off + /// replaces the Hermite when α² + β² > 9. + alpha: f32, + hermite: bool, +} + +/// Output level of the Brightness curve's knee. +const BRIGHTNESS_KNEE: f32 = 126.0 / 255.0; + +impl Brightness { + fn new(brightness: f32) -> Self { + let b = brightness.abs(); + let s = 1.375f32.powf(b / 50.0); + let e = 1.0 / (1.0 + 0.084 * b); + let x0 = (BRIGHTNESS_KNEE / s).min(1.0); + let secant = (1.0 - BRIGHTNESS_KNEE) / (1.0 - x0).max(1e-6); + let (alpha, beta) = (s / secant, e / secant); + Brightness { raise: brightness > 0.0, s, e, x0, alpha, hermite: alpha * alpha + beta * beta <= 9.0 } + } + + fn eval(&self, v: f32) -> f32 { + let v = v.clamp(0.0, 1.0); + if self.raise { + return self.raised(v); + } + if v <= 0.0 || v >= 1.0 { + return v; + } + // The inverse of the raised curve (monotone on [0, 1]), by bisection. + let (mut lo, mut hi) = (0.0f32, 1.0f32); + for _ in 0..32 { + let mid = 0.5 * (lo + hi); + if self.raised(mid) < v { + lo = mid; + } else { + hi = mid; + } + } + 0.5 * (lo + hi) + } + + /// The curve raised: the line, then a monotone roll-off to (1, 1). + fn raised(&self, x: f32) -> f32 { + let Brightness { s, e, x0, alpha, hermite, .. } = *self; + let y0 = BRIGHTNESS_KNEE; + if x <= x0 { + return y0.min(s * x); + } + let h = (1.0 - x0).max(1e-6); + let t = ((x - x0) / h).clamp(0.0, 1.0); + let y = if hermite { + let (t2, t3) = (t * t, t * t * t); + (2.0 * t3 - 3.0 * t2 + 1.0) * y0 + (t3 - 2.0 * t2 + t) * h * s + (-2.0 * t3 + 3.0 * t2) + (t3 - t2) * h * e + } else { + 1.0 - (1.0 - y0) * (1.0 - t).powf(alpha) + }; + y.clamp(0.0, 1.0) + } +} + +/// Modern Brightness/Contrast as one channel's lookup table (4096 samples over 0..1): +/// [`modern_brightness`] then [`modern_contrast`], as Photoshop applies them. +pub fn modern_lut(brightness: f32, contrast: f32) -> Vec { + let step = |k: usize, n: usize| k as f32 / (n - 1) as f32; + let bright: Vec = if brightness == 0.0 || !brightness.is_finite() { + (0..LUT_SIZE).map(|k| step(k, LUT_SIZE)).collect() + } else if brightness > 0.0 { + let curve = Brightness::new(brightness); + (0..LUT_SIZE).map(|k| curve.raised(step(k, LUT_SIZE))).collect() + } else { + // Lowered: invert the raised curve, sampled four times finer, in one monotone pass. + let curve = Brightness::new(-brightness); + let fine = 4 * LUT_SIZE; + let ys: Vec = (0..fine).map(|j| curve.raised(step(j, fine))).collect(); + let mut j = 0; + (0..LUT_SIZE) + .map(|k| { + let y = step(k, LUT_SIZE); + while j + 2 < fine && ys.get(j + 1).is_some_and(|&n| n < y) { + j += 1; + } + let (y0, y1) = (ys.get(j).copied().unwrap_or(0.0), ys.get(j + 1).copied().unwrap_or(1.0)); + let f = if y1 > y0 { ((y - y0) / (y1 - y0)).clamp(0.0, 1.0) } else { 0.0 }; + (step(j, fine) + f / (fine - 1) as f32).clamp(0.0, 1.0) + }) + .collect() + }; + bright.into_iter().map(|b| modern_contrast(b, contrast).clamp(0.0, 1.0)).collect() } /// Modern Contrast curve (one channel, `contrast` in [-50, 100]). A symmetric cubic-Hermite S-curve @@ -854,6 +1127,8 @@ pub fn modern_contrast(v: f32, contrast: f32) -> f32 { if v <= 0.5 { half(v / 0.5) } else { 1.0 - half((1.0 - v) / 0.5) } } +/// Photoshop posterize: `n` equal input bins over 0..=255, output levels +/// `floor(k * 255 / (n - 1))` (exact on the corpus for 3, 7, 13 and 21 levels). pub fn posterize(v: f32, levels: u32) -> f32 { let n = levels.clamp(2, 255) as f32; let x = (v.clamp(0.0, 1.0) * 255.0).round(); @@ -861,44 +1136,75 @@ pub fn posterize(v: f32, levels: u32) -> f32 { (bin * 255.0 / (n - 1.0)).floor() / 255.0 } +/// [`posterize`] in a document of `depth` (`None`: unknown, treated as 8-bit). At 16 bits +/// Photoshop bins the 16-bit sample (`⌊v · n / 32768⌋`) and lands on 8-bit levels through a +/// 16-bit step: `⌊b · 32768 / (n - 1)⌋`, converted with `⌊(o · 255 + 16384) / 32768⌋`, so +/// 127.5 comes out as 128 but 42.5 as 42 (8 bits floor them all). Exact on every sample of +/// psd-tools posterize_16bits_rgb.psd and posterize_16bits_grayscale.psd (levels 2 to 21), +/// which the 8-bit rule missed by up to half the range. +pub fn posterize_in(v: f32, levels: u32, depth: Option) -> f32 { + if depth != Some(SampleType::U16) { + return posterize(v, levels); + } + let n = f64::from(levels.clamp(2, 255)); + let x = (f64::from(v.clamp(0.0, 1.0)) * 32768.0).round(); + let bin = (x * n / 32768.0).floor().min(n - 1.0); + let step = (bin * 32768.0 / (n - 1.0)).floor(); + (((step * 255.0 + 16384.0) / 32768.0).floor() / 255.0) as f32 +} + pub fn levels(ch: &LevelsChannel, v: f32) -> f32 { levels_q(ch, v, None) } /// [`levels`] on samples with `quantum` steps per unit (8-bit: 255). Photoshop works on whole -/// levels: the input is a level, and the input-range stretch is rounded (half up) to a whole -/// level before the midtone gamma, so a 44..214 range maps 45, 46, 47, 48 → 1.5, 3, 4.5, 6 → -/// 2, 3, 5, 6 (psd-tools levels_grayscale, where the gamma toe magnifies the steps). In a LUT -/// the result is a staircase that is exact at every level. +/// levels in three steps, each rounded half up to a whole level: the input-range stretch, the +/// midtone gamma ([`levels_gamma`]) and the output range. So a 44..214 range maps 45, 46, 47, 48 +/// → 1.5, 3, 4.5, 6 → 2, 3, 5, 6 (psd-tools levels_grayscale, where the gamma toe magnifies the +/// steps), and an output range narrower than 0..255 maps neighbouring gamma levels to one level +/// (psd-tools levels_rgb: 43..48 → 58 through a 0.47 channel gamma and the master's 48..255). +/// In a LUT the result is a staircase that is exact at every level. pub fn levels_q(ch: &LevelsChannel, v: f32, quantum: Option) -> f32 { let range = (ch.in_white - ch.in_black).max(1e-6); - let u = match quantum.filter(|q| q.is_finite() && *q >= 1.0) { + let quantum = quantum.filter(|q| q.is_finite() && *q >= 1.0); + let whole = |x: f32| match quantum { + Some(q) => (x * q + 0.5).floor() / q, + None => x, + }; + let u = match quantum { Some(q) => { let v = (v * q).round() / q; (((v - ch.in_black) / range).clamp(0.0, 1.0) * q + 0.5 + 1e-3).floor() / q } None => ((v - ch.in_black) / range).clamp(0.0, 1.0), }; - let g = ch.gamma.max(0.01); - let t = if g > 1.0 { - // A midtone gamma > 1 lifts shadows, and a pure `u^(1/g)` has infinite slope at black — - // which Photoshop bounds, giving a soft shadow toe (initial slope ≈ 2^gamma, verified against - // the real app). Soft-min (p-norm) of the power curve with that line; reduces to the exact - // power curve in the body, so highlights are unchanged. gamma <= 1 is untouched. Slope and - // sharpness fitted on whole-level input (`levels_q`): a 0..255 ramp at gamma 2.0 and - // psd-tools levels_grayscale's gamma 1.78 (within one level of Photoshop on both). - let power = u.powf(1.0 / g); - let line = 0.93 * 2.0f32.powf(g) * u; - if power <= 1e-6 || line <= 1e-6 { - power.min(line) - } else { - let p = 10.0; // p-norm sharpness, fit to Photoshop ground truth - (power.powf(-p) + line.powf(-p)).powf(-1.0 / p) - } - } else { - u.powf(1.0 / g) - }; - ch.out_black + t * (ch.out_white - ch.out_black) + let t = whole(levels_gamma(u, ch.gamma.max(0.01))); + whole(ch.out_black + t * (ch.out_white - ch.out_black)) +} + +/// Exponent of the shadow toe in [`levels_gamma`] (fitted; see there). +const LEVELS_TOE_POWER: f32 = 2.2; + +/// Levels' midtone gamma `g` on `u` in 0..1: `u^(1/g)`, except that above 1, where that power +/// curve is infinitely steep at black, Photoshop starts along a toe `a·u − b·u^q` of slope +/// `a = 2^g` that joins the curve with the same value and slope. `q` = [`LEVELS_TOE_POWER`] is +/// fitted on whole-level oracles at gamma 1.16, 1.22, 1.3, 1.78 and 2.0 (psd-tools levels_rgb +/// and levels_grayscale, the photoshop corpus's rgb8 and gray8 levels.psd, a measured 0..255 +/// ramp): all but 2 of their 1118 levels exact, the others within one. Below 1 the power curve is +/// exact (gamma 0.67 and 0.7: every level). +pub fn levels_gamma(u: f32, g: f32) -> f32 { + let c = 1.0 / g; + if g <= 1.0 { + return u.powf(c); + } + let (a, q) = (2f32.powf(g), LEVELS_TOE_POWER); + // Where the toe meets the curve: a·u1 − b·u1^q = u1^c and a − q·b·u1^(q−1) = c·u1^(c−1). + let u1 = ((1.0 - c / q) / (a * (1.0 - 1.0 / q))).powf(1.0 / (1.0 - c)); + if u >= u1 || u1 <= 0.0 { + return u.powf(c); + } + let b = (a * u1 - u1.powf(c)) / u1.powf(q); + a * u - b * u.powf(q) } /// [`levels`] in a 32-bit document. Neither the input is clipped to the input range nor the result @@ -1178,6 +1484,41 @@ mod tone_tests { /// Photoshop 27.11, measured: Brightness/Contrast with Use Legacy on, over a gray ramp /// (x = 0, 16, …, 240, 255), one row per (brightness, contrast). + /// One pixel through `adj` in a 32-bit document. + fn hdr(adj: &Adjustment, c: [f32; 3]) -> [f32; 3] { + let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]); + apply_depth(adj, &mut b, Transfer::Gamma(1.0), Some(SampleType::F32)); + [b.px[0][0], b.px[0][1], b.px[0][2]] + } + + /// Photoshop's own 32-bit results (photoshop corpus rgb32): nothing clips, legacy contrast + /// pivots at 127, the mixer cuts its percentages to 1/1024 and counts 255/512 of the + /// constant, and the Black & White tint keeps values above 1. + #[test] + fn thirty_two_bit_adjustments_match_photoshop_unclipped() { + let near = |got: [f32; 3], want: [f32; 3], tol: f32| got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol); + let bc = Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy: true }; + // brightness-contrast-legacy.psd + assert!(near(hdr(&bc, [0.4910, 0.0009, 0.9301]), [0.6280, -0.0721, 1.2553], 2e-4), "{:?}", hdr(&bc, [0.4910, 0.0009, 0.9301])); + // channel-mixer.psd: pure red, green, blue and black, exactly. + let mixer = Adjustment::ChannelMixer { matrix: [[0.8, 0.3, -0.1, 0.0], [0.1, 0.9, 0.0, 0.05], [0.0, 0.2, 0.7, 0.0]], monochrome: false }; + for (c, want) in [ + ([1.0, 0.0, 0.0], [0.7998047, 0.1245117, 0.0]), + ([0.0, 1.0, 0.0], [0.2998047, 0.9243164, 0.1992188]), + ([0.0, 0.0, 1.0], [-0.0996094, 0.0249023, 0.6992188]), + ([0.0, 0.0, 0.0], [0.0, 0.0249023, 0.0]), + ] { + assert!(near(hdr(&mixer, c), want, 1e-6), "{c:?}: {:?}", hdr(&mixer, c)); + } + // black-white-tint.psd: white tints past 1. + let bw = Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: Some([0.88235295, 0.827451, 0.7019608]) }; + assert!(near(hdr(&bw, [1.0; 3]), [1.0522, 0.9973, 0.8718], 2e-4), "{:?}", hdr(&bw, [1.0; 3])); + // 8/16-bit documents still clip. + let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [1.0, 1.0, 1.0, 1.0]); + apply_depth(&bw, &mut b, Transfer::Srgb, None); + assert!(b.px[0].iter().all(|v| *v <= 1.0)); + } + #[test] fn legacy_brightness_contrast_matches_photoshop() { const X: [u8; 17] = [0, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 255]; @@ -1202,6 +1543,48 @@ mod tone_tests { } } + /// Pixel → Photoshop's split. 8-bit from psd-tools threshold_rgb.psd: the first three are + /// white though their Rec. 601 grey rounds below 60, and (131, 109, 99) sits exactly on 114.5 + /// at level 115 (halves round down). 16-bit from threshold_16bits_rgb.psd and the photoshop + /// corpus's rgb16 threshold-128.psd, whose mid grey 16448 is level 128 in 16 bits. + #[test] + fn threshold_splits_on_the_luminosity_like_photoshop() { + let c8 = |r: u8, g: u8, b: u8| [r, g, b].map(|v| f32::from(v) / 255.0); + for (c, level, white) in + [(c8(87, 51, 31), 60u8, true), (c8(90, 49, 33), 60, true), (c8(68, 57, 50), 60, true), (c8(48, 63, 72), 60, false), (c8(131, 109, 99), 115, false)] + { + assert_eq!(threshold_white(c, f32::from(level) / 255.0, Some(SampleType::U8)), white, "8-bit {c:?} at {level}"); + } + let c16 = |r: u16, g: u16, b: u16| [r, g, b].map(|v| f32::from(v) / 32768.0); + for (c, level, white) in [ + (c16(8995, 7196, 6425), 60u8, false), + (c16(11180, 6554, 3984), 60, false), + (c16(16448, 16448, 16448), 128, true), + (c16(16447, 16447, 16447), 128, false), + ] { + assert_eq!(threshold_white(c, f32::from(level) / 255.0, Some(SampleType::U16)), white, "16-bit {c:?} at {level}"); + } + // Through the adjustment: neutral black or white. + let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [87.0 / 255.0, 51.0 / 255.0, 31.0 / 255.0, 1.0]); + apply_depth(&Adjustment::Threshold { level: 60.0 / 255.0 }, &mut b, Transfer::Srgb, Some(SampleType::U8)); + assert_eq!(b.px[0], [1.0, 1.0, 1.0, 1.0]); + } + + /// 16-bit sample → Photoshop's 8-bit level, from psd-tools posterize_16bits_rgb.psd: bins + /// split on the 16-bit sample (10794 and 10923 straddle a third of 32768), and the middle of 3 + /// levels is 128 where 8 bits give 127, while 21 levels keep 25 for 25.5. + #[test] + fn sixteen_bit_posterize_matches_photoshop() { + let points: [(u32, u16, u8); 9] = + [(3, 10794, 0), (3, 10923, 128), (3, 21845, 128), (3, 21974, 255), (13, 2504, 0), (13, 2542, 21), (13, 7453, 42), (13, 7582, 64), (21, 3341, 25)]; + for (levels, x, want) in points { + let got = posterize_in(f32::from(x) / 32768.0, levels, Some(SampleType::U16)); + assert!((got * 255.0 - f32::from(want)).abs() < 1e-3, "{levels} levels at {x}: {} vs Photoshop {want}", got * 255.0); + } + // 8 bits keep their rule. + assert_eq!(posterize_in(128.0 / 255.0, 3, Some(SampleType::U8)), posterize(128.0 / 255.0, 3)); + } + #[test] fn color_lookup_with_an_overflowing_stored_size_is_the_identity() { // A stored document's size is untrusted: (2²²)³ × 3 overflows, which once wrapped past @@ -1254,6 +1637,78 @@ mod tone_tests { } } + /// Photoshop, measured (8-bit gray ramp, every channel): whole settings, where the tones fold + /// into one Levels per channel (#3423). The issue's midtones red +100 / magenta −100 (the + /// shadow toe), its sequoia14 layer with Preserve Luminosity off and on, all nine sliders with + /// Preserve Luminosity on, a black point past the white point, and lowered shadows with raised + /// highlights (their gammas cancel: a straight line between the two points). + #[test] + fn color_balance_whole_settings_match_photoshop() { + const X: [u8; 12] = [0, 4, 8, 16, 32, 64, 96, 128, 160, 192, 224, 255]; + type Case = ([[f32; 3]; 3], bool, [[u8; 12]; 3]); + let cases: [Case; 6] = [ + ( + [[0.0; 3], [100.0, -100.0, 0.0], [0.0; 3]], + false, + [[0, 16, 30, 55, 90, 128, 156, 181, 202, 221, 239, 255], [0, 0, 0, 1, 4, 16, 36, 64, 100, 145, 197, 255], X], + ), + ( + [[-6.0, 22.0, 5.0], [-1.0, -3.0, -8.0], [-1.0, -4.0, -6.0]], + false, + [ + [0, 0, 2, 9, 25, 56, 89, 122, 156, 188, 222, 255], + [0, 5, 9, 18, 35, 67, 100, 131, 163, 194, 225, 255], + [0, 3, 7, 14, 29, 60, 91, 123, 156, 189, 222, 255], + ], + ), + ( + [[-6.0, 22.0, 5.0], [-1.0, -3.0, -8.0], [-1.0, -4.0, -6.0]], + true, + [ + [0, 0, 0, 0, 5, 42, 80, 117, 154, 189, 226, 255], + [0, 4, 8, 16, 33, 66, 98, 130, 162, 195, 226, 255], + [0, 0, 0, 0, 15, 48, 82, 116, 151, 186, 221, 255], + ], + ), + ( + [[40.0, -30.0, 20.0], [-50.0, 25.0, 60.0], [30.0, -70.0, 10.0]], + true, + [ + [0, 1, 3, 9, 25, 69, 126, 193, 255, 255, 255, 255], + [0, 0, 0, 0, 0, 0, 46, 93, 136, 177, 217, 255], + [0, 0, 0, 0, 44, 107, 156, 199, 238, 255, 255, 255], + ], + ), + ( + [[100.0, -100.0, 0.0], [0.0; 3], [-100.0, 100.0, 0.0]], + true, + [X, [0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 0, 0, 0, 130, 255, 255, 255, 255]], + ), + ( + [[-76.0, -2.0, 60.0], [0.0; 3], [99.0, 0.0, -30.0]], + false, + [ + [0, 0, 0, 0, 0, 0, 71, 171, 255, 255, 255, 255], + [0, 2, 6, 14, 29, 61, 94, 126, 158, 191, 224, 255], + [0, 6, 11, 21, 39, 73, 106, 137, 168, 197, 227, 255], + ], + ), + ]; + for (tones, preserve, want) in cases { + for (ch, row) in want.iter().enumerate() { + for (x, w) in X.iter().zip(row) { + let got = (color_balance_channel(tones, preserve, ch, f32::from(*x) / 255.0) * 255.0).round() as i32; + assert!((got - i32::from(*w)).abs() <= 1, "{tones:?} pl {preserve} ch {ch} at {x}: {got} vs Photoshop {w}"); + } + } + } + // Hostile input stays in range. + for v in [f32::NAN, -1.0, 2.0, f32::INFINITY] { + let out = color_balance_channel([[f32::NAN, 1e9, -1e9]; 3], true, 7, v); + assert!((0.0..=1.0).contains(&out), "{v}: {out}"); + } + } + #[test] fn color_balance_midtones_with_multiple_sliders_preserve_luminosity() { // A shared offset depends on the two extremes, not the sum of moved sliders. @@ -1525,6 +1980,18 @@ mod tone_tests { } } + #[test] + fn gradient_map_follows_the_w3c_luminosity() { + // Photoshop's Gradient Map is driven by 0.3 R + 0.59 G + 0.11 B (photoshop corpus rgb8 and + // rgb16 gradient-map.psd), not the Rec. 601 grey. + let stops = vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])]; + for (c, t) in [([0.0, 1.0, 0.0], 0.59), ([1.0, 0.0, 0.0], 0.3), ([0.0, 0.0, 1.0], 0.11), ([0.2, 0.6, 0.9], 0.513)] { + let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]); + apply(&Adjustment::GradientMap { stops: stops.clone(), reverse: false, dither: false }, &mut b); + assert!((b.px[0][0] - t).abs() < 1e-5, "{c:?}: {}", b.px[0][0]); + } + } + #[test] fn gradient_map_dither_stays_close() { let stops = vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])]; @@ -1600,6 +2067,64 @@ mod tone_tests { assert!(near(in_cmyk(&t, [0.8; 3]), cmyk_to_rgb([0.0; 4]), 2e-3)); } + /// CMYK posterizes the stored value 1 - ink: with 3 levels an ink of 85 (stored 170) lands on + /// the middle step, 128 (stored 127), where posterizing the ink would drop it to none (psd-tools + /// posterize_cmyk.psd, levels 3 to 21, exact so). + #[test] + fn cmyk_posterize_works_on_the_stored_values() { + let mut ink = [85.0 / 255.0; 4]; + stored_channels(&Adjustment::Posterize { levels: 3 }, ColorMode::Cmyk, Some(SampleType::U8), &mut ink); + assert!(ink.iter().all(|v| (v * 255.0 - 128.0).abs() < 1e-3), "{ink:?}"); + assert_eq!(posterize(85.0 / 255.0, 3), 0.0, "posterizing the ink itself"); + } + + #[test] + fn cmyk_threshold_compares_the_inks_plain_luma() { + assert_eq!(ink_luma([0.0; 4]), 1.0); + assert_eq!(ink_luma([0.0, 0.0, 0.0, 1.0]), 0.0); + // Two greys either side of level 128 in photoshop corpus cmyk8 threshold-128.psd: black, + // then white (8-bit inks). + let at = |i: [f32; 4]| { + let mut ch = i.map(|v| v / 255.0); + stored_channels(&Adjustment::Threshold { level: 128.0 / 255.0 }, ColorMode::Cmyk, Some(SampleType::U8), &mut ch); + ch + }; + assert_eq!(at([144.0, 122.0, 121.0, 35.0]), [0.0, 0.0, 0.0, 1.0]); + assert_eq!(at([131.0, 110.0, 110.0, 19.0]), [0.0; 4]); + } + + #[test] + fn cmyk_brightness_contrast_curves_each_ink_from_paper() { + use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk}; + // The tone curve runs on each ink's stored value, 1 - ink (photoshop corpus cmyk8 + // brightness-contrast.psd and -legacy.psd): raising brightness takes ink away, and paper + // and solid ink stay put. + let red = [0.9, 0.2, 0.1]; + for legacy in [false, true] { + let adj = Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy }; + let table = ink_brightness_contrast_lut(25.0, 30.0, legacy); + let want = cmyk_to_rgb(rgb_to_cmyk(red).map(|v| 1.0 - lut(&table, 1.0 - v))); + assert!(near(in_cmyk(&adj, red), want, 2e-3), "legacy {legacy}"); + assert!(near(in_cmyk(&adj, [1.0; 3]), [1.0; 3], 2e-3), "legacy {legacy}: paper"); + let solid = cmyk_to_rgb([1.0; 4]); + assert!(near(in_cmyk(&adj, solid), cmyk_to_rgb(rgb_to_cmyk(solid).map(|v| ink_through(&table, v))), 2e-3)); + } + // Legacy +25 / +30 on Photoshop's own 16-bit inks (photoshop corpus cmyk16 + // brightness-contrast-legacy.psd): contrast pivots at 128 in ink, 127 stored. + let legacy = ink_brightness_contrast_lut(25.0, 30.0, true); + for (ink, ps) in [(139.0, 108.0), (160.0, 137.99), (172.0, 155.13), (183.0, 170.85), (196.0, 189.42), (98.0, 49.42), (128.0, 92.28), (168.0, 149.42)] { + let got = ink_through(&legacy, ink / 255.0) * 255.0; + assert!((got - ps).abs() < 0.1, "ink {ink}: {got} vs Photoshop {ps}"); + } + let brighter = Adjustment::BrightnessContrast { brightness: 60.0, contrast: 0.0, legacy: false }; + let ink = ink_brightness_contrast_lut(60.0, 0.0, false); + assert!(ink_through(&ink, 0.5) < 0.5 && ink_through(&ink, 0.0) == 0.0 && (ink_through(&ink, 1.0) - 1.0).abs() < 1e-6); + // On the screen colour it would land elsewhere. + let mut rgb = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [red[0], red[1], red[2], 1.0]); + apply(&brighter, &mut rgb); + assert!(!near(in_cmyk(&brighter, red), [rgb.px[0][0], rgb.px[0][1], rgb.px[0][2]], 0.01)); + } + #[test] fn lab_posterize_steps_l_a_and_b() { let got = in_lab(&Adjustment::Posterize { levels: 2 }, lab(70.0, 20.0, -30.0)); diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 6966254547..cbdf7f35a2 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -13,7 +13,8 @@ //! Interior effects are painted relative to the layer's shape and then take the shape's alpha //! (an overlay recolours a half-transparent edge without adding coverage). The outside parts //! of strokes blend onto the backdrop with their own modes, an upper stroke covering the -//! lower ones. Drop shadows are knocked out only through see-through fill. +//! lower ones. Drop shadows are knocked out through see-through fill, and beneath the whole shape +//! when the layer's mode lets what's under it show through (see [`shadow_see_through`]). //! //! Shapes come from the layer's alpha (after its mask). A stroke's outer edge, and precise glows / //! chiselled bevels, use an exact Euclidean transform: a 5 × 5 chamfer's level sets are polygons, @@ -36,6 +37,25 @@ pub fn has_effects(layer: &Layer) -> bool { layer.effects.enabled && layer.effects.items.iter().any(Effect::enabled) } +/// Whether an enabled effect is laid out over the layer's frame ([`crate::effects_frame`]), so +/// growing or shrinking the layer repaints it everywhere, not only where its pixels changed: a +/// Gradient Overlay, a gradient stroke (its frame is the shape's extent), and patterns linked with +/// the layer without an effects reference point (overlays, pattern strokes and glows, and bevel +/// textures tile from the frame's top-left). +pub fn spans_frame(layer: &Layer) -> bool { + let unanchored = layer.effects.reference.is_none(); + let pattern = |p: &FxPaint| unanchored && matches!(p, FxPaint::Pattern { .. }); + has_effects(layer) + && layer.effects.items.iter().filter(|e| e.enabled()).any(|e| match e { + Effect::GradientOverlay { .. } => true, + Effect::PatternOverlay { link, .. } => *link && unanchored, + Effect::Stroke(s) => matches!(s.paint, FxPaint::Gradient(_)) || pattern(&s.paint), + Effect::OuterGlow(g) | Effect::InnerGlow(g) => pattern(&g.paint), + Effect::BevelEmboss(b) => unanchored && b.texture.as_ref().is_some_and(|t| t.link), + _ => false, + }) +} + /// Pixels an effect stack can reach beyond the layer's shape. pub fn margin(layer: &Layer) -> i32 { let mut m = 0.0f32; @@ -1508,13 +1528,16 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern tent(&mut h, g.width); h } else { - let sharp = sharp_edges(shape); - let (din, dout) = (bevel_dist_inside(shape, &sharp, size), bevel_dist_outside(shape, &sharp, size)); - let mut h = Map::new(shape.w, shape.h, 0.0); + let [din, dout] = bevel_dists(shape, size); let soft = g.chisel_soft > 0.0; - for i in 0..h.v.len() { - h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i], soft); - } + let w = shape.w; + let v = par_fill(w, shape.h, 0.0, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let i = y * w + x; + *o = bevel_chisel_h(g.paint, size, din[i], dout[i], soft); + } + }); + let mut h = Map { w, h: shape.h, v }; // Inner and outer Chisel Soft are not blurred in Photoshop (their facets equal Chisel // Hard's across a straight edge); emboss styles keep the blur until measured. if soft && g.paint == BevelPaint::Both { @@ -1535,14 +1558,23 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern let place = Placement::anchored(tex.anchor, t.link, t.phase, t.scale, 0.0); let k = t.depth / unit; let (x0, y0) = (tex.rect.x0, tex.rect.y0); - for y in 0..h.h { - for x in 0..h.w { - let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5); - let p = tile.sample(u, v); - let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3]; - h.v[y * h.w + x] += k * if t.invert { 1.0 - l } else { l }; - } - } + let w = h.w; + par_units( + h.v.len(), + &mut h.v, + w, + &mut [] as &mut [()], + 0, + || (), + |_, y, row, _| { + for (x, o) in row.iter_mut().enumerate() { + let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5); + let p = tile.sample(u, v); + let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3]; + *o += k * if t.invert { 1.0 - l } else { l }; + } + }, + ); } if b.soften >= 1.0 { tent(&mut h, b.soften); @@ -1624,23 +1656,33 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat }; let shade_into = |depth: f32, region_kind: u8| -> (Map, Map) { let (mut hi, mut sh) = (Map::new(shape.w, shape.h, 0.0), Map::new(shape.w, shape.h, 0.0)); - for y in 0..shape.h as i64 { - for x in 0..shape.w as i64 { - let i = y as usize * shape.w + x as usize; - let gx = (hmap.get(x + 1, y) - hmap.get(x - 1, y)) * 0.5 * depth; - let gy = (hmap.get(x, y + 1) - hmap.get(x, y - 1)) * 0.5 * depth; - let n = [-gx, -gy, 1.0]; - let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt(); - let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len; - let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) }; - let k = shade - se; - if k > 0.0 { - hi.v[i] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region; - } else { - sh.v[i] = (-k / se.max(1e-3)).clamp(0.0, 1.0) * region; + // Rows on threads: every pixel reads only `hmap` and `shape`. + par_units( + hi.v.len(), + &mut hi.v, + shape.w, + &mut sh.v, + shape.w, + || (), + |_, y, hi_row, sh_row| { + let y = y as i64; + for x in 0..shape.w as i64 { + let i = y as usize * shape.w + x as usize; + let gx = (hmap.get(x + 1, y) - hmap.get(x - 1, y)) * 0.5 * depth; + let gy = (hmap.get(x, y + 1) - hmap.get(x, y - 1)) * 0.5 * depth; + let n = [-gx, -gy, 1.0]; + let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt(); + let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len; + let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) }; + let k = shade - se; + if k > 0.0 { + hi_row[x as usize] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region; + } else { + sh_row[x as usize] = (-k / se.max(1e-3)).clamp(0.0, 1.0) * region; + } } - } - } + }, + ); (apply_contour(hi, &b.gloss_contour), apply_contour(sh, &b.gloss_contour)) }; let maps = match g.paint { @@ -1656,7 +1698,13 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat let (hi, sh) = shade_into(g.depth, 0); let (ho, so) = shade_into(if g.pillow { -g.depth } else { g.depth }, 1); let mix = |a: Map, b: Map| -> Map { - let v = a.v.iter().zip(&b.v).zip(&shape.v).map(|((x, y), s)| if *s > INSIDE_EPS { *x } else { 0.0 } + y * (1.0 - s.min(1.0))).collect(); + let v = par_fill(a.w, a.h, 0.0, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let i = y * a.w + x; + let (x, y, s) = (a.v[i], b.v[i], shape.v[i]); + *o = if s > INSIDE_EPS { x } else { 0.0 } + y * (1.0 - s.min(1.0)); + } + }); Map { w: a.w, h: a.h, v } }; vec![mix(hi, ho), mix(sh, so)] @@ -1850,6 +1898,16 @@ fn knock_out_exterior(map: &mut Map, shape: &Map, fill_opacity: f32) { } } +/// How far the layer lets a knocked-out drop shadow show beneath its own shape (Layer Knocks Out +/// Drop Shadow), 0 = covered, 1 = knocked out wholly. In a normal mode an opaque layer covers the +/// shadow anyway, so only see-through fill knocks it out (and anti-aliased edges are not +/// attenuated twice). In any other mode the layer blends with what's beneath it, shadow and all: +/// an Exclusion or Difference layer would invert it, Multiply darken by it, Screen show it as +/// grey. Photoshop keeps it out from under the shape, so here it's knocked out wholly. +pub fn shadow_see_through(layer: &Layer) -> f32 { + if matches!(layer.blend, BlendMode::Normal | BlendMode::Dissolve | BlendMode::PassThrough) { 1.0 - layer.fill_opacity.clamp(0.0, 1.0) } else { 1.0 } +} + /// Blend Interior Effects as Group off (Photoshop's default): the layer's mode applies to its /// own pixels only, and the interior effects paint over the blended result. The layer is merged /// into the backdrop before they paint, so an Overlay colour overlay on a black Color-mode layer @@ -1916,10 +1974,10 @@ pub(crate) fn composite_with_effects_prepared( if let Effect::DropShadow(s) = e { let mut m = fx(i, 0); if s.knocks_out { - // The layer hides the shadow beneath it only where its fill is see-through: at - // 100 % fill the layer covers it anyway (and anti-aliased edges are not - // attenuated twice), at 0 % the shape shows the bare backdrop. - knock_out_exterior(&mut m, &shape, layer.fill_opacity); + // The layer hides the shadow beneath it where its fill is see-through (at 100 % + // fill in a normal mode it covers it anyway; at 0 % the shape shows the bare + // backdrop), and wholly in a mode that blends with it ([`shadow_see_through`]). + knock_out_exterior(&mut m, &shape, 1.0 - shadow_see_through(layer)); } paint_color(&mut work, &m, rgb(&s.color), s.common.blend, s.common.opacity); } @@ -2279,6 +2337,18 @@ pub fn distance_field(kind: FieldKind, alpha: Vec, w: usize, h: usize, reac } } +/// Both chiselled bevel fields of `alpha`, `[BevelInside, BevelOutside]`: each is its +/// [`distance_field`], with the sharp edges they both start from found once. +pub fn bevel_distance_fields(alpha: Vec, w: usize, h: usize, reach: f32) -> [Vec; 2] { + bevel_dists(&Map { w, h, v: alpha }, reach) +} + +/// The inside and outside bevel distances of `s`, from one set of sharp edges. +fn bevel_dists(s: &Map, reach: f32) -> [Vec; 2] { + let sharp = sharp_edges(s); + [bevel_dist_inside(s, &sharp, reach), bevel_dist_outside(s, &sharp, reach)] +} + #[cfg(test)] mod tests { use super::*; @@ -2921,6 +2991,55 @@ mod tests { } } + #[test] + fn bevel_maps_do_not_depend_on_the_threads() { + // Every style and technique, softened or not, and a texture; one thread (in order) is the reference. + let mut patchy = aa_disc(101, 101, 40.0); + for (i, v) in patchy.v.iter_mut().enumerate() { + if (i % 101 / 13 + i / 101 / 17) % 5 == 0 { + *v *= 0.6; + } + } + let shapes = [aa_disc(97, 83, 30.0), aa_round_rect(131, 77, 9.0, 11.0, 120.0, 66.0, 14.0), aa_bar(90, 130, 60.0, 150.0, 25.0), patchy]; + let mut sf = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8); + sf.fill_rect(Rect::new(0, 0, 4, 2), &[0.0, 0.0, 0.0, 1.0]); + sf.fill_rect(Rect::new(0, 2, 4, 4), &[1.0, 1.0, 1.0, 1.0]); + let pats = [Pattern::new("stripes", sf, 4, 4)]; + let light = GlobalLight::default(); + let styles = [BevelStyle::InnerBevel, BevelStyle::OuterBevel, BevelStyle::Emboss, BevelStyle::PillowEmboss, BevelStyle::StrokeEmboss]; + for (k, shape) in shapes.iter().enumerate() { + let tex = TextureCtx { rect: Rect::new(3, 5, 3 + shape.w as i32, 5 + shape.h as i32), patterns: &pats, anchor: (0.0, 0.0) }; + for style in styles { + for technique in [BevelTechnique::Smooth, BevelTechnique::ChiselHard, BevelTechnique::ChiselSoft] { + for (size, soften, textured) in [(5.0, 0.0, false), (12.0, 2.0, false), (8.0, 0.0, true)] { + let mut b = bevel_of(style, technique); + (b.size, b.soften) = (size, soften); + if textured { + b.texture = Some(photocraft_doc::BevelTexture { + name: "stripes".into(), + id: String::new(), + scale: 1.0, + depth: 1.0, + invert: false, + link: true, + phase: (0.0, 0.0), + }); + } + let run = || bevel_maps(shape, &b, &light, &tex, &PreparedPatterns::new(&pats, PREPARED_PATTERN_BYTES)).0; + let one = with_threads(1, run); + for threads in [2, 5, 16] { + let many = with_threads(threads, run); + assert_eq!(one.len(), many.len()); + for (j, (a, m)) in one.iter().zip(&many).enumerate() { + assert_eq!(bits(&a.v), bits(&m.v), "shape {k}, {style:?} {technique:?} size {size} map {j} on {threads} threads"); + } + } + } + } + } + } + } + /// The four fields that use the distance transform, of `m`. fn distance_field_of(m: &Map, reach: f32) -> Vec> { [FieldKind::Inside, FieldKind::Outside, FieldKind::BevelInside, FieldKind::BevelOutside] diff --git a/crates/compose/src/filter_mask.rs b/crates/compose/src/filter_mask.rs new file mode 100644 index 0000000000..b845720456 --- /dev/null +++ b/crates/compose/src/filter_mask.rs @@ -0,0 +1,191 @@ +//! A smart object's filter mask (#215): how the filtered pixels and the unfiltered (placed) ones +//! combine. +//! +//! Photoshop stores a smart object's layer pixels with its filters applied but not its filter +//! mask, and mixes the unfiltered pixels back in through the mask when it composites (photoshop +//! corpus `sf-filter-mask-hard.psd` and `sf-filter-mask-gradient.psd`: the stored pixels are +//! filtered everywhere, the merged image only where the mask shows the filter). Our smart objects +//! keep the mixed result as their pixels, so PSD import mixes ([`mix`]) and export unmixes +//! ([`unmix`]). + +use photocraft_doc::LayerMask; +use photocraft_raster::Surface; + +/// `filtered` mixed over `base` (the unfiltered pixels) through `mask`: premultiplied, any +/// channel layout. Pixels the mask hides entirely are `base`'s. +pub fn mix(base: &Surface, filtered: &Surface, mask: &LayerMask) -> Surface { + let fmt = base.format(); + let area = base.content_bounds().union(&filtered.content_bounds()); + let mut out = filtered.clone(); + if area.is_empty() || filtered.format() != fmt { + return out; + } + let n = fmt.channels(); + let (b, f) = (base.read_region(area), filtered.read_region(area)); + let mut k = Vec::new(); + mask.values_into(area, &mut k); + let mut o = vec![0.0f32; b.len()]; + for (i, ((bp, fp), op)) in b.chunks_exact(n).zip(f.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() { + let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0); + if !fmt.alpha { + for c in 0..n { + op[c] = bp[c] + (fp[c] - bp[c]) * m; + } + continue; + } + let a = n - 1; + let oa = bp[a] + (fp[a] - bp[a]) * m; + op[a] = oa; + for c in 0..a { + let pm = bp[c] * bp[a] + (fp[c] * fp[a] - bp[c] * bp[a]) * m; + op[c] = if oa > 0.0 { (pm / oa).clamp(0.0, 1.0) } else { 0.0 }; + } + } + out.write_region(area, &o); + out.prune(); + out +} + +/// The mask value below which a pixel's filtered value is taken to be unknown: [`unmix`] gives the +/// unfiltered value there, which the mask shows (almost) none of anyway. +const HIDDEN: f32 = 1.0 / 1024.0; + +/// The inverse of [`mix`]: the filtered pixels that mix with `base` through `mask` to `mixed` +/// (clamped to 0..1). Where the mask hides the filter entirely they are `base`'s, which Photoshop +/// doesn't show there. +pub fn unmix(base: &Surface, mixed: &Surface, mask: &LayerMask) -> Surface { + let fmt = base.format(); + let area = base.content_bounds().union(&mixed.content_bounds()); + let mut out = mixed.clone(); + if area.is_empty() || mixed.format() != fmt { + return out; + } + let n = fmt.channels(); + let (b, x) = (base.read_region(area), mixed.read_region(area)); + let mut k = Vec::new(); + mask.values_into(area, &mut k); + let mut o = vec![0.0f32; b.len()]; + for (i, ((bp, xp), op)) in b.chunks_exact(n).zip(x.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() { + let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0); + if m < HIDDEN { + // A transparent base pixel (Photoshop's cache keeps white there) stays empty, so the + // stored pixels don't grow past the content. + if !fmt.alpha || bp[n - 1] > 0.0 { + op.copy_from_slice(bp); + } + continue; + } + if !fmt.alpha { + for c in 0..n { + op[c] = (bp[c] + (xp[c] - bp[c]) / m).clamp(0.0, 1.0); + } + continue; + } + let a = n - 1; + let fa = (bp[a] + (xp[a] - bp[a]) / m).clamp(0.0, 1.0); + if fa <= 0.0 { + continue; + } + op[a] = fa; + for c in 0..a { + let pm = bp[c] * bp[a] + (xp[c] * xp[a] - bp[c] * bp[a]) / m; + op[c] = (pm / fa).clamp(0.0, 1.0); + } + } + out.write_region(area, &o); + out.prune(); + out +} + +#[cfg(test)] +mod tests { + use photocraft_color::{ColorMode, PixelFormat, SampleType}; + use photocraft_doc::LayerMask; + use photocraft_raster::Surface; + + use super::*; + + fn surface(fmt: PixelFormat, f: impl Fn(i32, i32) -> Vec) -> Surface { + let mut s = Surface::new(fmt); + for y in 0..4 { + for x in 0..8 { + s.write_pixel(x, y, &f(x, y)); + } + } + s + } + + /// A mask ramping from hidden (left) to shown (right). + fn ramp() -> LayerMask { + let mut m = LayerMask::reveal_all(); + m.surface = surface(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), |x, _| vec![x as f32 / 7.0]); + m + } + + #[test] + fn unmix_inverts_mix_wherever_the_mask_shows_the_filter() { + for fmt in [PixelFormat::new(ColorMode::Rgb, SampleType::F32, true), PixelFormat::new(ColorMode::Rgb, SampleType::F32, false)] { + let base = surface(fmt, |x, y| { + let mut p = vec![0.2 + 0.05 * x as f32, 0.6, 0.1 * y as f32]; + if fmt.alpha { + p.push(0.5 + 0.06 * x as f32); + } + p + }); + let filtered = surface(fmt, |_, y| { + let mut p = vec![0.9 - 0.1 * y as f32, 0.3, 0.5]; + if fmt.alpha { + p.push(0.8); + } + p + }); + let m = ramp(); + let mixed = mix(&base, &filtered, &m); + // Hidden column: the unfiltered pixels; shown column: the filtered ones. + assert!(mixed.pixel(0, 2).iter().zip(base.pixel(0, 2)).all(|(a, b)| (a - b).abs() < 1e-6)); + assert!(mixed.pixel(7, 2).iter().zip(filtered.pixel(7, 2)).all(|(a, b)| (a - b).abs() < 1e-6)); + let back = unmix(&base, &mixed, &m); + for y in 0..4 { + for x in 1..8 { + let (g, w) = (back.pixel(x, y), filtered.pixel(x, y)); + assert!(g.iter().zip(&w).all(|(a, b)| (a - b).abs() < 1e-4), "{fmt:?} ({x}, {y}): {g:?} vs {w:?}"); + } + // Where the mask hides the filter entirely its value is unknown: the base stands in. + assert_eq!(back.pixel(0, y), base.pixel(0, y)); + } + // So mixing the unmixed pixels gives the mixed ones back everywhere. + let again = mix(&base, &back, &m); + for y in 0..4 { + for x in 0..8 { + assert!(again.pixel(x, y).iter().zip(mixed.pixel(x, y)).all(|(a, b)| (a - b).abs() < 1e-4)); + } + } + } + } + + #[test] + fn transparent_base_pixels_stay_empty() { + // Photoshop's unfiltered cache carries white in transparent pixels; where the mask hides + // the filter those must not become content of the stored pixels. + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + let base = surface(fmt, |x, _| if x < 4 { vec![1.0, 1.0, 1.0, 0.0] } else { vec![0.3, 0.4, 0.5, 1.0] }); + let mut hidden = LayerMask::reveal_all(); + hidden.surface = Surface::with_default(PixelFormat::GRAY8, &[0.0]); + let mixed = mix(&base, &base, &hidden); + let back = unmix(&base, &mixed, &hidden); + assert_eq!(back.pixel(1, 1), vec![0.0; 4]); + assert_eq!(back.content_bounds().x0, 4); + } + + #[test] + fn mismatched_or_empty_surfaces_are_left_alone() { + let rgba = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, true); + let a = surface(rgba, |_, _| vec![0.5, 0.5, 0.5, 1.0]); + let b = surface(gray, |_, _| vec![0.1, 1.0]); + assert_eq!(mix(&a, &b, &ramp()), b); + assert_eq!(unmix(&a, &b, &ramp()), b); + let empty = Surface::new(rgba); + assert_eq!(unmix(&empty, &empty, &ramp()), empty); + } +} diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 8813b122d9..7c0ce9098e 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -21,10 +21,12 @@ pub mod advanced; pub mod bounds; pub mod effects; pub mod fill_layout; +pub mod filter_mask; pub mod gradient_fill; pub mod masks; pub mod multichannel; pub mod native; +pub mod native_lab; pub mod pattern; pub mod proxy; pub mod psblend; @@ -128,6 +130,14 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer if u64::from(rect.width()) * u64::from(rect.height()) > MAX_RENDER_PIXELS { return Buffer::transparent(Rect::EMPTY); } + // A CMYK stack with adjustment layers the inks carry exactly composites on its inks. + if let Some(buf) = native::render_cmyk(doc, rect, tile) { + return buf; + } + // A Lab stack with adjustment layers that work on L, a and b composites on its stored values. + if let Some(buf) = native_lab::render_lab(doc, rect, tile) { + return buf; + } cx.set_fx_clip(rect); if i64::from(rect.width()) <= i64::from(tile) && i64::from(rect.height()) <= i64::from(tile) { return photocraft_color::convert::with_cmyk_space(cmyk, || { @@ -260,7 +270,8 @@ pub fn band_rows_for(width: u32, requested: i32) -> i32 { /// otherwise count as content: the result would span the whole canvas. /// /// A CMYK document of plain Normal layers is composited in its ink values instead -/// ([`native::flatten_cmyk`]), so flattening doesn't round-trip its pixels through RGB (#3778). +/// ([`native::flatten_cmyk`]), so flattening doesn't round-trip its pixels through RGB (#3778), +/// and a Lab one in its L, a and b ([`native_lab::flatten_lab`]). pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface { composite_to_surface(doc, doc.bounds(), fmt, background) } @@ -273,6 +284,11 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P { return s; } + if (background.is_none() || paper) + && let Some(s) = native_lab::flatten_lab(doc, rect, fmt, paper) + { + return s; + } let mut s = Surface::new(fmt); let n = fmt.channels(); let empty = s.default_pixel(); @@ -326,12 +342,38 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer { fx_maps: Default::default(), fx_clip: Default::default(), doc: None, + layer_keys: Default::default(), }, advanced::Scope::ROOT, ); buf } +/// [`render_layer`] in `doc`'s context: the layer alone over `rect`, with the document's patterns, +/// Global Light, colour mode, depth and canvas (fill layers and gradients are laid out on it), as +/// the canvas composites it. Bakes (Scripts › Flatten All Layer Effects) render through this so +/// the pixels match what was on screen (#5719). Rects past [`MAX_RENDER_PIXELS`] give an empty +/// buffer. +pub fn render_layer_in(doc: &Document, layer: &Layer, rect: Rect) -> Buffer { + if rect.is_empty() { + return Buffer::transparent(rect); + } + if u64::from(rect.width()) * u64::from(rect.height()) > MAX_RENDER_PIXELS { + return Buffer::transparent(Rect::EMPTY); + } + let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode); + // A lone layer: a deep knockout reveals transparency, not the document's Background. + let cx = Ctx { doc: None, ..Ctx::for_doc(doc, &patterns) }; + let lab = doc.mode == photocraft_color::ColorMode::Lab; + let hdr = doc.depth == photocraft_color::SampleType::F32; + let cmyk = cmyk_space(doc); + photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || { + let mut buf = Buffer::transparent(rect); + in_blend_space(lab, hdr, || composite_stack_at(std::slice::from_ref(layer), &mut buf, &cx, advanced::Scope::ROOT)); + buf + }) +} + /// Documents above this many pixels get thumbnails from a proxy (see [`proxy`]) when that is /// faithful; smaller ones are reduced from the exact composite. pub const PROXY_THUMBNAIL_PIXELS: u64 = 16 << 20; @@ -523,6 +565,60 @@ struct Ctx<'a> { fx_clip: std::sync::Mutex>, /// The document being rendered (deep knockouts reveal its Background); `None` for a lone layer. doc: Option<&'a Document>, + /// Effect-map identities of this render's document layers (see [`LayerKeys`]). + layer_keys: LayerKeys, +} + +/// Memo of [`layer_identity`] for the layers of the document being rendered. The identity depends +/// on the layer alone, not on the tile, so it is computed once per layer per render instead of +/// once per tile. Only layers that sit in the document tree are memoised by address: the document +/// is borrowed for the whole render, so no other object can live at such an address. Temporary +/// copies (`advanced::effects_source`) are never in the tree and always take the full path. +#[derive(Default)] +struct LayerKeys { + /// Addresses of every layer in the document tree, collected on first use. + in_doc: std::sync::OnceLock>, + ids: std::sync::Mutex>, + /// Identities computed (tests count them). + #[cfg(test)] + built: std::sync::atomic::AtomicUsize, +} + +impl LayerKeys { + fn identity(&self, layer: &Layer, doc: Option<&Document>) -> u64 { + let addr = std::ptr::from_ref(layer) as usize; + let in_doc = doc.is_some_and(|d| self.in_doc.get_or_init(|| doc_layer_addresses(d)).contains(&addr)); + if in_doc { + let mut ids = self.ids.lock().unwrap_or_else(|e| e.into_inner()); + // Computing an identity only hashes immutable layer data; it does not run Rayon + // work or re-enter this memo. Keep the miss and insertion atomic across callers. + *ids.entry(addr).or_insert_with(|| self.compute(layer)) + } else { + self.compute(layer) + } + } + + fn compute(&self, layer: &Layer) -> u64 { + use std::hash::Hasher; + #[cfg(test)] + self.built.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut h = std::collections::hash_map::DefaultHasher::new(); + layer_identity(layer, &mut h, &mut |_| {}); + h.finish() + } +} + +/// Addresses of all layers of `doc` (groups' children included). +fn doc_layer_addresses(doc: &Document) -> std::collections::HashSet { + let mut out = std::collections::HashSet::new(); + let mut todo: Vec<&Layer> = doc.layers.iter().collect(); + while let Some(l) = todo.pop() { + out.insert(std::ptr::from_ref(l) as usize); + if let LayerContent::Group(g) = &l.content { + todo.extend(g.children.iter()); + } + } + out } impl<'a> Ctx<'a> { @@ -538,6 +634,7 @@ impl<'a> Ctx<'a> { vector_masks: RenderVectorMasks::default(), fx_maps: Default::default(), fx_clip: Default::default(), + layer_keys: Default::default(), } } @@ -793,6 +890,38 @@ pub fn paint_bounds(layer: &Layer) -> Option { (!r.is_empty()).then_some(r) } +/// The frame a layer's effects are laid out in: [`paint_bounds`] for a shape, else the layer's +/// bounds ([`layer_bounds`]). A Gradient Overlay spans it, linked patterns tile from its corner. +pub fn effects_frame(layer: &Layer, canvas: Rect) -> Rect { + paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, canvas)) +} + +/// The pixels a change from `a` to `b` repaints beyond those it changed: where a layer whose +/// effects span its frame ([`effects::spans_frame`]) moved that frame, the layer's whole composite +/// before and after. Painting on a layer with a Gradient Overlay lays the one gradient out again +/// over the grown layer, as Photoshop does after each stroke; damage that covers only the stroke +/// leaves the old gradient around earlier strokes (#4053). Empty when no such frame moved. +pub fn relayout_damage(a: &Document, b: &Document) -> Rect { + let mut out = Rect::EMPTY; + let mut add = |r: Rect| { + if !r.is_empty() { + out = if out.is_empty() { r } else { out.union(&r) }; + } + }; + for (_, _, y) in b.walk() { + if !y.visible || !effects::spans_frame(y) { + continue; + } + let Some(x) = a.layer(y.id) else { continue }; + if effects_frame(x, a.bounds()) == effects_frame(y, b.bounds()) { + continue; + } + add(change_bounds(x, a.bounds()).unwrap_or_else(|| a.bounds())); + add(change_bounds(y, b.bounds()).unwrap_or_else(|| b.bounds())); + } + out +} + /// The bounding box of a shape layer's vector content: its path control bounds plus stroke width. pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { if sh.fill.is_none() && sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.opacity <= 0.0) { @@ -1290,11 +1419,17 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer // Pass-through groups composite their children straight into the backdrop. Below 100% fill // Photoshop renders the group isolated, like Normal (psd-tools passthrough_fill_*: an - // adjustment inside no longer reaches the layers beneath). + // adjustment inside no longer reaches the layers beneath), and so it does for a group that + // is the base of a clipping group and holds an adjustment layer (psd-tools + // passthrough_clipping_mask_adjustment: an Exposure inside leaves the layers beneath alone), + // while such a group's blend modes still reach the backdrop (passthrough_clipping_mask_blendmode). + let clip_base_adjusts = clipped.iter().any(|c| c.visible) + && matches!(&layer.content, LayerContent::Group(g) if g.children.iter().any(|c| c.visible && matches!(c.content, LayerContent::Adjustment(_)))); if let LayerContent::Group(g) = &layer.content && layer.blend == BlendMode::PassThrough && layer.fill_opacity >= 1.0 && !effects::has_effects(layer) + && !clip_base_adjusts { let needs_mix = opacity < 1.0 || layer.mask.is_some() || layer.vector_mask.is_some(); let has_clipped = clipped.iter().any(|c| c.visible); @@ -1411,15 +1546,7 @@ fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buff advanced::composite_clipped(clipped, &mut content, cx); let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); let maps = effect_maps(layer, rect, cx); - effects::composite_with_effects_prepared( - layer, - &content, - backdrop, - &maps, - paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)), - cx.patterns, - vstroke, - ); + effects::composite_with_effects_prepared(layer, &content, backdrop, &maps, effects_frame(layer, cx.canvas), cx.patterns, vstroke); } /// The coverage-mixing gamma of a layer: the text blending gamma @@ -1454,11 +1581,12 @@ fn split_parts(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, O /// Order-independent identity of a layer's pixels, masks, effects, Advanced Blending and Blend If /// and (for groups) children. -/// Pixels are identified by their copy-on-write tile pointers; cache entries pin a clone of the -/// layer so those tiles (and their addresses) stay alive while the entry exists. -fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHasher) { +/// Pixels are identified by their copy-on-write tile pointers. `pin` sees every tile hashed: a +/// cache entry keeps a [`std::sync::Weak`] to each, which reserves the tile's address (so no +/// other tile can reuse it and match the key) without keeping its pixels alive (#4530). +fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHasher, pin: &mut dyn FnMut(&std::sync::Arc)) { use std::hash::{Hash, Hasher}; - fn surface_fp(s: &Surface) -> u64 { + fn surface_fp(s: &Surface, pin: &mut dyn FnMut(&std::sync::Arc)) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); s.format().hash(&mut h); // Missing tiles read the default pixel, so tile identity alone is not pixel identity. @@ -1466,6 +1594,7 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash sample.to_bits().hash(&mut h); } let tiles = s.tiles().fold(s.tile_count() as u64, |acc, (c, t)| { + pin(t); let mut x = (std::sync::Arc::as_ptr(t) as usize as u64) ^ ((c.tx as u64) << 40) ^ ((c.ty as u32 as u64) << 8); x = x.wrapping_mul(0x9e37_79b9_7f4a_7c15); acc.wrapping_add(x ^ (x >> 29)) @@ -1486,7 +1615,7 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash // composites (unlike the group's own visibility and opacity) shapes them. (c.visible, c.opacity.to_bits(), c.clipped).hash(h); format!("{:?}", c.blend).hash(h); - layer_identity(c, h); + layer_identity(c, h, pin); } format!("{:?}", g.artboard).hash(h); } @@ -1498,14 +1627,14 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash if let Some(c) = &layer.fill_cache && c.fill == *f { - surface_fp(&c.surface).hash(h); + surface_fp(&c.surface, pin).hash(h); } } LayerContent::Adjustment(a) => format!("{a:?}").hash(h), - _ => layer.surface().map_or(0, surface_fp).hash(h), + _ => layer.surface().map_or(0, |s| surface_fp(s, pin)).hash(h), } if let Some(m) = &layer.mask { - (surface_fp(&m.surface), m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(h); + (surface_fp(&m.surface, pin), m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(h); } if let Some(vm) = &layer.vector_mask { format!("{vm:?}").hash(h); @@ -1520,7 +1649,8 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash struct FxEntry { maps: std::sync::Arc, - _pin: Layer, + /// The tiles the key identifies (see [`layer_identity`]): their addresses, not their pixels. + _pin: Vec>, bytes: usize, } @@ -1603,7 +1733,7 @@ fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc std::s None => { let shape: Vec = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) }; let maps = effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns); - let bytes = maps.bytes(); + let mut pin = Vec::new(); + layer_identity(layer, &mut std::collections::hash_map::DefaultHasher::new(), &mut |t| pin.push(std::sync::Arc::downgrade(t))); + // The maps, plus what each reserved tile address keeps allocated. + let bytes = + maps.bytes().saturating_add(pin.len().saturating_mul(std::mem::size_of::() + 2 * std::mem::size_of::())); let maps = std::sync::Arc::new(maps); - if slot.set(FxEntry { maps: maps.clone(), _pin: layer.clone(), bytes }).is_ok() { + if slot.set(FxEntry { maps: maps.clone(), _pin: pin, bytes }).is_ok() { // Counted exactly once, by the build that filled the slot, and only while the // slot is still cached (eviction may have dropped it meanwhile). let mut c = fx_cache().lock().unwrap_or_else(|e| e.into_inner()); @@ -1670,7 +1804,7 @@ fn cached_effect_maps(layer: &Layer, region: Rect, key: u64, cx: &Ctx) -> std::s } fn texture_ctx<'a>(layer: &Layer, region: Rect, cx: &Ctx<'a>) -> effects::TextureCtx<'a> { - let sb = paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)); + let sb = effects_frame(layer, cx.canvas); effects::TextureCtx { rect: region, patterns: cx.patterns.source(), anchor: layer.effects.reference.unwrap_or((f64::from(sb.x0), f64::from(sb.y0))) } } @@ -1725,15 +1859,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); let mut opaque = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() }; let maps = effect_maps(layer, rect, cx); - effects::composite_with_effects_prepared( - layer, - &content, - &mut opaque, - &maps, - paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)), - cx.patterns, - vstroke, - ); + effects::composite_with_effects_prepared(layer, &content, &mut opaque, &maps, effects_frame(layer, cx.canvas), cx.patterns, vstroke); if let Some(m) = &hide { let plain = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() }; advanced::apply_hiding_mask(&mut opaque, &plain, m); diff --git a/crates/compose/src/masks.rs b/crates/compose/src/masks.rs index d70947ec7c..8323e1b939 100644 --- a/crates/compose/src/masks.rs +++ b/crates/compose/src/masks.rs @@ -171,9 +171,10 @@ fn key(layer: &Layer, canvas: Rect) -> u64 { h.finish() } -/// Gaussian sigma of a mask feather of `px` pixels (Properties › Feather). Fitted on the -/// psd-tools corpus (layer_mask_data.psd, mask-parameters-no-real-channel.psd: sigma = feather -/// beats 0.33, 0.5, 0.7, 1.3 and 1.6 times it). +/// Gaussian sigma of a mask feather of `px` pixels (Properties › Feather): the feather itself. +/// Blurred with exactly that variance, psd-tools mask-parameters-no-real-channel.psd (6 px vector +/// and 3 px pixel feather) matches Photoshop within 1.5 levels and layer_mask_data.psd's +/// feathered edges are 27 % → 4 % off (the rest are its shapes' antialiased edges). pub fn feather_sigma(px: f32) -> f32 { if px.is_finite() { (px * FEATHER_SIGMA).clamp(0.0, MAX_FEATHER_SIGMA) } else { 0.0 } } @@ -188,37 +189,73 @@ pub fn has_feather(layer: &Layer) -> bool { || layer.vector_mask.as_ref().is_some_and(|v| v.enabled && feather_sigma(v.feather) > 0.0) } +/// One box pass: weight 1 on `±r` around each sample and `end` (0..1) on the two samples just +/// beyond, so the pass's variance is any real number, not only the `r(r+1)/3` of whole boxes. +#[derive(Clone, Copy, Debug, PartialEq)] +struct BoxPass { + r: usize, + end: f32, +} + +impl BoxPass { + /// The pass whose three-fold repetition has variance `sigma²` (an "extended box", Gwosdek et + /// al. 2011), or `None` when there is nothing to blur. + fn for_sigma(sigma: f32) -> Option { + if !sigma.is_finite() || sigma <= 0.0 { + return None; + } + // Feathers top out at 1000 px; the cap keeps the radius arithmetic far from overflow. + let v = f64::from(sigma.min(MAX_FEATHER_SIGMA)).powi(2) / 3.0; + // The largest whole radius whose box variance r(r+1)/3 is at most v. + let mut r = (((12.0 * v + 1.0).sqrt() - 1.0) / 2.0).floor().max(0.0) as usize; + let var = |r: usize| (r as f64) * (r as f64 + 1.0) / 3.0; + while r > 0 && var(r) > v { + r -= 1; + } + while var(r + 1) <= v { + r += 1; + } + let (rf, n) = (r as f64, 2.0 * r as f64 + 1.0); + let end = (n * (rf * (rf + 1.0) / 3.0 - v)) / (2.0 * v - 2.0 * (rf + 1.0) * (rf + 1.0)); + Some(BoxPass { r, end: end.clamp(0.0, 1.0) as f32 }) + } +} + /// Apply one of the three box passes to a single line. `scratch` preserves the input while /// the sliding sum writes the output, matching the previous sequential implementation exactly. -fn blur_line(line: &mut [f32], scratch: &mut [f32], r: usize) { +fn blur_line(line: &mut [f32], scratch: &mut [f32], pass: BoxPass) { if line.is_empty() { return; } - let Ok(radius) = isize::try_from(r) else { return }; + let Ok(radius) = isize::try_from(pass.r) else { return }; scratch.copy_from_slice(line); let get = |i: isize| scratch[i.clamp(0, scratch.len() as isize - 1) as usize]; let mut acc: f32 = (-radius..=radius).map(get).sum(); - let n = (2 * r + 1) as f32; + let n = (2 * pass.r + 1) as f32 + 2.0 * pass.end; + // The sample leaving the window is the next one's low end weight. + let mut below = get(-radius - 1); for (i, dst) in line.iter_mut().enumerate() { - *dst = acc / n; let i = i as isize; - acc += get(i + radius + 1) - get(i - radius); + let (above, leaving) = (get(i + radius + 1), get(i - radius)); + *dst = (acc + pass.end * (below + above)) / n; + acc += above - leaving; + below = leaving; } } /// Apply three clamped box passes to independent contiguous rows. Scoped OS threads are used /// instead of Rayon because mask construction can happen inside a Rayon tile job; nested Rayon /// work here can deadlock when every worker waits on the same OnceLock (#276). -fn blur_rows_chunk(v: &mut [f32], w: usize, r: usize) { +fn blur_rows_chunk(v: &mut [f32], w: usize, pass: BoxPass) { let mut scratch = vec![0.0; w]; for row in v.chunks_mut(w) { for _ in 0..3 { - blur_line(row, &mut scratch, r); + blur_line(row, &mut scratch, pass); } } } -fn blur_rows(v: &mut [f32], w: usize, r: usize) { +fn blur_rows(v: &mut [f32], w: usize, pass: BoxPass) { if w == 0 || v.is_empty() { return; } @@ -229,14 +266,14 @@ fn blur_rows(v: &mut [f32], w: usize, r: usize) { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16).min(h.max(1)) }; if threads == 1 { - blur_rows_chunk(v, w, r); + blur_rows_chunk(v, w, pass); return; } let rows_per_worker = h.div_ceil(threads); let chunk_len = rows_per_worker.saturating_mul(w); std::thread::scope(|scope| { for chunk in v.chunks_mut(chunk_len) { - scope.spawn(move || blur_rows_chunk(chunk, w, r)); + scope.spawn(move || blur_rows_chunk(chunk, w, pass)); } }); } @@ -256,35 +293,34 @@ fn transpose_into(v: &[f32], out: &mut [f32], w: usize, h: usize) { } } -/// Approximate Gaussian blur of a `w`×`h` plane: three box passes per axis (edges clamp, which -/// is exact here since the planes extend into constant mask regions). The axes are processed in -/// parallel on bounded OS threads, preserving each line's floating-point operation order. +/// Approximate Gaussian blur of a `w`×`h` plane: three box passes per axis whose variance is +/// exactly `sigma²` ([`BoxPass`]; edges clamp, which is exact here since the planes extend into +/// constant mask regions). Whole-pixel boxes rounded the variance (a 6 px feather blurred like +/// 6.48 px, a 3 px one like 3.46) and missed Photoshop's feathered edges by up to 4 levels +/// (psd-tools mask-parameters-no-real-channel.psd); the exact variance is within 1.5. The axes +/// are processed in parallel on bounded OS threads, preserving each line's floating-point +/// operation order. fn gaussian(v: &mut [f32], w: usize, h: usize, sigma: f32) { - if sigma <= 0.0 || w == 0 || h == 0 || v.len() != w.saturating_mul(h) { - return; - } - // Box widths whose three-pass variance matches sigma² (Wells 1986 / Kovesi). - let ideal = (12.0 * sigma * sigma / 3.0 + 1.0).sqrt(); - let r = (((ideal.floor() as usize) | 1).max(1) - 1) / 2; - if r == 0 { + if w == 0 || h == 0 || v.len() != w.saturating_mul(h) { return; } + let Some(pass) = BoxPass::for_sigma(sigma) else { return }; // Reserve before modifying the input so allocation failure can use the sequential fallback // without leaving a half-blurred mask. let mut transposed = Vec::new(); if transposed.try_reserve_exact(v.len()).is_err() { - gaussian_sequential(v, w, h, r); + gaussian_sequential(v, w, h, pass); return; } transposed.resize(v.len(), 0.0); - blur_rows(v, w, r); + blur_rows(v, w, pass); transpose_into(v, &mut transposed, w, h); - blur_rows(&mut transposed, h, r); + blur_rows(&mut transposed, h, pass); transpose_into(&transposed, v, h, w); } /// Allocation-light reference path used if parallel transpose storage cannot be reserved. -fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, r: usize) { +fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, pass: BoxPass) { let mut line = Vec::new(); let mut scratch = Vec::new(); let len = w.max(h); @@ -300,7 +336,7 @@ fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, r: usize) { line[i] = if axis { v[i * w + k] } else { v[k * w + i] }; } for _ in 0..3 { - blur_line(&mut line[..line_len], &mut scratch[..line_len], r); + blur_line(&mut line[..line_len], &mut scratch[..line_len], pass); } for i in 0..line_len { if axis { @@ -693,14 +729,55 @@ mod tests { } } + /// Three passes have the variance sigma² exactly, for whole and fractional sigmas alike (whole + /// boxes rounded a 6 px feather to 6.48 and a 3 px one to 3.46). + #[test] + fn feather_blur_has_the_exact_variance() { + for sigma in [0.3f32, 1.0, 2.6, 3.0, 6.0, 13.7, 40.0] { + let (w, c) = (801usize, 400usize); + let mut v = vec![0.0f32; w]; + v[c] = 1.0; + gaussian(&mut v, w, 1, sigma); + // Within the kernel's reach (the running sums leave ~1e-8 behind further out). + let reach = (8.0 * sigma) as usize + 4; + let var: f64 = v.iter().enumerate().skip(c - reach).take(2 * reach + 1).map(|(i, &x)| f64::from(x) * ((i as f64) - c as f64).powi(2)).sum(); + assert!((var.sqrt() - f64::from(sigma)).abs() < 1e-3 * f64::from(sigma).max(1.0), "sigma {sigma}: {}", var.sqrt()); + assert!((v.iter().sum::() - 1.0).abs() < 1e-4, "sigma {sigma} keeps the total"); + } + assert_eq!(BoxPass::for_sigma(0.0), None); + assert_eq!(BoxPass::for_sigma(f32::NAN), None); + assert!(BoxPass::for_sigma(f32::MAX).is_some_and(|p| p.r < 2000 && (0.0..1.0).contains(&p.end))); + } + + /// A feathered edge follows the Gaussian Photoshop draws with sigma = feather: psd-tools + /// mask-parameters-no-real-channel.psd (a 6 px vector and 3 px pixel feather) matches it + /// within 1.5 levels, where whole-pixel boxes were 4 off. + #[test] + fn a_feathered_edge_is_a_gaussian_step() { + // erf, Abramowitz & Stegun 7.1.26 (1.5e-7). + let erf = |x: f64| { + let t = 1.0 / (1.0 + 0.327_591_1 * x.abs()); + let y = 1.0 - (((((1.061_405_429 * t - 1.453_152_027) * t) + 1.421_413_741) * t - 0.284_496_736) * t + 0.254_829_592) * t * (-x * x).exp(); + y.copysign(x) + }; + let (w, edge, sigma) = (240usize, 120usize, 6.0f32); + let mut v: Vec = (0..w).map(|i| if i >= edge { 1.0 } else { 0.0 }).collect(); + gaussian(&mut v, w, 1, sigma); + for (i, &got) in v.iter().enumerate().skip(edge - 24).take(48) { + let d = (i as f64 - (edge as f64 - 0.5)) / f64::from(sigma); + let want = 0.5 * (1.0 + erf(d / std::f64::consts::SQRT_2)); + // Three boxes are not quite a Gaussian: within 2.5 levels (whole boxes: 4.6). + assert!((f64::from(got) - want).abs() < 0.01, "{i}: {got} vs Gaussian {want}"); + } + } + #[test] fn parallel_gaussian_matches_the_sequential_reference() { let (w, h, sigma): (usize, usize, f32) = (320, 256, 13.0); let mut parallel: Vec<_> = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect(); let mut sequential = parallel.clone(); - let ideal = (12.0 * sigma * sigma / 3.0 + 1.0).sqrt(); - let r = (((ideal.floor() as usize) | 1).max(1) - 1) / 2; - gaussian_sequential(&mut sequential, w, h, r); + let pass = BoxPass::for_sigma(sigma).unwrap(); + gaussian_sequential(&mut sequential, w, h, pass); gaussian(&mut parallel, w, h, sigma); assert_eq!(parallel, sequential, "parallel blur must retain the exact per-line arithmetic"); } @@ -711,12 +788,11 @@ mod tests { use std::time::Instant; let (w, h, sigma): (usize, usize, f32) = (6336, 7920, 1000.0); - let ideal = (12.0 * sigma * sigma / 3.0 + 1.0).sqrt(); - let r = (((ideal.floor() as usize) | 1).max(1) - 1) / 2; + let pass = BoxPass::for_sigma(sigma).unwrap(); let source: Vec<_> = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect(); let mut sequential = source.clone(); let start = Instant::now(); - gaussian_sequential(&mut sequential, w, h, r); + gaussian_sequential(&mut sequential, w, h, pass); let sequential_time = start.elapsed(); let mut parallel = source; let start = Instant::now(); diff --git a/crates/compose/src/native.rs b/crates/compose/src/native.rs index 9eafd18cc6..31e17f3ea2 100644 --- a/crates/compose/src/native.rs +++ b/crates/compose/src/native.rs @@ -1,85 +1,255 @@ -//! Mode-native flattening of plain CMYK layer stacks (#3778). +//! Mode-native compositing of plain CMYK layer stacks (#3778). //! //! The compositor works in display RGB, so flattening a CMYK document through it reads every ink //! value through the profile and separates it again: even a lone Background comes back with //! different ink. When every visible layer is a plain raster layer in Normal mode (no masks, //! effects, clipping or advanced blending), the composite needs no colour conversion at all: //! Normal mixes ink values linearly, as Photoshop does in CMYK, so it is worked out on the stored -//! values and an opaque layer keeps them exactly. +//! values and an opaque layer keeps them exactly. A layer mask (unfeathered) scales the mix, as +//! it does there. +//! +//! Plain adjustment layers that Photoshop evaluates on the inks (Invert, Posterize, Threshold, +//! Brightness/Contrast, Hue/Saturation, Color Balance, and Levels and Curves on CMYK channels) +//! join such a stack too: on the +//! stored inks their rules are exact, where the RGB compositor can only apply them to a separation +//! of its RGB composite, whose black differs from the file's. Such stacks are also rendered for +//! display this way ([`render_cmyk`]); stacks of raster layers alone keep the RGB compositor there. use photocraft_color::blend::BlendMode; -use photocraft_color::{ColorMode, PixelFormat}; -use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::adjust::ToneSpace; +use photocraft_doc::{Adjustment, Document, Layer, LayerContent, LayerMask}; use photocraft_geom::Rect; use photocraft_raster::Surface; +use crate::{Buffer, adjust}; + /// Process channels of a CMYK pixel. const INKS: usize = 4; -/// A layer the native path can composite: a visible raster layer the compositor would only blend -/// Normally at its opacity. -fn plain(layer: &Layer) -> Option<&Surface> { - let LayerContent::Raster(s) = &layer.content else { return None }; - let simple = layer.blend == BlendMode::Normal +/// Whether the compositor would only blend `layer` Normally at its opacity through its layer mask: +/// no clipping, vector mask, feathered mask, effects, excluded channels, Blend If or advanced +/// blending. +fn simple(layer: &Layer) -> bool { + layer.blend == BlendMode::Normal && !layer.clipped - && layer.mask.as_ref().is_none_or(|m| !m.enabled) + && !crate::masks::has_feather(layer) && layer.vector_mask.as_ref().is_none_or(|v| !v.enabled) && !crate::effects::has_effects(layer) && layer.excluded_channels == 0 && layer.blend_if.is_default() && layer.advanced.is_default() && layer.video.is_none() - && s.format().mode == ColorMode::Cmyk; - simple.then_some(s) +} + +/// A layer the native path can composite: a visible raster layer the compositor would only blend +/// Normally at its opacity. +fn plain(layer: &Layer) -> Option<&Surface> { + let LayerContent::Raster(s) = &layer.content else { return None }; + (simple(layer) && s.format().mode == ColorMode::Cmyk).then_some(s) +} + +/// An adjustment on straight inks, prepared once per composite. +enum InkOp<'a> { + /// Each ink's stored value (1 - ink) through its channel's table: C, M, Y, K + /// (`adjust::ink_through`; Levels, Curves and Brightness/Contrast). + Tables([Vec; INKS]), + /// Invert, Posterize or Threshold (`adjust::stored_channels`). + Rules(&'a Adjustment), + /// Hue/Saturation or Color Balance on C, M and Y as inverted RGB (`adjust::apply_cmy`). + Cmy(&'a Adjustment), +} + +impl InkOp<'_> { + /// The adjusted inks of each pixel of `inks` that has coverage (its fifth value). + fn apply(&self, inks: &[[f32; INKS + 1]], depth: SampleType) -> Vec<[f32; INKS]> { + let mut out: Vec<[f32; INKS]> = inks.iter().map(|d| [d[0], d[1], d[2], d[3]]).collect(); + match self { + InkOp::Tables(t) => { + for ink in &mut out { + for (v, table) in ink.iter_mut().zip(t) { + *v = adjust::ink_through(table, *v); + } + } + } + InkOp::Rules(adj) => out.iter_mut().for_each(|ink| adjust::stored_channels(adj, ColorMode::Cmyk, Some(depth), ink)), + InkOp::Cmy(adj) => adjust::apply_cmy(adj, &mut out), + } + out + } +} + +/// The ink evaluation of an adjustment layer the native path can composite. +fn ink_op(layer: &Layer, depth: SampleType) -> Option> { + let LayerContent::Adjustment(adj) = &layer.content else { return None }; + if !simple(layer) { + return None; + } + match adj { + Adjustment::Invert | Adjustment::Posterize { .. } | Adjustment::Threshold { .. } => Some(InkOp::Rules(adj)), + _ if adjust::cmy_adjustment(adj) => Some(InkOp::Cmy(adj)), + Adjustment::BrightnessContrast { brightness, contrast, legacy } => { + let t = adjust::ink_brightness_contrast_lut(*brightness, *contrast, *legacy); + Some(InkOp::Tables([t.clone(), t.clone(), t.clone(), t])) + } + Adjustment::Levels { space: ToneSpace::Cmyk, .. } | Adjustment::Curves { space: ToneSpace::Cmyk, .. } => { + Some(InkOp::Tables(adjust::tone_luts_depth(adj, Some(depth)))) + } + _ => None, + } +} + +/// One step of a native stack, at the layer's opacity × fill, through its mask. +enum Item<'a> { + Layer(&'a Surface, f32, Option<&'a LayerMask>), + Adjust(InkOp<'a>, f32, Option<&'a LayerMask>), +} + +/// Groups nested deeper than this keep the RGB compositor. +const MAX_DEPTH: usize = 64; + +/// `layers`' visible steps, appended to `items`; `None` when one needs the RGB compositor. A group +/// at full opacity without a mask is inlined: pass-through groups composite their children straight +/// onto the backdrop, and so do isolated (Normal) groups of raster layers alone, since Normal +/// composites associatively; an adjustment inside an isolated group would only see the group's +/// own content, so such a group keeps the RGB compositor (`adjustments` false). +fn collect<'a>(layers: &'a [Layer], depth: SampleType, adjustments: bool, items: &mut Vec>, level: usize) -> Option<()> { + if level > MAX_DEPTH { + return None; + } + for layer in layers.iter().filter(|l| l.visible) { + let alpha = (layer.opacity * layer.fill_opacity).clamp(0.0, 1.0); + let mask = layer.mask.as_ref().filter(|m| m.enabled); + if let LayerContent::Group(g) = &layer.content { + let open = g.artboard.is_none() + && alpha >= 1.0 + && mask.is_none() + && !layer.clipped + && layer.vector_mask.as_ref().is_none_or(|v| !v.enabled) + && !crate::effects::has_effects(layer) + && layer.excluded_channels == 0 + && layer.blend_if.is_default() + && layer.advanced.is_default(); + match layer.blend { + BlendMode::PassThrough if open => collect(&g.children, depth, adjustments, items, level + 1)?, + BlendMode::Normal if open => collect(&g.children, depth, false, items, level + 1)?, + _ => return None, + } + continue; + } + let item = match plain(layer) { + Some(s) => Item::Layer(s, alpha, mask), + None if adjustments => Item::Adjust(ink_op(layer, depth)?, alpha, mask), + None => return None, + }; + items.push(item); + } + Some(()) +} + +/// A CMYK document's visible layers, prepared for compositing on their ink values. +pub struct InkStack<'a> { + items: Vec>, + depth: SampleType, +} + +impl<'a> InkStack<'a> { + /// `None` unless `doc` is CMYK and every visible layer is a plain raster layer, a plain + /// adjustment layer the inks carry exactly, or a group that can be inlined (see the module + /// docs and [`collect`]). + pub fn new(doc: &'a Document) -> Option { + if doc.mode != ColorMode::Cmyk { + return None; + } + let mut items = Vec::new(); + collect(&doc.layers, doc.depth, true, &mut items, 0)?; + Some(InkStack { items, depth: doc.depth }) + } + + /// Whether the stack has an adjustment layer (the RGB compositor can't match it exactly). + pub fn adjusts(&self) -> bool { + self.items.iter().any(|i| matches!(i, Item::Adjust(..))) + } + + /// Straight C, M, Y, K and alpha over `rect`, row-major, into `acc` (`src` and `mv` are + /// scratch). + fn composite(&self, rect: Rect, acc: &mut Vec<[f32; INKS + 1]>, src: &mut Vec, mv: &mut Vec) { + let n = rect.width() as usize * rect.height() as usize; + acc.clear(); + acc.resize(n, [0.0f32; INKS + 1]); + // The mask's value at pixel `i` (1 without one). + let masked = |mask: Option<&LayerMask>, mv: &mut Vec| match mask { + Some(m) => m.values_into(rect, mv), + None => { + mv.clear(); + mv.resize(n, 1.0); + } + }; + for item in &self.items { + match item { + Item::Layer(surf, opacity, mask) => { + let opacity = *opacity; + if opacity <= 0.0 || (surf.content_bounds().intersect(&rect).is_empty() && surf.default_pixel().iter().all(|v| *v == 0.0)) { + continue; + } + let sf = surf.format(); + let sn = sf.channels(); + surf.read_region_into(rect, src); + masked(*mask, mv); + for ((d, s), k) in acc.iter_mut().zip(src.chunks_exact(sn)).zip(mv.iter()) { + let sa = if sf.alpha { s.get(INKS).copied().unwrap_or(1.0) } else { 1.0 } * opacity * k.clamp(0.0, 1.0); + if sa <= 0.0 { + continue; + } + let oa = sa + d[INKS] * (1.0 - sa); + let keep = d[INKS] * (1.0 - sa); + for (c, v) in s.iter().take(INKS).enumerate() { + // An opaque pixel replaces what is below exactly (no rounding through the mix). + d[c] = if sa >= 1.0 { *v } else { (v * sa + d[c] * keep) / oa }; + } + d[INKS] = oa; + } + } + Item::Adjust(op, opacity, mask) => { + if *opacity <= 0.0 { + continue; + } + masked(*mask, mv); + let adjusted = op.apply(acc, self.depth); + for ((d, k), ink) in acc.iter_mut().zip(mv.iter()).zip(&adjusted) { + let mix = opacity * k.clamp(0.0, 1.0); + if d[INKS] <= 0.0 || mix <= 0.0 { + continue; + } + for (c, v) in ink.iter().enumerate() { + d[c] += (v - d[c]) * mix; + } + } + } + } + } + } } /// The composite of `doc` over `rect` in `fmt`, worked out in ink values, or `None` when the /// document isn't CMYK or a visible layer needs the full compositor (see the module docs). /// `paper` flattens onto white paper (no ink) and makes the result opaque. pub fn flatten_cmyk(doc: &Document, rect: Rect, fmt: PixelFormat, paper: bool) -> Option { - if doc.mode != ColorMode::Cmyk || fmt.mode != ColorMode::Cmyk || doc.layers.iter().any(|l| l.clipped) { + if fmt.mode != ColorMode::Cmyk { return None; } - let mut stack = Vec::new(); - for layer in doc.layers.iter().filter(|l| l.visible) { - let alpha = (layer.opacity * layer.fill_opacity).clamp(0.0, 1.0); - stack.push((plain(layer)?, alpha)); - } + let stack = InkStack::new(doc)?; let mut out = Surface::new(fmt); if rect.is_empty() { return Some(out); } let n = fmt.channels(); - let w = rect.width() as usize; let rows = crate::band_rows_for(rect.width(), 0); - let (mut acc, mut src, mut data) = (Vec::new(), Vec::new(), Vec::new()); + let (mut acc, mut src, mut mv, mut data) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); let mut y = rect.y0; while y < rect.y1 { let band = Rect::new(rect.x0, y, rect.x1, y.saturating_add(rows).min(rect.y1)); - // Straight C, M, Y, K, alpha per pixel, starting transparent. - acc.clear(); - acc.resize(w * band.height() as usize, [0.0f32; INKS + 1]); - for &(surf, opacity) in &stack { - if opacity <= 0.0 || (surf.content_bounds().intersect(&band).is_empty() && surf.default_pixel().iter().all(|v| *v == 0.0)) { - continue; - } - let sf = surf.format(); - let sn = sf.channels(); - surf.read_region_into(band, &mut src); - for (d, s) in acc.iter_mut().zip(src.chunks_exact(sn)) { - let sa = if sf.alpha { s[INKS] } else { 1.0 } * opacity; - if sa <= 0.0 { - continue; - } - let oa = sa + d[INKS] * (1.0 - sa); - let keep = d[INKS] * (1.0 - sa); - for c in 0..INKS { - // An opaque pixel replaces what is below exactly (no rounding through the mix). - d[c] = if sa >= 1.0 { s[c] } else { (s[c] * sa + d[c] * keep) / oa }; - } - d[INKS] = oa; - } - } + stack.composite(band, &mut acc, &mut src, &mut mv); data.clear(); data.resize(acc.len() * n, 0.0); for (p, o) in acc.iter().zip(data.chunks_exact_mut(n)) { @@ -99,3 +269,41 @@ pub fn flatten_cmyk(doc: &Document, rect: Rect, fmt: PixelFormat, paper: bool) - out.prune(); Some(out) } + +/// The display composite (straight RGBA, through the document's CMYK profile) of a CMYK document +/// whose stack has adjustment layers the inks carry exactly, worked out on the inks; `None` for +/// any other document, which the RGB compositor renders. Rows of `tile` render in parallel. +pub fn render_cmyk(doc: &Document, rect: Rect, tile: i32) -> Option { + let stack = InkStack::new(doc).filter(InkStack::adjusts)?; + let cmyk = crate::cmyk_space(doc); + let w = rect.width() as usize; + let mut out = Buffer::transparent(rect); + if w == 0 || rect.is_empty() { + return Some(out); + } + let rows = tile.max(1) as usize; + let band = |i: usize, px: &mut [[f32; 4]]| { + let y0 = rect.y0.saturating_add((i * rows) as i32); + let r = Rect::new(rect.x0, y0, rect.x1, y0.saturating_add((px.len() / w) as i32)); + let (mut acc, mut src, mut mv) = (Vec::new(), Vec::new(), Vec::new()); + stack.composite(r, &mut acc, &mut src, &mut mv); + photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || { + for (o, p) in px.iter_mut().zip(&acc) { + if p[INKS] > 0.0 { + let [r, g, b] = photocraft_color::convert::cmyk_to_rgb([p[0], p[1], p[2], p[3]]); + *o = [r, g, b, p[INKS]]; + } + } + }); + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.px.par_chunks_mut(w * rows).enumerate().for_each(|(i, px)| band(i, px)); + } + #[cfg(target_arch = "wasm32")] + for (i, px) in out.px.chunks_mut(w * rows).enumerate() { + band(i, px); + } + Some(out) +} diff --git a/crates/compose/src/native_lab.rs b/crates/compose/src/native_lab.rs new file mode 100644 index 0000000000..0db59499de --- /dev/null +++ b/crates/compose/src/native_lab.rs @@ -0,0 +1,253 @@ +//! Lab-native compositing of plain Lab layer stacks. +//! +//! The compositor works in display RGB, so a Lab document's layers are read through `lab_to_srgb`, +//! which clips colours outside the display gamut, and adjustments Photoshop evaluates on L, a and +//! b ([`adjust::lab_stored_op`]) can then only see those clipped colours: an inverted or levelled +//! saturated colour comes out several levels off. When every visible layer is a plain Lab raster +//! layer in Normal mode or such an adjustment layer, the stack is worked out on the stored values +//! instead: Normal mixes L, a and b linearly, as Photoshop does in Lab, and an opaque layer keeps +//! them exactly. A layer mask (unfeathered) scales the mix. +//! +//! Stacks with such adjustment layers render for display this way ([`render_lab`]); flattening +//! ([`flatten_lab`]) keeps the stored values for any plain stack. + +use photocraft_color::blend::BlendMode; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, LayerMask}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +use crate::Buffer; +use crate::adjust::{self, LabOp}; + +/// Colour channels of a Lab pixel. +const LAB: usize = 3; + +/// Whether the compositor would only blend `layer` Normally at its opacity through its layer mask: +/// no clipping, vector mask, feathered mask, effects, excluded channels, Blend If or advanced +/// blending. +fn simple(layer: &Layer) -> bool { + layer.blend == BlendMode::Normal + && !layer.clipped + && !crate::masks::has_feather(layer) + && layer.vector_mask.as_ref().is_none_or(|v| !v.enabled) + && !crate::effects::has_effects(layer) + && layer.excluded_channels == 0 + && layer.blend_if.is_default() + && layer.advanced.is_default() + && layer.video.is_none() +} + +/// One step of a native stack, at the layer's opacity × fill, through its mask. +enum Item<'a> { + Layer(&'a Surface, f32, Option<&'a LayerMask>), + Adjust(LabOp<'a>, f32, Option<&'a LayerMask>), +} + +/// Groups nested deeper than this keep the RGB compositor. +const MAX_DEPTH: usize = 64; + +/// `layers`' visible steps, appended to `items`; `None` when one needs the RGB compositor. A group +/// at full opacity without a mask is inlined: pass-through groups composite their children straight +/// onto the backdrop, and so do isolated (Normal) groups of raster layers alone, since Normal +/// composites associatively; an adjustment inside an isolated group would only see the group's +/// own content, so such a group keeps the RGB compositor (`adjustments` false). +fn collect<'a>(layers: &'a [Layer], depth: SampleType, adjustments: bool, items: &mut Vec>, level: usize) -> Option<()> { + if level > MAX_DEPTH { + return None; + } + for layer in layers.iter().filter(|l| l.visible) { + let alpha = (layer.opacity * layer.fill_opacity).clamp(0.0, 1.0); + let mask = layer.mask.as_ref().filter(|m| m.enabled); + let item = match &layer.content { + LayerContent::Group(g) => { + let open = g.artboard.is_none() + && alpha >= 1.0 + && mask.is_none() + && !layer.clipped + && layer.vector_mask.as_ref().is_none_or(|v| !v.enabled) + && !crate::effects::has_effects(layer) + && layer.excluded_channels == 0 + && layer.blend_if.is_default() + && layer.advanced.is_default(); + match layer.blend { + BlendMode::PassThrough if open => collect(&g.children, depth, adjustments, items, level + 1)?, + BlendMode::Normal if open => collect(&g.children, depth, false, items, level + 1)?, + _ => return None, + } + continue; + } + LayerContent::Raster(s) if simple(layer) && s.format().mode == ColorMode::Lab => Item::Layer(s, alpha, mask), + LayerContent::Adjustment(adj) if adjustments && simple(layer) => Item::Adjust(adjust::lab_stored_op(adj, Some(depth))?, alpha, mask), + _ => return None, + }; + items.push(item); + } + Some(()) +} + +/// A Lab document's visible layers, prepared for compositing on their stored L, a and b. +pub struct LabStack<'a> { + items: Vec>, +} + +impl<'a> LabStack<'a> { + /// `None` unless `doc` is Lab and every visible layer is a plain Lab raster layer, an + /// adjustment layer [`adjust::lab_stored_op`] evaluates, or a group that can be inlined (see + /// the module docs and [`collect`]). + pub fn new(doc: &'a Document) -> Option { + if doc.mode != ColorMode::Lab { + return None; + } + let mut items = Vec::new(); + collect(&doc.layers, doc.depth, true, &mut items, 0)?; + Some(LabStack { items }) + } + + /// Whether the stack has an adjustment layer (the RGB compositor can't match it exactly). + pub fn adjusts(&self) -> bool { + self.items.iter().any(|i| matches!(i, Item::Adjust(..))) + } + + /// Straight L, a, b and alpha over `rect`, row-major, into `acc` (`src` and `mv` are scratch). + fn composite(&self, rect: Rect, acc: &mut Vec<[f32; LAB + 1]>, src: &mut Vec, mv: &mut Vec) { + let n = rect.width() as usize * rect.height() as usize; + acc.clear(); + acc.resize(n, [0.0, adjust::LAB_NEUTRAL, adjust::LAB_NEUTRAL, 0.0]); + // The mask's value at each pixel (1 without one). + let masked = |mask: Option<&LayerMask>, mv: &mut Vec| match mask { + Some(m) => m.values_into(rect, mv), + None => { + mv.clear(); + mv.resize(n, 1.0); + } + }; + for item in &self.items { + match item { + Item::Layer(surf, opacity, mask) => { + let opacity = *opacity; + if opacity <= 0.0 || (surf.content_bounds().intersect(&rect).is_empty() && surf.default_pixel().get(LAB).is_none_or(|a| *a <= 0.0)) { + continue; + } + let sf = surf.format(); + let sn = sf.channels(); + surf.read_region_into(rect, src); + masked(*mask, mv); + for ((d, s), k) in acc.iter_mut().zip(src.chunks_exact(sn)).zip(mv.iter()) { + let sa = if sf.alpha { s.get(LAB).copied().unwrap_or(1.0) } else { 1.0 } * opacity * k.clamp(0.0, 1.0); + if sa <= 0.0 { + continue; + } + let oa = sa + d[LAB] * (1.0 - sa); + let keep = d[LAB] * (1.0 - sa); + for (c, v) in s.iter().take(LAB).enumerate() { + // An opaque pixel replaces what is below exactly (no rounding through the mix). + d[c] = if sa >= 1.0 { *v } else { (v * sa + d[c] * keep) / oa }; + } + d[LAB] = oa; + } + } + Item::Adjust(op, opacity, mask) => { + if *opacity <= 0.0 { + continue; + } + masked(*mask, mv); + for (d, k) in acc.iter_mut().zip(mv.iter()) { + let mix = opacity * k.clamp(0.0, 1.0); + if d[LAB] <= 0.0 || mix <= 0.0 { + continue; + } + let mut lab = [d[0], d[1], d[2]]; + op(&mut lab); + for (c, v) in lab.iter().enumerate() { + d[c] += (v - d[c]) * mix; + } + } + } + } + } + } +} + +/// The composite of `doc` over `rect` in `fmt`, worked out on stored L, a and b, or `None` when +/// the document isn't Lab, `fmt` isn't, or a visible layer needs the full compositor (see the +/// module docs). `white` flattens onto white (L 100, neutral a and b) and makes the result opaque. +pub fn flatten_lab(doc: &Document, rect: Rect, fmt: PixelFormat, white: bool) -> Option { + if fmt.mode != ColorMode::Lab { + return None; + } + let stack = LabStack::new(doc)?; + let mut out = Surface::new(fmt); + if rect.is_empty() { + return Some(out); + } + let n = fmt.channels(); + let empty = out.default_pixel(); + let rows = crate::band_rows_for(rect.width(), 0); + let (mut acc, mut src, mut mv, mut data) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + let mut y = rect.y0; + while y < rect.y1 { + let band = Rect::new(rect.x0, y, rect.x1, y.saturating_add(rows).min(rect.y1)); + stack.composite(band, &mut acc, &mut src, &mut mv); + data.clear(); + data.resize(acc.len() * n, 0.0); + for (p, o) in acc.iter().zip(data.chunks_exact_mut(n)) { + let a = p[LAB]; + if white { + let paper = [1.0, adjust::LAB_NEUTRAL, adjust::LAB_NEUTRAL]; + for c in 0..LAB { + o[c] = p[c] * a + paper[c] * (1.0 - a); + } + if fmt.alpha { + o[LAB] = 1.0; + } + } else if a <= 0.0 { + o.copy_from_slice(&empty[..n]); + } else { + o[..LAB].copy_from_slice(&p[..LAB]); + if fmt.alpha { + o[LAB] = a; + } + } + } + out.write_region(band, &data); + y = band.y1; + } + out.prune(); + Some(out) +} + +/// The display composite (straight RGBA) of a Lab document whose stack has adjustment layers that +/// work on L, a and b, worked out on the stored values; `None` for any other document, which the +/// RGB compositor renders. Rows of `tile` render in parallel. +pub fn render_lab(doc: &Document, rect: Rect, tile: i32) -> Option { + let stack = LabStack::new(doc).filter(LabStack::adjusts)?; + let w = rect.width() as usize; + let mut out = Buffer::transparent(rect); + if w == 0 || rect.is_empty() { + return Some(out); + } + let rows = tile.max(1) as usize; + let band = |i: usize, px: &mut [[f32; 4]]| { + let y0 = rect.y0.saturating_add((i * rows) as i32); + let r = Rect::new(rect.x0, y0, rect.x1, y0.saturating_add((px.len() / w) as i32)); + let (mut acc, mut src, mut mv) = (Vec::new(), Vec::new(), Vec::new()); + stack.composite(r, &mut acc, &mut src, &mut mv); + for (o, p) in px.iter_mut().zip(&acc) { + if p[LAB] > 0.0 { + let [r, g, b] = photocraft_color::convert::lab_to_srgb([p[0] * 100.0, p[1] * 255.0 - 128.0, p[2] * 255.0 - 128.0]); + *o = [r, g, b, p[LAB]]; + } + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.px.par_chunks_mut(w * rows).enumerate().for_each(|(i, px)| band(i, px)); + } + #[cfg(target_arch = "wasm32")] + for (i, px) in out.px.chunks_mut(w * rows).enumerate() { + band(i, px); + } + Some(out) +} diff --git a/crates/compose/src/proxy.rs b/crates/compose/src/proxy.rs index 84c9348029..f48f10d83f 100644 --- a/crates/compose/src/proxy.rs +++ b/crates/compose/src/proxy.rs @@ -4,8 +4,8 @@ //! channel thumbnails, histograms, file previews), where a full-resolution composite of a 200 MP //! document would cost seconds and gigabytes. -use photocraft_doc::{Document, Layer, LayerContent, Size}; -use photocraft_geom::Rect; +use photocraft_doc::{Document, Layer, LayerContent, Size, SmartObject}; +use photocraft_geom::{Affine, Rect}; use photocraft_raster::Surface; /// Nearest-neighbour downsample of a surface by integer factor `k` (document coordinates / k). @@ -60,11 +60,30 @@ fn shrink_layer(l: &mut Layer, k: u32) { } LayerContent::Text(t) => t.cache = t.cache.as_ref().map(|s| downsample(s, k)), LayerContent::Shape(sh) => sh.cache = sh.cache.as_ref().map(|s| downsample(s, k)), - LayerContent::Smart(so) => so.cache = so.cache.as_ref().map(|s| downsample(s, k)), + LayerContent::Smart(so) => shrink_smart(so, k), LayerContent::Adjustment(_) | LayerContent::Fill(_) => {} } } +/// Scales a smart object with its proxy: the cache and filter mask like any surface, and the +/// placement too. A preview that adds a smart filter re-renders the source through the placement; +/// left in full-size document coordinates, that render came out `k` times too large on the proxy +/// canvas, so the Curves preview on a smart object zoomed in (#5766). +fn shrink_smart(so: &mut SmartObject, k: u32) { + so.cache = so.cache.as_ref().map(|s| downsample(s, k)); + if let Some(m) = &mut so.filter_mask { + m.surface = downsample(&m.surface, k); + } + let s = 1.0 / f64::from(k); + so.transform = Affine::scale(s).mul(&so.transform); + // Row-major source → document map: scaling the document side scales its first two rows. + if let Some(h) = &mut so.perspective { + for v in h.iter_mut().take(6) { + *v *= s; + } + } +} + /// A copy of `doc` scaled down by `k` (same layer ids, so adjustments can be swapped in). The /// selection scales with it, so a filter previewed on the proxy stays inside the (feathered) /// selection exactly as the full-size result does. @@ -166,6 +185,33 @@ mod tests { assert!(proxy_faithful(&doc)); } + /// #5766: a smart object re-rendered on the proxy (a smart filter preview) goes through its + /// placement, which must shrink with the canvas, cache and filter mask. + #[test] + fn proxy_scales_smart_object_placement() { + use photocraft_doc::SmartSource; + let mut doc = Document::new("smart", Size::new(64, 64), ColorMode::Rgb, SampleType::U8); + let mut cache = Surface::new(doc.pixel_format()); + cache.fill_rect(Rect::new(16, 16, 32, 48), &[0.0, 0.0, 0.0, 1.0]); + let source = SmartSource::Embedded { file_name: "s.png".into(), bytes: std::sync::Arc::new(Vec::new()) }; + let mut sm = SmartObject::new(source, Affine::translate(16.0, 16.0).mul(&Affine::scale(2.0)), Some(cache)); + let mut mask = LayerMask::hide_all(); + mask.surface.fill_rect(Rect::new(16, 16, 32, 32), &[1.0]); + sm.filter_mask = Some(mask); + sm.perspective = Some([2.0, 0.0, 16.0, 0.0, 2.0, 16.0, 0.0, 0.0, 1.0]); + doc.layers.push(Layer::new("smart", LayerContent::Smart(sm))); + let p = proxy_document(&doc, 4); + let LayerContent::Smart(sm) = &p.layers[0].content else { panic!("a smart object") }; + assert_eq!(sm.transform, Affine { m: [0.5, 0.0, 0.0, 0.5, 4.0, 4.0] }); + assert_eq!(sm.perspective, Some([0.5, 0.0, 4.0, 0.0, 0.5, 4.0, 0.0, 0.0, 1.0])); + assert_eq!(sm.cache.as_ref().unwrap().content_bounds(), Rect::new(4, 4, 8, 12)); + let mask = &sm.filter_mask.as_ref().unwrap().surface; + assert_eq!((mask.pixel(4, 4)[0], mask.pixel(8, 8)[0]), (1.0, 0.0)); + // The document keeps its full-size placement. + let LayerContent::Smart(orig) = &doc.layers[0].content else { panic!("a smart object") }; + assert_eq!(orig.transform.m[4], 16.0); + } + #[test] fn proxy_scales_the_selection() { let mut doc = Document::with_background("d", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f9e49ac04e..bd7d3ceba7 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1,6 +1,6 @@ use super::*; use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; -use photocraft_doc::adjust::{CurvePoint, LevelsChannel}; +use photocraft_doc::adjust::{CurvePoint, LevelsChannel, ToneSpace}; use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerMask}; use photocraft_geom::Size; @@ -606,6 +606,142 @@ fn a_16_bit_lab_invert_layer_keeps_greys_neutral() { assert!((lab[0] - 75.0).abs() < 0.1 && lab[1].abs() < 0.05 && lab[2].abs() < 0.05, "{lab:?}"); } +/// A CMYK document of one opaque raster layer holding `ink` everywhere, at `depth`. +fn cmyk_doc(ink: [f32; 4], depth: SampleType) -> Document { + let mut d = Document::new("c", Size::new(4, 4), ColorMode::Cmyk, depth); + let mut l = Layer::raster("bg", d.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &[ink[0], ink[1], ink[2], ink[3], 1.0]); + d.layers.push(l); + d +} + +/// Adjustment layers Photoshop evaluates on the inks composite on the document's own inks, not on +/// a separation of the RGB composite: exact for Invert, Posterize, Threshold, Brightness/Contrast +/// and CMYK Levels / Curves (photoshop corpus cmyk8 and cmyk16 oracles), through opacity and an +/// (unfeathered) mask as linear ink mixes. +#[test] +fn cmyk_ink_adjustments_composite_on_the_documents_inks() { + use photocraft_color::convert::cmyk_to_rgb; + let ink = [0.2, 0.4, 0.6, 0.1]; + let rgb = |i: [f32; 4]| { + let c = cmyk_to_rgb(i); + [c[0], c[1], c[2], 1.0] + }; + for depth in [SampleType::U8, SampleType::U16] { + let mut d = cmyk_doc(ink, depth); + // Stored inks, as the document's surface rounds them. + let stored: [f32; 4] = std::array::from_fn(|c| d.layers[0].surface().unwrap().pixel(0, 0)[c]); + d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))); + assert!(close4(px(&d, 1, 1), rgb(stored.map(|v| 1.0 - v))), "{depth:?} invert"); + // Half opacity and a half mask mix the inks linearly: a quarter of the way. + d.layers[1].opacity = 0.5; + let mut m = LayerMask::reveal_all(); + m.surface.fill_rect(Rect::new(0, 0, 4, 4), &[0.5]); + d.layers[1].mask = Some(m); + let k = 0.5 * d.layers[1].mask.as_ref().unwrap().value(1, 1); + assert!(close4(px(&d, 1, 1), rgb(stored.map(|v| v + (1.0 - 2.0 * v) * k))), "{depth:?} mixed"); + // Brightness/Contrast curves each ink's stored value. + let table = adjust::ink_brightness_contrast_lut(25.0, 30.0, true); + d.layers[1] = Layer::new("bc", LayerContent::Adjustment(Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy: true })); + assert!(close4(px(&d, 1, 1), rgb(stored.map(|v| adjust::ink_through(&table, v)))), "{depth:?} brightness/contrast"); + // Flattening keeps the adjusted inks exactly, with no trip through RGB. + let flat = flatten_to_surface(&d, d.pixel_format(), None); + let got = flat.pixel(2, 2); + for c in 0..4 { + let want = adjust::ink_through(&table, stored[c]); + let q = if depth == SampleType::U8 { 255.0 } else { 65535.0 }; + assert!((got[c] - want).abs() <= 0.5 / q + 1e-6, "{depth:?} flattened ink {c}: {} vs {want}", got[c]); + } + } +} + +/// Only stacks with ink adjustments take the ink compositor for display: plain CMYK layers keep +/// the RGB compositor (which the GPU matches), and so does any stack with an adjustment the inks +/// don't carry or a layer they can't composite. +#[test] +fn only_cmyk_stacks_with_ink_adjustments_render_on_their_inks() { + let r = Rect::new(0, 0, 4, 4); + let mut d = cmyk_doc([0.2, 0.4, 0.6, 0.1], SampleType::U8); + assert!(native::render_cmyk(&d, r, 64).is_none(), "plain layers"); + d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))); + assert!(native::render_cmyk(&d, r, 64).is_some()); + let exposure = Adjustment::Exposure { exposure: 0.5, offset: 0.0, gamma: 1.0 }; + d.layers.push(Layer::new("exposure", LayerContent::Adjustment(exposure))); + assert!(native::render_cmyk(&d, r, 64).is_none(), "an adjustment the inks don't carry"); + d.layers.pop(); + d.layers[1].blend = BlendMode::Multiply; + assert!(native::render_cmyk(&d, r, 64).is_none(), "a blend mode"); + d.layers[1].blend = BlendMode::Normal; + let mut rgb = cmyk_doc([0.2, 0.4, 0.6, 0.1], SampleType::U8); + rgb.mode = ColorMode::Rgb; + rgb.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))); + assert!(native::render_cmyk(&rgb, r, 64).is_none(), "not a CMYK document"); + // Tiles render alike whatever their size. + let whole = render_tiled(&d, d.bounds(), 64); + assert_eq!(render_tiled(&d, d.bounds(), 1).px, whole.px); +} + +/// Hue/Saturation (not Colorize) and Color Balance work on C, M and Y as inverted RGB with the +/// black ink kept (photoshop corpus cmyk16 hue-saturation.psd: every ink within half a level). +#[test] +fn cmyk_hue_saturation_and_color_balance_keep_the_black_ink() { + use photocraft_color::convert::cmyk_to_rgb; + let ranges = photocraft_doc::adjust::HueRange::defaults(); + let hue = Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: -10.0, colorize: false, ranges: ranges.clone() }; + let balance = + Adjustment::ColorBalance { shadows: [20.0, -10.0, 0.0], midtones: [-15.0, 10.0, 30.0], highlights: [0.0, 0.0, -20.0], preserve_luminosity: true }; + for adj in [hue, balance] { + assert!(adjust::cmy_adjustment(&adj) && adjust::cmyk_channel_adjustment(&adj)); + let mut d = cmyk_doc([0.2, 0.7, 0.1, 0.3], SampleType::U16); + let stored: [f32; 4] = std::array::from_fn(|c| d.layers[0].surface().unwrap().pixel(0, 0)[c]); + d.layers.push(Layer::new("adj", LayerContent::Adjustment(adj.clone()))); + // The RGB adjustment on the inks' complement, black untouched. + let mut rgb = Buffer::filled(Rect::new(0, 0, 1, 1), [1.0 - stored[0], 1.0 - stored[1], 1.0 - stored[2], 1.0]); + adjust::apply(&adj, &mut rgb); + let want = [1.0 - rgb.px[0][0], 1.0 - rgb.px[0][1], 1.0 - rgb.px[0][2], stored[3]]; + assert!(native::render_cmyk(&d, d.bounds(), 64).is_some(), "{}", adj.label()); + let c = cmyk_to_rgb(want); + assert!(close4(px(&d, 1, 1), [c[0], c[1], c[2], 1.0]), "{}", adj.label()); + let flat = flatten_to_surface(&d, d.pixel_format(), None); + let got = flat.pixel(1, 1); + assert!((got[3] - stored[3]).abs() < 1e-4, "{}: black kept", adj.label()); + assert!((0..3).all(|i| (got[i] - want[i]).abs() < 1e-3), "{}: {got:?} vs {want:?}", adj.label()); + } + // Colorize separates its colour again (black included), so it stays on the screen colour. + let colorize = Adjustment::HueSaturation { hue: -160.0, saturation: 40.0, lightness: 5.0, colorize: true, ranges }; + assert!(!adjust::cmy_adjustment(&colorize) && !adjust::cmyk_channel_adjustment(&colorize)); +} + +/// Groups at full opacity without a mask are inlined: a pass-through group's adjustment reaches +/// the layers below it, and an isolated group of raster layers composites as its children would. +/// An isolated group's adjustment (which only sees the group) or a translucent group keeps the +/// RGB compositor. +#[test] +fn cmyk_ink_compositing_inlines_open_groups() { + use photocraft_color::convert::cmyk_to_rgb; + let r = Rect::new(0, 0, 4, 4); + let mut d = cmyk_doc([0.2, 0.4, 0.6, 0.1], SampleType::U16); + let stored: [f32; 4] = std::array::from_fn(|c| d.layers[0].surface().unwrap().pixel(0, 0)[c]); + let mut g = Layer::group("g", vec![Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]); + g.blend = BlendMode::PassThrough; + d.layers.push(g); + let c = cmyk_to_rgb(stored.map(|v| 1.0 - v)); + assert!(close4(px(&d, 1, 1), [c[0], c[1], c[2], 1.0]), "a pass-through group's Invert reaches the inks below"); + d.layers[1].blend = BlendMode::Normal; + assert!(native::render_cmyk(&d, r, 64).is_none(), "an isolated group's adjustment"); + d.layers[1].blend = BlendMode::PassThrough; + d.layers[1].opacity = 0.5; + assert!(native::render_cmyk(&d, r, 64).is_none(), "a translucent group"); + // An isolated group of raster layers, with an adjustment above it, still composites on the inks. + let mut top = Layer::raster("top", d.pixel_format()); + top.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 4), &[0.9, 0.0, 0.0, 0.0, 1.0]); + d.layers[1] = Layer::group("raster", vec![top]); + d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))); + assert!(native::render_cmyk(&d, r, 64).is_some()); + let c = cmyk_to_rgb(stored.map(|v| 1.0 - v)); + assert!(close4(px(&d, 3, 1), [c[0], c[1], c[2], 1.0]), "outside the group's layer"); +} + #[test] fn cmyk_document_renders_via_rgb() { let mut d = Document::new("c", Size::new(2, 2), ColorMode::Cmyk, SampleType::U8); @@ -946,6 +1082,34 @@ fn glow_gradient_follows_the_shape() { assert!(edge[0] > 0.8 && edge[2] < 0.2, "{edge:?}"); } +// A Gradient Overlay spans the layer's frame: growing the layer repaints all of it, not just the +// new pixels (#4053). Effects that don't depend on the frame repaint only what changed. +#[test] +fn relayout_damage_covers_a_layer_whose_gradient_frame_moved() { + let overlay = Effect::GradientOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false }; + let grown = |a: &Document, rect: Rect, rgba: [f32; 4]| { + let mut b = a.clone(); + let old = b.layers.last().unwrap().clone(); + let mut l = solid_layer("sq", rect, rgba); + (l.id, l.effects) = (old.id, old.effects); + *b.layers.last_mut().unwrap() = l; + b + }; + let a = fx_doc(vec![overlay.clone()]); + let b = grown(&a, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0]); + let d = crate::relayout_damage(&a, &b); + assert!(d.contains_rect(&Rect::new(10, 10, 36, 34)), "{d:?}"); + // Same bounds, other pixels: the frame stays, the pixels' own damage is enough. + assert_eq!(crate::relayout_damage(&a, &grown(&a, Rect::new(10, 10, 30, 30), [0.0, 1.0, 0.0, 1.0])), Rect::EMPTY); + // A Color Overlay doesn't depend on the frame. + let c = fx_doc(vec![Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: Color::BLACK }]); + assert_eq!(crate::relayout_damage(&c, &grown(&c, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0])), Rect::EMPTY); + // Nor does a disabled overlay. + let mut off = a.clone(); + off.layers.last_mut().unwrap().effects.enabled = false; + assert_eq!(crate::relayout_damage(&off, &grown(&off, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0])), Rect::EMPTY); +} + #[test] fn gradient_overlay_follows_angle_and_reverse() { let g = Gradient { stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], angle: 0.0, ..Gradient::default() }; @@ -1473,6 +1637,36 @@ fn shadow_knockout_needs_see_through_fill() { assert!(close4(on, px(&base, 10, 20)), "{on:?}"); } +#[test] +fn shadow_knockout_beneath_a_layer_in_a_blending_mode() { + // Layer Knocks Out Drop Shadow keeps the shadow out from under the layer's shape. In Normal + // mode an opaque layer covers it anyway; in a mode that blends with what's beneath, the shadow + // would show through: under a white Exclusion square on white, it read as white (white over + // the black shadow) instead of black (white over white), so a logo's Exclusion bar with a + // drop shadow came out blank. A red Multiply square came out black instead of red. + for (blend, colour, inside, depth) in [ + (photocraft_color::BlendMode::Exclusion, [1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]), + (photocraft_color::BlendMode::Multiply, [1.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0]), + ] + .into_iter() + .flat_map(|c| SampleType::ALL.map(|depth| (c, depth))) + .map(|((b, c, i), depth)| (b, c, i, depth)) + { + let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, depth); + d.layers.push(solid_layer("bg", Rect::new(0, 0, 40, 40), [1.0; 4])); + let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), colour); + l.blend = blend; + l.effects.items = vec![Effect::DropShadow(shadow(0.0, 90.0))]; + d.layers.push(l); + assert!(close4(px(&d, 20, 20), inside), "{blend:?} {depth:?}: {:?}", px(&d, 20, 20)); + // Not knocked out, the shadow shows through the blend, as in Photoshop with the box unticked. + if let Effect::DropShadow(s) = &mut d.layers[1].effects.items[0] { + s.knocks_out = false; + } + assert!(!close4(px(&d, 20, 20), inside), "{blend:?} {depth:?} without knockout: {:?}", px(&d, 20, 20)); + } +} + #[test] fn linked_pattern_overlay_anchors_at_the_effects_reference_point() { use photocraft_doc::pattern::Pattern; @@ -1698,6 +1892,43 @@ fn modern_contrast_matches_photoshop() { } } +/// Over the whole slider range the curve stays monotone and inside 0..1, lowering it undoes +/// raising it by as much, and odd input never panics. +#[test] +fn modern_brightness_is_monotone_and_lowering_inverts_raising() { + for b in (-150..=150).step_by(5).map(|b| b as f32) { + let mut prev = 0.0f32; + for k in 0..=1020 { + let v = adjust::modern_brightness(k as f32 / 1020.0, b); + assert!((0.0..=1.0).contains(&v) && v >= prev - 1e-6, "brightness {b} at {k}/1020: {v} after {prev}"); + prev = v; + } + assert!( + (adjust::modern_brightness(1.0, b) - 1.0).abs() < 1e-5 && adjust::modern_brightness(0.0, b).abs() < 1e-5, + "brightness {b} pins black and white" + ); + for k in 0..=50 { + let x = k as f32 / 50.0; + let back = adjust::modern_brightness(adjust::modern_brightness(x, b), -b); + assert!((back - x).abs() < 2e-3, "brightness {b}: {x} -> {back}"); + } + } + for v in [f32::NAN, f32::INFINITY, -3.0, 7.0] { + let _ = adjust::modern_brightness(v, 40.0); + let _ = adjust::modern_brightness(0.5, v); + } + assert_eq!(adjust::modern_lut(0.0, 0.0).len(), 4096); + // The table agrees with the curves it samples, lowered (inverted in one pass) or raised. + for (b, c) in [(-50.0, 30.0), (-150.0, -50.0), (40.0, 0.0), (0.0, 100.0)] { + let t = adjust::modern_lut(b, c); + for k in (0..4096).step_by(37) { + let x = k as f32 / 4095.0; + let want = adjust::modern_contrast(adjust::modern_brightness(x, b), c); + assert!((t[k] - want).abs() < 1e-3, "({b}, {c}) at {k}: {} vs {want}", t[k]); + } + } +} + #[test] fn modern_brightness_matches_photoshop() { let cases: [(f32, [u8; 10]); 2] = [(50.0, [0, 22, 44, 88, 132, 171, 203, 228, 246, 255]), (-50.0, [0, 12, 23, 47, 70, 93, 118, 148, 186, 255])]; @@ -1706,8 +1937,48 @@ fn modern_brightness_matches_photoshop() { for (i, &x) in xs.iter().enumerate() { let got = adjust::modern_brightness(x as f32 / 255.0, b) * 255.0; let err = (got - out[i] as f32).abs(); - // The brightness roll-off is a spline; our fit is close but approximate at the top. - assert!(err <= 6.0, "brightness {b} x={x}: got {got:.1} want {} (err {err:.1})", out[i]); + // Within about a level of Photoshop's (rounded) output everywhere. + assert!(err <= 1.2, "brightness {b} x={x}: got {got:.1} want {} (err {err:.1})", out[i]); + } + } + // Photoshop 27.11's brightness +25 with contrast +30 (photoshop corpus rgb8 + // brightness-contrast.psd): what the oracle's levels become. + let oracle = [ + (8, 7), + (24, 23), + (48, 49), + (72, 78), + (88, 99), + (104, 120), + (128, 154), + (144, 174), + (160, 192), + (176, 208), + (192, 222), + (200, 228), + (216, 239), + (232, 247), + (240, 250), + (248, 253), + ]; + for (x, want) in oracle { + let got = adjust::modern_contrast(adjust::modern_brightness(x as f32 / 255.0, 25.0), 30.0) * 255.0; + assert!((got - want as f32).abs() <= 1.0, "+25/+30 x={x}: got {got:.2} want {want}"); + } + // psd-tools' brightnesscontrast_rgb.psd masks four layers to their own columns: the brightness + // each applies, read back through its (exact) contrast curve. + // (brightness, tolerance in levels, [(input level, Photoshop's output level)]) + type Measured = (f32, f32, &'static [(u32, f32)]); + let measured: [Measured; 4] = [ + (-95.0, 1.5, &[(24, 13.3), (64, 35.1), (96, 52.9), (128, 69.7), (160, 89.6), (192, 114.4), (224, 151.6)]), + (-31.0, 1.5, &[(24, 20.3), (64, 53.4), (96, 79.0), (128, 105.6), (160, 132.3), (192, 161.8), (224, 197.4)]), + (23.0, 1.5, &[(24, 27.9), (64, 74.0), (96, 110.9), (128, 147.7), (160, 182.9), (192, 213.7), (224, 239.6)]), + (150.0, 6.0, &[(24, 60.3), (64, 162.1), (96, 214.5), (128, 238.4), (160, 248.9)]), + ]; + for (b, tol, points) in measured { + for &(x, want) in points { + let got = adjust::modern_brightness(x as f32 / 255.0, b) * 255.0; + assert!((got - want).abs() <= tol, "brightness {b} x={x}: got {got:.1} want {want}"); } } // Endpoints and the zero slider are exact. @@ -1718,7 +1989,7 @@ fn modern_brightness_matches_photoshop() { } // Levels ground truth from Adobe Photoshop 2026 (adjustLevels on a 0..255 ramp). Verifies our -// `levels()` matches the real app, including the gamma>1 soft shadow toe (initial slope 2^gamma). +// `levels()` matches the real app, including the gamma>1 shadow toe (initial slope 2^gamma). #[test] fn levels_matches_photoshop() { let ident = |g: f32| LevelsChannel { in_black: 0.0, in_white: 1.0, gamma: g, out_black: 0.0, out_white: 1.0 }; @@ -1734,8 +2005,8 @@ fn levels_matches_photoshop() { let want = ((x as f32 / 255.0).powi(2) * 255.0).round(); assert_eq!(got, want, "levels gamma 0.5 x={x}"); } - // Endpoints pinned; identity is identity. (The gamma>1 toe's soft-min leaves white within a - // few parts in 1e5 of 1.0 — invisible at any bit depth; exact at 8-bit.) + // Endpoints pinned; identity is identity. (The gamma>1 toe ends before white, so white + // stays exactly 1.0.) assert_eq!(adjust::levels(&ident(2.5), 0.0), 0.0); assert_eq!((adjust::levels(&ident(2.5), 1.0) * 255.0).round(), 255.0); assert!((adjust::levels(&ident(2.5), 1.0) - 1.0).abs() < 5e-4); @@ -1776,6 +2047,86 @@ fn levels_work_on_whole_levels() { } } +// Every Levels step lands on a whole level in 8 bits, and the gamma's shadow toe starts at +// slope 2^gamma (`adjust::levels_gamma`). Level → Photoshop's level, exact, from: +// - psd-tools levels_rgb.psd, Levels 2 (each channel, then the master): master 0..213, gamma +// 1.22, output 48..255; R gamma 0.47 to 0..194; B 61..255, gamma 0.47, to 0..213. Narrow +// output ranges merge neighbouring levels (R 43..48 → 58, G 8, 9 → 63, B 89, 90 → 54). +// - the photoshop corpus's rgb8 levels.psd: master 15..230, gamma 1.3, output 10..245. +// - psd-tools levels_grayscale.psd, Levels 3: 44..214, gamma 1.78 (52 → 37 is one level off). +#[test] +fn levels_steps_match_photoshop() { + let ch = |ib: f32, iw: f32, gamma: f32, ob: f32, ow: f32| LevelsChannel { + in_black: ib / 255.0, + in_white: iw / 255.0, + gamma, + out_black: ob / 255.0, + out_white: ow / 255.0, + }; + let id = LevelsChannel::default(); + let levels = |master: LevelsChannel, per_channel: [LevelsChannel; 3]| Adjustment::Levels { + master, + per_channel, + space: Default::default(), + black: LevelsChannel::default(), + }; + let run = |adj: &Adjustment, xs: &[u8]| { + let px = xs.iter().map(|&x| f32::from(x) / 255.0).map(|v| [v, v, v, 1.0]).collect(); + let mut b = Buffer { rect: Rect::new(0, 0, xs.len() as i32, 1), px }; + adjust::apply_depth(adj, &mut b, adjust::Transfer::Srgb, Some(SampleType::U8)); + b.px + }; + let rgb = levels(ch(0.0, 213.0, 1.22, 48.0, 255.0), [ch(0.0, 255.0, 0.47, 0.0, 194.0), id.clone(), ch(61.0, 255.0, 0.47, 0.0, 213.0)]); + let xs = [2, 8, 9, 15, 16, 43, 45, 48, 55, 61, 72, 79, 84, 89, 90, 101, 106, 120, 150, 180, 200]; + let want: [[u8; 21]; 3] = [ + [48, 48, 48, 50, 50, 58, 58, 58, 63, 63, 69, 72, 76, 79, 79, 86, 89, 100, 123, 153, 174], + [51, 63, 63, 72, 72, 103, 106, 109, 116, 122, 133, 141, 145, 149, 150, 160, 165, 178, 204, 228, 244], + [48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 50, 51, 54, 54, 54, 63, 63, 76, 102, 136, 164], + ]; + for (k, p) in run(&rgb, &xs).iter().enumerate() { + for c in 0..3 { + assert_eq!((p[c] * 255.0).round(), f32::from(want[c][k]), "levels_rgb channel {c} at {}", xs[k]); + } + } + let single = |c: LevelsChannel| levels(c, [id.clone(), id.clone(), id.clone()]); + let oracles: [(Adjustment, &[u8], &[u8]); 2] = [ + ( + single(ch(15.0, 230.0, 1.3, 10.0, 245.0)), + &[0, 21, 22, 49, 82, 118, 154, 176, 195, 215, 235, 251], + &[10, 25, 27, 66, 106, 144, 178, 198, 215, 232, 245, 245], + ), + ( + single(ch(44.0, 214.0, 1.78, 0.0, 255.0)), + &[44, 45, 46, 47, 48, 49, 50, 51, 53, 55, 57, 60, 65, 70, 80, 100, 150, 200, 214], + &[0, 7, 10, 17, 20, 26, 29, 35, 43, 51, 58, 66, 79, 89, 107, 137, 196, 243, 255], + ), + ]; + for (adj, xs, want) in &oracles { + for ((p, x), w) in run(adj, xs).iter().zip(*xs).zip(*want) { + assert_eq!((p[0] * 255.0).round(), f32::from(*w), "levels at {x}"); + } + } +} + +// The shadow toe stays a well-behaved curve for any gamma: from 0 to 1, rising, and without a +// jump where it meets the power curve. +#[test] +fn levels_gamma_toe_is_monotone_for_any_gamma() { + for g in [1.0001, 1.05, 1.5, 3.0, 9.99, 1e6, f32::INFINITY] { + let mut last = 0.0f32; + for k in 0..=4096 { + let u = k as f32 / 4096.0; + let t = adjust::levels_gamma(u, g); + assert!(t.is_finite() && (0.0..=1.0 + 1e-6).contains(&t), "gamma {g} at {u}: {t}"); + assert!(t >= last - 1e-6, "gamma {g} falls at {u}: {last} -> {t}"); + // Never steeper than the toe's initial slope 2^g. + assert!(k == 0 || t - last <= 2f32.powf(g) / 4096.0 + 1e-4, "gamma {g} jumps at {u}: {last} -> {t}"); + last = t; + } + assert!((adjust::levels_gamma(1.0, g) - 1.0).abs() < 1e-6, "gamma {g}: white"); + } +} + // The oracle corpus' rgb32 levels.psd: input 15..230, gamma 1.3, output 10..245. In a // 32-bit document neither range clips and the gamma is a plain power curve mirrored below black: // 0.0497 → 3 (the clipped curve gives 10), 0.9473 → 255 (245). Integer documents still clip. @@ -2294,9 +2645,10 @@ fn photo_filter_matches_photoshop() { } } // The 32-bit file picks the same numbers as linear values (Lab 81.5, 9.41, 82.19). Its fit is - // looser (mean 0.9/255 over the file, 6.9/255 with a plain RGB multiply). + // looser (mean 0.9/255 over the file, 6.9/255 with a plain RGB multiply), and Photoshop's + // results there are unclipped: above 1 and below 0. let warming32 = [0.966_445_6, 0.761_806_3, 0.0]; - for (v, ps) in [([0.2159, 0.2159, 0.2159], [66.0, 53.0, 40.0]), ([1.0, 1.0, 0.0], [255.0, 251.0, 0.0]), ([1.0, 0.0, 0.0], [255.0, 0.0, 0.0])] { + for (v, ps) in [([0.2159, 0.2159, 0.2159], [65.7, 53.4, 39.5]), ([1.0, 1.0, 0.0], [271.7, 250.9, -9.0]), ([1.0, 0.0, 0.0], [262.2, -2.0, -1.2])] { let got = filter(warming32, SampleType::F32, v); assert!(got.iter().zip(ps).all(|(g, p)| (g - p).abs() <= 2.5), "F32 {v:?}: got {got:?} want {ps:?}"); } @@ -2500,15 +2852,95 @@ fn lab_documents_invert_and_posterize_l_a_and_b() { let rgb_inv = photocraft_color::convert::srgb_to_lab(MUTED.map(|v| 1.0 - v)); assert!((rgb_inv[0] - inv[0]).abs() > 2.0, "{depth:?}: {rgb_inv:?} vs {inv:?}"); // Posterize, three levels: the same steps on L, a and b (here all land on the middle - // step: L ≈ 50 and a, b ≈ -1, a neutral grey). + // step: L ≈ 50 and a, b ≈ -1 (0 at 16 bits, whose middle level is 128), a neutral grey). let post = lab_doc_adjusted(MUTED, Adjustment::Posterize { levels: 3 }, depth); - let n = [lab[0] / 100.0, (lab[1] + 128.0) / 255.0, (lab[2] + 128.0) / 255.0].map(|v| adjust::posterize(v, 3)); + let n = [lab[0] / 100.0, (lab[1] + 128.0) / 255.0, (lab[2] + 128.0) / 255.0].map(|v| adjust::posterize_in(v, 3, Some(depth))); let want = [n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0]; assert!(want[0] > 45.0 && want[1].abs() < 2.0 && want[2].abs() < 2.0, "{want:?}"); near3(post, want, 1.0, &format!("{depth:?} posterize")); } } +/// A Lab document of one opaque layer holding stored L, a, b `lab` (normalised as Lab surfaces +/// store them), with `adjustments` above it. +fn lab_stack(lab: [f32; 3], depth: SampleType, adjustments: Vec) -> Document { + let mut doc = Document::new("lab", Size::new(4, 4), ColorMode::Lab, depth); + let fmt = doc.pixel_format(); + let mut layer = Layer::raster("bg", fmt); + if let Some(s) = layer.surface_mut() { + s.fill_rect(Rect::new(0, 0, 4, 4), &[lab[0], lab[1], lab[2], 1.0][..fmt.channels()]); + } + doc.layers.push(layer); + doc.layers.extend(adjustments.into_iter().map(|a| Layer::new("adj", LayerContent::Adjustment(a)))); + doc +} + +/// Display RGB of stored Lab `n`. +fn lab_display(n: [f32; 3]) -> [f32; 3] { + photocraft_color::convert::lab_to_srgb([n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0]) +} + +// A saturated purple far outside sRGB (L 60, a +90, b -90). Read through display RGB it clips, +// so Invert and Levels on L, a and b saw the clipped colour: the photoshop corpus's lab8 and +// lab16 invert.psd were up to 11 levels off there. Worked out on the stored values, Invert gives +// the bytes' complements (L 153 → 102, a 218 → 37, b 38 → 217), as Photoshop does. +#[test] +fn lab_adjustments_work_on_the_stored_values() { + let byte = |v: u8| f32::from(v) / 255.0; + let doc = lab_stack([byte(153), byte(218), byte(38)], SampleType::U8, vec![Adjustment::Invert]); + let p = px(&doc, 1, 1); + let want = lab_display([byte(102), byte(37), byte(217)]); + near3([p[0], p[1], p[2]], want, 1e-4, "inverted out-of-gamut purple"); + // Through clipped display RGB the result is another colour. + let clipped = photocraft_color::convert::srgb_to_lab(lab_display([byte(153), byte(218), byte(38)])); + let via_rgb = lab_display([1.0 - clipped[0] / 100.0, (-clipped[1] - 1.0 + 128.0) / 255.0, (-clipped[2] - 1.0 + 128.0) / 255.0]); + assert!(via_rgb.iter().zip(want).any(|(a, b)| (a - b).abs() > 4.0 / 255.0), "the clipped path differs: {via_rgb:?} vs {want:?}"); + // Levels on L only (a Lab record), half masked by opacity: L 0.6 → 0.6^(1/2) mixed halfway. + let ch = LevelsChannel { gamma: 2.0, ..Default::default() }; + let levels = Adjustment::Levels { + master: LevelsChannel::default(), + per_channel: [ch.clone(), LevelsChannel::default(), LevelsChannel::default()], + space: ToneSpace::Lab, + black: LevelsChannel::default(), + }; + let mut doc = lab_stack([0.6, byte(218), byte(38)], SampleType::U16, vec![levels]); + if let Some(l) = doc.layers.last_mut() { + l.opacity = 0.5; + } + let p = px(&doc, 1, 1); + let l = 0.6 + (adjust::levels_q(&ch, 0.6, Some(32768.0)) - 0.6) * 0.5; + near3([p[0], p[1], p[2]], lab_display([l, byte(218), byte(38)]), 1e-3, "levels on L at half opacity"); +} + +// Flattening a Lab document keeps its stored values, out-of-gamut colours included (the RGB +// compositor clipped them on the way through display RGB). +#[test] +fn lab_flatten_keeps_the_stored_values() { + let lab = [0.6, 218.0 / 255.0, 38.0 / 255.0]; + for depth in [SampleType::U8, SampleType::U16] { + let doc = lab_stack(lab, depth, vec![]); + let s = flatten_to_surface(&doc, doc.pixel_format(), None); + let got = s.pixel(2, 2); + assert!(got.iter().zip(lab).all(|(g, w)| (g - w).abs() < 1e-3), "{depth:?}: {got:?}"); + } +} + +// Anything the stored values can't carry exactly keeps the RGB compositor. +#[test] +fn lab_stacks_with_other_layers_keep_the_rgb_compositor() { + let lab = [0.6, 0.5, 0.5]; + assert!(native_lab::LabStack::new(&lab_stack(lab, SampleType::U8, vec![Adjustment::Invert])).is_some_and(|s| s.adjusts())); + let hue = Adjustment::HueSaturation { hue: 30.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() }; + assert!(native_lab::LabStack::new(&lab_stack(lab, SampleType::U8, vec![hue])).is_none()); + let mut multiply = lab_stack(lab, SampleType::U8, vec![Adjustment::Invert]); + if let Some(l) = multiply.layers.last_mut() { + l.blend = photocraft_color::blend::BlendMode::Multiply; + } + assert!(native_lab::LabStack::new(&multiply).is_none()); + let rgb = Document::with_background("rgb", Size::new(4, 4), ColorMode::Rgb, SampleType::U8, Color::rgb(0.5, 0.5, 0.5)); + assert!(native_lab::LabStack::new(&rgb).is_none()); +} + #[test] fn lab_documents_brightness_contrast_moves_lightness_only() { let lab = photocraft_color::convert::srgb_to_lab(MUTED); @@ -2564,6 +2996,75 @@ fn lab_documents_gradient_map_follows_lightness() { assert!((buf.px[0][0] - grey_buf.px[0][0]).abs() > 0.05, "{:?} {:?}", buf.px[0], grey_buf.px[0]); } +// ---------- Effect-map identity is computed once per layer per render (#4437) ---------- + +/// Effect layers of every kind the cache key walks: a plain layer, a styled group with a styled +/// child, and a layer whose mask hides its effects (rendered from a temporary copy). +fn identity_doc(depth: SampleType) -> Document { + let mut d = Document::with_background("t", Size::new(96, 96), ColorMode::Rgb, depth, Color::WHITE); + let fmt = d.pixel_format(); + let mut sq = Layer::raster("sq", fmt); + sq.surface_mut().unwrap().fill_rect(Rect::new(8, 8, 40, 40), &[1.0, 0.0, 0.0, 1.0]); + sq.effects.items = vec![stroke(3.0, StrokePosition::Outside), Effect::DropShadow(shadow(4.0, 120.0))]; + d.layers.push(sq); + let mut child = Layer::raster("child", fmt); + child.surface_mut().unwrap().fill_rect(Rect::new(50, 10, 86, 46), &[0.0, 0.6, 0.2, 1.0]); + child.effects.items = vec![stroke(2.0, StrokePosition::Outside)]; + let mut group = Layer::group("g", vec![child]); + group.effects.items = vec![Effect::DropShadow(shadow(5.0, 60.0))]; + d.layers.push(group); + let mut hidden = Layer::raster("hidden", fmt); + hidden.surface_mut().unwrap().fill_rect(Rect::new(10, 54, 46, 90), &[0.1, 0.2, 0.9, 1.0]); + hidden.effects.items = vec![stroke(3.0, StrokePosition::Outside)]; + hidden.mask = Some(LayerMask::reveal_all()); + hidden.advanced.layer_mask_hides_effects = true; + d.layers.push(hidden); + d +} + +#[test] +fn memoised_effect_identity_leaves_every_render_unchanged() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let d = identity_doc(depth); + let b = d.bounds(); + // One tile for the whole canvas never goes through `prepare_effects`; small tiles do. + purge_effect_cache(); + let whole = render_tiled(&d, b, 4096); + purge_effect_cache(); + let tiled = render_tiled(&d, b, 16); + let warm = render_tiled(&d, b, 24); + assert!(whole.px == tiled.px, "{depth:?}: tiled differs from one tile"); + assert!(whole.px == warm.px, "{depth:?}: warm differs from cold"); + assert!(whole.px.iter().any(|p| *p != [1.0; 4]), "{depth:?}: the effects should draw"); + // Edits still invalidate: a changed layer must not keep its old maps. + let mut e = d.clone(); + e.layers[1].effects.items = vec![Effect::DropShadow(shadow(9.0, 200.0))]; + let edited = render_tiled(&e, b, 16); + purge_effect_cache(); + assert!(edited.px == render_tiled(&e, b, 4096).px, "{depth:?}: stale maps after an edit"); + assert!(edited.px != whole.px, "{depth:?}: the edit should show"); + } +} + +#[test] +fn effect_identity_is_built_once_per_layer_not_per_tile() { + let mut d = identity_doc(SampleType::U8); + // Leave out the temporary-copy layer: it is rebuilt per call by design. + d.layers.pop(); + let patterns = pattern::PreparedPatterns::new(&d.patterns, pattern::PREPARED_PATTERN_BYTES); + let cx = Ctx::for_doc(&d, &patterns); + let b = d.bounds(); + let tiles = 6 * 6; + let first = render_tiled_with(&d, b, 16, &cx); + let built = cx.layer_keys.built.load(std::sync::atomic::Ordering::Relaxed); + // sq, the group and its child: three effect layers, however many tiles. + assert_eq!(built, 3, "identity built {built} times over {tiles} tiles"); + // A second render with the same context finds all of them memoised. + let second = render_tiled_with(&d, b, 16, &cx); + assert_eq!(cx.layer_keys.built.load(std::sync::atomic::Ordering::Relaxed), 3); + assert!(first.px == second.px); +} + #[test] fn render_refuses_rects_beyond_the_pixel_cap() { let doc = Document::new("c", Size::new(4, 4), ColorMode::Rgb, SampleType::U8); @@ -2638,3 +3139,106 @@ fn shape_layer_bounds_includes_vector_bounds_clipped_to_canvas_and_preserves_eff assert!(!b_fx.is_empty()); assert!(b_fx.contains_rect(&b)); } + +#[test] +#[ignore = "timing: cargo test --release -p photocraft-compose modern_lut_timing -- --ignored --nocapture"] +fn modern_lut_timing() { + let t = std::time::Instant::now(); + for _ in 0..100 { + std::hint::black_box(adjust::modern_lut(-50.0, 30.0)); + } + println!("modern_lut(-50, 30): {:?} each", t.elapsed() / 100); +} + +#[test] +#[ignore = "timing: cargo test --release -p photocraft-compose cmyk_ink_render_timing -- --ignored --nocapture"] +fn cmyk_ink_render_timing() { + // 24 MP CMYK8 photo-sized noise with one adjustment layer: the ink path (Invert) against the + // RGB compositor (Exposure, which the inks don't carry). + let (w, h) = (6000, 4000); + let mut d = Document::new("c", Size::new(w, h), ColorMode::Cmyk, SampleType::U8); + let mut l = Layer::raster("bg", d.pixel_format()); + let data: Vec = (0..(w * h) as usize) + .flat_map(|i| [(i % 251) as f32 / 255.0, (i % 241) as f32 / 255.0, (i % 239) as f32 / 255.0, (i % 97) as f32 / 255.0, 1.0]) + .collect(); + l.surface_mut().unwrap().write_region(Rect::new(0, 0, w as i32, h as i32), &data); + d.layers.push(l); + for adj in [Adjustment::Invert, Adjustment::Exposure { exposure: 0.5, offset: 0.0, gamma: 1.0 }] { + d.layers.truncate(1); + d.layers.push(Layer::new("adj", LayerContent::Adjustment(adj.clone()))); + let _ = render(&d, Rect::new(0, 0, 512, 512)); + let t = std::time::Instant::now(); + let b = render(&d, d.bounds()); + println!("{}: {:?} ({} px)", adj.label(), t.elapsed(), b.px.len()); + } +} + +// A pass-through group with an adjustment layer reaches the layers beneath, unless the group is +// the base of a clipping group: then Photoshop renders it isolated, and an adjustment with no +// content of its own in the group changes nothing (psd-tools passthrough_clipping_mask_adjustment). +#[test] +fn a_pass_through_clipping_base_keeps_its_adjustments_inside() { + let base = || { + let mut d = doc_white(4, 4); + let group = Layer::group("group", vec![Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert))]); + d.layers.push(group); + d + }; + // Unclipped: the invert reaches the white background. + assert!(close4(px(&base(), 1, 1), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&base(), 1, 1)); + // With a layer clipped to the group: the background stays white. + let mut d = base(); + let mut clip = Layer::raster("clipped", PixelFormat::RGBA8); + clip.clipped = true; + d.layers.push(clip); + assert!(close4(px(&d, 1, 1), [1.0, 1.0, 1.0, 1.0]), "{:?}", px(&d, 1, 1)); +} + +// A cache entry reserves the addresses of the tiles its key hashes, not their pixels (#4530): once +// the document lets go of a styled layer or group (an edit replaced its tiles, or it was +// deleted), the effect cache keeps none of its pixel memory alive, and the maps are still +// reused while the layer is unchanged. +#[test] +fn effect_cache_keeps_no_pixels_of_a_dropped_layer() { + let mut d = doc_white(300, 300); + let mut l = solid_layer("styled", Rect::new(20, 20, 280, 280), [0.9, 0.2, 0.2, 1.0]); + l.effects.items = vec![stroke(3.0, StrokePosition::Outside)]; + d.layers.push(l); + let mut g = Layer::group("g", vec![solid_layer("child", Rect::new(40, 40, 120, 120), [0.1, 0.2, 0.9, 1.0])]); + g.effects.items = vec![Effect::DropShadow(Shadow { size: 2.0, spread: 0.0, ..shadow(3.0, 90.0) })]; + d.layers.push(g); + let first = render(&d, d.bounds()); + let tile = |l: &Layer| l.surface().and_then(|s| s.tiles().next()).map(|(_, t)| t.clone()).unwrap(); + let own = tile(&d.layers[1]); + let LayerContent::Group(grp) = &d.layers[2].content else { panic!("not a group") }; + let child = tile(&grp.children[0]); + assert_eq!(render(&d, d.bounds()).px, first.px, "warm render"); + drop(d); + // Only this test's handles are left: the cache pins neither the layer's nor the child's pixels. + assert_eq!(std::sync::Arc::strong_count(&own), 1); + assert_eq!(std::sync::Arc::strong_count(&child), 1); +} + +// Concurrent callers share one memoized effect identity. +#[test] +fn concurrent_effect_identity_misses_compute_once() { + let mut doc = identity_doc(SampleType::U8); + // Make the existing pure identity hash long enough to expose overlapping misses. + doc.layers[1].effects.items = vec![Effect::DropShadow(shadow(4.0, 120.0)); 512]; + let layer = &doc.layers[1]; + let keys = LayerKeys::default(); + let start = std::sync::Barrier::new(8); + std::thread::scope(|scope| { + let handles: Vec<_> = (0..8) + .map(|_| { + scope.spawn(|| { + start.wait(); + keys.identity(layer, Some(&doc)) + }) + }) + .collect(); + let ids: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect(); + assert!(ids.iter().all(|id| *id == ids[0])); + }); + assert_eq!(keys.built.load(std::sync::atomic::Ordering::Relaxed), 1, "concurrent cache misses must compute one memoized identity"); +} diff --git a/crates/doc/src/analysis.rs b/crates/doc/src/analysis.rs index 840bf54990..9b1d8efdb0 100644 --- a/crates/doc/src/analysis.rs +++ b/crates/doc/src/analysis.rs @@ -30,14 +30,12 @@ impl MeasurementScale { } /// Photoshop's "Scale" column text, e.g. `1 pixels = 1.0000 pixels`. pub fn describe(&self) -> String { - format!("{} pixels = {:.4} {}", trim(self.pixel_length), self.logical_length, self.units) + // f64 Display already drops a whole number's `.0` (`5`, `0.5`) and, unlike an i64 cast, + // prints lengths past i64::MAX in full. + format!("{} pixels = {:.4} {}", self.pixel_length, self.logical_length, self.units) } } -fn trim(v: f64) -> String { - if v.fract() == 0.0 { format!("{}", v as i64) } else { format!("{v}") } -} - /// One Count tool group: its numbered markers (document pixel coordinates, in click order). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -133,6 +131,17 @@ mod tests { assert_eq!(MeasurementScale { pixel_length: 0.0, ..Default::default() }.factor(), 1.0); } + #[test] + fn scale_text_prints_large_and_fractional_pixel_lengths() { + let big = MeasurementScale { pixel_length: 1e20, logical_length: 1.0, units: "mm".into() }; + assert_eq!(big.describe(), "100000000000000000000 pixels = 1.0000 mm"); + assert_eq!(MeasurementScale::default().describe(), "1 pixels = 1.0000 pixels"); + let s = |p: f64| MeasurementScale { pixel_length: p, ..Default::default() }.describe(); + assert_eq!(s(5.0), "5 pixels = 1.0000 pixels"); + assert_eq!(s(0.5), "0.5 pixels = 1.0000 pixels"); + assert_eq!(s(12.25), "12.25 pixels = 1.0000 pixels"); + } + #[test] fn serde_defaults_fill_missing_fields() { let m: Measurement = serde_json::from_str("{}").unwrap(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c4f7f523d1..268b0b6b43 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. @@ -912,8 +912,8 @@ pub struct Document { pub variables: Variables, /// Window › Timeline (None until a video timeline is created). pub timeline: Option, - /// How the document's lossy file was encoded (its JPEG quality, WebP lossy or lossless), so - /// a save writes it back the same way rather than at fixed defaults. + /// How the document's lossy file was encoded (its JPEG quality, WebP lossy or lossless, + /// PNG stereo pairing), so a save writes it back the same way rather than at fixed defaults. pub file_encoding: FileEncoding, } @@ -932,6 +932,10 @@ pub struct FileEncoding { /// Lossy WebP quality. #[serde(skip_serializing_if = "Option::is_none")] pub webp_quality: Option, + /// PNG stereo pairing (the `sTER` chunk's mode byte, PNG Extensions 1.4.0 §3.6): `Some(0)` + /// side by side, `Some(1)` stacked, so saving a stereo PNG keeps the mark (#6063). + #[serde(skip_serializing_if = "Option::is_none")] + pub png_stereo: Option, } impl FileEncoding { diff --git a/crates/doc/src/mode.rs b/crates/doc/src/mode.rs index 96e129cbee..9722ce5b15 100644 --- a/crates/doc/src/mode.rs +++ b/crates/doc/src/mode.rs @@ -74,7 +74,7 @@ impl Duotone { let density = 1.0 - gray.clamp(0.0, 1.0); let mut out = [1.0f32; 3]; for ink in &self.inks { - let d = curve_eval(&ink.curve, density); + let d = ink.density(density); for (o, c) in out.iter_mut().zip(ink.color) { // Each ink filters light: full density transmits the ink colour. *o *= 1.0 - d * (1.0 - c); @@ -209,6 +209,22 @@ mod tests { assert!(c[0] > c[2], "{c:?}"); } + #[test] + fn duotone_render_clamps_ink_density_like_multichannel() { + // Curves above 100 % (accepted by `image.mode.duotone`) must not push the print out of + // gamut: red + blue at full density is black, not green. + let over = |name: &str, color| { + let mut ink = DuotoneInk::new(name, color); + ink.curve = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 2.0 }]; + ink + }; + let d = Duotone { inks: vec![over("Red", [1.0, 0.0, 0.0]), over("Blue", [0.0, 0.0, 1.0])], psd_raw: None }; + assert_eq!(d.render(0.0), [0.0, 0.0, 0.0]); + for g in [0.0, 0.25, 0.5, 1.0] { + assert!(d.render(g).iter().all(|c| (0.0..=1.0).contains(c)), "{g}: {:?}", d.render(g)); + } + } + #[test] fn stack_mode_ids_roundtrip() { for m in StackMode::ALL { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 741d927a3f..fb3bc3fbec 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -43,6 +43,15 @@ roxmltree = "0.20" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = "3" +# `file_cmds::same_file` on Windows: the volume serial number and file index, so a hard link to a +# file is that file (std's `MetadataExt::file_index` is unstable). +[target.'cfg(windows)'.dependencies] +same-file = "1" + +# 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/examples/bench_crop.rs b/crates/engine/examples/bench_crop.rs new file mode 100644 index 0000000000..1d301ff19c --- /dev/null +++ b/crates/engine/examples/bench_crop.rs @@ -0,0 +1,40 @@ +//! End-to-end timing of `image.crop`, upright and turned, on a two-layer document. +//! `cargo run --release -p photocraft-engine --example bench_crop -- [image] [reps]` +//! Without an image a 3859×2594 noise photo stands in (the size of the Great Wave scan). +use std::time::{Duration, Instant}; + +use photocraft_engine::Session; +use serde_json::{Value, json}; + +fn main() { + let args: Vec = std::env::args().collect(); + let reps: usize = args.get(2).and_then(|v| v.parse().ok()).unwrap_or(3); + let mut s = Session::new(); + match args.get(1) { + Some(path) => { + let bytes = std::fs::read(path).expect("read the image"); + let doc = photocraft_io::import(path, &bytes).expect("decode the image").document; + s.add_document(doc, None); + } + None => { + s.execute("file.new", json!({"width": 3859, "height": 2594, "background": "white"})).expect("new"); + s.execute("filter.noise.addNoise", json!({"amount": 50})).expect("noise"); + } + } + s.execute("layer.duplicate", json!({})).expect("a second layer"); + let cases: [(&str, Value); 3] = [ + ("upright 512x442", json!({"x": 40, "y": 40, "width": 512, "height": 442})), + ("turned 3° 512x442", json!({"x": 40, "y": 40, "width": 512, "height": 442, "angle": 3})), + ("turned 3° 3000x2000", json!({"x": 400, "y": 300, "width": 3000, "height": 2000, "angle": 3})), + ]; + for (label, p) in cases { + let mut best = Duration::MAX; + for _ in 0..reps { + let t = Instant::now(); + s.execute("image.crop", p.clone()).expect("crop"); + best = best.min(t.elapsed()); + s.execute("edit.undo", json!({})).expect("undo"); + } + println!("{label:24} best of {reps}: {best:.2?}"); + } +} diff --git a/crates/engine/examples/bench_web_palette.rs b/crates/engine/examples/bench_web_palette.rs index 5ed1c3a3b0..4b8b87616f 100644 --- a/crates/engine/examples/bench_web_palette.rs +++ b/crates/engine/examples/bench_web_palette.rs @@ -18,13 +18,13 @@ fn main() -> Result<(), Box> { let settings = WebSettings::from_params(&json!({"format": "gif", "colors": 128, "dither": "diffusion"}), "bench")?; let rect = Rect::new(0, 0, w as i32, h as i32); let started = Instant::now(); - let original = optimize(&pixels, w, rect, &settings, None, None, 72.0, false)?; + let original = optimize(&pixels, w, rect, &settings, None, None, None, 72.0, false)?; println!("24 MP generated palette: {:.1} ms", started.elapsed().as_secs_f64() * 1000.0); let mut edited = settings; edited.color_table = Some(original.color_table); edited.color_table_edits.insert(0, [0, 255, 0]); let started = Instant::now(); - let result = optimize(&pixels, w, rect, &edited, None, None, 72.0, false)?; + let result = optimize(&pixels, w, rect, &edited, None, None, None, 72.0, false)?; println!("24 MP edited fixed palette: {:.1} ms ({} bytes)", started.elapsed().as_secs_f64() * 1000.0, result.bytes.len()); Ok(()) } diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index 3b3ffd1f01..d881a6112b 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -45,11 +45,17 @@ pub struct Action { /// filled by Load Actions (`actions.load`) and written by Save Actions (`actions.save`). #[serde(default, skip_serializing_if = "Option::is_none")] pub set: Option, + /// Button Mode colour. Empty is none; otherwise one of [`BUTTON_COLORS`]. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub color: String, } +/// Button Mode colours, in Photoshop's order. Empty is no colour. +pub const BUTTON_COLORS: [&str; 8] = ["", "red", "orange", "yellow", "green", "blue", "violet", "gray"]; + impl Action { pub fn new(name: impl Into, steps: Vec<(String, Value)>) -> Self { - Self { name: name.into(), steps, disabled: BTreeSet::new(), dialogs: BTreeSet::new(), set: None } + Self { name: name.into(), steps, disabled: BTreeSet::new(), dialogs: BTreeSet::new(), set: None, color: String::new() } } /// Apply `f` to every per-step index set (`disabled`, `dialogs`) after steps moved. @@ -235,7 +241,7 @@ fn get(s: &mut Session, p: &Value) -> Result { let idx = resolve(&s.actions.list, p, "actions.get")?; let action = &s.actions.list[idx]; let steps: Vec = action.steps.iter().map(|(id, params)| json!([id, params])).collect(); - Ok(json!({"name": action.name, "steps": steps, "disabled": action.disabled, "dialogs": action.dialogs, "set": action.set})) + Ok(json!({"name": action.name, "steps": steps, "disabled": action.disabled, "dialogs": action.dialogs, "set": action.set, "color": action.color})) } fn record(s: &mut Session, p: &Value) -> Result { @@ -553,6 +559,25 @@ fn rename(s: &mut Session, p: &Value) -> Result { Ok(json!({"action": name, "index": idx})) } +fn set_color(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.setColor"; + not_playing(s, cmd)?; + let idx = resolve(&s.actions.list, p, cmd)?; + let Some(color) = p.get("color").and_then(Value::as_str) else { + return Err(bad(cmd, "needs \"color\"")); + }; + if !BUTTON_COLORS.contains(&color) { + return Err(bad(cmd, "color must be empty, red, orange, yellow, green, blue, violet, or gray")); + } + let Some(action) = s.actions.list.get_mut(idx) else { + return Err(bad(cmd, "no such action")); + }; + action.color = color.to_string(); + let name = action.name.clone(); + s.actions.touch(); + Ok(json!({"action": name, "color": color})) +} + pub fn specs() -> Vec { vec![ CommandSpec { @@ -580,7 +605,7 @@ pub fn specs() -> Vec { label: "Get Action", menu: &[], shortcut: None, - params: r##"{"action":name|index} → {name, steps:[[id, params]…], disabled:[step…], dialogs:[step…], set:name|null} (steps is the shape file.automate.batch and droplets take; disabled lists the 0-based steps playback skips; dialogs the steps whose dialog Photoshop would show; set the action set from Load Actions)"##, + params: r##"{"action":name|index} → {name, steps:[[id, params]…], disabled:[step…], dialogs:[step…], set:name|null, color:str} (steps is the shape file.automate.batch and droplets take; disabled lists the 0-based steps playback skips; dialogs the steps whose dialog Photoshop would show; set the action set from Load Actions; color is the Button Mode swatch, or empty)"##, enabled: always, run: get, journal: false, @@ -645,6 +670,16 @@ pub fn specs() -> Vec { run: rename, journal: false, }, + CommandSpec { + id: "actions.setColor", + label: "Set Action Color", + menu: &[], + shortcut: None, + params: r##"{"action":name|index, "color":""|"red"|"orange"|"yellow"|"green"|"blue"|"violet"|"gray"} → {action, color}. The Button Mode colour. Empty clears it."##, + enabled: always, + run: set_color, + journal: false, + }, ] } @@ -957,6 +992,109 @@ mod tests { } } + #[test] + fn recorded_layer_selection_replays_by_name_on_another_document() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap(); + let original = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.setProps", json!({"name": "Photo"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "LogoBase"})).unwrap(); + s.execute("actions.record", json!({"name": "Duplicate photo"})).unwrap(); + s.execute("layer.select", json!({"layer": original.0, "mode": "replace"})).unwrap(); + // The target name must be captured now, not when recording stops. + s.execute("layer.setProps", json!({"name": "Retouched"})).unwrap(); + s.execute("layer.duplicate", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps[0].1, json!({"name":"Photo", "mode":"replace"})); + // Persisted actions must work in an entirely different session too. + let saved = serde_json::to_string(&s.actions.list).unwrap(); + let mut replay = Session::new(); + replay.actions.list = serde_json::from_str(&saved).unwrap(); + replay.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap(); + replay.execute("layer.setProps", json!({"name": "Photo"})).unwrap(); + let target = replay.active().unwrap().active_layer.unwrap(); + assert_ne!(original, target); + replay.execute("layer.new.layer", json!({"name": "LogoBase"})).unwrap(); + let result = replay.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(result["ran"], 3); + assert_eq!(replay.active().unwrap().doc.layer(target).unwrap().name, "Retouched"); + let active = replay.active().unwrap().active_layer.unwrap(); + assert_eq!(replay.active().unwrap().doc.layer(active).unwrap().name, "Retouched copy"); + } + } + + #[test] + fn named_selection_preserves_modes_and_duplicate_occurrences() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("layer.setProps", json!({"name":"Photo"})).unwrap(); + let first = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.group", json!({"name":"Group"})).unwrap(); + // A duplicate nested in a group must be found even when that group is collapsed. + let second = s + .edit("child", |doc, active| { + let child = photocraft_doc::Layer::raster("Photo", doc.pixel_format()); + let id = child.id; + if let photocraft_doc::LayerContent::Group(group) = &mut doc.layer_mut(active.unwrap()).unwrap().content { + group.children.push(child); + group.expanded = false; + } + Ok(id) + }) + .unwrap(); + s.execute("actions.record", json!({"name":"Select duplicates"})).unwrap(); + s.execute("layer.select", json!({"layer":first.0})).unwrap(); + s.execute("layer.select", json!({"layer":second.0,"mode":"add"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps[0].1, json!({"name":"Photo","occurrence":0})); + assert_eq!(s.actions.list[0].steps[1].1, json!({"name":"Photo","occurrence":1,"mode":"add"})); + s.execute("layer.select", json!({"layer":first.0})).unwrap(); + let r = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + assert_eq!(s.active().unwrap().selected_layers(), vec![first, second]); + s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"toggle"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers(), vec![first]); + s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"range"})).unwrap(); + assert!(s.active().unwrap().selected_layers().contains(&second)); + // Main's imported .atn steps use a string-valued `layer` and choose the topmost match. + s.execute("layer.select", json!({"layer":"Photo"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers(), vec![second]); + s.execute("layer.select", json!({"name":"Photo","occurrence":0,"mode":"add"})).unwrap(); + s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"remove"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers(), vec![first]); + // Removing an unselected layer does not add it; removing the last target keeps it. + s.execute("layer.select", json!({"layer":"Photo","mode":"remove"})).unwrap(); + s.execute("layer.select", json!({"name":"Photo","occurrence":0,"mode":"remove"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers(), vec![first]); + for p in [ + json!({"name":"absent"}), + json!({"name":"Photo"}), + json!({"name":1}), + json!({"name":"Photo","occurrence":-1}), + json!({"name":"Photo","occurrence":1.5}), + json!({"name":"Photo","occurrence":99}), + json!({"name":"Photo","layer":first.0}), + json!({"layer":first.0,"occurrence":0}), + ] { + let before = s.active().unwrap().selected_layers(); + assert!(s.execute("layer.select", p.clone()).is_err(), "{p}"); + assert_eq!(s.active().unwrap().selected_layers(), before); + } + s.actions.list.push(Action { + name: "Missing".into(), + steps: vec![("layer.select".into(), json!({"name":"absent"})), ("layer.duplicate".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); + let before = s.active().unwrap().doc.walk().len(); + let result = s.execute("actions.play", json!({"action":"Missing"})).unwrap(); + assert_eq!(result["ran"], 0); + assert!(result["failed"]["error"].as_str().unwrap().contains("absent")); + assert_eq!(s.active().unwrap().doc.walk().len(), before); + } + #[test] fn play_stops_at_the_failing_step_and_reports_it() { let mut s = Session::new(); @@ -1308,4 +1446,24 @@ mod tests { let first = &s.documents()[0].doc; assert_eq!((first.size.width, first.size.height), (48, 48), "the recording document keeps its own crop"); } + + #[test] + fn button_color_is_one_of_the_photoshop_swatches() { + let mut s = Session::new(); + s.execute("actions.record", json!({"name": "Print"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let set = s.execute("actions.setColor", json!({"action": "Print", "color": "blue"})).unwrap(); + assert_eq!(set["color"], "blue"); + assert_eq!(s.actions.list[0].color, "blue"); + assert_eq!(s.execute("actions.get", json!({"action": 0})).unwrap()["color"], "blue"); + let saved = serde_json::to_string(&s.actions.list).unwrap(); + assert!(saved.contains("blue")); + s.execute("actions.setColor", json!({"action": 0, "color": ""})).unwrap(); + assert!(s.actions.list[0].color.is_empty()); + assert!(!serde_json::to_string(&s.actions.list).unwrap().contains("color"), "an empty colour is omitted"); + for bad in [json!({"action": 0, "color": "pink"}), json!({"action": 0}), json!({"action": 4, "color": "red"}), json!({"color": "red"})] { + assert!(s.execute("actions.setColor", bad).is_err()); + } + assert!(s.actions.list[0].color.is_empty(), "rejected colours change nothing"); + } } diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index c1393bb94c..7e3b05b44c 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -270,6 +270,8 @@ pub(crate) fn adjust_as_smart_filter(s: &mut Session, kind: &str, label: &str, p if !smart { return Ok(false); } + // A pixel or full lock refuses the smart filter, as for `filter.*` on a smart object. + crate::commands::check_pixels_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; // The recorded params are the command's own, minus the engine's private `__kind` key. let mut params = p.clone(); if let Value::Object(m) = &mut params { @@ -296,6 +298,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); @@ -335,7 +349,7 @@ fn shadows_highlights_params(p: &Value) -> ShadowsHighlights { fn replace_color(s: &mut Session, p: &Value) -> Result { const CMD: &str = "image.adjustments.replaceColor"; - let c = crate::commands::color_param(p, "color", s.tools.foreground); + let c = crate::commands::color_param(CMD, p, "color", s.tools.foreground)?; let fuzz = num(p, "fuzziness", 40.0).clamp(0.0, 200.0); let (h, sat, l) = (num(p, "hue", 0.0).clamp(-180.0, 180.0), num(p, "saturation", 0.0).clamp(-100.0, 100.0), num(p, "lightness", 0.0).clamp(-100.0, 100.0)); // Localized Color Clusters, as in Select › Color Range: the colour only matches within Range diff --git a/crates/engine/src/adjust_cmds/tests.rs b/crates/engine/src/adjust_cmds/tests.rs index 8fa053e86e..1dc0c88656 100644 --- a/crates/engine/src/adjust_cmds/tests.rs +++ b/crates/engine/src/adjust_cmds/tests.rs @@ -353,7 +353,6 @@ fn invert_with_the_mask_targeted_inverts_the_mask() { let mut s = session(depth, "rgb"); s.execute("select.rect", json!({"x": 24, "y": 0, "width": 24, "height": 32})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); let (pixels, off_canvas) = (rgba(&s, 3, 3), mask_at(&s, -500, 3)); let steps = s.active().unwrap().history.past_len(); s.execute("image.adjustments.invert", json!({"target": "mask"})).unwrap(); @@ -372,6 +371,31 @@ fn invert_with_the_mask_targeted_inverts_the_mask() { } } +/// Photoshop: a mask made from the selection (Reveal Selection, the Layers panel's mask button) +/// takes the selection with it, as Hide Selection does. So ⌘I on the new mask right away inverts +/// all of it; with the selection kept it inverted only the inside, leaving the mask all black. +/// Undo brings the selection back with the mask gone, in one step. +#[test] +fn reveal_selection_drops_the_selection_so_invert_flips_the_whole_mask() { + for depth in DEPTHS { + let mut s = session(depth, "rgb"); + s.execute("select.rect", json!({"x": 24, "y": 0, "width": 24, "height": 32})).unwrap(); + let selection = s.active().unwrap().doc.selection.clone(); + let steps = s.active().unwrap().history.past_len(); + s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none(), "depth {depth}: the selection became the mask"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one step"); + assert_eq!((mask_at(&s, 3, 3), mask_at(&s, 30, 3)), (0.0, 1.0)); + s.execute("image.adjustments.invert", json!({"target": "mask"})).unwrap(); + assert_eq!((mask_at(&s, 3, 3), mask_at(&s, 30, 3)), (1.0, 0.0), "depth {depth}: inside and outside both flip"); + s.undo(); + s.undo(); + let st = s.active().unwrap(); + assert_eq!(st.doc.selection, selection, "undo restores the selection"); + assert!(st.doc.layer(st.active_layer.unwrap()).unwrap().mask.is_none(), "and removes the mask"); + } +} + // ---------- Image › Adjustments as smart filters (FILE-215-4) ---------- /// A painted pixel layer converted to a smart object, over the painted Background. @@ -455,6 +479,26 @@ fn adjustment_smart_filters_take_a_selection_as_their_mask() { assert_eq!(at(&now, 40, 16), at(&original, 40, 16), "outside the mask the composite is unchanged"); } +/// #5655: as for `filter.*` smart filters, a pixel or full lock refuses an adjustment smart +/// filter, leaving the layer and the history as they were. +#[test] +fn locked_smart_objects_refuse_adjustment_smart_filters() { + let adjustments = + [("image.adjustments.invert", json!({})), ("image.adjustments.shadowsHighlights", json!({"shadowAmount": 100, "blackClip": 0, "whiteClip": 0}))]; + for lock in [json!({"all": true}), json!({"pixels": true})] { + for (cmd, p) in &adjustments { + let mut s = smart_session(); + s.execute("layer.lockLayers", lock.clone()).unwrap(); + let (steps, flat) = (s.active().unwrap().history.past_len(), flatten(&s)); + let r = s.execute(cmd, p.clone()); + assert!(r.is_err(), "{cmd} added a smart filter to a smart object locked with {lock}: {r:?}"); + assert!(smart_filters_of(&s).is_empty(), "{cmd} {lock}: nothing recorded"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{cmd} {lock}: no history step"); + assert_eq!(flatten(&s), flat, "{cmd} {lock}: pixels untouched"); + } + } +} + /// A wrong-typed value errors before anything is recorded (never a panic), and the adjustments /// that can't re-render from a source (Replace Color, Match Color) stay off a smart object. #[test] @@ -492,7 +536,6 @@ fn a_targeted_mask_enables_editing_it_on_any_layer() { s.execute("layer.delete", json!({})).unwrap(); s.execute("select.rect", json!({"x": 24, "y": 0, "width": 24, "height": 32})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); let pixels = rgba(&s, 23, 3); s.execute("filter.blur.gaussianBlur", json!({"radius": 3, "target": "mask"})).unwrap(); assert!(mask_at(&s, 23, 3) > 0.0 && mask_at(&s, 23, 3) < 1.0, "the mask's edge is blurred"); @@ -504,6 +547,106 @@ fn a_targeted_mask_enables_editing_it_on_any_layer() { assert_eq!(rgba(&s, 23, 3), pixels); } +/// #5335: Curves (⌘M) edits the targeted layer mask, saved channel or Quick Mask at every depth. +#[test] +fn curves_apply_to_non_colour_targets_at_every_depth() { + let area = photocraft_geom::Rect::from_xywh(0, 0, 48, 32); + for depth in DEPTHS { + for target in ["mask", "alpha", "quickMask"] { + let mut s = session(depth, "rgb"); + s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + let p = match target { + "mask" => { + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + json!({"points": [[0, 0], [128, 200], [255, 255]], "target": "mask"}) + } + "alpha" => { + s.execute("channel.new", json!({"name": "Sky sel", "fill": "white"})).unwrap(); + json!({"points": [[0, 0], [128, 200], [255, 255]], "target": {"channel": 0}}) + } + _ => { + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + json!({"points": [[0, 0], [128, 200], [255, 255]], "target": "quickMask"}) + } + }; + let edit_target = p.get("target").cloned().unwrap_or(json!(null)); + s.edit("levels", |doc, active| { + let (surf, _) = crate::channel_cmds::target_surface(doc, *active, &json!({ "target": edit_target })).unwrap(); + surf.fill_rect(area, &[0.5]); + Ok(()) + }) + .unwrap(); + let read = |s: &Session| { + let d = s.active().unwrap(); + let layer = d.doc.layer(d.active_layer.unwrap()).unwrap(); + let t = match target { + "quickMask" => d.doc.quick_mask.as_ref().unwrap().surface.sample_channel(8, 8, 0), + "alpha" => d.doc.channels[0].surface.sample_channel(8, 8, 0), + _ => layer.mask.as_ref().unwrap().surface.sample_channel(8, 8, 0), + }; + (rgba(s, 8, 8), t) + }; + let ((pixels, before), past) = (read(&s), s.active().unwrap().history.past_len()); + s.execute("image.adjustments.curves", p).unwrap(); + let (pixels_after, after) = read(&s); + let case = format!("{target} at {depth}"); + assert_eq!(pixels_after, pixels, "{case}: colour pixels stay put"); + assert!(after > before + 0.1, "{case}: the curve lifted the midtone ({before} → {after})"); + assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{case}"); + s.undo(); + assert!((read(&s).1 - before).abs() < 1e-5, "{case}: undo restores the target"); + } + } +} + +/// #5335: a layer mask, saved channel or Quick Mask holds gray levels whatever the document's +/// colour mode: inverting 25% grey gives 75% grey in Lab and CMYK, not an untouched plane (Lab +/// ignores the composite) or an ink-separated result (CMYK curves the inks). +#[test] +fn curves_on_grey_targets_ignore_the_document_colour_mode() { + let area = photocraft_geom::Rect::from_xywh(0, 0, 48, 32); + for mode in ["lab", "cmyk"] { + for depth in [8u64, 16] { + for target in ["mask", "alpha", "quickMask"] { + let mut s = session(depth, mode); + let p = match target { + "mask" => { + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + json!("mask") + } + "alpha" => { + s.execute("channel.new", json!({"name": "Sky sel", "fill": "white"})).unwrap(); + json!({"channel": 0}) + } + _ => { + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + json!("quickMask") + } + }; + s.edit("levels", |doc, active| { + let (surf, _) = crate::channel_cmds::target_surface(doc, *active, &json!({ "target": p })).unwrap(); + surf.fill_rect(area, &[0.25]); + Ok(()) + }) + .unwrap(); + let read = |s: &Session| match target { + "quickMask" => doc(s).quick_mask.as_ref().unwrap().surface.sample_channel(8, 8, 0), + "alpha" => doc(s).channels[0].surface.sample_channel(8, 8, 0), + _ => mask_at(s, 8, 8), + }; + let before = read(&s); + let pixels = rgba(&s, 8, 8); + s.execute("image.adjustments.curves", json!({"points": [[0, 255], [255, 0]], "target": p})).unwrap(); + let after = read(&s); + let case = format!("{mode} {depth}-bit {target}"); + assert!((before - 0.25).abs() < 0.01, "{case}: filled with 25% grey ({before})"); + assert!((after - 0.75).abs() < 0.02, "{case}: inverting 25% grey gives 75% grey ({after})"); + assert_eq!(rgba(&s, 8, 8), pixels, "{case}: colour pixels stay put"); + } + } + } +} + #[test] fn replace_color_localized_clusters_only_change_the_colour_near_the_sample() { // The same cream on the left (x < 8) and the right (x ≥ 40), grey between. @@ -538,3 +681,53 @@ fn replace_color_localized_clusters_only_change_the_colour_near_the_sample() { let err = s.execute("image.adjustments.replaceColor", json!({"points": [[1]]})).unwrap_err(); assert!(err.to_string().contains("bad point"), "{err}"); } + +/// #6095: ⌘I in Quick Mask inverts the whole mask, not only where it was white, so leaving Quick +/// Mask selects everything but the original rectangle, whichever way the colour reads. With +/// Color Indicates Selected Areas and nothing selected, it selects everything. A black alpha +/// channel inverts to white. +#[test] +fn invert_turns_the_black_of_quick_mask_and_alpha_channels_white() { + for depth in DEPTHS { + let mut s = session(depth, "rgb"); + let pixels = rgba(&s, 2, 2); + s.execute("select.rect", json!({"x": 10, "y": 8, "width": 20, "height": 10})).unwrap(); + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + s.execute("image.adjustments.invert", json!({"target": "quickMask"})).unwrap(); + let q = &doc(&s).quick_mask.as_ref().unwrap().surface; + let (inside, outside) = (q.sample_channel(15, 12, 0), q.sample_channel(2, 2, 0)); + assert!(inside < 0.01 && outside > 0.99, "{depth}-bit Quick Mask inverted: inside {inside}, outside {outside}"); + s.execute("select.editInQuickMaskMode", json!({"on": false})).unwrap(); + let sel = doc(&s).selection.as_ref().expect("an inverse selection after Quick Mask"); + let (inside, outside) = (sel.sample_channel(15, 12, 0), sel.sample_channel(2, 2, 0)); + assert!(inside < 0.01 && outside > 0.99, "{depth}-bit selection: inside {inside}, outside {outside}"); + assert_eq!(rgba(&s, 2, 2), pixels, "{depth}-bit: the layer is untouched"); + + s.execute("select.deselect", json!({})).unwrap(); + s.execute("channel.new", json!({"name": "Black", "fill": "black"})).unwrap(); + s.execute("image.adjustments.invert", json!({"target": {"channel": 0}})).unwrap(); + let a = doc(&s).channels[0].surface.sample_channel(40, 30, 0); + assert!(a > 0.99, "{depth}-bit black alpha channel inverts to white ({a})"); + s.undo(); + assert!(doc(&s).channels[0].surface.sample_channel(40, 30, 0) < 0.01, "{depth}-bit: undo restores the black"); + + // Color Indicates Selected Areas: a rectangle comes back inverted, and nothing selected + // comes back as everything selected. + for (start, inside, outside) in [("rectangle", 0.0, 1.0), ("nothing", 1.0, 1.0)] { + let mut s = session(depth, "rgb"); + s.execute("channel.options", json!({"channel": "quickMask", "indicates": "selected"})).unwrap(); + if start == "rectangle" { + s.execute("select.rect", json!({"x": 10, "y": 8, "width": 20, "height": 10})).unwrap(); + } + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + s.execute("image.adjustments.invert", json!({"target": "quickMask"})).unwrap(); + s.execute("select.editInQuickMaskMode", json!({"on": false})).unwrap(); + let sel = doc(&s).selection.as_ref().unwrap_or_else(|| panic!("{depth}-bit Selected Areas, {start}: a selection after Quick Mask")); + let got = (sel.sample_channel(15, 12, 0), sel.sample_channel(2, 2, 0)); + assert!( + (got.0 - inside).abs() < 0.01 && (got.1 - outside).abs() < 0.01, + "{depth}-bit Selected Areas, {start}: selection (inside, outside) {got:?}" + ); + } + } +} diff --git a/crates/engine/src/align_cmds.rs b/crates/engine/src/align_cmds.rs index b54f4b7a73..c7df82902f 100644 --- a/crates/engine/src/align_cmds.rs +++ b/crates/engine/src/align_cmds.rs @@ -381,7 +381,7 @@ mod tests { let m = r["aligned"][0]["matrix"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::>(); assert!((m[2] + 6.0).abs() < 1.0 && (m[5] + 5.0).abs() < 1.0, "{r}"); assert!(s.execute("edit.autoAlignLayers", json!({"projection": "sideways"})).is_err()); - assert!(s.execute("edit.autoAlignLayers", json!({"reference": 9999})).is_err()); + assert!(s.execute("edit.autoAlignLayers", json!({"reference": u64::MAX})).is_err()); } #[test] diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 64af849e59..92b630aa44 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -177,6 +177,12 @@ fn set_quiet(s: &mut Session, f: impl FnOnce(&mut Document)) -> Result<()> { pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let (before, history, prior) = (st.doc.clone(), st.history.clone(), st.layer_target()); + // An inner command may select a layer that disappears when the compound rolls back. + // Selection, channel targeting and coalescing must roll back with the document (#4975). + let anchor = st.layer_anchor; + let channel_view = st.channel_view.clone(); + let coalesce = st.coalesce.clone(); + let was_clean = !st.is_dirty(); let journal = s.journal.len(); // Color Sampler points are view state a step may move (crop, rotation): roll them back too, so // a failed second step can't leave them pinned to pixels the restored document no longer has. @@ -187,23 +193,37 @@ pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess } let st = s.active_mut().ok_or(EngineError::NoDocument)?; st.history = history; - match r { + let result = match r { Ok(v) => { // As in `Session::edit`: undo targets what was selected just before the step (#1356). st.history.set_current_layers(prior); st.history.record(label, before, st.layer_target()); - st.history.trim(&st.doc); + st.trim_history(); st.coalesce = None; Ok(v) } Err(e) => { st.doc = before; + st.active_layer = prior.active; + st.selected_layers = prior.selected; + st.layer_anchor = anchor; + st.channel_view = channel_view; + st.coalesce = coalesce; st.color_samplers = samplers; st.revision += 1; + // A failed compound does not make an otherwise untouched document dirty. + if was_clean { + st.saved_revision = st.revision; + } st.last_damage = None; Err(e) } + }; + if result.is_err() { + // The app-wide tool colours/mask mode follow the restored channel target. + s.sync_tools_mask_target(); } + result } /// Current time as ISO 8601 UTC (`2026-10-01T12:34:56Z`); empty on the web (no clock). @@ -702,10 +722,12 @@ fn group_index(d: &Document, p: &Value) -> Option { (i < d.measurement.count_groups.len()).then_some(i) } -fn color_of(p: &Value, key: &str) -> Option { - p.get(key)?; - let c = crate::commands::color_param(p, key, [1.0, 0.0, 0.0, 1.0]); - Some(Color::rgb(c[0], c[1], c[2])) +fn color_of(cmd: &str, p: &Value, key: &str) -> Result> { + if p.get(key).is_none() { + return Ok(None); + } + let c = crate::commands::color_param(cmd, p, key, [1.0, 0.0, 0.0, 1.0])?; + Ok(Some(Color::rgb(c[0], c[1], c[2]))) } /// Photoshop's colours for new count groups, in order. @@ -735,7 +757,12 @@ fn count_add(s: &mut Session, p: &Value) -> Result { if !at[0].is_finite() || !at[1].is_finite() { return Err(bad("count.add", "coordinates must be finite")); } - let gi = p.get("group").and_then(Value::as_u64).map(|v| v as usize); + let gi = p.get("group").and_then(Value::as_u64).map(|v| usize::try_from(v).map_err(|_| bad("count.add", "no such group"))).transpose()?; + // The first marker creates group zero, but an explicit target must never be redirected. + let group_count = doc(s)?.measurement.count_groups.len().max(1); + if gi.is_some_and(|i| i >= group_count) { + return Err(bad("count.add", "no such group")); + } let n = s.edit("Count", |d, _| { if d.measurement.count_groups.is_empty() { let g = new_group(d, None, None); @@ -820,7 +847,7 @@ fn count_clear(s: &mut Session, p: &Value) -> Result { fn count_new_group(s: &mut Session, p: &Value) -> Result { let name = p.get("name").and_then(Value::as_str).map(str::to_string); - let color = color_of(p, "color"); + let color = color_of("count.newGroup", p, "color")?; s.edit("New Count Group", |d, _| { let g = new_group(d, name, color); d.measurement.count_groups.push(g); @@ -836,7 +863,9 @@ fn count_delete_group(s: &mut Session, p: &Value) -> Result { s.edit("Delete Count Group", |d, _| { d.measurement.count_groups.remove(g); let n = d.measurement.count_groups.len(); - d.measurement.active_count_group = d.measurement.active_count_group.min(n.saturating_sub(1)); + // Removing a group before the active one shifts it down a slot. + let a = d.measurement.active_count_group; + d.measurement.active_count_group = (if g < a { a - 1 } else { a }).min(n.saturating_sub(1)); Ok(()) })?; Ok(count_json(doc(s)?.as_ref())) @@ -867,7 +896,7 @@ fn count_set_group(s: &mut Session, p: &Value) -> Result { if let Some(n) = p.get("name").and_then(Value::as_str) { grp.name = n.to_string(); } - if let Some(c) = color_of(p, "color") { + if let Some(c) = color_of(CMD, p, "color")? { grp.color = c; } if let Some(v) = whole_size(CMD, p, "markerSize", 1, 10)? { @@ -1101,8 +1130,12 @@ fn place_scale_marker(s: &mut Session, p: &Value) -> Result { }; // Bottom-left with a margin; the text sits under (or over) the bar. let margin = (w.min(h) * 0.03).round().max(2.0); - let bar_h = (font_size / 4.0).round().clamp(2.0, h / 4.0); - let text_h = if show_text { font_size * 1.25 } else { 0.0 }; + // `fontSize` is in points; `type.create` renders it at resolution/72 px per point, so lay out + // the bar and label in those pixels (at 300 ppi, 12 pt is 50 px) (#5754). + let dpi = f64::from(d.resolution_dpi); + let font_px = font_size * if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let bar_h = (font_px / 4.0).round().clamp(2.0, h / 4.0); + let text_h = if show_text { font_px * 1.25 } else { 0.0 }; // Keep the bar on the canvas when the text under it doesn't fit; the label is clipped instead (#932). let bar_y = if text_top { h - margin - bar_h } else { h - margin - bar_h - text_h }.max(0.0); // Likewise pull a bar nearly as wide as the image in from the right edge (#932). @@ -1123,7 +1156,7 @@ fn place_scale_marker(s: &mut Session, p: &Value) -> Result { })?; let mut text_layer = Value::Null; if show_text { - let baseline = if text_top { f64::from(bar.y0) - font_size * 0.3 } else { f64::from(bar.y1) + font_size }; + let baseline = if text_top { f64::from(bar.y0) - font_px * 0.3 } else { f64::from(bar.y1) + font_px }; let mut tp = json!({"x": bar.x0, "y": baseline, "text": label, "size": font_size, "color": [rgb[0], rgb[1], rgb[2], 1.0], "name": label}); if let Some(f) = &font { tp["font"] = json!(f); diff --git a/crates/engine/src/analysis_cmds/tests.rs b/crates/engine/src/analysis_cmds/tests.rs index e5272d558d..f9138e8b65 100644 --- a/crates/engine/src/analysis_cmds/tests.rs +++ b/crates/engine/src/analysis_cmds/tests.rs @@ -1,5 +1,5 @@ use super::*; -use photocraft_doc::ColorMode; +use photocraft_doc::{ColorMode, LayerContent, LayerId}; fn session(w: u32, h: u32, depth: u32) -> Session { let mut s = Session::new(); @@ -11,6 +11,69 @@ fn d(s: &Session) -> Arc { s.active().unwrap().doc.clone() } +#[test] +fn failed_compound_restores_layer_target_history_and_journal() { + let mut s = session(80, 60, 8); + let background = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "Selected"})).unwrap(); + let selected = s.active().unwrap().active_layer.unwrap(); + { + let st = s.active_mut().unwrap(); + st.selected_layers = vec![background, selected]; + st.layer_anchor = Some(background); + st.coalesce = Some("previous-gesture".into()); + } + + let st = s.active().unwrap(); + let prior_doc = st.doc.clone(); + let prior_history = st.history.past_len(); + let prior_channel_view = st.channel_view.clone(); + let prior_mask_targeted = s.tools.mask_targeted; + let prior_journal = s.journal.len(); + let error = compound(&mut s, "Place Scale Marker", |s| -> Result<()> { + // Mirrors a scale-bar creation followed by a failed type.create: the + // inner command selects a new layer, which rollback must remove. + s.execute("layer.new.layer", json!({"name": "Temporary Scale Bar"}))?; + s.active_mut().unwrap().channel_view.target = crate::channel_cmds::ChannelTarget::LayerMask; + s.sync_tools_mask_target(); + Err(bad("test", "synthetic second step failed")) + }) + .unwrap_err(); + + assert!(error.to_string().contains("synthetic second step failed")); + let st = s.active().unwrap(); + assert!(Arc::ptr_eq(&prior_doc, &st.doc), "the original document is restored"); + assert_eq!(st.history.past_len(), prior_history, "no undo step remains"); + assert_eq!(s.journal.len(), prior_journal, "no rolled-back command is journaled"); + assert_eq!(st.active_layer, Some(selected)); + assert_eq!(st.selected_layers, vec![background, selected]); + assert_eq!(st.layer_anchor, Some(background)); + assert_eq!(st.channel_view, prior_channel_view); + assert_eq!(s.tools.mask_targeted, prior_mask_targeted, "tool mask target follows the restored channel"); + assert_eq!(st.coalesce.as_deref(), Some("previous-gesture")); + assert!(st.doc.layer(selected).is_some()); + + // Subsequent ordinary commands target the original layer, not the + // nonexistent scale bar. + s.execute("layer.setProps", json!({"layer": selected.0, "visible": false})).unwrap(); + assert!(!s.active().unwrap().doc.layer(selected).unwrap().visible); +} + +#[test] +fn failed_compound_preserves_a_clean_document() { + let mut s = session(80, 60, 8); + assert!(!s.active().unwrap().is_dirty()); + let prior_doc = d(&s); + let error = compound(&mut s, "Place Scale Marker", |s| -> Result<()> { + s.execute("layer.new.layer", json!({"name": "Temporary Scale Bar"}))?; + Err(bad("test", "synthetic second step failed")) + }) + .unwrap_err(); + assert!(error.to_string().contains("synthetic second step failed")); + assert!(Arc::ptr_eq(&prior_doc, &d(&s))); + assert!(!s.active().unwrap().is_dirty(), "a rolled-back edit does not need saving"); +} + #[test] fn record_measurements_refuses_an_area_too_large_to_measure() { // #933: with no selection the whole document is measured, and `width * height` overflowed @@ -228,6 +291,27 @@ fn count_groups_markers_and_undo() { assert!(s.execute("count.add", json!({"x": 1})).is_err()); } +#[test] +fn deleting_an_earlier_count_group_keeps_the_active_group() { + let mut s = session(100, 100, 8); + s.execute("count.add", json!({"x": 10, "y": 10})).unwrap(); + for _ in 0..3 { + s.execute("count.newGroup", json!({})).unwrap(); + } + s.execute("count.setGroup", json!({"group": 2})).unwrap(); + let r = s.execute("count.deleteGroup", json!({"group": 0})).unwrap(); + assert_eq!(r["activeGroup"], 1); + assert_eq!(r["groups"][1]["name"], "Count Group 3"); + // A later group leaves the active index alone. + let r = s.execute("count.deleteGroup", json!({"group": 2})).unwrap(); + assert_eq!(r["activeGroup"], 1); + assert_eq!(r["groups"][1]["name"], "Count Group 3"); + // Deleting the active last group selects the new last group. + let r = s.execute("count.deleteGroup", json!({})).unwrap(); + assert_eq!(r["activeGroup"], 0); + assert_eq!(r["groups"][0]["name"], "Count Group 2"); +} + #[test] fn log_delete_and_csv_export() { let mut s = session(10, 10, 8); @@ -292,6 +376,27 @@ fn place_scale_marker_builds_one_group_in_one_step() { assert!(d(&s).layers.iter().all(|l| l.name != "Measurement Scale Marker")); } +#[test] +fn place_scale_marker_lays_out_its_label_in_points_at_high_resolution() { + // `fontSize` is in points and `type.create` renders it at resolution/72 px per point, but the + // layout treated it as pixels: at 300 ppi a 12 pt label (50 px) overlapped the bar. + for res in [72.0, 300.0] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1200, "height": 900, "resolution": res, "background": "white"})).unwrap(); + let r = s.execute("image.analysis.placeScaleMarker", json!({"fontSize": 12, "textPosition": "bottom"})).unwrap(); + let bar_bottom = r["barRect"][3].as_f64().unwrap(); + let doc = d(&s); + let Some(LayerContent::Text(t)) = doc.layer(LayerId(r["text"].as_u64().unwrap())).map(|l| &l.content) else { panic!("a type layer: {r}") }; + assert_eq!(t.size_pt, 12.0, "{res} ppi: type.create still gets points"); + let layout = photocraft_text::shared().lock().unwrap().layout(t, doc.resolution_dpi); + let b = layout.bounds().expect("the label has a line"); + let ty = t.transform.m[5]; + let (top, bottom) = (f64::from(b[1]) + ty, f64::from(b[3]) + ty); + assert!(top >= bar_bottom - 0.5, "{res} ppi: label top {top} overlaps the bar ending at {bar_bottom}: {r}"); + assert!(bottom <= 900.5, "{res} ppi: label bottom {bottom} stays on the canvas: {r}"); + } +} + #[test] fn place_scale_marker_on_a_short_document_errors_without_panicking() { // A document under 8 px tall inverted the bar-height clamp and panicked (#932). Call `run` @@ -386,3 +491,47 @@ fn count_set_group_rejects_sizes_that_are_not_whole_numbers() { assert_eq!((g.marker_size, g.label_size), (3, 12), "a rejected call changes nothing"); assert_eq!(s.active().unwrap().history.past_len(), past); } + +#[test] +fn count_add_rejects_unknown_groups_without_changing_the_document() { + for groups in [0, 1, 3] { + for group in [(groups as u64).max(1), u64::MAX] { + let mut s = session(10, 10, 8); + for _ in 0..groups { + s.execute("count.newGroup", json!({})).unwrap(); + } + let before = d(&s); + let history = s.active().unwrap().history.past_len(); + let journal = s.journal.len(); + let error = s.execute("count.add", json!({"x": 2, "y": 3, "group": group})).unwrap_err(); + assert!(error.to_string().contains("no such group"), "{error}"); + assert!(Arc::ptr_eq(&before, &d(&s)), "an invalid target must not create a group or add a marker"); + assert_eq!(s.active().unwrap().history.past_len(), history); + assert_eq!(s.journal.len(), journal); + } + } +} + +#[test] +fn count_add_keeps_first_group_creation_and_targets_the_requested_group() { + let mut s = session(10, 10, 8); + // Group zero can be requested before the first marker creates it. + let r = s.execute("count.add", json!({"x": 2, "y": 3, "group": 0})).unwrap(); + assert_eq!(r["groups"][0]["points"], json!([[2.0, 3.0]])); + s.execute("count.newGroup", json!({})).unwrap(); + let before = d(&s).measurement.clone(); + let history = s.active().unwrap().history.past_len(); + let r = s.execute("count.add", json!({"x": 4, "y": 5, "group": 0})).unwrap(); + assert_eq!(r["activeGroup"], 1); + assert_eq!(r["groups"][0]["points"], json!([[2.0, 3.0], [4.0, 5.0]])); + assert_eq!(r["groups"][1]["count"], 0); + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + let after = d(&s).measurement.clone(); + assert!(s.undo()); + assert_eq!(d(&s).measurement, before); + assert!(s.redo()); + assert_eq!(d(&s).measurement, after); + // Omitting the group still uses the active group. + let r = s.execute("count.add", json!({"x": 6, "y": 7})).unwrap(); + assert_eq!(r["groups"][1]["points"], json!([[6.0, 7.0]])); +} diff --git a/crates/engine/src/artboard_cmds.rs b/crates/engine/src/artboard_cmds.rs index 5cebe86aa9..4bc4a829b2 100644 --- a/crates/engine/src/artboard_cmds.rs +++ b/crates/engine/src/artboard_cmds.rs @@ -9,7 +9,7 @@ use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Artboard, ArtboardBackground, Document, Layer, LayerContent, LayerId, Size}; -use photocraft_geom::Rect; +use photocraft_geom::{Affine, Rect}; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -92,10 +92,12 @@ fn preset_size(name: &str) -> Option<(u32, u32)> { } fn background_param(p: &Value, cmd: &str) -> Result> { - let custom = p.get("color").map(|_| { - let c = color_param(p, "color", [1.0; 4]); - ArtboardBackground::Custom(Color::rgba(c[0], c[1], c[2], c[3])) - }); + let custom = if p.get("color").is_some() { + let c = color_param(cmd, p, "color", [1.0; 4])?; + Some(ArtboardBackground::Custom(Color::rgba(c[0], c[1], c[2], c[3]))) + } else { + None + }; Ok(match p.get("background").and_then(Value::as_str) { None => custom, Some("white") => Some(ArtboardBackground::White), @@ -504,6 +506,9 @@ pub fn artboard_document(doc: &Document, id: LayerId) -> Result one.layer_comps.clear(); one.last_applied_comp = None; one.last_document_state = None; + // Paths, notes, slices and Count/Ruler marks move with the board. This also shifts + // `one.guides`, but they are rebuilt below from the source's, so nothing moves twice. + crate::canvas_geom::transform_doc_marks(&mut one, &Affine::translate(f64::from(dx), f64::from(dy))); let shift = |v: &[f64], o: i32, len: u32| v.iter().map(|g| g - f64::from(o)).filter(|g| *g > 0.0 && *g < f64::from(len)).collect::>(); one.guides.vertical = shift(&doc.guides.vertical, r.x0, r.width()); one.guides.horizontal = shift(&doc.guides.horizontal, r.y0, r.height()); @@ -528,7 +533,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 +543,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..f3053e21fd 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; @@ -331,6 +331,33 @@ fn export_rejects_a_board_too_far_from_the_origin() { assert_eq!(one.artboards()[0].2.rect, Rect::new(0, 0, 50, 50)); } +/// Artboards to Files moves paths, notes, slices and Count/Ruler marks with the board, like its +/// layers and guides, so they land on the same pixels in the exported file. +#[test] +fn artboard_export_moves_paths_notes_slices_and_marks_with_the_board() { + use photocraft_doc::{CountGroup, NamedPath, Note, Path, Slice, Subpath}; + let mut s = session(8); + s.execute("layer.new.artboard", json!({"x": 200, "y": 100, "width": 50, "height": 50})).unwrap(); + s.edit("marks", |d, _| { + d.paths.push(NamedPath { name: "P".into(), path: Path::new(vec![Subpath::polygon(&[(210.0, 110.0), (240.0, 110.0), (240.0, 140.0)])]), psd_raw: None }); + d.work_path = Some(Path::new(vec![Subpath::polygon(&[(205.0, 105.0), (215.0, 105.0), (215.0, 115.0)])])); + d.notes.push(Note { position: [220.0, 120.0], popup: [220.0, 120.0, 260.0, 160.0], ..Default::default() }); + d.slices.list.push(Slice { id: 1, rect: Rect::new(210, 110, 230, 130), ..Default::default() }); + d.measurement.count_groups.push(CountGroup { points: vec![[225.0, 125.0]], ..Default::default() }); + Ok(()) + }) + .unwrap(); + let one = artboard_document(doc(&s), doc(&s).artboards()[0].0).unwrap().unwrap(); + let k = &one.paths[0].path.subpaths[0].knots[0].anchor; + assert_eq!((k.x, k.y), (10.0, 10.0)); + let w = &one.work_path.as_ref().unwrap().subpaths[0].knots[0].anchor; + assert_eq!((w.x, w.y), (5.0, 5.0)); + assert_eq!(one.notes[0].position, [20.0, 20.0]); + assert_eq!(one.notes[0].popup, [20.0, 20.0, 60.0, 60.0]); + assert_eq!(one.slices.list[0].rect, Rect::new(10, 10, 30, 30)); + assert_eq!(one.measurement.count_groups[0].points, vec![[25.0, 25.0]]); +} + /// #1531: a board 40×40 at (10, 10) holding a red square at (10..30)², the artboard active. fn board_with_square(s: &mut Session) -> LayerId { add_square(s, Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]); diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 86e8075dd6..36ce9aa278 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -898,11 +898,18 @@ macro_rules! ev { Some(m) => step($id, Value::Object(m)), None => Mapped::Unsupported { harmless: false }, }, - to: |_, p| fields_to(p, $fields, desc("null")), + to: |_, p| if pixel_target(p) { fields_to(p, $fields, desc("null")) } else { None }, } }; } +/// Whether `p` edits the layer's pixels: no `"target"` or `"target":"pixels"`. Photoshop's +/// descriptor for a plain event has no field for a mask, Quick Mask or channel target, so such +/// a step can't be exported faithfully. +fn pixel_target(p: &Value) -> bool { + p.get("target").is_none_or(|t| t.as_str() == Some("pixels")) +} + const INTERPOLATION: &[(&str, &str)] = &[ ("Bcbc", "bicubic"), ("Blnr", "bilinear"), @@ -932,7 +939,10 @@ const STROKE_LOCATION: &[(&str, &str)] = &[("Insd", "inside"), ("Cntr", "center" const INTENT: &[(&str, &str)] = &[("Img ", "perceptual"), ("Clrm", "relative"), ("Grp ", "saturation"), ("AbsC", "absolute")]; const PROFILES: &[(&str, &str)] = &[("sRGB IEC61966-2.1", "srgb"), ("Display P3", "display-p3"), ("Adobe RGB (1998)", "adobe-rgb-compat"), ("ProPhoto RGB", "prophoto-compat")]; -const SMART_BLUR_QUALITY: &[(&str, &str)] = &[("Hgh ", "high"), ("Mdm ", "medium"), ("Low ", "low")]; +/// Smart Blur quality. Photoshop's codes come first, so they are what an export writes (measured in +/// Photoshop: `Hgh `/`Mdm `/`Low ` play back as its fallback, low quality). The old codes after +/// them still read, for actions PhotoCraft wrote before. +const SMART_BLUR_QUALITY: &[(&str, &str)] = &[("SBQL", "low"), ("SBQM", "medium"), ("SBQH", "high"), ("Hgh ", "high"), ("Mdm ", "medium"), ("Low ", "low")]; const SMART_BLUR_MODE: &[(&str, &str)] = &[("SBMN", "normal"), ("SBME", "edgeOnly"), ("SBMO", "overlayEdge")]; const RADIAL_METHOD: &[(&str, &str)] = &[("Spn ", "spin"), ("Zm ", "zoom")]; const RADIAL_QUALITY: &[(&str, &str)] = &[("Drft", "draft"), ("Gd ", "good"), ("Bst ", "best")]; @@ -1000,6 +1010,26 @@ fn pattern_desc(p: &Value) -> Option { } fn make_from(d: &Descriptor) -> Mapped { + // Duplicate layer/group into a new document: `Mk` with a document class, a layer + // `Usng`, an optional `Nm` (the new document's name) and an optional `LyrN` + // (the copy's name). A `Dplc` with a `T` document reference (duplicate into another + // open document) stays a kept Photoshop step: document indices mean nothing at replay. + if let Some(r) = get_ref(d, "null") + && ref_class(r).as_deref() == Some("Dcmn") + && matches!(target(r), Target::Class) + && let Some(u) = get_ref(d, "Usng") + && matches!(ref_class(u).as_deref(), Some("Lyr") | Some("layerSection")) + && let Some(mut p) = layer_target(u) + { + p.insert("document".into(), "new".into()); + if let Some(n) = get_text(d, "Nm ") { + p.insert("documentName".into(), n.into()); + } + if let Some(n) = get_text(d, "LyrN") { + p.insert("name".into(), n.into()); + } + return step("layer.duplicate", Value::Object(p)); + } if let Some(nw) = get_obj(d, "Nw ") { // Layer via Copy / Cut (`Usng` = the selection), or a new document. if get_enum(d, "Usng").as_deref() == Some("Slct") { @@ -1134,6 +1164,21 @@ fn make_to(id: &str, p: &Value) -> Option { let t = pattern_desc(p)?; desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))) } + "layer.duplicate" => { + // Duplicate into a new document is a `Mk`, not a `Dplc` (see `make_from`). + // Anything else falls through to the `Dplc` event below. + if jstr(p, "document") != Some("new") { + return None; + } + let mut d = desc("null").with("null", class_ref("Dcmn")).with("Usng", layer_ref(p)); + if let Some(n) = jstr(p, "documentName") { + d = d.with("Nm ", text(n)); + } + if let Some(n) = jstr(p, "name") { + d = d.with("LyrN", text(n)); + } + d + } "file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))), "file.new" => { let mut n = desc("Dcmn"); @@ -1590,7 +1635,12 @@ fn close_from(d: &Descriptor) -> Mapped { if get_enum(d, "Svng").as_deref() == Some("Ys") { steps(vec![("file.save", json!({})), ("file.close", json!({}))]) } else { none("file.close") } } -fn close_to(_: &str, _: &Value) -> Option { +/// A Close of an explicit document has no mapping here: written as a plain Close it would +/// close the active document on playback, so it is left to `skipUnsupported`. +fn close_to(_: &str, p: &Value) -> Option { + if p.get("document").is_some_and(|d| !d.is_null()) { + return None; + } Some(desc("null").with("Svng", enm("YsN ", "N "))) } @@ -1623,14 +1673,17 @@ fn transform_from(d: &Descriptor) -> Mapped { if d.get("Skew").is_some() || d.get("Usng").is_some() { return unsupported; } - let geometry_only = p.keys().all(|k| k == "dx" || k == "dy"); + // A whole-pixel move is a plain `layer.translate`; a fractional one needs `edit.transform` + // to keep its sub-pixel placement and interpolation. + let whole_pixels = ["dx", "dy"].iter().all(|k| p.get(*k).and_then(Value::as_f64).is_none_or(|v| v.fract() == 0.0)); + let geometry_only = p.keys().all(|k| k == "dx" || k == "dy") && whole_pixels; match (ref_class(r).as_deref(), target(r)) { (Some("Chnl"), Target::Property(prop)) if prop == "fsel" => step("select.transformSelection", Value::Object(p)), (Some("Lyr"), _) => { let Some(mut lp) = layer_target(r) else { return unsupported }; if geometry_only { - lp.insert("dx".into(), p.get("dx").and_then(Value::as_f64).unwrap_or(0.0).round().into()); - lp.insert("dy".into(), p.get("dy").and_then(Value::as_f64).unwrap_or(0.0).round().into()); + lp.insert("dx".into(), p.get("dx").and_then(Value::as_f64).unwrap_or(0.0).into()); + lp.insert("dy".into(), p.get("dy").and_then(Value::as_f64).unwrap_or(0.0).into()); return step("layer.translate", Value::Object(lp)); } lp.extend(p); @@ -1940,6 +1993,11 @@ fn duplicate_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; match ref_class(r).as_deref() { Some("Lyr") => { + // A `T` document reference (duplicate into another open document) has no + // replayable meaning here: keep the step verbatim rather than misplay it. + if get_ref(d, "T ").is_some() { + return Mapped::Unsupported { harmless: false }; + } let Some(mut p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; if let Some(n) = get_text(d, "Nm ") { p.insert("name".into(), n.into()); @@ -1961,6 +2019,10 @@ fn duplicate_from(d: &Descriptor) -> Mapped { } fn duplicate_to(id: &str, p: &Value) -> Option { + // Duplicate into a new document is a `Mk` (handled above); let it fall through. + if id == "layer.duplicate" && jstr(p, "document") == Some("new") { + return None; + } let mut d = match id { "layer.duplicate" => desc("null").with("null", layer_ref(p)), "image.duplicate" => { @@ -1986,6 +2048,10 @@ fn play_from(d: &Descriptor) -> Mapped { fn play_to(_: &str, p: &Value) -> Option { let name = jstr(p, "action")?; + // The Play Action descriptor written here carries no starting step, so a call from a later step is not a whole call. + if p.get("from").is_some_and(|v| !v.is_null() && v.as_f64() != Some(0.0)) { + return None; + } let mut items = vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul(name) }]; if let Some(set) = jstr(p, "set") { items.push(ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul(set) }); @@ -2006,6 +2072,10 @@ fn fill_from(d: &Descriptor) -> Mapped { match get_enum(d, "Usng").as_deref() { Some("FrgC") | None => {} Some("Pttn") => p["contents"] = json!("pattern"), + Some("Clr") => match get_obj(d, "Clr ").and_then(color_hex) { + Some(c) => p["color"] = json!(c), + None => return Mapped::Unsupported { harmless: false }, + }, Some(_) => return Mapped::Unsupported { harmless: false }, } if let Some(t) = get_num(d, "Tlrn") { @@ -2057,7 +2127,16 @@ fn fill_to(id: &str, p: &Value) -> Option { if let Some(a) = jbool(p, "antiAlias") { d = d.with("AntA", D::Boolean(a)); } - d = d.with("Usng", enm("FlCn", if jstr(p, "contents") == Some("pattern") { "Pttn" } else { "FrgC" })); + // `source` is the command's older name for `contents`. + d = if p.get("contents").or_else(|| p.get("source")).and_then(Value::as_str) == Some("pattern") { + d.with("Usng", enm("FlCn", "Pttn")) + } else { + // An explicit colour overrides the foreground (#7199): it travels as Fill's `Clr `. + match bucket_color(p.get("color"))? { + Some(c) => d.with("Usng", enm("FlCn", "Clr ")).with("Clr ", D::Descriptor(c)), + None => d.with("Usng", enm("FlCn", "FrgC")), + } + }; if let Some(o) = jnum(p, "opacity") { d = d.with("Opct", pct(o)); } @@ -2086,6 +2165,29 @@ fn fill_to(id: &str, p: &Value) -> Option { Some(d) } +/// The Paint Bucket's `color` as `paint.bucket` reads it (`[r, g, b(, a)]` in 0..=1 or +/// `#rrggbb(aa)`): `Some(None)` when it fills with the foreground (no colour, or a string the +/// command doesn't read), `None` when the colour can't be written (not opaque, or outside 0..=1). +fn bucket_color(v: Option<&Value>) -> Option> { + let rgba = match v { + Some(Value::Array(a)) if a.len() >= 3 => { + let c: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect(); + [*c.first()?, *c.get(1)?, *c.get(2)?, c.get(3).copied().unwrap_or(1.0)] + } + Some(Value::String(h)) => { + let h = h.trim_start_matches('#'); + let c = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| f64::from(v) / 255.0); + let (Some(r), Some(g), Some(b)) = (c(0), c(2), c(4)) else { return Some(None) }; + [r, g, b, c(6).unwrap_or(1.0)] + } + _ => return Some(None), + }; + if rgba[3] != 1.0 || !rgba.iter().all(|v| (0.0..=1.0).contains(v)) { + return None; + } + Some(Some(desc("RGBC").with("Rd ", D::Double(rgba[0] * 255.0)).with("Grn ", D::Double(rgba[1] * 255.0)).with("Bl ", D::Double(rgba[2] * 255.0)))) +} + const STROKE: &[F] = &[ F("Wdth", "width", Kind::Long), F("Lctn", "location", Kind::Enum("StrL", STROKE_LOCATION)), @@ -2113,7 +2215,11 @@ fn rotate_from(d: &Descriptor) -> Mapped { } else if targets_layer(d) { return Mapped::Unsupported { harmless: false }; } else { - return step("image.rotation.arbitrary", json!({"angle": angle.abs(), "direction": if angle < 0.0 { "ccw" } else { "cw" }})); + let mut p = json!({"angle": angle.abs(), "direction": if angle < 0.0 { "ccw" } else { "cw" }}); + if let Some(i) = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).filter(|i| matches!(*i, "bicubic" | "bilinear" | "nearest")) { + p["interpolation"] = i.into(); + } + return step("image.rotation.arbitrary", p); }; none(if targets_layer(d) { layer } else { doc }) } @@ -2133,7 +2239,20 @@ fn rotate_to(id: &str, p: &Value) -> Option { if layer { d = d.with("null", target_ref("Lyr ")); } - Some(d.with("Angl", ang(angle))) + d = d.with("Angl", ang(angle)); + if id == "image.rotation.arbitrary" + && let Some(i) = p.get("interpolation") + { + // The command resamples with nearest, bilinear or bicubic; any other value can't be + // carried faithfully, so refuse rather than replay with a different interpolation. + let i = match i.as_str()? { + "nearestNeighbor" => "nearest", + i @ ("bicubic" | "bilinear" | "nearest") => i, + _ => return None, + }; + d = d.with("Intr", enm("Intp", lookup_back(INTERPOLATION, i)?)); + } + Some(d) } fn flip_from(d: &Descriptor) -> Mapped { @@ -2355,6 +2474,52 @@ fn image_size_from(d: &Descriptor) -> Mapped { step("image.imageSize", Value::Object(p)) } +/// Shadows/Highlights (`adaptCorrect`, #5830): the shadow and highlight tones are `shadowMode` +/// and `highlightMode` objects (amount and width in %, radius in px), then the black and white +/// clips (%), midtone contrast (`center`) and colour correction. Each key is read as its char ID +/// or its string ID, whichever the file used; absent keys keep the command's defaults. +const SHADOWS_HIGHLIGHTS_TONES: [(&str, &str, &str); 2] = [("sdwM", "shadowMode", "shadow"), ("hglM", "highlightMode", "highlight")]; +const SHADOWS_HIGHLIGHTS_TONE: [(&str, &str, &str); 3] = [("Amnt", "amount", "Amount"), ("Wdth", "width", "Tone"), ("Rds ", "radius", "Radius")]; +const SHADOWS_HIGHLIGHTS: [(&str, &str, &str); 4] = + [("BlcC", "blackClip", "blackClip"), ("WhtC", "whiteClip", "whiteClip"), ("Cntr", "center", "midtone"), ("ClrC", "colorCorrection", "color")]; + +fn shadows_highlights_from(d: &Descriptor) -> Mapped { + let mut m = Map::new(); + for (code, name, prefix) in SHADOWS_HIGHLIGHTS_TONES { + let Some(t) = get_obj(d, code).or_else(|| get_obj(d, name)) else { continue }; + for (code, name, suffix) in SHADOWS_HIGHLIGHTS_TONE { + if let Some(v) = get_num(t, code).or_else(|| get_num(t, name)) { + m.insert(format!("{prefix}{suffix}"), Value::from(v)); + } + } + } + for (code, name, param) in SHADOWS_HIGHLIGHTS { + if let Some(v) = get_num(d, code).or_else(|| get_num(d, name)) { + m.insert(param.into(), Value::from(v)); + } + } + step("image.adjustments.shadowsHighlights", Value::Object(m)) +} + +fn shadows_highlights_to(_: &str, p: &Value) -> Option { + let mut d = desc("null"); + for (_, name, prefix) in SHADOWS_HIGHLIGHTS_TONES { + let mut t = desc("adaptCorrectTones"); + for (_, key, suffix) in SHADOWS_HIGHLIGHTS_TONE { + if let Some(v) = jnum(p, &format!("{prefix}{suffix}")) { + t = t.with(key, if suffix == "Radius" { long(v) } else { pct(v) }); + } + } + d = d.with(name, D::Descriptor(t)); + } + for (_, key, param) in SHADOWS_HIGHLIGHTS { + if let Some(v) = jnum(p, param) { + d = d.with(key, if matches!(param, "midtone" | "color") { long(v) } else { D::Double(v) }); + } + } + Some(d) +} + fn image_size_to(_: &str, p: &Value) -> Option { let mut d = desc("null"); if let Some(pc) = jnum(p, "percent") { @@ -2607,8 +2772,8 @@ const EVENTS: &[Event] = &[ ev!("DstS", "dustAndScratches", "Dust & Scratches", "filter.noise.dustAndScratches", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]), ev!("Dspc", "despeckle", "Despeckle", "filter.noise.despeckle", &[]), ev!("MtnB", "motionBlur", "Motion Blur", "filter.blur.motionBlur", &[F("Angl", "angle", Kind::Ang), F("Dstn", "distance", Kind::Px)]), - ev!("BoxB", "boxblur", "Box Blur", "filter.blur.boxBlur", &[F("Rds ", "radius", Kind::Px)]), - ev!("SrfB", "surfaceBlur", "Surface Blur", "filter.blur.surfaceBlur", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]), + ev!("", "boxblur", "Box Blur", "filter.blur.boxBlur", &[F("Rds ", "radius", Kind::Px)]), + ev!("", "surfaceBlur", "Surface Blur", "filter.blur.surfaceBlur", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]), ev!( "SmrB", "smartBlur", @@ -2647,6 +2812,14 @@ const EVENTS: &[Event] = &[ ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::Long)]), Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to }, // Adjustments (Image › Adjustments). + Event { + code: "", + name: "adaptCorrect", + label: "Shadows/Highlights", + ids: &["image.adjustments.shadowsHighlights"], + from: shadows_highlights_from, + to: shadows_highlights_to, + }, Event { code: "Lvls", name: "levels", @@ -2676,7 +2849,7 @@ const EVENTS: &[Event] = &[ adjustment_event!("Exps", "Exps", "exposure", "Exposure"), adjustment_event!("PhtF", "PhtF", "photoFilter", "Photo Filter"), Event { - code: "BlnW", + code: "", name: "blackAndWhite", label: "Black & White", ids: &["image.adjustments.blackWhite"], @@ -2777,6 +2950,7 @@ const EVENTS: &[Event] = &[ "layer.new.layer", "layer.new.group", "layer.groupLayers", + "layer.duplicate", "layer.new.layerViaCopy", "layer.new.layerViaCut", "layer.newFillLayer.solidColor", @@ -2872,7 +3046,7 @@ const EVENTS: &[Event] = &[ from: move_from, to: move_to, }, - ev!("Fltn", "flattenImage", "Flatten Image", "layer.flattenImage", &[]), + ev!("FltI", "flattenImage", "Flatten Image", "layer.flattenImage", &[]), Event { code: "MrgV", name: "mergeVisible", @@ -2981,9 +3155,20 @@ const EVENTS: &[Event] = &[ Event { code: "Stop", name: "stop", label: "Stop", ids: &[STOP_MESSAGE], from: stop_from, to: stop_to }, ]; +/// Four-char codes PhotoCraft once exported for events Photoshop only runs by name (or by +/// another code), with the name they stand for. Measured in Photoshop: `executeAction` with +/// these codes fails ("the command is not currently available"), and `Fltn` is the +/// `flatness` property, not the flatten event. Import keeps accepting them so actions saved +/// before still load; export never writes them. +const LEGACY_CODES: &[(&str, &str)] = &[("BoxB", "boxblur"), ("SrfB", "surfaceBlur"), ("Fltn", "flattenImage"), ("BlnW", "blackAndWhite")]; + fn event_for(item: &AtnEvent) -> Option<&'static Event> { let id = item.id(); - EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id) + let find = |id: &str| EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id); + find(&id).or_else(|| match item { + AtnEvent::Code(_) => LEGACY_CODES.iter().find(|(code, _)| *code == id).and_then(|(_, name)| find(name)), + AtnEvent::Name(_) => None, + }) } /// The event `id` exports as: the first event listing it whose converter accepts the params @@ -3079,8 +3264,8 @@ pub fn import(bytes: &[u8]) -> std::result::Result { let mut out = Imported { set: set.clone(), warnings: file.warnings.clone(), ..Default::default() }; for a in &file.actions { let action = convert_action(a, &set, &mut out.unsupported); - if a.function_key > 0 && !a.shift && !a.command { - out.function_keys.push((action.name.clone(), format!("F{}", a.function_key))); + if a.function_key > 0 { + out.function_keys.push((action.name.clone(), function_key_name(a.function_key, a.shift, a.command))); } out.actions.push(action); } @@ -3119,8 +3304,32 @@ fn export_item(id: &str, params: &Value, set: &str) -> Option { Some(AtnItem { event, dictionary: e.label.as_bytes().to_vec(), descriptor: Some(descriptor), ..Default::default() }) } +/// An action's key as a shortcut string: `F5`, with Photoshop's Shift and Command options +/// `Shift+F5`, `Cmd+F5` or `Cmd+Shift+F5` (#7646). +pub fn function_key_name(key: u16, shift: bool, command: bool) -> String { + format!("{}{}F{key}", if command { "Cmd+" } else { "" }, if shift { "Shift+" } else { "" }) +} + +/// The `.atn` function key (1…15), Shift and Command of a shortcut string, or `None` for +/// anything else. +pub fn parse_function_key(key: &str) -> Option<(u16, bool, bool)> { + let (mut shift, mut command) = (false, false); + let mut rest = key; + loop { + if let Some(r) = rest.strip_prefix("Cmd+").filter(|_| !command) { + (command, rest) = (true, r); + } else if let Some(r) = rest.strip_prefix("Shift+").filter(|_| !shift) { + (shift, rest) = (true, r); + } else { + break; + } + } + let n = rest.strip_prefix('F')?.parse::().ok().filter(|n| (1..=15).contains(n))?; + Some((n, shift, command)) +} + /// The `.atn` bytes of `actions` as the set `set`. `function_keys` gives each action's key -/// (`F1`…`F15`). Steps with no Photoshop equivalent are an error unless `skip_unsupported`. +/// (`F1`…`F15`, with `Shift+` and `Cmd+` as [`function_key_name`] writes them). Steps with no Photoshop equivalent are an error unless `skip_unsupported`. pub fn export( set: &str, actions: &[&Action], @@ -3131,11 +3340,8 @@ pub fn export( let mut file = AtnFile { name: UnicodeString::new_nul(set), expanded: true, actions: Vec::new(), warnings: Vec::new() }; for a in actions { let mut out = AtnAction { name: UnicodeString::new_nul(&a.name), expanded: false, ..Default::default() }; - if let Some((_, key)) = function_keys.iter().find(|(n, _)| n == &a.name) - && let Some(n) = key.strip_prefix('F').and_then(|n| n.parse::().ok()) - && (1..=15).contains(&n) - { - out.function_key = n; + if let Some((n, shift, command)) = function_keys.iter().find(|(n, _)| n == &a.name).and_then(|(_, key)| parse_function_key(key)) { + (out.function_key, out.shift, out.command) = (n, shift, command); } for (i, (id, params)) in a.steps.iter().enumerate() { let Some(mut item) = export_item(id, params, set) else { @@ -3193,31 +3399,39 @@ fn load(s: &mut Session, p: &Value) -> Result { } let mut taken: Vec = s.actions.list.iter().map(|a| a.name.clone()).collect(); let mut names = Vec::with_capacity(imp.actions.len()); - let mut renamed = Vec::new(); + // The file's name of each action that had to be renamed, and its new name. + let mut renamed: std::collections::HashMap = std::collections::HashMap::new(); let mut steps = 0usize; + let mut added = Vec::with_capacity(imp.actions.len()); for mut a in imp.actions { let name = unique(&a.name, &taken); if name != a.name { - renamed.push((a.name.clone(), name.clone())); + renamed.entry(a.name.clone()).or_insert_with(|| name.clone()); } a.name = name.clone(); taken.push(name.clone()); steps = steps.saturating_add(a.steps.len()); names.push(name); - s.actions.list.push(a); + added.push(a); } - // A Play step calls its action by name: follow renames within the set. - for (from, to) in &renamed { - for a in s.actions.list.iter_mut().filter(|a| a.set.as_deref() == Some(imp.set.as_str())) { - for (id, params) in &mut a.steps { - if id == "actions.play" && params.get("action").and_then(Value::as_str) == Some(from) { - params["action"] = json!(to); + // Play and Conditional steps call actions by name: follow the renames in the actions just + // loaded, each target looked up once by the name the file gave it. + for a in &mut added { + for (id, params) in &mut a.steps { + let keys: &[&str] = match id.as_str() { + "actions.play" => &["action"], + "actions.conditional" => &["then", "else"], + _ => &[], + }; + for key in keys { + if let Some(to) = params.get(*key).and_then(Value::as_str).and_then(|n| renamed.get(n)) { + params[*key] = json!(to); } } } } - let keys: Vec<(String, String)> = - imp.function_keys.into_iter().map(|(n, k)| (renamed.iter().find(|(f, _)| f == &n).map_or(n, |(_, t)| t.clone()), k)).collect(); + s.actions.list.extend(added); + let keys: Vec<(String, String)> = imp.function_keys.into_iter().map(|(n, k)| (renamed.get(&n).cloned().unwrap_or(n), k)).collect(); if !keys.is_empty() { s.edit_prefs(|prefs| { for (name, key) in &keys { @@ -3240,6 +3454,7 @@ fn load(s: &mut Session, p: &Value) -> Result { fn save(s: &mut Session, p: &Value) -> Result { let cmd = "actions.save"; + crate::actions_cmds::flush_recording(s)?; let skip = match p.get("skipUnsupported") { None | Some(Value::Null) => false, Some(Value::Bool(b)) => *b, @@ -3287,7 +3502,8 @@ fn save(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn write_file(path: &str, bytes: &[u8]) -> std::io::Result<()> { - std::fs::write(path, bytes) + // Atomic replacement (temporary file + rename): a failed write keeps the previous ATN (#7290). + photocraft_format::atomic_write(std::path::Path::new(path), bytes) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index de4f7efc99..87611b4a89 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -130,6 +130,39 @@ fn load_maps_events_keeps_flags_and_plays() { assert_eq!(s.actions.list[2].set.as_deref(), Some("Other")); } +/// Renames that chain ("A" becomes "A 2" while the file's own "A 2" becomes "A 2 2") rewrite +/// each Play and Conditional target once, to the renamed copy of the action it named. +#[test] +fn load_renames_each_play_target_once() { + let mut s = Session::new(); + let mut mine = Action::new("A", vec![]); + mine.set = Some("Mine".into()); + s.actions.list.push(mine); + let a = Action::new("A", vec![("actions.conditional".into(), json!({"if": "always", "then": "A", "else": "A 2"}))]); + let a2 = Action::new("A 2", vec![("actions.play".into(), json!({"action": "A"}))]); + let keys = [("A".to_string(), "F4".to_string())]; + let (bytes, _) = export("File", &[&a, &a2], &keys, false).unwrap(); + let r = s.execute("actions.load", json!({"data": b64(&bytes)})).unwrap(); + assert_eq!(r["actions"], json!(["A 2", "A 2 2"])); + let get = |n: &str| s.actions.list.iter().find(|a| a.name == n && a.set.as_deref() == Some("File")).unwrap(); + assert_eq!(get("A 2 2").steps[0].1["action"], "A 2", "plays the file's A, not its A 2"); + let cond = &get("A 2").steps[0].1; + assert_eq!((&cond["then"], &cond["else"]), (&json!("A 2"), &json!("A 2 2")), "{cond}"); + assert_eq!(r["functionKeys"], json!([{"action": "A 2", "key": "F4"}])); + + // Adding to a set of the same name leaves the Play steps already in it alone. + let mut s = Session::new(); + for (name, steps) in [("A", vec![]), ("B", vec![("actions.play".to_string(), json!({"action": "A"}))])] { + let mut a = Action::new(name, steps); + a.set = Some("File".into()); + s.actions.list.push(a); + } + let (bytes, _) = export("File", &[&Action::new("A", vec![])], &[], false).unwrap(); + let r = s.execute("actions.load", json!({"data": b64(&bytes), "replace": false})).unwrap(); + assert_eq!(r["actions"], json!(["A 2"])); + assert_eq!(s.actions.list[1].steps[0].1, json!({"action": "A"}), "B still plays the A it had"); +} + #[test] fn save_round_trips_through_photoshop_events() { let mut s = Session::new(); @@ -144,7 +177,7 @@ fn save_round_trips_through_photoshop_events() { assert_eq!(file.name.to_string_lossy(), "Test Set"); assert_eq!(file.actions[0].function_key, 3); let events: Vec = file.actions[0].items.iter().map(|i| i.event.id()).collect(); - assert_eq!(events, ["GsnB", "Mk ", "setd", "slct", "slct", "Stop", "syntheticFill", "Ply ", "Fltn"]); + assert_eq!(events, ["GsnB", "Mk ", "setd", "slct", "slct", "Stop", "syntheticFill", "Ply ", "FltI"]); assert!(file.actions[0].items[0].with_dialog); assert!(!file.actions[0].items[8].enabled); // The kept Photoshop step is written back byte for byte. @@ -174,6 +207,7 @@ fn every_mapped_command_exports_and_imports_back() { ("filter.noise.addNoise", json!({"amount": 12.5, "distribution": "gaussian", "monochromatic": true, "seed": 7})), ("filter.noise.median", json!({"radius": 2})), ("filter.blur.motionBlur", json!({"angle": 45, "distance": 20})), + ("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})), ("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})), ("filter.blur.blur", json!({})), ( @@ -186,6 +220,10 @@ fn every_mapped_command_exports_and_imports_back() { json!({"colorize": false, "hue": 10, "saturation": -20, "lightness": 0, "reds": {"hue": 5, "saturation": 0, "lightness": 0, "range": [315, 345, 15, 45]}}), ), ("image.adjustments.brightnessContrast", json!({"brightness": 10, "contrast": -5, "legacy": false})), + ( + "image.adjustments.shadowsHighlights", + json!({"shadowAmount": 60, "shadowTone": 40, "shadowRadius": 25, "highlightAmount": 10, "highlightTone": 50, "highlightRadius": 30, "blackClip": 0.02, "whiteClip": 0.05, "midtone": 15, "color": 20}), + ), ("image.adjustments.threshold", json!({"level": 100})), ("image.adjustments.posterize", json!({"levels": 4})), ("image.adjustments.invert", json!({})), @@ -212,6 +250,12 @@ fn every_mapped_command_exports_and_imports_back() { ("image.mode.grayscale", json!({})), ("image.mode.bits16", json!({})), ("layer.duplicate", json!({"name": "copy"})), + // Duplicate into a new document is a `Mk`, not a `Dplc` (the three shapes of + // a recorded Duplicate Layer with Destination: New, plus a named layer). + ("layer.duplicate", json!({"document": "new"})), + ("layer.duplicate", json!({"document": "new", "documentName": "test new name"})), + ("layer.duplicate", json!({"document": "new", "name": "Laer name change"})), + ("layer.duplicate", json!({"layer": "Shadow", "document": "new", "documentName": "Doc"})), ("image.duplicate", json!({"mergedOnly": true})), ("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})), ("layer.new.group", json!({"name": "G"})), @@ -418,7 +462,6 @@ fn every_mapped_command_exports_and_imports_back() { "filter.blur.blurMore", "filter.blur.boxBlur", "filter.blur.radialBlur", - "filter.blur.smartBlur", "filter.blur.surfaceBlur", "filter.noise.despeckle", "filter.noise.dustAndScratches", @@ -441,6 +484,55 @@ fn every_mapped_command_exports_and_imports_back() { } } +/// `paint.bucket` exported to .atn and imported back → its params, or None when it doesn't export. +fn bucket_round_trip(params: Value) -> Option { + let item = export_item("paint.bucket", ¶ms, "S")?; + let e = event_for(&item.event)?; + match (e.from)(item.descriptor.as_ref()?) { + Mapped::Steps(steps) => steps.first().map(|(id, p)| { + assert_eq!(id, "paint.bucket"); + p.clone() + }), + _ => None, + } +} + +/// A Paint Bucket step with an explicit colour keeps it through .atn (#7199): replayed on a fresh +/// document, whose foreground is black, it still fills red. A colour .atn can't hold makes the +/// step not export rather than fill with the foreground. +#[test] +fn bucket_keeps_an_explicit_color_through_atn() { + let fill = |params: &Value| { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "#ffffff"})).unwrap(); + s.execute("paint.bucket", params.clone()).unwrap(); + let d = s.active().unwrap(); + d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().pixel(2, 2) + }; + let red = json!({"x": 2, "y": 2, "antiAlias": false, "color": "#ff0000"}); + let back = bucket_round_trip(red.clone()).unwrap(); + assert_eq!(back["color"], "#ff0000", "{back}"); + assert_eq!(fill(&back), fill(&red)); + assert_eq!(fill(&back), vec![1.0, 0.0, 0.0, 1.0]); + // The colour as an array of channels. + assert_eq!(bucket_round_trip(json!({"x": 1, "y": 1, "color": [0.0, 0.5, 1.0]})).unwrap()["color"], "#0080ff"); + // No colour, or one the command can't read: the foreground, as before. + for p in [json!({"x": 1, "y": 1}), json!({"x": 1, "y": 1, "color": "red"})] { + let back = bucket_round_trip(p.clone()).unwrap(); + assert!(back.get("color").is_none() && back.get("contents").is_none(), "{p} → {back}"); + } + // A pattern fill ignores the colour, also when named with the older `source`. + for key in ["contents", "source"] { + let back = bucket_round_trip(json!({"x": 1, "y": 1, key: "pattern", "color": "#ff0000"})).unwrap(); + assert_eq!(back["contents"], "pattern", "{key}: {back}"); + assert!(back.get("color").is_none(), "{key}: {back}"); + } + // Translucent or out-of-range colours have no .atn form. + for c in [json!("#ff000080"), json!([1.0, 0.0, 0.0, 0.5]), json!([2.0, 0.0, 0.0])] { + assert!(bucket_round_trip(json!({"x": 1, "y": 1, "color": c})).is_none(), "{c}"); + } +} + #[test] fn unsupported_shapes_become_photoshop_steps_not_errors() { // Image size in two different percentages, a Paint Bucket click in percent of the canvas, a @@ -463,6 +555,17 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("GdMp")))))), ), item("GsnB", "Gaussian Blur", None), + // Duplicate into another open document: the `T` reference means nothing at + // replay, so the step is kept verbatim instead of misplayed as a same-doc copy. + item( + "Dplc", + "Duplicate", + Some( + desc("null") + .with("null", target_ref("Lyr ")) + .with("T ", D::Reference(vec![ReferenceItem::Name { class: class("Dcmn"), name: UnicodeString::new_nul("Other") }])), + ), + ), ]; let file = AtnFile { name: UnicodeString::new_nul("Odd"), @@ -472,8 +575,8 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { }; let imp = import(&atn::write(&file).unwrap()).unwrap(); let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect(); - assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur"]); - assert_eq!(imp.unsupported.len(), 4); + assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]); + assert_eq!(imp.unsupported.len(), 5); assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); } @@ -534,6 +637,33 @@ fn save_writes_the_file_and_the_set_of_the_selected_action() { std::fs::remove_dir_all(&dir).ok(); } +/// #7290: saving over an existing ATN must replace it atomically (a temporary file renamed over +/// it), never truncate and rewrite it in place, so a write that fails partway (a full disk) +/// leaves the previously valid file readable. A handle opened on the old file still sees the old +/// bytes after an atomic replacement; an in-place rewrite changes them under it. +#[cfg(unix)] +#[test] +fn save_over_an_existing_file_never_rewrites_it_in_place() { + use std::io::Read; + let mut s = Session::new(); + s.actions.list.push(Action::new("Owned", vec![("select.all".into(), json!({}))])); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Owned.atn"); + let p = path.to_string_lossy().to_string(); + s.execute("actions.save", json!({"path": p})).unwrap(); + let before = std::fs::read(&path).unwrap(); + let mut old = std::fs::File::open(&path).unwrap(); + s.actions.list.push(Action::new("Second", vec![("select.all".into(), json!({}))])); + s.execute("actions.save", json!({"path": p})).unwrap(); + let mut seen = Vec::new(); + old.read_to_end(&mut seen).unwrap(); + assert_eq!(seen, before, "the existing ATN was rewritten in place"); + let after = atn::parse(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(after.actions.len(), 2); + let names: Vec<_> = std::fs::read_dir(dir.path()).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(names, vec![std::ffi::OsString::from("Owned.atn")], "no temporary file is left behind"); +} + #[test] fn layer_param_accepts_a_name() { let mut s = Session::new(); @@ -571,7 +701,7 @@ fn remaining_events_import_in_their_photoshop_shapes() { let moved = desc("null") .with("null", target_ref("Lyr ")) .with("FTcs", enm("QCSt", "Qcsa")) - .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(-57.4)).with("Vrtc", px(12.0)))) + .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(-57.0)).with("Vrtc", px(12.0)))) .with("Intr", enm("Intp", "Bcbc")); let scaled = desc("null") .with("null", target_ref("Lyr ")) @@ -998,3 +1128,321 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(!st.doc.is_background(bottom.id), "the Background became a normal layer"); assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } + +/// Measured in Photoshop: `executeAction` fails for the codes `BoxB`, `SrfB`, `Fltn` and +/// `BlnW` ("the command is not currently available"), while the names `boxblur`, +/// `surfaceBlur`, `flattenImage`, `blackAndWhite` and the code `FltI` run. +#[test] +fn events_photoshop_only_runs_by_name_export_by_name_and_old_codes_still_import() { + let bw = json!({"reds": 40, "yellows": 60, "greens": 40, "cyans": 60, "blues": 20, "magentas": 80, "tint": false, "tintColor": "#e4d5b0"}); + let cases = [ + ("filter.blur.boxBlur", json!({"radius": 3}), "boxblur", "BoxB"), + ("filter.blur.surfaceBlur", json!({"radius": 5, "threshold": 15}), "surfaceBlur", "SrfB"), + ("layer.flattenImage", json!({}), "FltI", "Fltn"), + ("image.adjustments.blackWhite", bw, "blackAndWhite", "BlnW"), + ]; + for (id, params, written, legacy) in &cases { + let exported = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export")); + assert_eq!(exported.event.id(), *written, "{id} exports under an id Photoshop runs"); + assert_eq!(matches!(exported.event, AtnEvent::Name(_)), written.len() != 4, "{id}: a name is written as a name"); + let d = exported.descriptor.clone(); + // The written id, the name for Flatten Image, and the old code all import to the command. + let name = if *id == "layer.flattenImage" { "flattenImage" } else { written }; + for event in [*written, name, *legacy] { + let e = event_for(&item(event, "", None).event).unwrap_or_else(|| panic!("{event} does not import")); + assert!(e.ids.contains(id), "{event} imports as {:?}, not {id}", e.ids); + match (e.from)(d.as_ref().unwrap()) { + Mapped::Steps(steps) => { + assert_eq!(steps.len(), 1, "{event}: {steps:?}"); + assert_eq!(steps[0].0, *id, "{event}"); + let mut back = steps[0].1.clone(); + normalise(&mut back); + assert_eq!(back, *params, "{event}"); + } + Mapped::Unsupported { .. } => panic!("{event} does not import back"), + } + } + } + + // A whole set of Photoshop-shaped steps (names, FltI) and old PhotoCraft-shaped ones + // (legacy codes) loads to the same commands. + let blur = |event: &str| item(event, "Box Blur", Some(desc("null").with("Rds ", px(3.0)))); + let surface = |event: &str| item(event, "Surface Blur", Some(desc("null").with("Rds ", px(5.0)).with("Thsh", long(15.0)))); + let photoshop = vec![blur("boxblur"), surface("surfaceBlur"), item("FltI", "Flatten Image", None), item("flattenImage", "Flatten Image", None)]; + let legacy = vec![blur("BoxB"), surface("SrfB"), item("Fltn", "Flatten Image", None)]; + let data = set_of("S", vec![("Photoshop", photoshop), ("Legacy", legacy)]); + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(r["unsupported"], json!([]), "{r}"); + let ids = |i: usize| s.actions.list[i].steps.iter().map(|(id, _)| id.clone()).collect::>(); + assert_eq!(ids(0), ["filter.blur.boxBlur", "filter.blur.surfaceBlur", "layer.flattenImage", "layer.flattenImage"]); + assert_eq!(ids(1), ["filter.blur.boxBlur", "filter.blur.surfaceBlur", "layer.flattenImage"]); + assert_eq!(s.actions.list[1].steps[1].1, json!({"radius": 5, "threshold": 15})); + // Saving them writes the ids Photoshop runs, never the old codes. + let saved = s.execute("actions.save", json!({"set": "S"})).unwrap(); + let file = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + let events: Vec = file.actions.iter().flat_map(|a| a.items.iter().map(|i| i.event.id())).collect(); + assert_eq!(events, ["boxblur", "surfaceBlur", "FltI", "FltI", "boxblur", "surfaceBlur", "FltI"]); + + // Unknown ids, near misses and the legacy codes written as names stay Photoshop steps, + // never a panic or a wrong command. + for event in ["BoxBlur", "boxBlur", "BOXB", "Fltn ", "FLTI", "", "\u{0}\u{0}\u{0}\u{0}", "BlnWx"] { + let e = event_for(&item(event, "", None).event); + assert!(e.is_none(), "{event:?} maps to {:?}", e.map(|e| e.ids)); + } + // A legacy code with a broken descriptor is unsupported, not a crash. + let e = event_for(&item("SrfB", "", None).event).unwrap(); + assert!(matches!((e.from)(&desc("null").with("Rds ", D::Double(f64::NAN))), Mapped::Unsupported { .. } | Mapped::Steps(_))); + // A wrong-typed key is skipped like any other unreadable field: the step keeps the defaults. + match (e.from)(&desc("null").with("Rds ", text("x"))) { + Mapped::Steps(steps) => assert!(steps.iter().all(|(_, p)| p.get("radius").is_none()), "{steps:?}"), + Mapped::Unsupported { .. } => {} + } +} + +/// Smart Blur's quality uses Photoshop's `SmBQ` codes (`SBQL`/`SBQM`/`SBQH`): measured in +/// Photoshop, the old `Hgh `/`Mdm `/`Low ` play back as its fallback quality. Old codes still read. +#[test] +fn smart_blur_quality_uses_photoshop_codes() { + let smart_blur = + |quality: D, mode: &str| desc("null").with("Rds ", D::Double(6.0)).with("Thsh", D::Double(60.0)).with("SmBQ", quality).with("SmBM", enm("SmBM", mode)); + // Every quality writes Photoshop's code, as a double radius/threshold and both enums, + // and reads back as the same step. + for (quality, code) in [("low", "SBQL"), ("medium", "SBQM"), ("high", "SBQH")] { + let params = json!({"radius": 6, "threshold": 60, "quality": quality, "mode": "edgeOnly"}); + let a = Action::new("Q", vec![("filter.blur.smartBlur".into(), params.clone())]); + let (bytes, skipped) = export("S", &[&a], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + let it = &file.actions[0].items[0]; + assert_eq!(it.event.id(), "SmrB"); + assert_eq!(it.descriptor.as_ref(), Some(&smart_blur(enm("SmBQ", code), "SBME")), "{quality}"); + let back = import(&bytes).unwrap(); + assert!(back.unsupported.is_empty(), "{quality}"); + assert_eq!(back.actions[0].steps, vec![("filter.blur.smartBlur".to_string(), params)]); + } + + // Photoshop-shaped steps (each quality and mode, as Photoshop stores them) and the codes + // PhotoCraft wrote before this fix read as the right quality. + let cases = [ + ("SBQH", "SBMN", "high", "normal"), + ("SBQM", "SBMO", "medium", "overlayEdge"), + ("SBQL", "SBME", "low", "edgeOnly"), + ("Hgh ", "SBMN", "high", "normal"), + ("Mdm ", "SBMN", "medium", "normal"), + ("Low ", "SBMN", "low", "normal"), + ]; + let items = cases.iter().map(|(q, m, _, _)| item("SmrB", "Smart Blur", Some(smart_blur(enm("SmBQ", q), m)))).collect(); + let data = set_of("Blurs", vec![("Smart", items)]); + let mut s = Session::new(); + s.execute("actions.load", json!({"data": data})).unwrap(); + let steps = &s.actions.list[0].steps; + for ((q, _, quality, mode), (id, p)) in cases.iter().zip(steps) { + assert_eq!(id, "filter.blur.smartBlur", "{q}"); + assert_eq!(p, &json!({"radius": 6, "threshold": 60, "quality": quality, "mode": mode}), "{q}"); + } + + // An unknown code, a quality of the wrong type, NaN and a missing quality never panic: the + // unknown ones stay Photoshop steps, kept verbatim. + let odd = [ + smart_blur(enm("SmBQ", "Xxxx"), "SBMN"), + smart_blur(D::Integer(2), "SBMN"), + smart_blur(enm("SmBQ", "SBQH"), "Xxxx"), + desc("null").with("Rds ", D::Double(f64::NAN)).with("SmBQ", enm("SmBQ", "SBQM")), + desc("null"), + ]; + let items: Vec = odd.iter().map(|d| item("SmrB", "Smart Blur", Some(d.clone()))).collect(); + let imported = import(&photocraft_paint::tile::b64_decode(&set_of("Odd", vec![("Odd", items)])).unwrap()).unwrap(); + let steps = &imported.actions[0].steps; + assert_eq!(steps.len(), odd.len()); + assert_eq!(steps[0].0, PHOTOSHOP_STEP, "an unknown quality is kept verbatim"); + assert_eq!(steps[2].0, PHOTOSHOP_STEP, "an unknown mode is kept verbatim"); + assert_eq!(imported.unsupported.iter().map(|u| u.step).collect::>(), [0, 2]); + // A wrong-type quality is left out (the filter's default applies), as for every enum field. + assert_eq!(steps[1], ("filter.blur.smartBlur".to_string(), json!({"radius": 6, "threshold": 60, "mode": "normal"}))); + // An unknown quality or a non-string one is not exported. + for bad in [json!({"quality": "best"}), json!({"quality": 3}), json!({"radius": "x"})] { + let a = Action::new("Bad", vec![("filter.blur.smartBlur".into(), bad.clone())]); + assert!(export("S", &[&a], &[], false).is_err(), "{bad}"); + } +} + +/// #5830: Shadows/Highlights (`adaptCorrect`) recorded by Photoshop, with string IDs, plays as +/// Image › Adjustments › Shadows/Highlights instead of stopping as a step with no equivalent. +#[test] +fn photoshop_shadows_highlights_steps_play() { + let tones = |amount: f64, width: f64, radius: i32| { + D::Descriptor(desc("adaptCorrectTones").with("amount", pct(amount)).with("width", pct(width)).with("radius", D::Integer(radius))) + }; + let d = desc("null") + .with("shadowMode", tones(35.0, 50.0, 30)) + .with("highlightMode", tones(20.0, 40.0, 24)) + .with("blackClip", D::Double(0.01)) + .with("whiteClip", D::Double(0.02)) + .with("center", D::Integer(5)) + .with("colorCorrection", D::Integer(20)); + let e = event_for(&item("adaptCorrect", "Sombra/Realce", None).event).expect("adaptCorrect is mapped"); + let Mapped::Steps(steps) = (e.from)(&d) else { panic!("Shadows/Highlights maps to a step") }; + assert_eq!(steps.len(), 1); + let (id, p) = &steps[0]; + assert_eq!(id, "image.adjustments.shadowsHighlights"); + let mut p = p.clone(); + normalise(&mut p); + assert_eq!( + p, + json!({"shadowAmount": 35, "shadowTone": 50, "shadowRadius": 30, "highlightAmount": 20, "highlightTone": 40, "highlightRadius": 24, "blackClip": 0.01, "whiteClip": 0.02, "midtone": 5, "color": 20}) + ); + // The same settings as char IDs read the same; absent keys are left to the defaults. + let codes = desc("null") + .with("sdwM", D::Descriptor(desc("adaptCorrectTones").with("Amnt", pct(35.0)).with("Wdth", pct(50.0)).with("Rds ", D::Integer(30)))) + .with("BlcC", D::Double(0.01)); + let Mapped::Steps(steps) = (e.from)(&codes) else { panic!("char IDs map too") }; + let mut q = steps[0].1.clone(); + normalise(&mut q); + assert_eq!(q, json!({"shadowAmount": 35, "shadowTone": 50, "shadowRadius": 30, "blackClip": 0.01})); + // And it runs on a document. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16, "background": "#404040"})).unwrap(); + s.execute(id, p).unwrap(); +} + +#[test] +fn save_while_recording_includes_pending_steps() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("actions.record", json!({"name": "Pending"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Recorded"})).unwrap(); + + // While still recording (without calling actions.stop), save the action. + let saved = s.execute("actions.save", json!({"action": "Pending"})).unwrap(); + assert_eq!(saved["steps"], 1, "pending steps must be flushed and included in save"); + assert_eq!(saved["skipped"], json!([])); + + // After save, recording continues and additional steps are flushed on stop without duplication. + s.execute("layer.new.layer", json!({"name": "Second"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + + let action = s.execute("actions.get", json!({"action": "Pending"})).unwrap(); + assert_eq!(action["steps"].as_array().unwrap().len(), 2, "both steps must be present without duplication"); +} + +/// Photoshop's Close has no document target: a Close of a specific document must not be +/// exported as a plain Close, which would close the active document on playback. +#[test] +fn close_with_document_target_is_not_exported_as_plain_close() { + let targeted = Action { name: "Probe".into(), steps: vec![("file.close".into(), json!({"document": 0}))], ..Default::default() }; + assert!(export("Audit4", &[&targeted], &[], false).is_err()); + let (_, skipped) = export("Audit4", &[&targeted], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "file.close".into() }]); + let plain = Action { name: "Plain".into(), steps: vec![("file.close".into(), json!({}))], ..Default::default() }; + let (bytes, skipped) = export("Audit4", &[&plain], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("file.close".to_string(), json!({}))]); +} + +/// A translation-only Free Transform with a fractional offset must not collapse into an +/// integer `layer.translate`: the sub-pixel placement and interpolation have to survive. +#[test] +fn fractional_translation_only_transform_keeps_offset_and_interpolation() { + let params = json!({"dx": 0.5, "dy": 0, "interpolation": "bilinear"}); + let d = transform_to("edit.transform", ¶ms).unwrap(); + let file = AtnFile { + name: UnicodeString::new_nul("Frac"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items: vec![item("transform", "Transform", Some(d))], ..Default::default() }], + warnings: vec![], + }; + let imp = import(&atn::write(&file).unwrap()).unwrap(); + assert!(imp.unsupported.is_empty()); + let steps = &imp.actions[0].steps; + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].0, "edit.transform"); + assert_eq!(steps[0].1.get("dx").and_then(Value::as_f64), Some(0.5)); + assert_eq!(steps[0].1.get("interpolation"), Some(&json!("bilinear"))); +} + +/// Arbitrary canvas rotation must keep its interpolation through a strict export: dropping it +/// silently replays the rotation with the default bicubic resampling. +#[test] +fn arbitrary_rotation_keeps_interpolation_through_atn() { + for params in [ + json!({"angle": 17, "direction": "ccw", "interpolation": "nearest"}), + json!({"angle": 17, "direction": "ccw", "interpolation": "bilinear"}), + json!({"angle": 17, "direction": "ccw", "interpolation": "bicubic"}), + json!({"angle": 17, "direction": "ccw"}), + ] { + let a = Action { name: "Rot".into(), steps: vec![("image.rotation.arbitrary".into(), params.clone())], ..Default::default() }; + let (bytes, skipped) = export("Rot", &[&a], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps, vec![("image.rotation.arbitrary".to_string(), params)]); + } +} + +/// A filter recorded against a layer mask must not export as a plain filter that edits the +/// layer's pixels on replay: Photoshop's descriptor has no field for PhotoCraft's `target`. +#[test] +fn add_noise_with_mask_target_is_not_exported_as_pixel_filter() { + let masked = Action { + name: "Probe".into(), + steps: vec![("filter.noise.addNoise".into(), json!({"amount": 75, "seed": 123, "target": "mask"}))], + ..Default::default() + }; + assert!(export("Audit", &[&masked], &[], false).is_err()); + let (_, skipped) = export("Audit", &[&masked], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "filter.noise.addNoise".into() }]); + for params in [json!({"amount": 75, "seed": 123}), json!({"amount": 75, "seed": 123, "target": "pixels"})] { + let plain = Action { name: "Plain".into(), steps: vec![("filter.noise.addNoise".into(), params)], ..Default::default() }; + let (bytes, skipped) = export("Audit", &[&plain], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("filter.noise.addNoise".to_string(), json!({"amount": 75, "seed": 123}))]); + } +} + +/// A Play Action that starts the called action at a later step must not export as a call of the +/// whole action: that would replay the steps the recording deliberately skipped. +#[test] +fn partial_play_action_is_not_exported_as_whole_action_call() { + let partial = Action { name: "Parent".into(), steps: vec![("actions.play".into(), json!({"action": "Child", "from": 1}))], ..Default::default() }; + assert!(export("Set", &[&partial], &[], false).is_err()); + let (_, skipped) = export("Set", &[&partial], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Parent".into(), step: 0, id: "actions.play".into() }]); + for params in [json!({"action": "Child"}), json!({"action": "Child", "from": 0})] { + let whole = Action { name: "Parent".into(), steps: vec![("actions.play".into(), params)], ..Default::default() }; + let (bytes, skipped) = export("Set", &[&whole], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, vec![("actions.play".to_string(), json!({"action": "Child"}))]); + } +} + +/// #7646: an action's function key keeps Photoshop's Shift and Command options through +/// `.atn` load and save, as `Shift+F5`, `Cmd+F5` and `Cmd+Shift+F5` shortcuts. +#[test] +fn function_keys_keep_shift_and_command() { + let mut file = sample(); + file.actions[1].function_key = 5; + file.actions[1].shift = true; + file.actions[1].command = true; + file.actions[0].shift = true; + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap(); + assert_eq!(r["functionKeys"], json!([{"action": "Soften", "key": "Shift+F3"}, {"action": "Finish", "key": "Cmd+Shift+F5"}])); + let shortcuts = &s.prefs().shortcuts; + assert_eq!(shortcuts.get("actions.play:Soften").map(String::as_str), Some("Shift+F3")); + assert_eq!(shortcuts.get("actions.play:Finish").map(String::as_str), Some("Cmd+Shift+F5")); + let r = s.execute("actions.save", json!({"set": "Test Set"})).unwrap(); + let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); + let keys: Vec<_> = saved.actions.iter().map(|a| (a.function_key, a.shift, a.command)).collect(); + assert_eq!(keys, [(3, true, false), (5, true, true)]); + + assert_eq!(super::parse_function_key("Cmd+F12"), Some((12, false, true))); + assert_eq!(super::parse_function_key("Shift+Cmd+F2"), Some((2, true, true))); + for bad in ["F16", "F0", "Alt+F2", "Cmd+Cmd+F2", "Shift+", "Cmd+K", ""] { + assert_eq!(super::parse_function_key(bad), None, "{bad}"); + } + assert_eq!(super::function_key_name(7, false, false), "F7"); +} diff --git a/crates/engine/src/automate_cmds.rs b/crates/engine/src/automate_cmds.rs index 6a98ee3c4f..4c78f90b34 100644 --- a/crates/engine/src/automate_cmds.rs +++ b/crates/engine/src/automate_cmds.rs @@ -24,6 +24,22 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } +/// Photoshop ExtendScript / UXP script suffixes — not PhotoCraft JSON or command lists. +fn extendscript_ext(path: &str) -> Option<&str> { + let name = path.rsplit(['/', '\\']).next().unwrap_or(path); + let dot = name.rfind('.')?; + let ext = &name[dot + 1..]; + let lower = ext.to_ascii_lowercase(); + matches!(lower.as_str(), "js" | "jsx" | "jsxbin" | "psjs" | "ccjs").then_some(ext) +} + +fn reject_extendscript(cmd: &str, path: &str) -> Result<()> { + match extendscript_ext(path) { + Some(_) => Err(bad(cmd, "PhotoCraft scripts are JSON or command lists (one command.id {json} per line), not Photoshop ExtendScript (.js/.jsx)")), + None => Ok(()), + } +} + /// Session state of the File-menu commands (not saved, not history). #[derive(Clone, Debug, Default)] pub struct FileMenuState { @@ -127,6 +143,12 @@ fn run_steps(s: &mut Session, steps: &[(String, Value)]) -> Vec { /// File › Scripts › Browse…: run a script file on the session. fn browse(s: &mut Session, p: &Value) -> Result { let cmd = "file.scripts.browse"; + if let Some(path) = p.get("path").and_then(Value::as_str) { + reject_extendscript(cmd, path)?; + } + if let Some(name) = p.get("name").and_then(Value::as_str) { + reject_extendscript(cmd, name)?; + } let steps = match (p.get("path").and_then(Value::as_str), p.get("script").and_then(Value::as_str), p.get("steps")) { (Some(path), _, _) => parse_script(&String::from_utf8_lossy(&read_file(path)?))?, (_, Some(text), _) => parse_script(text)?, diff --git a/crates/engine/src/automate_cmds/tests.rs b/crates/engine/src/automate_cmds/tests.rs index b60dc2940e..06d0e88f36 100644 --- a/crates/engine/src/automate_cmds/tests.rs +++ b/crates/engine/src/automate_cmds/tests.rs @@ -50,10 +50,31 @@ fn browse_runs_a_script_file() { assert_eq!((d.size.width, d.size.height), (20, 30)); assert!(s.execute("file.scripts.browse", json!({"script": "no.such.command"})).is_err()); // A failing step stops the script and is reported. - let r = s.execute("file.scripts.browse", json!({"steps": [["layer.delete", {"layer": 999_999}], ["image.imageRotation.90cw", {}]]})).unwrap(); + let r = s.execute("file.scripts.browse", json!({"steps": [["layer.delete", {"layer": u64::MAX}], ["image.imageRotation.90cw", {}]]})).unwrap(); assert_eq!(r["results"].as_array().unwrap().len(), 1); } +#[test] +fn browse_rejects_photoshop_extendscript() { + let dir = tmp("jsx"); + let jsx = format!("{dir}/hello.jsx"); + std::fs::write(&jsx, "alert('hi');\n").unwrap(); + let mut s = Session::new(); + let err = s.execute("file.scripts.browse", json!({"path": jsx})).unwrap_err().to_string(); + assert!(err.contains("not Photoshop ExtendScript"), "{err}"); + assert!(err.contains("JSON"), "{err}"); + let js = format!("{dir}/hello.js"); + std::fs::write(&js, "alert('hi');\n").unwrap(); + assert!(s.execute("file.scripts.browse", json!({"path": js})).is_err()); + let err = s.execute("file.scripts.browse", json!({"name": "Resize.jsx", "script": "alert(1);"})).unwrap_err().to_string(); + assert!(err.contains(".js/.jsx"), "{err}"); + // A JSON script still runs even if the file name is not a Photoshop suffix. + let json = format!("{dir}/rotate.json"); + std::fs::write(&json, r#"[["file.new", {"width": 8, "height": 8}]]"#).unwrap(); + let r = s.execute("file.scripts.browse", json!({"path": json})).unwrap(); + assert_eq!(r["ok"], true); +} + #[test] fn recording_browse_script_captures_each_effect_once() { let mut s = Session::new(); @@ -348,7 +369,7 @@ fn a_failed_step_inside_a_called_action_fails_scripts_and_batch() { }); s.actions.list.push(Action { name: "Bad".into(), - steps: vec![("layer.delete".into(), json!({"layer": 999_999}))], + steps: vec![("layer.delete".into(), json!({"layer": u64::MAX}))], disabled: Default::default(), ..Default::default() }); diff --git a/crates/engine/src/background_cmds.rs b/crates/engine/src/background_cmds.rs index 81c47501d3..0c067b3842 100644 --- a/crates/engine/src/background_cmds.rs +++ b/crates/engine/src/background_cmds.rs @@ -144,6 +144,32 @@ mod tests { (s, [bg, a, b]) } + #[test] + fn opacity_fill_or_a_blend_mode_turn_the_background_into_a_layer() { + // Like Photoshop: the Background takes none of them, so it becomes "Layer 0" first. + for (key, value) in [("opacity", json!(0.5)), ("fill", json!(0.5)), ("blend", json!("Multiply"))] { + let (mut s, [bg, ..]) = with_background(); + s.execute("layer.setProps", json!({"layer": bg.0, key: value})).unwrap(); + let l = doc(&s).layer(bg).unwrap(); + assert!(!doc(&s).is_background(bg) && l.name == "Layer 0", "{key}"); + assert!(l.opacity < 1.0 || l.fill_opacity < 1.0 || l.blend == photocraft_color::BlendMode::Multiply, "{key}: applied"); + } + // Values that leave its look alone keep it the Background. + let (mut s, [bg, ..]) = with_background(); + s.execute("layer.setProps", json!({"layer": bg.0, "opacity": 1.0, "fill": 1.0, "blend": "Normal", "visible": false})).unwrap(); + assert!(doc(&s).is_background(bg)); + } + + #[test] + fn select_and_mask_output_to_a_layer_mask_turns_the_background_into_a_layer() { + let (mut s, [bg, ..]) = with_background(); + s.execute("layer.select", json!({"layer": bg.0})).unwrap(); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 6, "height": 6})).unwrap(); + s.execute("select.refineEdge", json!({"output": "layerMask"})).unwrap(); + let l = doc(&s).layer(bg).unwrap(); + assert!(l.mask.is_some() && !doc(&s).is_background(bg) && l.name == "Layer 0"); + } + #[test] fn makes_the_layer_the_background_at_the_bottom() { for depth in [8, 16, 32] { @@ -207,7 +233,7 @@ mod tests { assert!(s.execute(CMD, json!({"layer": bg.0})).is_err()); // Bad params. let (mut s, [_, a, _]) = session(8); - for p in [json!({"layer": "A"}), json!({"layer": -1}), json!({"layer": 1.5}), json!({"layer": 99999}), json!({"layer": [a.0]})] { + for p in [json!({"layer": "A"}), json!({"layer": -1}), json!({"layer": 1.5}), json!({"layer": u64::MAX}), json!({"layer": [a.0]})] { assert!(s.execute(CMD, p.clone()).is_err(), "{p}"); } assert!(doc(&s).background().is_none()); diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 370b6fcc78..3de6bc84a1 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -143,6 +143,11 @@ pub fn merge_brush(base: &BrushSettings, patch: &Value, cmd: &str) -> Result Result // A library preset's tips stay on disk until a stroke uses them (#1843). s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?; if let Some(v) = num(p, "size") { - b.size = v.max(0.5); + b.resize_with_dual_brush(v.max(0.5)); } if let Some(v) = num(p, "hardness") { b.hardness = v.clamp(0.0, 1.0); @@ -788,13 +793,19 @@ fn presets_delete(s: &mut Session, p: &Value) -> Result { if s.tools.presets.len() == before { return Err(bad(cmd, format!("no brush preset named `{name}`"))); } - if s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(&name)) { + forget_deleted_preset(s, &name); + Ok(json!({ "count": s.tools.presets.len() })) +} + +/// After preset `name` was deleted (here or in Edit › Presets › Preset Manager): the current +/// brush no longer comes from a preset, and the library is saved. +pub(crate) fn forget_deleted_preset(s: &mut Session, name: &str) { + if s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(name)) { s.tools.current_preset = None; // The remembered brush names the preset it was picked from: the name went away. s.tools.brush_changed(); } s.brush_presets_changed(); - Ok(json!({ "count": s.tools.presets.len() })) } /// Overwrite an existing preset with the current brush (or the current brush plus a `brush` @@ -858,9 +869,16 @@ 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. The + // multiply is checked too: on a 32-bit target (wasm32) it can overflow where the rest of + // this command is already fallible. + let len = w.checked_mul(h).ok_or_else(|| crate::allocation::error("defining a brush from the selection", "requested size overflowed"))?; + let mut v = crate::allocation::filled(len, 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); @@ -868,11 +886,24 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result { v[y * w + x] = ((1.0 - l) * c[3] * sv[(y * w + x) * sc]).clamp(0.0, 1.0); } } + let (brush, tw, th) = sampled_brush(cmd, &v, w, h)?; + upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new(), folder: Vec::new(), captures: Default::default() }); + s.tools.brush = brush; + s.tools.current_preset = Some(name.clone()); + s.tools.brush_changed(); + Ok(json!({ "name": name, "width": tw, "height": th })) +} + +/// A sampled-tip brush from a paint-amount map (`w`×`h`, row-major, 0 = no paint, 1 = full): +/// cropped to the painted extent, at most [`MAX_TIP`] px a side. Shared by Define Brush Preset +/// and importing an image file as a brush (`brush.presets.importImage`). +pub(crate) fn sampled_brush(cmd: &str, v: &[f32], w: usize, h: usize) -> Result<(BrushSettings, u32, u32)> { + let at = |x: usize, y: usize| v.get(y * w + x).copied().unwrap_or(0.0); // Crop to the painted extent. let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0); for y in 0..h { for x in 0..w { - if v[y * w + x] > 1e-4 { + if at(x, y) > 1e-4 { x0 = x0.min(x); y0 = y0.min(y); x1 = x1.max(x + 1); @@ -881,23 +912,19 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result { } } if x1 <= x0 || y1 <= y0 { - return Err(bad(cmd, "the selected pixels contain no paint (all white or transparent)")); + return Err(bad(cmd, "the pixels contain no paint (all white or transparent)")); } let (tw, th) = ((x1 - x0) as u32, (y1 - y0) as u32); if tw.max(th) > MAX_TIP { - return Err(bad(cmd, format!("selection too large for a brush tip ({tw}×{th}; max {MAX_TIP} px)"))); + return Err(bad(cmd, format!("too large for a brush tip ({tw}×{th}; max {MAX_TIP} px)"))); } let mut data = Vec::with_capacity((tw * th) as usize); for y in y0..y1 { - data.extend_from_slice(&v[y * w + x0..y * w + x1]); + data.extend((x0..x1).map(|x| at(x, y))); } let tip = GrayTile::from_f32(tw, th, &data); let brush = BrushSettings { tip: TipShape::Sampled(tip), size: tw.max(th) as f32, spacing: 0.25, pressure_size: false, ..BrushSettings::default() }; - upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new(), folder: Vec::new(), captures: Default::default() }); - s.tools.brush = brush; - s.tools.current_preset = Some(name.clone()); - s.tools.brush_changed(); - Ok(json!({ "name": name, "width": tw, "height": th })) + Ok((brush, tw, th)) } pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index d91e9ec4d7..cf9c761ce6 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -349,6 +349,89 @@ fn set_brush_merges_fields() { assert_eq!(s.tools.brush.texture.pattern, chalk_pattern); } +#[test] +fn resizing_a_brush_resizes_its_enabled_secondary_tip() { + let mut s = session(64, 64); + s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 12}})).unwrap(); + s.execute("tools.setBrush", json!({"size": 80})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (80.0, 24.0)); + + s.execute("tools.setBrush", json!({"brush": {"size": 20}})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (20.0, 6.0)); + + // Editing both diameters in the same patch is an explicit secondary-tip size. + s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"size": 18}})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 18.0)); + s.execute("tools.setBrush", json!({"dualBrush": {"size": 9}})).unwrap(); + assert_eq!(s.tools.brush.dual_brush.size, 9.0); + + // The stroke-level size override must use the same proportional dimensions. + let for_stroke = resolve_brush(&s, &json!({"size": 80, "points": [[10, 10]]}), "paint.stroke").unwrap(); + assert_eq!((for_stroke.size, for_stroke.dual_brush.size), (80.0, 18.0)); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 9.0)); +} + +/// Every way of changing the main size scales the Dual Brush tip as Photoshop does (#4809): `[` / +/// `]`, the options bar / Brushes panel / Alt+right-drag (`tools.setBrush` with only a size), down +/// to 1 px and back without losing the proportion to a floor. +#[test] +fn every_size_change_scales_the_dual_tip_and_keeps_its_proportion() { + let mut s = session(32, 32); + s.execute("tools.setBrush", json!({"brush": {"size": 100, "dualBrush": {"enabled": true, "size": 20}}})).unwrap(); + let sizes = |s: &Session| (s.tools.brush.size, s.tools.brush.dual_brush.size); + s.execute("tools.decreaseBrushSize", json!({})).unwrap(); + assert_eq!(sizes(&s), (80.0, 16.0)); + s.execute("tools.increaseBrushSize", json!({})).unwrap(); + assert_eq!(sizes(&s), (100.0, 20.0)); + // A coalesced drag (options bar slider, Alt+right-drag) is a run of size-only patches. + for size in [60, 10, 1] { + s.execute("tools.setBrush", json!({"brush": {"size": size}, "coalesce": "drag"})).unwrap(); + } + assert_eq!(s.tools.brush.size, 1.0); + assert!((s.tools.brush.dual_brush.size - 0.2).abs() < 1e-5, "no 0.5 px floor: {}", s.tools.brush.dual_brush.size); + s.execute("tools.setBrush", json!({"brush": {"size": 100}})).unwrap(); + assert_eq!(s.tools.brush.size, 100.0); + assert!((s.tools.brush.dual_brush.size - 20.0).abs() < 1e-3, "back to 20 px: {}", s.tools.brush.dual_brush.size); + // Painting with the sub-pixel dual tip works (the renderer bounds it). + s.execute("tools.setBrush", json!({"brush": {"size": 1}})).unwrap(); + s.execute("paint.stroke", json!({"points": [[4, 16], [28, 16]]})).unwrap(); +} + +/// Picking a preset that captured its size sets both diameters from the preset: the dual tip is +/// not scaled on top of that. +#[test] +fn a_captured_preset_size_is_not_scaled_twice() { + let mut s = session(32, 32); + s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 10}})).unwrap(); + s.execute("brush.presets.save", json!({"name": "Dual40"})).unwrap(); + s.execute("tools.setBrush", json!({"size": 200})).unwrap(); + assert_eq!(s.tools.brush.dual_brush.size, 50.0); + s.execute("tools.setBrush", json!({"preset": "Dual40"})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 10.0)); +} + +#[test] +fn bad_sizes_fail_and_leave_the_dual_tip_alone() { + let mut s = session(32, 32); + s.execute("tools.setBrush", json!({"brush": {"size": 100, "dualBrush": {"enabled": true, "size": 20}}})).unwrap(); + for bad in [json!(1e30), json!("big"), json!([1]), json!({"x": 1}), json!(null)] { + let _ = s.execute("tools.setBrush", json!({"brush": {"size": bad}})); + assert_eq!(s.tools.brush.dual_brush.size, 20.0, "{bad}"); + assert_eq!(s.tools.brush.size, 100.0, "{bad}"); + } + assert!(s.execute("tools.setBrush", json!({"brush": {"size": 1e30}})).is_err()); +} + +#[test] +fn an_uncaptured_preset_size_keeps_dual_brush_proportions() { + let mut s = session(32, 32); + s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 10}})).unwrap(); + s.execute("brush.presets.save", json!({"name": "Dual", "captureSize": false})).unwrap(); + s.execute("tools.setBrush", json!({"size": 80})).unwrap(); + s.execute("tools.setBrush", json!({"preset": "Dual"})).unwrap(); + assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (80.0, 20.0)); +} + #[test] fn set_brush_keeps_untouched_bitmaps_and_replaces_touched_ones() { let tile = |w, h, k: u32| GrayTile::from_fn(w, h, move |x, y| ((x * k + y) % 5) as f32 / 4.0); @@ -410,6 +493,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)] { @@ -910,6 +1013,23 @@ fn an_offer_that_does_not_match_the_commit_is_ignored() { assert!(r.get("fromLive").is_none()); } +#[test] +fn set_brush_takes_a_per_dynamic_curve_and_a_patch_resets_it() { + let mut s = session(20, 20); + let curve = json!([[0.0, 0.0], [0.5, 0.2], [1.0, 1.0]]); + s.execute("tools.setBrush", json!({"shapeDynamics": {"enabled": true, "size": {"control": "penPressure", "curve": curve}}})).unwrap(); + let with = s.tools.brush.clone(); + assert_eq!(with.shape_dynamics.size.curve.points(), vec![[0.0, 0.0], [0.5, 0.2], [1.0, 1.0]]); + assert!(with.transfer.opacity.curve.is_linear(), "other dynamics keep their own curve"); + // The panel's diff back to linear is a real patch, and applying it restores the linear curve. + let mut linear = with.clone(); + linear.shape_dynamics.size.curve = photocraft_paint::ResponseCurve::LINEAR; + let patch = brush_patch(&with, &linear); + assert_eq!(patch["shapeDynamics"]["size"]["curve"], json!([[0.0, 0.0], [1.0, 1.0]])); + s.execute("tools.setBrush", patch).unwrap(); + assert!(s.tools.brush.shape_dynamics.size.curve.is_linear()); +} + #[test] fn last_brush_is_remembered_when_the_revision_moves_only() { let mut s = session(8, 8); diff --git a/crates/engine/src/brush_preset_cmds.rs b/crates/engine/src/brush_preset_cmds.rs index 46331126ac..1c126ec477 100644 --- a/crates/engine/src/brush_preset_cmds.rs +++ b/crates/engine/src/brush_preset_cmds.rs @@ -109,21 +109,29 @@ fn rename(s: &mut Session, p: &Value) -> Result { let cmd = "brush.presets.rename"; let i = find(s, p, "name", cmd)?; let new = text(p, "newName", cmd)?; - if index_of(&s.tools.presets, &new).is_some_and(|j| j != i) { + rename_at(s, i, &new, cmd)?; + Ok(json!({ "name": new })) +} + +/// Rename preset `i` (shared by Brush Settings and Edit › Presets › Preset Manager): refuses a +/// name another preset has (case-insensitively), makes the preset the user's and keeps the +/// current preset pointing at it. +pub(crate) fn rename_at(s: &mut Session, i: usize, new: &str, cmd: &str) -> Result<()> { + if index_of(&s.tools.presets, new).is_some_and(|j| j != i) { return Err(bad(cmd, format!("a brush preset named `{new}` already exists"))); } let pr = s.tools.presets.get_mut(i).ok_or_else(|| bad(cmd, "no such preset"))?; if pr.name != new { let was_current = s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(&pr.name)); - pr.name = new.clone(); + pr.name = new.to_string(); pr.builtin = false; if was_current { - s.tools.current_preset = Some(new.clone()); + s.tools.current_preset = Some(new.to_string()); s.tools.brush_changed(); } s.brush_presets_changed(); } - Ok(json!({ "name": new })) + Ok(()) } fn move_preset(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 0722c6bd10..8de32f4327 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -55,9 +55,15 @@ fn map_fill(a: &Affine, f: &mut Fill) { if let Fill::Gradient { angle, offset, .. } = f { *angle = map_angle(a, *angle); // The centre offset (a fraction of the frame) turns with the canvas: exact for 90° turns - // and flips, where the frame's sides swap with the axes. + // and flips, where the frame's sides swap with the axes. Scale (Image Size) resizes the + // frame too, so only each axis's direction applies. + let unit = |x: f64, y: f64| { + let n = x.hypot(y); + if n < EPS { (x, y) } else { (x / n, y / n) } + }; + let ((m0, m1), (m2, m3)) = (unit(a.m[0], a.m[1]), unit(a.m[2], a.m[3])); let (ox, oy) = (f64::from(offset.0), f64::from(offset.1)); - *offset = ((a.m[0] * ox + a.m[2] * oy) as f32, (a.m[1] * ox + a.m[3] * oy) as f32); + *offset = ((m0 * ox + m2 * oy) as f32, (m1 * ox + m3 * oy) as f32); } } @@ -150,15 +156,24 @@ pub(crate) fn transform_doc_marks(doc: &mut Document, a: &Affine) { if m1.abs() < EPS && m2.abs() < EPS { g.horizontal.iter_mut().for_each(|y| *y = m3 * *y + ty); g.vertical.iter_mut().for_each(|x| *x = m0 * *x + tx); + // Colours are keyed by exact position, so they move by the same expressions. + for c in &mut g.colors { + c.position = if c.vertical { m0 * c.position + tx } else { m3 * c.position + ty }; + } } else if m0.abs() < EPS && m3.abs() < EPS { // A horizontal guide at y becomes a vertical one at x' = m2·y + tx, and vice versa. let v: Vec = g.horizontal.iter().map(|y| m2 * y + tx).collect(); let h: Vec = g.vertical.iter().map(|x| m1 * x + ty).collect(); g.horizontal = h; g.vertical = v; + for c in &mut g.colors { + c.position = if c.vertical { m1 * c.position + ty } else { m2 * c.position + tx }; + c.vertical = !c.vertical; + } } else { g.horizontal.clear(); g.vertical.clear(); + g.colors.clear(); } } @@ -238,7 +253,8 @@ impl Turn { } /// Every pixel surface a canvas turn moves: content, rendered caches (type, shape, smart object, -/// fill), masks (layer, smart filter), alpha channels, Quick Mask and the selection. +/// fill), stored video frames (current and original), masks (layer, smart filter), alpha +/// channels, Quick Mask and the selection. fn remap_pixels(doc: &mut Document, map: &dyn Fn(i32, i32) -> (i32, i32)) { fn rec(layers: &mut [Layer], map: &dyn Fn(i32, i32) -> (i32, i32)) { let re = |s: &mut Surface| *s = remap_surface(s, map); @@ -249,6 +265,10 @@ fn remap_pixels(doc: &mut Document, map: &dyn Fn(i32, i32) -> (i32, i32)) { if let Some(fc) = &mut l.fill_cache { re(&mut fc.surface); } + // Frames away from the playhead come back on scrubbing; they turn with the canvas (#4956). + if let Some(v) = &mut l.video { + v.frames.iter_mut().chain(v.original_frames.iter_mut().flatten()).for_each(re); + } match &mut l.content { LayerContent::Raster(s) => re(s), LayerContent::Text(t) => t.cache.iter_mut().for_each(re), diff --git a/crates/engine/src/canvas_geom/tests.rs b/crates/engine/src/canvas_geom/tests.rs index 804a18b665..2ba0dfbd66 100644 --- a/crates/engine/src/canvas_geom/tests.rs +++ b/crates/engine/src/canvas_geom/tests.rs @@ -188,6 +188,39 @@ fn gradient_fill_angle_follows_the_canvas() { assert_eq!(map_angle(&Turn::Ccw90.affine(10.0, 20.0), 0.0), 90.0); } +/// The gradient fill's centre offset after `cmd`, as `gradient.fill.get` reports it (% of the frame). +fn fill_offset_after(cmd: &str, p: serde_json::Value) -> (f64, f64) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("gradient.fill.create", json!({"from": [10, 50], "to": [90, 50], "style": "radial"})).unwrap(); + s.execute("gradient.fill.set", json!({"angle": 0, "offset": [25, 0]})).unwrap(); + s.execute(cmd, p).unwrap(); + let g = s.execute("gradient.fill.get", json!({})).unwrap(); + (g["offset"][0].as_f64().unwrap(), g["offset"][1].as_f64().unwrap()) +} + +#[test] +fn gradient_fill_offset_stays_a_fraction_of_the_frame() { + let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3; + // Image Size scales the frame, so the offset (a fraction of it) stays put. + for (w, h) in [(200, 200), (50, 50), (200, 50)] { + let o = fill_offset_after("image.imageSize", json!({"width": w, "height": h})); + assert!(near(o, (25.0, 0.0)), "{w}x{h}: {o:?}"); + } + // Flips and 90° turns still mirror and turn it. + assert!(near(fill_offset_after("image.imageRotation.flipCanvasHorizontal", json!({})), (-25.0, 0.0))); + assert!(near(fill_offset_after("image.imageRotation.90cw", json!({})), (0.0, 25.0))); + assert!(near(fill_offset_after("image.imageRotation.180", json!({})), (-25.0, 0.0))); + // Non-uniform scale with a turn: only the axis swap applies. + let mut f = Fill::gradient(Vec::new(), 0.0, 1.0, photocraft_doc::GradientStyle::Linear, false); + if let Fill::Gradient { offset, .. } = &mut f { + *offset = (0.25, -0.1); + } + map_fill(&Affine { m: [0.0, 3.0, -0.5, 0.0, 7.0, 1.0] }, &mut f); + let Fill::Gradient { offset, .. } = f else { panic!("not a gradient") }; + assert!((offset.0 - 0.1).abs() < 1e-6 && (offset.1 - 0.25).abs() < 1e-6, "{offset:?}"); +} + fn type_and_shape_origin(d: &Document) -> ([f64; 6], [f64; 2]) { let mut t = None; let mut k = None; @@ -230,6 +263,28 @@ fn canvas_size_and_crop_move_vector_geometry() { assert!(mean < 1.0 / 255.0, "{mean}"); } +#[test] +fn guide_colours_follow_their_guides() { + const MAGENTA: [u8; 3] = [255, 0, 255]; + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 20, "color": "#ff00ff"})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 30})).unwrap(); + // Crop shifts both guides by 10: the colour goes with the guide at 20, not to the one now there. + s.execute("image.crop", json!({"x": 10, "y": 0, "width": W - 10, "height": H})).unwrap(); + let g = &doc(&s).guides; + assert_eq!(g.vertical, vec![10.0, 20.0]); + assert_eq!((g.color(true, 10.0), g.color(true, 20.0)), (Some(MAGENTA), None)); + // A quarter turn makes it a horizontal guide at y = 10, still magenta. + s.execute("image.imageRotation.90cw", json!({})).unwrap(); + let g = &doc(&s).guides; + assert_eq!(g.horizontal, vec![10.0, 20.0]); + assert_eq!((g.color(false, 10.0), g.color(true, 10.0)), (Some(MAGENTA), None)); + // An arbitrary angle drops the guides, so their colours go too. + s.execute("image.rotation.arbitrary", json!({"angle": 33})).unwrap(); + assert!(doc(&s).guides.colors.is_empty()); +} + #[test] fn image_size_scales_vector_geometry() { let mut s = mixed(8, "rgb"); @@ -270,6 +325,30 @@ fn arbitrary_rotation_moves_marks_and_unlinked_vector_masks() { assert!(mean < 3.0 / 255.0 && big < 0.02, "mean {mean} big {big}"); } +/// A canvas turn moves every stored video frame, not just the displayed one (#4956). +#[test] +fn turns_move_stored_video_frames() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s, |x, y| if (x, y) == (1, 2) { [1.0, 0.0, 0.0, 1.0] } else { [0.0; 4] }); + s.edit("video", |doc, active| { + let l = doc.layer_mut(active.unwrap()).unwrap(); + let frame = l.surface().unwrap().clone(); + let mut v = photocraft_doc::VideoData::new(vec![frame.clone(), frame.clone()], 24.0); + v.original_frames = Some(vec![frame.clone(), frame]); + l.video = Some(v); + Ok(()) + }) + .unwrap(); + s.execute(command(Turn::FlipHorizontal), json!({})).unwrap(); + let v = doc(&s).layers[1].video.as_ref().unwrap(); + for frame in v.frames.iter().chain(v.original_frames.iter().flatten()) { + assert_eq!(frame.pixel(W as i32 - 2, 2), vec![1.0, 0.0, 0.0, 1.0]); + assert_eq!(frame.pixel(1, 2)[3], 0.0); + } +} + #[test] fn turns_on_empty_and_tiny_documents_never_panic() { for (w, h) in [(1, 1), (1, 7), (5, 1)] { diff --git a/crates/engine/src/channel_clip.rs b/crates/engine/src/channel_clip.rs index fc52cf0a43..86a49b0be6 100644 --- a/crates/engine/src/channel_clip.rs +++ b/crates/engine/src/channel_clip.rs @@ -59,15 +59,16 @@ pub(crate) fn copy(s: &mut Session) -> Result> { return Err(EngineError::Other("Could not copy: the selected area is empty".into())); } let fmt = PixelFormat::new(ColorMode::Grayscale, doc.pixel_format().sample, true); - let values = src.read_region(area); - let cover = sel.map(|m| (m.read_region(area), m.format().channels().max(1))); - let mut px = Vec::with_capacity(values.len() / n * 2); + let values = crate::allocation::read_region(src, area, "copying a channel")?; + let cover = sel.map(|m| crate::allocation::read_region(m, area, "copying a channel selection").map(|v| (v, m.format().channels().max(1)))).transpose()?; + let len = (values.len() / n).checked_mul(2).ok_or_else(|| EngineError::Other("the channel is too large to copy".into()))?; + let mut px = crate::allocation::capacity(len, "copying a channel")?; for (i, p) in values.chunks_exact(n).enumerate() { px.push(p.get(k).copied().unwrap_or(0.0)); px.push(cover.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0))); } let mut surface = Surface::new(fmt); - surface.write_region(area, &px); + surface.try_write_region(area, &px)?; surface.prune(); let bounds = surface.content_bounds(); if bounds.is_empty() { @@ -95,10 +96,13 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit: return Err(EngineError::Other(format!("no colour channel {k} in this layer"))); } if !area.is_empty() { - let mut rgba = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize]; + let len = (area.width() as usize).checked_mul(area.height() as usize).ok_or_else(|| EngineError::Other("the channel paste is too large".into()))?; + let mut rgba = crate::allocation::filled(len, [0.0f32; 4], "pasting into a channel")?; src.read_rgba_into(area, &mut rgba); - let weight = limit.map(|m| (m.read_region(area), m.format().channels().max(1))); - let mut px = surf.read_region(area); + let weight = limit + .map(|m| crate::allocation::read_region(m, area, "reading the channel paste selection").map(|v| (v, m.format().channels().max(1)))) + .transpose()?; + let mut px = crate::allocation::read_region(surf, area, "pasting into a channel")?; for (i, (dst, s)) in px.chunks_exact_mut(n).zip(&rgba).enumerate() { let w = s[3] * weight.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0)); let v = photocraft_color::convert::rgb_to_gray([s[0], s[1], s[2]]); @@ -106,7 +110,7 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit: *c += (v - *c) * w.clamp(0.0, 1.0); } } - surf.write_region(area, &px); + surf.try_write_region(area, &px)?; surf.prune(); } doc.selection = None; diff --git a/crates/engine/src/channel_clip/tests.rs b/crates/engine/src/channel_clip/tests.rs index 39cb029b3d..3d89c8f647 100644 --- a/crates/engine/src/channel_clip/tests.rs +++ b/crates/engine/src/channel_clip/tests.rs @@ -163,3 +163,41 @@ fn clearing_a_channel_reports_allocation_failure_without_editing() { assert_eq!(s.active().unwrap().history.entries(), history); } } + +#[test] +fn channel_paste_allocation_failure_is_transactional() { + for depth in [8, 16, 32] { + let mut s = session(depth); + s.execute("edit.copy", json!({})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + crate::allocation::fail_next_for_test(); + let result = paste(&mut s, &json!({}), 1, true, None, "Paste"); + let _ = crate::allocation::capacity::(0, "reset failpoint"); + assert!(result.is_err()); + assert_eq!(*s.active().unwrap().doc, *before); + assert_eq!(s.active().unwrap().history.entries(), history); + paste(&mut s, &json!({}), 1, true, None, "Paste").unwrap(); + assert!(s.undo()); + assert_eq!(*s.active().unwrap().doc, *before); + } +} + +#[test] +fn channel_copy_allocation_failure_keeps_clipboard_and_document() { + for depth in [8, 16, 32] { + let mut s = session(depth); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "red"})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let clip = s.clipboard.as_ref().unwrap().surface.clone(); + let history = s.active().unwrap().history.entries(); + crate::allocation::fail_next_for_test(); + let result = copy(&mut s); + let _ = crate::allocation::capacity::(0, "reset failpoint"); + assert!(result.is_err()); + assert_eq!(*s.active().unwrap().doc, *before); + assert_eq!(s.active().unwrap().history.entries(), history); + assert_eq!(s.clipboard.as_ref().unwrap().surface, clip); + } +} diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 00f3ec4e31..991f40dc0b 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -182,6 +182,82 @@ pub fn composite_name(mode: ColorMode) -> &'static str { } } +/// The Curves dialog's Channel popup lists only this channel when the Channels panel has a +/// single non-composite target (a colour component, layer mask, saved channel or Quick Mask). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IsolatedCurveChannel { + /// Channels-panel name, including a custom alpha name or "{layer} Mask". + pub name: String, + /// Curves param key: empty = composite `points`; otherwise `red`/`green`/… + pub key: &'static str, + /// Index into the tone-space histograms (composite = 0 for RGB/CMYK). + pub hist: usize, + /// Colour components keep their curve tint; masks and alphas do not. + pub tint: bool, + /// Built-in labels (colour channels, Quick Mask) follow the interface language; a custom + /// alpha name or a "{layer} Mask" keeps the layer name, so it stays exactly as entered (#5335). + pub translate: bool, + /// The gray plane this target edits, whatever the document's colour mode; its surface is + /// what the dialog's histogram must describe (#5335). None for colour components. + pub gray: Option, +} + +/// A grayscale plane Curves can target: the dialog parses and previews its curves in gray levels +/// even in Lab and CMYK documents (#5335). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GraySurface { + /// The active layer's layer mask. + LayerMask, + /// A saved channel, as an index into [`Document::channels`]. + Alpha(usize), + /// The Quick Mask. + QuickMask, +} + +/// Isolated Curves channel for the active document, matching [`inject_target`]. +pub fn isolated_curve_channel(st: &DocState) -> Option { + isolated_curve_channel_for(st, false) +} + +/// [`isolated_curve_channel`] with the Layers panel's mask target added: clicking a mask +/// thumbnail targets it without touching the Channels panel, and both ways of selecting the mask +/// must give the same Curves dropdown (#5335). +pub fn isolated_curve_channel_for(st: &DocState, layers_panel_mask: bool) -> Option { + let doc = &st.doc; + let mode = edit_mode(doc); + match st.channel_view.target { + ChannelTarget::Color(k) => { + let names = color_names(mode); + let keys = crate::adjust_params::channel_keys(crate::adjust_params::default_space(mode)); + let name = names.get(k)?.to_string(); + let key = *keys.get(k)?; + let hist = if matches!(mode, ColorMode::Lab) { k } else { k.saturating_add(1) }; + Some(IsolatedCurveChannel { name, key, hist, tint: true, translate: true, gray: None }) + } + ChannelTarget::Alpha(i) => doc.channels.get(i).map(|c| IsolatedCurveChannel { + name: c.name.clone(), + key: "", + hist: 0, + tint: false, + translate: false, + gray: Some(GraySurface::Alpha(i)), + }), + ChannelTarget::Composite if doc.quick_mask.is_some() => { + Some(IsolatedCurveChannel { name: "Quick Mask".into(), key: "", hist: 0, tint: false, translate: true, gray: Some(GraySurface::QuickMask) }) + } + ChannelTarget::LayerMask => layer_mask_curve_channel(st), + ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() || layers_panel_mask => layer_mask_curve_channel(st), + _ => None, + } +} + +fn layer_mask_curve_channel(st: &DocState) -> Option { + let layer = st.active_layer.and_then(|id| st.doc.layer(id))?; + layer.mask.as_ref()?; + let name = format!("{} Mask", layer.name); + Some(IsolatedCurveChannel { name, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::LayerMask) }) +} + fn edit_mode(doc: &Document) -> ColorMode { doc.pixel_format().mode } @@ -1010,17 +1086,19 @@ fn indicates_param(v: Option<&Value>) -> Option { } } -fn color_value(p: &Value, key: &str) -> Option { - p.get(key)?; - let c = crate::commands::color_param(p, key, [1.0, 0.0, 0.0, 1.0]); - Some(Color::rgb(c[0], c[1], c[2])) +fn color_value(cmd: &str, p: &Value, key: &str) -> Result> { + if p.get(key).is_none() { + return Ok(None); + } + let c = crate::commands::color_param(cmd, p, key, [1.0, 0.0, 0.0, 1.0])?; + Ok(Some(Color::rgb(c[0], c[1], c[2]))) } fn new_channel(s: &mut Session, p: &Value) -> Result { let fill = p.get("fill").and_then(Value::as_str).unwrap_or("black").to_string(); let name = p.get("name").and_then(Value::as_str).map(str::to_string); let indicates = indicates_param(p.get("indicates")); - let color = color_value(p, "color"); + let color = color_value("channel.new", p, "color")?; let opacity = p.get("opacity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); let i = s.edit("New Channel", |doc, _| { let area = doc.bounds(); @@ -1049,7 +1127,7 @@ fn new_channel(s: &mut Session, p: &Value) -> Result { } fn new_spot(s: &mut Session, p: &Value) -> Result { - let color = color_value(p, "color").unwrap_or(Color::rgb(1.0, 0.0, 0.0)); + let color = color_value("channel.newSpot", p, "color")?.unwrap_or(Color::rgb(1.0, 0.0, 0.0)); let solidity = p.get("solidity").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 100.0) as f32 / 100.0; let name = p.get("name").and_then(Value::as_str).map(str::to_string); let from_sel = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true); @@ -1345,7 +1423,7 @@ fn options(s: &mut Session, p: &Value) -> Result { let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; let r = p.get("channel").and_then(|v| parse_ref(v, doc)).ok_or_else(|| bad(cmd, "missing or unknown `channel`"))?; let name = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); - let color = color_value(p, "color"); + let color = color_value(cmd, p, "color")?; let opacity = p.get("opacity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); let solidity = p.get("solidity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); let kind = p.get("indicates").and_then(Value::as_str).map(str::to_string); diff --git a/crates/engine/src/channel_cmds/tests.rs b/crates/engine/src/channel_cmds/tests.rs index 1d721ee5c5..d63dc5fd0d 100644 --- a/crates/engine/src/channel_cmds/tests.rs +++ b/crates/engine/src/channel_cmds/tests.rs @@ -867,3 +867,51 @@ fn thumbnail_selection_shape_without_pixel_cache_uses_its_transparency() { assert_eq!((b.x0, b.y0, b.width(), b.height()), (20, 10, 40, 30)); } } + +/// #5335: a single targeted channel is the only Curves popup entry, named as in the Channels panel. +#[test] +fn isolated_curve_channel_matches_the_channels_panel() { + let mut s = session(); + assert!(isolated_curve_channel(s.active().unwrap()).is_none(), "composite lists every colour channel"); + s.execute("channel.target", json!({"channel": {"color": 1}})).unwrap(); + let green = isolated_curve_channel(s.active().unwrap()).expect("green"); + assert_eq!((green.name.as_str(), green.key, green.tint), ("Green", "green", true)); + assert!(green.translate && green.gray.is_none(), "built-in colour labels follow the language"); + + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "mask"})).unwrap(); + let mask = isolated_curve_channel(s.active().unwrap()).expect("mask"); + assert_eq!(mask.key, ""); + assert!(!mask.tint); + assert_eq!(mask.name, format!("{} Mask", doc(&s).layer(s.active().unwrap().active_layer.unwrap()).unwrap().name)); + + s.execute("channel.new", json!({"name": "Green", "fill": "white"})).unwrap(); + s.execute("channel.target", json!({"channel": 0})).unwrap(); + let alpha = isolated_curve_channel(s.active().unwrap()).expect("alpha"); + assert_eq!((alpha.name.as_str(), alpha.key, alpha.tint), ("Green", "", false)); + assert!(!alpha.translate, "a custom channel name stays exactly as entered"); + assert_eq!(alpha.gray, Some(GraySurface::Alpha(0))); + + let mut qm = session(); + qm.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + let row = isolated_curve_channel(qm.active().unwrap()).expect("quick mask"); + assert_eq!((row.name.as_str(), row.tint), ("Quick Mask", false)); + assert_eq!(row.gray, Some(GraySurface::QuickMask)); +} + +/// #5335: selecting the mask in the Layers panel gives the same isolated Curves channel as +/// selecting it in the Channels panel; the Quick Mask keeps priority. +#[test] +fn layers_panel_mask_target_isolates_the_curve_channel() { + let mut s = session(); + assert!(isolated_curve_channel_for(s.active().unwrap(), true).is_none(), "a layer without a mask is not targeted"); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "mask"})).unwrap(); + let by_panel = isolated_curve_channel(s.active().unwrap()).expect("mask targeted from the Channels panel"); + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + let by_layers = isolated_curve_channel_for(s.active().unwrap(), true).expect("mask targeted from the Layers panel"); + assert_eq!(by_panel, by_layers, "both ways of selecting the mask give the same dropdown"); + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + let row = isolated_curve_channel_for(s.active().unwrap(), true).expect("quick mask wins"); + assert_eq!((row.name.as_str(), row.gray), ("Quick Mask", Some(GraySurface::QuickMask))); +} diff --git a/crates/engine/src/clip_group.rs b/crates/engine/src/clip_group.rs index 4e356efce9..31f62696a3 100644 --- a/crates/engine/src/clip_group.rs +++ b/crates/engine/src/clip_group.rs @@ -42,6 +42,11 @@ fn group_of(doc: &Document, id: LayerId) -> Vec { if group.len() > 1 { group } else { Vec::new() } } +/// The base `id` clips to (itself when it is a base), when it is in a clip group. +pub(crate) fn base_of(doc: &Document, id: LayerId) -> Option { + group_of(doc, id).first().copied() +} + /// The layers clipped to `id`: the clipped run directly above it. Empty when `id` is itself /// clipped (it is not a base) or nothing is clipped to it. fn clipped_to(doc: &Document, id: LayerId) -> Vec { @@ -51,6 +56,14 @@ fn clipped_to(doc: &Document, id: LayerId) -> Vec { } } +/// The clipped layers directly above `id` among its siblings (Release Clipping Mask releases them +/// with it). +pub(crate) fn clipped_above(doc: &Document, id: LayerId) -> Vec { + let Some(path) = doc.path_of(id) else { return Vec::new() }; + let Some((list, idx)) = siblings(doc, &path) else { return Vec::new() }; + list.iter().skip(idx.saturating_add(1)).take_while(|l| l.clipped).map(|l| l.id).collect() +} + /// The clip groups of layers about to move or be deleted, read before they leave their slots. #[derive(Default)] pub(crate) struct Before { diff --git a/crates/engine/src/color_to_alpha_cmds.rs b/crates/engine/src/color_to_alpha_cmds.rs index a6208e8246..ba8b1aebcc 100644 --- a/crates/engine/src/color_to_alpha_cmds.rs +++ b/crates/engine/src/color_to_alpha_cmds.rs @@ -34,7 +34,7 @@ fn unit(v: f32, default: f32) -> f32 { /// Algorithm parameters (lenient: out-of-range values are clamped, unreadable ones fall back to /// the defaults). [`run`] validates strictly before it gets here. pub(crate) fn params(p: &Value) -> FilterParams { - let c = crate::commands::color_param(p, "color", WHITE); + let c = crate::commands::color_param(ID, p, "color", WHITE).unwrap_or(WHITE); let num = |k: &str, d: f32| p.get(k).and_then(Value::as_f64).map_or(d, |v| unit(v as f32, d)); FilterParams::ColorToAlpha { color: [unit(c[0], 1.0), unit(c[1], 1.0), unit(c[2], 1.0), 1.0], @@ -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/engine/src/commands.rs b/crates/engine/src/commands.rs index a32c5feb8d..9b4d4a7472 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -4,7 +4,7 @@ use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerId, LayerMask, Size}; use photocraft_geom::Rect; use photocraft_raster::Surface; -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; use crate::{EngineError, Result, Session, inspect, pixels}; @@ -117,7 +117,10 @@ fn has_selection(s: &Session) -> std::result::Result<(), String> { fn turn(s: &mut Session, label: &str, t: crate::canvas_geom::Turn) -> Result { if let Some(d) = s.active() { let a = t.affine(f64::from(d.doc.size.width), f64::from(d.doc.size.height)); - s.set_sampler_map(a); + // Sampler points are pixel indices, not pixel edges: map the pixel's centre, so the point + // lands on the same pixel `Turn::pixel_map` moves the pixels to (`w - 1 - x`). + let half = photocraft_geom::Affine::translate(0.5, 0.5); + s.set_sampler_map(half.then(&a).then(&photocraft_geom::Affine::translate(-0.5, -0.5))); } s.edit(label, |doc, _| { crate::canvas_geom::turn_canvas(doc, t); @@ -143,10 +146,32 @@ pub fn command_specs() -> &'static [CommandSpec] { pub fn list_entry(s: &Session, c: &CommandSpec, reason: Option) -> Value { let needs_document = s.active().is_none() && reason.as_deref().is_some_and(|r| ["no document", "no active document", "no open document"].iter().any(|p| r.starts_with(p))); - json!({ + let mut entry = json!({ "id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, "enabled": reason.is_none(), "disabledReason": reason, "needsDocument": needs_document, - }) + }); + if let (Some(k), Some(o)) = (search_keywords(c.id), entry.as_object_mut()) { + o.insert("keywords".into(), json!(k)); + } + entry +} + +/// Other names a command is looked for by, beyond its id and label: the Photoshop tools and +/// options it stands for (#5060). `select.rect` is also the Elliptical and Single Row/Column +/// Marquee, which an agent searches for as "ellipse" or "marquee". +pub(crate) const SEARCH_KEYWORDS: &[(&str, &str)] = + &[("select.rect", "Rectangular Marquee, Elliptical Marquee (\"ellipse\":true), ellipse, oval, circle, Single Row/Column Marquee (\"line\")")]; + +/// [`SEARCH_KEYWORDS`] of command `id`, if it has any. +pub fn search_keywords(id: &str) -> Option<&'static str> { + SEARCH_KEYWORDS.iter().find(|(k, _)| *k == id).map(|(_, words)| *words) +} + +/// Does a [`list_entry`] row match a command-list filter (`commands --filter`, `engine.commands`, +/// MCP `command_list`)? Its id, label or keywords contain `needle`, ignoring case. +pub fn list_entry_matches(entry: &Value, needle: &str) -> bool { + let needle = needle.to_lowercase(); + ["id", "label", "keywords"].iter().any(|k| entry.get(*k).and_then(Value::as_str).is_some_and(|v| v.to_lowercase().contains(&needle))) } pub fn find(id: &str) -> Option<&'static CommandSpec> { @@ -210,15 +235,53 @@ pub(crate) fn u32_id_param(cmd: &str, p: &Value, key: &str) -> Result f32 { p.get(key).and_then(Value::as_f64).map(|v| v as f32).unwrap_or(default) } -pub(crate) fn color_param(p: &Value, key: &str, default: [f32; 4]) -> [f32; 4] { - match p.get(key) { +/// JSON numbers may be finite f64 values but overflow when stored in a document's f32 field. +pub(crate) fn finite_f32(cmd: &str, key: &str, value: f64) -> Result { + let narrowed = value as f32; + if narrowed.is_finite() { Ok(narrowed) } else { Err(bad(cmd, format!("`{key}` must fit a finite 32-bit number"))) } +} + +/// Effect/type parameters contain nested colour arrays and snapshots as well as scalars. +/// Validate before parsing or editing, including numbers hidden in an effect snapshot. +pub(crate) fn check_f32_values(cmd: &str, value: &Value) -> Result<()> { + // Bound the call stack, without allocating a work list proportional to an input array. + fn check(cmd: &str, value: &Value, depth: usize) -> Result<()> { + if depth > 64 { + return Err(bad(cmd, "parameters nested deeper than 64 levels")); + } + match value { + Value::Number(n) => { + finite_f32(cmd, "number", n.as_f64().ok_or_else(|| bad(cmd, "invalid number"))?)?; + } + Value::Array(a) => { + for v in a { + check(cmd, v, depth + 1)?; + } + } + Value::Object(o) => { + for v in o.values() { + check(cmd, v, depth + 1)?; + } + } + _ => {} + } + Ok(()) + } + check(cmd, value, 0) +} + +pub(crate) fn color_param(cmd: &str, p: &Value, key: &str, default: [f32; 4]) -> Result<[f32; 4]> { + Ok(match p.get(key) { Some(Value::Array(a)) if a.len() >= 3 => { - let v: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect(); - [v[0], v[1], v[2], v.get(3).copied().unwrap_or(1.0)] + let mut out = [0.0, 0.0, 0.0, 1.0]; + for (slot, value) in out.iter_mut().zip(a) { + *slot = finite_f32(cmd, key, value.as_f64().unwrap_or(0.0))?; + } + out } Some(Value::String(s)) => parse_hex(s).unwrap_or(default), _ => default, - } + }) } pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> { let s = s.trim_start_matches('#'); @@ -231,7 +294,10 @@ pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> { } /// Layer › Create / Release Clipping Mask. With a `layer` param, just that layer. Otherwise, as in /// Photoshop, the selection (#1248): Create clips every selected layer except the lowest, which -/// becomes the base; Release releases every selected layer. One history step either way. +/// becomes the base; Release releases every selected layer. Releasing a clipped layer also +/// releases the clipped layers above it in its group, as Adobe documents ("removes the selected +/// layer and any layers above it from the clipping mask"), so none is left clipped to a layer +/// that is no longer clipped. One history step either way. fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result { let label = if clip { "Create Clipping Mask" } else { "Release Clipping Mask" }; let ids: Vec = if p.get("layer").is_some() { @@ -245,6 +311,20 @@ fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result { (false, all) => all.to_vec(), } }; + let ids = if clip { + ids + } else { + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let mut all = ids.clone(); + for id in ids.iter().filter(|id| doc.layer(**id).is_some_and(|l| l.clipped)) { + for above in crate::clip_group::clipped_above(doc, *id) { + if !all.contains(&above) { + all.push(above); + } + } + } + all + }; s.edit(label, |doc, _| { for id in &ids { doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?.clipped = clip; @@ -256,7 +336,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)) => { @@ -264,19 +345,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)? { @@ -359,12 +462,12 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value) }); } if crate::channel_cmds::is_channel_target(p) { - // Alpha channel / Quick Mask target: the adjustment runs on the grayscale channel. + // Alpha channel / Quick Mask target: the adjustment runs on the grayscale channel as on + // a layer mask, its unstored black included, so ⌘I in Quick Mask inverts it all (#6095). return s.edit(label, |doc, _| { let sel = doc.selection.clone(); if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, None, p)? { - pixels::adjust_surface(surf, &adj, sel.as_ref(), ColorMode::Grayscale); - surf.prune(); + pixels::adjust_mask(surf, &adj, sel.as_ref()); } Ok(Value::Null) }); @@ -392,6 +495,31 @@ fn doc_mode(s: &Session) -> ColorMode { s.active().map_or(ColorMode::Rgb, |d| d.doc.mode) } +/// The colour mode a destructive adjustment parses its tone space in: a targeted layer mask, +/// saved channel or Quick Mask holds gray levels whatever the document's mode, so Curves and +/// Levels stay in the neutral space instead of Lab (where the composite is ignored) or CMYK +/// (where the curve runs on the inks) (#5335). +fn adjust_mode(s: &Session, p: &Value) -> ColorMode { + if is_mask_target(p) || crate::channel_cmds::is_channel_target(p) { ColorMode::Grayscale } else { doc_mode(s) } +} + +/// Tone params for a gray plane target: the document-space channel records are dropped, because +/// those keys select the Lab or CMYK tone space and a plane only holds levels (#5335). +fn gray_tone_params(p: &Value) -> Value { + match p { + Value::Object(m) => { + let mut o = Map::new(); + for (k, v) in m { + if !matches!(k.as_str(), "cyan" | "magenta" | "yellow" | "black" | "lightness" | "a" | "b") { + o.insert(k.clone(), v.clone()); + } + } + Value::Object(o) + } + other => other.clone(), + } +} + // ---------- the table ---------- #[allow(clippy::too_many_lines)] @@ -626,7 +754,6 @@ fn build() -> Vec { return crate::layer_multi_cmds::duplicate_selected(s, in_place, "Duplicate Layers"); } let id = layer_param(s, p)?; - let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let nid = s.edit("Duplicate Layer", |doc, active| { let mut dup = layer_copy(doc, id)?; if let Some(name) = &name { @@ -639,7 +766,6 @@ fn build() -> Vec { *active = Some(nid); Ok(nid) })?; - crate::layer_multi_cmds::note_insert(s, &before); Ok(json!({ "layer": nid.0 })) } ), @@ -668,7 +794,7 @@ fn build() -> Vec { "Select Layer", [], None, - r##"{"layer":id,"mode":"replace|toggle|range|add|remove"="replace"} (toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##, + r##"{"layer":id|str | "name":str,"occurrence":index?,"mode":"replace|toggle|range|add|remove"="replace"} (name occurrence: zero-based document walk order; required for duplicate names; toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##, has_doc, crate::layer_multi_cmds::select ), @@ -677,55 +803,111 @@ fn build() -> Vec { "Layer Properties", [], None, - r##"{"layer":id?,"name":str?,"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##, + r##"{"layer":id?,"layers":[id,…]? (the same change to each, one step: the Layers panel's controls with several layers selected),"name":str? (one layer only),"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##, has_layer, |s, p| { - let id = layer_param(s, p)?; - let only_visibility = - p.as_object().is_some_and(|o| o.contains_key("visible") && o.keys().all(|k| matches!(k.as_str(), "layer" | "visible" | "coalesce"))); - let label = if only_visibility { "Layer Visibility" } else { "Layer Properties" }; + // Several layers at once (the Layers panel with several selected, as in Photoshop), + // else the given or active one. + let ids: Vec = match p.get("layers") { + None | Some(Value::Null) => vec![layer_param(s, p)?], + Some(Value::Array(a)) if !a.is_empty() && a.len() <= 10_000 => { + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let mut ids = Vec::with_capacity(a.len()); + for v in a { + let id = v.as_u64().map(LayerId).ok_or_else(|| bad("layer.setProps", format!("`layers` holds layer ids, got {v}")))?; + if doc.layer(id).is_none() { + return Err(EngineError::NoLayer(id)); + } + if !ids.contains(&id) { + ids.push(id); + } + } + ids + } + Some(v) => return Err(bad("layer.setProps", format!("`layers` is a non-empty list of layer ids, got {v}"))), + }; + if ids.len() > 1 && p.get("name").is_some() { + return Err(bad("layer.setProps", "`name` renames one layer, not several")); + } + // Opacity and Fill are fractions here, while the Layer Style commands take + // percentages: a value outside 0..1 is refused, never clamped to 100 % (#5242). + let unit = |key: &str| -> Result> { + let Some(v) = p.get(key) else { return Ok(None) }; + match v.as_f64() { + Some(x) if (0.0..=1.0).contains(&x) => Ok(Some(x as f32)), + _ => Err(bad("layer.setProps", format!("{key} must be a number in 0..1 (1 = 100 %), got {v}"))), + } + }; + let (opacity, fill) = (unit("opacity")?, unit("fill")?); + // Photoshop's History names for a single kind of change; a mix is "Layer Properties". + let changed: Vec<&str> = p + .as_object() + .map(|o| o.keys().map(String::as_str).filter(|k| !matches!(*k, "layer" | "layers" | "coalesce")).collect()) + .unwrap_or_default(); + let label = match changed.as_slice() { + ["visible"] => "Layer Visibility", + ["opacity"] => "Opacity Change", + ["fill"] => "Fill Opacity Change", + ["blend"] => "Blending Change", + ["name"] => "Rename Layer", + _ => "Layer Properties", + }; let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); // Clipping and channel changes reach the layers around it: recomposite everything. let local = p.get("clipped").is_none() && p.get("channels").is_none(); s.edit(label, |doc, _| { - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - if let Some(v) = p.get("name").and_then(Value::as_str) { - l.name = v.to_string(); - } - if let Some(v) = p.get("visible").and_then(Value::as_bool) { - l.visible = v; - } - if let Some(v) = p.get("opacity").and_then(Value::as_f64) { - l.opacity = (v as f32).clamp(0.0, 1.0); - } - if let Some(v) = p.get("fill").and_then(Value::as_f64) { - l.fill_opacity = (v as f32).clamp(0.0, 1.0); - } - if let Some(v) = p.get("blend").and_then(Value::as_str) { - l.blend = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?; - } - if let Some(v) = p.get("clipped").and_then(Value::as_bool) { - l.clipped = v; - } - if let Some(v) = p.get("locked").and_then(Value::as_bool) { - l.locks.all = v; - } - if let Some(Value::Array(ch)) = p.get("channels") { - // Blending Options › Advanced Blending › Channels: unchecked = left out. - l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i); - } - if let Some(Value::Object(m)) = p.get("locks") { - for (k, v) in m { - let v = v.as_bool().unwrap_or(false); - if !l.locks.set(k, v) { - return Err(bad("layer.setProps", format!("unknown lock `{k}`"))); + // The Background has no opacity, fill or blend mode: like Photoshop, setting + // one that changes its look turns it into a normal layer ("Layer 0") first. + let num = |k: &str| p.get(k).and_then(Value::as_f64).is_some_and(|v| v < 1.0); + let blends = p.get("blend").and_then(Value::as_str).and_then(blend_from_str).is_some_and(|b| b != photocraft_color::BlendMode::Normal); + for &id in &ids { + if num("opacity") || num("fill") || blends { + crate::extra_cmds::background_to_layer(doc, id); + } + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + if let Some(v) = p.get("name").and_then(Value::as_str) { + l.name = v.to_string(); + } + if let Some(v) = p.get("visible").and_then(Value::as_bool) { + l.visible = v; + } + if let Some(v) = opacity { + l.opacity = v; + } + if let Some(v) = fill { + l.fill_opacity = v; + } + if let Some(v) = p.get("blend").and_then(Value::as_str) { + let b = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?; + // Pass Through is a group's mode only, as in Photoshop. + if b == photocraft_color::BlendMode::PassThrough && !l.is_group() { + return Err(bad("layer.setProps", format!("Pass Through applies to groups only; layer {} isn't one", l.id.0))); + } + l.blend = b; + } + if let Some(v) = p.get("clipped").and_then(Value::as_bool) { + l.clipped = v; + } + if let Some(v) = p.get("locked").and_then(Value::as_bool) { + l.locks.all = v; + } + if let Some(Value::Array(ch)) = p.get("channels") { + // Blending Options › Advanced Blending › Channels: unchecked = left out. + l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i); + } + if let Some(Value::Object(m)) = p.get("locks") { + for (k, v) in m { + let v = v.as_bool().unwrap_or(false); + if !l.locks.set(k, v) { + return Err(bad("layer.setProps", format!("unknown lock `{k}`"))); + } } } } Ok(()) })?; if local { - crate::layer_multi_cmds::note_damage(s, &before, &[id]); + crate::layer_multi_cmds::note_damage(s, &before, &ids); } Ok(Value::Null) } @@ -788,7 +970,10 @@ fn build() -> Vec { |s, p| { let sel = s.active().and_then(|d| d.doc.selection.clone()).ok_or(EngineError::Other("no selection".into()))?; let mask = LayerMask { surface: sel, ..LayerMask::reveal_all() }; - set_mask(s, p, "Add Layer Mask", Some(mask)) + // The selection becomes the mask, so Photoshop drops it (as Hide Selection does): a + // following Invert (⌘I) on the targeted mask then inverts all of it, not just the + // part inside the old selection. Same undo step as the mask. + set_mask_and_deselect(s, p, "Add Layer Mask", mask) } ), cmd!("layer.layerMask.delete", "Delete", ["Layer", "Layer Mask"], None, r##"{"layer":id?}"##, has_layer, |s, p| set_mask( @@ -845,7 +1030,7 @@ fn build() -> Vec { r##"{"color":"#rrggbb"=foreground,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask, making a shape; else the selection is the layer mask)}"##, has_doc, |s, p| { - let c = color_param(p, "color", s.tools.foreground); + let c = color_param("layer.newFillLayer.solidColor", p, "color", s.tools.foreground)?; let id = s.edit("New Color Fill Layer", |doc, active| { let fill = Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode); let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill)); @@ -865,8 +1050,8 @@ fn build() -> Vec { r##"{"from":"#rrggbb","to":"#rrggbb","angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask)}"##, has_doc, |s, p| { - let a = color_param(p, "from", s.tools.foreground); - let b = color_param(p, "to", s.tools.background); + let a = color_param("layer.newFillLayer.gradient", p, "from", s.tools.foreground)?; + let b = color_param("layer.newFillLayer.gradient", p, "to", s.tools.background)?; let angle = f32_or(p, "angle", 90.0); let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); @@ -997,8 +1182,10 @@ fn build() -> Vec { } }; let (fg_cmyk, bg_cmyk) = (cmyk("foregroundCmyk")?, cmyk("backgroundCmyk")?); - s.tools.foreground = color_param(p, "foreground", s.tools.foreground); - s.tools.background = color_param(p, "background", s.tools.background); + let foreground = color_param("tools.setColors", p, "foreground", s.tools.foreground)?; + let background = color_param("tools.setColors", p, "background", s.tools.background)?; + s.tools.foreground = foreground; + s.tools.background = background; if let Some(k) = fg_cmyk { let [r, g, b, _] = s.tools.foreground; s.tools.remember_cmyk([r, g, b], k); @@ -1227,7 +1414,16 @@ fn build() -> Vec { }); let adj = match eyedropped { Some(adj) => adj, - None => crate::adjust_params::from_params(&kind, p, None, doc_mode(s))?, + None => { + // A gray plane target keeps the neutral tone space even over params + // carrying Lab or CMYK records (#5335). + let tone = if is_mask_target(p) || crate::channel_cmds::is_channel_target(p) { + if matches!(kind.as_str(), "levels" | "curves") { gray_tone_params(p) } else { p.clone() } + } else { + p.clone() + }; + crate::adjust_params::from_params(&kind, &tone, None, adjust_mode(s, p))? + } }; let label = adj.label().to_string(); // A smart object records the adjustment as a smart filter (Photoshop), which @@ -1241,6 +1437,7 @@ fn build() -> Vec { }); } v.extend(crate::layer_style::specs()); + v.extend(crate::layer_list_cmds::specs()); v.extend(crate::filters::specs()); v.extend(crate::filters_ext::specs()); v.extend(crate::color_to_alpha_cmds::specs()); @@ -1289,6 +1486,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()); @@ -1314,6 +1512,7 @@ fn build() -> Vec { v.extend(crate::proof_sim::specs()); v.extend(crate::presets::specs()); v.extend(crate::document_preset_cmds::specs()); + v.extend(crate::export_preset_cmds::specs()); v.extend(crate::render_cmds::specs()); v.extend(crate::slice_cmds::specs()); v.extend(crate::web_cmds::specs()); @@ -1427,6 +1626,15 @@ fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result { /// Add (`Some`) or remove (`None`) a layer mask. Adding acts on every selected layer that can take /// one ([`crate::layer_multi_cmds::mask_targets`]), in one history step. fn set_mask(s: &mut Session, p: &Value, label: &str, mask: Option) -> Result { + set_mask_with(s, p, label, mask, false) +} + +/// [`set_mask`] that also drops the selection the mask was made from, in the same history step. +fn set_mask_and_deselect(s: &mut Session, p: &Value, label: &str, mask: LayerMask) -> Result { + set_mask_with(s, p, label, Some(mask), true) +} + +fn set_mask_with(s: &mut Session, p: &Value, label: &str, mask: Option, deselect: bool) -> Result { let ids = if mask.is_some() { crate::layer_multi_cmds::mask_targets(s, p)? } else { vec![layer_param(s, p)?] }; s.edit(label, |doc, _| { let mask = mask.clone().map(|mask| mask_at_depth(mask, doc.depth)).transpose()?; @@ -1436,6 +1644,9 @@ fn set_mask(s: &mut Session, p: &Value, label: &str, mask: Option) -> } doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?.mask = mask.clone(); } + if deselect { + doc.selection = None; + } Ok(()) })?; Ok(Value::Null) @@ -1518,7 +1729,7 @@ fn merge_group(s: &mut Session, id: LayerId) -> Result { solo.clipped = false; let mut tmp = doc.clone(); tmp.layers = vec![solo]; - let mut merged = Layer::new(group.name.clone(), LayerContent::Raster(pixels::composite_layers(&tmp, fmt, None))); + let mut merged = Layer::new(group.name.clone(), LayerContent::Raster(pixels::composite_layers_uncropped(&tmp, fmt))); merged.id = group.id; merged.opacity = group.opacity; merged.blend = if group.blend == photocraft_doc::BlendMode::PassThrough { photocraft_doc::BlendMode::Normal } else { group.blend }; @@ -1547,3 +1758,30 @@ mod registry_tests { assert_eq!(registry.specs.len(), 2); } } + +#[cfg(test)] +mod set_props_tests { + use super::*; + + #[test] + fn opacity_and_fill_outside_zero_to_one_are_refused_not_clamped() { + // #5242: `55` (a percentage, as the Layer Style commands take) used to become 100 %. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.setProps", json!({"opacity": 0.55, "fill": 0.4})).unwrap(); + let layer = |s: &Session| { + let d = s.active().unwrap(); + let l = d.doc.layer(d.active_layer.unwrap()).unwrap(); + (l.opacity, l.fill_opacity) + }; + assert_eq!(layer(&s), (0.55, 0.4)); + for bad in [json!({"opacity": 55}), json!({"opacity": -0.1}), json!({"fill": 1.5}), json!({"opacity": "50%"}), json!({"opacity": 0.5, "fill": 40})] { + let e = s.execute("layer.setProps", bad.clone()).unwrap_err().to_string(); + assert!(e.contains("0..1"), "{bad}: {e}"); + assert_eq!(layer(&s), (0.55, 0.4), "{bad} left the layer alone"); + } + s.execute("layer.setProps", json!({"opacity": 0, "fill": 1})).unwrap(); + assert_eq!(layer(&s), (0.0, 1.0)); + } +} diff --git a/crates/engine/src/comps_cmds.rs b/crates/engine/src/comps_cmds.rs index fe5708387c..43a8ec12f0 100644 --- a/crates/engine/src/comps_cmds.rs +++ b/crates/engine/src/comps_cmds.rs @@ -208,6 +208,23 @@ fn apply_cmd(s: &mut Session, p: &Value) -> Result { apply(s, id, "Apply Layer Comp") } +/// How an action records Apply Layer Comp: by the comp's name, as Photoshop does. Comp ids are +/// local to a document, so a recorded id would apply whichever comp of another document has it +/// (#7181); by name the step finds that document's comp of the name, or stops with an error. +/// The comp is the one just applied, so `{}` and `null` (the last applied) record its name too. +pub(crate) fn recorded_apply(s: &Session, mut p: Value) -> Value { + let Some(d) = s.active() else { return p }; + let Some(comp) = d.doc.last_applied_comp.and_then(|id| d.doc.comp(id)) else { return p }; + // `null` params run as `{}` (the last applied comp), so they are recorded by name too. + if p.is_null() { + p = json!({}); + } + if let Value::Object(m) = &mut p { + m.insert("comp".into(), json!(comp.name)); + } + p +} + fn step(s: &mut Session, delta: i64) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let n = d.doc.layer_comps.len() as i64; diff --git a/crates/engine/src/comps_cmds/tests.rs b/crates/engine/src/comps_cmds/tests.rs index 5458523bcf..d4857ad025 100644 --- a/crates/engine/src/comps_cmds/tests.rs +++ b/crates/engine/src/comps_cmds/tests.rs @@ -347,3 +347,56 @@ fn clearing_warnings_of_an_unknown_comp_is_an_error() { assert_eq!(doc(&s).layer_comps, before); assert_eq!(s.execute("layerComp.updateWarnings", json!({})).unwrap()["count"], 1); } + +/// Apply Layer Comp is recorded by the comp's name, not its document-local id: played in another +/// document it applies that document's comp of the name, or stops when there is none (#7181). +#[test] +fn a_recorded_apply_layer_comp_finds_the_comp_by_name() { + let (mut s, _, _) = session(8); + s.execute("layerComp.new", json!({"name": "Wanted"})).unwrap(); + let wanted = doc(&s).layer_comps[0].id; + s.execute("actions.record", json!({"name": "ApplyWanted"})).unwrap(); + s.execute("layerComp.apply", json!({"comp": wanted})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + // `{}` and `null` apply the last applied comp; they are recorded by name too. + s.execute("actions.record", json!({"name": "ApplyLast"})).unwrap(); + s.execute("layerComp.apply", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + s.execute("actions.record", json!({"name": "ApplyNull"})).unwrap(); + s.execute("layerComp.apply", Value::Null).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + for action in ["ApplyWanted", "ApplyLast", "ApplyNull"] { + let steps = s.execute("actions.get", json!({"action": action})).unwrap()["steps"].clone(); + assert_eq!(steps, json!([["layerComp.apply", {"comp": "Wanted"}]]), "{action}"); + } + + // A second document whose comp with the recorded id is another one. + let (mut other, _, ob) = session(8); + other.actions = s.actions.clone(); + let show = |s: &mut Session, visible: bool| { + s.edit("t", |d, _| { + d.layer_mut(ob).unwrap().visible = visible; + Ok(()) + }) + .unwrap(); + }; + show(&mut other, false); + other.execute("layerComp.new", json!({"name": "Different"})).unwrap(); + show(&mut other, true); + other.execute("layerComp.new", json!({"name": "Wanted"})).unwrap(); + assert_eq!(doc(&other).layer_comps[0].id, wanted, "the recorded id names `Different` here"); + show(&mut other, false); + other.execute("actions.play", json!({"action": "ApplyWanted"})).unwrap(); + let applied = doc(&other).last_applied_comp.and_then(|id| doc(&other).comp(id)).map(|c| c.name.clone()); + assert_eq!(applied.as_deref(), Some("Wanted")); + assert!(doc(&other).layer(ob).unwrap().visible, "Wanted shows the layer"); + + // A document without a comp of that name stops the action instead of applying another one. + other.execute("layerComp.delete", json!({"comp": "Wanted"})).unwrap(); + show(&mut other, false); + let before = (doc(&other).last_applied_comp, doc(&other).layer(ob).unwrap().visible); + let played = other.execute("actions.play", json!({"action": "ApplyWanted"})).unwrap(); + assert_eq!(played["ran"], 0, "{played}"); + assert!(played["failed"]["error"].as_str().unwrap().contains("no layer comp named \"Wanted\""), "{played}"); + assert_eq!((doc(&other).last_applied_comp, doc(&other).layer(ob).unwrap().visible), before, "the document is unchanged"); +} diff --git a/crates/engine/src/crop_fill.rs b/crates/engine/src/crop_fill.rs new file mode 100644 index 0000000000..6ef0698cea --- /dev/null +++ b/crates/engine/src/crop_fill.rs @@ -0,0 +1,213 @@ +//! The Crop tool's Fill: Content-Aware Fill (#3823). After a rotated crop or one that extends past +//! the canvas, the area the original image doesn't cover (the corners a rotation uncovers, the +//! strips an extension adds) is filled from its surroundings instead of holding the background +//! colour or transparency. +//! +//! Which layer: the Background when the document has one (the layer the crop filled with the +//! background colour), otherwise the active pixel layer (where the crop left transparency). Other +//! layers keep their transparency there, as after a plain crop. Each connected piece of the +//! uncovered area (each corner, each edge strip) is filled on its own, with Photoshop's sampling +//! window around that piece, and only pixels the image covers are sampled. + +use photocraft_doc::{Document, LayerId}; +use photocraft_geom::Rect; +use rayon::prelude::*; + +use crate::{EngineError, Result}; + +/// Seed for the patch search, so a crop fills the same way every time. +const SEED: u64 = 1; + +/// The largest canvas (pixels) the uncovered area is worked out for: the Content-Aware window cap. +const MAX_PIXELS: u64 = 400_000_000; + +/// Where the cropped canvas came from in the original document. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Coverage { + /// Cropped canvas → original document: `[a, b, c, d, e, f]` with + /// `x = a·u + b·v + e`, `y = c·u + d·v + f`. + pub map: [f64; 6], + /// The original canvas size (px). + pub width: f64, + pub height: f64, + /// Whether the map rotates (its resampled edge is antialiased against the background colour). + pub rotated: bool, +} + +impl Coverage { + /// An unrotated crop to `r` (original document px). + pub fn crop(r: Rect, width: u32, height: u32) -> Self { + Coverage { map: [1.0, 0.0, 0.0, 1.0, f64::from(r.x0), f64::from(r.y0)], width: f64::from(width), height: f64::from(height), rotated: false } + } + + /// Whether the cropped canvas's pixel (u, v) is the original image's: its centre maps inside + /// the original canvas by at least half the pixel's projected size (exact for an unrotated + /// crop); a rotated crop also refills the one-pixel antialiased edge, which the resampling + /// blended with the background colour. + fn covered(&self, u: i32, v: i32, margin: f64) -> bool { + let [a, b, c, d, e, f] = self.map; + let (cu, cv) = (f64::from(u) + 0.5, f64::from(v) + 0.5); + let (x, y) = (a * cu + b * cv + e, c * cu + d * cv + f); + x >= margin && y >= margin && x <= self.width - margin && y <= self.height - margin + } + + fn margin(&self) -> f64 { + let [a, b, ..] = self.map; + 0.5 * (a.abs() + b.abs()) + if self.rotated { 1.0 } else { 0.0 } + } +} + +/// Labels of the uncovered pixels: 0 covered, `UNLABELLED` uncovered before labelling, else the +/// component number (1-based). +const UNLABELLED: u32 = u32::MAX; + +/// Fills the area of the cropped `doc` that `cov` says the original image doesn't cover (see the +/// module docs). Nothing uncovered: nothing changes. Errors leave `doc` to be rolled back by the +/// caller's history step. +pub(crate) fn fill_uncovered(doc: &mut Document, active: Option, cov: &Coverage) -> Result<()> { + let canvas = doc.bounds(); + let (w, h) = (canvas.width(), canvas.height()); + if u64::from(w) * u64::from(h) > MAX_PIXELS { + return Err(EngineError::Other(format!("the {w} x {h} crop is too large for Content-Aware fill"))); + } + let (w, h) = (w as usize, h as usize); + let len = w.checked_mul(h).ok_or_else(|| EngineError::Other("the crop is too large for Content-Aware fill".into()))?; + if len == 0 { + return Ok(()); + } + let margin = cov.margin(); + let mut labels = crate::allocation::filled(len, 0u32, "Content-Aware crop fill")?; + labels.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + for (x, l) in row.iter_mut().enumerate() { + if !cov.covered(x as i32, y as i32, margin) { + *l = UNLABELLED; + } + } + }); + if !labels.contains(&UNLABELLED) { + return Ok(()); + } + if !labels.contains(&0) { + return Err(EngineError::Other("Content-Aware fill needs some of the image inside the crop to sample from".into())); + } + let id = target_layer(doc, active)?; + let pieces = label_components(&mut labels, w, h)?; + let surf = doc.layer(id).and_then(|l| l.surface()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?; + let label = |x: i32, y: i32| -> u32 { + if x < 0 || y < 0 { + return 0; + } + (y as usize).checked_mul(w).and_then(|i| i.checked_add(x as usize)).and_then(|i| labels.get(i)).copied().unwrap_or(0) + }; + let n = surf.format().channels().max(1); + let filled: Vec<(Rect, Vec)> = pieces + .par_iter() + .enumerate() + .map(|(k, bounds)| -> Result<(Rect, Vec)> { + let k = k as u32 + 1; + let (window, px) = crate::fill_cmds::content_aware_native(surf, *bounds, canvas, true, SEED, |x, y| label(x, y) == k, |x, y| label(x, y) == 0)?; + // The piece's bounds, its pixels from the fill and the rest as they were. + let mut out = surf.read_region(*bounds); + let ww = window.width() as usize; + for y in bounds.y0..bounds.y1 { + for x in bounds.x0..bounds.x1 { + if label(x, y) != k || !window.contains(x, y) { + continue; + } + let src = ((y - window.y0) as usize * ww + (x - window.x0) as usize) * n; + let dst = ((y - bounds.y0) as usize * bounds.width() as usize + (x - bounds.x0) as usize) * n; + if let (Some(s), Some(d)) = (px.get(src..src + n), out.get_mut(dst..dst + n)) { + d.copy_from_slice(s); + } + } + } + Ok((*bounds, out)) + }) + .collect::>()?; + let surf = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?; + for (r, px) in filled { + surf.write_region(r, &px); + } + Ok(()) +} + +/// The Background, else the active pixel layer. +fn target_layer(doc: &Document, active: Option) -> Result { + if let Some(bg) = doc.background() { + return Ok(bg.id); + } + active + .filter(|id| doc.layer(*id).is_some_and(|l| matches!(l.content, photocraft_doc::LayerContent::Raster(_)))) + .ok_or_else(|| EngineError::Other("Content-Aware crop fill needs a Background or an active pixel layer".into())) +} + +/// Numbers the 8-connected pieces of `UNLABELLED` pixels 1, 2, … and returns each one's bounds. +fn label_components(labels: &mut [u32], w: usize, h: usize) -> Result> { + let mut pieces: Vec = Vec::new(); + let mut stack: Vec = Vec::new(); + for start in 0..labels.len() { + if labels.get(start) != Some(&UNLABELLED) { + continue; + } + let k = u32::try_from(pieces.len() + 1).ok().filter(|k| *k < UNLABELLED).ok_or_else(|| EngineError::Other("too many uncovered areas".into()))?; + let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); + if let Some(l) = labels.get_mut(start) { + *l = k; + } + stack.push(start); + while let Some(i) = stack.pop() { + let (x, y) = (i % w, i / w); + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + for dy in -1i64..=1 { + for dx in -1i64..=1 { + let (nx, ny) = (x as i64 + dx, y as i64 + dy); + if (dx, dy) == (0, 0) || nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { + continue; + } + let j = ny as usize * w + nx as usize; + if let Some(l) = labels.get_mut(j) + && *l == UNLABELLED + { + *l = k; + stack.push(j); + } + } + } + } + pieces.push(Rect::new(x0 as i32, y0 as i32, x1 as i32 + 1, y1 as i32 + 1)); + } + Ok(pieces) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn components_are_8_connected_with_their_bounds() { + let (w, h) = (6, 4); + let u = UNLABELLED; + #[rustfmt::skip] + let mut l = vec![ + u, 0, 0, 0, 0, u, + 0, u, 0, 0, 0, u, + 0, 0, 0, 0, 0, 0, + 0, 0, 0, u, u, 0, + ]; + let pieces = label_components(&mut l, w, h).unwrap(); + assert_eq!(pieces, vec![Rect::new(0, 0, 2, 2), Rect::new(5, 0, 6, 2), Rect::new(3, 3, 5, 4)]); + assert_eq!(l[7], 1); + assert_eq!(l[11], 2); + assert_eq!(l[22], 3); + } + + #[test] + fn unrotated_coverage_is_exact() { + let c = Coverage::crop(Rect::new(-2, 0, 8, 4), 5, 4); + let m = c.margin(); + assert!(!c.covered(1, 0, m), "x = -1"); + assert!(c.covered(2, 0, m), "x = 0"); + assert!(c.covered(6, 0, m), "x = 4"); + assert!(!c.covered(7, 0, m), "x = 5"); + } +} diff --git a/crates/engine/src/cutout_cmds.rs b/crates/engine/src/cutout_cmds.rs index d5cc734668..2f6e2b70b5 100644 --- a/crates/engine/src/cutout_cmds.rs +++ b/crates/engine/src/cutout_cmds.rs @@ -254,7 +254,7 @@ mod tests { let (mut s, _) = disc(8); let before = s.active().unwrap().revision; - assert!(s.execute(CMD, json!({"layer": 999_999})).is_err()); + assert!(s.execute(CMD, json!({"layer": u64::MAX})).is_err()); // A `layer` string names a layer; one that isn't there is an error, not the active layer. assert!(s.execute(CMD, json!({"layer": "x"})).is_err()); assert_eq!(s.active().unwrap().revision, before); 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..393e85a062 --- /dev/null +++ b/crates/engine/src/doc_path/tests.rs @@ -0,0 +1,227 @@ +//! 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:?}"); +} + +#[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:?}"); +} + +/// 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/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index f0541136ad..aa2ce627f7 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -35,15 +35,14 @@ pub struct Clip { /// Photoshop's layer-based copy (CC 2018 on): with no selection, Copy takes the active layer /// whole when it is a smart object (#2428), a type or shape layer, or a group (its whole subtree) -/// (#2670), so Paste recreates it instead of its pixels. Pixel layers still copy their pixels; -/// several selected layers are [`multi_copy_ids`]. +/// (#2670), so Paste recreates it instead of its pixels. Fill and adjustment layers have no +/// pixels of their own, so they are taken whole too rather than refused (#2344). Pixel layers +/// still copy their pixels; several selected layers are [`multi_copy_ids`]. fn takes_whole(d: &crate::DocState, id: LayerId) -> bool { d.doc.selection.is_none() && d.selected_layers().len() <= 1 && crate::layer_copy_cmds::layered(d.doc.mode) - && d.doc - .layer(id) - .is_some_and(|l| matches!(l.content, LayerContent::Smart(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Group(_))) + && d.doc.layer(id).is_some_and(|l| !matches!(l.content, LayerContent::Raster(_))) } /// A scratch document in the source's colour, to hold copied layers. @@ -280,13 +279,18 @@ fn copy(s: &mut Session, merged: bool) -> Result { } match (d.doc.layer(id).and_then(Layer::surface).filter(|_| several.is_empty()), &layers) { (Some(surf), _) => Clip { layers, ..lift(surf, d.doc.selection.as_ref(), canvas)? }, - // A group has no pixels of its own: the clipboard pixels are what it shows. + // A group or fill layer has no pixels of its own: the clipboard pixels are what it + // shows. Adjustment layers show nothing by themselves, so their clip spans the canvas + // they apply to: Paste places them as a full-canvas layer, and the UI still mirrors + // the clip to the OS clipboard (an empty clip would let a stale OS image replace it). (None, Some(copies)) => { let mut shown = (**copies).clone(); for l in &mut shown.layers { l.visible = true; } - Clip { layers, ..lift(&merged_surface(&shown), None, canvas)? } + let clip = lift(&merged_surface(&shown), None, canvas)?; + let bounds = if clip.bounds.is_empty() { canvas } else { clip.bounds }; + Clip { layers, bounds, ..clip } } (None, None) => return Err(EngineError::Other("the active layer has no pixels".into())), } @@ -390,7 +394,6 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { let moved = shifted(&clip.surface, dx, dy); let target = PixelFormat::new(fmt.mode, fmt.sample, true); let surf = if moved.format() == target { moved } else { moved.convert(target) }; - let before = d.doc.clone(); let id = s.edit("Paste", |doc, active| { let mut l = Layer::raster(doc.next_layer_name("Layer"), target); *crate::pixels_mut(&mut l)? = surf; @@ -399,8 +402,7 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { doc.selection = None; Ok(id) })?; - // Only where the pasted pixels land needs recompositing, not the whole document (#2901). - crate::layer_multi_cmds::note_insert(s, &before); + // `edit` reports only where the pasted pixels land as damage, not the whole document (#2901). Ok(json!({"layer": id.0, "offset": [dx, dy]})) } @@ -495,9 +497,12 @@ fn new_from_clipboard(s: &mut Session) -> Result { if w == 0 || h == 0 { return Err(EngineError::Other("the clipboard image has no size".into())); } + let (Some(dx), Some(dy)) = (b.x0.checked_neg(), b.y0.checked_neg()) else { + return Err(EngineError::Other("the clipboard origin is outside the coordinate range for a new document".into())); + }; let fmt = clip.surface.format(); let target = PixelFormat::new(fmt.mode, fmt.sample, true); - let moved = if b.x0 == 0 && b.y0 == 0 { clip.surface } else { photocraft_algo::resample::translate_surface(&clip.surface, -b.x0, -b.y0) }; + let moved = if dx == 0 && dy == 0 { clip.surface } else { photocraft_algo::resample::translate_surface(&clip.surface, dx, dy) }; let mut doc = Document::new("Untitled", photocraft_geom::Size::new(w, h), fmt.mode, fmt.sample); if let Some(r) = clip.resolution.filter(|r| r.is_finite() && *r > 0.0) { doc.resolution_dpi = r; @@ -832,6 +837,44 @@ fn guide_color(p: &Value) -> Result> { ) } +/// Edit Selected Guides (the guide context menu): change one guide's orientation, position and +/// colour in one history step. A guide that keeps its orientation keeps its index; one that turns +/// goes to the end of the other list. Without `color` it keeps its own colour, or none. +fn edit_guide(s: &mut Session, p: &Value) -> Result { + let orientation = |key: &str| match p.get(key).map(|v| v.as_str()) { + None => Ok(None), + Some(Some("horizontal")) => Ok(Some(false)), + Some(Some("vertical")) => Ok(Some(true)), + Some(_) => Err(EngineError::Other(format!("`{key}` must be \"horizontal\" or \"vertical\""))), + }; + let from = orientation("orientation")?.unwrap_or(false); + let to = orientation("newOrientation")?.unwrap_or(from); + let index = p.get("index").and_then(Value::as_u64).ok_or(EngineError::Other("missing `index`".into()))?; + let pos = guide_position(s, p, to)?; + let color = guide_color(p)?; + s.edit("Edit Guide", |doc, _| { + let guides = &mut doc.guides; + let list = if from { &guides.vertical } else { &guides.horizontal }; + let i = usize::try_from(index).ok().filter(|i| *i < list.len()).ok_or(EngineError::Other("no such guide".into()))?; + let old = list[i]; + let at = pos.unwrap_or(old); + let rgb = color.or_else(|| guides.color(from, old)); + if from == to { + let list = if from { &mut guides.vertical } else { &mut guides.horizontal }; + list[i] = at; + } else { + (if from { &mut guides.vertical } else { &mut guides.horizontal }).remove(i); + (if to { &mut guides.vertical } else { &mut guides.horizontal }).push(at); + } + guides.prune_colors(); + if rgb.is_some() { + guides.set_color(to, at, rgb); + } + Ok(()) + })?; + Ok(Value::Null) +} + /// View › New Guide / guide moves (undoable, like Photoshop's "New Guide"/"Move Guide" states). fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result { let vertical = p.get("orientation").and_then(Value::as_str).unwrap_or("horizontal") == "vertical"; @@ -1011,6 +1054,15 @@ pub fn specs() -> Vec { has_doc, |s, p| guide_cmd(s, p, "move") ), + spec!( + "view.editGuide", + "Edit Guide", + &[], + None, + r##"{"orientation":"horizontal|vertical","index":n,"newOrientation":"horizontal|vertical"?,"position":px or ""?,"color":name or "#rrggbb"?} (Edit Selected Guides: units and colours as for view.newGuide; % is of the new orientation's extent; whatever is left out stays as it was)"##, + has_doc, + edit_guide + ), spec!("view.deleteGuide", "Delete Guide", &[], None, r##"{"orientation":"horizontal|vertical","index":n}"##, has_doc, |s, p| guide_cmd(s, p, "delete")), spec!("view.clearGuides", "Clear Guides", &["View"], None, "{}", has_doc, |s, p| guide_cmd(s, p, "clear")), spec!( @@ -1044,6 +1096,19 @@ mod tests { s } + #[test] + fn new_clipboard_document_rejects_an_unrepresentable_origin() { + let mut s = Session::new(); + let mut surface = Surface::new(PixelFormat::RGBA8); + surface.write_pixel(i32::MIN, 0, &[1.0, 0.0, 0.0, 1.0]); + s.clipboard = Some(Clip { surface, bounds: Rect::from_xywh(i32::MIN, 0, 1, 1), layers: None, resolution: None }); + let result = new_from_clipboard(&mut s); + assert!(result.unwrap_err().to_string().contains("clipboard origin")); + assert!(s.execute("file.newFromClipboard", json!({})).is_err()); + assert!(s.documents().is_empty()); + assert_eq!(s.clipboard.as_ref().unwrap().surface.rgba(i32::MIN, 0), [1.0, 0.0, 0.0, 1.0]); + } + #[test] fn injected_clear_buffer_failure_leaves_the_document_unchanged() { let mut s = session(); @@ -1391,12 +1456,12 @@ mod tests { assert!(s.clipboard.is_none(), "a refused copy leaves the clipboard alone"); s.execute("layer.layerMask.revealAll", json!({})).unwrap(); assert!(s.execute("edit.copy", json!({"target": "mask"})).is_ok()); - // An adjustment layer has no pixels of its own: Copy is only enabled while its mask is - // targeted (#2928). + // An adjustment layer has no pixels of its own: Copy takes the layer whole (#2344), and + // its mask while that is targeted (#2928). s.execute("file.new", json!({"width": 20, "height": 20})).unwrap(); let a = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].as_u64().unwrap(); s.execute("layer.select", json!({"layer": a})).unwrap(); - assert!(!s.is_enabled("edit.copy"), "no pixels to copy"); + assert!(s.is_enabled("edit.copy"), "the layer itself can be copied"); s.execute("layer.layerMask.revealAll", json!({})).unwrap(); assert!(s.is_enabled_with("edit.copy", &json!({"target": "mask"})), "the mask can be copied"); } @@ -1853,6 +1918,56 @@ mod tests { assert_eq!(shape.surface().map(|c| c.format().sample), Some(s.active().unwrap().doc.depth), "converted to 16 bits"); } + /// #2344: fill and adjustment layers have no pixels of their own, so with no selection Copy + /// takes them whole and Paste recreates them, in the same or another document, in one step. + #[test] + fn copy_paste_keeps_fill_and_adjustment_layers() { + for (depth, other) in [(8, 16), (16, 32), (32, 8)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 60, "height": 40, "depth": depth})).unwrap(); + s.execute("layer.newFillLayer.gradient", json!({"from": "#000000", "to": "#ffffff", "angle": 0})).unwrap(); + let LayerContent::Fill(fill) = active_content(&s) else { panic!("a fill layer") }; + let src = s.active().unwrap().active_layer.unwrap(); + assert!(s.is_enabled("edit.copy"), "{depth}-bit: a fill layer can be copied"); + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([0, 0, 60, 40]), "{depth}-bit: what it shows"); + let clip = s.clipboard.clone().unwrap(); + assert!(clip.surface.rgba(59, 20)[0] > clip.surface.rgba(0, 20)[0], "{depth}-bit: the clip holds the gradient's pixels"); + let steps = s.active().unwrap().history.past_len(); + s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{depth}-bit: one history step"); + assert_eq!(active_content(&s), LayerContent::Fill(fill.clone()), "{depth}-bit: the paste is the same fill layer"); + assert_ne!(s.active().unwrap().active_layer, Some(src), "{depth}-bit: a new layer"); + s.undo(); + assert_eq!(s.active().unwrap().active_layer, Some(src), "{depth}-bit: undo removes the paste"); + s.redo(); + assert!(matches!(active_content(&s), LayerContent::Fill(_)), "{depth}-bit: redo brings it back"); + + s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap(); + let LayerContent::Adjustment(levels) = active_content(&s) else { panic!("an adjustment layer") }; + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([0, 0, 60, 40]), "{depth}-bit: it applies to the canvas"); + s.execute("file.new", json!({"width": 30, "height": 30, "depth": other})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(active_content(&s), LayerContent::Adjustment(levels), "{depth}-bit: the same Levels layer in the {other}-bit document"); + assert_eq!(s.active().unwrap().doc.layers.len(), 2, "{depth}-bit: pasted above the Background"); + } + } + + /// #2344: several adjustment layers, which show nothing alone, still copy and paste together. + #[test] + fn several_adjustment_layers_copy_together() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 40})).unwrap(); + let a = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layer": a})).unwrap(); + s.execute("layer.select", json!({"layer": b, "mode": "toggle"})).unwrap(); + assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([0, 0, 40, 40])); + s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.layers.len(), 5, "both are pasted"); + assert!(d.layers[3..].iter().all(|l| matches!(l.content, LayerContent::Adjustment(_)))); + } + #[test] fn merge_visible_keeps_hidden_layers() { let mut s = session(); @@ -2066,6 +2181,60 @@ mod tests { assert_eq!(s.active().unwrap().history.can_undo(), undo_before); } + #[test] + fn editing_a_guide_changes_what_is_given_in_one_undoable_step() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 200})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 20, "color": "magenta"})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 30})).unwrap(); + let guides = |s: &Session| s.active().unwrap().doc.guides.clone(); + // Position only: the guide keeps its index and its own colour. + s.execute("view.editGuide", json!({"orientation": "vertical", "index": 1, "position": "50%"})).unwrap(); + let g = guides(&s); + assert_eq!(g.vertical, vec![10.0, 200.0, 30.0]); + assert_eq!((g.color(true, 20.0), g.color(true, 200.0)), (None, Some([0xff, 0x00, 0xff]))); + // Colour only: an uncoloured guide gets one; the position stays. + s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "color": "#123456"})).unwrap(); + assert_eq!(guides(&s).color(true, 10.0), Some([0x12, 0x34, 0x56])); + // Turning it: % is of the new orientation's extent, and the colour goes along. + s.execute("view.editGuide", json!({"orientation": "vertical", "index": 1, "newOrientation": "horizontal", "position": "25%"})).unwrap(); + let g = guides(&s); + assert_eq!((g.vertical.clone(), g.horizontal.clone()), (vec![10.0, 30.0], vec![50.0])); + assert_eq!((g.color(true, 200.0), g.color(false, 50.0)), (None, Some([0xff, 0x00, 0xff]))); + // One step each: undo restores the vertical guide with its colour. + s.undo(); + let g = guides(&s); + assert_eq!((g.vertical.clone(), g.horizontal.is_empty()), (vec![10.0, 200.0, 30.0], true)); + assert_eq!(g.color(true, 200.0), Some([0xff, 0x00, 0xff])); + } + + #[test] + fn bad_guide_edits_are_refused_without_a_history_step() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 200})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10, "color": "green"})).unwrap(); + let before = s.active().unwrap().doc.guides.clone(); + for bad in [ + json!({}), + json!({"orientation": "vertical"}), + json!({"orientation": "vertical", "index": 1}), + json!({"orientation": "horizontal", "index": 0}), + json!({"orientation": "vertical", "index": -1}), + json!({"orientation": "vertical", "index": "0"}), + json!({"orientation": "vertical", "index": u64::MAX}), + json!({"orientation": "diagonal", "index": 0}), + json!({"orientation": "vertical", "index": 0, "newOrientation": 3}), + json!({"orientation": "vertical", "index": 0, "position": "far"}), + json!({"orientation": "vertical", "index": 0, "position": 1e12}), + json!({"orientation": "vertical", "index": 0, "color": "puce"}), + ] { + assert!(s.execute("view.editGuide", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(s.active().unwrap().doc.guides, before); + assert_eq!(s.active().unwrap().history.undo_label(), Some("New Guide")); + } + #[test] fn bad_guide_positions_are_refused_without_a_guide() { let mut s = Session::new(); diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index cda3de84d4..b620230a61 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -68,7 +68,7 @@ pub(crate) fn after_command(s: &mut Session, id: &str) { let now = st.doc.layer(layer); let was = prev.layer(layer); let surf_eq = |a: Option<&Surface>, b: Option<&Surface>| match (a, b) { - (Some(a), Some(b)) => crate_fingerprint(a) == crate_fingerprint(b), + (Some(a), Some(b)) => a.format() == b.format() && a.default_pixel() == b.default_pixel() && crate_fingerprint(a) == crate_fingerprint(b), (None, None) => true, _ => false, }; @@ -110,6 +110,12 @@ fn always(_: &Session) -> std::result::Result<(), String> { fn pixel_layer(s: &Session) -> std::result::Result { let d = s.active().ok_or("no document open")?; let id = d.active_layer.ok_or("no active layer")?; + pixel_layer_id(s, id) +} + +/// `id` if it is an unlocked pixel layer of the active document. +fn pixel_layer_id(s: &Session, id: LayerId) -> std::result::Result { + let d = s.active().ok_or("no document open")?; let l = d.doc.layer(id).ok_or("no active layer")?; if !matches!(l.content, LayerContent::Raster(_)) { return Err(format!("active layer is {} {} layer, not a pixel layer", l.content.article(), l.content.kind_name())); @@ -208,7 +214,9 @@ fn fade(s: &mut Session, p: &Value) -> Result { if now.format() != before.format() { return Err(EngineError::Other("can't fade a step that changed the layer's pixel format".into())); } - let area = before.content_bounds().union(&now.content_bounds()); + // A uniform mask can change every pixel without storing a single tile. In that case + // the content bounds are empty, but Fade must still blend across the canvas. + let area = if before.default_pixel() != now.default_pixel() { st.doc.bounds() } else { before.content_bounds().union(&now.content_bounds()) }; let label = format!("Fade {}", src.label); s.edit(&label, |doc, _| { let l = doc.layer_mut(src.layer).ok_or(EngineError::NoLayer(src.layer))?; @@ -224,7 +232,13 @@ fn fade(s: &mut Session, p: &Value) -> Result { fn history_bytes(s: &Session, all: bool) -> usize { let docs: Vec<&crate::DocState> = if all { s.documents().iter().collect() } else { s.active().into_iter().collect() }; - docs.iter().map(|d| d.history.unique_bytes(&d.doc)).sum() + // Purge keeps the snapshots, so the tiles they share with the states aren't freed. + docs.iter() + .map(|d| { + let snaps: Vec<&photocraft_doc::Document> = d.snapshots.iter().map(|s| &*s.doc).collect(); + d.history.unique_bytes_besides(&d.doc, &snaps) + }) + .sum() } fn clip_bytes(s: &Session) -> usize { @@ -317,7 +331,7 @@ fn can_caf(s: &Session) -> std::result::Result<(), String> { } /// Mask surface (gray) of an alpha channel by index or name. -fn channel_mask<'a>(doc: &'a Document, v: &Value) -> Option<&'a Surface> { +pub(crate) fn channel_mask<'a>(doc: &'a Document, v: &Value) -> Option<&'a Surface> { match v { Value::Number(n) => doc.channels.get(n.as_u64()? as usize).map(|c| &c.surface), Value::String(name) => doc.channels.iter().find(|c| c.name == *name).map(|c| &c.surface), @@ -352,38 +366,169 @@ fn delete_and_fill(s: &mut Session, _: &Value) -> Result { content_aware_fill_as(s, &json!({"sampling": "rectangular"}), "edit.deleteAndFillSelection", "Delete and Fill Selection") } -fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &'static str) -> Result { - use photocraft_algo::content_aware::{FillOptions, auto_sampling_with, color_level, fill_owned, rotation_level}; - let id = pixel_layer(s).map_err(EngineError::Other)?; - let st = s.active().ok_or(EngineError::NoDocument)?; - let doc = st.doc.clone(); - let sel = doc.selection.clone().ok_or_else(|| bad(cmd, "no selection"))?; - let hb = sel.content_bounds().intersect(&doc.bounds()); - if hb.is_empty() { +/// Everything one Content-Aware Fill needs, worked out from the document and the command's +/// params. The Content-Aware Fill workspace runs exactly this for its preview, so what it shows +/// is what OK and Apply write. +pub struct CafPlan { + pub layer: LayerId, + /// The rectangle worked on: the sampling window, and any sampling-brush strokes or custom + /// area beyond it. + pub rect: Rect, + /// Photoshop's sampling window around the fill area (`content_aware::sampling_window`). + pub window: Rect, + pub fmt: PixelFormat, + /// What is sampled over `rect`: the target's pixels, or with Sample All Layers the visible + /// composite in the target's encoding. + pub img: Vec, + /// The target's own pixels over `rect` when they differ from `img` (Sample All Layers); see + /// [`CafPlan::layer_pixels`]. + pub layer_px: Option>, + /// The fill area (selection coverage, after the workspace's lasso edits) over `rect`. + pub cover: Vec, + pub hole: Vec, + /// What may be copied from: the sampling option's area before the brush strokes, and after. + pub base: Vec, + pub source: Vec, + pub opts: photocraft_algo::content_aware::FillOptions, + pub output: String, +} + +impl CafPlan { + /// The target layer's pixels over `rect`. + pub fn layer_pixels(&self) -> &[f32] { + self.layer_px.as_deref().unwrap_or(&self.img) + } +} + +/// The workspace's lasso edits of the fill area: `{"op": "new|add|subtract|intersect", "points": +/// [[x, y], ...]}` or `{"op": "expand|contract", "by": px}`, applied in order. +#[derive(Clone, Debug, PartialEq)] +pub enum FillEdit { + Lasso { op: SelOp, points: Vec<[f64; 2]> }, + Grow(i32), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SelOp { + New, + Add, + Subtract, + Intersect, +} + +/// Most `fillEdits` and `samplingStrokes` one fill takes, and most points in all of them +/// together: the strokes and lassos are painted pixel by pixel, segment by segment. +pub const MAX_CAF_EDITS: usize = 1_000; +pub const MAX_CAF_POINTS: usize = 200_000; + +fn points_param(v: &Value, cmd: &str, what: &str, budget: &mut usize) -> Result> { + let a = v.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("{what} needs `points` [[x, y], ...]")))?; + *budget = budget.checked_sub(a.len()).ok_or_else(|| bad(cmd, format!("at most {MAX_CAF_POINTS} points in all fill edits and sampling strokes")))?; + a.iter() + .map(|p| { + let x = p.get(0).and_then(Value::as_f64).filter(|v| v.is_finite() && v.abs() < 1e7); + let y = p.get(1).and_then(Value::as_f64).filter(|v| v.is_finite() && v.abs() < 1e7); + match (x, y) { + (Some(x), Some(y)) => Ok([x, y]), + _ => Err(bad(cmd, format!("{what} points must be pairs of finite numbers"))), + } + }) + .collect() +} + +/// `p[key]` as an array of at most [`MAX_CAF_EDITS`] entries (empty when absent). +fn edits_param<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<&'a [Value]> { + let Some(a) = p.get(key) else { return Ok(&[]) }; + let a = a.as_array().ok_or_else(|| bad(cmd, format!("`{key}` must be an array")))?; + if a.len() > MAX_CAF_EDITS { + return Err(bad(cmd, format!("`{key}` takes at most {MAX_CAF_EDITS} entries"))); + } + Ok(a) +} + +fn fill_edits(p: &Value, cmd: &str, budget: &mut usize) -> Result> { + edits_param(p, "fillEdits", cmd)? + .iter() + .map(|e| { + let op = e.get("op").and_then(Value::as_str).unwrap_or(""); + Ok(match op { + "expand" | "contract" => { + let by = e + .get("by") + .and_then(Value::as_f64) + .filter(|v| v.is_finite() && (0.0..=1000.0).contains(v)) + .ok_or_else(|| bad(cmd, "`by` must be 0..1000 px"))?; + FillEdit::Grow(if op == "expand" { by.round() as i32 } else { -(by.round() as i32) }) + } + "new" | "add" | "subtract" | "intersect" => FillEdit::Lasso { + op: match op { + "new" => SelOp::New, + "add" => SelOp::Add, + "subtract" => SelOp::Subtract, + _ => SelOp::Intersect, + }, + points: points_param(e, cmd, "a lasso edit", budget)?, + }, + o => return Err(bad(cmd, format!("unknown fill edit `{o}` (new|add|subtract|intersect|expand|contract)"))), + }) + }) + .collect() +} + +fn sampling_strokes(p: &Value, cmd: &str, budget: &mut usize) -> Result> { + edits_param(p, "samplingStrokes", cmd)? + .iter() + .map(|e| { + let add = match e.get("mode").and_then(Value::as_str).unwrap_or("add") { + "add" => true, + "subtract" => false, + o => return Err(bad(cmd, format!("unknown stroke mode `{o}` (add|subtract)"))), + }; + let size = e + .get("size") + .and_then(Value::as_f64) + .filter(|v| v.is_finite() && (1.0..=5000.0).contains(v)) + .ok_or_else(|| bad(cmd, "a stroke's `size` must be 1..5000 px"))?; + Ok(photocraft_algo::content_aware::SamplingStroke { add, size, points: points_param(e, cmd, "a sampling stroke", budget)? }) + }) + .collect() +} + +/// A Content-Aware Fill's params, checked against the document before any pixel is read. +struct CafParams<'a> { + sel: &'a Surface, + /// The selection's bounds on the canvas. + sb: Rect, + edits: Vec, + strokes: Vec, + sampling: String, + custom_mask: Option<&'a Surface>, + custom_rect: Option, + opts: photocraft_algo::content_aware::FillOptions, + output: String, + surf: &'a Surface, +} + +fn caf_params<'a>(doc: &'a Document, id: LayerId, p: &Value, cmd: &str) -> Result> { + use photocraft_algo::content_aware::{FillOptions, color_level, rotation_level}; + let sel = doc.selection.as_ref().ok_or_else(|| bad(cmd, "no selection"))?; + let sb = sel.content_bounds().intersect(&doc.bounds()); + if sb.is_empty() { return Err(bad(cmd, "the selection is outside the canvas")); } + let mut budget = MAX_CAF_POINTS; + let edits = fill_edits(p, cmd, &mut budget)?; + let strokes = sampling_strokes(p, cmd, &mut budget)?; let sampling = str_or(p, "sampling", "auto").to_string(); - let custom_mask: Option = p.get("channel").and_then(|v| channel_mask(&doc, v)).cloned(); + let custom_mask: Option<&Surface> = p.get("channel").and_then(|v| channel_mask(doc, v)); let custom_rect = if p.get("area").is_some() { Some(rect_param(p, "area", cmd)?) } else { None }; - // Photoshop's window (`content_aware::sampling_window`) for Auto and Rectangular; `margin` - // asks for a rectangle that far around the selection instead. - let window = match sampling.as_str() { - "auto" => crate::fill_cmds::sampling_window(hb, doc.bounds()), - "rectangular" => match int(p, "margin") { - Some(m) => hb.inflate(m.clamp(0, 100_000) as i32), - None => crate::fill_cmds::sampling_window(hb, doc.bounds()), - }, - "custom" => { - let r = match (custom_rect, custom_mask.as_ref()) { - (Some(r), _) => r, - (None, Some(m)) => m.content_bounds(), - (None, None) => return Err(bad(cmd, "custom sampling needs `area` [x,y,w,h] or `channel` (alpha channel index or name)")), - }; - r.union(&hb).inflate(4) - } + match sampling.as_str() { + "auto" | "rectangular" => {} + // The workspace's Custom: only what the Sampling Brush adds. + "custom" if custom_rect.is_some() || custom_mask.is_some() || strokes.iter().any(|s| s.add) => {} + "custom" => return Err(bad(cmd, "custom sampling needs `area` [x,y,w,h], `channel` (alpha channel index or name) or `samplingStrokes`")), other => return Err(bad(cmd, format!("unknown sampling `{other}` (auto|rectangular|custom)"))), } - .intersect(&doc.bounds()); let opts = FillOptions { color_adaptation: color_level(str_or(p, "colorAdaptation", "default")), rotations: rotation_level(str_or(p, "rotationAdaptation", "none")), @@ -396,6 +541,77 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: & return Err(bad(cmd, "`output` must be current|new|duplicate")); } let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?; + Ok(CafParams { sel, sb, edits, strokes, sampling, custom_mask, custom_rect, opts, output, surf }) +} + +/// Work out a Content-Aware Fill (see [`CafPlan`]) on layer `id` of `doc`. +pub fn content_aware_plan(doc: &Document, id: LayerId, p: &Value, cmd: &str) -> Result { + content_aware_plan_with(doc, id, p, cmd, &photocraft_raster::Interrupt::NONE) +} + +/// [`content_aware_plan`], asking `ctl` row by row whether to stop ([`EngineError::Cancelled`]). +pub fn content_aware_plan_with(doc: &Document, id: LayerId, p: &Value, cmd: &str, ctl: &photocraft_raster::Interrupt) -> Result { + use photocraft_algo::content_aware::{auto_sampling_with, grow_mask, paint_strokes, polygon_mask}; + use rayon::prelude::*; + + use crate::fill_cmds::{read_window, read_window_mask}; + let CafParams { sel, sb, edits, strokes, sampling, custom_mask, custom_rect, opts, output, surf } = caf_params(doc, id, p, cmd)?; + let canvas = doc.bounds(); + let cancel = || ctl.cancelled(); + let stop = || if ctl.cancelled() { Err(EngineError::Cancelled) } else { Ok(()) }; + // The fill area: the selection, then the lasso edits, over a rectangle that holds them all. + let grow: i32 = edits.iter().map(|e| if let FillEdit::Grow(b) = e { b.max(&0) } else { &0 }).sum(); + let reach = edits.iter().fold(sb, |r, e| match e { + FillEdit::Lasso { points, .. } => { + points.iter().fold(r, |r, q| r.union(&Rect::new(q[0].floor() as i32, q[1].floor() as i32, q[0].floor() as i32 + 1, q[1].floor() as i32 + 1))) + } + FillEdit::Grow(_) => r, + }); + let er = reach.inflate(grow.saturating_add(1)).intersect(&canvas); + let (ew, eh) = crate::fill_cmds::window_size(er)?; + let ecover = read_window_mask(sel, er, &cancel).ok_or(EngineError::Cancelled)?; + let mut ehole: Vec = ecover.par_iter().map(|c| *c > 0.0).collect(); + for e in &edits { + stop()?; + match e { + FillEdit::Lasso { op, points } => { + let m = polygon_mask(er, points); + for (h, v) in ehole.iter_mut().zip(&m) { + *h = match op { + SelOp::New => *v, + SelOp::Add => *h || *v, + SelOp::Subtract => *h && !*v, + SelOp::Intersect => *h && *v, + }; + } + } + FillEdit::Grow(by) => ehole = grow_mask(&ehole, ew, eh, *by), + } + } + let (bx0, by0, bx1, by1) = mask_bounds(ew, eh, &ehole); + if bx1 <= bx0 { + return Err(bad(cmd, "the fill area is empty")); + } + let hb = Rect::new(er.x0 + bx0 as i32, er.y0 + by0 as i32, er.x0 + bx1 as i32, er.y0 + by1 as i32); + // Photoshop's window (`content_aware::sampling_window`) for Auto and Rectangular; `margin` + // asks for a rectangle that far around the fill area instead. + let window = match sampling.as_str() { + "rectangular" => match int(p, "margin") { + Some(m) => hb.inflate(m.clamp(0, 100_000) as i32), + None => crate::fill_cmds::sampling_window(hb, canvas), + }, + "custom" => match (custom_rect, custom_mask) { + (Some(r), _) => r, + (None, Some(m)) => m.content_bounds(), + (None, None) => hb, + } + .union(&hb) + .inflate(4), + _ => crate::fill_cmds::sampling_window(hb, canvas), + } + .intersect(&canvas); + // Strokes that add to the sampling area may reach past the window. + let rect = strokes.iter().filter(|s| s.add).fold(window, |r, s| r.union(&s.bounds())).intersect(&canvas); let fmt = surf.format(); // Photoshop's Sample All Layers checkbox is off unless the caller asks. When the active // layer has no pixels in the window there is nothing to sample, so that case uses the @@ -406,56 +622,134 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: & }; let n = fmt.channels(); // Refuse a window too large to read before allocating for it, whatever the sampling (#963). - let (w, h) = crate::fill_cmds::window_size(window)?; - // A background job when started with `Session::start` (#210): reading the window and the - // PatchMatch fill run on a worker against the document snapshot, cancellable per row band. + let (w, h) = crate::fill_cmds::window_size(rect)?; + let pixels = read_window(surf, rect, &cancel).ok_or(EngineError::Cancelled)?; + let (img, layer_px) = if sample_all { + stop()?; + (crate::retouch_cmds::composite_region(doc, Some(id), crate::retouch_cmds::SampleLayers::All, rect, fmt).data, Some(pixels)) + } else { + (pixels, None) + }; + stop()?; + let mut cover = vec![0.0f32; w * h]; + let mut hole = vec![false; w * h]; + cover.par_chunks_mut(w.max(1)).zip(hole.par_chunks_mut(w.max(1))).enumerate().try_for_each(|(y, (crow, hrow))| { + stop()?; + let dy = rect.y0 + y as i32; + if dy < er.y0 || dy >= er.y1 { + return Ok(()); + } + for (x, (c, hl)) in crow.iter_mut().zip(hrow.iter_mut()).enumerate() { + let dx = rect.x0 + x as i32; + if dx >= er.x0 && dx < er.x1 { + let j = (dy - er.y0) as usize * ew + (dx - er.x0) as usize; + let (eh, ec) = (ehole.get(j).copied().unwrap_or(false), ecover.get(j).copied().unwrap_or(0.0)); + *hl = eh; + // Pixels the lasso added are filled outright; selected ones by their coverage. + *c = if eh { if ec > 0.0 { ec } else { 1.0 } } else { 0.0 }; + } + } + Ok::<(), EngineError>(()) + })?; + drop((ecover, ehole)); + // The sampling option's area: Auto keeps what looks like the surroundings within the window, + // Rectangular the whole window, Custom the given area (or nothing, for the brush to add to). + let mut base = vec![false; w * h]; + match sampling.as_str() { + "auto" | "rectangular" => { + let (ww, wh) = (window.width() as usize, window.height() as usize); + let (ox, oy) = ((window.x0 - rect.x0) as usize, (window.y0 - rect.y0) as usize); + let area: Vec = match sampling.as_str() { + "auto" if window == rect => auto_sampling_with(w, h, n, &img, &hole, ctl).map_err(|_| EngineError::Cancelled)?, + "auto" => { + let mut wimg = vec![0.0f32; ww * wh * n]; + let mut whole = vec![false; ww * wh]; + for y in 0..wh { + let s = ((oy + y) * w + ox) * n; + wimg[y * ww * n..(y + 1) * ww * n].copy_from_slice(&img[s..s + ww * n]); + whole[y * ww..(y + 1) * ww].copy_from_slice(&hole[(oy + y) * w + ox..(oy + y) * w + ox + ww]); + } + auto_sampling_with(ww, wh, n, &wimg, &whole, ctl).map_err(|_| EngineError::Cancelled)? + } + _ => vec![true; ww * wh], + }; + for y in 0..wh { + base[(oy + y) * w + ox..(oy + y) * w + ox + ww].copy_from_slice(&area[y * ww..(y + 1) * ww]); + } + } + _ => match (custom_rect, custom_mask) { + (Some(r), _) => base.par_iter_mut().enumerate().for_each(|(i, b)| *b = r.contains(rect.x0 + (i % w) as i32, rect.y0 + (i / w) as i32)), + (None, Some(m)) => { + let v = read_window_mask(m, rect, &cancel).ok_or(EngineError::Cancelled)?; + base.par_iter_mut().zip(&v).for_each(|(b, v)| *b = *v > 0.5); + } + (None, None) => {} + }, + } + stop()?; + base.par_iter_mut().zip(&hole).for_each(|(b, h)| *b &= !*h); + let mut source = base.clone(); + paint_strokes(&mut source, rect, &strokes); + source.par_iter_mut().zip(&hole).for_each(|(s, h)| *s &= !*h); + Ok(CafPlan { layer: id, rect, window, fmt, img, layer_px, cover, hole, base, source, opts, output }) +} + +fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &'static str) -> Result { + use photocraft_algo::content_aware::fill_owned; + // The workspace names the layer it opened on: after an Apply to a new layer that one is + // active, but the fill still samples the original. + let id = match p.get("layer").and_then(Value::as_u64) { + Some(l) => pixel_layer_id(s, LayerId(l)), + None => pixel_layer(s), + } + .map_err(EngineError::Other)?; + let st = s.active().ok_or(EngineError::NoDocument)?; + let doc = st.doc.clone(); + // Check the params before the job starts. + caf_params(&doc, id, p, cmd)?; + let p = p.clone(); + // A background job when started with `Session::start` (#210): working out the fill area and + // the sampling area, reading the pixels and the PatchMatch fill run on a worker against the + // document snapshot, cancellable per row band. crate::jobs::run( s, label, true, move |ctx| { use rayon::prelude::*; - - use crate::fill_cmds::{read_window, read_window_cover, read_window_mask}; ctx.progress(0.0, label); let cancel = || ctx.cancelled(); - let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?; - let img = if sample_all { - if cancel() { - return Err(EngineError::Cancelled); - } - let region = crate::retouch_cmds::composite_region(&doc, None, crate::retouch_cmds::SampleLayers::All, window, fmt); - if cancel() { - return Err(EngineError::Cancelled); - } - region.data - } else { - read_window(surf, window, &cancel).ok_or(EngineError::Cancelled)? - }; - let hole = read_window_cover(&sel, window, &cancel).ok_or(EngineError::Cancelled)?; - let mut source = match (sampling.as_str(), custom_rect, custom_mask.as_ref()) { - ("custom", Some(r), _) => (0..w * h).into_par_iter().map(|i| r.contains(window.x0 + (i % w) as i32, window.y0 + (i / w) as i32)).collect(), - ("custom", None, Some(m)) => read_window_mask(m, window, &cancel).ok_or(EngineError::Cancelled)?.par_iter().map(|v| *v > 0.5).collect(), - _ => vec![true; w * h], - }; - ctx.check()?; - // Auto: only what looks like the selection's surroundings. - if sampling == "auto" { - source = auto_sampling_with(w, h, n, &img, &hole, &photocraft_raster::Interrupt::cancel_only(&cancel)).map_err(|_| EngineError::Cancelled)?; + let plan = content_aware_plan_with(&doc, id, &p, cmd, &photocraft_raster::Interrupt::cancel_only(&cancel))?; + let CafPlan { layer, rect, fmt, img, layer_px, cover, hole, source, opts, output, .. } = plan; + // Patches come only from the sampling area outside the fill area. With none (a + // Rectangular `margin` of 0, a Custom area inside the selection or off the canvas) + // the fill would be black (#6532), so refuse instead. The workspace's preview builds + // the same plan and may show an empty area while the Sampling Brush adds to it. + if !source.iter().any(|s| *s) { + return Err(bad(cmd, "nothing to sample from: the sampling area has no pixels outside the fill area")); } - ctx.check()?; - // The fill changes only the selected pixels: keep the original and the coverage over - // their bounds for the apply step, and fill the window's pixels themselves. + let (w, h) = (rect.width() as usize, rect.height() as usize); + let n = fmt.channels(); + // The fill changes only the fill area: keep the layer's pixels and the coverage over + // its bounds for the apply step, and fill the sampled pixels themselves. let (bx0, by0, bx1, by1) = mask_bounds(w, h, &hole); - let original: Vec = (by0..by1).flat_map(|y| img[(y * w + bx0) * n..(y * w + bx1) * n].iter().copied()).collect(); - let at = |x: usize, y: usize| (window.x0 + x as i32, window.y0 + y as i32); - let ((x0, y0), (x1, y1)) = (at(bx0, by0), at(bx1, by1)); - let cover = read_window_mask(&sel, Rect::new(x0, y0, x1, y1), &cancel).ok_or(EngineError::Cancelled)?; + let own = layer_px.as_deref().unwrap_or(&img); + let original: Vec = (by0..by1).flat_map(|y| own[(y * w + bx0) * n..(y * w + bx1) * n].iter().copied()).collect(); + let cover: Vec = (by0..by1).flat_map(|y| cover[y * w + bx0..y * w + bx1].iter().copied()).collect(); let n_hole = cover.iter().filter(|c| **c > 0.0).count(); - let filled = ctx.stage(0.05, 1.0, label, |ctl| fill_owned(w, h, n, img, &hole, &source, &opts, ctl)).map_err(|_| EngineError::Cancelled)?; - Ok(FilledWindow { filled, bounds: (bx0, by0, bx1, by1), original, cover, n_hole }) + ctx.check()?; + let mut filled = ctx.stage(0.05, 1.0, label, |ctl| fill_owned(w, h, n, img, &hole, &source, &opts, ctl)).map_err(|_| EngineError::Cancelled)?; + // Sampled from all layers: outside the fill area the window keeps the layer's pixels. + if let Some(own) = layer_px { + filled.par_chunks_mut(n.max(1)).zip(own.par_chunks(n.max(1))).zip(hole.par_iter()).for_each(|((f, o), h)| { + if !*h { + f.copy_from_slice(o); + } + }); + } + Ok((layer, rect, fmt, output, FilledWindow { filled, bounds: (bx0, by0, bx1, by1), original, cover, n_hole })) }, - move |s, done| apply_content_aware_fill(s, label, id, &output, fmt, window, done), + move |s, (layer, rect, fmt, output, done)| apply_content_aware_fill(s, label, layer, &output, fmt, rect, done), ) } @@ -1021,18 +1315,18 @@ fn preset_manager(s: &mut Session, p: &Value) -> Result { let new = p.get("newName").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "missing `newName`"))?.to_string(); match kind.as_str() { - "brushes" => { - // A renamed built-in is the user's preset now (and persists as one). - let b = &mut s.tools.presets[i]; - b.name = new; - b.builtin = false; - } + // Same rules as Brush Settings: no duplicate names (case-insensitive), the current + // preset follows, a renamed built-in is the user's preset now (and persists as one). + "brushes" => crate::brush_preset_cmds::rename_at(s, i, &new, cmd)?, "patterns" => s.patterns.items[i].name = new, _ => s.edit_state.custom_shapes[i].name = new, } } "delete" => match kind.as_str() { - "brushes" => drop(s.tools.presets.remove(i)), + "brushes" => { + let gone = s.tools.presets.remove(i); + crate::brush_cmds::forget_deleted_preset(s, &gone.name); + } "patterns" => drop(s.patterns.items.remove(i)), _ => drop(s.edit_state.custom_shapes.remove(i)), }, @@ -1060,6 +1354,7 @@ pub struct PresetFile { pub version: u32, pub brushes: Vec, pub custom_shapes: Vec, + pub tool_presets: Vec, } pub const PRESET_FORMAT: &str = "photocraft-presets"; @@ -1070,7 +1365,7 @@ fn export_import(s: &mut Session, p: &Value) -> Result { .get("kinds") .and_then(Value::as_array) .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) - .unwrap_or_else(|| ["brushes", "customShapes"].iter().map(|k| k.to_string()).collect()); + .unwrap_or_else(|| ["brushes", "customShapes", "tools"].iter().map(|k| k.to_string()).collect()); let want = |k: &str| kinds.iter().any(|x| x == k); match str_or(p, "action", "export") { "export" => { @@ -1089,8 +1384,14 @@ fn export_import(s: &mut Session, p: &Value) -> Result { Vec::new() }, custom_shapes: if want("customShapes") { s.edit_state.custom_shapes.clone() } else { Vec::new() }, + tool_presets: if want("tools") { + let builtins = crate::presets::tools::builtin(); + s.presets.tool_presets.iter().filter(|preset| bool_or(p, "includeBuiltins", false) || !builtins.contains(preset)).cloned().collect() + } else { + Vec::new() + }, }; - Ok(json!({"data": file, "brushes": file.brushes.len(), "customShapes": file.custom_shapes.len()})) + Ok(json!({"data": file, "brushes": file.brushes.len(), "customShapes": file.custom_shapes.len(), "tools": file.tool_presets.len()})) } "import" => { let data = p.get("data").cloned().ok_or_else(|| bad(cmd, "missing `data` (an exported preset file)"))?; @@ -1102,6 +1403,29 @@ fn export_import(s: &mut Session, p: &Value) -> Result { if file.format != PRESET_FORMAT { return Err(bad(cmd, format!("not a preset file (format `{}`)", file.format))); } + // Validate the whole tool import before changing any preset library. + let mut tools = s.presets.tool_presets.clone(); + let nt = if want("tools") { file.tool_presets.len() } else { 0 }; + if nt > crate::presets::tools::MAX_PRESETS { + return Err(bad(cmd, "too many tool presets")); + } + if want("tools") { + for mut preset in file.tool_presets { + preset.name = crate::presets::tools::checked_name(&preset.name, cmd)?; + if preset.tool.trim().is_empty() || !preset.options.is_object() { + return Err(bad(cmd, "tool presets require a non-empty tool and an options object")); + } + if let Some(old) = tools.iter_mut().find(|old| old.name == preset.name && old.tool.eq_ignore_ascii_case(&preset.tool)) { + *old = preset; + } else if tools.iter().any(|old| old.name == preset.name) { + return Err(bad(cmd, "a preset of that name belongs to another tool; rename it before importing")); + } else if tools.len() < crate::presets::tools::MAX_PRESETS { + tools.push(preset); + } else { + return Err(bad(cmd, "too many tool presets")); + } + } + } let (mut nb, mut ns) = (0, 0); if want("brushes") { for mut b in file.brushes { @@ -1126,7 +1450,11 @@ fn export_import(s: &mut Session, p: &Value) -> Result { ns += 1; } } - Ok(json!({"brushes": nb, "customShapes": ns})) + if want("tools") { + s.presets.tool_presets = tools; + s.presets_changed(); + } + Ok(json!({"brushes": nb, "customShapes": ns, "tools": nt})) } other => Err(bad(cmd, format!("unknown action `{other}` (export|import)"))), } @@ -1163,7 +1491,7 @@ pub fn specs() -> Vec { "Content-Aware Fill…", ["Edit"], None, - r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"sampleAllLayers":bool=false,"output":"current|new|duplicate","seed":u64=1}"##, + r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"samplingStrokes":[{"mode":"add|subtract","size":px,"points":[[x,y],…]}]? (the workspace's Sampling Brush, after the sampling option's area; added strokes may lie outside the window),"fillEdits":[{"op":"new|add|subtract|intersect","points":[[x,y],…]}|{"op":"expand|contract","by":px}]? (the workspace's lasso edits of the fill area, in order),"sampleAllLayers":bool=false,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"output":"current|new|duplicate","layer":id?=active (the layer filled and sampled),"seed":u64=1}"##, can_caf, content_aware_fill ), @@ -1218,7 +1546,7 @@ pub fn specs() -> Vec { "Export/Import Presets…", ["Edit", "Presets"], None, - r##"{"action":"export|import","kinds":["brushes","customShapes"]?,"data":json (import),"includeBuiltins":bool=false}"##, + r##"{"action":"export|import","kinds":["brushes","customShapes","tools"]?,"data":json (import),"includeBuiltins":bool=false}"##, always, export_import ), diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index f7c2f23e83..16e98b8719 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -57,6 +57,38 @@ fn fade_with_blend_mode_and_invalidation() { assert!(s.execute("edit.fade", json!({"mode": "nonsense"})).is_err()); } +#[test] +fn fade_uniform_layer_mask_invert_at_all_depths() { + for depth in [8, 16, 32] { + for (opacity, expected) in [(0, 1.0), (50, 0.5), (100, 0.0)] { + let mut s = session(depth); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "mask"})).unwrap(); + let mask_pixel = |s: &Session| { + let layer = active(s); + let mask = &layer.mask.as_ref().unwrap().surface; + mask.pixel(5, 5)[0] + }; + assert!((mask_pixel(&s) - 1.0).abs() < 0.01); + s.execute("image.adjustments.invert", json!({})).unwrap(); + assert!(mask_pixel(&s) < 0.01, "depth {depth}: invert changed the mask"); + assert!(s.edit_state.fade.as_ref().is_some_and(|f| f.mask), "depth {depth}: Fade must target the mask"); + let history_before_fade = s.active().unwrap().history.past_len(); + + s.execute("edit.fade", json!({"opacity": opacity})).unwrap(); + for (x, y) in [(0, 0), (5, 5), (63, 47)] { + let pixel = active(&s).mask.as_ref().unwrap().surface.pixel(x, y)[0]; + assert!((pixel - expected).abs() < 0.02, "depth {depth}, opacity {opacity}, ({x}, {y}): {pixel}"); + } + assert_eq!(s.active().unwrap().history.past_len(), history_before_fade + 1); + assert!(s.undo()); + assert!(mask_pixel(&s) < 0.01, "undo restores inverted mask"); + assert!(s.redo()); + assert!((mask_pixel(&s) - expected).abs() < 0.02, "redo restores faded mask"); + } + } +} + #[test] fn purge_commands() { let mut s = session(8); @@ -105,10 +137,15 @@ fn blob_session(depth: u32) -> Session { fn content_aware_fill_huge_area_neither_panics_nor_wraps() { let mut s = blob_session(8); // `[1e30, 0, 1e30, 10]` overflowed the i32 additions in the area parser (a debug-build - // panic, a garbage sampling window in release); the additions saturate now, so this is - // simply a whole-canvas window. - let r = s.execute("edit.contentAwareFill", json!({"sampling": "custom", "area": [1e30, 0.0, 1e30, 10.0], "colorAdaptation": "none"})).unwrap(); - assert!(r["filled"].as_u64().unwrap() > 100); + // panic, a garbage sampling window in release); the additions saturate now, so the area lies + // wholly right of the canvas: nothing to sample, which is an error, not a black fill (#6532). + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let err = s.execute("edit.contentAwareFill", json!({"sampling": "custom", "area": [1e30, 0.0, 1e30, 10.0], "colorAdaptation": "none"})).unwrap_err(); + assert!(err.to_string().contains("nothing to sample from"), "{err}"); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, &st.doc), "the document changed"); + assert_eq!(st.history.past_len(), past, "a history step was recorded"); // A malformed area names the problem instead of silently falling back. for area in [json!([0.0, 0.0, null, 10.0]), json!([0.0, 0.0, 10.0]), json!("nope")] { let err = s.execute("edit.contentAwareFill", json!({"sampling": "custom", "area": area})).unwrap_err(); @@ -266,6 +303,69 @@ fn content_aware_fill_samples_photoshops_window() { } } +#[test] +fn content_aware_fill_takes_the_workspace_edits() { + // The plan the workspace previews: lasso edits change the fill area (and the window follows), + // sampling-brush strokes change what may be copied, also outside the window. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 600, "height": 400, "background": "white"})).unwrap(); + s.execute("select.rect", json!({"x": 275, "y": 175, "width": 50, "height": 50})).unwrap(); + let st = s.active().unwrap(); + let (doc, id) = (st.doc.clone(), st.active_layer.unwrap()); + let plan = |p: Value| content_aware_plan(&doc, id, &p, "edit.contentAwareFill"); + let count = |m: &[bool]| m.iter().filter(|v| **v).count(); + let base = plan(json!({"sampling": "rectangular"})).unwrap(); + assert_eq!((base.window, count(&base.hole)), (Rect::new(200, 100, 400, 300), 2500)); + assert_eq!(count(&base.source), 200 * 200 - 2500); + // A lasso square added beside the selection: a bigger fill area, a window around both. + let p = plan(json!({"sampling": "rectangular", "fillEdits": [{"op": "add", "points": [[340, 175], [390, 175], [390, 225], [340, 225]]}]})).unwrap(); + assert_eq!(count(&p.hole), 5000); + assert!(p.window.width() > 200 && p.window.contains_rect(&Rect::new(275, 175, 390, 225)), "{:?}", p.window); + // Expand by 2 grows the 50 px square to about 54 px (rounded corners). + let p = plan(json!({"sampling": "rectangular", "fillEdits": [{"op": "expand", "by": 2}]})).unwrap(); + assert!((54 * 54 - 20..=54 * 54).contains(&count(&p.hole)), "{}", count(&p.hole)); + // Subtracting it all leaves nothing to fill. + assert!(plan(json!({"fillEdits": [{"op": "subtract", "points": [[0, 0], [600, 0], [600, 400], [0, 400]]}]})).is_err()); + // A subtracting stroke takes a band out of the sampling area; an adding one far away reaches + // past the window, and the plan's rectangle grows to hold it. + let p = plan(json!({"sampling": "rectangular", "samplingStrokes": [{"mode": "subtract", "size": 10, "points": [[210, 110], [390, 110]]}]})).unwrap(); + assert!((count(&base.source) - count(&p.source)).abs_diff(10 * 190) < 60); + let p = plan(json!({"sampling": "rectangular", "samplingStrokes": [{"mode": "add", "size": 20, "points": [[540, 350], [580, 350]]}]})).unwrap(); + assert!(p.rect.contains(560, 350) && !p.window.contains(560, 350)); + assert!(count(&p.source) > count(&base.source) + 600); + // Custom from strokes alone; bad edits are errors. + let p = plan(json!({"sampling": "custom", "samplingStrokes": [{"mode": "add", "size": 20, "points": [[100, 100], [160, 100]]}]})).unwrap(); + assert!((1450..1600).contains(&count(&p.source)), "a 20 px brush along 60 px: {}", count(&p.source)); + for bad in [ + json!({"samplingStrokes": [{"mode": "paint", "size": 10, "points": [[0, 0]]}]}), + json!({"samplingStrokes": [{"size": 0, "points": [[0, 0]]}]}), + json!({"fillEdits": [{"op": "add", "points": [[0, "x"]]}]}), + json!({"fillEdits": [{"op": "expand", "by": -3}]}), + ] { + assert!(plan(bad.clone()).is_err(), "{bad}"); + } + // The command fills with the same plan; `layer` names the layer even when another is active. + let r = s.execute("edit.contentAwareFill", json!({"sampling": "rectangular", "fillEdits": [{"op": "expand", "by": 2}], "layer": id.0})).unwrap(); + assert!(r["filled"].as_u64().unwrap() > 2800); +} + +/// #6532: a sampling area with no pixels outside the fill area is refused instead of filling it +/// black, and leaves the document untouched; any margin past the selection fills. +#[test] +fn content_aware_fill_with_nothing_to_sample_is_refused() { + let mut s = blob_session(8); + let before = px(&s, 30, 22); + let revision = s.active().unwrap().revision; + for p in [json!({"sampling": "rectangular", "margin": 0}), json!({"sampling": "custom", "area": [28, 18, 4, 4]})] { + let e = s.execute("edit.contentAwareFill", p.clone()).unwrap_err().to_string(); + assert!(e.contains("nothing to sample from"), "{p}: {e}"); + assert_eq!(px(&s, 30, 22), before, "{p}: nothing was filled"); + assert_eq!(s.active().unwrap().revision, revision, "{p}: no history step"); + } + s.execute("edit.contentAwareFill", json!({"sampling": "rectangular", "margin": 1})).unwrap(); + assert_ne!(px(&s, 30, 22), before, "the red blob is filled from around the selection"); +} + #[test] fn content_aware_fill_outputs_and_sampling() { let mut s = blob_session(8); @@ -427,6 +527,35 @@ fn define_custom_shape_and_preset_manager() { assert!(t.execute("edit.presets.exportImportPresets", json!({"action": "import", "data": {"format": "other"}})).is_err()); } +#[test] +fn preset_manager_brush_rename_follows_current_preset() { + let mut s = Session::new(); + s.execute("brush.presets.setCurrent", json!({"name": "Chalk"})).unwrap(); + s.execute("edit.presets.presetManager", json!({"action": "rename", "kind": "brushes", "name": "Chalk", "newName": "Chalky"})).unwrap(); + assert_eq!(s.tools.current_preset.as_deref(), Some("Chalky")); + s.execute("brush.presets.update", json!({})).unwrap(); +} + +#[test] +fn preset_manager_brush_rename_refuses_duplicate_name() { + let mut s = Session::new(); + let before: Vec = s.tools.presets.iter().map(|b| b.name.clone()).collect(); + let r = s.execute("edit.presets.presetManager", json!({"action": "rename", "kind": "brushes", "name": "Soft Round", "newName": "hard round"})); + assert!(r.is_err(), "a case-insensitive duplicate name must be refused"); + assert_eq!(s.tools.presets.iter().map(|b| b.name.clone()).collect::>(), before); + // Renaming to a different case of its own name is fine. + s.execute("edit.presets.presetManager", json!({"action": "rename", "kind": "brushes", "name": "Soft Round", "newName": "soft round"})).unwrap(); +} + +#[test] +fn preset_manager_brush_delete_clears_current_preset() { + let mut s = Session::new(); + s.execute("brush.presets.setCurrent", json!({"name": "Chalk"})).unwrap(); + s.execute("edit.presets.presetManager", json!({"action": "delete", "kind": "brushes", "name": "Chalk"})).unwrap(); + assert!(!s.tools.presets.iter().any(|b| b.name == "Chalk")); + assert_eq!(s.tools.current_preset, None); +} + #[test] fn define_brush_preset_wraps_define_from_selection() { let mut s = session(8); @@ -564,3 +693,49 @@ fn fade_allocation_failure_keeps_document_and_history() { assert_eq!(*s.active().unwrap().doc, *before); } } + +#[test] +fn tool_presets_round_trip_through_export_import() { + let mut source = Session::new(); + source.execute("tool.presets.new", json!({"name":"2 : 3", "tool":"crop", "options":{"toolOptions":{"crop_ratio":"2:3"}}})).unwrap(); + let exported = source.execute("edit.presets.exportImportPresets", json!({"action":"export"})).unwrap(); + assert_eq!(exported["tools"], 1); + assert_eq!(exported["data"]["toolPresets"][0]["name"], "2 : 3"); + let mut target = Session::new(); + target.execute("tool.presets.new", json!({"name":"2 : 3", "tool":"crop", "options":{"toolOptions":{"crop_ratio":"1:1"}}})).unwrap(); + for _ in 0..2 { + let result = target.execute("edit.presets.exportImportPresets", json!({"action":"import", "data":exported["data"]})).unwrap(); + assert_eq!(result["tools"], 1); + } + let selected = target.execute("tool.presets.select", json!({"preset":"2 : 3"})).unwrap(); + assert_eq!(selected["options"]["toolOptions"]["crop_ratio"], "2:3"); + assert_eq!(target.presets.tool_presets.iter().filter(|p| p.name == "2 : 3").count(), 1); + let only_brushes = source.execute("edit.presets.exportImportPresets", json!({"kinds":["brushes"]})).unwrap(); + assert_eq!(only_brushes["tools"], 0); + let with_builtins = source.execute("edit.presets.exportImportPresets", json!({"kinds":["tools"], "includeBuiltins":true})).unwrap(); + assert_eq!(with_builtins["tools"].as_u64().unwrap() as usize, source.presets.tool_presets.len()); +} + +#[test] +fn tool_preset_import_is_validated_before_mutation_and_accepts_old_files() { + let mut s = Session::new(); + let before = s.presets.tool_presets.clone(); + for preset in [ + json!({"name":"", "tool":"crop", "options":{}}), + json!({"name":"x".repeat(256), "tool":"crop", "options":{}}), + json!({"name":"Crop", "tool":" ", "options":{}}), + json!({"name":"Crop", "tool":"crop", "options":[]}), + ] { + let result = s.execute( + "edit.presets.exportImportPresets", + json!({"action":"import", "data":{"format":PRESET_FORMAT, "toolPresets":[{"name":"Valid", "tool":"crop", "options":{}}, preset]}}), + ); + assert!(result.is_err()); + assert_eq!(s.presets.tool_presets, before); + } + let too_many: Vec<_> = (0..crate::presets::tools::MAX_PRESETS + 1).map(|i| json!({"name":format!("Crop {i}"), "tool":"crop", "options":{}})).collect(); + assert!(s.execute("edit.presets.exportImportPresets", json!({"action":"import", "data":{"format":PRESET_FORMAT, "toolPresets":too_many}})).is_err()); + assert_eq!(s.presets.tool_presets, before); + let result = s.execute("edit.presets.exportImportPresets", json!({"action":"import", "data":{"format":PRESET_FORMAT, "version":1}})).unwrap(); + assert_eq!(result["tools"], 0); +} diff --git a/crates/engine/src/export_preset_cmds.rs b/crates/engine/src/export_preset_cmds.rs new file mode 100644 index 0000000000..3d56c22437 --- /dev/null +++ b/crates/engine/src/export_preset_cmds.rs @@ -0,0 +1,290 @@ +//! Named Export As configurations. They persist with the other presets in the preferences +//! document; applying one only returns settings, and the export itself stays in Export As. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::presets::{always, bad, req_str}; +use crate::{Result, Session}; + +const MAX_PRESETS: usize = 256; +const MAX_PX: u32 = 300_000; +const FORMATS: [&str; 6] = ["png", "jpg", "webp", "tif", "tga", "ico"]; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportPresetSettings { + pub format: String, + pub quality: u8, + pub lossless: bool, + pub transparency: bool, + pub scale: f64, + /// Output pixels. Ignored when [`Self::sizing`] is `"scale"`. + pub width: u32, + pub height: u32, + pub lock_aspect: bool, + /// `"scale"` uses [`Self::scale`] on the current document; `"pixels"` uses width and height. + pub sizing: String, + /// `"none"` drops XMP and EXIF/resolution, `"exif"` keeps EXIF and resolution, `"all"` keeps both. + pub metadata: String, + /// `"document"` keeps the document profile; `"srgb"` converts to sRGB before export. + pub profile: String, + pub embed_profile: bool, + /// `"auto"`, `"24"` or `"32"`. + pub tga_bits: String, +} + +impl ExportPresetSettings { + fn validate(&self) -> std::result::Result<(), String> { + if !FORMATS.contains(&self.format.as_str()) { + return Err(format!("format must be one of {}", FORMATS.join(", "))); + } + if !(1..=100).contains(&self.quality) { + return Err("quality must be within 1..=100".into()); + } + if !self.scale.is_finite() || !(1.0..=1000.0).contains(&self.scale) { + return Err("scale must be within 1..=1000 percent".into()); + } + if !matches!(self.sizing.as_str(), "scale" | "pixels") { + return Err("sizing must be scale or pixels".into()); + } + if self.sizing == "pixels" && (!(1..=MAX_PX).contains(&self.width) || !(1..=MAX_PX).contains(&self.height)) { + return Err(format!("width and height must be within 1..={MAX_PX} pixels")); + } + if !matches!(self.metadata.as_str(), "none" | "exif" | "all") { + return Err("metadata must be none, exif or all".into()); + } + if !matches!(self.profile.as_str(), "document" | "srgb") { + return Err("profile must be document or srgb".into()); + } + if !matches!(self.tga_bits.as_str(), "auto" | "24" | "32") { + return Err("tgaBits must be auto, 24 or 32".into()); + } + Ok(()) + } + + fn from_params(p: &Value, cmd: &str) -> Result { + let mut fields = p.as_object().cloned().ok_or_else(|| bad(cmd, "`settings` must be an object"))?; + let defaults = json!({ + "format": "png", + "quality": 85, + "lossless": false, + "transparency": true, + "scale": 100.0, + "width": 0, + "height": 0, + "lockAspect": true, + "sizing": "scale", + "metadata": "none", + "profile": "document", + "embedProfile": true, + "tgaBits": "auto" + }); + if let Some(defaults) = defaults.as_object() { + for (k, v) in defaults { + fields.entry(k.clone()).or_insert_with(|| v.clone()); + } + } + for key in ["width", "height"] { + if let Some(n) = fields.get(key).and_then(Value::as_f64) { + if !n.is_finite() || n < 0.0 || n > f64::from(MAX_PX) { + return Err(bad(cmd, format!("`{key}` must be within 0..={MAX_PX} pixels"))); + } + fields.insert(key.into(), json!(n.round() as u32)); + } + } + if let Some(q) = fields.get("quality").and_then(Value::as_f64) { + if !q.is_finite() || !(1.0..=100.0).contains(&q) { + return Err(bad(cmd, "`quality` must be within 1..=100")); + } + fields.insert("quality".into(), json!(q.round() as u8)); + } + let settings: Self = serde_json::from_value(Value::Object(fields)).map_err(|e| bad(cmd, format!("invalid settings: {e}")))?; + settings.validate().map_err(|e| bad(cmd, e))?; + Ok(settings) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExportPreset { + pub name: String, + pub settings: ExportPresetSettings, +} + +fn checked_name(name: &str) -> std::result::Result { + let name = name.trim(); + if name.is_empty() || name.chars().count() > crate::brush_preset_cmds::MAX_NAME { + return Err("Preset names must contain 1–255 characters after trimming".into()); + } + Ok(name.into()) +} + +/// Read entries independently so a damaged preset cannot discard other presets or preferences. +pub(crate) fn load(value: Value) -> Vec { + let Some(entries) = value.as_array() else { return Vec::new() }; + let mut presets: Vec = Vec::new(); + for entry in entries.iter().take(MAX_PRESETS) { + let Ok(mut preset) = serde_json::from_value::(entry.clone()) else { continue }; + let Ok(name) = checked_name(&preset.name) else { continue }; + if preset.settings.validate().is_err() || presets.iter().any(|p| p.name.eq_ignore_ascii_case(&name)) { + continue; + } + preset.name = name; + presets.push(preset); + } + presets +} + +fn save(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "file.export.presets.save"; + let name = checked_name(req_str(p, "name", CMD)?).map_err(|e| bad(CMD, e))?; + if s.presets.export_presets.len() >= MAX_PRESETS && s.presets.export_presets.iter().all(|pr| !pr.name.eq_ignore_ascii_case(&name)) { + return Err(bad(CMD, format!("At most {MAX_PRESETS} export presets can be saved"))); + } + let settings = ExportPresetSettings::from_params(p.get("settings").unwrap_or(&Value::Null), CMD)?; + let preset = ExportPreset { name: name.clone(), settings }; + if let Some(existing) = s.presets.export_presets.iter_mut().find(|pr| pr.name.eq_ignore_ascii_case(&name)) { + *existing = preset.clone(); + } else { + s.presets.export_presets.push(preset.clone()); + } + s.presets_changed(); + Ok(json!({"preset": preset})) +} + +fn get(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "file.export.presets.get"; + let name = req_str(p, "name", CMD)?; + let preset = s.presets.export_presets.iter().find(|pr| pr.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(CMD, "No such export preset"))?; + Ok(json!({"preset": preset})) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "file.export.presets.delete"; + let name = req_str(p, "name", CMD)?; + let index = s.presets.export_presets.iter().position(|pr| pr.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(CMD, "No such export preset"))?; + s.presets.export_presets.remove(index); + s.presets_changed(); + Ok(json!({"deleted": name})) +} + +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: "file.export.presets.list", + label: "List Export Presets", + menu: &[], + shortcut: None, + params: "{}", + enabled: always, + run: |s, _| Ok(json!({"presets": s.presets.export_presets})), + journal: false, + }, + CommandSpec { + id: "file.export.presets.get", + label: "Get Export Preset", + menu: &[], + shortcut: None, + params: r#"{"name":str} -> {preset} (settings for Export As; nothing is exported)"#, + enabled: always, + run: get, + journal: false, + }, + CommandSpec { + id: "file.export.presets.save", + label: "Save Export Preset", + menu: &[], + shortcut: None, + params: r#"{"name":str,"settings":{"format":"png|jpg|webp|tif|tga|ico","quality":1..100,"lossless":bool,"transparency":bool,"scale":1..1000,"width":px,"height":px,"lockAspect":bool,"sizing":"scale|pixels","metadata":"none|exif|all","profile":"document|srgb","embedProfile":bool,"tgaBits":"auto|24|32"}} (a saved name is replaced)"#, + enabled: always, + run: save, + journal: false, + }, + CommandSpec { + id: "file.export.presets.delete", + label: "Delete Export Preset", + menu: &[], + shortcut: None, + params: r#"{"name":str}"#, + enabled: always, + run: delete, + journal: false, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings(sizing: &str) -> Value { + json!({ + "format": "jpg", + "quality": 70, + "lossless": false, + "transparency": false, + "scale": 50, + "width": 640, + "height": 480, + "lockAspect": true, + "sizing": sizing, + "metadata": "exif", + "profile": "srgb", + "embedProfile": false, + "tgaBits": "24" + }) + } + + #[test] + fn presets_round_trip_through_the_preferences_document() { + let mut s = Session::new(); + s.execute("file.export.presets.save", json!({"name": " Web JPEG ", "settings": settings("pixels")})).unwrap(); + let again = s.execute("file.export.presets.save", json!({"name": "web jpeg", "settings": settings("scale")})).unwrap(); + assert_eq!(again["preset"]["settings"]["sizing"], "scale"); + assert_eq!(s.presets.export_presets.len(), 1, "saving the same name replaces it"); + + let raw = s.prefs_to_json(); + let mut restored = Session::new(); + restored.load_prefs_json(&raw).unwrap(); + let listed = restored.execute("file.export.presets.list", json!({})).unwrap(); + let presets = listed["presets"].as_array().unwrap(); + assert_eq!(presets.len(), 1); + assert_eq!(presets[0]["name"], "web jpeg"); + assert_eq!(presets[0]["settings"]["format"], "jpg"); + assert_eq!(presets[0]["settings"]["width"], 640); + assert_eq!(presets[0]["settings"]["embedProfile"], false); + assert_eq!(presets[0]["settings"]["metadata"], "exif"); + + let got = restored.execute("file.export.presets.get", json!({"name": "WEB JPEG"})).unwrap(); + assert_eq!(got["preset"]["settings"]["profile"], "srgb"); + restored.execute("file.export.presets.delete", json!({"name": "web jpeg"})).unwrap(); + assert!(restored.execute("file.export.presets.list", json!({})).unwrap()["presets"].as_array().unwrap().is_empty()); + let raw = restored.prefs_to_json(); + let mut after = Session::new(); + after.load_prefs_json(&raw).unwrap(); + assert!(after.presets.export_presets.is_empty()); + } + + #[test] + fn bad_presets_are_rejected_and_a_damaged_entry_is_dropped() { + let mut s = Session::new(); + assert!(s.execute("file.export.presets.save", json!({})).is_err()); + assert!(s.execute("file.export.presets.save", json!({"name": " ", "settings": settings("scale")})).is_err()); + assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"format": "bmp"}})).is_err()); + assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"sizing": "pixels", "width": 0, "height": 10}})).is_err()); + assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"scale": 0}})).is_err()); + assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"metadata": "copyright"}})).is_err()); + assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"profile": "fogra39"}})).is_err()); + assert!(s.execute("file.export.presets.delete", json!({"name": "missing"})).is_err()); + assert!(s.execute("file.export.presets.get", json!({"name": ""})).is_err()); + + let loaded = load(json!([ + {"name": "ok", "settings": {"format": "png", "quality": 85, "lossless": false, "transparency": true, "scale": 100.0, "width": 0, "height": 0, "lockAspect": true, "sizing": "scale", "metadata": "none", "profile": "document", "embedProfile": true, "tgaBits": "auto"}}, + {"name": "broken", "settings": {"format": "nope"}}, + "not an object" + ])); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].name, "ok"); + } +} diff --git a/crates/engine/src/exr_cmds.rs b/crates/engine/src/exr_cmds.rs index 908942d4fd..5fbc355314 100644 --- a/crates/engine/src/exr_cmds.rs +++ b/crates/engine/src/exr_cmds.rs @@ -52,7 +52,8 @@ fn open_exr_parts(s: &mut Session, p: &Value) -> Result { precomp: p.get("precomp").and_then(Value::as_bool).unwrap_or(false), }; let bytes = read_file(&path)?; - let r = photocraft_io::exr_parts::import(&stem(&path), &bytes, &sel).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let mut r = photocraft_io::exr_parts::import(&stem(&path), &bytes, &sel).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::file_cmds::tag_exr_colorspace(s, &path, &mut r.document); let (i, color) = s.open_document(r.document, Some(path.clone())); Ok(json!({"document": i, "color": color, "warnings": r.warnings})) } @@ -173,6 +174,25 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// File Handling › "Open color-less EXR as ACEScg" applies to Open EXR Parts too. + #[test] + fn open_exr_parts_honours_the_aces_cg_preference() { + let dir = std::env::temp_dir().join(format!("pcraft-exr-aces-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("aov.exr"); + write_multipart(&path); + let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes(); + let aces = photocraft_cms::Builtin::AcesCg.profile().to_bytes(); + for (on, want) in [(false, &linear), (true, &aces)] { + let mut s = session(); + s.prefs.edit(|p| p.file_handling.exr_aces_cg = on); + s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), "part/beauty": true})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.icc_profile.as_deref().map(|v| v.as_slice()), Some(want.as_slice()), "exr_aces_cg = {on}"); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn open_exr_parts_refuses_an_empty_selection_and_unknown_names() { let dir = std::env::temp_dir().join(format!("pcraft-exr-empty-{}", std::process::id())); diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 1f3748d7b9..072a3d4417 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -135,7 +135,7 @@ fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut fn stroke(s: &mut Session, p: &Value) -> Result { // Whole pixels, rounded up, as in Photoshop (0.5 px strokes 1 px). let width = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()).unwrap_or(1.0).clamp(1.0, 250.0).ceil() as usize; - let color = color_param(p, "color", s.tools.foreground); + let color = color_param("edit.stroke", p, "color", s.tools.foreground)?; // A colour picked as CMYK strokes a CMYK document with the picked inks (#4196). let picked = s.tools.picked_cmyk([color[0], color[1], color[2]]); let mode = match p.get("mode") { @@ -335,15 +335,18 @@ fn clouds(s: &mut Session, p: &Value, difference: bool) -> Result { const TRANSFORM_TARGET: &str = r#"{"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask turns alone)}"#; +/// Flips and turns about the centre of the box Free Transform would open: with a selection +/// limiting the transform it is the selection's own bounds, as in Photoshop, so Select All +/// mirrors across the canvas (#6928); otherwise the content's bounds. fn transform_preset(s: &mut Session, p: &Value, kind: &str) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; // A targeted alpha channel, the Quick Mask or an unlinked layer mask turns by itself (#2835). let (b, mut params) = match crate::transform_cmds::lone_target(&d.doc, d.active_layer, p)? { - Some(surf) => (crate::transform_cmds::target_bounds(&d.doc, surf), json!({"target": p.get("target")})), + Some(surf) => (crate::transform_cmds::target_frame(&d.doc, surf), json!({"target": p.get("target")})), None => { let id = d.active_layer.ok_or(EngineError::Other("no active layer".into()))?; let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; - (crate::transform_cmds::transform_bounds(&d.doc, l), json!({"layer": id.0})) + (crate::transform_cmds::transform_frame(&d.doc, l), json!({"layer": id.0})) } }; if b.is_empty() { @@ -522,11 +525,16 @@ fn delete_where(s: &mut Session, label: &str, pred: fn(&Layer) -> bool) -> Resul n } let backup = doc.layers.clone(); + // The clip groups of the doomed layers, read before they go (clip_group rule 3: layers + // clipped to a deleted base are released). + let doomed: Vec = doc.walk().into_iter().filter(|(_, _, l)| pred(l)).map(|(_, _, l)| l.id).collect(); + let before = crate::clip_group::Before::read(doc, &doomed); let n = rec(&mut doc.layers, pred); if doc.layers.is_empty() { doc.layers = backup; return Err(EngineError::Other("a document must keep at least one layer".into())); } + crate::clip_group::settle(doc, &[], &before, false); if active.is_none_or(|id| doc.layer(id).is_none()) { *active = doc.top_layer(); } @@ -864,7 +872,9 @@ pub fn specs() -> Vec { if all == d.doc.bounds() { return Ok(json!({"changed": false})); } - s.execute("image.crop", json!({"x": all.x0, "y": all.y0, "width": all.width(), "height": all.height(), "deleteCroppedPixels": false}))?; + // One History step named as in Photoshop, not the crop's "Crop". + let p = json!({"x": all.x0, "y": all.y0, "width": all.width(), "height": all.height(), "deleteCroppedPixels": false}); + crate::analysis_cmds::compound(s, "Reveal All", |s| s.execute("image.crop", p))?; Ok(json!({"changed": true})) }), spec!( @@ -878,8 +888,17 @@ pub fn specs() -> Vec { ), spec!("layer.layerStyle.copyLayerStyle", "Copy Layer Style", &["Layer", "Layer Style"], None, r##"{"layer":id?}"##, has_layer, |s, p| { let id = layer_param(s, p)?; - let l = s.active().and_then(|d| d.doc.layer(id)).ok_or(EngineError::NoLayer(id))?; - let style = (l.effects.clone(), l.blend, l.fill_opacity, l.advanced); + let d = s.active().ok_or(EngineError::NoDocument)?; + let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; + let style = crate::CopiedStyle { + effects: l.effects.clone(), + blend: l.blend, + fill: l.fill_opacity, + advanced: l.advanced, + blend_if: l.blend_if.clone(), + excluded_channels: l.excluded_channels, + mode: d.doc.mode, + }; s.style_clipboard = Some(style); Ok(Value::Null) }), @@ -895,17 +914,38 @@ pub fn specs() -> Vec { }, |s, p| { let ids = style_targets(s, p, |_| true)?; - let (mut fx, blend, fill, advanced) = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?; + let style = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?; + let mut fx = style.effects; s.edit("Paste Layer Style", |doc, _| { // A style copied from a document in another mode takes this one's colours. let mode = doc.mode; fx.items = std::mem::take(&mut fx.items).into_iter().map(|e| e.in_mode(mode)).collect(); + // Blend If's Gray range carries over to any mode; its channel ranges and the + // channel restrictions only to the mode they were set in. + let same_mode = style.mode == mode; + let blend_if = if same_mode { + style.blend_if.clone() + } else { + // Through `set`, which keeps the normalised form (no trailing full + // entries), so a full Gray range pastes as the default. + let mut gray = photocraft_doc::BlendIf::default(); + gray.set(0, style.blend_if.get(0)); + gray + }; for id in ids { let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.effects = fx.clone(); - l.blend = blend; - l.fill_opacity = fill; - l.advanced = advanced; + // Pass Through is a group's mode: a group's style pastes onto other layers as + // Normal, as a merged group's Pass Through becomes Normal. + l.blend = if style.blend == photocraft_color::BlendMode::PassThrough && !l.is_group() { + photocraft_color::BlendMode::Normal + } else { + style.blend + }; + l.fill_opacity = style.fill; + l.advanced = style.advanced; + l.blend_if = blend_if.clone(); + l.excluded_channels = if same_mode { style.excluded_channels } else { 0 }; } Ok(()) })?; @@ -1434,6 +1474,49 @@ mod tests { assert_eq!(pixel(&s, 10, 10), vec![0.0, 1.0, 0.0, 1.0]); } + /// #6928: with a selection, the presets flip and turn about the selection's own bounds, as + /// in Photoshop: Select All mirrors across the canvas, a marquee across its own centre. + /// Without a selection they keep turning about the content (above). + #[test] + fn transform_presets_turn_about_the_selection() { + let mut s = session(8); + paint_square(&mut s, Rect::new(10, 10, 20, 14), [1.0, 0.0, 0.0, 1.0]); + paint_square(&mut s, Rect::new(10, 10, 11, 11), [0.0, 1.0, 0.0, 1.0]); + let green = vec![0.0, 1.0, 0.0, 1.0]; + // Select All on the 40 × 30 canvas: across the canvas's centre lines. + s.execute("select.all", json!({})).unwrap(); + let past = s.active().unwrap().history.past_len(); + s.execute("edit.transform.flipHorizontal", json!({})).unwrap(); + assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(20, 10, 30, 14)); + assert_eq!(pixel(&s, 29, 10), green); + assert_eq!(s.active().unwrap().history.past_len(), past + 1, "one undo step"); + s.execute("edit.transform.flipVertical", json!({})).unwrap(); + assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(20, 16, 30, 20)); + assert_eq!(pixel(&s, 29, 19), green); + s.execute("edit.transform.rotate180", json!({})).unwrap(); + assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(10, 10, 20, 14)); + assert_eq!(pixel(&s, 10, 10), green); + // A marquee (0, 0)–(24, 30) holding the whole square: across x = 12. + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 24, "height": 30})).unwrap(); + s.execute("edit.transform.flipHorizontal", json!({})).unwrap(); + assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(4, 10, 14, 14)); + assert_eq!(pixel(&s, 13, 10), green); + // Undo puts it back where it was. + s.undo(); + assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(10, 10, 20, 14)); + // A targeted alpha channel turns about the selection's bounds too. + s.execute("channel.new", json!({})).unwrap(); + s.edit("paint channel", |doc, _| { + doc.channels[0].surface.fill_rect(Rect::new(10, 10, 20, 20), &[1.0]); + Ok(()) + }) + .unwrap(); + s.execute("channel.target", json!({"channel": 0})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + s.execute("edit.transform.flipHorizontal", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.channels[0].surface.content_bounds(), Rect::new(20, 10, 30, 20)); + } + #[test] fn reselect_restores_the_last_selection() { let mut s = session(8); @@ -1549,7 +1632,7 @@ mod tests { // Layer 1 is already the only visible layer: ⌥-clicking it shows everything. assert_eq!(s.execute("layer.showOnly", json!({})).unwrap()["shownAlone"], false); assert!(doc(&s).layers.iter().all(|l| l.visible)); - assert!(s.execute("layer.showOnly", json!({"layer": 9999})).is_err()); + assert!(s.execute("layer.showOnly", json!({"layer": u64::MAX})).is_err()); assert!(Session::new().execute("layer.showOnly", json!({})).is_err()); } @@ -1624,7 +1707,7 @@ mod tests { let bg_locks = doc(&s).layer(bg).unwrap().locks; assert!(s.execute("layer.toggleLockTransparency", json!({"layer": bg.0})).is_err()); assert_eq!(doc(&s).layer(bg).unwrap().locks, bg_locks, "the Background is left alone"); - assert!(s.execute("layer.toggleLockTransparency", json!({"layer": 999_999})).is_err()); + assert!(s.execute("layer.toggleLockTransparency", json!({"layer": u64::MAX})).is_err()); } #[test] @@ -1679,8 +1762,12 @@ mod tests { s.execute("layer.translate", json!({"dx": 0, "dy": 0})).ok(); paint_square(&mut s, Rect::new(-5, 0, 10, 10), [1.0, 0.0, 0.0, 1.0]); assert_eq!(reveal_all_bounds(doc(&s)), Rect::new(-5, 0, 40, 30)); + let steps = s.active().unwrap().history.past_len(); s.execute("image.revealAll", json!({})).unwrap(); assert_eq!((doc(&s).size.width, doc(&s).size.height), (45, 30)); + let history = &s.active().unwrap().history; + assert_eq!(history.past_len(), steps + 1, "one step"); + assert_eq!(history.entries().last().map(String::as_str), Some("Reveal All"), "named as in Photoshop, not Crop"); assert_eq!(s.execute("image.revealAll", json!({})).unwrap()["changed"], false); } 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/file_cmds.rs b/crates/engine/src/file_cmds.rs index 7689f39679..f9cee9e214 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -219,6 +219,45 @@ 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)] + { + // The volume serial number and file index of each, as unix compares dev and inode, so a + // hard link (another name for the file) is the same file. + if let Ok(same) = same_file::is_same_file(a, b) { + return same; + } + // Should a file not open: 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 +} + +/// A path as command results report it: `/` between components, whatever the platform. +/// +/// `join` already builds with `/`, but a directory from the caller or from `std::path::absolute` +/// carries the native separator, which would leave a reported path mixed. The control channel +/// takes `/` and rejects alternate separators (`AuthorizedWorkspace` in `crates/automation`), so +/// a result spelt this way can be handed straight back to it. Verbatim and UNC prefixes keep +/// theirs: Win32 does not accept `/` after a leading `\\`. +pub(crate) fn forward_slashes(path: String) -> String { + if path.starts_with(r"\\") { path } else { path.replace('\\', "/") } +} + pub(crate) fn join(dir: &str, name: &str) -> String { if dir.is_empty() { name.to_string() @@ -249,8 +288,8 @@ pub(crate) fn import(name: &str, bytes: &[u8]) -> Result { Ok(r.document) } -/// What a headless save writes beyond the format: JPEG quality, TIFF layers, Targa bits, PSD -/// previews and the PSD merged image. +/// What a headless save writes beyond the format: JPEG quality, TIFF layers, Targa bits, DPX +/// bits, DDS compression and mipmaps, PSD previews and the PSD merged image. #[derive(Debug, Clone, Copy)] pub(crate) struct SaveOpts { /// Photoshop's 0–12 JPEG scale. @@ -262,6 +301,9 @@ pub(crate) struct SaveOpts { pub tga_bits: Option, /// DPX: 8, 10, 12 or 16 bits per sample (`"dpxBits"`); unset, 8 for an 8-bit document, else 16. pub dpx_bits: Option, + /// DDS: the compression and whether to write the mip chain (`"ddsCompression"`, + /// `"ddsMipmaps"`); unset, uncompressed without mipmaps. + pub dds: photocraft_io::DdsOptions, /// PSD/PSB: embed the thumbnail resource (Preferences › File Handling › Image Previews). pub image_previews: bool, /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB @@ -271,7 +313,15 @@ pub(crate) struct SaveOpts { impl Default for SaveOpts { fn default() -> Self { - SaveOpts { quality: None, tiff_layers: false, tga_bits: None, dpx_bits: None, image_previews: true, psd_maximize_compatibility: true } + SaveOpts { + quality: None, + tiff_layers: false, + tga_bits: None, + dpx_bits: None, + dds: photocraft_io::DdsOptions::default(), + image_previews: true, + psd_maximize_compatibility: true, + } } } @@ -287,6 +337,7 @@ impl SaveOpts { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits: tga_bits_param(p, cmd)?, dpx_bits: dpx_bits_param(p, cmd)?, + dds: dds_param(p, cmd)?.unwrap_or_default(), image_previews: file_handling.image_previews != crate::prefs::Ask::Never, psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never, }) @@ -331,6 +382,30 @@ pub fn dpx_bits_param(p: &Value, cmd: &str) -> Result Result> { + let bad = |msg: String| EngineError::BadParams { cmd: cmd.into(), msg }; + let compression = match p.get("ddsCompression") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_str() + .and_then(photocraft_io::DdsCompression::from_name) + .ok_or_else(|| bad(format!("`ddsCompression` must be \"none\", \"bc1\", \"bc2\" or \"bc3\", got {v}")))?, + ), + }; + let mipmaps = match p.get("ddsMipmaps") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad(format!("`ddsMipmaps` must be true or false, got {v}")))?), + }; + if compression.is_none() && mipmaps.is_none() { + return Ok(None); + } + Ok(Some(photocraft_io::DdsOptions { compression: compression.unwrap_or_default(), mipmaps: mipmaps.unwrap_or(false) })) +} + /// Encodes `doc` for `path`'s extension. pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> Result<(Vec, Vec)> { crate::allocation::checkpoint("saving document")?; @@ -340,12 +415,15 @@ 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 // lossy or lossless as it was), as Photoshop's Save does. photocraft_io::apply_file_encoding(doc, &mut opts.encode); opts.encode.dpx_bits = save.dpx_bits; + opts.encode.dds = save.dds; if let Some(q) = save.quality { let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8; opts.encode.jpeg_quality = q; @@ -445,6 +523,15 @@ fn revert(s: &mut Session) -> Result { fn save_a_copy(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path", "file.saveACopy")?.to_string(); let d = s.active().ok_or(EngineError::NoDocument)?; + // A copy over the document's own file replaces it, and a flat or lossy format can't be + // undone, so it takes an explicit `overwrite` (#7437). The desktop dialog writes through the + // shell, after the OS has confirmed the replace. + if d.path.as_deref().is_some_and(|own| same_file(&path, own)) && p.get("overwrite").and_then(Value::as_bool) != Some(true) { + return Err(EngineError::BadParams { + cmd: "file.saveACopy".into(), + msg: "`path` is the document's own file, so the copy would replace it: choose another path, or pass \"overwrite\": true".into(), + }); + } let mut doc = (*d.doc).clone(); if p.get("layers").and_then(Value::as_bool) == Some(false) { // "Layers" unchecked in Save a Copy: write the flattened image. @@ -457,6 +544,20 @@ fn save_a_copy(s: &mut Session, p: &Value) -> Result { } /// Open a file's bytes, decoding them as the format `as_ext` (Open As) when given. +/// EXR/HDR rarely states its colour space, so io tags it with the linear-sRGB default. +/// With File Handling's ACEScg option on, such an EXR opens as ACEScg instead (AP1, +/// linear) — the working space ACES render pipelines write. Only the io default is +/// replaced; an embedded or EXIF-declared profile is kept untouched. +pub(crate) fn tag_exr_colorspace(s: &Session, name: &str, doc: &mut Document) { + if !name.to_ascii_lowercase().ends_with(".exr") || !s.prefs.get().file_handling.exr_aces_cg { + return; + } + let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes(); + if doc.icc_profile.as_ref().map(|v| v.as_slice()) == Some(linear.as_slice()) { + doc.icc_profile = Some(photocraft_cms::Builtin::AcesCg.profile().to_bytes()); + } +} + pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<&str>, path: Option) -> Result { crate::allocation::checkpoint("importing document")?; let decode_name = match as_ext { @@ -465,6 +566,7 @@ pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<& }; let r = photocraft_io::import(&decode_name, bytes).map_err(|e| EngineError::Other(format!("{decode_name}: {e}")))?; let mut doc = r.document; + tag_exr_colorspace(s, &decode_name, &mut doc); let path = match template_name(s, name) { Some(untitled) => { doc.name = untitled; @@ -563,7 +665,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) ---------- @@ -930,13 +1041,106 @@ pub(crate) fn batch_inputs(p: &Value, cmd: &str) -> Result> { } } +/// One input of a batch run: the file, and the folder it sits in relative to the input folder +/// (`""` at the top level, `"a/b"` two levels down), which its result keeps under `output`. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct BatchInput { + pub path: String, + pub rel_dir: String, +} + +/// Subfolders below a batch input folder are walked at most this deep ("Include All Subfolders"). +pub(crate) const MAX_SUBFOLDER_DEPTH: usize = 32; +/// A batch over subfolders lists at most this many files; a larger tree is an error, not a run +/// that silently stops partway. +pub(crate) const MAX_SUBFOLDER_FILES: usize = 100_000; + +/// [`batch_inputs`] for the commands whose Photoshop dialog has "Include All Subfolders" (Batch, +/// Image Processor): with `"includeSubfolders": true` a folder input also yields the images in its +/// subfolders, each tagged with its relative folder. `output` (when inside the input folder, as +/// the dialog's default `/batch` is) is not walked, so a rerun doesn't process its results. +pub(crate) fn batch_inputs_tree(p: &Value, cmd: &str, output: &str) -> Result> { + let recurse = match p.get("includeSubfolders") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"includeSubfolders\" must be true or false".into() }), + }; + match p.get("input").or_else(|| p.get("files")) { + Some(Value::String(dir)) if recurse => list_images_tree(dir, output, cmd), + _ => Ok(batch_inputs(p, cmd)?.into_iter().map(|path| BatchInput { path, rel_dir: String::new() }).collect()), + } +} + +/// The images under `dir` and its subfolders: each folder's files sorted by name, then its +/// subfolders in name order (depth first). Bounded by [`MAX_SUBFOLDER_DEPTH`] and +/// [`MAX_SUBFOLDER_FILES`]; a folder reached twice (a symlink loop) is walked once, and `skip` +/// (the output folder) is left out. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result> { + use std::path::{Path, PathBuf}; + let canon = |p: &Path| std::fs::canonicalize(p).ok(); + let root = Path::new(dir); + let root_canon = canon(root).ok_or_else(|| EngineError::Other(format!("{dir}: cannot open the folder")))?; + let skip = if skip.is_empty() { None } else { canon(Path::new(skip)) }; + let mut seen = std::collections::HashSet::new(); + seen.insert(root_canon); + let mut out = Vec::new(); + // Depth first with an explicit stack (no recursion): (folder, relative folder, depth). + let mut stack: Vec<(PathBuf, String, usize)> = vec![(root.to_path_buf(), String::new(), 0)]; + while let Some((folder, rel, depth)) = stack.pop() { + let shown = folder.to_string_lossy().into_owned(); + // The top folder must be readable; a subfolder that isn't is skipped. + let files = match list_images(&shown) { + Ok(f) => f, + Err(e) if depth == 0 => return Err(e), + Err(_) => continue, + }; + if out.len().saturating_add(files.len()) > MAX_SUBFOLDER_FILES { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("{dir} holds more than {MAX_SUBFOLDER_FILES} images with its subfolders") }); + } + out.extend(files.into_iter().map(|path| BatchInput { path, rel_dir: rel.clone() })); + if depth >= MAX_SUBFOLDER_DEPTH { + continue; + } + let Ok(rd) = std::fs::read_dir(&folder) else { continue }; + let mut subs: Vec<(String, PathBuf)> = rd + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .filter_map(|p| Some((p.file_name()?.to_string_lossy().into_owned(), p))) + .collect(); + subs.sort(); + // Pushed in reverse so the stack pops them in name order. + for (name, sub) in subs.into_iter().rev() { + let Some(c) = canon(&sub) else { continue }; + if skip.as_ref() == Some(&c) || !seen.insert(c) { + continue; + } + let rel = if rel.is_empty() { name } else { format!("{rel}/{name}") }; + stack.push((sub, rel, depth + 1)); + } + } + Ok(out) +} +#[cfg(target_arch = "wasm32")] +pub(crate) fn list_images_tree(dir: &str, _skip: &str, _cmd: &str) -> Result> { + Err(EngineError::Other(format!("cannot list {dir}: no file system on the web"))) +} + /// Opens each input in a scratch session, runs `f` on it and saves it to `output` as `format` /// (`"same"` keeps the input's extension). Errors per file are collected, not fatal. pub(crate) fn process_files(inputs: &[String], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value { - let outputs = inputs.iter().map(|path| { + let inputs: Vec = inputs.iter().map(|path| BatchInput { path: path.clone(), rel_dir: String::new() }).collect(); + process_tree(&inputs, output, format, save, suffix, f) +} + +/// [`process_files`] for inputs from subfolders: each result goes to its input's relative folder +/// under `output` (created as needed), so `a/x.png` and `b/x.png` don't collide. +pub(crate) fn process_tree(inputs: &[BatchInput], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value { + let outputs = inputs.iter().map(|BatchInput { path, rel_dir }| { let ext = if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() }; - (path.as_str(), join(output, &format!("{}{suffix}.{ext}", stem(path)))) + (path.as_str(), join(&join(output, rel_dir), &format!("{}{suffix}.{ext}", stem(path)))) }); process_outputs(outputs, save, f) } @@ -1000,8 +1204,8 @@ fn batch(s: &mut Session, p: &Value) -> Result { if let Some((id, _)) = steps.iter().find(|(id, _)| crate::commands::find(id).is_none()) { return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unknown command `{id}` in the action") }); } - let inputs = batch_inputs(p, cmd)?; let output = str_param(p, "output", cmd)?.to_string(); + let inputs = batch_inputs_tree(p, cmd, &output)?; let format = p.get("format").and_then(Value::as_str).unwrap_or("same").to_string(); // Steps that call another action play it from the caller's actions; the playback stack comes // along so an action that batches itself is caught as a recursive call. @@ -1032,23 +1236,23 @@ fn batch(s: &mut Session, p: &Value) -> Result { Ok(()) }; let r = match names { - Some(names) => process_outputs(inputs.iter().map(String::as_str).zip(names), save, &run), - None => process_files(&inputs, &output, &format, save, "", &run), + Some(names) => process_outputs(inputs.iter().map(|i| i.path.as_str()).zip(names), save, &run), + None => process_tree(&inputs, &output, &format, save, "", &run), }; Ok(r) } fn image_processor(s: &mut Session, p: &Value) -> Result { let cmd = "file.scripts.imageProcessor"; - let inputs = batch_inputs(p, cmd)?; let output = str_param(p, "output", cmd)?.to_string(); + let inputs = batch_inputs_tree(p, cmd, &output)?; let format = p.get("format").and_then(Value::as_str).unwrap_or("jpg").to_string(); let fit = match (size_param(p, "width"), size_param(p, "height")) { (None, None) => None, (w, h) => Some(json!({"width": w.unwrap_or(1e9), "height": h.unwrap_or(1e9), "dontEnlarge": true})), }; let to_srgb = p.get("convertToSrgb").and_then(Value::as_bool).unwrap_or(false); - let r = process_files(&inputs, &output, &format, SaveOpts::from_params(s, p, cmd)?.or_quality(8.0), "", &|scratch| { + let r = process_tree(&inputs, &output, &format, SaveOpts::from_params(s, p, cmd)?.or_quality(8.0), "", &|scratch| { if to_srgb && scratch.active().is_some_and(|d| d.doc.mode != ColorMode::Rgb) { scratch.execute("image.mode.rgb", json!({}))?; } @@ -1126,10 +1330,12 @@ fn has_live_effects(l: &Layer) -> bool { l.effects.enabled && l.effects.items.iter().any(|e| e.enabled()) } -/// The layer rendered alone (keeping what `keep` says), as a surface in `fmt`. With `apply_mask` -/// off the layer's pixel and vector masks are left out of the render, so a caller that keeps them -/// live on the baked raster does not mask the pixels twice. -fn bake(l: &Layer, canvas: Rect, fmt: PixelFormat, keep_effects: bool, apply_mask: bool) -> Surface { +/// The layer of `doc` rendered alone (keeping what `keep` says) with the document's Global Light, +/// patterns and canvas, as a surface in the document's format. With `apply_mask` off the layer's +/// pixel and vector masks are left out of the render, so a caller that keeps them live on the +/// baked raster does not mask the pixels twice. +fn bake(doc: &Document, l: &Layer, keep_effects: bool, apply_mask: bool) -> Surface { + let canvas = doc.bounds(); let mut tmp = l.clone(); tmp.opacity = 1.0; tmp.blend = photocraft_color::BlendMode::Normal; @@ -1146,7 +1352,7 @@ fn bake(l: &Layer, canvas: Rect, fmt: PixelFormat, keep_effects: bool, apply_mas // Render over the union of the canvas and the content so off-canvas pixels survive. let area = l.surface().map_or(canvas, |s| s.content_bounds().union(&canvas)); let area = if keep_effects { area.inflate(256) } else { area }; - buffer_surface(&photocraft_compose::render_layer(&tmp, area), fmt) + buffer_surface(&photocraft_compose::render_layer_in(doc, &tmp, area), doc.pixel_format()) } fn walk_ids(doc: &Document) -> Vec { @@ -1156,8 +1362,6 @@ fn walk_ids(doc: &Document) -> Vec { fn flatten_all_effects(s: &mut Session) -> Result { let mut n = 0; s.edit("Flatten All Layer Effects", |doc, _| { - let canvas = doc.bounds(); - let fmt = doc.pixel_format(); for id in walk_ids(doc) { let Some(l) = doc.layer(id) else { continue }; if !has_live_effects(l) || matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_)) { @@ -1165,7 +1369,7 @@ fn flatten_all_effects(s: &mut Session) -> Result { } // The pixel and vector masks stay on the layer (baking must not destroy them), so the // pixels are baked without them and each mask then applies once to the baked raster. - let px = bake(l, canvas, fmt, true, false); + let px = bake(doc, l, true, false); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.content = LayerContent::Raster(px); l.effects = Default::default(); @@ -1182,8 +1386,6 @@ fn flatten_all_effects(s: &mut Session) -> Result { fn flatten_all_masks(s: &mut Session) -> Result { let (mut n, mut skipped) = (0, 0); s.edit("Flatten All Masks", |doc, _| { - let canvas = doc.bounds(); - let fmt = doc.pixel_format(); for id in walk_ids(doc) { let Some(l) = doc.layer(id) else { continue }; let masked = l.mask.as_ref().is_some_and(|m| m.enabled) || l.vector_mask.is_some(); @@ -1196,7 +1398,7 @@ fn flatten_all_masks(s: &mut Session) -> Result { skipped += 1; continue; } - let px = bake(l, canvas, fmt, false, true); + let px = bake(doc, l, false, true); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.content = LayerContent::Raster(px); l.mask = None; @@ -1324,7 +1526,7 @@ fn bake_cube_layers(size: usize, title: &str, layers: &[&Layer]) -> String { lattice.layers.push(a); } let out = photocraft_compose::flatten(&lattice); - let mut s = format!("TITLE \"{}\"\n# Created by Photocraft\nLUT_3D_SIZE {n}\nDOMAIN_MIN 0.0 0.0 0.0\nDOMAIN_MAX 1.0 1.0 1.0\n", title.replace('"', "'")); + let mut s = format!("TITLE \"{}\"\n# Created by PhotoCraft\nLUT_3D_SIZE {n}\nDOMAIN_MIN 0.0 0.0 0.0\nDOMAIN_MAX 1.0 1.0 1.0\n", title.replace('"', "'")); // .cube order: red changes fastest, then green, then blue. for b in 0..n { for g in 0..n { @@ -1486,7 +1688,7 @@ pub fn specs() -> Vec { "Save a Copy…", &["File"], Some("Cmd+Alt+S"), - r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)}"##, + r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)}"##, native_doc, save_a_copy ), @@ -1549,7 +1751,7 @@ pub fn specs() -> Vec { "Batch…", &["File", "Automate"], None, - r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, batch ), @@ -1558,7 +1760,7 @@ pub fn specs() -> Vec { "Image Processor…", &["File", "Scripts"], None, - r##"{"input":folder|[paths],"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, image_processor ), @@ -1586,7 +1788,7 @@ pub fn specs() -> Vec { "Layers to Files…", &["File", "Export"], None, - r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)} → {files}"##, + r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)} → {files}"##, native_doc, layers_to_files ), @@ -1616,3 +1818,75 @@ pub fn specs() -> Vec { #[cfg(test)] #[path = "file_cmds/tests.rs"] mod tests; + +#[cfg(test)] +mod aces_exr_tests { + use super::*; + + /// File Handling › "Open color-less EXR as ACEScg": off, an EXR opens on io's linear-sRGB + /// default; on, it opens as ACEScg — the working space ACES render pipelines write. + #[test] + fn exr_aces_cg_preference_retags_the_opened_document() { + let image = photocraft_codecs::Image::from_f32( + 4, + 4, + photocraft_codecs::ChannelLayout::Rgb, + &[ + 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, + 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, + ], + ) + .unwrap(); + let bytes = photocraft_codecs::encode(&image, photocraft_codecs::Format::OpenExr, &photocraft_codecs::EncodeOptions::default()).unwrap(); + + let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes(); + let aces = photocraft_cms::Builtin::AcesCg.profile().to_bytes(); + + // Off (the default): io's linear-sRGB tagging stays. + let mut s = Session::new(); + open_bytes_as(&mut s, "shot.exr", &bytes, None, None).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.icc_profile.as_deref().map(|v| v.as_slice()), Some(linear.as_slice())); + + // On: the same file opens as ACEScg. + let mut s = Session::new(); + s.prefs.edit(|p| p.file_handling.exr_aces_cg = true); + open_bytes_as(&mut s, "shot.exr", &bytes, None, None).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.icc_profile.as_deref().map(|v| v.as_slice()), Some(aces.as_slice())); + } + + /// The desktop app opens files as background jobs (File > Open, drag-drop, Open Recent): + /// the ACEScg preference applies there too, not only to `file.openAs`. + #[test] + fn exr_aces_cg_preference_applies_to_background_opens() { + use crate::jobs::{OpenSource, Started}; + let image = photocraft_codecs::Image::from_f32(4, 4, photocraft_codecs::ChannelLayout::Rgb, &[0.5; 48]).unwrap(); + let bytes = + std::sync::Arc::new(photocraft_codecs::encode(&image, photocraft_codecs::Format::OpenExr, &photocraft_codecs::EncodeOptions::default()).unwrap()); + let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes(); + let aces = photocraft_cms::Builtin::AcesCg.profile().to_bytes(); + for (on, want) in [(false, &linear), (true, &aces)] { + let mut s = Session::new(); + s.prefs.edit(|p| p.file_handling.exr_aces_cg = on); + let v = match s.start_open("shot.exr", OpenSource::Bytes(std::sync::Arc::clone(&bytes))).unwrap() { + Started::Job(id) => s.wait_job(id).unwrap(), + Started::Done(v) => v, + }; + // ACEScg isn't the working space, so Ask When Opening holds it for the prompt, which + // names the ACEScg profile; linear sRGB (io's HDR default) opens without asking. + let profile = match v.get("held").and_then(Value::as_u64) { + Some(held) => { + assert!(on, "{v}"); + assert!(v["color"]["embedded"].as_str().unwrap().starts_with("ACEScg"), "{v}"); + s.take_held_open(held).unwrap().0.icc_profile + } + None => { + assert!(v.get("document").is_some(), "{v}"); + s.active().unwrap().doc.icc_profile.clone() + } + }; + assert_eq!(profile.as_deref().map(|v| v.as_slice()), Some(want.as_slice()), "exr_aces_cg = {on}"); + } + } +} diff --git a/crates/engine/src/file_cmds/batch_naming.rs b/crates/engine/src/file_cmds/batch_naming.rs index 2a32f71328..e5dcc4a6e6 100644 --- a/crates/engine/src/file_cmds/batch_naming.rs +++ b/crates/engine/src/file_cmds/batch_naming.rs @@ -1,7 +1,7 @@ //! Batch output names are planned before opening or writing files. Names substituted from an //! input are literal: braces in a source filename must never be interpreted as template tokens. -use super::{EngineError, Result, Value, join, stem}; +use super::{BatchInput, EngineError, Result, Value, join, stem}; fn bad(message: impl Into) -> EngineError { EngineError::BadParams { cmd: "file.automate.batch".into(), msg: message.into() } @@ -51,7 +51,7 @@ fn expand(parts: &[Part<'_>], name: &str, serial: u32, date: &str) -> String { /// An absent or empty template preserves the old filename path exactly. An explicit template /// generates one component per input; the export format supplies the extension separately. -pub(super) fn plan(inputs: &[String], output: &str, format: &str, params: &Value) -> Result>> { +pub(super) fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &Value) -> Result>> { let start = match params.get("startingSerial") { None => 1, Some(n) => { @@ -77,7 +77,7 @@ pub(super) fn plan(inputs: &[String], output: &str, format: &str, params: &Value return Err(bad("naming: the current UTC date is unavailable")); } let mut outputs = Vec::with_capacity(inputs.len()); - for (index, path) in inputs.iter().enumerate() { + for (index, BatchInput { path, rel_dir }) in inputs.iter().enumerate() { let serial = if has_serial { u32::try_from(index).ok().and_then(|index| start.checked_add(index)).ok_or_else(|| bad("startingSerial plus the input count exceeds 4294967295"))? } else { @@ -93,14 +93,15 @@ pub(super) fn plan(inputs: &[String], output: &str, format: &str, params: &Value if name.len() > 255 || name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c)) { return Err(bad("naming must produce a filename of at most 255 bytes without path separators or reserved characters")); } - outputs.push(join(output, &name)); + // From subfolders ("Include All Subfolders"), each result keeps its subfolder. + outputs.push(join(&join(output, rel_dir), &name)); } // Renaming within the source folder could replace a later input before it is read. Resolve // existing paths so aliases such as `source/./001.png` cannot bypass the check. The default // (no template) path keeps its existing in-place behavior. #[cfg(not(target_arch = "wasm32"))] { - let sources: std::collections::HashSet<_> = inputs.iter().filter_map(|p| std::fs::canonicalize(p).ok()).collect(); + let sources: std::collections::HashSet<_> = inputs.iter().filter_map(|i| std::fs::canonicalize(&i.path).ok()).collect(); for output in &outputs { if std::fs::canonicalize(output).ok().is_some_and(|p| sources.contains(&p)) { return Err(bad(format!("naming would replace an input file: {output}; choose a different destination or template"))); diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 99ddce15de..167ddfb898 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -135,6 +135,44 @@ fn save_a_copy_keeps_path_and_dirty_state() { assert!(s.execute("file.saveACopy", json!({})).is_err()); } +/// A copy over the document's own file, however the path is spelt, would replace it (a lossy +/// re-encode can't be undone), so it is refused unless `overwrite` is passed (#7437). +#[test] +fn save_a_copy_refuses_the_documents_own_file() { + let dir = tmp("copy-own"); + let path = png(&dir, "own.png", 6, 4, "#ff0000"); + let bytes = std::fs::read(&path).unwrap(); + let mut s = Session::new(); + open_bytes_as(&mut s, &path, &bytes, None, Some(path.clone())).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + let link = join(&dir, "link.png"); + std::fs::hard_link(&path, &link).unwrap(); + let rev = s.active().unwrap().revision; + for p in [path.clone(), format!("{dir}/./own.png"), link] { + let r = s.execute("file.saveACopy", json!({"path": p})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("overwrite")), "{p}: {r:?}"); + assert!(s.execute("file.saveACopy", json!({"path": p, "overwrite": false})).is_err(), "{p}"); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "{p}"); + let st = s.active().unwrap(); + assert_eq!(st.path.as_deref(), Some(path.as_str()), "{p}"); + assert_eq!(st.revision, rev, "{p}"); + assert!(st.is_dirty(), "{p}"); + } + let other = join(&dir, "other.png"); + s.execute("file.saveACopy", json!({"path": other})).unwrap(); + assert!(std::fs::metadata(&other).unwrap().len() > 0); + s.execute("file.saveACopy", json!({"path": path, "overwrite": true})).unwrap(); + assert_ne!(std::fs::read(&path).unwrap(), bytes); + assert_eq!(s.active().unwrap().path.as_deref(), Some(path.as_str())); + assert!(s.active().unwrap().is_dirty()); + + // A document that was never saved has no file of its own: it copies anywhere, as before. + let mut fresh = session(4, 4, 8); + fresh.execute("file.saveACopy", json!({"path": path})).unwrap(); + assert_eq!(fresh.active().unwrap().path, None); +} + /// `tgaBits` picks the Targa's bits per pixel (byte 16 of the header); unset, a document with an /// alpha channel is written as 32-bit, with the channel as the alpha. #[test] @@ -181,6 +219,53 @@ fn save_a_copy_writes_the_dpx_bits_asked_for() { } } +/// `ddsCompression` picks the DDS FourCC (or none, uncompressed) and `ddsMipmaps` the mip count +/// in the header; unset, an uncompressed texture without mipmaps. Bad values are refused before +/// anything is written. +#[test] +fn save_a_copy_writes_the_dds_options_asked_for() { + let dir = tmp("dds-options"); + let le32 = |b: &[u8], at: usize| u32::from_le_bytes(b[at..at + 4].try_into().unwrap()); + for (params, fourcc, mips) in [ + (json!({}), [0; 4], 1), + (json!({"ddsCompression": "none", "ddsMipmaps": true}), [0; 4], 5), + (json!({"ddsCompression": "bc1"}), *b"DXT1", 1), + (json!({"ddsCompression": "DXT3", "ddsMipmaps": false}), *b"DXT3", 1), + (json!({"ddsCompression": "bc3", "ddsMipmaps": true}), *b"DXT5", 5), + (json!({"ddsCompression": null, "ddsMipmaps": null}), [0; 4], 1), + ] { + let mut s = session(16, 12, 8); + let out = join(&dir, "a.dds"); + let mut p = params.clone(); + p["path"] = json!(out); + s.execute("file.saveACopy", p).unwrap(); + let bytes = std::fs::read(&out).unwrap(); + assert_eq!((&bytes[..4], &bytes[84..88], le32(&bytes, 28)), (&b"DDS "[..], &fourcc[..], mips), "{params}"); + assert_eq!(photocraft_io::import("a.dds", &bytes).unwrap().document.size, photocraft_geom::Size::new(16, 12), "{params}"); + } + let mut s = session(8, 8, 8); + let state = |s: &Session| s.active().map(|st| (st.revision, st.path.clone(), st.is_dirty())); + let before = state(&s); + for bad in [json!({"ddsCompression": "bc7"}), json!({"ddsCompression": 1}), json!({"ddsMipmaps": "yes"}), json!({"ddsMipmaps": 1})] { + let out = join(&dir, "bad.dds"); + let mut p = bad.clone(); + p["path"] = json!(out); + let r = s.execute("file.saveACopy", p); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}"); + assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written"); + assert_eq!(state(&s), before, "{bad}: the document is untouched"); + } +} + +#[test] +fn dds_params_are_none_until_one_is_given() { + use photocraft_io::{DdsCompression, DdsOptions}; + assert_eq!(dds_param(&json!({}), "file.saveAs").unwrap(), None); + assert_eq!(dds_param(&json!({"ddsCompression": null}), "file.saveAs").unwrap(), None); + assert_eq!(dds_param(&json!({"ddsMipmaps": true}), "file.saveAs").unwrap(), Some(DdsOptions { compression: DdsCompression::None, mipmaps: true })); + assert_eq!(dds_param(&json!({"ddsCompression": "bc2"}), "file.saveAs").unwrap(), Some(DdsOptions { compression: DdsCompression::Bc2, mipmaps: false })); +} + /// A `tgaBits` other than 24 or 32 is a bad parameter, reported before anything is written. #[test] fn bad_targa_bits_are_refused_before_writing() { @@ -586,7 +671,6 @@ fn flatten_all_layer_effects_and_masks() { s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 40})).unwrap(); s.execute("select.inverse", json!({})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); assert!(composite(&s, 15, 15)[1] > 0.99 && composite(&s, 15, 15)[0] > 0.99, "white shows through the mask"); let r = s.execute("file.scripts.flattenAllMasks", json!({})).unwrap(); assert_eq!(r["flattened"], 1); @@ -633,7 +717,6 @@ fn flatten_all_layer_effects_keeps_masks() { .unwrap(); s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 40})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); // Layer 2: a red square at the bottom-right with the same overlay and a *disabled* mask. s.execute("layer.new.layer", json!({})).unwrap(); s.execute("select.rect", json!({"x": 30, "y": 30, "width": 8, "height": 8})).unwrap(); @@ -652,7 +735,6 @@ fn flatten_all_layer_effects_keeps_masks() { .unwrap(); s.execute("select.rect", json!({"x": 34, "y": 34, "width": 2, "height": 2})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); s.edit("disable", |doc, _| { if let Some(m) = doc.layer_mut(id2).and_then(|l| l.mask.as_mut()) { m.enabled = false; @@ -712,6 +794,74 @@ fn flatten_all_layer_effects_keeps_masks() { } } +/// Flattens all layer effects on a red square carrying `fx` (in a document prepared by `setup`) +/// and asserts the composite did not change: the bake uses the document's own context (#5719). +fn assert_flatten_effects_is_a_no_op(depth: u32, setup: impl FnOnce(&mut Document), fx: photocraft_doc::Effect) { + let mut s = session(40, 40, depth); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 14, "y": 14, "width": 12, "height": 12})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("fx", |doc, _| { + setup(doc); + let l = doc.layer_mut(id).unwrap(); + l.effects.enabled = true; + l.effects.items.push(fx); + Ok(()) + }) + .unwrap(); + let before = photocraft_compose::flatten(doc(&s)); + assert_eq!(s.execute("file.scripts.flattenAllLayerEffects", json!({})).unwrap()["flattened"], 1); + let after = photocraft_compose::flatten(doc(&s)); + for (i, (a, b)) in after.px.iter().zip(&before.px).enumerate() { + for k in 0..4 { + assert!((a[k] - b[k]).abs() < 0.02, "depth {depth}: pixel ({}, {}) changed: {b:?} -> {a:?}", i % 40, i / 40); + } + } +} + +/// A Drop Shadow that uses Global Light is baked at the document's angle, not the default 120°. +#[test] +fn flatten_all_layer_effects_uses_the_documents_global_light() { + for depth in [8, 16, 32] { + let shadow = photocraft_doc::Effect::DropShadow(photocraft_doc::effects::Shadow { + common: photocraft_doc::effects::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + color: photocraft_color::Color::rgb(0.0, 0.0, 1.0), + angle: 120.0, + use_global_light: true, + distance: 10.0, + spread: 0.0, + size: 0.0, + contour: photocraft_doc::effects::Contour::Linear, + anti_alias: false, + noise: 0.0, + knocks_out: true, + }); + assert_flatten_effects_is_a_no_op(depth, |d| d.global_light.angle = 0.0, shadow); + } +} + +/// A Pattern Overlay of a document pattern survives the bake instead of vanishing. +#[test] +fn flatten_all_layer_effects_keeps_a_pattern_overlay() { + for depth in [8, 16, 32] { + let mut tile = photocraft_raster::Surface::new(PixelFormat::new(ColorMode::Rgb, photocraft_color::SampleType::U8, true)); + tile.fill_rect(Rect::new(0, 0, 2, 2), &[0.0, 1.0, 0.0, 1.0]); + let pat = photocraft_doc::Pattern::new("green", tile, 2, 2); + let overlay = photocraft_doc::Effect::PatternOverlay { + common: photocraft_doc::effects::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + name: pat.name.clone(), + id: pat.id.clone(), + scale: 1.0, + angle: 0.0, + link: true, + phase: (0.0, 0.0), + }; + assert_flatten_effects_is_a_no_op(depth, |d| d.patterns.push(pat), overlay); + } +} + #[test] fn layers_to_files_writes_each_visible_layer() { let dir = tmp("layers"); @@ -1036,7 +1186,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. @@ -1047,6 +1197,110 @@ fn reveal_in_finder_reveals_the_documents_saved_path() { assert_eq!(r["program"], json!(program)); assert_eq!(r["args"], json!(args)); } + +/// "Include All Subfolders" (AUTO-219-5, #3753): Batch and Image Processor take the images in +/// subfolders too, each result keeping its subfolder under the output folder (so same-named files +/// don't collide), and skip the output folder when it sits inside the input folder. +#[test] +fn batch_include_all_subfolders() { + let dir = tmp("batch-subfolders"); + let input = join(&dir, "in"); + for sub in ["sub/deeper", "z"] { + std::fs::create_dir_all(join(&input, sub)).unwrap(); + } + png(&input, "a.png", 8, 8, "#ff0000"); + png(&join(&input, "sub"), "b.png", 8, 8, "#00ff00"); + png(&join(&input, "sub/deeper"), "a.png", 8, 8, "#0000ff"); + png(&join(&input, "z"), "a.png", 8, 8, "#ffffff"); + let mut s = Session::new(); + // Default: the top level only, as before. + let out = join(&input, "batch"); + let r = s.execute("file.automate.batch", json!({"steps": [], "input": input, "output": out, "format": "png"})).unwrap(); + assert_eq!((r["files"].as_array().unwrap().len(), r["errors"].as_array().unwrap().len()), (1, 0), "{r}"); + // With subfolders: every image, in folder order, results kept apart by subfolder; the output + // folder (inside the input folder, holding last run's a.png) is not an input. + let r = s + .execute( + "file.automate.batch", + json!({"steps": [["image.adjustments.invert", {}]], "input": input, "includeSubfolders": true, "output": out, "format": "png"}), + ) + .unwrap(); + assert!(r["errors"].as_array().unwrap().is_empty(), "{r}"); + let files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect(); + let o = out.replace('\\', "/"); + assert_eq!(files, vec![format!("{o}/a.png"), format!("{o}/sub/b.png"), format!("{o}/sub/deeper/a.png"), format!("{o}/z/a.png")]); + let deep = photocraft_io::import("a.png", &std::fs::read(join(&out, "sub/deeper/a.png")).unwrap()).unwrap().document; + let px = photocraft_compose::render(&deep, Rect::new(1, 1, 2, 2)).px[0]; + assert!(px[0] > 0.99 && px[1] > 0.99 && px[2] < 0.01, "blue inverted to yellow: {px:?}"); + std::fs::remove_dir_all(&out).unwrap(); + // A naming template applies within each kept subfolder (output outside the input folder). + let named = join(&dir, "named"); + let r = s + .execute("file.automate.batch", json!({"steps": [], "input": input, "includeSubfolders": true, "output": named, "format": "png", "naming": "{serial}"})) + .unwrap(); + assert!(r["errors"].as_array().unwrap().is_empty(), "{r}"); + let files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect(); + let n = named.replace('\\', "/"); + assert_eq!(files, vec![format!("{n}/001.png"), format!("{n}/sub/002.png"), format!("{n}/sub/deeper/003.png"), format!("{n}/z/004.png")]); + // Image Processor shares the option (with its results out of the input folder). + let out = join(&dir, "jpg"); + let r = s.execute("file.scripts.imageProcessor", json!({"input": input, "includeSubfolders": true, "output": out})).unwrap(); + assert_eq!((r["files"].as_array().unwrap().len(), r["errors"].as_array().unwrap().len()), (4, 0), "{r}"); + assert!(std::path::Path::new(&join(&out, "sub/deeper/a.jpg")).is_file()); + // A wrong type is a parameter error, for both. + for bad in [json!("yes"), json!(1), json!([true])] { + for cmd in ["file.automate.batch", "file.scripts.imageProcessor"] { + let e = s.execute(cmd, json!({"steps": [], "input": input, "includeSubfolders": bad, "output": out})).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }), "{cmd} {bad}: {e}"); + } + } + // A missing input folder is an error, not a panic. + assert!(s.execute("file.automate.batch", json!({"steps": [], "input": join(&dir, "nope"), "includeSubfolders": true, "output": out})).is_err()); +} + +/// The subfolder walk stops at `MAX_SUBFOLDER_DEPTH` levels. +#[test] +fn batch_subfolder_walk_is_depth_limited() { + let dir = tmp("batch-subfolder-depth"); + let mut deep = dir.clone(); + for level in 1..=MAX_SUBFOLDER_DEPTH + 1 { + deep = join(&deep, "d"); + std::fs::create_dir_all(&deep).unwrap(); + if level >= MAX_SUBFOLDER_DEPTH { + std::fs::write(join(&deep, &format!("l{level}.png")), b"x").unwrap(); + } + } + let found = list_images_tree(&dir, "", "test").unwrap(); + let names: Vec<_> = found.iter().map(|i| file_name(&i.path)).collect(); + assert_eq!(names, vec![format!("l{MAX_SUBFOLDER_DEPTH}.png")], "{found:?}"); + assert_eq!(found[0].rel_dir.split('/').count(), MAX_SUBFOLDER_DEPTH); +} + +/// A symlink back up the tree is walked once, not forever. Skipped where the platform doesn't +/// let the test create directory symlinks (Windows without developer mode). +#[test] +fn batch_subfolder_walk_survives_symlink_loops() { + let dir = tmp("batch-subfolder-loop"); + let sub = join(&dir, "sub"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(join(&dir, "a.png"), b"x").unwrap(); + std::fs::write(join(&sub, "b.png"), b"x").unwrap(); + let link = std::path::Path::new(&sub).join("loop"); + #[cfg(unix)] + let made = std::os::unix::fs::symlink(&dir, &link); + #[cfg(windows)] + let made = std::os::windows::fs::symlink_dir(&dir, &link); + #[cfg(not(any(unix, windows)))] + let made: std::io::Result<()> = Err(std::io::Error::other("no symlinks")); + if made.is_err() { + eprintln!("skipped: cannot create a directory symlink here"); + return; + } + let found = list_images_tree(&dir, "", "test").unwrap(); + let names: Vec<_> = found.iter().map(|i| (i.rel_dir.as_str(), file_name(&i.path))).collect(); + assert_eq!(names, vec![("", "a.png".to_string()), ("sub", "b.png".to_string())]); +} + #[test] fn batch_naming_validates_the_whole_plan_before_writing() { let dir = tmp("naming-invalid"); diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index 68e617bc8c..c35ed9774b 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -106,7 +106,7 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { let source = match contents { "foreground" => color_source(s, s.tools.foreground), "background" => color_source(s, s.tools.background), - "color" => color_source(s, color_param(p, "color", s.tools.foreground)), + "color" => color_source(s, color_param(CMD, p, "color", s.tools.foreground)?), "black" => Source::Color([0.0, 0.0, 0.0, 1.0]), "gray" => Source::Color([128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]), "white" => Source::Color([1.0; 4]), @@ -235,24 +235,47 @@ pub(crate) fn blend_color_mask( /// The selection filled from its surroundings (`photocraft_algo::content_aware`), as straight /// RGBA over the sampling window. Only the selected pixels are used. fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, color_adaptation: bool, seed: u64) -> Result { - use photocraft_algo::content_aware::{FillOptions, color_level, fill, rotation_level}; let hole_bounds = sel.content_bounds().intersect(&canvas); if hole_bounds.is_empty() { return Err(EngineError::Other("the selection is outside the canvas".into())); } - let window = sampling_window(hole_bounds, canvas); + let is_hole = |x: i32, y: i32| sel.sample_channel(x, y, 0) > 0.0; + let (window, filled) = content_aware_native(surf, hole_bounds, canvas, color_adaptation, seed, is_hole, |_, _| true)?; let fmt = surf.format(); - let n = fmt.channels(); + let px = filled.chunks_exact(fmt.channels().max(1)).map(|c| to_rgba(&fmt, c)).collect(); + Ok(Source::Pixels(window, px)) +} + +/// The pixels `is_hole` picks inside `hole_bounds` filled from their surroundings +/// (`photocraft_algo::content_aware`): Photoshop's sampling window around `hole_bounds`, sampling +/// only the non-hole pixels `usable` allows. Returns the window and its pixels in the surface's +/// own channels (the hole filled, the rest as read). Shared by Edit › Fill › Content-Aware and the +/// Crop tool's Content-Aware fill (#3823). +pub(crate) fn content_aware_native( + surf: &Surface, + hole_bounds: Rect, + canvas: Rect, + color_adaptation: bool, + seed: u64, + is_hole: impl Fn(i32, i32) -> bool, + usable: impl Fn(i32, i32) -> bool, +) -> Result<(Rect, Vec)> { + use photocraft_algo::content_aware::{FillOptions, color_level, fill, rotation_level}; + let window = sampling_window(hole_bounds, canvas); + let n = surf.format().channels(); let (w, h) = window_size(window)?; let img = surf.read_region(window); - let mut hole = vec![false; w * h]; - for (i, hv) in hole.iter_mut().enumerate() { - *hv = sel.sample_channel(window.x0 + (i % w) as i32, window.y0 + (i / w) as i32, 0) > 0.0; + let len = w.checked_mul(h).ok_or_else(|| EngineError::Other("the area is too large for Content-Aware fill".into()))?; + let mut hole = crate::allocation::filled(len, false, "Content-Aware fill")?; + let mut source = crate::allocation::filled(len, false, "Content-Aware fill")?; + for (i, (hv, sv)) in hole.iter_mut().zip(source.iter_mut()).enumerate() { + let (x, y) = (window.x0 + (i % w) as i32, window.y0 + (i / w) as i32); + *hv = is_hole(x, y); + *sv = !*hv && usable(x, y); } - if hole.iter().all(|h| *h) { - return Err(EngineError::Other("Content-Aware fill needs pixels outside the selection to sample from".into())); + if !source.iter().any(|s| *s) { + return Err(EngineError::Other("Content-Aware fill needs pixels outside the area to fill to sample from".into())); } - let source = vec![true; w * h]; let opts = FillOptions { color_adaptation: color_level(if color_adaptation { "default" } else { "none" }), rotations: rotation_level("none"), @@ -260,12 +283,10 @@ fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, color_adaptation: mirror: false, seed, }; - if img.len() != w * h * n { + if img.len() != len * n { return Err(EngineError::Other("could not read the layer pixels".into())); } - let filled = fill(w, h, n, &img, &hole, &source, &opts); - let px = filled.chunks_exact(n.max(1)).map(|c| to_rgba(&fmt, c)).collect(); - Ok(Source::Pixels(window, px)) + Ok((window, fill(w, h, n, &img, &hole, &source, &opts))) } /// Photoshop's Content-Aware sampling window around the selection bounds `hole` @@ -313,30 +334,6 @@ pub(crate) fn read_window(surf: &Surface, r: Rect, cancelled: &(dyn Fn() -> bool if cancelled() { None } else { Some(out) } } -/// Which pixels of `r` a selection (or mask) covers at all: channel 0 above zero, read in -/// parallel bands as [`read_window`] does, without holding the coverage itself. -pub(crate) fn read_window_cover(surf: &Surface, r: Rect, cancelled: &(dyn Fn() -> bool + Sync)) -> Option> { - use rayon::prelude::*; - const BAND: usize = 64; - let n = surf.channels().max(1); - let (w, h) = (r.width() as usize, r.height() as usize); - let mut out = vec![false; w * h]; - if out.is_empty() { - return Some(out); - } - out.par_chunks_mut(BAND * w).enumerate().for_each_init(Vec::new, |buf, (i, chunk)| { - if cancelled() { - return; - } - let y0 = r.y0 + (i * BAND) as i32; - surf.read_region_into(Rect::new(r.x0, y0, r.x1, y0 + (chunk.len() / w) as i32), buf); - for (o, px) in chunk.iter_mut().zip(buf.chunks_exact(n)) { - *o = px[0] > 0.0; - } - }); - if cancelled() { None } else { Some(out) } -} - /// Channel 0 of [`read_window`] (a selection's or an alpha channel's coverage). pub(crate) fn read_window_mask(surf: &Surface, r: Rect, cancelled: &(dyn Fn() -> bool + Sync)) -> Option> { use rayon::prelude::*; diff --git a/crates/engine/src/fill_key_cmds.rs b/crates/engine/src/fill_key_cmds.rs index 6b94e6cd47..ce3b478a70 100644 --- a/crates/engine/src/fill_key_cmds.rs +++ b/crates/engine/src/fill_key_cmds.rs @@ -264,7 +264,7 @@ mod tests { for id in IDS { assert!(s.execute(id, json!([1, 2])).is_err(), "{id}"); assert!(s.execute(id, json!("x")).is_err(), "{id}"); - assert!(s.execute(id, json!({"layer": 99_999})).is_err(), "{id}"); + assert!(s.execute(id, json!({"layer": u64::MAX})).is_err(), "{id}"); assert!(s.execute(id, json!({"target": {"channel": 42}})).is_err(), "{id}"); } } diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index a9b6564309..dd0ff8c803 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -174,8 +174,9 @@ pub fn params_for(id: &str, p: &Value) -> Option { /// Runs filter command `id` with JSON `params` on `surf` (the pixel function behind every /// `filter.*` command; smart filters re-run it on re-render). `bounds` frames distortions; /// `selection` limits the result. Like layer filters, neighbourhood filters repeat the edge pixels -/// of `canvas` ∪ the surface's content instead of fading in transparency, and the output is -/// clipped to it. `None` for ids that aren't pixel filters. +/// `None` for ids that aren't pixel filters. `Err` when the requesting session's authority denies +/// the filter's params (an ambient path the session may not read): the caller must surface the +/// error rather than render unfiltered pixels. pub fn apply_filter_to_surface( id: &str, params: &Value, @@ -183,31 +184,36 @@ pub fn apply_filter_to_surface( bounds: photocraft_geom::Rect, selection: Option<&photocraft_raster::Surface>, canvas: photocraft_geom::Rect, -) -> Option { +) -> Result> { + // Replays bypass command dispatch; nested paths must still obey the requesting session's + // authority, including adjustment LUTs and plug-ins, before any resolver can read them. + crate::jobs::authorize_render_command(id, params)?; // Liquify / Puppet Warp / Perspective Warp smart filters (distort_cmds). if let Some(out) = crate::distort_cmds::apply_to_surface(id, params, surf, canvas) { - return Some(out); + return Ok(Some(out)); } // Lens Correction / Adaptive Wide Angle / Camera Raw smart filters (lens_cmds). if let Some(out) = crate::lens_cmds::apply_to_surface(id, params, surf, canvas) { - return Some(out); + return Ok(Some(out)); } // Image › Adjustments recorded as smart filters (Levels, Curves, Shadows/Highlights… from PSD). if let Some(kind) = id.strip_prefix("image.adjustments.") { - return crate::adjust_cmds::adjust_as_filter(kind, params, surf); + return Ok(crate::adjust_cmds::adjust_as_filter(kind, params, surf)); } // WebAssembly plug-in smart filters (plugin_cmds). if id == crate::plugin_cmds::RUN { - return crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas); + return Ok(crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas)); } - let mut fp = params_for(id, params)?; + let Some(mut fp) = params_for(id, params) else { return Ok(None) }; // A map that can no longer be read (moved or deleted since) leaves the pixels alone, like an // unknown filter; the command itself already refused an unreadable map when it was added. - crate::filters_ext::resolve_map_path(&mut fp, params).ok()?; + if crate::filters_ext::resolve_map_path(&mut fp, params).is_err() { + return Ok(None); + } let sel_bounds = selection.map(photocraft_raster::Surface::content_bounds); let content = surf.content_bounds(); let area = algo::output_area(&fp, content, bounds, sel_bounds); - Some(algo::apply_in(surf, &fp, area, bounds, selection, canvas.union(&content))) + Ok(Some(algo::apply_in(surf, &fp, area, bounds, selection, canvas.union(&content)))) } pub(crate) fn has_filterable_layer(s: &Session) -> std::result::Result<(), String> { @@ -286,6 +292,11 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result let surf = match &mut l.content { LayerContent::Raster(surf) => surf, LayerContent::Smart(_) => { + // A smart filter re-renders from its params alone, without the document's + // channels, so Lens Blur's alpha-channel depth would be lost (#7400). + if matches!(fp, FilterParams::LensBlur { depth: algo::DepthSource::Channel, .. }) { + return Err(EngineError::Other("Lens Blur can't read an alpha channel as depth on a Smart Object: rasterize the layer first".into())); + } // Non-destructive: record the filter and re-render the smart object from its source. let sf = SmartFilter { command: id.clone(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; @@ -307,7 +318,7 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result } /// Lock transparency: put the layer's alpha back after a filter, in the tiles it changed. -fn keep_alpha(old: &photocraft_raster::Surface, new: &mut photocraft_raster::Surface) { +pub(crate) fn keep_alpha(old: &photocraft_raster::Surface, new: &mut photocraft_raster::Surface) { if old.format() != new.format() || !new.format().alpha { return; } @@ -460,7 +471,6 @@ mod tests { if reveal_selection { s.execute("select.all", json!({})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); } else { s.execute("layer.layerMask.revealAll", json!({})).unwrap(); } diff --git a/crates/engine/src/filters/validation.rs b/crates/engine/src/filters/validation.rs index 127e92efe1..b2359ffa57 100644 --- a/crates/engine/src/filters/validation.rs +++ b/crates/engine/src/filters/validation.rs @@ -1,4 +1,4 @@ -//! Validate new core-filter invocations, without changing how old smart-filter records decode. +//! Validate new filter invocations, without changing how old smart-filter records decode. use serde_json::Value; @@ -33,7 +33,7 @@ impl Rule { } } -// The bounds and choices are the core filters' public dialog-unit contract. Defaults remain in +// The bounds and choices are the filters' public dialog-unit contract. Defaults remain in // params_for; gallery filters are checked in gallery_cmds. fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> { use Rule::{Bool, Choice, Number, Unsigned}; @@ -48,6 +48,20 @@ fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> { ("centerX", Number(0.0, 1.0)), ("centerY", Number(0.0, 1.0)), ], + "filter.blur.smartBlur" => &[ + ("radius", Number(0.1, 100.0)), + ("threshold", Number(0.1, 100.0)), + ("quality", Choice("high|medium|low")), + ("mode", Choice("normal|edgeOnly|overlayEdge")), + ], + "filter.blur.shapeBlur" => &[("radius", Number(5.0, 1000.0)), ("shape", Choice("circle|ring|square|diamond|triangle|hexagon|star|heart|cross"))], + "filter.noise.reduceNoise" => &[ + ("strength", Number(0.0, 10.0)), + ("preserveDetails", Number(0.0, 100.0)), + ("reduceColorNoise", Number(0.0, 100.0)), + ("sharpenDetails", Number(0.0, 100.0)), + ("removeJpegArtifact", Bool), + ], "filter.blur.surfaceBlur" => &[("radius", Number(1.0, 100.0)), ("threshold", Number(2.0, 255.0))], "filter.sharpen.unsharpMask" => &[("amount", Number(1.0, 500.0)), ("radius", Number(0.1, 1000.0)), ("threshold", Number(0.0, 255.0))], "filter.sharpen.smartSharpen" => &[("amount", Number(1.0, 500.0)), ("radius", Number(0.1, 64.0)), ("reduceNoise", Number(0.0, 100.0))], @@ -92,8 +106,8 @@ fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> { }) } -// Extended filters: only their documented choices are checked for now, since `prepare` injects -// extra keys (colours, map documents) and their numeric ranges clamp in params_for. +// Remaining extended filters: only their documented choices are checked for now, since some +// accept extra keys (colours, map documents) and their numeric ranges clamp in params_for. fn choices(id: &str) -> Option<&'static [(&'static str, &'static str)]> { Some(match id { "filter.pixelate.mezzotint" => { @@ -109,11 +123,11 @@ fn choices(id: &str) -> Option<&'static [(&'static str, &'static str)]> { "filter.distort.zigZag" => &[("style", "pondRipples|outFromCenter|aroundCenter")], "filter.render.lensFlare" => &[("lens", "zoom|prime35|prime105|moviePrime")], "filter.render.lightingEffects" => &[("lightType", "spot|point|infinite"), ("texture", "none|red|green|blue|alpha|luminance")], - "filter.blur.smartBlur" => &[("quality", "high|medium|low"), ("mode", "normal|edgeOnly|overlayEdge")], - "filter.blur.lensBlur" => { - &[("shape", "hexagon|triangle|square|pentagon|heptagon|octagon"), ("depthMap", "none|transparency|layerMask"), ("distribution", "uniform|gaussian")] - } - "filter.blur.shapeBlur" => &[("shape", "circle|ring|square|diamond|triangle|hexagon|star|heart|cross")], + "filter.blur.lensBlur" => &[ + ("shape", "hexagon|triangle|square|pentagon|heptagon|octagon"), + ("depthMap", "none|transparency|layerMask|channel"), + ("distribution", "uniform|gaussian"), + ], "filter.other.hsbHsl" => &[("inputMode", "rgb|hsb|hsl"), ("rowOrder", "hsb|hsl|rgb")], "filter.video.deInterlace" => &[("eliminate", "oddFields|evenFields"), ("createBy", "interpolation|duplication")], _ => return None, @@ -324,9 +338,7 @@ mod tests { "filter.distort.zigZag", "filter.render.lensFlare", "filter.render.lightingEffects", - "filter.blur.smartBlur", "filter.blur.lensBlur", - "filter.blur.shapeBlur", "filter.other.hsbHsl", "filter.video.deInterlace", ]; @@ -348,6 +360,124 @@ mod tests { s.execute("filter.stylize.wind", json!({"direction":"fromLeft"})).unwrap(); } + const STRICT_EXTENDED: [&str; 3] = ["filter.blur.smartBlur", "filter.blur.shapeBlur", "filter.noise.reduceNoise"]; + + #[test] + fn strict_extended_filters_reject_bad_fields_without_editing() { + let numbers = [ + ("filter.blur.smartBlur", "radius", 0.1, 100.0), + ("filter.blur.smartBlur", "threshold", 0.1, 100.0), + ("filter.blur.shapeBlur", "radius", 5.0, 1000.0), + ("filter.noise.reduceNoise", "strength", 0.0, 10.0), + ("filter.noise.reduceNoise", "preserveDetails", 0.0, 100.0), + ("filter.noise.reduceNoise", "reduceColorNoise", 0.0, 100.0), + ("filter.noise.reduceNoise", "sharpenDetails", 0.0, 100.0), + ]; + let specs = crate::filters_ext::specs(); + for depth in [8, 16, 32] { + let mut s = session(depth); + for (id, key, min, max) in numbers { + let doc = specs.iter().find(|spec| spec.id == id).unwrap().params; + assert!(doc.contains(&format!("\"{key}\":{min}..{max}=")), "{id}.{key}"); + for value in [json!("3"), json!(true), Value::Null, json!([]), json!({}), json!(min - 0.01), json!(max + 0.01), json!(1e300)] { + rejected(&mut s, id, key, value); + } + // Validate large endpoints without allocating large blur kernels. + for value in [min, max] { + validate_params(id, &json!({key:value})).unwrap(); + } + } + for id in STRICT_EXTENDED { + for (key, value) in [("radus", json!(3)), ("layer", json!("2")), ("target", json!("typo")), ("coalesce", json!(false))] { + rejected(&mut s, id, key, value); + } + for value in [json!([]), json!(false), json!(3), json!("params")] { + assert!(matches!(validate_params(id, &value), Err(EngineError::BadParams { .. }))); + } + } + for value in [json!("true"), json!(1), Value::Null, json!([]), json!({})] { + rejected(&mut s, "filter.noise.reduceNoise", "removeJpegArtifact", value); + } + } + for value in [false, true] { + validate_params("filter.noise.reduceNoise", &json!({"removeJpegArtifact":value})).unwrap(); + } + for (id, key, allowed) in [ + ("filter.blur.smartBlur", "quality", "high|medium|low"), + ("filter.blur.smartBlur", "mode", "normal|edgeOnly|overlayEdge"), + ("filter.blur.shapeBlur", "shape", "circle|ring|square|diamond|triangle|hexagon|star|heart|cross"), + ] { + let doc = specs.iter().find(|spec| spec.id == id).unwrap().params; + assert!(doc.contains(&format!("\"{key}\":\"{allowed}\"")), "{id}.{key}"); + let mut s = session(8); + for value in [json!("typo"), json!(false), json!(1), Value::Null] { + rejected(&mut s, id, key, value); + } + for value in allowed.split('|') { + validate_params(id, &json!({key:value})).unwrap(); + } + } + } + + #[test] + fn strict_extended_filters_validate_before_floating_selection_commit_and_jobs() { + let mut s = session(16); + s.execute("select.rect", json!({"x":0,"y":0,"width":4,"height":4})).unwrap(); + s.execute("select.float", json!({"dx":8})).unwrap(); + for id in STRICT_EXTENDED { + rejected(&mut s, id, "radus", json!(3)); + let doc = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + let journal = s.journal.clone(); + let params = json!({"radus":3}); + assert!(matches!(s.start(id, params.clone()), Err(EngineError::BadParams { .. }))); + let spec = crate::commands::find(id).unwrap(); + assert!(matches!((spec.run)(&mut s, ¶ms), Err(EngineError::BadParams { .. }))); + assert!(crate::float_cmds::floating(s.active().unwrap()).is_some()); + assert!(Arc::ptr_eq(&s.active().unwrap().doc, &doc)); + assert_eq!(s.active().unwrap().history.entries(), history); + assert_eq!(s.journal, journal); + assert!(s.jobs().is_empty()); + } + } + + #[test] + fn strict_extended_filters_keep_defaults_targets_history_and_last_filter() { + for depth in [8, 16, 32] { + for id in STRICT_EXTENDED { + let mut s = session(depth); + let layer = s.active().unwrap().active_layer.unwrap(); + for params in [Value::Null, json!({}), json!({"layer":layer.0,"target":"pixels","coalesce":"extended"})] { + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + s.execute(id, params).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + let after = s.active().unwrap().doc.clone(); + s.undo(); + assert_eq!(*s.active().unwrap().doc, *before); + s.redo(); + assert_eq!(*s.active().unwrap().doc, *after); + } + s.execute("filter.lastFilter", json!({})).unwrap(); + // The session injects this target when editing an alpha channel. + s.execute("channel.new", json!({"fill":"white"})).unwrap(); + s.execute(id, json!({})).unwrap(); + } + } + } + + #[test] + fn stored_extended_filter_records_still_decode_tolerantly() { + use photocraft_algo::{BlurQuality, BlurShape, FilterParams, SmartBlurMode}; + let smart = + crate::filters::params_for("filter.blur.smartBlur", &json!({"radius":-2,"threshold":900,"quality":"old","mode":false,"extra":true})).unwrap(); + assert!(matches!(smart, FilterParams::SmartBlur { radius: 0.1, threshold: 100.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal })); + let shape = crate::filters::params_for("filter.blur.shapeBlur", &json!({"radius":2000,"shape":"old"})).unwrap(); + assert!(matches!(shape, FilterParams::ShapeBlur { radius: 1000.0, shape: BlurShape::Circle })); + let noise = crate::filters::params_for("filter.noise.reduceNoise", &json!({"strength":-1,"preserveDetails":101,"removeJpegArtifact":"yes"})).unwrap(); + assert!(matches!(noise, FilterParams::ReduceNoise { strength: 0.0, preserve_details: 100.0, remove_jpeg_artifact: false, .. })); + } + #[test] fn invalid_filters_leave_floating_selection_and_background_jobs_alone() { let mut s = session(16); diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs index dae5e9b98b..0425e1932a 100644 --- a/crates/engine/src/filters_ext.rs +++ b/crates/engine/src/filters_ext.rs @@ -277,6 +277,7 @@ pub fn params_for(id: &str, p: &Value) -> Option { depth: match s(p, "depthMap", "none") { "transparency" => DepthSource::Transparency, "layerMask" => DepthSource::LayerMask, + "channel" => DepthSource::Channel, _ => DepthSource::None, }, focal_distance: f(p, "focalDistance", 0.0).clamp(0.0, 255.0), @@ -429,8 +430,19 @@ fn map_from_document(doc: &Document, layer: Option) -> Option } /// Resolves inputs that live outside the target layer: Displace's map (`mapPath`, or -/// `mapDocument` index with optional `mapLayer` id). +/// `mapDocument` index with optional `mapLayer` id), and the alpha channel Lens Blur reads its +/// depth from (`depthChannel`, an index or a name). pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<()> { + if let FilterParams::LensBlur { depth: DepthSource::Channel, depth_map, .. } = fp { + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let channel = p + .get("depthChannel") + .ok_or_else(|| EngineError::Other("Lens Blur: `depthMap` channel needs `depthChannel` (an alpha channel's index or name)".into()))?; + let surface = crate::edit_menu_cmds::channel_mask(doc, channel) + .ok_or_else(|| EngineError::Other(format!("Lens Blur: the document has no alpha channel {channel}")))?; + *depth_map = Some(Arc::new(algo::Image::read(surface, doc.bounds()))); + return Ok(()); + } let FilterParams::Displace { map, .. } = fp else { return Ok(()) }; if map.is_some() { return Ok(()); @@ -466,13 +478,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(()) } @@ -660,7 +674,7 @@ pub fn specs() -> Vec { "filter.blur.lensBlur", "Lens Blur…", ["Filter", "Blur"], - r##"{"radius":0..100=15,"shape":"hexagon|triangle|square|pentagon|heptagon|octagon","bladeCurvature":0..100=0,"rotation":0..360=0,"depthMap":"none|transparency|layerMask","focalDistance":0..255=0,"invert":bool,"brightness":0..100=0,"threshold":0..255=255,"noise":0..100=0,"distribution":"uniform|gaussian","monochromatic":bool,"seed":u32=0}"## + r##"{"radius":0..100=15,"shape":"hexagon|triangle|square|pentagon|heptagon|octagon","bladeCurvature":0..100=0,"rotation":0..360=0,"depthMap":"none|transparency|layerMask|channel","depthChannel":index|name?,"focalDistance":0..255=0,"invert":bool,"brightness":0..100=0,"threshold":0..255=255,"noise":0..100=0,"distribution":"uniform|gaussian","monochromatic":bool,"seed":u32=0}"## ), cmd!( "filter.blur.shapeBlur", diff --git a/crates/engine/src/filters_ext/tests.rs b/crates/engine/src/filters_ext/tests.rs index b616101f7c..490568e87c 100644 --- a/crates/engine/src/filters_ext/tests.rs +++ b/crates/engine/src/filters_ext/tests.rs @@ -164,6 +164,27 @@ fn colour_filters_use_and_record_tool_colours() { } } +#[test] +fn fibers_fill_an_empty_layer() { + // #5241: Fibers renders from the colours, not the layer's pixels, so an empty layer fills + // like in Photoshop (and can be transformed afterwards). + let mut s = Session::new(); + s.execute("file.new", json!({"width": 48, "height": 32})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("filter.render.fibers", json!({"seed": 3})).unwrap(); + let px = pixels(&s); + assert!(px.as_chunks::<4>().0.iter().all(|p| p[3] == 1.0), "every pixel is rendered opaque"); + s.execute("edit.transform.flipHorizontal", json!({})).unwrap(); + + // With a selection only the selection fills. + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 4, "y": 4, "width": 10, "height": 6})).unwrap(); + s.execute("filter.render.fibers", json!({"seed": 3})).unwrap(); + let d = s.active().unwrap(); + let surf = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap(); + assert_eq!(surf.content_bounds(), Rect::new(4, 4, 14, 10)); +} + #[test] fn displace_reads_another_document() { let mut s = session(); @@ -199,6 +220,51 @@ fn lens_blur_uses_the_layer_mask_as_depth() { assert_ne!(pixels(&s), before); } +/// #7400: an alpha channel is a Lens Blur depth source, by name or index, as Photoshop lists +/// the document's channels in Depth Map › Source. A missing channel is an error that changes +/// nothing, and on a Smart Object (whose smart filters re-render without the channels) it is +/// refused instead of silently blurring without depth. +#[test] +fn lens_blur_reads_an_alpha_channel_as_depth() { + let mut s = session(); + // A white channel: depth 1 everywhere. Creating it targets it; the filter is for the layer. + s.execute("channel.new", json!({"name": "Depth Map", "fill": "white"})).unwrap(); + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + let before = pixels(&s); + for channel in [json!("Depth Map"), json!(0)] { + let p = |focal: f64| json!({"radius": 6, "depthMap": "channel", "depthChannel": channel, "focalDistance": focal}); + // Focal distance 255 puts depth 1 in focus: nothing blurs. + s.execute("filter.blur.lensBlur", p(255.0)).unwrap(); + assert_eq!(pixels(&s), before, "{channel}: in focus"); + s.execute("filter.blur.lensBlur", p(0.0)).unwrap(); + assert_ne!(pixels(&s), before, "{channel}: out of focus blurs"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(pixels(&s), before); + } + for bad in [ + json!({"radius": 6, "depthMap": "channel"}), + json!({"radius": 6, "depthMap": "channel", "depthChannel": "Nope"}), + json!({"radius": 6, "depthMap": "channel", "depthChannel": 7}), + ] { + let doc = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let e = s.execute("filter.blur.lensBlur", bad.clone()).unwrap_err().to_string(); + assert!(e.contains("Lens Blur"), "{bad}: {e}"); + assert_eq!(pixels(&s), before, "{bad}: nothing changed"); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &st.doc), "{bad}: the document was replaced"); + assert_eq!(st.history.past_len(), past, "{bad}: a history step was recorded"); + } + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let e = s.execute("filter.blur.lensBlur", json!({"radius": 6, "depthMap": "channel", "depthChannel": "Depth Map"})).unwrap_err().to_string(); + assert!(e.contains("Smart Object"), "{e}"); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &st.doc), "no smart filter was added"); + assert_eq!(st.history.past_len(), past, "no history step"); +} + #[test] fn params_map_to_algorithm_units() { assert_eq!(params_for("filter.pixelate.crystallize", &json!({"cellSize": 1})), Some(FilterParams::Crystallize { cell_size: 3.0, seed: 0 })); diff --git a/crates/engine/src/frame_cmds.rs b/crates/engine/src/frame_cmds.rs index c97006e746..7a401594fd 100644 --- a/crates/engine/src/frame_cmds.rs +++ b/crates/engine/src/frame_cmds.rs @@ -3,7 +3,7 @@ //! primitives: group the selected layers, then clip the group with a rectangular layer mask the //! size of their bounds — the same visual result Photoshop's Frame tool produces for this command. //! `layer.new.frame` is the Frame tool's (K) command: a rectangular or elliptical frame drawn on -//! the canvas, empty or holding the pixel layer it was drawn over. Headless and scriptable like +//! the canvas, empty or holding the layer it was drawn over. Headless and scriptable like //! every other command. use photocraft_doc::{Layer, LayerContent, LayerId, LayerMask, PixelFormat}; @@ -107,8 +107,9 @@ fn ellipse_mask(rect: Rect, area: Rect) -> LayerMask { } /// Frame tool (K): a new frame — a group clipped to the drawn rectangle or ellipse — as one -/// history step. With `layer` (a pixel layer, not the Background), that layer becomes the frame's -/// content, as when a frame is drawn over a selected pixel layer in Photoshop; else it is empty. +/// history step. With `layer` (a pixel, type, shape or smart object layer, not the Background), +/// that layer becomes the frame's content, as when a frame is drawn over a selected layer in +/// Photoshop; else it is empty. fn new_frame(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.new.frame"; let bad = |msg: String| EngineError::BadParams { cmd: CMD.into(), msg }; @@ -137,8 +138,9 @@ fn new_frame(s: &mut Session, p: &Value) -> Result { let gid = match content { Some(id) => { let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?; - if !matches!(l.content, LayerContent::Raster(_)) || doc.is_background(id) { - return Err(bad("only a pixel layer (not the Background) can go in a frame".into())); + let framable = matches!(l.content, LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_)); + if !framable || doc.is_background(id) { + return Err(bad("only a pixel, type, shape or smart object layer (not the Background) can go in a frame".into())); } let gid = doc.insert_above(Some(id), Layer::group(name, vec![])); let child = doc.remove(id).ok_or(EngineError::NoLayer(id))?; @@ -163,7 +165,7 @@ pub fn specs() -> Vec { label: "New Frame", menu: &[], shortcut: None, - params: r#"{"x":i32,"y":i32,"width":u32,"height":u32,"shape":"rectangle|ellipse"="rectangle","layer":id?,"name":str?} → {layer, frame:[x,y,w,h], shape}: Frame tool; a group clipped to the shape, holding pixel layer `layer` if given"#, + params: r#"{"x":i32,"y":i32,"width":u32,"height":u32,"shape":"rectangle|ellipse"="rectangle","layer":id?,"name":str?} → {layer, frame:[x,y,w,h], shape}: Frame tool; a group clipped to the shape, holding pixel, type, shape or smart object layer `layer` if given"#, enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()), journal: true, run: new_frame, @@ -274,6 +276,27 @@ mod tests { } } + /// #4435: a type layer (and the other rendered layer kinds) goes into a frame like a pixel layer. + #[test] + fn new_frame_takes_a_type_layer() { + let (mut s, a, _) = session(); + let tid = s + .edit("setup", |doc, _| { + let cache = doc.layer(a).and_then(|l| l.surface()).cloned(); + let t = Layer::new("T", LayerContent::Text(photocraft_doc::TextLayer { cache, ..Default::default() })); + let id = t.id; + doc.layers.push(t); + Ok(id) + }) + .unwrap(); + let r = s.execute("layer.new.frame", json!({"x": 0, "y": 0, "width": 40, "height": 30, "layer": tid.0})).unwrap(); + let gid = LayerId(r["layer"].as_u64().unwrap()); + let g = s.active().unwrap().doc.layer(gid).unwrap(); + assert!(matches!(&g.content, LayerContent::Group(c) if c.children.iter().map(|l| l.id).eq([tid])), "the frame holds the type layer"); + assert!(s.undo()); + assert!(s.active().unwrap().doc.layer(tid).is_some(), "undo gives the type layer back"); + } + #[test] fn needs_a_selected_layer() { let mut s = Session::new(); diff --git a/crates/engine/src/fx_view_cmds.rs b/crates/engine/src/fx_view_cmds.rs index 03241d8405..3598ea56b0 100644 --- a/crates/engine/src/fx_view_cmds.rs +++ b/crates/engine/src/fx_view_cmds.rs @@ -118,7 +118,7 @@ mod tests { let bg = s.active().unwrap().doc.layers[0].id.0; for p in [ json!({"layer": bg}), - json!({"layer": 9999}), + json!({"layer": u64::MAX}), json!({"layer": "x"}), json!({"layer": -1}), json!({"expanded": "yes", "all": true}), diff --git a/crates/engine/src/fx_visibility_cmds.rs b/crates/engine/src/fx_visibility_cmds.rs index bc24d84b18..7911bcd3cc 100644 --- a/crates/engine/src/fx_visibility_cmds.rs +++ b/crates/engine/src/fx_visibility_cmds.rs @@ -161,7 +161,7 @@ mod tests { let before = state(&s, a); for p in [ json!({"layer": bg}), - json!({"layer": 9999}), + json!({"layer": u64::MAX}), json!({"layer": "x"}), json!({"layer": -1}), json!({"layer": a, "index": 2}), diff --git a/crates/engine/src/gradient_fill_cmds.rs b/crates/engine/src/gradient_fill_cmds.rs index 041d311691..356d2252db 100644 --- a/crates/engine/src/gradient_fill_cmds.rs +++ b/crates/engine/src/gradient_fill_cmds.rs @@ -28,9 +28,10 @@ pub const GET: &str = "gradient.fill.get"; /// Largest coordinate accepted for a start/end point (pixels). const MAX_COORD: f32 = 1.0e7; -/// Scale range of a gradient fill (a fraction): Photoshop allows 10–150 % (the PSD stores a -/// percentage). A handle drag past it clamps, so the far handle stops where the scale does. -const SCALE_RANGE: (f32, f32) = (0.1, 1.5); + +/// Scale range of a Gradient Fill layer (a fraction): Photoshop allows 1–1000 %. +/// Gradient Overlay effects have a separate range; the live tool creates fill layers. +pub const SCALE_RANGE: (f32, f32) = (0.01, 10.0); fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } @@ -149,7 +150,7 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } -fn clamp_scale(v: f32) -> f32 { +pub(crate) fn clamp_scale(v: f32) -> f32 { v.clamp(SCALE_RANGE.0, SCALE_RANGE.1) } diff --git a/crates/engine/src/gradient_fill_cmds/tests.rs b/crates/engine/src/gradient_fill_cmds/tests.rs index bb357df050..3c0c2f3c6a 100644 --- a/crates/engine/src/gradient_fill_cmds/tests.rs +++ b/crates/engine/src/gradient_fill_cmds/tests.rs @@ -27,7 +27,7 @@ fn create_makes_a_canvas_aligned_fill_layer_with_the_drag_as_handles() { let r = s .execute( CREATE, - json!({"from": [14, 16], "to": [34, 8], "style": "radial", "stops": [[0, "#ff0000"], [1, "#0000ff"]], "opacity": 50, "mode": "multiply"}), + json!({"from": [14, 16], "to": [200, -40], "style": "radial", "stops": [[0, "#ff0000"], [1, "#0000ff"]], "opacity": 50, "mode": "multiply"}), ) .unwrap(); assert_eq!(layer_count(&s), 2); @@ -42,7 +42,7 @@ fn create_makes_a_canvas_aligned_fill_layer_with_the_drag_as_handles() { assert!(!align && dither); let g = s.execute(GET, json!({})).unwrap(); let (from, to) = (g["from"].as_array().unwrap(), g["to"].as_array().unwrap()); - for (v, want) in from.iter().chain(to).zip([14.0, 16.0, 34.0, 8.0]) { + for (v, want) in from.iter().chain(to).zip([14.0, 16.0, 200.0, -40.0]) { assert!((v.as_f64().unwrap() - want).abs() < 1e-3, "{g}"); } // One undo step removes the layer. @@ -225,11 +225,11 @@ fn stops_add_move_recolour_delete_and_midpoints() { #[test] fn preview_equals_the_committed_edit() { let mut s = session(8, "white"); - s.execute(CREATE, json!({"from": [4, 4], "to": [40, 20], "style": "diamond"})).unwrap(); + s.execute(CREATE, json!({"from": [4, 4], "to": [40, 20], "style": "radial"})).unwrap(); let d = s.active().unwrap(); let l = d.doc.layer(d.active_layer.unwrap()).unwrap().clone(); let LayerContent::Fill(f) = &l.content else { panic!() }; - let p = json!({"from": [10, 12], "to": [30, 2]}); + let p = json!({"from": [10, 12], "to": [150, -80]}); let preview = apply_set(&l, f, d.doc.bounds(), &p, [0.0; 4], [1.0; 4]).unwrap(); s.execute(SET, p).unwrap(); assert_eq!(active_fill(&s), preview); @@ -273,7 +273,7 @@ fn commands_fail_gracefully() { // A zero-length drag still makes a valid gradient. s.execute(CREATE, json!({"from": [3, 3], "to": [3, 3]})).unwrap(); for p in [ - json!({"layer": 999_999}), + json!({"layer": u64::MAX}), json!({"from": [0]}), json!({"to": [1e30, 0]}), json!({"angle": "x"}), @@ -316,12 +316,12 @@ fn commands_fail_gracefully() { s.execute(SET, json!({"scale": 1e30, "angle": 1e9, "offset": [1e4, -1e4]})).unwrap(); let g = s.execute(GET, json!({})).unwrap(); assert!(g["scale"].as_f64().unwrap().is_finite() && g["from"][0].as_f64().unwrap().is_finite()); - // Scale stays within Photoshop's 10–150 %, also for a handle dragged far away. - assert!((g["scale"].as_f64().unwrap() - 150.0).abs() < 1e-3, "{g}"); + // Fill layers use Photoshop's 1–1000 %, also for a handle dragged far away. + assert!((g["scale"].as_f64().unwrap() - 1000.0).abs() < 1e-3, "{g}"); let g = s.execute(SET, json!({"from": [0, 0], "to": [1e6, 0]})).unwrap(); - assert!((g["scale"].as_f64().unwrap() - 150.0).abs() < 1e-3, "{g}"); - let g = s.execute(SET, json!({"scale": 1})).unwrap(); - assert!((g["scale"].as_f64().unwrap() - 10.0).abs() < 1e-3, "{g}"); + assert!((g["scale"].as_f64().unwrap() - 1000.0).abs() < 1e-3, "{g}"); + let g = s.execute(SET, json!({"scale": 0.1})).unwrap(); + assert!((g["scale"].as_f64().unwrap() - 1.0).abs() < 1e-3, "{g}"); } /// Dragging one handle past the scale range stops it; the other handle stays put (#1243). @@ -332,17 +332,19 @@ fn a_clamped_handle_drag_leaves_the_other_handle_alone() { for style in STYLES { for (from, to, short, short_start) in [([14.0, 16.0], [34.0, 16.0], [15.0, 16.0], [33.0, 16.0]), ([24.0, 6.0], [24.0, 26.0], [24.0, 7.0], [24.0, 25.0])] { - let mut s = session(8, "white"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 480, "height": 320})).unwrap(); s.execute(CREATE, json!({"from": from, "to": to, "style": style})).unwrap(); - // End dragged almost onto the start: far below 10 %. + // End dragged almost onto the start: below 1 % on this larger canvas. let g = s.execute(SET, json!({"to": short})).unwrap(); - assert!((g["scale"].as_f64().unwrap() - 10.0).abs() < 1e-3, "{style}: {g}"); + assert!((g["scale"].as_f64().unwrap() - 1.0).abs() < 1e-3, "{style}: {g}"); assert!(close(pt(&g, "from"), from), "{style}: start moved to {:?}", pt(&g, "from")); // The end stops on the line towards the pointer. let e = pt(&g, "to"); assert!((e[0] - from[0]) * (short[0] - from[0]) >= 0.0 && (e[1] - from[1]) * (short[1] - from[1]) >= 0.0, "{style}: {e:?}"); // And the same for the start handle. - let mut s = session(8, "white"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 480, "height": 320})).unwrap(); s.execute(CREATE, json!({"from": from, "to": to, "style": style})).unwrap(); let g = s.execute(SET, json!({"from": short_start})).unwrap(); assert!(close(pt(&g, "to"), to), "{style}: end moved: {g}"); diff --git a/crates/engine/src/group_view_cmds.rs b/crates/engine/src/group_view_cmds.rs index 32a22f5e39..b95acff6a7 100644 --- a/crates/engine/src/group_view_cmds.rs +++ b/crates/engine/src/group_view_cmds.rs @@ -142,7 +142,7 @@ mod tests { let bg = s.active().unwrap().doc.layers[0].id.0; for p in [ json!({"layer": bg}), - json!({"layer": 9999}), + json!({"layer": u64::MAX}), json!({"layer": "x"}), json!({"layer": -1}), json!({"expanded": "yes", "all": true}), diff --git a/crates/engine/src/history_cmds.rs b/crates/engine/src/history_cmds.rs index cc18df581e..c9c91b3d14 100644 --- a/crates/engine/src/history_cmds.rs +++ b/crates/engine/src/history_cmds.rs @@ -8,8 +8,10 @@ //! states: `history.newSnapshot` takes one, `history.selectSnapshot` brings it back as a new, //! undoable state. They live on [`crate::DocState::snapshots`] for the session only, as in //! Photoshop, and are addressed by an id that is never reused. Their tiles are shared -//! copy-on-write with the document and its history, and are not counted in the history's pixel -//! budget. +//! copy-on-write with the document and its history; the tiles only snapshots hold count against +//! the history's pixel budget ([`crate::DocState::trim_history`]), so a Merged Layers snapshot +//! (a full flattened copy) makes room by dropping the oldest states rather than taking memory +//! beyond the budget unseen (#4530). Edit › Purge keeps them. use std::sync::Arc; @@ -451,6 +453,54 @@ mod tests { assert_eq!(s.active().unwrap().doc.layers.len(), 2); } + fn tile_bytes(doc: &Document) -> usize { + doc.layers.iter().filter_map(|l| l.surface()).flat_map(|s| s.tiles().map(|(_, t)| t.bytes().len())).sum() + } + + /// A Merged Layers snapshot is a flattened copy only it holds (#4530): it counts in the + /// document's memory and against the history budget, so the same edits keep one state less. + #[test] + fn merged_snapshot_counts_against_the_history_budget() { + let (mut with, mut without) = (session(), session()); + let base = with.active().unwrap().memory_bytes(); + with.execute("history.newSnapshot", json!({"from": "mergedLayers"})).unwrap(); + let st = with.active().unwrap(); + let flat = tile_bytes(&st.snapshots[0].doc); + assert!(flat > 0); + assert_eq!(st.memory_bytes(), base + flat); + // Room for the document's tiles and four fill tiles. + let cur = tile_bytes(&st.doc); + let fill = tile_bytes(&Document { layers: st.doc.layers[1..].to_vec(), ..(*st.doc).clone() }); + assert!(flat <= fill, "the flattened tile is no bigger than a fill's"); + let budget = cur + 4 * fill; + for s in [&mut with, &mut without] { + s.active_mut().unwrap().history.max_bytes = budget; + for i in 0..6 { + s.execute("edit.fill", json!({"color": format!("#0000{:02x}", 0x40 + i * 0x20)})).unwrap(); + } + } + let held = |s: &Session| s.active().unwrap().history.past_len(); + assert_eq!(held(&without), 4); + assert_eq!(held(&with), 3, "the snapshot's tile takes one state's room"); + assert!(with.active().unwrap().memory_bytes() <= budget); + } + + /// Purge › Histories keeps the snapshots, so it doesn't report the tiles they share with + /// the states as freed (#4530). + #[test] + fn purging_histories_frees_only_what_no_snapshot_keeps() { + let mut s = session(); + s.execute("history.newSnapshot", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + let st = s.active().unwrap(); + // The snapshot's green layer tile, now only in it and the last state. + let shared = tile_bytes(&Document { layers: st.snapshots[0].doc.layers[1..].to_vec(), ..(*st.doc).clone() }); + let all = st.history.unique_bytes(&st.doc); + let r = s.execute("edit.purge.histories", json!({})).unwrap(); + assert_eq!(r["bytes"], json!(all - shared), "the green tile stays with the snapshot"); + assert_eq!(s.active().unwrap().snapshots.len(), 1); + } + #[test] fn rename_and_delete_snapshots_keep_the_other_ids() { let mut s = session(); diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 15a71376b0..6d5342b931 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -16,7 +16,8 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { } /// Applies `f` to every pixel surface of a layer tree: content (raster or -/// cached text/shape/smart pixels), fill caches, and (if `masks`) masks. +/// cached text/shape/smart pixels), fill caches, stored video frames (current and original), +/// and (if `masks`) masks. pub(crate) fn for_each_surface(layers: &mut [Layer], masks: bool, f: &mut dyn FnMut(&mut Surface, bool)) { for l in layers { if masks && let Some(m) = &mut l.mask { @@ -25,6 +26,13 @@ pub(crate) fn for_each_surface(layers: &mut [Layer], masks: bool, f: &mut dyn Fn if let Some(fc) = &mut l.fill_cache { f(&mut fc.surface, false); } + // The displayed content is only a copy of the frame at the playhead; the other frames + // would keep the old geometry and depth and come back stale when scrubbing (#4956). + if let Some(v) = &mut l.video { + for frame in v.frames.iter_mut().chain(v.original_frames.iter_mut().flatten()) { + f(frame, false); + } + } match &mut l.content { LayerContent::Raster(s) => f(s, false), LayerContent::Text(t) => { @@ -54,6 +62,18 @@ pub(crate) fn for_each_surface(layers: &mut [Layer], masks: bool, f: &mut dyn Fn } } +/// Drops every fill cache of a layer tree. A fill layer covers the whole canvas, but its cache +/// (the pixels a PSD stored) only the old one: after the canvas grows the fill must re-render +/// from its model, or the added area stays transparent (#7362). +fn drop_fill_caches(layers: &mut [Layer]) { + for l in layers { + l.fill_cache = None; + if let LayerContent::Group(g) = &mut l.content { + drop_fill_caches(&mut g.children); + } + } +} + /// Converts every pixel surface of a layer tree (masks included) to `depth`. pub(crate) fn convert_layers_depth(layers: &mut [Layer], depth: SampleType) { for_each_surface(layers, true, &mut |surf, _| { @@ -71,11 +91,12 @@ pub(crate) fn for_each_layer(layers: &mut [Layer], f: &mut dyn FnMut(&mut Layer) } } -/// Assign new layer ids to a copied document and update every saved layer-comp reference. +/// Assign new layer ids to a copied document and update every saved layer reference. /// -/// A document copy needs fresh identities, but its comps must still target the copied -/// layers. Deliberately leave references to already-deleted layers untouched so their -/// missing-layer warnings remain meaningful. The same applies to Last Document State. +/// A document copy needs fresh identities, but its comps and variable definitions must +/// still target the copied layers. Deliberately leave references to already-deleted +/// layers untouched so their missing-layer warnings remain meaningful. The same applies +/// to Last Document State. pub(crate) fn reidentify_copied_layers(doc: &mut Document) { let mut ids = std::collections::HashMap::new(); for_each_layer(&mut doc.layers, &mut |layer| { @@ -97,6 +118,16 @@ pub(crate) fn reidentify_copied_layers(doc: &mut Document) { if let Some(last) = &mut doc.last_document_state { remap(last); } + for def in &mut doc.variables.defs { + if let Some(&new_id) = ids.get(&def.layer) { + def.layer = new_id; + } + } + for slice in &mut doc.slices.list { + if let Some(&new_id) = slice.layer.and_then(|layer| ids.get(&layer)) { + slice.layer = Some(new_id); + } + } } /// Scale every size and distance of a style by `k` (Image Size's Scale Styles, Layer Style › @@ -165,6 +196,12 @@ fn image_size(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let (ow, oh) = (d.doc.size.width, d.doc.size.height); let resample = parse_resample(p.get("resample").and_then(Value::as_str).unwrap_or("bicubic")); + // An Indexed Color document holds only its colour table's colours, and a save quantises to + // them: interpolation showed blends that the saved file didn't have (#6019). + let resample = match resample { + Method::Filter(_) | Method::Automatic if d.doc.mode == ColorMode::Indexed => Method::Filter(Resample::Nearest), + m => m, + }; let mut pw = p.get("width").and_then(Value::as_f64); let mut ph = p.get("height").and_then(Value::as_f64); // `percent` scales both sides (what a Photoshop action records as "200%"); an explicit side wins. @@ -331,6 +368,9 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi surf.write_interleaved(keep, &saved); } } + if extends { + drop_fill_caches(&mut doc.layers); + } crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes); Ok(()) } @@ -391,6 +431,9 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result { s.edit("Canvas Size", |doc, _| { translate_doc(doc, "image.canvasSize", dx, dy)?; doc.size = Size::new(nw, nh); + if nw > ow as u32 || nh > oh as u32 { + drop_fill_caches(&mut doc.layers); + } crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes); let canvas = doc.bounds(); let old = Rect::from_xywh(dx, dy, ow as u32, oh as u32); @@ -428,6 +471,12 @@ const MAX_CROP_SIDE: i32 = 300_000; /// Image → Crop (to the selection bounds). fn crop(s: &mut Session, p: &Value) -> Result { let delete = p.get("deleteCroppedPixels").and_then(Value::as_bool).unwrap_or(true); + // The Crop tool's Fill: Content-Aware Fill of what the crop uncovers (#3823). + let content_aware = match p.get("contentAware") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(v) => return Err(bad("image.crop", format!("`contentAware` must be true or false, not {v}"))), + }; // Values beyond i32 would wrap through the narrowing casts into a rectangle unrelated to // the numbers passed; reject them (see `commands::int_i32`). let explicit = match ( @@ -459,7 +508,7 @@ fn crop(s: &mut Session, p: &Value) -> Result { } if let Some(a) = crop_angle(p)? { let rect = explicit.ok_or_else(|| bad("image.crop", "an `angle` needs an explicit `x`, `y`, `width` and `height`"))?; - return rotated_crop(s, rect, a, delete, &target); + return rotated_crop(s, rect, a, delete, content_aware, &target); } // An explicit rectangle (the Crop tool) may extend past the canvas; a selection crop is clamped. let r = explicit @@ -470,8 +519,12 @@ fn crop(s: &mut Session, p: &Value) -> Result { } s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(r.x0), -f64::from(r.y0))); let extension = s.tools.background; - s.edit("Crop", |doc, _| { + s.edit("Crop", |doc, active| { + let original = doc.size; crop_doc(doc, "image.crop", r, delete, extension)?; + if content_aware { + crate::crop_fill::fill_uncovered(doc, *active, &crate::crop_fill::Coverage::crop(r, original.width, original.height))?; + } doc.selection = None; target.apply(doc); Ok(()) @@ -509,8 +562,10 @@ fn crop_target(p: &Value) -> Result { let side = |key: &str| -> Result> { match p.get(key) { None | Some(Value::Null) => Ok(None), + // Whole-number floats (`100.0`) count too, as for the crop rectangle. Some(v) => v .as_u64() + .or_else(|| v.as_f64().filter(|f| f.fract() == 0.0 && (0.0..=f64::from(MAX_CROP_SIDE)).contains(f)).map(|f| f as u64)) .and_then(|n| u32::try_from(n).ok()) .filter(|n| (1..=MAX_CROP_SIDE as u32).contains(n)) .map(Some) @@ -579,8 +634,9 @@ fn crop_angle(p: &Value) -> Result> { /// Arbitrary: the canvas grows to fit, the Background fills what the rotation uncovers with the /// background colour, other layers keep transparency there and every pixel of theirs), which makes /// the frame upright, then cropped to it (`deleteCroppedPixels` as for an unrotated crop), all in -/// one undo step. -fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, crop_target: &CropTarget) -> Result { +/// one undo step. With `content_aware`, the corners the rotation uncovers (and any area past the +/// rotated image) are then filled from their surroundings, in the same step (#3823). +fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_aware: bool, crop_target: &CropTarget) -> Result { const CMD: &str = "image.crop"; let size = s.active().ok_or(EngineError::NoDocument)?.doc.size; let (w, h) = (f64::from(r.width()), f64::from(r.height())); @@ -601,13 +657,41 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, crop_target: let end = |o: i32, len: u32| i32::try_from(len).ok().and_then(|l| o.checked_add(l)); let (Some(x1), Some(y1)) = (end(x, r.width()), end(y, r.height())) else { return Err(far()) }; let target = Rect::new(x, y, x1, y1); + // The cropped canvas back to the original document: undo the crop's offset, then the rotation + // (x = c·(x' − nx) + s·(y' − ny) + cx, y = −s·(x' − nx) + c·(y' − ny) + cy). + let (ox, oy) = (f64::from(target.x0) - f64::from(new.width) / 2.0, f64::from(target.y0) - f64::from(new.height) / 2.0); + let (ocx, ocy) = (f64::from(size.width) / 2.0, f64::from(size.height) / 2.0); + let coverage = crate::crop_fill::Coverage { + map: [cs, sn, -sn, cs, cs * ox + sn * oy + ocx, -sn * ox + cs * oy + ocy], + width: f64::from(size.width), + height: f64::from(size.height), + rotated: true, + }; crate::analysis_cmds::compound(s, "Crop", |s| { + // Pixels the crop deletes needn't be turned first: keeping only what lands in the frame + // makes the rotation cost the frame's size, not the canvas's (a small turned crop of a + // 10 MP photo took over half a second). + if delete { + let keep = turned_crop_source(size, new, angle, target); + s.edit("Crop", |doc, _| { + for_each_surface(&mut doc.layers, true, &mut |surf, _| *surf = crop_surface(surf, keep)); + for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { + ch.surface = crop_surface(&ch.surface, keep); + } + // The crop drops the selection anyway. + doc.selection = None; + Ok(()) + })?; + } crate::mode_cmds::rotate_arbitrary(s, &json!({"angle": angle, "direction": "ccw"}))?; // Color Sampler points follow the crop's origin shift too (the rotation moved them above). s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(target.x0), -f64::from(target.y0))); let extension = s.tools.background; - s.edit("Crop", |doc, _| { + s.edit("Crop", |doc, active| { crop_doc(doc, CMD, target, delete, extension)?; + if content_aware { + crate::crop_fill::fill_uncovered(doc, *active, &coverage)?; + } doc.selection = None; crop_target.apply(doc); Ok(()) @@ -616,6 +700,26 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, crop_target: Ok(crop_result(s, json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "angle": angle }))) } +/// The pixels a turned crop reads, in document coordinates before the turn: the source of the +/// `target` frame (on the `new` canvas the rotation by `-angle` makes of `old`), grown by the widest +/// interpolation kernel's reach so every sample the frame's pixels take is kept. +fn turned_crop_source(old: Size, new: Size, angle: f64, target: Rect) -> Rect { + const KERNEL_REACH: f64 = 4.0; + // The inverse of `rotate_arbitrary`'s map: x = c·u + s·v + cx, y = −s·u + c·v + cy, where + // (u, v) is the point relative to the new canvas's centre. + let (sn, cs) = (-angle).to_radians().sin_cos(); + let (cx, cy) = (f64::from(old.width) / 2.0, f64::from(old.height) / 2.0); + let (nx, ny) = (f64::from(new.width) / 2.0, f64::from(new.height) / 2.0); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for (px, py) in [(target.x0, target.y0), (target.x1, target.y0), (target.x0, target.y1), (target.x1, target.y1)] { + let (u, v) = (f64::from(px) - nx, f64::from(py) - ny); + let (x, y) = (cs * u + sn * v + cx, -sn * u + cs * v + cy); + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + } + // `as` saturates: a frame near the coordinate limits keeps more, never less. + Rect::new((x0 - KERNEL_REACH).floor() as i32, (y0 - KERNEL_REACH).floor() as i32, (x1 + KERNEL_REACH).ceil() as i32, (y1 + KERNEL_REACH).ceil() as i32) +} + /// Image → Trim. fn trim(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; @@ -711,6 +815,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { doc.layer_comps.clear(); doc.last_applied_comp = None; doc.last_document_state = None; + // Likewise, variables drove template layers that no longer exist: drop the whole + // binding set (defs, data sets and the active index) on the flattened copy. + doc.variables = photocraft_doc::Variables::default(); } reidentify_copied_layers(&mut doc); let name = doc.name.clone(); @@ -747,7 +854,7 @@ pub fn specs() -> Vec { "image.crop", "Crop", ["Image"], - r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi)"##, + r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; contentAware: the Crop tool's Fill › Content-Aware Fill, the area a rotated or extending crop uncovers filled from its surroundings on the Background, else the active pixel layer)"##, has_doc, crop ), @@ -874,6 +981,28 @@ mod tests { &s.active().unwrap().doc } + /// Document-wide conversions must move every stored video frame, not just the displayed one (#4956). + #[test] + fn crop_moves_stored_video_frames() { + let mut s = session(); + s.edit("video", |doc, active| { + let l = doc.layer_mut(active.unwrap()).unwrap(); + let frame = l.surface().unwrap().clone(); + let mut v = photocraft_doc::VideoData::new(vec![frame.clone(), frame.clone()], 24.0); + v.original_frames = Some(vec![frame.clone(), frame]); + l.video = Some(v); + Ok(()) + }) + .unwrap(); + s.execute("image.crop", json!({"x": -10, "y": -5, "width": 60, "height": 30})).unwrap(); + let v = doc(&s).layers[1].video.as_ref().unwrap(); + let red = vec![1.0, 0.0, 0.0, 1.0]; + for frame in v.frames.iter().chain(v.original_frames.iter().flatten()) { + assert_eq!(frame.pixel(20, 10), red); + assert_eq!(frame.pixel(5, 5)[3], 0.0); + } + } + /// Resample on with only a new resolution keeps the print size, as Photoshop's Image Size /// does (an action's `imageSize` with `Rslt` alone); without `keepPrintSize` only the /// resolution changes. @@ -905,6 +1034,28 @@ mod tests { } } + /// #6019: Image Size on an Indexed Color document makes no colours the colour table doesn't have. + #[test] + fn image_size_resamples_indexed_color_with_nearest_neighbor() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 1})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.rect", json!({"x": 1, "y": 0, "width": 1, "height": 1})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("image.mode.indexedColor", json!({})).unwrap(); + for resample in ["bicubic", "automatic", "bilinear", "lanczos", "preserveDetails"] { + s.execute("image.imageSize", json!({"width": 8, "resample": resample})).unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layers[0].surface().unwrap(); + for x in 0..8 { + let px = surf.pixel(x, 0); + assert!(px[..3] == [1.0, 0.0, 0.0] || px[..3] == [0.0, 0.0, 1.0], "{resample}: no blended colour at x {x}: {px:?}"); + } + s.execute("history.undo", json!({})).unwrap(); + } + } + /// The canvas border stays opaque after Image Size (it used to fade into transparency, so a /// Background or a 200 % export got a translucent frame), and a full selection stays full. /// #1544: 64 x 48 to 300000 x 300000 (90 billion pixels) started allocating resampled bands @@ -1179,7 +1330,10 @@ mod tests { for y in 0..20 { for x in 0..10 { let (a, b) = (layer_rgba(&s, l, x, y), layer_rgba(&reference, l, x, y)); - assert!(a.iter().zip(b).all(|(p, q)| (p - q).abs() < 1e-3), "{depth} layer {l} ({x}, {y}): {a:?} vs {b:?}"); + // A fully transparent pixel's colour is invisible, and the turned crop + // doesn't turn the pixels it deletes, which the warp spreads colour from. + let n = if b[3] > 1e-3 { 4 } else { 1 }; + assert!(a.iter().rev().zip(b.iter().rev()).take(n).all(|(p, q)| (p - q).abs() < 1e-3), "{depth} layer {l} ({x}, {y}): {a:?} vs {b:?}"); } } } @@ -1220,6 +1374,61 @@ mod tests { } } + /// A turned crop that deletes the cropped pixels turns only what lands in the frame; inside the + /// new canvas every layer, mask and channel is exactly what turning everything gives (the + /// frame spans several tiles, at several depths and angles). + #[test] + fn turned_crop_deleting_pixels_matches_turning_the_whole_canvas() { + use photocraft_color::PixelFormat; + let pixels = || (0..500).flat_map(|y| (0..700).map(move |x| (x, y))); + for depth in [8, 16, 32] { + for angle in [3.0, -7.5, 33.0, 181.0] { + let mut sessions = [true, false].map(|delete| { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 700, "height": 500, "depth": depth, "background": "white"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let wave = |x: i32, y: i32, k: i32| ((x * 7 + y * 13 + k * 31) % 97) as f32 / 96.0; + let all = Rect::new(0, 0, 700, 500); + let rgba: Vec = pixels().flat_map(|(x, y)| [wave(x, y, 0), wave(x, y, 1), wave(x, y, 2), wave(y, x, 3)]).collect(); + let gray: Vec = pixels().map(|(x, y)| wave(y, x, 4)).collect(); + let l = doc.layer_mut(active.unwrap()).unwrap(); + l.surface_mut().unwrap().write_region(all, &rgba); + let mut mask = photocraft_doc::LayerMask::reveal_all(); + mask.surface.write_region(all, &gray); + l.mask = Some(mask); + let bg = doc.background_mut().unwrap().surface_mut().unwrap(); + bg.write_region(all, &pixels().flat_map(|(x, y)| [wave(x, y, 5), wave(x, y, 6), wave(x, y, 7), 1.0]).collect::>()); + let fmt = PixelFormat::new(ColorMode::Grayscale, doc.pixel_format().sample, false); + let mut ch = Surface::new(fmt); + ch.write_region(all, &gray); + doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", ch)); + Ok(()) + }) + .unwrap(); + let p = json!({"x": 150, "y": 120, "width": 330, "height": 290, "angle": angle, "deleteCroppedPixels": delete}); + s.execute("image.crop", p).unwrap(); + s + }); + let [deleted, kept] = &mut sessions; + let (a, b) = (doc(deleted), doc(kept)); + assert_eq!(a.size, b.size, "{depth} {angle}"); + let canvas = a.bounds(); + for (la, lb) in a.layers.iter().zip(&b.layers) { + let (sa, sb) = (la.surface().unwrap(), lb.surface().unwrap()); + assert!(sa.to_interleaved(canvas) == sb.to_interleaved(canvas), "{depth} {angle}: layer {}", la.name); + if let (Some(ma), Some(mb)) = (&la.mask, &lb.mask) { + assert!(ma.surface.to_interleaved(canvas) == mb.surface.to_interleaved(canvas), "{depth} {angle}: mask of {}", la.name); + } + } + assert!(a.channels[0].surface.to_interleaved(canvas) == b.channels[0].surface.to_interleaved(canvas), "{depth} {angle}: alpha channel"); + // Deleting keeps nothing past the canvas. + let layer = a.layers[1].surface().unwrap(); + assert!(canvas.contains_rect(&layer.content_bounds()), "{depth} {angle}: {:?}", layer.content_bounds()); + } + } + } + /// Angle 0 (or a whole turn) is the plain crop: no resampling, the same document. #[test] fn crop_at_angle_zero_is_the_plain_crop() { @@ -1339,6 +1548,14 @@ mod tests { assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(30, 10), 150.0)); } + #[test] + fn crop_target_accepts_whole_number_floats() { + // `json.dumps(100.0)` and many other JSON writers send whole numbers as floats. + let mut s = session(); + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 20, "height": 10, "targetWidth": 40.0, "targetHeight": 20.0})).unwrap(); + assert_eq!(doc(&s).size, Size::new(40, 20)); + } + #[test] fn crop_target_rejects_bad_params_without_changing_the_document() { let rect = json!({"x": 0, "y": 0, "width": 10, "height": 10}); @@ -1348,6 +1565,10 @@ mod tests { json!({"targetWidth": 0, "targetHeight": 10}), json!({"targetWidth": -5, "targetHeight": 10}), json!({"targetWidth": 10.5, "targetHeight": 10}), + json!({"targetWidth": -1.0, "targetHeight": 10}), + json!({"targetWidth": 0.0, "targetHeight": 10}), + json!({"targetWidth": 1e39, "targetHeight": 10}), + json!({"targetWidth": 300_001.0, "targetHeight": 10}), json!({"targetWidth": "10", "targetHeight": 10}), json!({"targetWidth": 300_001, "targetHeight": 10}), json!({"targetWidth": 4_294_967_306_u64, "targetHeight": 10}), diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 22c3d3a96f..6068503aec 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -1,6 +1,8 @@ //! JSON views of engine state for automation, tests and debugging. -use photocraft_doc::{Layer, LayerContent, SmartSource}; +use std::collections::HashMap; + +use photocraft_doc::{Layer, LayerContent, SmartContentsId, SmartSource}; use serde_json::{Value, json}; use crate::{DocState, Session}; @@ -25,6 +27,7 @@ pub fn session(s: &Session) -> Value { pub fn document(d: &DocState) -> Value { let doc = &d.doc; let selected = d.selected_layers(); + let instances = smart_instances(&doc.layers); json!({ "name": doc.name, "path": &d.path, @@ -38,7 +41,7 @@ pub fn document(d: &DocState) -> Value { "isolatedLayers": d.isolated_layers.iter().map(|l| l.0).collect::>(), "hasSelection": doc.selection.is_some(), "selectionBounds": doc.selection.as_ref().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] }), - "layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected, Some(&doc.metadata))).collect::>(), + "layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected, &instances, Some(&doc.metadata))).collect::>(), "history": d.history.entries(), "snapshots": d.snapshots.iter().map(|snap| json!({"id": snap.id, "name": snap.name})).collect::>(), "canUndo": d.history.can_undo(), @@ -61,14 +64,27 @@ pub fn document(d: &DocState) -> Value { }) } -/// Layer tree top-to-bottom (display order). +/// Layer tree top-to-bottom (display order). Smart object instances are counted within `l`. pub fn layer(l: &Layer) -> Value { - layer_sel(l, &[], None) + layer_sel(l, &[], &smart_instances(std::slice::from_ref(l)), None) } -/// [`layer`] with a `selected` flag on every node (Layers panel multi-selection); with the -/// document's `meta`, a smart object whose file is embedded in the PSD says so. -fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], meta: Option<&photocraft_doc::Metadata>) -> Value { +/// How many smart objects in `layers`, groups included, share each contents identity. +fn smart_instances(layers: &[Layer]) -> HashMap { + let mut counts = HashMap::new(); + let mut pending: Vec<&Layer> = layers.iter().collect(); + while let Some(l) = pending.pop() { + match &l.content { + LayerContent::Smart(sm) => *counts.entry(sm.contents_id).or_default() += 1, + LayerContent::Group(g) => pending.extend(&g.children), + _ => {} + } + } + counts +} + +/// [`layer`] with a `selected` flag on every node (Layers panel multi-selection). +fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], instances: &HashMap, meta: Option<&photocraft_doc::Metadata>) -> Value { let bounds = l.surface().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] @@ -92,7 +108,7 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], meta: Option<&phot }); match &l.content { LayerContent::Group(g) => { - v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected, meta)).collect()); + v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected, instances, meta)).collect()); v["expanded"] = json!(g.expanded); if let Some(a) = &g.artboard { v["artboard"] = json!({"rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()], "background": a.background.name(), "preset": a.preset}); @@ -122,6 +138,9 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], meta: Option<&phot None => json!({"kind": "linked", "path": path}), }, }; + // Copies of the same contents change together (Edit and Replace Contents), and + // `smartSource` can't tell them from independent copies of one file (#4075). + v["smartContents"] = json!({"id": sm.contents_id.0, "instances": instances.get(&sm.contents_id).copied().unwrap_or(1)}); } LayerContent::Text(t) => { v["text"] = json!({"text": t.text, "font": t.font_family, "sizePt": t.size_pt}); diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index dc43f4d794..0ee0573170 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -29,6 +29,43 @@ use serde_json::{Value, json}; use crate::{EngineError, Result, Session}; +type Authorization = fn(&str, &Value) -> Result<()>; + +thread_local! { + // Smart-filter rendering has no Session argument. Carry the requesting session's gate + // through synchronous rendering and explicitly reinstall it on background workers. + static RENDER_AUTHORIZATION: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +pub(crate) struct AuthorizationScope(Option); + +impl AuthorizationScope { + fn enter(gate: Option) -> Self { + Self(RENDER_AUTHORIZATION.with(|current| current.replace(gate.or(current.get())))) + } +} + +impl Drop for AuthorizationScope { + fn drop(&mut self) { + RENDER_AUTHORIZATION.with(|current| current.set(self.0)); + } +} + +/// [`AuthorizationScope`] entered with `gate` (or the one already installed, so trusted work on +/// this thread keeps ambient paths usable). [`Session::edit`] and the job paths use this to carry +/// the requesting session's authority into every smart-object re-render. +pub(crate) fn render_authorization_scope(gate: Option) -> AuthorizationScope { + AuthorizationScope::enter(gate) +} + +/// Recheck a stored filter's command and parameters before evaluating it or using its cache. +pub(crate) fn authorize_render_command(id: &str, params: &Value) -> Result<()> { + match RENDER_AUTHORIZATION.with(std::cell::Cell::get) { + Some(gate) => gate(id, params), + None => Ok(()), + } +} + /// Identifies one background job for the session's lifetime. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, serde::Deserialize)] pub struct JobId(pub u64); @@ -182,12 +219,36 @@ struct Running { started_ms: f64, coalesce: Option, color_restrict: Option, + /// The document's layer selection when the job started: applying targets it, not whatever + /// got selected meanwhile (a Type click does that without editing the document, #7366). + layers: Option, rx: std::sync::mpsc::Receiver>, apply: Option, #[cfg(not(target_arch = "wasm32"))] handle: Option>, } +/// A document's active layer, selected layers and selection anchor. +#[derive(Clone, PartialEq)] +struct LayerSelection { + active: Option, + selected: Vec, + anchor: Option, +} + +impl LayerSelection { + fn of(st: &crate::DocState) -> Self { + Self { active: st.active_layer, selected: st.selected_layers.clone(), anchor: st.layer_anchor } + } + + fn set(self, st: &mut crate::DocState) { + st.active_layer = self.active; + st.selected_layers = self.selected; + st.layer_anchor = self.anchor; + crate::channel_cmds::fix_view(st); + } +} + impl Running { fn info(&self) -> JobInfo { JobInfo { @@ -284,6 +345,11 @@ pub fn run( work: impl FnOnce(&JobCtx) -> Result + Send + 'static, apply: impl FnOnce(&mut Session, T) -> Result + Send + 'static, ) -> Result { + let authorization = s.authorize.or_else(|| RENDER_AUTHORIZATION.with(std::cell::Cell::get)); + let _authorization = AuthorizationScope::enter(authorization); + // 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); @@ -291,14 +357,17 @@ pub fn run( if s.jobs.pending.is_some() { return Err(EngineError::Other(format!("`{label}` started a second background job in one command"))); } - let (doc, snapshot) = match (lock_document, s.active()) { - (true, Some(st)) => (Some(st.doc.id), Some(Arc::downgrade(&st.doc))), + let (doc, snapshot, layers) = match (lock_document, s.active()) { + (true, Some(st)) => (Some(st.doc.id), Some(Arc::downgrade(&st.doc)), Some(LayerSelection::of(st))), (true, None) => return Err(EngineError::NoDocument), - (false, _) => (None, None), + (false, _) => (None, None, None), }; 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) }); @@ -311,6 +380,8 @@ pub fn run( // The work closure is moved into the thread. If spawning fails (thread limits), there is // no way to get it back, so the error is reported instead. let spawned = std::thread::Builder::new().name(format!("photocraft-job-{}", id.0)).spawn(move || { + let _authorization = AuthorizationScope::enter(authorization); + let _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. @@ -335,6 +406,7 @@ pub fn run( started_ms: now_ms(), coalesce: s.coalesce_request.clone(), color_restrict: s.color_restrict, + layers, rx, apply: Some(erased), #[cfg(not(target_arch = "wasm32"))] @@ -382,6 +454,24 @@ pub fn edit_job( ) } +/// Scan every layer surface's content bounds into the shared tile bounds cache, so the first +/// draw of a document opened in the background doesn't do it on the UI thread: GPU planning +/// asks each layer for its bounds, a multi-second scan on a multi-gigabyte PSB (#4024). +/// `check` runs between layers (cancellation). +pub fn warm_layer_bounds(doc: &Document, mut check: impl FnMut() -> Result<()>) -> Result<()> { + let mut pending: Vec<&photocraft_doc::Layer> = doc.layers.iter().collect(); + while let Some(layer) = pending.pop() { + check()?; + if let Some(children) = layer.children() { + pending.extend(children); + } + if let Some(surface) = layer.surface() { + photocraft_compose::bounds::content_bounds(surface); + } + } + Ok(()) +} + /// Where a background open reads its file from. #[derive(Clone, Debug)] pub enum OpenSource { @@ -434,13 +524,19 @@ impl Session { }; ctx.check()?; ctx.progress(0.02, "Decoding"); - ctx.stage(0.02, 1.0, "Decoding", |ctl| photocraft_io::import_with_svg_group_depth_and_interrupt(&name_w, &bytes, max_svg_group_depth, ctl)) + let imported = ctx + .stage(0.02, 0.95, "Decoding", |ctl| photocraft_io::import_with_svg_group_depth_and_interrupt(&name_w, &bytes, max_svg_group_depth, ctl)) .map_err(|e| match e { photocraft_io::IoError::Cancelled => EngineError::Cancelled, e => EngineError::Other(e.to_string()), - }) + })?; + ctx.progress(0.95, "Preparing layers"); + warm_layer_bounds(&imported.document, || ctx.check())?; + Ok(imported) }, - move |s, r: photocraft_io::ImportResult| { + move |s, mut r: photocraft_io::ImportResult| { + // File Handling's ACEScg option, before the colour prompt looks at the profile. + crate::file_cmds::tag_exr_colorspace(s, &name_a, &mut r.document); // Ask When Opening: held back until the prompt is answered (`take_held_open`). A // developed raw has no embedded profile to ask about. let raw = r.warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)); @@ -480,7 +576,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; @@ -630,6 +726,8 @@ impl Session { if let Some(gate) = self.authorize { 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 @@ -649,17 +747,7 @@ impl Session { return Ok(Started::Done(v)); } // Pixel commands follow the Channels panel target unless the caller names one. - let run_params = crate::channel_cmds::inject_target(self, id, crate::commands::inject_kind(id, params.clone())); - if let Err(why) = self.explicit_layer_enabled(spec, ¶ms).unwrap_or_else(|| self.precondition(spec, &run_params)) { - return Err(EngineError::Disabled(id.to_string(), why)); - } - if let Some(why) = self.job_conflict(id, spec.journal) { - return Err(EngineError::Disabled(id.to_string(), why)); - } - // Painting on or moving a hidden layer is refused, as in Photoshop (#571). - if let Some(why) = crate::hidden_target::refusal(self, id, ¶ms) { - return Err(EngineError::Other(why.into())); - } + let run_params = self.admit(id, spec, ¶ms)?; self.coalesce_request = params.get("coalesce").and_then(Value::as_str).map(str::to_string); self.color_restrict = crate::channel_cmds::color_restriction(self, id, &run_params); self.jobs.spawn = background; @@ -669,6 +757,7 @@ impl Session { // action it plays (a script step) must not flush the steps it ran so far into the action // being recorded, nor move the recording mark past the entries it is about to replace. let recording = if spec.journal && crate::actions_cmds::replayable(id) { self.actions.recording.take() } else { None }; + let before = self.active().map(|st| st.doc.clone()); // Last-resort guard (AGENTS.md, Never crash): a command that panics anyway fails with an // error instead of taking the app down. `edit` only commits a document after its closure // returns, so the documents are unchanged. @@ -686,7 +775,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; @@ -696,12 +785,40 @@ impl Session { Ok(Started::Job(jid)) } (Ok(v), None) => { + if let Some(before) = before { + self.add_relayout_damage(&before); + } self.after_command(id, params, spec.journal, Some(nested_from)); Ok(Started::Done(v)) } } } + /// Grows the damage a command reported for the pixels it changed by what effects laid out + /// over a layer's frame repaint when it moved ([`photocraft_compose::relayout_damage`]): a + /// stroke on a layer with a Gradient Overlay reports the stroke, but the gradient is laid out + /// again over the whole grown layer (#4053). `before` is the active document before. + fn add_relayout_damage(&mut self, before: &Arc) { + let Some(st) = self.active_mut().filter(|st| st.doc.id == before.id && !Arc::ptr_eq(&st.doc, before)) else { return }; + let Some(d) = st.last_damage.filter(|d| !d.is_empty()) else { return }; + let extra = photocraft_compose::relayout_damage(before, &st.doc); + if !extra.is_empty() { + st.last_damage = Some(d.union(&extra)); + } + } + + /// 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 @@ -724,6 +841,12 @@ impl Session { self.journal.truncate(from); } if !crate::brush_cmds::coalesce_journal(self, id, ¶ms) { + let params = match id { + _ if self.actions.recording.is_none() => params, + "layer.select" => crate::layer_multi_cmds::recorded_selection(self, params), + "layerComp.apply" => crate::comps_cmds::recorded_apply(self, params), + _ => params, + }; self.journal.push((id.to_string(), params)); } } @@ -745,7 +868,7 @@ impl Session { } fn apply_job(&mut self, job: &Running, apply: Option, payload: Payload) -> Result { - let Running { label, doc, snapshot, coalesce, color_restrict, command, params, journal, .. } = job; + let Running { label, doc, snapshot, coalesce, color_restrict, layers, command, params, journal, .. } = job; let prev_active = self.active; let target = match doc { Some(d) => { @@ -760,6 +883,19 @@ impl Session { } None => None, }; + // Selecting a layer doesn't change the document, so the snapshot check above lets it + // through. Apply against the layers the job started on: the colour restriction and any + // `active`-based edit land on them (#7366). The user's selection is put back below. + let user_layers = match (target, layers) { + (Some(i), Some(started)) => self.docs.get_mut(i).and_then(|st| { + let now = LayerSelection::of(st); + (now != *started).then(|| { + started.clone().set(st); + now + }) + }), + _ => None, + }; self.coalesce_request = coalesce.clone(); self.color_restrict = *color_restrict; // Apply-time hooks can dispatch commands too. Keep the same single-step recording @@ -782,6 +918,15 @@ impl Session { if r.is_ok() { self.after_command(command, params.clone(), *journal, Some(nested_from)); } + // Give the user back the layers they selected while the job ran, unless the job chose a + // selection itself (a new layer, say) or one of their layers is gone. + if let (Some(started), Some(user), Some(st)) = (layers, user_layers, target.and_then(|i| self.docs.get_mut(i))) { + let kept = LayerSelection::of(st) == *started; + let exist = user.active.iter().chain(&user.selected).all(|id| st.doc.layer(*id).is_some()); + if (r.is_err() || kept) && exist { + user.set(st); + } + } // Keep the user's active document unless the job opened a new one. // This also restores the selection when the apply step returned an error. if target.is_some() && self.active == target { diff --git a/crates/engine/src/jobs_tests.rs b/crates/engine/src/jobs_tests.rs index 0126922818..2bcdda76fc 100644 --- a/crates/engine/src/jobs_tests.rs +++ b/crates/engine/src/jobs_tests.rs @@ -490,6 +490,32 @@ fn an_open_that_asks_about_its_profile_holds_the_document_back() { assert_eq!(s.documents().len(), 1); } +#[test] +fn warm_layer_bounds_visits_every_layer_and_can_be_cancelled() { + let mut s = session(40, 30); + s.execute("layer.groupLayers", json!({})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + fn count(layers: &[photocraft_doc::Layer]) -> usize { + layers.iter().map(|l| 1 + l.children().map_or(0, count)).sum() + } + assert!(doc.layers.iter().any(|l| l.children().is_some_and(|c| !c.is_empty())), "a group with a layer in it"); + let mut checks = 0; + warm_layer_bounds(&doc, || { + checks += 1; + Ok(()) + }) + .unwrap(); + assert_eq!(checks, count(&doc.layers), "one check per layer, nested ones included"); + let mut checks = 0; + let r = warm_layer_bounds(&doc, || { + checks += 1; + if checks > 1 { Err(EngineError::Cancelled) } else { Ok(()) } + }); + assert!(matches!(r, Err(EngineError::Cancelled))); + assert_eq!(checks, 2, "stops at the first failed check"); +} + #[test] fn wait_job_blocks_until_applied() { let mut s = session(400, 300); diff --git a/crates/engine/src/kys.rs b/crates/engine/src/kys.rs index e3343ed1cd..6a0ff99c84 100644 --- a/crates/engine/src/kys.rs +++ b/crates/engine/src/kys.rs @@ -9,10 +9,14 @@ //! Where one label sits in several menus (Levels… under Image › Adjustments and Layer › New //! Adjustment Layer; All under Select, Edit › Purge, View › Show), the command whose default is //! one of the imported keys wins, then the one that has a default at all, then the first -//! candidate (the app lists them in Photoshop's menu order). Ours are one per command, so a -//! row's first shortcut is the one kept, unless one of its keys is already the command's -//! default (Zoom In lists `Cmd++` before `Cmd+=`). Tool keys are not imported: the tool -//! letters are fixed here. +//! candidate (the app lists them in Photoshop's menu order). A row's first shortcut becomes the +//! command's main key, unless one of its keys is already the command's default (Zoom In lists +//! `Cmd++` before `Cmd+=`); its other keys become the command's alternates (#3665: Copy is +//! `Cmd+C` and `F3`, and importing it must not leave `Cmd+C` unbound). Tool keys match our +//! tools by name ([`plan_tools`]; the app knows the tools). A set saved on Windows spells the +//! primary modifier Ctrl where a Mac set says Cmd ([`KysSet::windows`], [`to_ours`]). +//! [`write`] saves a set the same way, with our command id on each row (`pc-id`, which +//! Photoshop ignores), so PhotoCraft reads its own sets back exactly. use std::collections::{BTreeMap, HashMap}; @@ -22,10 +26,12 @@ use crate::prefs::normalize_shortcut; pub const MAX_BYTES: usize = 4 << 20; /// One `` row: Photoshop's label and its shortcuts, first one primary. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct KysCommand { pub name: String, pub shortcuts: Vec, + /// Our command id, in sets PhotoCraft wrote ([`write`]'s `pc-id`): matched before the label. + pub id: Option, } /// The parsed set: its name, its command rows and how many tools carry a key. @@ -36,6 +42,11 @@ pub struct KysSet { pub name: Option, pub commands: Vec, pub tool_keys: usize, + /// The `` rows with a key: the tool's name, its key, our id in sets PhotoCraft wrote. + pub tools: Vec, + /// Saved by Photoshop on Windows: no key spells ⌘ or Opt, and Ctrl is the primary modifier, + /// so `Ctrl` here is our `Cmd` (a Mac set's `Control` stays the Control key). See [`to_ours`]. + pub windows: bool, } /// Parse a `.kys` file (or Photoshop's live `Keyboard Shortcuts.psp`, the same XML). Rows @@ -62,16 +73,62 @@ pub fn parse(text: &str) -> Result { .filter_map(|n| n.text().map(str::trim).filter(|t| !t.is_empty()).map(str::to_string)) .collect(); if !shortcuts.is_empty() { - set.commands.push(KysCommand { name: name.to_string(), shortcuts }); + let id = node.attribute(PC_ID).map(str::trim).filter(|i| !i.is_empty()).map(str::to_string); + set.commands.push(KysCommand { name: name.to_string(), shortcuts, id }); + } + } + "tool" => { + let key = node.text().map(str::trim).filter(|t| !t.is_empty()); + let name = node.attribute("name").map(str::trim).filter(|n| !n.is_empty()); + if let Some(key) = key { + set.tool_keys += 1; + if let Some(name) = name { + let id = node.attribute(PC_ID).map(str::trim).filter(|i| !i.is_empty()).map(str::to_string); + set.tools.push(KysCommand { name: name.to_string(), shortcuts: vec![key.to_string()], id }); + } } } - "tool" if node.text().is_some_and(|t| !t.trim().is_empty()) => set.tool_keys += 1, _ => {} } } + set.windows = windows_set(&set); Ok(set) } +/// The modifier words of `sc` (`Ctrl+Alt+K` → Ctrl, Alt), whatever the key is. +fn modifiers(sc: &str) -> impl Iterator + '_ { + let mods = sc.strip_suffix("++").unwrap_or_else(|| sc.rsplit_once('+').map_or("", |(m, _)| m)); + mods.split('+').filter(|m| !m.is_empty()).map(|m| m.trim().to_ascii_lowercase()) +} + +/// Photoshop on Windows writes Ctrl for the primary modifier, where a Mac set writes Cmd (and +/// Control for the Control key): a set with no ⌘ or Opt anywhere but some Ctrl is a Windows set. +fn windows_set(set: &KysSet) -> bool { + let keys = || set.commands.iter().flat_map(|c| c.shortcuts.iter()); + let mac = keys().flat_map(|k| modifiers(k).collect::>()).any(|m| matches!(m.as_str(), "cmd" | "command" | "opt" | "option")); + let ctrl = keys().flat_map(|k| modifiers(k).collect::>()).any(|m| matches!(m.as_str(), "ctrl" | "control")); + !mac && ctrl +} + +/// A key of `set` in our notation, normalised: on a Windows set, Ctrl (or Control) is our `Cmd` +/// (Ctrl on Windows and Linux, ⌘ on the Mac), so `Ctrl+L` is the `Cmd+L` default rather than a +/// literal Control key. +pub fn to_ours(set: &KysSet, sc: &str) -> Option { + if !set.windows { + return normalize_shortcut(sc); + } + let (mods, key) = match sc.strip_suffix("++") { + Some(m) => (m, "+"), + None => sc.rsplit_once('+').unwrap_or(("", sc)), + }; + let mods: Vec<&str> = mods + .split('+') + .filter(|m| !m.is_empty()) + .map(|m| if matches!(m.trim().to_ascii_lowercase().as_str(), "ctrl" | "control") { "Cmd" } else { m }) + .collect(); + normalize_shortcut(&mods.into_iter().chain(std::iter::once(key)).collect::>().join("+")) +} + /// What an import would change. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Plan { @@ -81,8 +138,12 @@ pub struct Plan { pub unknown: Vec, /// Rows whose shortcut could not be read (`unknown` does not list them). pub unreadable: Vec, - /// Second and later shortcuts of a row, which have no place here. + /// Second and later shortcuts of the matched rows (counted; kept in `alternate_keys`). pub alternates: usize, + /// Command id → the matched row's other keys (normalised, without the main key in `set`), + /// for every matched row: a set lists each command's whole binding, so an empty list + /// means the command has no alternates in it. + pub alternate_keys: BTreeMap>, } /// One command a row may match: its id, its label and its default shortcut. @@ -99,12 +160,14 @@ fn key(label: &str) -> String { l.trim_end_matches('…').trim().to_lowercase() } -/// The engine's own candidates: every command in a menu, in command order. +/// The engine's own candidates: every command in a menu (the engine's, then the shell's), in +/// command order. pub fn command_candidates() -> Vec { - crate::command_specs() - .iter() - .filter(|c| !c.menu.is_empty()) - .map(|c| Candidate { id: c.id.to_string(), label: c.label.to_string(), default: c.shortcut.map(str::to_string) }) + let engine = crate::command_specs().iter().map(|c| (c.id, c.label, c.menu, c.shortcut)); + engine + .chain(crate::shell_commands::SHELL_COMMANDS.iter().copied()) + .filter(|c| !c.2.is_empty()) + .map(|(id, label, _, default)| Candidate { id: id.to_string(), label: label.to_string(), default: default.map(str::to_string) }) .collect() } @@ -119,18 +182,21 @@ pub fn plan(candidates: impl IntoIterator, set: &KysSet) -> Pl entry.push((c.id, c.default)); } } + let by_id: HashMap<&str, &(String, Option)> = by_label.values().flatten().map(|c| (c.0.as_str(), c)).collect(); let mut plan = Plan::default(); for row in &set.commands { - let Some(candidates) = by_label.get(&key(&row.name)) else { + // A set PhotoCraft wrote names the command itself, whatever its label says. + let own = row.id.as_deref().and_then(|id| by_id.get(id)).map(|c| vec![(*c).clone()]); + let Some(candidates) = own.as_ref().or_else(|| by_label.get(&key(&row.name))) else { plan.unknown.push(row.name.clone()); continue; }; - let Some(sc) = row.shortcuts.first().and_then(|s| normalize_shortcut(s)) else { + let Some(sc) = row.shortcuts.first().and_then(|s| to_ours(set, s)) else { plan.unreadable.push(row.name.clone()); continue; }; plan.alternates += row.shortcuts.len().saturating_sub(1); - let imported: Vec = row.shortcuts.iter().filter_map(|s| normalize_shortcut(s)).collect(); + let imported: Vec = row.shortcuts.iter().filter_map(|s| to_ours(set, s)).collect(); let pick = candidates .iter() .find(|(_, d)| d.as_deref().and_then(normalize_shortcut).is_some_and(|d| imported.contains(&d))) @@ -141,12 +207,104 @@ pub fn plan(candidates: impl IntoIterator, set: &KysSet) -> Pl // (Zoom In is "Cmd++" then "Cmd+=" in Photoshop; ours is Cmd+=). let default = default.as_deref().and_then(normalize_shortcut); let sc = default.filter(|d| imported.contains(d)).unwrap_or(sc); + let mut rest: Vec = Vec::new(); + for k in imported { + if k != sc && !rest.contains(&k) && rest.len() < crate::prefs::MAX_ALTERNATES { + rest.push(k); + } + } + plan.alternate_keys.insert(id.clone(), rest); plan.set.insert(id.clone(), sc); } } plan } +/// The tool keys of `set` matched to `tools` (our tool id and Photoshop's name for it, which is +/// ours too: "Move Tool"): tool id → key (normalised: a letter, or an F-key), and the names with no +/// tool here. A row PhotoCraft wrote names the tool by id. A key with modifiers is skipped. +pub fn plan_tools<'a>(tools: impl IntoIterator, set: &KysSet) -> (BTreeMap, Vec) { + let tools: Vec<(&str, &str)> = tools.into_iter().collect(); + let mut keys = BTreeMap::new(); + let mut unknown = Vec::new(); + for row in &set.tools { + let found = + row.id.as_deref().and_then(|id| tools.iter().find(|(t, _)| *t == id)).or_else(|| tools.iter().find(|(_, name)| key(name) == key(&row.name))); + let Some((id, _)) = found else { + unknown.push(row.name.clone()); + continue; + }; + // One key without modifiers (Photoshop's letters; our tools may also take an F-key). + let key = row.shortcuts.first().and_then(|k| normalize_shortcut(k.trim())); + if let Some(k) = key.filter(|k| !k.contains('+') || k == "+") { + keys.insert((*id).to_string(), k); + } + } + (keys, unknown) +} + +/// The attribute naming our command id on the rows PhotoCraft writes (Photoshop ignores it). +pub const PC_ID: &str = "pc-id"; + +/// One row to write: a command (or tool), its label and its keys, main key first. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Entry { + pub id: String, + pub label: String, + pub tool: bool, + pub keys: Vec, +} + +/// A key in the spelling Photoshop's macOS sets use (`Control+Opt+Shift+Cmd+G`): our `Ctrl` is +/// Control, `Alt` is Opt. `None` for a key that isn't one. +pub fn photoshop_key(sc: &str) -> Option { + let n = normalize_shortcut(sc)?; + // The key is the last part; `Cmd++` ends in an empty part, so take it from the string. + let (mods, key) = match n.strip_suffix("++") { + Some(m) => (m, "+"), + None => n.rsplit_once('+').unwrap_or(("", n.as_str())), + }; + let has = |m: &str| mods.split('+').any(|x| x == m); + let mut out: Vec<&str> = + [("Ctrl", "Control"), ("Alt", "Opt"), ("Shift", "Shift"), ("Cmd", "Cmd")].iter().filter(|(ours, _)| has(ours)).map(|(_, ps)| *ps).collect(); + out.push(key); + Some(out.join("+")) +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'") +} + +/// A `.kys` file for the set `name` (Edit › Keyboard Shortcuts › Save Set): every entry with a +/// key, commands then tools, labels with Photoshop's `...`, keys in [`photoshop_key`] spelling, +/// and our id on each row so PhotoCraft reads its own sets back exactly. Rows without a key are +/// left out, and a key that isn't one is skipped. +pub fn write(name: &str, entries: &[Entry]) -> String { + let name = xml_escape(name.trim()); + let mut out = format!( + "\u{feff}\n\n" + ); + let row = |e: &Entry| -> Option { + let keys: Vec = e.keys.iter().filter_map(|k| photoshop_key(k)).collect(); + let first = keys.first()?; + let label = xml_escape(&e.label.replace('…', "...")); + let id = xml_escape(&e.id); + Some(if e.tool { + format!("\t{}\n", xml_escape(first)) + } else { + let shortcuts: String = keys.iter().map(|k| format!("{}", xml_escape(k))).collect(); + format!("\t{shortcuts}\n") + }) + }; + for e in entries.iter().filter(|e| !e.tool).chain(entries.iter().filter(|e| e.tool)) { + if let Some(r) = row(e) { + out.push_str(&r); + } + } + out.push_str("\n"); + out +} + #[cfg(test)] mod tests { use super::*; @@ -179,6 +337,18 @@ mod tests { assert_eq!(p.unknown, ["Hide Photoshop"]); assert_eq!(p.unreadable, ["Odd"]); assert_eq!(p.alternates, 1); + // #3665: the row's second key is kept as the command's alternate, not dropped. + assert_eq!(p.alternate_keys.get("edit.undo"), Some(&vec!["F1".to_string()])); + assert_eq!(p.alternate_keys.get("image.levels"), Some(&Vec::new())); + } + + #[test] + fn a_row_with_the_default_and_a_function_key_keeps_both() { + let xml = + "F3Cmd+C"; + let p = plan([c("edit.copy", "Copy", Some("Cmd+C"))], &parse(xml).unwrap()); + assert_eq!(p.set.get("edit.copy").map(String::as_str), Some("Cmd+C")); + assert_eq!(p.alternate_keys.get("edit.copy"), Some(&vec!["F3".to_string()])); } #[test] @@ -187,4 +357,103 @@ mod tests { assert!(parse(&big).unwrap_err().contains("4 MiB")); assert!(parse("").unwrap_err().contains("not a keyboard shortcut set")); } + + #[test] + fn written_sets_read_back_exactly_and_keys_use_photoshops_spelling() { + assert_eq!(photoshop_key("Cmd+Alt+Shift+Ctrl+g").as_deref(), Some("Control+Opt+Shift+Cmd+G")); + assert_eq!(photoshop_key("Cmd++").as_deref(), Some("Cmd++")); + assert_eq!(photoshop_key("F3").as_deref(), Some("F3")); + assert_eq!(photoshop_key("Cmd+Shift+Ctrl"), None); + let entries = vec![ + Entry { id: "edit.copy".into(), label: "Copy".into(), tool: false, keys: vec!["Cmd+C".into(), "F3".into()] }, + Entry { id: "x.odd".into(), label: "Levels…".into(), tool: false, keys: vec!["Cmd+Alt+L".into()] }, + Entry { id: "tools.select.move".into(), label: "Move Tool".into(), tool: true, keys: vec!["V".into()] }, + Entry { id: "none".into(), label: "Unbound".into(), tool: false, keys: vec![] }, + Entry { id: "bad".into(), label: "A <\"weird\"> & 'name'".into(), tool: false, keys: vec!["Cmd+Shift+Ctrl".into(), "F9".into()] }, + ]; + let xml = write(" My & Set ", &entries); + assert!(xml.contains("KeyboardCustomization/My & Set=My & Set"), "{xml}"); + assert!(xml.contains("Opt+Cmd+L"), "{xml}"); + assert!(!xml.contains("Unbound"), "{xml}"); + assert!(xml.find("Move Tool").unwrap() > xml.find("Levels").unwrap(), "tools after commands"); + let set = parse(&xml).unwrap(); + assert_eq!(set.name.as_deref(), Some("My & Set")); + assert_eq!(set.tool_keys, 1); + assert_eq!(set.commands.len(), 3); + assert_eq!(set.commands[0], KysCommand { name: "Copy".into(), shortcuts: vec!["Cmd+C".into(), "F3".into()], id: Some("edit.copy".into()) }); + assert_eq!(set.commands[2].name, "A <\"weird\"> & 'name'"); + assert_eq!(set.commands[2].shortcuts, vec!["F9".to_string()]); + // The id wins over the label: a renamed or translated label still finds its command. + let cands = || { + vec![ + Candidate { id: "x.odd".into(), label: "Something Else".into(), default: None }, + Candidate { id: "y.levels".into(), label: "Levels".into(), default: Some("Cmd+L".into()) }, + ] + }; + let p = plan(cands(), &set); + assert_eq!(p.set.get("x.odd").map(String::as_str), Some("Cmd+Alt+L")); + assert!(!p.set.contains_key("y.levels")); + // Without the id, the label matches as before. + let mut plain = set.clone(); + plain.commands[1].id = None; + assert!(plan(cands(), &plain).set.contains_key("y.levels")); + } + + #[test] + fn a_windows_set_means_cmd_by_ctrl_and_a_mac_set_keeps_control() { + let win = " +\tCtrl+L +\tCtrl+Alt+Shift+G +\tCtrl++Ctrl+= +"; + let set = parse(win).unwrap(); + assert!(set.windows); + assert_eq!(to_ours(&set, "Ctrl+L").as_deref(), Some("Cmd+L")); + assert_eq!(to_ours(&set, "Ctrl+Alt+Shift+G").as_deref(), normalize_shortcut("Cmd+Alt+Shift+G").as_deref()); + assert_eq!(to_ours(&set, "Ctrl++").as_deref(), Some("Cmd++")); + assert_eq!(to_ours(&set, "F3").as_deref(), Some("F3")); + let cands = vec![Candidate { id: "image.levels".into(), label: "Levels…".into(), default: Some("Cmd+L".into()) }]; + assert_eq!(plan(cands, &set).set.get("image.levels").map(String::as_str), Some("Cmd+L"), "the default, not a Control override"); + // A Mac set spells ⌘ Cmd: its Control is the Control key. + let mac = parse( + "Cmd+LControl+Opt+Cmd+GControl+K", + ) + .unwrap(); + assert!(!mac.windows); + assert_eq!(to_ours(&mac, "Control+K"), normalize_shortcut("Ctrl+K")); + // No modifiers at all: nothing to tell, nothing to change. + let plain = parse("F3").unwrap(); + assert!(!plain.windows); + } + + #[test] + fn tool_keys_match_by_name_or_id_and_take_one_letter() { + let xml = " +\tV +\tn +\tB +\tQ +\tShift+L +\t +\tZ +"; + let set = parse(xml).unwrap(); + assert_eq!((set.tool_keys, set.tools.len()), (6, 5), "a key without a name counts but can't be matched"); + let tools = [ + ("tools.select.move", "Move Tool"), + ("tools.select.pencil", "Pencil Tool"), + ("tools.select.brush", "Brush Tool"), + ("tools.select.lasso", "Lasso Tool"), + ]; + let (keys, unknown) = plan_tools(tools, &set); + assert_eq!(keys.get("tools.select.move").map(String::as_str), Some("V")); + assert_eq!(keys.get("tools.select.pencil").map(String::as_str), Some("N"), "case-insensitive, upper-cased"); + assert_eq!(keys.get("tools.select.brush").map(String::as_str), Some("B"), "by pc-id"); + assert!(!keys.contains_key("tools.select.lasso"), "a tool key has no modifiers"); + assert_eq!(unknown, vec!["Imaginary Tool".to_string()]); + // Our own export reads back by id. + let out = write("T", &[Entry { id: "tools.select.pencil".into(), label: "Pencil Tool".into(), tool: true, keys: vec!["F".into()] }]); + let (keys, _) = plan_tools(tools, &parse(&out).unwrap()); + assert_eq!(keys.get("tools.select.pencil").map(String::as_str), Some("F")); + } } diff --git a/crates/engine/src/layer_copy_cmds/tests.rs b/crates/engine/src/layer_copy_cmds/tests.rs index a81ebbdc47..93bd7b94ef 100644 --- a/crates/engine/src/layer_copy_cmds/tests.rs +++ b/crates/engine/src/layer_copy_cmds/tests.rs @@ -130,7 +130,7 @@ fn bad_params_and_states_fail_without_changing_anything() { json!({"document": -1}), json!({"document": "1"}), json!({"document": 1, "source": 9}), - json!({"document": 1, "layers": [999_999]}), + json!({"document": 1, "layers": [u64::MAX]}), json!({"document": 1, "layers": "all"}), json!({"document": 1, "layers": []}), json!({"document": 1, "at": [1.0]}), @@ -314,7 +314,7 @@ fn duplicate_destinations_that_are_not_new_fail_cleanly() { json!({"document": []}), json!({"document": {}}), json!({"document": "other"}), - json!({"document": "new", "layer": 999_999}), + json!({"document": "new", "layer": u64::MAX}), ] { let before = (doc(&s, 0).clone(), s.documents().len(), s.active_index()); let err = s.execute("layer.duplicate", p.clone()).unwrap_err().to_string(); diff --git a/crates/engine/src/layer_list_cmds.rs b/crates/engine/src/layer_list_cmds.rs new file mode 100644 index 0000000000..6ced6ae0d9 --- /dev/null +++ b/crates/engine/src/layer_list_cmds.rs @@ -0,0 +1,163 @@ +//! `layer.list`: the layer tree alone, for agents and scripts (#5059). `document.inspect` also has +//! it, buried among history, channels and much more; an agent looking for the layers' ids looks +//! for a `layer.*` command first. + +use photocraft_doc::{Layer, LayerContent, LayerId}; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{DocState, EngineError, Result, Session}; + +const ID: &str = "layer.list"; + +/// Groups nest at most this deep in the listing; deeper layers are left out (documents can be +/// deep, and the tree is built recursively). +const MAX_DEPTH: usize = 256; + +fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +} + +/// One layer's row: what an agent needs to pick a layer to work on. +fn row(l: &Layer, selected: &[LayerId], active: Option) -> Value { + let bounds = crate::layer_multi_cmds::layer_bounds(l).map(|r| [r.x0, r.y0, r.width() as i32, r.height() as i32]); + json!({ + "id": l.id.0, + "name": l.name, + "kind": l.content.kind_name(), + "visible": l.visible, + "bounds": bounds, + "selected": selected.contains(&l.id), + "active": active == Some(l.id), + }) +} + +/// The layers top-down (the Layers panel's order), groups with their `children`. +fn tree(layers: &[Layer], d: &DocState, selected: &[LayerId], depth: usize) -> Vec { + if depth >= MAX_DEPTH { + return Vec::new(); + } + layers + .iter() + .rev() + .map(|l| { + let mut v = row(l, selected, d.active_layer); + if let LayerContent::Group(g) = &l.content { + v["children"] = Value::Array(tree(&g.children, d, selected, depth + 1)); + } + v + }) + .collect() +} + +/// The layers top-down as one list, each with its `depth` (0 = top level) and `parent` group id. +fn flat(layers: &[Layer], d: &DocState, selected: &[LayerId], parent: Option, depth: usize, out: &mut Vec) { + if depth >= MAX_DEPTH { + return; + } + for l in layers.iter().rev() { + let mut v = row(l, selected, d.active_layer); + v["depth"] = json!(depth); + v["parent"] = json!(parent.map(|p| p.0)); + out.push(v); + if let LayerContent::Group(g) = &l.content { + flat(&g.children, d, selected, Some(l.id), depth + 1, out); + } + } +} + +fn run(s: &mut Session, p: &Value) -> Result { + let bad = |msg: &str| EngineError::BadParams { cmd: ID.into(), msg: msg.into() }; + let d = match p.get("document") { + None | Some(Value::Null) => s.active().ok_or(EngineError::NoDocument)?, + Some(v) => { + let i = v.as_u64().and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad("`document` must be an index"))?; + s.documents().get(i).ok_or_else(|| EngineError::Other(format!("no document at index {i}")))? + } + }; + let as_flat = match p.get("flat") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad("`flat` must be true or false"))?, + }; + let selected = d.selected_layers(); + let layers = if as_flat { + let mut out = Vec::new(); + flat(&d.doc.layers, d, &selected, None, 0, &mut out); + out + } else { + tree(&d.doc.layers, d, &selected, 0) + }; + Ok(json!({"layers": layers, "activeLayer": d.active_layer.map(|l| l.0)})) +} + +pub fn specs() -> Vec { + vec![CommandSpec { + id: ID, + label: "List Layers", + menu: &[], + shortcut: None, + params: r##"{"document":index?,"flat":bool=false} → {"layers":[{"id","name","kind","visible","bounds":[x,y,w,h]|null,"selected","active","children"?}],"activeLayer":id?} (top-down; groups list their "children", or with "flat" every layer is one row with its "depth" and "parent" group id)"##, + enabled: has_doc, + run, + journal: false, + }] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session_with_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s + } + + #[test] + fn lists_ids_names_kinds_and_nesting_top_down() { + let mut s = session_with_doc(); + let bg = s.active().unwrap().active_layer.unwrap().0; + let inner = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("layer.groupLayers", json!({})).unwrap(); + let group = s.active().unwrap().doc.layers.last().unwrap().id.0; + s.execute("layer.select", json!({"layer": inner})).unwrap(); + s.execute("layer.setProps", json!({"layer": bg, "visible": false})).unwrap(); + let r = s.execute(ID, json!({})).unwrap(); + let layers = r["layers"].as_array().unwrap(); + assert_eq!(layers.len(), 2, "{r}"); + assert_eq!((layers[0]["id"].as_u64(), layers[0]["kind"].as_str()), (Some(group), Some("Group")), "{r}"); + assert_eq!(layers[0]["children"][0]["id"], inner, "{r}"); + assert_eq!(layers[0]["children"][0]["active"], true, "{r}"); + assert!(layers[0]["children"][0]["bounds"].is_array(), "{r}"); + assert_eq!(layers[0]["bounds"], layers[0]["children"][0]["bounds"], "a group spans its children"); + assert_eq!((layers[1]["id"].as_u64(), layers[1]["name"].as_str(), layers[1]["visible"].as_bool()), (Some(bg), Some("Background"), Some(false))); + assert!(layers[1].get("children").is_none()); + assert_eq!(r["activeLayer"], inner); + // Flat: one row per layer, with its depth and parent group. + let r = s.execute(ID, json!({"flat": true})).unwrap(); + let rows: Vec<(u64, u64, Option)> = + r["layers"].as_array().unwrap().iter().map(|l| (l["id"].as_u64().unwrap(), l["depth"].as_u64().unwrap(), l["parent"].as_u64())).collect(); + assert_eq!(rows, [(group, 0, None), (inner, 1, Some(group)), (bg, 0, None)]); + } + + #[test] + fn is_a_query_and_leaves_the_document_alone() { + let mut s = session_with_doc(); + let revision = s.active().unwrap().revision; + let journal = s.journal.len(); + s.execute(ID, json!({})).unwrap(); + assert_eq!((s.active().unwrap().revision, s.journal.len()), (revision, journal)); + } + + #[test] + fn bad_params_are_errors_not_panics() { + let mut s = Session::new(); + assert!(s.execute(ID, json!({})).is_err(), "no document"); + let mut s = session_with_doc(); + for p in [json!({"flat": "yes"}), json!({"document": -1}), json!({"document": 99}), json!({"document": u64::MAX}), json!({"document": "a"})] { + assert!(s.execute(ID, p.clone()).is_err(), "{p}"); + } + assert!(s.execute(ID, json!({"document": 0, "flat": false})).is_ok()); + } +} diff --git a/crates/engine/src/layer_menu_cmds.rs b/crates/engine/src/layer_menu_cmds.rs index 366f9c8d39..ab25912296 100644 --- a/crates/engine/src/layer_menu_cmds.rs +++ b/crates/engine/src/layer_menu_cmds.rs @@ -373,30 +373,56 @@ 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})); } + ensure_folder(program, &args)?; spawn(program, &args)?; Ok(json!({"path": path})) } +/// `xdg-open` opens whatever it is given in its default application, so it only gets a folder: a +/// linked path such as `tool.jar/a.png` would otherwise launch `tool.jar` instead of showing it. +fn ensure_folder(program: &str, args: &[String]) -> Result<()> { + if program != "xdg-open" { + return Ok(()); + } + match args.first() { + Some(dir) if std::path::Path::new(dir).is_dir() => Ok(()), + Some(dir) => Err(other(format!("{dir} is not a folder, so it can't be shown in the file manager"))), + None => Err(other("no folder to show in the file manager")), + } +} + /// 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 708bee5fce..171034876e 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,61 @@ 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}"); + } +} + +/// `xdg-open` launches a file it is given, so Reveal hands it the link's folder only when that is a +/// folder: a link stored as `tool.jar/a.png` names a file, not a folder, as its parent. +#[test] +fn reveal_on_linux_never_hands_xdg_open_a_file() { + let dir = std::env::temp_dir().join(format!("photocraft-reveal-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("tool.jar"); + std::fs::write(&file, b"not a folder").unwrap(); + let (program, args) = reveal_command_on("linux", &file.join("a.png").to_string_lossy()).unwrap(); + assert_eq!(args, vec![file.to_string_lossy().into_owned()]); + let err = ensure_folder(program, &args).unwrap_err(); + assert!(err.to_string().contains("is not a folder"), "{err}"); + let (program, args) = reveal_command_on("linux", &dir.join("a.png").to_string_lossy()).unwrap(); + ensure_folder(program, &args).unwrap(); + assert!(ensure_folder("xdg-open", &[]).is_err()); + // Finder and Explorer select the file itself, whatever it is. + ensure_folder("open", &["-R".into(), "--".into(), file.to_string_lossy().into_owned()]).unwrap(); + std::fs::remove_dir_all(&dir).unwrap(); +} + #[test] fn mask_all_objects_masks_the_subject() { let mut s = session(8, "rgb"); @@ -588,7 +635,7 @@ fn transfer_effects_moves_or_copies_between_layers() { for bad in [ json!({"from": a.0, "to": a.0}), json!({"from": a.0}), - json!({"from": a.0, "to": 9999}), + json!({"from": a.0, "to": u64::MAX}), json!({"from": a.0, "to": b.0, "effect": 7}), json!({"from": a.0, "to": b.0, "effect": -1}), json!({"from": b.0, "to": a.0}), diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 21e9e2caac..418b0051d0 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -100,6 +100,48 @@ pub(crate) fn reselect(s: &mut Session, ids: Vec, active: Option Value { + let Some(id) = p.get("layer").and_then(Value::as_u64).map(LayerId) else { return p }; + let Some(st) = s.active() else { return p }; + let Some(layer) = st.doc.layer(id) else { return p }; + let matches: Vec<_> = st.doc.walk().into_iter().filter(|(_, _, l)| l.name == layer.name).map(|(_, _, l)| l.id).collect(); + let Some(occurrence) = matches.iter().position(|candidate| *candidate == id) else { return p }; + if let Some(fields) = p.as_object_mut() { + fields.remove("layer"); + fields.insert("name".into(), json!(layer.name)); + if matches.len() > 1 { + fields.insert("occurrence".into(), json!(occurrence)); + } + } + p +} + +fn selection_target(s: &Session, p: &Value) -> Result { + let Some(name) = p.get("name") else { + if p.get("occurrence").is_some() { + return Err(bad("layer.select", "`occurrence` requires `name`")); + } + return crate::commands::explicit_layer(s, p)?.ok_or_else(|| bad("layer.select", "needs `layer` or `name`")); + }; + if p.get("layer").is_some() { + return Err(bad("layer.select", "use either `layer` or `name`, not both")); + } + let name = name.as_str().ok_or_else(|| bad("layer.select", "`name` must be a string"))?; + let occurrence = p + .get("occurrence") + .map(|v| v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad("layer.select", "`occurrence` must be a non-negative integer"))) + .transpose()?; + let st = s.active().ok_or(EngineError::NoDocument)?; + let matches: Vec<_> = st.doc.walk().into_iter().filter(|(_, _, l)| l.name == name).map(|(_, _, l)| l.id).collect(); + if matches.len() > 1 && occurrence.is_none() { + return Err(bad("layer.select", format!("multiple layers named `{name}`; specify `occurrence`"))); + } + let index = occurrence.unwrap_or(0); + matches.get(index).copied().ok_or_else(|| bad("layer.select", format!("no layer named `{name}` at occurrence {index}"))) +} + /// Layer rows follow the Layers panel's visual order, not Document::walk's storage order. /// Closed groups hide their children unless both endpoints were selected programmatically. fn range_order(layers: &[Layer], include_collapsed: bool, out: &mut Vec) { @@ -115,7 +157,7 @@ fn range_order(layers: &[Layer], include_collapsed: bool, out: &mut Vec /// `layer.select`: `{"layer": id, "mode": "replace|toggle|range|add|remove"}`. pub fn select(s: &mut Session, p: &Value) -> Result { - let id = crate::commands::explicit_layer(s, p)?.ok_or_else(|| bad("layer.select", "missing `layer`"))?; + let id = selection_target(s, p)?; let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace"); let st = s.active().ok_or(EngineError::NoDocument)?; if st.doc.layer(id).is_none() { @@ -221,7 +263,8 @@ fn can_align(s: &Session) -> std::result::Result<(), String> { let d = doc_state(s)?; match d.selected_layers().len() { 0 => Err("no layer selected".into()), - // Photoshop aligns a single layer to the canvas (or to an active selection); 2+ align to each other. + // Photoshop aligns to an active selection however many layers are selected; without one, + // a single layer aligns to the canvas and 2+ align to each other. _ => Ok(()), } } @@ -842,9 +885,9 @@ pub(crate) fn note_inert_insert(s: &mut Session, id: LayerId) { } } -/// Report the pixels an edit from `before` that added layers changed ([`step_damage`]), for -/// commands made of more than one edit, so the canvas recomposites only where the new layers -/// draw instead of the whole document. +/// Report the pixels a command made of more than one edit changed since `before` ([`step_damage`]), +/// so the canvas recomposites only where its new layers draw instead of the whole document. +/// A single [`Session::edit`] already reports its own damage: don't call this after one (#5695). pub(crate) fn note_insert(s: &mut Session, before: &Document) { let Some(st) = s.active_mut() else { return }; st.last_damage = step_damage(before, &st.doc); @@ -919,8 +962,9 @@ fn align(s: &mut Session, p: &Value, kind: &str) -> Result { "canvas" => doc.bounds(), "selection" => doc.selection.as_ref().map(|m| m.content_bounds()).ok_or_else(|| EngineError::Other("there is no selection to align to".into()))?, "auto" | "layers" => match (&doc.selection, sel.len()) { - // Photoshop: one layer plus an active selection aligns to the selection bounds. - (Some(m), 1) if to == "auto" => m.content_bounds(), + // Photoshop: with an active selection, the layers align to its bounds (Layer › Align + // Layers to Selection), however many are selected. + (Some(m), _) if to == "auto" && !m.content_bounds().is_empty() => m.content_bounds(), // One layer with no selection aligns to the canvas (otherwise it would align to itself). (None, 1) if to == "auto" => doc.bounds(), _ => items.iter().fold(Rect::EMPTY, |a, (_, b)| a.union(b)), @@ -1270,7 +1314,13 @@ fn merge_layers(s: &mut Session) -> Result { let fmt = PixelFormat::new(fmt.mode, fmt.sample, true); let mut merged = Layer::raster(top.name.clone(), fmt); merged.locks = top.locks; - *crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers(&solo, fmt, None); + merged.label = top.label; + // Clipped layers of one chain merge into a layer still clipped to their base. Clipped + // layers of different chains don't: the result, at the top one's slot, would clip the + // lower chain's pixels to the upper chain's base. + let base = crate::clip_group::base_of(doc, top_id); + merged.clipped = base.is_some() && solo.layers.iter().all(|l| l.clipped && crate::clip_group::base_of(doc, l.id) == base); + *crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers_uncropped(&solo, fmt); let mid = merged.id; // Hidden selected layers are discarded, as in Photoshop. for id in &ids[..ids.len() - 1] { @@ -1336,7 +1386,6 @@ pub fn delete_selected(s: &mut Session) -> Result { /// this path, #2777). pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Result { let sel = selected(s); - let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let old_active = s.active().and_then(|d| d.active_layer); let (copies, active) = s.edit(label, |doc, active| { let mut copies = Vec::new(); @@ -1358,7 +1407,6 @@ pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Resul *active = a; Ok((copies, a)) })?; - note_insert(s, &before); let out = copies.iter().map(|l| l.0).collect::>(); reselect(s, copies, active); Ok(json!({"layers": out, "layer": active.map(|l| l.0)})) @@ -1389,7 +1437,7 @@ pub fn specs() -> Vec { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } }; } - const ALIGN: &str = r##"{"to":"auto|layers|selection|canvas"="auto"} (auto: the selection bounds with one layer and an active selection, else the selected layers' bounds)"##; + const ALIGN: &str = r##"{"to":"auto|layers|selection|canvas"="auto"} (auto: the active selection's bounds when there is one, however many layers are selected; else one layer aligns to the canvas and several to their combined bounds)"##; vec![ spec!("select.allLayers", "All Layers", &["Select"], Some("Cmd+Alt+A"), "{}", has_doc, |s, _| select_all_layers(s)), spec!("select.deselectLayers", "Deselect Layers", &["Select"], None, "{}", has_doc, |s, _| { @@ -1437,7 +1485,15 @@ pub fn specs() -> Vec { link_layers(s) }), spec!("layer.mergeLayers", "Merge Layers", &["Layer"], Some("Cmd+E"), "{} (one layer selected: Merge Down)", has_layer, |s, _| merge_layers(s)), - spec!("layer.new.groupFromLayers", "Group from Layers…", &["Layer", "New"], None, r##"{"name":str?}"##, has_layer, group_layers), + spec!( + "layer.new.groupFromLayers", + "Group from Layers…", + &["Layer", "New"], + None, + r##"{"layer":id?,"name":str?} (no layer: every selected layer)"##, + has_layer, + group_layers + ), spec!("layer.arrange.reverse", "Reverse", &["Layer", "Arrange"], None, "{}", two_plus, |s, _| reverse(s)), spec!( "layer.lockLayers", @@ -1625,8 +1681,8 @@ mod tests { let all = doc(&s).bounds(); assert!(damage(&s).is_none_or(|r| r.contains_rect(&all)), "{:?}", damage(&s)); // Bad params still fail cleanly. - assert!(s.execute("layer.setProps", json!({"layer": 999_999, "visible": true})).is_err()); - assert!(s.execute("layer.translate", json!({"layer": 999_999, "dx": 1})).is_err()); + assert!(s.execute("layer.setProps", json!({"layer": u64::MAX, "visible": true})).is_err()); + assert!(s.execute("layer.translate", json!({"layer": u64::MAX, "dx": 1})).is_err()); } /// #1771: a new empty layer or group changes no pixels, so it (and undoing or redoing it) @@ -1765,8 +1821,8 @@ mod tests { x.px.iter().zip(&y.px).enumerate().all(|(i, (p, q))| p == q || damage.contains(i as i32 % w, i as i32 / w)) } - /// Duplicate, paste and delete, and their undo and redo, recomposite only where the layer - /// draws; undoing a duplicate used to recomposite the whole document. + /// Duplicate (one layer or several), paste and delete, and their undo and redo, recomposite + /// only where the layers draw; undoing a duplicate used to recomposite the whole document. #[test] fn duplicate_paste_and_delete_report_where_the_layer_draws() { let mut s = session(8); @@ -1789,6 +1845,12 @@ mod tests { s.execute("edit.copy", json!({})).unwrap(); s.execute("select.deselect", json!({})).unwrap(); step(&mut s, "edit.paste", json!({})); + // Duplicating several selected layers reports where the copies draw, too. + let b = rect_layer(&mut s, Rect::new(30, 30, 40, 40)); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + step(&mut s, "layer.duplicate", json!({})); + assert_eq!(damage(&s), Some(Rect::new(10, 10, 40, 40)), "both copies, nothing else"); step(&mut s, "layer.delete", json!({"layer": a.0})); } @@ -1805,7 +1867,7 @@ mod tests { let mut locked = (*before).clone(); locked.layer_mut(a).unwrap().locks.position = true; assert!(moved(&locked, &ids, 1, 1).is_err()); - assert!(moved(&before, &[LayerId(999_999)], 1, 1).is_err()); + assert!(moved(&before, &[LayerId(u64::MAX)], 1, 1).is_err()); } #[test] @@ -1894,7 +1956,7 @@ mod tests { assert_eq!(s.active().unwrap().doc.layer(a).unwrap().name, "x"); s.execute("layer.showLayers", json!({"layer": a.0})).unwrap(); assert!(s.active().unwrap().doc.layer(a).unwrap().visible); - assert!(s.execute("layer.hideLayers", json!({"layer": 999_999})).is_err()); + assert!(s.execute("layer.hideLayers", json!({"layer": u64::MAX})).is_err()); assert!(s.execute("layer.hideLayers", json!({})).is_err()); assert!(!s.is_enabled("layer.align.topEdges")); } @@ -2051,6 +2113,37 @@ mod tests { assert!(!s.is_enabled("layer.distribute.leftEdges")); } + #[test] + fn merge_keeps_pixels_outside_the_canvas() { + // #5067: the merged layer was cropped to the 100x80 canvas. + let mut s = session(8); + let a = rect_layer(&mut s, Rect::new(-30, -20, 10, 10)); + let b = rect_layer(&mut s, Rect::new(90, 70, 140, 120)); + select_all(&mut s, &[a, b]); + let m = LayerId(s.execute("layer.mergeLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); + let ml = doc(&s).layer(m).unwrap(); + assert_eq!(layer_bounds(ml).unwrap(), Rect::new(-30, -20, 140, 120)); + assert_eq!(ml.surface().unwrap().pixel(-10, -5)[3], 1.0); + assert_eq!(ml.surface().unwrap().pixel(130, 110)[3], 1.0); + } + + #[test] + fn merge_down_and_merge_group_keep_pixels_outside_the_canvas() { + let mut s = session(8); + let a = rect_layer(&mut s, Rect::new(-30, -20, 10, 10)); + let b = rect_layer(&mut s, Rect::new(90, 70, 140, 120)); + s.execute("layer.mergeDown", json!({"layer": b.0})).unwrap(); + assert_eq!(layer_bounds(doc(&s).layer(a).unwrap()).unwrap(), Rect::new(-30, -20, 140, 120)); + + let mut s = session(8); + let a = rect_layer(&mut s, Rect::new(-30, -20, 10, 10)); + let b = rect_layer(&mut s, Rect::new(90, 70, 140, 120)); + select_all(&mut s, &[a, b]); + let g = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.mergeGroup", json!({"layer": g.0})).unwrap(); + assert_eq!(layer_bounds(doc(&s).layer(g).unwrap()).unwrap(), Rect::new(-30, -20, 140, 120)); + } + #[test] fn merge_selected_into_top_most() { for depth in [8, 16, 32] { diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index fbbc949e20..8933f838b9 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -98,7 +98,7 @@ pub fn effect_defaults(kind: &str) -> Value { } "patternOverlay" => json!({"blend": "Normal", "opacity": 100, "pattern": "", "angle": 0, "scale": 100, "link": true}), "bevelEmboss" => { - json!({"style": "inner", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "useGlobalLight": true, "altitude": 30, "glossContour": "Linear", "contour": false, "contourName": "Linear", "contourRange": 50, "contourAntiAlias": false}) + json!({"style": "inner", "technique": "smooth", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "useGlobalLight": true, "altitude": 30, "glossContour": "Linear", "contour": false, "contourName": "Linear", "contourRange": 50, "contourAntiAlias": false}) } "satin" => { json!({"blend": "Multiply", "color": "#000000", "opacity": 50, "angle": 19, "distance": 11, "size": 14, "contour": "Linear", "antiAlias": true, "invert": true}) @@ -594,6 +594,9 @@ fn overlay_gradient(g: &mut Gradient, p: &Value) { /// a dialog round trip never touches what it doesn't model: an imported /// gradient stroke keeps its gradient while its opacity is edited. pub fn overlay_effect(fx: &mut Effect, p: &Value) { + if crate::commands::check_f32_values("layer.layerStyle", p).is_err() { + return; + } fn set_color(p: &Value, k: &str, c: &mut Color) { if let Some(x) = color_of(p, k) { *c = x; @@ -821,6 +824,7 @@ pub fn overlay_effect(fx: &mut Effect, p: &Value) { /// Builds an effect of `kind` from JSON params (Photoshop units: opacity, /// spread, range in percent; sizes in px; angles in degrees). pub fn effect_from_params(kind: &str, p: &Value) -> Option { + crate::commands::check_f32_values("layer.layerStyle", p).ok()?; let mut fx = fresh_effect(kind)?; overlay_effect(&mut fx, p); Some(fx) @@ -856,6 +860,7 @@ const MAX_EFFECTS: usize = 256; /// mismatches and malformed snapshots are errors, never panics. fn replace_effects(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.layerStyle.replace"; + crate::commands::check_f32_values(CMD, p)?; let id = crate::commands::layer_param(s, p)?; let entries = p.get("effects").and_then(Value::as_array).ok_or_else(|| bad(CMD, "need `effects`: [{kind, params, fx?}]"))?; if entries.len() > MAX_EFFECTS { @@ -895,14 +900,17 @@ fn replace_effects(s: &mut Session, p: &Value) -> Result { } let mode = doc.mode; let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + // The list's master switch (`layer.setEffectsVisible`, the Layers panel's "Effects" eye) + // survives the edit: the dialog models per-effect state, not the switch (#3648). A layer + // with no effects yet still has the default `enabled: true`, so its style shows. l.effects.items = list.into_iter().map(|e| e.in_mode(mode)).collect(); - l.effects.enabled = true; Ok(()) })?; Ok(json!({ "layer": id.0, "effects": count })) } fn set_effect(s: &mut Session, p: &Value, kind: &str) -> Result { + crate::commands::check_f32_values(&format!("layer.layerStyle.{kind}"), p)?; let fx = effect_from_params(kind, p).ok_or_else(|| EngineError::Other(format!("unknown effect {kind}")))?; let (fx, pattern) = crate::pattern_cmds::resolve_effect(s, fx)?; let label = format!("Layer Style: {}", fx.label()); @@ -915,7 +923,8 @@ fn set_effect(s: &mut Session, p: &Value, kind: &str) -> Result { // Colours in the document's mode, as Image › Mode keeps them. let fx = fx.in_mode(doc.mode); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - l.effects.enabled = true; + // Adding or replacing one effect leaves the list's master switch alone (#3648); a fresh + // effects list already has the default `enabled: true`, so a first effect still shows. match l.effects.items.iter_mut().find(|e| same_kind(e, &fx)) { Some(slot) if !add => *slot = fx, _ => l.effects.items.push(fx), @@ -933,6 +942,7 @@ fn make_default(s: &mut Session, p: &Value) -> Result { Some(v) if v.is_object() => v.clone(), _ => return Err(bad(CMD, "`params` must be an object")), }; + crate::commands::check_f32_values(CMD, ¶ms)?; s.presets.layer_defaults.insert(kind.to_string(), params); s.presets_changed(); Ok(json!({"kind": kind})) @@ -1149,17 +1159,25 @@ mod tests { /// blur kernel of 2^62 taps ("capacity overflow"), or ran for minutes at 1e6. The blur width /// is capped at the effect reach, so the document still renders, quickly. #[test] - fn oversized_satin_and_bevel_sizes_render() { - for (cmd, p) in [ - ("satin", json!({"size": 1e308})), - ("satin", json!({"size": 1_000_000})), - ("bevelEmboss", json!({"size": 1e308})), - ("bevelEmboss", json!({"soften": 1e308})), - ("bevelEmboss", json!({"size": 1_000_000, "soften": 1_000_000})), + fn oversized_satin_and_bevel_sizes_stay_saveable_and_render() { + for (cmd, p, accepted) in [ + ("satin", json!({"size": 1e308}), false), + ("satin", json!({"size": 1e30}), true), + ("satin", json!({"size": 1_000_000}), true), + ("bevelEmboss", json!({"size": 1e308}), false), + ("bevelEmboss", json!({"soften": 1e308}), false), + ("bevelEmboss", json!({"size": 1e30, "soften": 1e30}), true), + ("bevelEmboss", json!({"size": 1_000_000, "soften": 1_000_000}), true), ] { let mut s = session(); s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); - s.execute(&format!("layer.layerStyle.{cmd}"), p.clone()).unwrap(); + let result = s.execute(&format!("layer.layerStyle.{cmd}"), p.clone()); + if accepted { + result.unwrap(); + } else { + assert!(matches!(result, Err(EngineError::BadParams { .. }))); + } + photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap(); let start = std::time::Instant::now(); s.execute("document.pixel", json!({"x": 0, "y": 0})).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")); assert!(start.elapsed().as_secs() < 20, "{cmd} {p}: {:?}", start.elapsed()); @@ -1197,6 +1215,62 @@ mod tests { assert_eq!(effects(&s).len(), 1); } + fn pixel(s: &mut Session) -> Vec { + serde_json::from_value(s.execute("document.pixel", json!({"x": 3, "y": 3})).unwrap()).unwrap() + } + + fn list_enabled(s: &Session) -> bool { + let d = s.active().unwrap(); + d.doc.layer(d.active_layer.unwrap()).unwrap().effects.enabled + } + + /// #3648: the Layers panel's "Effects" eye (`layer.setEffectsVisible`) must survive a Layer + /// Style dialog OK: the dialog's whole-list replace re-creates the items but leaves the + /// list's master switch alone. + #[test] + fn replacing_the_list_keeps_a_disabled_effect_list_disabled() { + let mut s = session(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("layer.layerStyle.colorOverlay", json!({"color": "#0000ff"})).unwrap(); + let shown = pixel(&mut s); + s.execute("layer.setEffectsVisible", json!({"visible": false})).unwrap(); + let hidden = pixel(&mut s); + assert_ne!(hidden, shown, "the eye really hides the style"); + let r = s.execute("layer.layerStyle.replace", json!({"effects": [{"kind": "colorOverlay", "params": {"color": "#00ff00"}}]})).unwrap(); + assert_eq!(r["effects"], 1, "the list itself is updated"); + assert!(!list_enabled(&s), "the whole-list switch survives the edit"); + assert!(effects(&s)[0].enabled(), "the effect's own state is updated"); + assert_eq!(pixel(&mut s), hidden, "a disabled list still draws nothing"); + s.execute("layer.setEffectsVisible", json!({"visible": true})).unwrap(); + assert_ne!(pixel(&mut s), hidden, "re-showing the list draws the edited effect"); + } + + /// #3648: adding or replacing one effect (`layer.layerStyle.`) also leaves the master + /// switch as the user left it. + #[test] + fn set_effect_keeps_a_disabled_effect_list_disabled() { + let mut s = session(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("layer.layerStyle.colorOverlay", json!({"color": "#0000ff"})).unwrap(); + s.execute("layer.setEffectsVisible", json!({"visible": false})).unwrap(); + let hidden = pixel(&mut s); + s.execute("layer.layerStyle.colorOverlay", json!({"color": "#00ff00"})).unwrap(); + assert!(!list_enabled(&s), "the whole-list switch survives the edit"); + assert!(effects(&s)[0].enabled(), "the effect's own state is updated"); + assert_eq!(pixel(&mut s), hidden, "a disabled list still draws nothing"); + } + + /// A layer that never had effects keeps the default: its first effect shows. + #[test] + fn a_first_effect_shows_on_a_layer_that_never_had_effects() { + let mut s = session(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + let plain = pixel(&mut s); + s.execute("layer.layerStyle.colorOverlay", json!({"color": "#00ff00"})).unwrap(); + assert!(list_enabled(&s), "a fresh effects list defaults to enabled"); + assert_ne!(pixel(&mut s), plain, "the first effect renders"); + } + /// #3102: an effect row dropped on the Delete button deletes only that effect, in one step. #[test] fn clear_one_effect_by_index() { @@ -1206,7 +1280,7 @@ mod tests { let layer = s.active().unwrap().active_layer.unwrap().0; s.execute("layer.layerStyle.clear", json!({"layer": layer, "effect": 0})).unwrap(); assert!(matches!(effects(&s)[..], [Effect::ColorOverlay { .. }])); - for bad in [json!({"effect": 5}), json!({"effect": -1}), json!({"effect": "x"}), json!({"effect": 0, "layer": 999_999})] { + for bad in [json!({"effect": 5}), json!({"effect": -1}), json!({"effect": "x"}), json!({"effect": 0, "layer": u64::MAX})] { assert!(s.execute("layer.layerStyle.clear", bad).is_err()); } assert_eq!(effects(&s).len(), 1, "bad params change nothing"); diff --git a/crates/engine/src/lens_cmds.rs b/crates/engine/src/lens_cmds.rs index 379ca2402a..ba088c7086 100644 --- a/crates/engine/src/lens_cmds.rs +++ b/crates/engine/src/lens_cmds.rs @@ -62,7 +62,7 @@ pub fn lens_params(cmd: &str, p: &Value, frame: Rect, info: Option<&exif::Camera "edgeExtension" | "extension" => EdgeMode::Extension, "black" => EdgeMode::Color([0.0, 0.0, 0.0, 1.0]), "white" => EdgeMode::Color([1.0, 1.0, 1.0, 1.0]), - "background" | "color" => EdgeMode::Color(crate::commands::color_param(p, "edgeColor", [0.0, 0.0, 0.0, 1.0])), + "background" | "color" => EdgeMode::Color(crate::commands::color_param(cmd, p, "edgeColor", [0.0, 0.0, 0.0, 1.0])?), other => return Err(bad(cmd, format!("unknown edge `{other}` (transparency|edgeExtension|black|white|color)"))), }; let focal = num(p, "focalLength", 0.0); @@ -147,6 +147,9 @@ pub fn raw_params(cmd: &str, p: &Value) -> Result { cr.noise_luminance_detail = cr.noise_luminance_detail.clamp(0.0, 100.0); cr.noise_color = cr.noise_color.clamp(0.0, 100.0); cr.noise_color_detail = cr.noise_color_detail.clamp(0.0, 100.0); + // `exposure` becomes `2^exposure`, which overflows f32 to infinity from 128 up (and then + // writes inf/NaN into 32-bit layers). Clamp to the documented -5..5. + cr.exposure = cr.exposure.clamp(-5.0, 5.0); Ok(cr) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ff3cde6e4c..ba7556901b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1,4 +1,4 @@ -//! The Photocraft engine façade: open documents, history, and the command registry. +//! The PhotoCraft engine façade: open documents, history, and the command registry. //! //! Every user-visible action is a command with a stable id (Photoshop-style, such as //! `layer.newAdjustmentLayer.invert`) and JSON parameters. Every frontend goes through the same @@ -30,15 +30,19 @@ pub mod color_cmds; pub mod color_to_alpha_cmds; pub mod commands; pub mod comps_cmds; +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; 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; @@ -62,6 +66,7 @@ pub mod jobs; pub mod kys; pub mod layer_copy_cmds; pub mod layer_label_cmds; +pub mod layer_list_cmds; pub mod layer_mask_props_cmds; pub mod layer_menu_cmds; pub mod layer_multi_cmds; @@ -100,6 +105,8 @@ pub mod sample_cmds; pub mod sampler_cmds; pub mod select_extra_cmds; pub mod selection_cmds; +pub mod shell_commands; +pub mod shortcut_sets; pub mod slice_cmds; pub mod smart_cmds; pub mod smartselect_cmds; @@ -150,6 +157,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. @@ -231,6 +242,20 @@ pub struct DocState { } impl DocState { + /// Trim the history to its budget, counting the tiles only History panel snapshots hold + /// against it too (#4530). Returns how many states were dropped. + pub fn trim_history(&mut self) -> usize { + let snaps: Vec<&Document> = self.snapshots.iter().map(|s| &*s.doc).collect(); + self.history.trim_with(&self.doc, &snaps) + } + + /// Pixel bytes of the document, the tiles only its history holds and the tiles only its + /// History panel snapshots hold. + pub fn memory_bytes(&self) -> usize { + let snaps: Vec<&Document> = self.snapshots.iter().map(|s| &*s.doc).collect(); + self.history.pixel_bytes(&self.doc).saturating_add(self.history.extra_bytes(&self.doc, &snaps)) + } + pub fn new(doc: Document, path: Option) -> Self { let active_layer = doc.top_layer(); let mut history = History::default(); @@ -388,6 +413,29 @@ impl Default for ToolState { } } +/// Layer › Layer Style › Copy Layer Style: the effects and the Blending Options, as Photoshop +/// copies them (Paste Layer Style puts them all on each target). +#[derive(Clone, Debug)] +pub struct CopiedStyle { + pub effects: photocraft_doc::Effects, + pub blend: photocraft_color::BlendMode, + pub fill: f32, + pub advanced: photocraft_doc::AdvancedBlending, + pub blend_if: photocraft_doc::BlendIf, + pub excluded_channels: u32, + /// The colour mode it was copied in: Blend If's channel ranges and the channel restrictions + /// only carry over to a document in the same mode. + pub mode: photocraft_color::ColorMode, +} + +/// The commands whose [`Session::precheck`] is complete: it accepts what `execute` does (Free +/// Transform applies its box before these, and only when they will run), but for the group +/// nesting limit ([`photocraft_doc::MAX_GROUP_DEPTH`]): grouping layers already that deep is +/// refused only as the edit is made. Layer via Copy and +/// Cut aren't: whether they have anything to copy (a selection over transparent pixels, a group +/// or fill layer) is known only when they run. +pub const PRECHECKED: &[&str] = &["layer.new.layer", "layer.new.group", "layer.new.groupFromLayers", "layer.new.layerFromBackground"]; + #[derive(Default)] pub struct Session { docs: Vec, @@ -399,9 +447,9 @@ pub struct Session { coalesce_request: Option, /// Pixels copied with Edit › Copy / Cut (shared by all documents, like Photoshop). pub clipboard: Option, - /// Layer › Layer Style › Copy Layer Style: effects, blend mode, fill opacity and the Advanced - /// Blending switches. - pub style_clipboard: Option<(photocraft_doc::Effects, photocraft_color::BlendMode, f32, photocraft_doc::AdvancedBlending)>, + /// Layer › Layer Style › Copy Layer Style: the effects and the Blending Options + /// ([`CopiedStyle`]: blend mode, fill opacity, Advanced Blending, Blend If, channels). + pub style_clipboard: Option, /// Shape path context menu: copied vector fill and stroke styles. pub path_fill_clipboard: Option, pub path_stroke_clipboard: Option, @@ -438,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, @@ -626,6 +677,53 @@ impl Session { self.live_commit = None; } + /// Check a call without running it: the command exists, its params have the shape + /// [`Self::execute`] requires (New Layer's fully), and it can run now. For a caller that does + /// something first only when the command will go through: Free Transform applies its box + /// before a new layer is made. + /// + /// Complete for the commands in [`PRECHECKED`]: there it accepts what `execute` accepts, but + /// for the group nesting limit (a test holds the two together). For others it checks only what they share. + pub fn precheck(&mut self, id: &str, params: &Value) -> Result<()> { + let spec = crate::commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + if !(params.is_object() || params.is_null()) { + return Err(EngineError::BadParams { cmd: id.to_string(), msg: "params must be a JSON object".into() }); + } + crate::param_check::check(spec, params)?; + crate::filters::validate_params(id, params)?; + if id == "layer.new.layer" { + crate::new_layer::parse(params)?; + } + // A command that takes `"layer"` (an id or a name, as it resolves it) fails on one the + // document doesn't have. + if spec.params.contains("\"layer\":id") + && let Some(l) = commands::explicit_layer(self, params)? + && self.active().is_none_or(|st| st.doc.layer(l).is_none()) + { + return Err(EngineError::BadParams { cmd: id.to_string(), msg: format!("no layer {}", l.0) }); + } + self.admit(id, spec, params).map(|_| ()) + } + + /// What `dispatch` checks once the params are valid and a floating selection has dropped: + /// the precondition (on a named layer for commands that take one), a job running on the + /// document, a hidden target. The run params (the Channels panel's target filled in) when it + /// may run. + pub(crate) fn admit(&mut self, id: &str, spec: &commands::CommandSpec, params: &Value) -> Result { + let run_params = channel_cmds::inject_target(self, id, commands::inject_kind(id, params.clone())); + if let Err(why) = self.explicit_layer_enabled(spec, params).unwrap_or_else(|| self.precondition(spec, &run_params)) { + return Err(EngineError::Disabled(id.to_string(), why)); + } + if let Some(why) = self.job_conflict(id, spec.journal) { + return Err(EngineError::Disabled(id.to_string(), why)); + } + // Painting on or moving a hidden layer is refused, as in Photoshop (#571). + if let Some(why) = hidden_target::refusal(self, id, params) { + return Err(EngineError::Other(why.into())); + } + Ok(run_params) + } + /// Is the command currently runnable? (drives menu enablement) pub fn is_enabled(&self, id: &str) -> bool { self.is_enabled_with(id, &Value::Null) @@ -690,6 +788,10 @@ impl Session { /// Apply an undoable edit to the active document. pub fn edit(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Option) -> Result) -> Result { + // Every mutation path (linked-file refresh, transform, place) re-renders smart objects + // through this entry; the requesting session's authority must reach those renders. + let _authorization = crate::jobs::render_authorization_scope(self.authorize); + let _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)?; @@ -739,7 +841,7 @@ impl Session { if key.is_none() || st.coalesce != key || !st.history.can_undo() { st.history.set_current_layers(prior); st.history.record(label, before, layers); - st.history.trim(&st.doc); + st.trim_history(); } else { st.history.set_current_layers(layers); } diff --git a/crates/engine/src/link_watch.rs b/crates/engine/src/link_watch.rs index 5c235fb919..453af7806b 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}; @@ -32,15 +35,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 +66,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 +92,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() { @@ -103,6 +122,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 +180,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 +305,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:?}"), + } + } } diff --git a/crates/engine/src/mask_view_cmds/tests.rs b/crates/engine/src/mask_view_cmds/tests.rs index 33e36b1421..ce9bffa744 100644 --- a/crates/engine/src/mask_view_cmds/tests.rs +++ b/crates/engine/src/mask_view_cmds/tests.rs @@ -10,7 +10,6 @@ fn session() -> (Session, u64, u64) { s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); s.execute("select.rect", json!({"x": 20, "y": 0, "width": 20, "height": 20})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); let st = s.active_mut().unwrap(); st.saved_revision = st.revision; (s, masked, bg) @@ -96,7 +95,7 @@ fn bad_params_and_no_mask_fail_gracefully() { for p in [ json!({"layer": bg, "mode": "gray"}), json!({"layer": bg}), - json!({"layer": 999_999, "mode": "gray"}), + json!({"layer": u64::MAX, "mode": "gray"}), json!({"layer": "x"}), json!({"layer": -1}), json!({"mode": 3}), diff --git a/crates/engine/src/migrate_cmds.rs b/crates/engine/src/migrate_cmds.rs index 18d2be8017..68fbffd25b 100644 --- a/crates/engine/src/migrate_cmds.rs +++ b/crates/engine/src/migrate_cmds.rs @@ -40,12 +40,14 @@ fn migrate(s: &mut Session, p: &Value) -> Result { let st = merge_groups(&mut ps.styles, groups(&v, "styles")); let sh = merge_groups(&mut ps.shapes, groups(&v, "shapes")); let pt = merge_groups(&mut ps.pattern_groups, groups(&v, "pattern_groups")); - // Tool presets are a flat list, matched by name. + // Tool presets are a flat list, matched by name. Read them like the launch loader does (entry by + // entry, checked names), and keep the library within its cap, so what we import survives a restart. let mut tp = 0usize; - if let Some(arr) = v.get("tool_presets").cloned() - && let Ok(incoming) = serde_json::from_value::>(arr) - { - for t in incoming { + if let Some(arr) = v.get("tool_presets").cloned() { + for t in crate::presets::tools::load(arr) { + if ps.tool_presets.len() >= crate::presets::tools::MAX_PRESETS { + break; + } if !ps.tool_presets.iter().any(|e| e.name == t.name) { ps.tool_presets.push(t); tp += 1; @@ -151,6 +153,46 @@ mod tests { let _ = std::fs::remove_file(file); } + #[test] + fn tool_presets_are_read_entry_by_entry_like_the_launch_loader() { + use crate::presets::tools::MAX_PRESETS; + let file = std::env::temp_dir().join(format!("pc-migrate-tools-{}.json", std::process::id())); + let migrate = |s: &mut Session, data: Value| { + std::fs::write(&file, serde_json::to_string(&data).unwrap()).unwrap(); + s.execute("edit.presets.migratePresets", json!({"path": file.to_string_lossy()})).unwrap() + }; + // A damaged entry no longer discards the good ones; blank and overlong names are skipped. + let mut s = Session::new(); + let before = s.presets.tool_presets.len(); + let r = migrate( + &mut s, + json!({"tool_presets": [ + {"name": "4x6 print", "tool": "crop", "options": {}}, + {"name": "old", "tool": null}, + {"name": " ", "tool": "crop"}, + {"name": "x".repeat(300), "tool": "crop"}, + {"name": " Padded ", "tool": "brush", "options": 5}, + ]}), + ); + assert_eq!(r["added"]["toolPresets"], 2); + assert_eq!(s.presets.tool_presets.len(), before + 2); + assert!(s.presets.tool_presets.iter().any(|t| t.name == "4x6 print")); + let padded = s.presets.tool_presets.iter().find(|t| t.name == "Padded").expect("trimmed name"); + assert_eq!(padded.options, json!({})); + // What was imported survives the next launch. + let mut restored = Session::new(); + restored.load_prefs_json(&s.prefs_to_json()).unwrap(); + assert_eq!(restored.presets.tool_presets, s.presets.tool_presets); + // The library stays within MAX_PRESETS. + let mut s = Session::new(); + let many: Vec = (0..MAX_PRESETS + 500).map(|i| json!({"name": format!("p{i}"), "tool": "brush"})).collect(); + let before = s.presets.tool_presets.len(); + let r = migrate(&mut s, json!({"tool_presets": many})); + assert_eq!(s.presets.tool_presets.len(), MAX_PRESETS); + assert_eq!(r["added"]["toolPresets"], MAX_PRESETS - before); + let _ = std::fs::remove_file(file); + } + #[test] fn bad_path_is_an_error() { let mut s = Session::new(); diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index fe22a192c7..c28b17689a 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -63,7 +63,8 @@ pub fn rotated_size(size: Size, deg: f64) -> Size { pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { const CMD: &str = "image.rotation.arbitrary"; - let angle = f64::from(num(p, "angle", 0.0)); + // At full precision: an f32 angle can grow the canvas 1 px past what `rotated_size` gives callers. + let angle = p.get("angle").and_then(Value::as_f64).unwrap_or(0.0); if !angle.is_finite() || angle.abs() > 3600.0 { return Err(bad(CMD, "angle out of range")); } @@ -75,15 +76,18 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { if deg.rem_euclid(360.0).abs() < 1e-9 { return Ok(json!({"width": s.active().map(|d| d.doc.size.width), "height": s.active().map(|d| d.doc.size.height)})); } - let bg = s.tools.background; + // The Background stays opaque, whatever the background colour's alpha. + let bg = [s.tools.background[0], s.tools.background[1], s.tools.background[2], 1.0]; // Color Sampler points turn with the canvas (the same map the edit applies to the geometry). + // They are pixel indices, so map the pixel's centre, as the warp samples the pixels. { let old = s.active().ok_or(EngineError::NoDocument)?.doc.size; let new = rotated_size(old, deg); let (sn, cs) = deg.to_radians().sin_cos(); let (cx, cy) = (f64::from(old.width) / 2.0, f64::from(old.height) / 2.0); let (nx, ny) = (f64::from(new.width) / 2.0, f64::from(new.height) / 2.0); - s.set_sampler_map(Affine { m: [cs, sn, -sn, cs, nx - (cs * cx - sn * cy), ny - (sn * cx + cs * cy)] }); + let a = Affine { m: [cs, sn, -sn, cs, nx - (cs * cx - sn * cy), ny - (sn * cx + cs * cy)] }; + s.set_sampler_map(Affine::translate(0.5, 0.5).then(&a).then(&Affine::translate(-0.5, -0.5))); } let size = s.edit("Rotate Canvas", |doc, _| { let old = doc.size; @@ -108,7 +112,8 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { let rotated = photocraft_algo::transform::warp_surface(surf, src, &h, interp)?; let mut base = Surface::new(fmt); let fill = photocraft_raster::from_rgba(&fmt, bg); - base.write_region(canvas, &fill.repeat(canvas.width() as usize * canvas.height() as usize)); + // One shared tile per full tile, not a canvas-sized float buffer. + base.fill_rect(canvas, &fill); crate::transform_cmds::composite_over(&mut base, &rotated); base.prune(); *surf = base; @@ -444,6 +449,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..e6892680f7 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -81,6 +81,27 @@ fn rotate_arbitrary_other_modes() { } } +#[test] +fn rotate_arbitrary_keeps_the_background_opaque_with_a_translucent_background_colour() { + let mut s = session(40, 20, 8, "rgb"); + s.execute("tools.setColors", json!({"background": "#00ff0080"})).unwrap(); + s.execute("image.rotation.arbitrary", json!({"angle": 30})).unwrap(); + let corner = doc(&s).layers[0].surface().unwrap().rgba(0, 0); + assert_eq!(corner, [0.0, 1.0, 0.0, 1.0], "the uncovered corner is opaque background colour"); +} + +#[test] +fn rotate_arbitrary_keeps_the_full_precision_angle() { + // Rounded to f32 this angle crosses a whole pixel: 1003 px instead of 1002, so a rotated crop + // that sized its canvas with `rotated_size` at f64 would land 1 px off. + let angle = 0.114_706_514_493_370_71; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1000, "height": 1000})).unwrap(); + let r = s.execute("image.rotation.arbitrary", json!({"angle": angle})).unwrap(); + assert_eq!(rotated_size(Size::new(1000, 1000), angle), Size::new(1002, 1002)); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(1002), Some(1002))); +} + #[test] fn rotate_arbitrary_rotates_grouped_locked_children_and_preserves_lock() { let mut s = Session::new(); @@ -353,6 +374,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"); diff --git a/crates/engine/src/new_layer.rs b/crates/engine/src/new_layer.rs index a6f4cefd38..cb1b151aba 100644 --- a/crates/engine/src/new_layer.rs +++ b/crates/engine/src/new_layer.rs @@ -24,9 +24,49 @@ pub fn neutral_color(mode: BlendMode) -> Option<([f32; 3], &'static str)> { } } +/// A New Layer call's params, checked. +pub(crate) struct NewLayer { + clip: bool, + mode: BlendMode, + opacity: f32, + label: LabelColor, + neutral: Option<[f32; 3]>, + below: bool, + name: Option, +} + /// `layer.new.layer`: a pixel layer above the active one (below it with `position: below`, the /// Layers panel's ⌘/Ctrl-click), joining the clip group it lands in. pub fn run(s: &mut Session, p: &Value) -> Result { + let NewLayer { clip, mode, opacity, label, neutral, below, name } = parse(p)?; + let id = s.edit("New Layer", |doc, active| { + let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer")); + let mut layer = Layer::raster(name, doc.pixel_format()); + layer.blend = mode; + layer.opacity = opacity; + layer.label = label; + if let Some(rgb) = neutral { + let bounds = doc.bounds(); + let surface = layer.surface_mut().ok_or_else(|| bad("a new pixel layer has no pixels"))?; + let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]); + surface.fill_rect(bounds, &px); + } + // Nothing may sit under the Background: below it the layer lands above it instead. + let id = match *active { + Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer), + _ => doc.insert_above(*active, layer), + }; + crate::clip_group::settle(doc, &[id], &Default::default(), clip); + *active = Some(id); + Ok(id) + })?; + crate::layer_multi_cmds::note_inert_insert(s, id); + Ok(json!({ "layer": id.0 })) +} + +/// Check a New Layer call's params without running it (Free Transform applies its box before a +/// new layer only when the new layer can be made). +pub(crate) fn parse(p: &Value) -> Result { let flag = |k: &str| match p.get(k) { None | Some(Value::Null) => Ok(false), Some(v) => v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))), @@ -62,29 +102,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => None, Some(v) => Some(v.as_str().ok_or_else(|| bad("`name` must be a string"))?.to_string()), }; - let id = s.edit("New Layer", |doc, active| { - let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer")); - let mut layer = Layer::raster(name, doc.pixel_format()); - layer.blend = mode; - layer.opacity = opacity; - layer.label = label; - if let Some(rgb) = neutral { - let bounds = doc.bounds(); - let surface = layer.surface_mut().ok_or_else(|| bad("a new pixel layer has no pixels"))?; - let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]); - surface.fill_rect(bounds, &px); - } - // Nothing may sit under the Background: below it the layer lands above it instead. - let id = match *active { - Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer), - _ => doc.insert_above(*active, layer), - }; - crate::clip_group::settle(doc, &[id], &Default::default(), clip); - *active = Some(id); - Ok(id) - })?; - crate::layer_multi_cmds::note_inert_insert(s, id); - Ok(json!({ "layer": id.0 })) + Ok(NewLayer { clip, mode, opacity, label, neutral, below, name }) } #[cfg(test)] diff --git a/crates/engine/src/notes_cmds.rs b/crates/engine/src/notes_cmds.rs index 54bbd60b7a..2f589f3390 100644 --- a/crates/engine/src/notes_cmds.rs +++ b/crates/engine/src/notes_cmds.rs @@ -40,10 +40,12 @@ fn list_json(d: &Document) -> Value { json!({"notes": d.notes.iter().enumerate().map(|(i, n)| note_json(i, n)).collect::>()}) } -fn color_of(p: &Value) -> Option { - p.get("color")?; - let c = crate::commands::color_param(p, "color", [1.0, 1.0, 0.51, 1.0]); - Some(Color::rgb(c[0], c[1], c[2])) +fn color_of(cmd: &str, p: &Value) -> Result> { + if p.get("color").is_none() { + return Ok(None); + } + let c = crate::commands::color_param(cmd, p, "color", [1.0, 1.0, 0.51, 1.0])?; + Ok(Some(Color::rgb(c[0], c[1], c[2]))) } fn index_of(s: &Session, p: &Value, cmd: &str) -> Result { @@ -68,7 +70,7 @@ fn add(s: &mut Session, p: &Value) -> Result { modified: pdf_date(), ..Default::default() }; - if let Some(c) = color_of(p) { + if let Some(c) = color_of(CMD, p)? { n.color = c; } let i = s.edit("New Note", |d, _| { @@ -90,7 +92,7 @@ fn set(s: &mut Session, p: &Value) -> Result { if let Some(a) = p.get("author").and_then(Value::as_str) { n.author = a.to_string(); } - if let Some(c) = color_of(p) { + if let Some(c) = color_of(CMD, p)? { n.color = c; } if let Some(o) = p.get("open").and_then(Value::as_bool) { @@ -106,6 +108,9 @@ fn set(s: &mut Session, p: &Value) -> Result { n.position = [nx, ny]; // The popup travels with its icon. n.popup = [n.popup[0] + dx, n.popup[1] + dy, n.popup[2] + dx, n.popup[3] + dy]; + if !n.popup.iter().all(|v| v.is_finite()) { + return Err(bad(CMD, "the move is too far: the note popup would leave the representable range")); + } } if n == d.notes[i] { return Ok(json!({"index": i, "note": note_json(i, &n), "changed": false})); @@ -279,6 +284,19 @@ mod tests { assert!(s.execute("notes.set", json!({"index": 0})).is_err()); } + #[test] + fn huge_move_is_rejected_and_document_stays_saveable() { + let mut s = session(); + s.execute("notes.add", json!({"x": 1.7e308, "y": 0})).unwrap(); + let before = s.active().unwrap().doc.notes[0].clone(); + let past = s.active().unwrap().history.past_len(); + assert!(s.execute("notes.set", json!({"index": 0, "x": -1.7e308})).is_err()); + let a = s.active().unwrap(); + assert_eq!(a.doc.notes[0], before); + assert_eq!(a.history.past_len(), past); + assert!(photocraft_format::save_to_bytes(&a.doc, &Default::default()).is_ok()); + } + #[test] fn bad_params() { let mut s = session(); diff --git a/crates/engine/src/path_edit_cmds.rs b/crates/engine/src/path_edit_cmds.rs index 6db84c270c..503ac8efae 100644 --- a/crates/engine/src/path_edit_cmds.rs +++ b/crates/engine/src/path_edit_cmds.rs @@ -80,6 +80,9 @@ fn move_anchors(s: &mut Session, p: &Value) -> Result { }) .collect::>()?; let d = offset(CMD, p)?; + if p.get("curvature").and_then(Value::as_bool).unwrap_or(false) { + return edit_path(s, CMD, p, "Drag Anchor Points", |path| edit::move_anchors_curvature(path, &anchors, d)); + } edit_path(s, CMD, p, "Drag Anchor Points", |path| edit::move_anchors(path, &anchors, d)) } @@ -106,6 +109,9 @@ fn bend_segment(s: &mut Session, p: &Value) -> Result { fn convert_point(s: &mut Session, p: &Value) -> Result { const CMD: &str = "path.convertPoint"; let k = knot(CMD, p)?; + if p.get("curvature").and_then(Value::as_bool).unwrap_or(false) { + return edit_path(s, CMD, p, "Convert Point", |path| edit::toggle_curvature_point(path, k)); + } let out = point(CMD, p, "out")?; edit_path(s, CMD, p, "Convert Point", |path| edit::convert_point(path, k, out)) } @@ -155,7 +161,7 @@ pub fn specs() -> Vec { journal: true, }; vec![ - spec("path.moveAnchors", "Drag Anchor Points", r#""anchors":[[subpath,knot],…],"move":[dx,dy] (anchors move with their handles; Direct Selection drag)"#.into(), move_anchors), + spec("path.moveAnchors", "Drag Anchor Points", r#""anchors":[[subpath,knot],…],"move":[dx,dy],"curvature":bool=false (anchors move with their handles; Direct Selection drag. curvature = the Curvature Pen's drag: every smooth knot of the touched subpaths gets its handles recomputed so the curve flows through the anchors)"#.into(), move_anchors), spec( "path.moveHandle", "Drag Direction Point", @@ -171,7 +177,7 @@ pub fn specs() -> Vec { spec( "path.convertPoint", "Convert Point", - r#""subpath":i,"knot":j,"out":[x,y]? (none: corner with retracted handles; given: smooth with this out handle and its mirror)"#.into(), + r#""subpath":i,"knot":j,"out":[x,y]? (none: corner with retracted handles; given: smooth with this out handle and its mirror),"curvature":bool=false (the Curvature Pen's double-click: toggles smooth/corner and reflows the smooth knots of the subpath; out ignored)"#.into(), convert_point, ), spec( @@ -226,6 +232,36 @@ mod tests { assert_eq!(work(&s), before); } + #[test] + fn a_curvature_anchor_drag_reflows_the_smooth_knots() { + let mut s = session(); + let smooth = |a: [f64; 2]| json!({"anchor": a, "in": a, "out": a, "smooth": true}); + let path = json!({"subpaths": [{"closed": true, "knots": [smooth([0.0, 50.0]), smooth([100.0, 0.0]), smooth([200.0, 50.0]), smooth([100.0, 100.0])]}]}); + s.execute("path.set", json!({"path": path})).unwrap(); + s.execute("path.moveAnchors", json!({"anchors": [[0, 1]], "move": [0, 60], "curvature": true})).unwrap(); + let p = work(&s); + let pts = [[0.0, 50.0], [100.0, 60.0], [200.0, 50.0], [100.0, 100.0]]; + let want = photocraft_vector::edit::curvature_knots(&pts, &[false; 4], true); + for (k, w) in p.subpaths[0].knots.iter().zip(want) { + assert_eq!([[k.anchor.x, k.anchor.y], [k.in_ctrl.x, k.in_ctrl.y], [k.out_ctrl.x, k.out_ctrl.y]], w); + } + assert_eq!(s.active().unwrap().history.undo_label(), Some("Drag Anchor Points")); + assert!(s.execute("path.moveAnchors", json!({"anchors": [[0, 9]], "move": [1, 0], "curvature": true})).is_err()); + } + + #[test] + fn a_curvature_convert_toggles_the_point_and_reflows() { + let mut s = session(); + s.execute("path.set", json!({"path": square()})).unwrap(); + let before = work(&s); + s.execute("path.convertPoint", json!({"subpath": 0, "knot": 1, "curvature": true})).unwrap(); + let k = work(&s).subpaths[0].knots[1]; + assert!(k.smooth && k.in_ctrl != k.anchor && k.out_ctrl != k.anchor, "{k:?}"); + s.execute("path.convertPoint", json!({"subpath": 0, "knot": 1, "curvature": true})).unwrap(); + assert_eq!(work(&s), before); + assert!(s.execute("path.convertPoint", json!({"subpath": 0, "knot": 9, "curvature": true})).is_err()); + } + #[test] fn a_coalesced_drag_is_one_history_step() { let mut s = session(); diff --git a/crates/engine/src/pattern_fill_cmds.rs b/crates/engine/src/pattern_fill_cmds.rs index daa7e9660f..b9d75e6cb5 100644 --- a/crates/engine/src/pattern_fill_cmds.rs +++ b/crates/engine/src/pattern_fill_cmds.rs @@ -225,7 +225,7 @@ mod tests { json!([1, 2]), json!({"layer": -1}), json!({"layer": "x"}), - json!({"layer": 999_999}), + json!({"layer": u64::MAX}), json!({"document": 999_999}), json!({"pattern": "nope"}), json!({"pattern": ""}), diff --git a/crates/engine/src/perspective_crop_cmds.rs b/crates/engine/src/perspective_crop_cmds.rs index 8acaf42ce2..0cd1f01604 100644 --- a/crates/engine/src/perspective_crop_cmds.rs +++ b/crates/engine/src/perspective_crop_cmds.rs @@ -188,7 +188,8 @@ fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect, fn apply(doc: &mut Document, q: &[[f64; 2]; 4], h: &Homography, size: (u32, u32), interp: Interp, bg: [f32; 4]) -> Result<()> { let (w, ht) = size; let clip = Rect::from_xywh(0, 0, w, ht); - let bg = photocraft_raster::from_rgba(&doc.pixel_format(), bg); + // The Background stays opaque, whatever the background colour's alpha. + let bg = photocraft_raster::from_rgba(&doc.pixel_format(), [bg[0], bg[1], bg[2], 1.0]); let background_id = doc.background().map(|l| l.id); warp_layers(&mut doc.layers, h, interp, clip, &bg, background_id)?; for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { @@ -298,6 +299,31 @@ mod tests { } } + /// Corners past the canvas: the uncovered Background is the background colour, opaque even + /// when that colour has alpha (the Background stays opaque). + #[test] + fn uncovered_background_is_opaque() { + let q = [[-20.0, -20.0], [180.0, -20.0], [180.0, 140.0], [-20.0, 140.0]]; + let mut s = skewed_ramp(SampleType::U8, &q, 100.0, 80.0); + s.execute("tools.setColors", json!({"background": "#00ff0080"})).unwrap(); + s.execute(CMD, json!({"corners": q, "width": 100, "height": 80})).unwrap(); + let corner = s.active().unwrap().doc.layers[0].surface().unwrap().rgba(0, 0); + assert_eq!(corner, [0.0, 1.0, 0.0, 1.0]); + } + + /// #7557: a quad low on a strongly converging photo (its horizon crosses the canvas, with the + /// canvas centre beyond it) is cropped, not left blank in the background colour. + #[test] + fn a_quad_below_a_horizon_across_the_canvas_keeps_its_content() { + let mut doc = Document::with_background("red", Size::new(100, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + doc.layers[0].surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 100, 100), &[1.0, 0.0, 0.0, 1.0]); + let mut s = Session::new(); + s.add_document(doc, None); + s.execute(CMD, json!({"corners": [[40, 70], [60, 70], [90, 95], [10, 95]], "width": 50, "height": 39})).unwrap(); + let px = s.active().unwrap().doc.layers[0].surface().unwrap().rgba(25, 20); + assert_eq!(px, [1.0, 0.0, 0.0, 1.0]); + } + /// Bad quads and params are refused with an error, and the document is untouched. #[test] fn perspective_crop_rejects_bad_quads() { diff --git a/crates/engine/src/photo_cmds.rs b/crates/engine/src/photo_cmds.rs index 09fd474aad..c729afd0d5 100644 --- a/crates/engine/src/photo_cmds.rs +++ b/crates/engine/src/photo_cmds.rs @@ -402,10 +402,18 @@ pub(crate) fn seam_blend(warped: &[Surface], canvas: Rect, order: &[usize], seam .collect() }) .collect(); - // Shift ROIs to canvas-relative coordinates for the blend. - let local: Vec = rois.iter().map(|r| RoiImage { x0: r.x0 - canvas.x0, y0: r.y0 - canvas.y0, ..r.clone() }).collect(); + // Shift ROIs to canvas-relative coordinates for the blend, and back after: a shifted copy + // would hold every source's pixels twice (#7758). + for r in &mut rois { + r.x0 -= canvas.x0; + r.y0 -= canvas.y0; + } let levels = if seamless { panorama::blend_levels(cw, chh).min(7) } else { 1 }; - let (blend, cov) = panorama::multiband(cw, chh, &local, &weights, levels); + let (blend, cov) = panorama::multiband(cw, chh, &rois, &weights, levels); + for r in &mut rois { + r.x0 += canvas.x0; + r.y0 += canvas.y0; + } let cc = fmt.mode.color_channels(); let nch = fmt.channels(); let mut composite = Surface::new(fmt); @@ -859,6 +867,7 @@ fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result { let mut nd = Document::new(format!("{} copy {}", doc.name, k + 1), Size::new(pw, ph), doc.mode, doc.depth); nd.icc_profile = doc.icc_profile.clone(); nd.resolution_dpi = doc.resolution_dpi; + nd.duotone = doc.duotone.clone(); let mut bg = Layer::new("Background", LayerContent::Raster(px)); bg.locks.transparency = true; bg.locks.position = true; @@ -871,8 +880,13 @@ fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result { // ---------- specs ---------- -fn has_doc(s: &Session) -> std::result::Result<(), String> { - s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +/// Photoshop greys Crop and Straighten Photos out for Indexed Color documents. +fn not_indexed(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.doc.mode == ColorMode::Indexed { + return Err("not available for Indexed Color documents".into()); + } + Ok(()) } fn always(_: &Session) -> std::result::Result<(), String> { @@ -907,7 +921,7 @@ pub fn specs() -> Vec { menu: &["File", "Automate"], shortcut: None, params: "{} → one new document per photo found on the scan", - enabled: has_doc, + enabled: not_indexed, run: crop_and_straighten, journal: true, }, @@ -1106,6 +1120,54 @@ mod tests { if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } } + /// A light scan with one dark tilted print on it. + fn scan_with_one_photo() -> Document { + let (w, h) = (300usize, 220usize); + let mut px = vec![[0.98f32, 0.98, 0.98, 1.0]; w * h]; + let (s, co) = 5.0f64.to_radians().sin_cos(); + for y in 0..h { + for x in 0..w { + let (dx, dy) = (x as f64 + 0.5 - 150.0, y as f64 + 0.5 - 110.0); + let (u, v) = (co * dx + s * dy, -s * dx + co * dy); + if u.abs() < 80.0 && v.abs() < 55.0 { + px[y * w + x] = [0.2 + (u / 160.0) as f32 * 0.3, 0.25, 0.3, 1.0]; + } + } + } + doc_from("scan", &px, w, h, SampleType::U8) + } + + #[test] + fn crop_and_straighten_keeps_duotone_inks() { + let mut s = Session::new(); + s.add_document(scan_with_one_photo(), None); + s.execute("image.mode.grayscale", json!({})).unwrap(); + s.execute( + "image.mode.duotone", + json!({"type": "duotone", "inks": [{"name": "Black", "color": [0, 0, 0]}, {"name": "Orange", "color": [255, 128, 0]}]}), + ) + .unwrap(); + let src = s.documents()[0].doc.duotone.clone(); + assert!(src.is_some()); + let r = s.execute("file.automate.cropAndStraightenPhotos", json!({})).unwrap(); + assert_eq!(r["photos"].as_array().unwrap().len(), 1, "{r}"); + let d = &s.documents()[1].doc; + assert_eq!(d.mode, ColorMode::Duotone); + assert_eq!(d.duotone, src); + } + + #[test] + fn crop_and_straighten_disabled_for_indexed() { + // Photoshop greys the command out for Indexed Color documents. + let mut s = Session::new(); + s.add_document(scan_with_one_photo(), None); + s.execute("image.mode.indexedColor", json!({})).unwrap(); + assert_eq!(s.documents()[0].doc.mode, ColorMode::Indexed); + assert!(!s.is_enabled("file.automate.cropAndStraightenPhotos")); + assert!(s.execute("file.automate.cropAndStraightenPhotos", json!({})).is_err()); + assert_eq!(s.documents().len(), 1); + } + #[test] fn crop_and_straighten_makes_documents() { let (w, h) = (500usize, 360usize); diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index cde7fa41db..b046de03bd 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -28,6 +28,36 @@ pub fn composite_layers(solo: &Document, format: photocraft_color::PixelFormat, photocraft_compose::composite_to_surface(solo, area, format, background) } +/// [`composite_layers`] without cropping to the canvas: pixels of the layers that lie outside it are kept +/// (merging layers must not lose them, #5067). +pub fn composite_layers_uncropped(solo: &Document, format: photocraft_color::PixelFormat) -> Surface { + let canvas = solo.bounds(); + // The canvas grown to every visible layer's content and effects, so no layer is clipped by `composite_bounds`. + let reach = solo + .layers + .iter() + .filter(|layer| layer.visible) + .map(|layer| { + let margin = if photocraft_compose::effects::has_effects(layer) { photocraft_compose::effects::margin(layer) } else { 0 }; + let bounds = photocraft_compose::layer_bounds(layer, canvas); + if bounds.is_empty() { bounds } else { bounds.inflate(margin) } + }) + .filter(|bounds| !bounds.is_empty()) + .fold(canvas, |a, b| a.union(&b)); + let area = solo + .layers + .iter() + .filter(|layer| layer.visible) + .map(|layer| photocraft_compose::composite_bounds(layer, reach).unwrap_or(canvas)) + .filter(|bounds| !bounds.is_empty()) + .reduce(|a, b| a.union(&b)) + .unwrap_or(Rect::EMPTY); + if area.is_empty() { + return Surface::new(format); + } + photocraft_compose::composite_to_surface(solo, area, format, None) +} + /// Apply an adjustment destructively to a surface, weighted by an optional selection. /// Applies `adj` to a surface (any colour model and depth, via straight RGBA) through the /// selection; `mode` is the document's, for the tone transfer (e.g. Exposure in Grayscale). @@ -92,23 +122,39 @@ pub fn adjust_mask(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface fn adjusted(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>, mode: photocraft_color::ColorMode) -> Vec { let fmt = s.format(); if fmt.mode == mode && matches!(mode, photocraft_color::ColorMode::Lab | photocraft_color::ColorMode::Cmyk) && adjust::stored_channel_adjustment(adj) { - return adjusted_channels(s, r, adj, selection); + return adjusted_channels(s, r, selection, |ch| adjust::stored_channels(adj, fmt.mode, Some(fmt.sample), ch)); + } + if fmt.mode == mode && mode == photocraft_color::ColorMode::Cmyk && adjust::cmy_adjustment(adj) { + return adjusted_cmy(s, r, adj, selection); + } + if let Adjustment::BrightnessContrast { brightness, contrast, legacy } = adj + && fmt.mode == mode + && mode == photocraft_color::ColorMode::Cmyk + { + let table = adjust::ink_brightness_contrast_lut(*brightness, *contrast, *legacy); + return adjusted_channels(s, r, selection, |ch| ch.iter_mut().for_each(|v| *v = adjust::ink_through(&table, *v))); } let n = fmt.channels(); let raw = s.read_region(r); let mut buf = Buffer { rect: r, px: raw.chunks_exact(n).map(|p| to_rgba(&fmt, p)).collect() }; let orig = buf.clone(); // 32-bit documents get the float behaviour of an adjustment layer there (Levels doesn't clip); - // integer depths keep the unrounded, clipped curves. - let depth = (fmt.sample == photocraft_color::SampleType::F32).then_some(fmt.sample); + // integer depths keep the unrounded, clipped curves, except that 16-bit Posterize and + // Threshold use the 16-bit rules an adjustment layer measures against Photoshop, so the + // command matches its live preview (an adjustment layer) and Photoshop. + let depth = match (fmt.sample, adj) { + (photocraft_color::SampleType::F32, _) => Some(fmt.sample), + (photocraft_color::SampleType::U16, Adjustment::Posterize { .. } | Adjustment::Threshold { .. }) => Some(fmt.sample), + _ => None, + }; adjust::apply_in(adj, &mut buf, adjust::Transfer::for_document(mode, fmt.sample), depth, mode); mix_through_selection(&fmt, r, &orig.px, &buf.px, selection) } -/// [`adjusted`] for an adjustment Photoshop applies to a Lab or CMYK layer's own channels -/// (`adjust::stored_channels`): exact on the stored values, with no trip through RGB, and mixed -/// through the selection on those values (alpha untouched). -fn adjusted_channels(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>) -> Vec { +/// [`adjusted`] for an adjustment Photoshop applies to a Lab or CMYK layer's own channels: `op` +/// changes one pixel's colour channels, exact on the stored values with no trip through RGB, and +/// the selection mixes those values (alpha untouched). +fn adjusted_channels(s: &Surface, r: Rect, selection: Option<&Surface>, op: impl Fn(&mut [f32])) -> Vec { let fmt = s.format(); let (n, colors) = (fmt.channels(), fmt.mode.color_channels()); let mut raw = s.read_region(r); @@ -122,13 +168,11 @@ fn adjusted_channels(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<& if k <= 0.0 { continue; } - let p = to_rgba(&fmt, px); - let gray = photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]]); let Some(ch) = px.get_mut(..colors) else { continue }; let mut out = [0.0f32; 4]; let Some(out) = out.get_mut(..colors) else { continue }; out.copy_from_slice(ch); - adjust::stored_channels(adj, fmt.mode, Some(fmt.sample), out, gray); + op(out); for (v, o) in ch.iter_mut().zip(out.iter()) { *v += (o - *v) * k; } @@ -137,6 +181,29 @@ fn adjusted_channels(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<& raw } +/// [`adjusted`] for Hue/Saturation or Color Balance on a CMYK layer (`adjust::apply_cmy`): on its +/// stored C, M and Y, black kept, the whole region at once; the selection mixes the stored inks. +fn adjusted_cmy(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>) -> Vec { + let fmt = s.format(); + let n = fmt.channels(); + let mut raw = s.read_region(r); + let mut inks: Vec<[f32; 4]> = raw.chunks_exact(n).map(|p| std::array::from_fn(|c| p.get(c).copied().unwrap_or(0.0))).collect(); + adjust::apply_cmy(adj, &mut inks); + let w = (r.width() as usize).max(1); + let mut coverage = vec![1.0f32; w]; + for (j, (row, adjusted)) in raw.chunks_mut(w * n).zip(inks.chunks(w)).enumerate() { + if let Some(sel) = selection { + sel.sample_row_strided(r.y0.saturating_add(j as i32), r.x0, 1, 0, &mut coverage); + } + for ((px, ink), &k) in row.chunks_exact_mut(n).zip(adjusted).zip(&coverage) { + for (v, a) in px.iter_mut().zip(ink) { + *v += (a - *v) * k; + } + } + } + raw +} + /// `edited` mixed back over `orig` (straight RGBA over `r`, row-major) by the selection's /// coverage, encoded in `fmt`: what a destructive edit writes back through a selection. pub(crate) fn mix_through_selection( @@ -339,7 +406,8 @@ pub fn merge_down(doc: &Document, lower: &Layer, upper: &Layer) -> Layer { // The pair is read (and a CMYK one separated again) through the document's own profile. tmp.icc_profile = doc.icc_profile.clone(); tmp.layers = stack; - let s = composite_layers(&tmp, format, None); + // A Background stays canvas-sized; any other layer keeps the pixels outside the canvas (#5067). + let s = if doc.is_background(lower.id) { composite_layers(&tmp, format, None) } else { composite_layers_uncropped(&tmp, format) }; let mut merged = Layer::new(lower.name.clone(), LayerContent::Raster(s)); merged.id = lower.id; merged.blend = lower.blend; @@ -347,5 +415,10 @@ pub fn merge_down(doc: &Document, lower: &Layer, upper: &Layer) -> Layer { // Merge Down replaces the lower layer, so keep its editing restrictions. // In particular, a merged Background must remain opaque and position-locked. merged.locks = lower.locks; + // And its place: two layers clipped in one chain merge into a layer still clipped to the base, + // with the lower layer's colour label and link. + merged.clipped = lower.clipped && upper.clipped; + merged.label = lower.label; + merged.link_group = lower.link_group; merged } diff --git a/crates/engine/src/pixels_tests.rs b/crates/engine/src/pixels_tests.rs index 965544307a..5af30d0e76 100644 --- a/crates/engine/src/pixels_tests.rs +++ b/crates/engine/src/pixels_tests.rs @@ -349,3 +349,48 @@ fn invert_posterize_and_threshold_change_lab_and_cmyk_channels_as_stored() { assert_levels(&adjusted_pixel(lab8, &f([128.0, 30.0, 220.0, 255.0]), &level, None), &[255.0, 128.0, 128.0, 255.0], "Lab threshold light"); assert_levels(&adjusted_pixel(lab16, &f([127.0, 30.0, 220.0, 255.0]), &level, None), &[0.0, 128.0, 128.0, 255.0], "Lab threshold dark"); } + +/// Image › Adjustments › Brightness/Contrast on a CMYK layer curves each stored ink value +/// (1 - ink) exactly, modern and legacy, and a selection mixes the stored inks. +#[test] +fn brightness_contrast_curves_cmyk_inks_as_stored() { + let cmyk = PixelFormat::new(ColorMode::Cmyk, SampleType::U16, true); + let ink = [0.2, 0.4, 0.6, 0.1, 1.0]; + for legacy in [false, true] { + let adj = Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy }; + let table = adjust::ink_brightness_contrast_lut(25.0, 30.0, legacy); + let got = adjusted_pixel(cmyk, &ink, &adj, None); + for c in 0..4 { + let want = adjust::ink_through(&table, ink[c]); + assert!((got[c] - want).abs() < 1e-4, "legacy {legacy} ink {c}: {} vs {want}", got[c]); + } + assert_eq!(got[4], 1.0); + let half = adjusted_pixel(cmyk, &ink, &adj, Some(0.5)); + for c in 0..4 { + let want = ink[c] + (adjust::ink_through(&table, ink[c]) - ink[c]) * 128.0 / 255.0; + assert!((half[c] - want).abs() < 1e-3, "legacy {legacy} ink {c} through half a selection"); + } + } +} + +/// Image › Adjustments › Hue/Saturation and Color Balance on a CMYK layer work on its stored C, M +/// and Y as inverted RGB, keep its black ink exactly, and mix through a selection. +#[test] +fn hue_saturation_and_color_balance_keep_a_cmyk_layers_black() { + let cmyk = PixelFormat::new(ColorMode::Cmyk, SampleType::U16, true); + let ink = [0.2, 0.7, 0.1, 0.3, 1.0]; + let hue = + Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: -10.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() }; + let balance = + Adjustment::ColorBalance { shadows: [20.0, -10.0, 0.0], midtones: [-15.0, 10.0, 30.0], highlights: [0.0, 0.0, -20.0], preserve_luminosity: true }; + for adj in [hue, balance] { + let mut want = [[ink[0], ink[1], ink[2], ink[3]]]; + adjust::apply_cmy(&adj, &mut want); + let got = adjusted_pixel(cmyk, &ink, &adj, None); + assert!((0..4).all(|c| (got[c] - want[0][c]).abs() < 1e-4), "{}: {got:?} vs {want:?}", adj.label()); + assert!((got[3] - ink[3]).abs() < 1e-4 && got[4] == 1.0, "{}: black and alpha kept", adj.label()); + let half = adjusted_pixel(cmyk, &ink, &adj, Some(0.5)); + let k = 128.0 / 255.0; + assert!((0..4).all(|c| (half[c] - (ink[c] + (want[0][c] - ink[c]) * k)).abs() < 1e-3), "{}: half a selection", adj.label()); + } +} diff --git a/crates/engine/src/plugin_cmds.rs b/crates/engine/src/plugin_cmds.rs index 5c7d2dd71a..a2044f5e8c 100644 --- a/crates/engine/src/plugin_cmds.rs +++ b/crates/engine/src/plugin_cmds.rs @@ -77,10 +77,17 @@ fn run(s: &mut Session, p: &Value) -> Result { *surf = plugin.apply(surf, canvas, selection.as_ref(), &resolved).map_err(err)?; return Ok(()); } + crate::commands::check_pixels_unlocked(doc, layer)?; + let keep_alpha = doc.effective_locks(layer).transparency; let l = doc.layer_mut(layer).ok_or(EngineError::NoLayer(layer))?; match &mut l.content { LayerContent::Raster(surf) => { + // Lock Transparency: the plug-in may change colour but not alpha (as built-in filters). + let before = keep_alpha.then(|| surf.clone()); *surf = plugin.apply(surf, canvas, selection.as_ref(), &resolved).map_err(err)?; + if let Some(before) = &before { + crate::filters::keep_alpha(before, surf); + } Ok(()) } LayerContent::Smart(_) => { diff --git a/crates/engine/src/plugin_cmds/tests.rs b/crates/engine/src/plugin_cmds/tests.rs index 3d178fada9..39bb1acc7d 100644 --- a/crates/engine/src/plugin_cmds/tests.rs +++ b/crates/engine/src/plugin_cmds/tests.rs @@ -155,6 +155,56 @@ fn params_are_validated_and_passed() { Session::new().execute("plugin.remove", json!({"id": "test.cmds.grey"})).unwrap(); } +#[test] +fn locked_layers_refuse_plugin_filters() { + // Like built-in filters: locked pixels (or Lock All) refuse the run with no undo step. + let id = "test.cmds.locks"; + Session::new().execute("plugin.install", json!({"data": b64(&wat_plugin(id, "{}", GREY))})).unwrap(); + for lock_all in [false, true] { + let mut s = doc(16); + let layer = s.active().unwrap().active_layer.unwrap(); + s.edit("lock", |doc, _| { + let l = doc.layer_mut(layer).unwrap(); + if lock_all { + l.locks.all = true; + } else { + l.locks.pixels = true; + } + Ok(()) + }) + .unwrap(); + let before = pixels(&s); + let r = s.execute("plugin.run", json!({"id": id})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("is locked")), "lock_all {lock_all}: {r:?}"); + assert_eq!(pixels(&s), before, "lock_all {lock_all}"); + s.execute("edit.undo", json!({})).unwrap(); + let l = s.active().unwrap().doc.layer(layer).unwrap(); + assert!(!l.locks.pixels && !l.locks.all, "the refused run left no undo step"); + // Unlocked, the plug-in runs. + s.execute("plugin.run", json!({"id": id})).unwrap(); + assert_ne!(pixels(&s), before); + } + // Lock Transparency: the run changes colour but keeps every pixel's alpha (GREY sets alpha too). + let mut s = doc(16); + let layer = s.active().unwrap().active_layer.unwrap(); + s.edit("lock", |doc, _| { + doc.layer_mut(layer).unwrap().locks.transparency = true; + Ok(()) + }) + .unwrap(); + let before = pixels(&s); + s.execute("plugin.run", json!({"id": id})).unwrap(); + let after = pixels(&s); + assert_ne!(after, before); + for (a, b) in after.as_chunks::<4>().0.iter().zip(before.as_chunks::<4>().0) { + assert_eq!(a[3], b[3], "alpha kept"); + if b[3] > 0.0 { + assert!((a[0] - 0.5).abs() < 1e-3, "colour changed: {a:?}"); + } + } + Session::new().execute("plugin.remove", json!({"id": id})).unwrap(); +} + #[test] fn hostile_plugins_fail_without_touching_the_document() { for (id, body) in [ diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 5e728cb546..77acd2e346 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -257,6 +257,9 @@ pub struct Interface { /// Show the repeating dot pattern on the canvas surround (including the empty workspace). /// Disable for a uniform background, for example on OLED displays (#2264). pub show_canvas_dots: bool, + /// List recent files on the Home screen (the empty workspace). Off hides the list; the + /// recent files themselves are kept and File > Open Recent still shows them. + pub show_home_recent_files: bool, /// Document border in standard screen mode. pub canvas_border: CanvasBorder, pub ui_scale: UiScale, @@ -269,6 +272,9 @@ pub struct Interface { pub dynamic_color_sliders: bool, /// Draw menu item colours set with Edit › Menus. pub show_menu_colors: bool, + /// Layers panel thumbnails frame the layer's own pixels instead of the whole canvas, so small + /// objects in a large document stay visible (Photoshop's Clip Thumbnails to Layer Bounds; #4281). + pub clip_thumbnails_to_layer_bounds: bool, pub show_tooltips: bool, /// Menus, popups and collapsing sections fade and slide, and scroll areas glide to a new /// position. Off, everything changes at once (a reduced-motion mode; #1218, #3273). @@ -280,6 +286,10 @@ pub struct Interface { /// own one-row title bar (tiling window managers, desktops that draw their own decorations; /// #1271, #1316). Read when the app starts. macOS always uses the system's. pub system_title_bar: bool, + /// Show the active document's file name in the title bar and the OS window title. The tabs + /// already name the file, so this can be turned off for a plain "PhotoCraft" title (#5037). + /// The unsaved-changes mark stays either way. + pub show_file_name_in_title: bool, /// Notices (the lower-right cards) hide themselves after /// [`Interface::notification_duration_seconds`] unless the pointer rests on them (#2022). pub notification_auto_hide: bool, @@ -298,6 +308,7 @@ impl Default for Interface { canvas_color: CanvasColor::Default, canvas_custom_color: "#282828".into(), show_canvas_dots: true, + show_home_recent_files: true, canvas_border: CanvasBorder::DropShadow, ui_scale: UiScale::Auto, language: "auto".into(), @@ -305,10 +316,12 @@ impl Default for Interface { show_channels_in_color: false, dynamic_color_sliders: true, show_menu_colors: true, + clip_thumbnails_to_layer_bounds: false, show_tooltips: true, enable_animations: true, show_bounding_box_when_dragging_layer: false, system_title_bar: false, + show_file_name_in_title: true, notification_auto_hide: true, notification_duration_seconds: 6, } @@ -374,6 +387,13 @@ pub struct Tools { /// Pen pressure response: `[input, output]` control points in 0..1 that the pen's pressure /// passes through before it reaches any brush ([`PressureCurve`]). Linear by default. pub pressure_curve: Vec<[f32; 2]>, + /// Arrow-key nudges of one thing (the selected layers, the selection outline, the Free + /// Transform box) that follow each other within [`Tools::nudge_bundle_pause_ms`] are one + /// History state (#2259). Off: every press is a state. + pub bundle_nudges: bool, + /// The longest pause, in milliseconds, between two nudges that still bundle into one state. + /// Longer than a key's auto-repeat delay (about half a second), so a held key is one state. + pub nudge_bundle_pause_ms: u32, } impl Default for Tools { @@ -392,104 +412,16 @@ impl Default for Tools { right_click_with_painting_tools: RightClickPaint::BrushPicker, use_tablet_pressure: true, pressure_curve: vec![[0.0, 0.0], [1.0, 1.0]], + bundle_nudges: true, + nudge_bundle_pause_ms: 1000, } } } -/// Preferences › Tools › Pressure Curve, ready to evaluate: a monotone cubic (Fritsch–Carlson) -/// through the control points, so a firmer press never gives less pressure. The points are -/// sanitised here, not trusted: a whole-section update or a hand-edited preferences file can store -/// anything. Non-finite points are dropped, the rest clamped to 0..1, sorted by input (one point -/// per input), capped at [`Self::MAX_POINTS`], and their outputs made non-decreasing; before the -/// first point and after the last the curve stays flat. Fewer than two usable points is linear. -#[derive(Clone, Debug, PartialEq)] -pub struct PressureCurve { - xs: Vec, - ys: Vec, - /// Tangents at the points. - ms: Vec, -} - -impl PressureCurve { - pub const MAX_POINTS: usize = 16; - - pub fn new(points: &[[f32; 2]]) -> Self { - let mut pts: Vec<[f32; 2]> = - points.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0)]).collect(); - pts.sort_by(|a, b| a[0].total_cmp(&b[0])); - pts.dedup_by(|b, a| { - // Same input: keep the later point's output. - let same = (a[0] - b[0]).abs() < 1e-6; - if same { - a[1] = b[1]; - } - same - }); - pts.truncate(Self::MAX_POINTS); - if pts.len() < 2 { - pts = vec![[0.0, 0.0], [1.0, 1.0]]; - } - let xs: Vec = pts.iter().map(|p| p[0]).collect(); - let mut ys: Vec = pts.iter().map(|p| p[1]).collect(); - for i in 1..ys.len() { - ys[i] = ys[i].max(ys[i - 1]); - } - // Fritsch–Carlson tangents: secant averages, zero at flat steps, limited so the cubic - // never overshoots (which keeps it monotone). - let n = xs.len(); - let d: Vec = (0..n - 1).map(|k| (ys[k + 1] - ys[k]) / (xs[k + 1] - xs[k])).collect(); - let mut ms = vec![0.0f32; n]; - ms[0] = d[0]; - ms[n - 1] = d[n - 2]; - for k in 1..n - 1 { - ms[k] = if d[k - 1] * d[k] <= 0.0 { 0.0 } else { (d[k - 1] + d[k]) / 2.0 }; - } - for k in 0..n - 1 { - if d[k] == 0.0 { - ms[k] = 0.0; - ms[k + 1] = 0.0; - continue; - } - let (a, b) = (ms[k] / d[k], ms[k + 1] / d[k]); - let s = a * a + b * b; - if s > 9.0 { - let t = 3.0 / s.sqrt(); - ms[k] = t * a * d[k]; - ms[k + 1] = t * b * d[k]; - } - } - Self { xs, ys, ms } - } - - /// The curve's output for pen pressure `x` (0..1; anything else is clamped, NaN reads as 0). - pub fn eval(&self, x: f32) -> f32 { - let x = if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) }; - let n = self.xs.len(); - let (Some(&x0), Some(&xn)) = (self.xs.first(), self.xs.last()) else { return x }; - if x <= x0 { - return self.ys.first().copied().unwrap_or(x); - } - if x >= xn { - return self.ys.last().copied().unwrap_or(x); - } - let k = self.xs.partition_point(|&v| v <= x).saturating_sub(1).min(n.saturating_sub(2)); - let (Some(&xa), Some(&xb), Some(&ya), Some(&yb), Some(&ma), Some(&mb)) = - (self.xs.get(k), self.xs.get(k + 1), self.ys.get(k), self.ys.get(k + 1), self.ms.get(k), self.ms.get(k + 1)) - else { - return x; - }; - let h = xb - xa; - let t = (x - xa) / h; - let (t2, t3) = (t * t, t * t * t); - let y = (2.0 * t3 - 3.0 * t2 + 1.0) * ya + (t3 - 2.0 * t2 + t) * h * ma + (-2.0 * t3 + 3.0 * t2) * yb + (t3 - t2) * h * mb; - y.clamp(ya.min(yb), ya.max(yb)) - } - - /// The identity (the default curve): pressure passes through unchanged. - pub fn is_linear(&self) -> bool { - self.xs == [0.0, 1.0] && self.ys == [0.0, 1.0] - } -} +/// Preferences › Tools › Pressure Curve, ready to evaluate: the same monotone cubic every brush +/// dynamic uses for its own control ([`photocraft_paint::ResponseCurve`]), sanitised the same way, +/// since a whole-section update or a hand-edited preferences file can store anything. +pub use photocraft_paint::ResponseCurve as PressureCurve; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -521,6 +453,9 @@ pub struct FileHandling { pub recover_on_launch: bool, /// Open a file with no ICC profile as untagged even when its EXIF ColorSpace tag says sRGB. pub ignore_exif_profile_tag: bool, + /// Most renderers write ACEScg EXRs without stating the colour space. With this on, an EXR + /// that would otherwise open as linear sRGB opens as ACEScg (AP1, linear) instead. + pub exr_aces_cg: bool, pub ask_before_saving_layered_tiff: bool, pub maximize_psd_compatibility: Ask, /// SVG groups deeper than this are rasterised on import; parser and document safety caps remain fixed. @@ -540,6 +475,7 @@ impl Default for FileHandling { autosave_minutes: 10, recover_on_launch: true, ignore_exif_profile_tag: false, + exr_aces_cg: false, ask_before_saving_layered_tiff: true, maximize_psd_compatibility: Ask::Always, rasterize_svg_groups_deeper_than: photocraft_doc::MAX_GROUP_DEPTH as u32, @@ -598,8 +534,19 @@ pub struct Performance { /// sliders while they drag) render on a reduced copy: fast, but blocky when zoomed in. Off: /// they render at full resolution. pub low_resolution_previews: bool, + /// Low-latency drawing (applies at next launch): frames reach the screen without waiting for + /// vsync, so a stroke follows the pen about 50 ms sooner (the swap chain's wait, #1343). wgpu + /// picks Immediate where the driver has it (most Vulkan and DX12 drivers on Windows and X11), + /// so the canvas usually tears while it changes; Mailbox (no tearing) only where Immediate is + /// missing. Frames are capped at 240 fps. Off (the default): vsync, smooth but late. Desktop + /// only. + pub low_latency_drawing: bool, /// Linux display server (applies at next launch; see [`LinuxDisplayServer`]). pub linux_display_server: LinuxDisplayServer, + /// 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, @@ -629,7 +576,9 @@ impl Default for Performance { rendering_mode: None, gpu_backend: GpuBackend::Auto, low_resolution_previews: true, + low_latency_drawing: false, linux_display_server: LinuxDisplayServer::Auto, + gpu_memory_mb: 0, effect_cache_mb: 768, legacy_compositing: false, } @@ -954,6 +903,20 @@ pub struct MenuCustomization { pub colors: BTreeMap, } +impl MenuCustomization { + /// False for the items Edit › Menus can't hide (#5096): the way back into this dialog + /// (Edit › Menus, Edit › Keyboard Shortcuts), Preferences, Save, Save As and Exit. + pub fn hideable(id: &str) -> bool { + !matches!(id, "edit.menus" | "edit.keyboardShortcuts" | "file.save" | "file.saveAs" | "file.exit") && !id.starts_with("edit.preferences") + } + + /// Whether the menus leave `id` out. Items that can't be hidden stay shown even when an + /// older or hand-edited preferences file lists them. + pub fn is_hidden(&self, id: &str) -> bool { + Self::hideable(id) && self.hidden.iter().any(|h| h == id) + } +} + /// Edit › Toolbar: hidden tools and a custom order (tool names as in `ui.set {tool}`). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -988,6 +951,15 @@ pub struct Preferences { /// Edit › Keyboard Shortcuts: command id → shortcut (`Cmd+Shift+N` notation); an empty /// string removes the default shortcut. pub shortcuts: BTreeMap, + /// Edit › Keyboard Shortcuts: a command's further keys besides its main one (#3665), + /// command id → keys. A list replaces the command's default alternates + /// ([`ALTERNATE_SHORTCUTS`]); an empty list removes them. Settings files from before this + /// map load with nothing stored, so every command keeps its defaults. + pub alternate_shortcuts: BTreeMap>, + /// Edit › Keyboard Shortcuts › Set: saved shortcut sets by name (`crate::shortcut_sets`). + pub shortcut_sets: BTreeMap, + /// The saved set in use ("" for the defaults). + pub shortcut_set: String, pub menus: MenuCustomization, pub toolbar: ToolbarCustomization, /// Edit › Check Spelling: words added to the dictionary ("Add"). @@ -1015,6 +987,9 @@ pub struct Preferences { /// differ from their defaults, saved as the user changes them and restored at launch, as /// Photoshop remembers each tool's options between sessions. JSON owned by the shell. pub tool_options: Value, + /// Last selected tool, restored at launch. A tool-name string owned and validated by the + /// shell; null in older files. Kept separate from tool options so neither overwrites the other. + pub selected_tool: Value, /// View › Extras, the View › Show items (Pixel Grid, Grid, Guides, …) and the View › Snap To /// targets that differ from their defaults, saved as the user toggles them and restored at /// launch, as Photoshop keeps them between sessions (#3762). JSON owned by the shell. @@ -1070,7 +1045,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "type.textEngine", "type.recentFonts", "enhancedControls.scrubbySliderAcceleration", - "enhancedControls.touchGestures", "rawDefaults.colorSpace", "rawDefaults.bitDepth", "rawDefaults.resolution", @@ -1137,10 +1111,12 @@ pub fn range(path: &str) -> Option<(f64, f64)> { Some(match path { "fileHandling.autosaveMinutes" => (1.0, 240.0), "fileHandling.recentFileCount" => (0.0, 100.0), + "tools.nudgeBundlePauseMs" => (100.0, 10_000.0), "fileHandling.rasterizeSvgGroupsDeeperThan" => (0.0, photocraft_doc::MAX_GROUP_DEPTH as 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), @@ -1158,7 +1134,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> { /// Is this preference a `#rrggbb` colour? pub fn is_color(path: &str) -> bool { - if choices(path).is_some() || path.starts_with("shortcuts.") || path.starts_with("menus.") { + if choices(path).is_some() || path.starts_with("shortcuts.") || path.starts_with("alternateShortcuts.") || path.starts_with("menus.") { return false; } let leaf = path.rsplit('.').next().unwrap_or(path); @@ -1187,9 +1163,10 @@ pub fn parse_hex(s: &str) -> Option<[u8; 3]> { // ------------------------------------------------------------------ JSON paths -/// Map keys that are command ids (which contain dots): `shortcuts.`, `menus.colors.`. +/// Map keys that are command ids (which contain dots): `shortcuts.`, +/// `alternateShortcuts.`, `menus.colors.`. fn keyed(path: &str) -> Option<(&str, &str)> { - for prefix in ["shortcuts.", "menus.colors."] { + for prefix in ["shortcuts.", "alternateShortcuts.", "menus.colors."] { if let Some(id) = path.strip_prefix(prefix) { return Some((&prefix[..prefix.len() - 1], id)); } @@ -1306,6 +1283,28 @@ impl Preferences { /// Set a value by dotted path, validating it. Unknown paths and wrong types are errors. pub fn set(&mut self, path: &str, value: Value) -> std::result::Result<(), String> { + if path == "alternateShortcuts" { + let obj = value.as_object().ok_or("`alternateShortcuts` maps command ids to lists of shortcuts")?; + let mut map = BTreeMap::new(); + for (id, v) in obj { + map.insert(id.clone(), alternate_keys(v).map_err(|e| format!("`alternateShortcuts.{id}`: {e}"))?); + } + self.alternate_shortcuts = map; + return Ok(()); + } + if let Some(id) = path.strip_prefix("alternateShortcuts.") { + // null = back to the command's default alternates. + match value { + Value::Null => { + self.alternate_shortcuts.remove(id); + } + v => { + let keys = alternate_keys(&v).map_err(|e| format!("`{path}`: {e}"))?; + self.alternate_shortcuts.insert(id.to_string(), keys); + } + } + return Ok(()); + } let value = match (path.strip_prefix("shortcuts."), &value) { // Normalise shortcut spelling so conflicts compare equal. (Some(_), Value::String(s)) if !s.is_empty() => Value::String(normalize_shortcut(s).ok_or_else(|| format!("`{s}` is not a valid shortcut"))?), @@ -1336,6 +1335,10 @@ impl Preferences { self.shortcuts.remove(id); return Ok(()); } + Some(("alternateShortcuts", id)) => { + self.alternate_shortcuts.remove(id); + return Ok(()); + } Some(("menus.colors", id)) => { self.menus.colors.remove(id); return Ok(()); @@ -1361,6 +1364,50 @@ impl Preferences { None => default, } } + + /// A command's further keys in effect (#3665): the user's list, else its defaults + /// ([`ALTERNATE_SHORTCUTS`]), normalised, without duplicates and without `primary` (the + /// command's main key in effect, which it already answers). + pub fn alternates(&self, id: &str, primary: Option<&str>) -> Vec { + let main = primary.and_then(normalize_shortcut); + let keys: Vec = match self.alternate_shortcuts.get(id) { + Some(list) => list.iter().filter_map(|s| normalize_shortcut(s)).collect(), + None => default_alternates(id).filter_map(normalize_shortcut).collect(), + }; + let mut out: Vec = Vec::with_capacity(keys.len()); + for k in keys { + if main.as_ref() != Some(&k) && !out.contains(&k) { + out.push(k); + } + } + out + } +} + +/// Most further keys one command may hold; Photoshop's own defaults use one. +pub const MAX_ALTERNATES: usize = 8; + +/// Validate and normalise a command's alternate keys: a list of shortcut strings (a single +/// string is a one-key list, `""` none). +pub fn alternate_keys(v: &Value) -> std::result::Result, String> { + let items: Vec<&Value> = match v { + Value::String(s) if s.is_empty() => Vec::new(), + Value::String(_) => vec![v], + Value::Array(a) => a.iter().collect(), + _ => return Err("alternate shortcuts are a list of shortcuts (e.g. [\"F3\"])".into()), + }; + if items.len() > MAX_ALTERNATES { + return Err(format!("at most {MAX_ALTERNATES} alternate shortcuts (got {})", items.len())); + } + let mut out: Vec = Vec::with_capacity(items.len()); + for item in items { + let s = item.as_str().ok_or_else(|| format!("alternate shortcut `{item}` must be a string"))?; + let n = normalize_shortcut(s).ok_or_else(|| format!("`{s}` is not a valid shortcut (e.g. Cmd+Shift+N, F7)"))?; + if !out.contains(&n) { + out.push(n); + } + } + Ok(out) } // ------------------------------------------------------------------ shortcuts @@ -1448,9 +1495,83 @@ pub const TEMPORARY_TOOLS: &[(&str, &str, &str)] = &[ ("tools.temporary.erase", "Erase with Brush or Pencil (hold)", "`"), ]; -/// Every bindable id with its default: commands, then the temporary tools. +/// Photoshop's second default keys (#3665): (command, key). Each command keeps its main key +/// (the one its menu shows) and answers these as well, as in Photoshop's default set: the +/// legacy function keys F2 Cut, F3 Copy, F4 Paste and Shift+F7 Inverse; Shift+Backspace +/// (Shift+Delete on a Mac) for Fill; and ⌘+ beside ⌘= for Zoom In. +pub const ALTERNATE_SHORTCUTS: &[(&str, &str)] = &[ + ("edit.cut", "F2"), + ("edit.copy", "F3"), + ("edit.paste", "F4"), + ("edit.fill", "Shift+Backspace"), + ("select.inverse", "Shift+F7"), + ("view.zoomIn", "Cmd++"), +]; + +/// A command's default alternate keys ([`ALTERNATE_SHORTCUTS`]). +pub fn default_alternates(id: &str) -> impl Iterator + '_ { + ALTERNATE_SHORTCUTS.iter().filter(move |(c, _)| *c == id).map(|(_, sc)| *sc) +} + +/// Keys the system takes before the app sees them (switching apps, closing the window, force +/// quit): `edit.keyboardShortcuts` won't bind them, as Photoshop won't. `sc` is in our notation, +/// where `Cmd` is Ctrl off the Mac. +pub fn reserved_shortcut(sc: &str) -> bool { + let Some(n) = normalize_shortcut(sc) else { return false }; + let reserved: &[&str] = if cfg!(target_os = "macos") { + &["Cmd+Tab", "Cmd+Shift+Tab", "Cmd+Alt+Escape", "Cmd+`"] + } else if cfg!(target_os = "windows") { + &["Alt+Tab", "Alt+Shift+Tab", "Alt+F4", "Cmd+Alt+Delete", "Cmd+Escape", "Cmd+Shift+Escape"] + } else { + // Linux and the BSDs: window switching and closing, and the virtual consoles. + &[ + "Alt+Tab", + "Alt+Shift+Tab", + "Alt+F4", + "Cmd+Alt+Delete", + "Cmd+Alt+F1", + "Cmd+Alt+F2", + "Cmd+Alt+F3", + "Cmd+Alt+F4", + "Cmd+Alt+F5", + "Cmd+Alt+F6", + "Cmd+Alt+F7", + "Cmd+Alt+F8", + "Cmd+Alt+F9", + "Cmd+Alt+F10", + "Cmd+Alt+F11", + "Cmd+Alt+F12", + ] + }; + reserved.iter().filter_map(|r| normalize_shortcut(r)).any(|r| r == n) +} + +/// What a bindable id is: a command (the engine's or the shell's), a tool, or a held tool. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BindKind { + Command, + Tool, + Hold, +} + +/// Every bindable id: (id, label, menu path, default key, kind). The engine's commands, the +/// shell's ([`crate::shell_commands::SHELL_COMMANDS`]), the tools and the held temporary tools, +/// so Edit › Keyboard Shortcuts works the same with or without the app's UI. +pub fn bindable_items() -> impl Iterator, BindKind)> { + let engine = crate::command_specs().iter().map(|c| (c.id, c.label, c.menu, c.shortcut, BindKind::Command)); + let shell = crate::shell_commands::SHELL_COMMANDS.iter().map(|c| (c.0, c.1, c.2, c.3, BindKind::Command)); + let tools = crate::shell_commands::TOOLS.iter().map(|t| (t.0, t.1, &["Tools"][..], t.2, BindKind::Tool)); + let hold = TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, &["Tools", "Temporary"][..], Some(t.2), BindKind::Hold)); + engine.chain(shell).chain(tools).chain(hold) +} + +/// Every bindable id with its default. fn bindable() -> impl Iterator)> { - crate::command_specs().iter().map(|c| (c.id, c.shortcut)).chain(TEMPORARY_TOOLS.iter().map(|t| (t.0, Some(t.2)))) + bindable_items().map(|i| (i.0, i.3)) +} + +fn is_tool(id: &str) -> bool { + crate::shell_commands::tool_default(id).is_some() } /// Shortcuts bound to more than one command: (shortcut, ids). `bindings` are (id, shortcut). @@ -1488,6 +1609,100 @@ impl PrefsStore { } } +/// A saved preference [`Session::load_prefs_json`] couldn't read (a wrong type, an unknown +/// choice) and left at its default. +#[derive(Clone, Debug, PartialEq)] +pub struct InvalidPref { + /// JSON keys from the top of the preferences file (`["fileHandling", "recentFileCount"]`), + /// kept apart because keys such as command ids contain dots. + pub keys: Vec, + /// The value the file held there. + pub value: Value, +} + +impl InvalidPref { + /// The dotted path, as `prefs.get` and `prefs.set` take it. + pub fn path(&self) -> String { + self.keys.join(".") + } +} + +/// How deep [`read_lenient`] looks for the readable parts of an unreadable value. +const LENIENT_DEPTH: usize = 32; + +/// Read `v` as a `T`. When that fails, start from `T::default()` and lay the file's values over +/// it one at a time, keeping each one the whole still reads with and descending into objects +/// that don't, so one bad value resets only itself. The values left out are added to +/// `invalid`, their keys after `prefix`. +fn read_lenient(v: Value, prefix: &[&str], invalid: &mut Vec) -> T +where + T: Default + Serialize + for<'de> Deserialize<'de>, +{ + if let Ok(t) = T::deserialize(&v) { + return t; + } + let prefix: Vec = prefix.iter().map(|k| (*k).to_string()).collect(); + let mut acc = serde_json::to_value(T::default()).unwrap_or(Value::Null); + let fits = |root: &Value| T::deserialize(root).is_ok(); + match v { + Value::Object(file) if acc.is_object() && fits(&acc) => { + lay_over(&mut acc, &mut Vec::new(), file, &fits, &prefix, invalid); + T::deserialize(&acc).unwrap_or_default() + } + value => { + invalid.push(InvalidPref { keys: prefix, value }); + T::default() + } + } +} + +/// [`read_lenient`]'s walk: lay `file` over the object at `at` inside `root`, key by key, so that +/// `root` always reads (`fits`). +fn lay_over( + root: &mut Value, + at: &mut Vec, + file: Map, + fits: &dyn Fn(&Value) -> bool, + prefix: &[String], + invalid: &mut Vec, +) { + for (k, fv) in file { + let Some(obj) = object_at(root, at) else { return }; + let old = obj.insert(k.clone(), fv.clone()); + if fits(root) { + continue; + } + // Back to the default. An object keeps its parts that read, and so does a new map entry + // (a shortcut set, a workspace) when it reads from an empty object. + let Some(obj) = object_at(root, at) else { return }; + let mut descend = fv.is_object() && at.len() < LENIENT_DEPTH && matches!(old, None | Some(Value::Object(_))); + match old { + Some(o) => obj.insert(k.clone(), o), + None if descend => obj.insert(k.clone(), Value::Object(Map::new())), + None => obj.remove(&k), + }; + at.push(k); + if descend && !fits(root) { + descend = false; + if let Some((key, parent)) = at.split_last() + && let Some(obj) = object_at(root, parent) + { + obj.remove(key); + } + } + match fv { + Value::Object(m) if descend => lay_over(root, at, m, fits, prefix, invalid), + value => invalid.push(InvalidPref { keys: prefix.iter().chain(at.iter()).cloned().collect(), value }), + } + at.pop(); + } +} + +/// The JSON object at `path` inside `root`. +fn object_at<'a>(root: &'a mut Value, path: &[String]) -> Option<&'a mut Map> { + path.iter().try_fold(root, |cur, k| cur.get_mut(k.as_str()))?.as_object_mut() +} + impl Session { /// The current preferences. pub fn prefs(&self) -> &Preferences { @@ -1510,7 +1725,7 @@ impl Session { for st in &mut self.docs { st.history.max_states = n; st.history.max_bytes = bytes; - st.history.trim(&st.doc); + st.trim_history(); } photocraft_compose::set_effect_cache_budget(budget << 20); // Without waiting for the system font scan: this runs on the UI thread at launch. @@ -1536,8 +1751,14 @@ impl Session { /// Restore preferences saved by [`Session::prefs_to_json`]. Missing keys keep their /// defaults and unknown keys are ignored, so files from older and newer versions load. - pub fn load_prefs_json(&mut self, s: &str) -> std::result::Result<(), String> { + /// A value of the wrong type (a hand-edited `"recentFileCount": "ten"`) keeps its default + /// and everything else still loads (#6611); the returned list names those values. Only + /// text that isn't a JSON object fails as a whole. + pub fn load_prefs_json(&mut self, s: &str) -> std::result::Result, String> { let mut v: Value = serde_json::from_str(s).map_err(|e| format!("preferences: {e}"))?; + if !v.is_object() { + return Err("preferences: expected a JSON object".into()); + } // Saved preferences before appearance modes had one concrete theme. Preserve its // appearance instead of silently switching established users to Auto. if let Some(interface) = v.get_mut("interface").and_then(Value::as_object_mut) @@ -1553,13 +1774,15 @@ impl Session { } }; interface.insert("appearanceMode".into(), json!(mode)); - interface.insert(slot.into(), json!(theme.name())); + // A partially migrated file may already contain a newer theme selection (#5139). + interface.entry(slot).or_insert_with(|| json!(theme.name())); } let color = v.as_object_mut().and_then(|m| m.remove("colorSettings")); let presets = v.as_object_mut().and_then(|m| m.remove("presets")); - let prefs: Preferences = serde_json::from_value(v).map_err(|e| format!("preferences: {e}"))?; + let mut invalid = Vec::new(); + let prefs: Preferences = read_lenient(v, &[], &mut invalid); if let Some(c) = color { - self.color.settings = serde_json::from_value(c).unwrap_or_default(); + self.color.settings = read_lenient(c, &["colorSettings"], &mut invalid); photocraft_compose::psblend::set_text_gamma(self.color.settings.blend_text_gamma); } self.prefs.edit(|p| *p = prefs); @@ -1567,7 +1790,7 @@ impl Session { self.load_presets_json(v); } self.apply_prefs(); - Ok(()) + Ok(invalid) } /// Full JSON view used by `prefs.get`: preferences plus `colorSettings`. @@ -1601,7 +1824,7 @@ impl Session { } let mut next = self.prefs().clone(); let mut color_changed = false; - if path.is_empty() || SECTIONS.iter().any(|(id, _)| *id == path) || matches!(path, "shortcuts" | "menus" | "toolbar") { + if path.is_empty() || SECTIONS.iter().any(|(id, _)| *id == path) || matches!(path, "shortcuts" | "alternateShortcuts" | "menus" | "toolbar") { // Whole section (or everything): merge the object key by key so each value is checked. let obj = value.as_object().ok_or_else(|| format!("`{path}` needs an object"))?; for (k, v) in obj { @@ -1610,7 +1833,12 @@ impl Session { color_changed |= self.set_pref("colorSettings", v.clone(), validate_color)?; continue; } - if v.is_object() && !p.starts_with("shortcuts") && !p.ends_with(".colors") && next.get(&p).is_some_and(|c| c.is_object()) { + if v.is_object() + && !p.starts_with("shortcuts") + && !p.starts_with("alternateShortcuts") + && !p.ends_with(".colors") + && next.get(&p).is_some_and(|c| c.is_object()) + { for (k2, v2) in v.as_object().into_iter().flatten() { next.set(&format!("{p}.{k2}"), v2.clone())?; } @@ -1786,33 +2014,77 @@ fn preferences_section(s: &mut Session, p: &Value) -> Result { /// Edit › Keyboard Shortcuts (engine part): list, change and reset application shortcuts. fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { let cmd = "edit.keyboardShortcuts"; + // Every change lands at once, after all of it validated: a bad key leaves the set as it was. + let mut next = s.prefs().clone(); + // A set loads first, so the call's other changes apply on top of it. + let mut changed = crate::shortcut_sets::load(&mut next, p).map_err(|e| bad(cmd, e))?; if let Some(r) = p.get("reset") { match r { - Value::Bool(true) => s.edit_prefs(|p| p.shortcuts.clear()), - Value::Array(ids) => s.edit_prefs(|p| { + Value::Bool(true) => { + next.shortcuts.clear(); + next.alternate_shortcuts.clear(); + changed = true; + } + Value::Array(ids) => { for id in ids.iter().filter_map(Value::as_str) { - p.shortcuts.remove(id); + next.shortcuts.remove(id); + next.alternate_shortcuts.remove(id); } - }), + changed = true; + } _ => {} } } - // A Photoshop `.kys` set (`importKys`, its XML text) becomes `set` entries, matched by label - // (`crate::kys`); a key equal to the command's default restores the default, and keys given - // in `set` as well win over the file's. + // A Photoshop `.kys` set (`importKys`, its XML text) becomes `set` and `alternates` entries, + // matched by label (`crate::kys`); a key equal to the command's default restores the + // default, and keys given in `set` / `alternates` as well win over the file's. let imported; let mut import = None; let p = match p.get("importKys") { - Some(xml) => { - let xml = xml.as_str().ok_or_else(|| bad(cmd, "`importKys` is the text of a .kys file"))?; + Some(v) => { + // The file's text, or {"path": …} to read it. + let read; + let xml = match v { + Value::String(s) => s.as_str(), + Value::Object(o) => { + let path = + o.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(cmd, "`importKys.path` is a .kys file"))?; + read = read_text_file(path, crate::kys::MAX_BYTES).map_err(|e| bad(cmd, format!("{path}: {e}")))?; + read.as_str() + } + _ => return Err(bad(cmd, "`importKys` is the text of a .kys file, or {\"path\": …}")), + }; let set = crate::kys::parse(xml).map_err(|e| bad(cmd, e))?; let plan = crate::kys::plan(crate::kys::command_candidates(), &set); let mut merged = serde_json::Map::new(); - for (id, sc) in &plan.set { + // A key the system reserves is left out (reported as `reserved`), not a failed import. + let mut skipped: Vec = Vec::new(); + for (id, sc) in plan.set.iter().filter(|(id, sc)| { + let bad = reserved_shortcut(sc); + if bad { + skipped.push(format!("{id}: {sc}")); + } + !bad + }) { let default = crate::command_specs().iter().find(|c| c.id == id).and_then(|c| c.shortcut).and_then(normalize_shortcut); merged.insert(id.clone(), if default.as_deref() == Some(sc.as_str()) { Value::Null } else { json!(sc) }); } + // Tool keys: a tool's own letter restores it (and its Shift cycling). + let tools = crate::shell_commands::TOOLS.iter().map(|t| (t.0, t.1)); + let (tool_keys, unknown_tools) = crate::kys::plan_tools(tools, &set); + for (id, key) in &tool_keys { + let own = crate::shell_commands::tool_default(id).flatten().is_some_and(|d| d.eq_ignore_ascii_case(key)); + merged.insert(id.clone(), if own { Value::Null } else { json!(key) }); + } merged.extend(p.get("set").and_then(Value::as_object).cloned().unwrap_or_default()); + let mut alternates = serde_json::Map::new(); + for (id, keys) in &plan.alternate_keys { + let keys: &Vec = &keys.iter().filter(|k| !reserved_shortcut(k)).cloned().collect(); + let main = plan.set.get(id).map(String::as_str); + let default = Preferences::default().alternates(id, main); + alternates.insert(id.clone(), if *keys == default { Value::Null } else { json!(keys) }); + } + alternates.extend(p.get("alternates").and_then(Value::as_object).cloned().unwrap_or_default()); import = Some(json!({ "name": set.name, "imported": plan.set.len(), @@ -1820,69 +2092,381 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { "unknown": plan.unknown, "unreadable": plan.unreadable, "alternates": plan.alternates, + "alternateKeys": plan.alternate_keys, "toolKeys": set.tool_keys, + "tools": tool_keys, + "unknownTools": unknown_tools, + "reserved": skipped, + "windows": set.windows, })); - let mut next = p.clone(); - next["set"] = Value::Object(merged); - imported = next; + let mut with_import = p.clone(); + with_import["set"] = Value::Object(merged); + with_import["alternates"] = Value::Object(alternates); + imported = with_import; &imported } None => p, }; - if let Some(m) = p.get("set").and_then(Value::as_object) { - let mut next = s.prefs().clone(); - for (id, v) in m { - if !bindable().any(|(b, _)| b == id) && !p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false) { + let empty = serde_json::Map::new(); + let set = match p.get("set") { + Some(Value::Object(m)) => m, + _ => &empty, + }; + let alts = match p.get("alternates") { + None | Some(Value::Null) => &empty, + Some(v) => v.as_object().ok_or_else(|| bad(cmd, "`alternates` maps command ids to lists of shortcuts"))?, + }; + if !set.is_empty() || !alts.is_empty() { + let allow_unknown = p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false); + changed = true; + // Reserved system keys can't be bound, by hand or from a file. + let reserved = |v: &Value| -> Option { + let keys = match v { + Value::String(s) => vec![s.clone()], + other => alternate_keys(other).unwrap_or_default(), + }; + keys.into_iter().find(|k| reserved_shortcut(k)) + }; + if let Some((id, k)) = set.iter().chain(alts.iter()).find_map(|(id, v)| reserved(v).map(|k| (id, k))) { + return Err(bad(cmd, format!("`{k}` (for `{id}`) is reserved by the system and can't be assigned"))); + } + for (id, v) in set { + if !bindable().any(|(b, _)| b == id) && !allow_unknown { return Err(bad(cmd, format!("unknown command `{id}`"))); } next.set(&format!("shortcuts.{id}"), v.clone()).map_err(|e| bad(cmd, e))?; } - // Like Photoshop, a shortcut moved to a new command is taken away from the old one. + for (id, v) in alts { + if !bindable().any(|(b, _)| b == id) && default_alternates(id).next().is_none() && !allow_unknown { + return Err(bad(cmd, format!("unknown command `{id}`"))); + } + next.set(&format!("alternateShortcuts.{id}"), v.clone()).map_err(|e| bad(cmd, e))?; + } + // Like Photoshop, a shortcut moved to a new command is taken away from the old one, + // whether the old one held it as its main key or as an alternate (#3665). if p.get("removeConflicts").and_then(Value::as_bool).unwrap_or(true) { - for (id, v) in m { - let Some(sc) = v.as_str().and_then(normalize_shortcut) else { continue }; + let assigned: Vec<(&str, String)> = set + .iter() + .filter_map(|(id, v)| Some((id.as_str(), v.as_str().and_then(normalize_shortcut)?))) + .chain(alts.iter().flat_map(|(id, v)| alternate_keys(v).unwrap_or_default().into_iter().map(move |k| (id.as_str(), k)))) + .collect(); + for (id, sc) in assigned { for (c, def) in bindable() { + // Tools may share a key: they cycle with Shift, as Photoshop's groups do. + if is_tool(id) && is_tool(c) { + continue; + } // Never strip a command that this same call is assigning: // two entries for one key are a clash inside the call, which // the returned conflicts list reports (#719). Stripping // each other here unbound both silently. - if c == id || m.contains_key(c) { + if c == id || set.contains_key(c) || alts.contains_key(c) { continue; } - if next.shortcut(c, def).and_then(normalize_shortcut).as_deref() == Some(sc.as_str()) { + let main = next.shortcut(c, def).map(str::to_string); + if main.as_deref().and_then(normalize_shortcut).as_deref() == Some(sc.as_str()) { next.shortcuts.insert(c.to_string(), String::new()); } + let held = next.alternates(c, main.as_deref()); + if held.contains(&sc) { + next.alternate_shortcuts.insert(c.to_string(), held.into_iter().filter(|k| *k != sc).collect()); + } } } } - s.edit_prefs(|p| *p = next); } - let prefs = s.prefs(); + // Then the sets are saved, deleted or switched, from the bindings as they now stand. + changed |= crate::shortcut_sets::store(&mut next, p).map_err(|e| bad(cmd, e))?; + // The reply and the exports read the bindings as they will be; they are committed only once + // the exports are written, so a bad export target changes nothing. + let prefs = &next; let filter = p.get("filter").and_then(Value::as_str).map(str::to_ascii_lowercase); - let temporary = TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, &["Tools", "Temporary"][..], Some(t.2), true)); - let list: Vec = crate::command_specs() - .iter() - .map(|c| (c.id, c.label, c.menu, c.shortcut, false)) - .chain(temporary) + let list: Vec = bindable_items() .filter(|c| filter.as_ref().is_none_or(|f| c.0.to_ascii_lowercase().contains(f) || c.1.to_ascii_lowercase().contains(f))) - .filter(|c| p.get("list").and_then(Value::as_bool).unwrap_or(false) || filter.is_some() || prefs.shortcuts.contains_key(c.0)) - .map(|(id, label, menu, def, hold)| { - let mut v = json!({"id": id, "label": label, "menu": menu, "default": def, "shortcut": prefs.shortcut(id, def)}); - if hold { - v["hold"] = json!(true); + .filter(|c| { + p.get("list").and_then(Value::as_bool).unwrap_or(false) + || filter.is_some() + || prefs.shortcuts.contains_key(c.0) + || prefs.alternate_shortcuts.contains_key(c.0) + }) + .map(|(id, label, menu, def, kind)| { + let main = prefs.shortcut(id, def); + let defaults: Vec<&str> = default_alternates(id).collect(); + let mut v = json!({"id": id, "label": label, "menu": menu, "default": def, "shortcut": main, "alternates": prefs.alternates(id, main)}); + if !defaults.is_empty() { + v["defaultAlternates"] = json!(defaults); + } + match kind { + BindKind::Hold => v["hold"] = json!(true), + BindKind::Tool => v["tool"] = json!(true), + BindKind::Command => {} } v }) .collect(); - let bindings: Vec<(&str, &str)> = bindable().filter_map(|(id, def)| Some((id, prefs.shortcut(id, def)?))).collect(); - let conflicts: Vec = conflicts(bindings).into_iter().map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})).collect(); - let mut out = json!({"overrides": prefs.shortcuts, "commands": list, "conflicts": conflicts}); + let mut bindings: Vec<(&str, String)> = Vec::new(); + for (id, def) in bindable() { + let main = prefs.shortcut(id, def); + bindings.extend(main.map(|m| (id, m.to_string()))); + bindings.extend(prefs.alternates(id, main).into_iter().map(|k| (id, k))); + } + // Tools sharing a key are a cycling group, not a conflict. + let conflicts: Vec = conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str()))) + .into_iter() + .filter(|(_, ids)| !ids.iter().all(|id| is_tool(id))) + .map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})) + .collect(); + let mut out = json!({"overrides": prefs.shortcuts, "alternateOverrides": prefs.alternate_shortcuts, "commands": list, "conflicts": conflicts}); + if let (Some(o), Value::Object(sets)) = (out.as_object_mut(), crate::shortcut_sets::describe(prefs)) { + o.extend(sets); + } + // The bindings as a `.kys` file, or Photoshop's Summarize page: every bindable item (the + // engine's commands, the shell's commands and the tools). + let set_name = if prefs.shortcut_set.is_empty() { crate::shortcut_sets::DEFAULTS_NAME } else { prefs.shortcut_set.as_str() }; + // Both targets are resolved and their texts made before anything is written, and a path that + // can't be written is refused before either file is: a bad export changes nothing. + let kys_target = file_target(cmd, p.get("exportKys"))?; + let html_target = file_target(cmd, p.get("summarize"))?; + let mut files: Vec<(&str, String, String)> = Vec::new(); + if let Some(target) = kys_target { + let entries: Vec = + crate::shortcut_sets::bound(prefs).into_iter().map(|b| crate::kys::Entry { id: b.id, label: b.label, tool: b.tool, keys: b.keys }).collect(); + let text = crate::kys::write(set_name, &entries); + match target { + Some(path) => files.push(("kysPath", path, text)), + None => out["kys"] = json!(text), + } + } + if let Some(target) = html_target { + let v = p.get("summarize").cloned().unwrap_or(Value::Null); + let mac = v.get("mac").and_then(Value::as_bool).unwrap_or(cfg!(target_os = "macos")); + let mut sections: Vec = Vec::new(); + for crate::shortcut_sets::Bound { label, menu, keys, .. } in crate::shortcut_sets::bound(prefs) { + match sections.iter_mut().find(|(m, _)| *m == menu) { + Some((_, rows)) => rows.push((label, keys)), + None => sections.push((menu, vec![(label, keys)])), + } + } + let html = crate::shortcut_sets::summarize(set_name, §ions, mac); + match target { + Some(path) => files.push(("htmlPath", path, html)), + None => out["html"] = json!(html), + } + } + for (_, path, _) in &files { + writable(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?; + } + let list: Vec<(&str, &str)> = files.iter().map(|(_, path, text)| (path.as_str(), text.as_str())).collect(); + write_files_atomically(&list, &rename_path).map_err(|e| bad(cmd, e))?; + for (key, path, _) in &files { + out[*key] = json!(path); + } + if changed { + s.edit_prefs(|p| *p = next); + } if let Some(import) = import { out["import"] = import; } Ok(out) } +/// An output parameter (`exportKys`, `summarize`): absent or false → `None`; true (or an object +/// without `path`) → `Some(None)`, the text comes back in the reply; `{"path": …}` → +/// `Some(Some(path))`, written there. +fn file_target(cmd: &str, v: Option<&Value>) -> Result>> { + match v { + None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None), + Some(Value::Bool(true)) => Ok(Some(None)), + Some(Value::Object(o)) => match o.get("path") { + None | Some(Value::Null) => Ok(Some(None)), + Some(Value::String(s)) if !s.trim().is_empty() => Ok(Some(Some(s.clone()))), + Some(_) => Err(bad(cmd, "`path` must be a non-empty string")), + }, + Some(_) => Err(bad(cmd, "expected true or {\"path\": …}")), + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn read_text_file(path: &str, max: usize) -> std::result::Result { + let meta = std::fs::metadata(path).map_err(|e| e.to_string())?; + if !meta.is_file() || meta.len() > max as u64 { + return Err(format!("not a file of at most {} KiB", max / 1024)); + } + std::fs::read_to_string(path).map_err(|e| e.to_string()) +} + +#[cfg(target_arch = "wasm32")] +fn read_text_file(_: &str, _: usize) -> std::result::Result { + Err("paths can't be read on the web; pass the file's text instead".into()) +} + +/// Whether `path` can be written: not a folder, in a folder that exists. +#[cfg(not(target_arch = "wasm32"))] +fn writable(path: &str) -> std::result::Result<(), String> { + let p = std::path::Path::new(path); + if p.is_dir() { + return Err("is a folder".into()); + } + if std::fs::metadata(p).is_ok_and(|m| m.permissions().readonly()) { + return Err("is read-only".into()); + } + match p.parent().filter(|d| !d.as_os_str().is_empty()) { + Some(d) if !d.is_dir() => Err("its folder doesn't exist".into()), + _ => Ok(()), + } +} + +#[cfg(target_arch = "wasm32")] +fn writable(_: &str) -> std::result::Result<(), String> { + Err("paths can't be written on the web; omit `path` to get the text back".into()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn rename_path(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> { + std::fs::rename(from, to) +} + +#[cfg(target_arch = "wasm32")] +fn rename_path(_: &std::path::Path, _: &std::path::Path) -> std::io::Result<()> { + Err(std::io::Error::other("paths can't be written on the web")) +} + +/// Where `path` points, for telling whether two targets are one file: its folder resolved, its +/// name compared without case where the file system ignores it. +#[cfg(not(target_arch = "wasm32"))] +fn path_key(path: &str) -> String { + let p = std::path::Path::new(path); + let dir = p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(std::path::Path::new(".")); + let dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()); + let key = dir.join(p.file_name().unwrap_or_default()).to_string_lossy().into_owned(); + if cfg!(any(target_os = "windows", target_os = "macos")) { key.to_lowercase() } else { key } +} + +/// A new, empty file beside `path` with a name nothing else uses (`create_new`), for staging +/// (`kind` "tmp") or a backup ("bak"). +#[cfg(not(target_arch = "wasm32"))] +fn sibling(path: &str, kind: &str) -> std::io::Result<(std::path::PathBuf, std::fs::File)> { + for n in 0..1000u32 { + let p = std::path::PathBuf::from(format!("{path}.{}-{n}.photocraft-{kind}", std::process::id())); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&p) { + Ok(f) => return Ok((p, f)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::other("no free name beside it")) +} + +/// Write `files` ((path, text)) all or nothing. Each text is staged in a new file beside its +/// target; once every one is written, each existing target is moved aside to a backup and the +/// staged file moved in. Any failure moves the backups back and removes what was staged, so the +/// user's files end as they began; on success the backups go. Two targets naming one file are +/// refused. `rename` is `std::fs::rename` (a test fails it on purpose). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn write_files_atomically( + files: &[(&str, &str)], + rename: &dyn Fn(&std::path::Path, &std::path::Path) -> std::io::Result<()>, +) -> std::result::Result<(), String> { + use std::io::Write; + use std::path::{Path, PathBuf}; + for (i, (a, _)) in files.iter().enumerate() { + if files.iter().skip(i + 1).any(|(b, _)| path_key(a) == path_key(b)) { + return Err(format!("{a}: two exports to the same file")); + } + } + let mut staged: Vec = Vec::new(); + let drop_all = |paths: &[PathBuf]| { + for p in paths { + let _ = std::fs::remove_file(p); + } + }; + for (path, text) in files { + let (tmp, mut f) = match sibling(path, "tmp") { + Ok(v) => v, + Err(e) => { + drop_all(&staged); + return Err(format!("{path}: {e}")); + } + }; + staged.push(tmp); + if let Err(e) = f.write_all(text.as_bytes()).and_then(|()| f.sync_all()) { + drop_all(&staged); + return Err(format!("{path}: {e}")); + } + } + // (target, its backup) of each file moved in so far. + let mut moved: Vec<(&Path, Option)> = Vec::new(); + // Put each backup back over its target (a target that had none is removed). What couldn't be + // put back is named, so the user can find their previous file. + let undo = |moved: &[(&Path, Option)]| -> String { + let mut stranded = Vec::new(); + for (target, backup) in moved.iter().rev() { + match backup { + Some(b) => { + if rename(b, target).is_err() { + stranded.push(format!("the previous {} is at {}", target.display(), b.display())); + } + } + None => { + let _ = std::fs::remove_file(target); + } + } + } + if stranded.is_empty() { String::new() } else { format!(" ({})", stranded.join("; ")) } + }; + for ((path, _), tmp) in files.iter().zip(&staged) { + let target = Path::new(*path); + let backup = if target.exists() { + let moved_aside = sibling(path, "bak").and_then(|(b, f)| { + drop(f); + match rename(target, &b) { + Ok(()) => Ok(b), + Err(e) => { + // The placeholder the backup name reserved goes too. + let _ = std::fs::remove_file(&b); + Err(e) + } + } + }); + match moved_aside { + Ok(b) => Some(b), + Err(e) => { + let stranded = undo(&moved); + drop_all(&staged); + return Err(format!("{path}: {e}{stranded}")); + } + } + } else { + None + }; + if let Err(e) = rename(tmp, target) { + let mut stranded = undo(&moved); + if let Some(b) = &backup + && rename(b, target).is_err() + { + stranded = format!("{stranded} (the previous {path} is at {})", b.display()); + } + drop_all(&staged); + return Err(format!("{path}: {e}{stranded}")); + } + moved.push((target, backup)); + } + for (_, backup) in moved { + if let Some(b) = backup { + let _ = std::fs::remove_file(b); + } + } + Ok(()) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn write_files_atomically( + files: &[(&str, &str)], + _: &dyn Fn(&std::path::Path, &std::path::Path) -> std::io::Result<()>, +) -> std::result::Result<(), String> { + if files.is_empty() { Ok(()) } else { Err("paths can't be written on the web; omit `path` to get the text back".into()) } +} + /// Edit › Menus: hide/show items and give them colours. fn menus(s: &mut Session, p: &Value) -> Result { let ids = |k: &str| -> Vec { @@ -1897,6 +2481,9 @@ fn menus(s: &mut Session, p: &Value) -> Result { } } let (hide, show) = (ids("hide"), ids("show")); + if let Some(id) = hide.iter().find(|id| !MenuCustomization::hideable(id)) { + return Err(bad("edit.menus", format!("`{id}` can't be hidden"))); + } s.edit_prefs(|pr| { if p.get("reset").and_then(Value::as_bool) == Some(true) { pr.menus = MenuCustomization::default(); @@ -2005,7 +2592,7 @@ pub fn specs() -> Vec { "Keyboard Shortcuts…", ["Edit"], Some("Cmd+Alt+Shift+K"), - r##"{"set":{"|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"reset":true|["",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?}"##, + r##"{"set":{"|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"":["F3",…]|[](none)|null(default)}?,"reset":true|["",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"|{"path":".kys"}?,"useSet":""|""(defaults)?,"saveSet":""?,"deleteSet":""?,"sets":{"":{"shortcuts":{…},"alternateShortcuts":{…}}}?,"activeSet":""|""?,"exportKys":true|{"path":".kys"}?,"summarize":true|{"mac":bool,"path":".html"}?} (with a path the file is written and the reply has kysPath/htmlPath instead of kys/html)"##, keyboard_shortcuts, true ), diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 36a5b69abc..55e41949b9 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -49,6 +49,24 @@ fn canvas_dot_grid_can_be_disabled_persisted_and_reset() { assert!(s.prefs().interface.show_canvas_dots); } +/// UI-217-10: the Home screen's recent-files list can be switched off and the choice persists. +#[test] +fn home_recent_files_preference_defaults_on_persists_and_resets() { + let mut s = Session::new(); + assert!(s.prefs().interface.show_home_recent_files); + s.execute("prefs.set", json!({"path": "interface.showHomeRecentFiles", "value": false})).unwrap(); + assert!(!s.prefs().interface.show_home_recent_files); + let mut restored = Session::new(); + restored.load_prefs_json(&s.prefs_to_json()).unwrap(); + assert!(!restored.prefs().interface.show_home_recent_files); + let mut legacy = Session::new(); + legacy.load_prefs_json(r#"{"interface": {"theme": "studio"}}"#).unwrap(); + assert!(legacy.prefs().interface.show_home_recent_files); + assert!(s.execute("prefs.set", json!({"path": "interface.showHomeRecentFiles", "value": 3})).is_err()); + s.execute("prefs.reset", json!({"path": "interface.showHomeRecentFiles"})).unwrap(); + assert!(s.prefs().interface.show_home_recent_files); +} + #[test] fn appearance_defaults_and_legacy_theme_migrate() { let p = Preferences::default(); @@ -67,6 +85,17 @@ fn appearance_defaults_and_legacy_theme_migrate() { } } +#[test] +fn legacy_theme_migration_preserves_saved_theme_slots() { + for (legacy, slot, saved) in [("proMedium", "darkTheme", "solarizedDark"), ("studioLight", "lightTheme", "adwaita")] { + let mut interface = json!({"theme": legacy}); + interface[slot] = json!(saved); + let mut session = Session::new(); + session.load_prefs_json(&json!({"interface": interface}).to_string()).unwrap(); + assert_eq!(session.prefs_value()["interface"][slot], saved, "explicit {slot} survives migration"); + } +} + #[test] fn appearance_choices_validate_and_legacy_theme_still_selects() { let mut s = Session::new(); @@ -386,6 +415,19 @@ fn menus_and_toolbar_customisation_persist() { assert_eq!(back.toolbar.hidden, vec!["Sponge".to_string()]); } +/// #5096: Edit › Menus can't hide the way back into itself, Preferences, Save or Exit. +#[test] +fn menus_refuse_to_hide_essential_items() { + let mut s = session(); + for id in ["edit.menus", "edit.keyboardShortcuts", "edit.preferences.general", "file.save", "file.saveAs", "file.exit"] { + assert!(s.execute("edit.menus", json!({"hide": ["edit.fade", id]})).is_err(), "{id}"); + assert!(s.prefs().menus.hidden.is_empty(), "{id}: a refused call changes nothing"); + } + // A preferences file that lists them anyway still shows them. + let menus = MenuCustomization { hidden: vec!["file.exit".into(), "edit.fade".into()], ..Default::default() }; + assert!(!menus.is_hidden("file.exit") && menus.is_hidden("edit.fade")); +} + #[test] fn preference_sections_are_commands() { let mut s = Session::new(); @@ -614,6 +656,168 @@ fn ruler_units_round_to_their_own_decimal_places() { assert_eq!(r.format(100.0, 300.0, 1500.0), "0.333"); } +/// #2259: arrow-key nudges bundle into one History state by default, within a pause long enough +/// for a held key's auto-repeat delay; both are Tools preferences with a validated range. +#[test] +fn nudge_bundling_preferences() { + let p = Preferences::default(); + assert!(p.tools.bundle_nudges); + assert_eq!(p.tools.nudge_bundle_pause_ms, 1000); + // Preferences saved before these existed load with the defaults. + let old: Preferences = serde_json::from_value(json!({"tools": {"showTooltips": false}})).unwrap(); + assert!(old.tools.bundle_nudges && !old.tools.show_tooltips); + assert_eq!(old.tools.nudge_bundle_pause_ms, 1000); + + let mut s = session(); + assert_eq!(s.execute("prefs.get", json!({"path": "tools.bundleNudges"})).unwrap(), json!(true)); + s.execute("prefs.set", json!({"values": {"tools.bundleNudges": false, "tools.nudgeBundlePauseMs": 250}})).unwrap(); + assert!(!s.prefs().tools.bundle_nudges); + assert_eq!(s.prefs().tools.nudge_bundle_pause_ms, 250); + for edge in [100, 10_000] { + s.execute("prefs.set", json!({"path": "tools.nudgeBundlePauseMs", "value": edge})).unwrap(); + assert_eq!(s.prefs().tools.nudge_bundle_pause_ms, edge); + } + for bad in [json!(0), json!(99), json!(10_001), json!(-1), json!(1.5e300), json!("long"), json!(null)] { + assert!(s.execute("prefs.set", json!({"path": "tools.nudgeBundlePauseMs", "value": bad.clone()})).is_err(), "{bad}"); + } + assert!(s.execute("prefs.set", json!({"path": "tools.bundleNudges", "value": "yes"})).is_err()); + assert_eq!(s.prefs().tools.nudge_bundle_pause_ms, 10_000, "a rejected value changes nothing"); + s.execute("prefs.reset", json!({"path": "tools"})).unwrap(); + assert!(s.prefs().tools.bundle_nudges); + assert_eq!(s.prefs().tools.nudge_bundle_pause_ms, 1000); +} + +/// #3665: Photoshop's second default keys (F2/F3/F4, Shift+F7, Cmd++) are a command's +/// alternates, beside the main key its menu shows. +#[test] +fn commands_have_photoshops_alternate_keys() { + let p = Preferences::default(); + let main = |id: &str| crate::commands::find(id).and_then(|c| c.shortcut); + assert_eq!(main("edit.cut"), Some("Cmd+X")); + assert_eq!(p.alternates("edit.cut", main("edit.cut")), ["F2"]); + assert_eq!(p.alternates("edit.copy", main("edit.copy")), ["F3"]); + assert_eq!(p.alternates("edit.paste", main("edit.paste")), ["F4"]); + assert_eq!(main("select.inverse"), Some("Cmd+Shift+I")); + assert_eq!(p.alternates("select.inverse", main("select.inverse")), ["Shift+F7"]); + assert_eq!(p.alternates("edit.fill", main("edit.fill")), ["Shift+Backspace"]); + assert_eq!(p.alternates("view.zoomIn", Some("Cmd+=")), ["Cmd++"]); + assert!(p.alternates("edit.undo", main("edit.undo")).is_empty()); + // No alternate is another command's main key or another command's alternate. + let mut seen = std::collections::HashMap::new(); + for c in crate::command_specs() { + if let Some(sc) = c.shortcut.and_then(normalize_shortcut) { + seen.insert(sc, c.id); + } + } + for (id, sc) in ALTERNATE_SHORTCUTS { + let n = normalize_shortcut(sc).unwrap(); + assert!(seen.insert(n, id).is_none(), "{sc} of {id} is already bound"); + } + // A command's own main key is never repeated as its alternate. + assert!(p.alternates("edit.copy", Some("F3")).is_empty()); +} + +/// Settings files written before alternates existed still load, keeping their overrides and +/// gaining the default alternates. +#[test] +fn old_settings_without_alternates_load() { + let old: Preferences = serde_json::from_str(r#"{"shortcuts": {"edit.copy": "Cmd+K", "edit.undo": ""}}"#).unwrap(); + assert_eq!(old.shortcut("edit.copy", Some("Cmd+C")), Some("Cmd+K")); + assert!(old.alternate_shortcuts.is_empty()); + // Overriding the main key keeps the alternate (Photoshop edits each row on its own). + assert_eq!(old.alternates("edit.copy", old.shortcut("edit.copy", Some("Cmd+C"))), ["F3"]); + // And a file with alternates round-trips. + let mut p = old.clone(); + p.set("alternateShortcuts.edit.copy", json!(["f9", "F9"])).unwrap(); + let back: Preferences = serde_json::from_value(p.to_json()).unwrap(); + assert_eq!(back.alternate_shortcuts.get("edit.copy"), Some(&vec!["F9".to_string()])); +} + +#[test] +fn alternate_shortcuts_set_reset_and_reject_bad_input() { + let mut s = session(); + // Through the command: a list replaces the defaults, [] removes them, null restores them. + s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": ["F9"]}})).unwrap(); + assert_eq!(s.prefs().alternates("edit.copy", Some("Cmd+C")), ["F9"]); + let r = s.execute("edit.keyboardShortcuts", json!({"filter": "edit.copy"})).unwrap(); + let row = r["commands"].as_array().unwrap().iter().find(|c| c["id"] == "edit.copy").unwrap().clone(); + assert_eq!(row["alternates"], json!(["F9"])); + assert_eq!(row["defaultAlternates"], json!(["F3"])); + s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": []}})).unwrap(); + assert!(s.prefs().alternates("edit.copy", Some("Cmd+C")).is_empty()); + s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": null}})).unwrap(); + assert_eq!(s.prefs().alternates("edit.copy", Some("Cmd+C")), ["F3"]); + // Through prefs.set / prefs.reset by path. + s.execute("prefs.set", json!({"path": "alternateShortcuts.edit.cut", "value": "F10"})).unwrap(); + assert_eq!(s.prefs().alternates("edit.cut", Some("Cmd+X")), ["F10"]); + s.execute("prefs.reset", json!({"path": "alternateShortcuts.edit.cut"})).unwrap(); + assert_eq!(s.prefs().alternates("edit.cut", Some("Cmd+X")), ["F2"]); + // Reset all clears alternates too. + s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.paste": []}})).unwrap(); + s.execute("edit.keyboardShortcuts", json!({"reset": true})).unwrap(); + assert!(s.prefs().alternate_shortcuts.is_empty()); + // Bad input is an error, not a panic, and changes nothing. + for bad in [ + json!({"alternates": {"edit.copy": "Cmd+Shift"}}), + json!({"alternates": {"edit.copy": [3]}}), + json!({"alternates": {"edit.copy": {"a": 1}}}), + json!({"alternates": {"edit.copy": vec!["F1"; MAX_ALTERNATES + 1]}}), + json!({"alternates": {"no.such.command": ["F9"]}}), + json!({"alternates": "F3"}), + ] { + assert!(s.execute("edit.keyboardShortcuts", bad.clone()).is_err(), "{bad}"); + } + assert!(s.prefs().alternate_shortcuts.is_empty()); + // A bad key in the same call as a reset leaves every shortcut as it was. + s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": "Cmd+K"}})).unwrap(); + assert!(s.execute("edit.keyboardShortcuts", json!({"reset": true, "alternates": {"edit.copy": ["Cmd+Shift"]}})).is_err()); + assert_eq!(s.prefs().shortcut("edit.copy", Some("Cmd+C")), Some("Cmd+K")); + assert!(s.execute("prefs.set", json!({"path": "alternateShortcuts", "value": {"edit.copy": ["Cmd+Shift"]}})).is_err()); + assert!(s.execute("prefs.set", json!({"path": "alternateShortcuts", "value": 7})).is_err()); +} + +/// A key given to another command is taken from the command that held it as an alternate +/// (Photoshop moves a shortcut rather than duplicating it), and kept duplicates are reported. +#[test] +fn moving_an_alternate_key_takes_it_from_its_holder() { + let mut s = session(); + // F2 is Cut's alternate; Rename Layer may take it (#2405). + let r = s.execute("edit.keyboardShortcuts", json!({"set": {"layer.renameLayer": "F2"}, "removeConflicts": false})).unwrap(); + assert!(r["conflicts"].as_array().unwrap().iter().any(|c| c["shortcut"] == "F2"), "{r}"); + s.execute("edit.keyboardShortcuts", json!({"reset": true})).unwrap(); + let r = s.execute("edit.keyboardShortcuts", json!({"set": {"layer.renameLayer": "F2"}})).unwrap(); + assert!(r["conflicts"].as_array().unwrap().is_empty(), "{r}"); + assert!(s.prefs().alternates("edit.cut", Some("Cmd+X")).is_empty()); + assert_eq!(s.prefs().shortcut("edit.cut", Some("Cmd+X")), Some("Cmd+X"), "Cut keeps its main key"); + // An alternate assigned to another command takes a main key too. + s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": ["Cmd+Z"]}})).unwrap(); + assert_eq!(s.prefs().shortcut("edit.undo", Some("Cmd+Z")), None); + // Overriding the main key keeps the alternates. + s.execute("edit.keyboardShortcuts", json!({"reset": true, "set": {"edit.copy": "Cmd+K"}})).unwrap(); + assert_eq!(s.prefs().alternates("edit.copy", s.prefs().shortcut("edit.copy", Some("Cmd+C"))), ["F3"]); +} + +/// #3665: a `.kys` row listing a command's default and a function key keeps both; the old +/// import kept only the first key. +#[test] +fn kys_import_keeps_a_rows_second_key() { + let mut s = session(); + let xml = "\ + Cmd+CF3\ + Cmd+X\ + Cmd+ZF1\ + "; + let r = s.execute("edit.keyboardShortcuts", json!({"importKys": xml})).unwrap(); + assert_eq!(r["import"]["alternates"], 2); + let p = s.prefs(); + assert_eq!(p.shortcut("edit.copy", Some("Cmd+C")), Some("Cmd+C")); + assert_eq!(p.alternates("edit.copy", Some("Cmd+C")), ["F3"]); + assert!(!p.alternate_shortcuts.contains_key("edit.copy"), "the default alternate needs no override"); + // A row without Cut's F2 removes it; Undo gains F1. + assert!(p.alternates("edit.cut", Some("Cmd+X")).is_empty()); + assert_eq!(p.alternates("edit.undo", Some("Cmd+Z")), ["F1"]); +} + /// #3296: like Photoshop, "Use Shift Key for Tool Switch" is on by default; a stored choice wins. #[test] fn shift_key_tool_switch_is_on_by_default_and_a_stored_false_is_kept() { @@ -623,3 +827,138 @@ fn shift_key_tool_switch_is_on_by_default_and_a_stored_false_is_kept() { let stored: Tools = serde_json::from_value(json!({"useShiftKeyForToolSwitch": false})).unwrap(); assert!(!stored.use_shift_key_for_tool_switch); } + +/// Shortcut exports are all or nothing (#5801): a rename that fails after the first file was +/// moved in puts the user's files back as they were and leaves nothing staged. +#[test] +fn exports_are_written_all_or_nothing() { + use super::write_files_atomically; + let dir = std::env::temp_dir().join(format!("photocraft-atomic-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (a, b) = (dir.join("a.kys"), dir.join("b.html")); + std::fs::write(&a, "old A").unwrap(); + std::fs::write(&b, "old B").unwrap(); + let (pa, pb) = (a.to_str().unwrap(), b.to_str().unwrap()); + let staged_moves = std::cell::Cell::new(0); + let failing = |from: &std::path::Path, to: &std::path::Path| { + if from.to_string_lossy().ends_with("photocraft-tmp") { + staged_moves.set(staged_moves.get() + 1); + if staged_moves.get() == 2 { + return Err(std::io::Error::other("disk gone")); + } + } + std::fs::rename(from, to) + }; + assert!(write_files_atomically(&[(pa, "new A"), (pb, "new B")], &failing).is_err()); + assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&b).unwrap()), ("old A".into(), "old B".into())); + let leftovers = || std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).filter(|e| e.file_name().to_string_lossy().contains(".photocraft-")).count(); + assert_eq!(leftovers(), 0); + // A backup move that fails leaves no empty placeholder behind. + let backups = std::cell::Cell::new(0); + let failing_backup = |from: &std::path::Path, to: &std::path::Path| { + if to.to_string_lossy().ends_with("photocraft-bak") { + backups.set(backups.get() + 1); + if backups.get() == 2 { + return Err(std::io::Error::other("locked")); + } + } + std::fs::rename(from, to) + }; + assert!(write_files_atomically(&[(pa, "new A"), (pb, "new B")], &failing_backup).is_err()); + assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&b).unwrap()), ("old A".into(), "old B".into())); + assert_eq!(leftovers(), 0); + // The same target twice is refused before anything is written. + assert!(write_files_atomically(&[(pa, "x"), (pa, "y")], &|f, t| std::fs::rename(f, t)).is_err()); + assert_eq!(std::fs::read_to_string(&a).unwrap(), "old A"); + // A target named like a staging file is just a file. + let odd = dir.join("c.kys.photocraft-tmp"); + std::fs::write(&odd, "old C").unwrap(); + write_files_atomically(&[(pa, "new A"), (odd.to_str().unwrap(), "new C")], &|f, t| std::fs::rename(f, t)).unwrap(); + assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&odd).unwrap()), ("new A".into(), "new C".into())); + assert_eq!(leftovers(), 1, "only c.kys.photocraft-tmp itself"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A backup that can't be put back after a failed export is named in the error, and stays where +/// it is with the user's previous text. +#[test] +fn a_stranded_backup_is_named_in_the_error() { + use super::write_files_atomically; + let dir = std::env::temp_dir().join(format!("photocraft-stranded-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (a, b) = (dir.join("a.kys"), dir.join("b.html")); + std::fs::write(&a, "old A").unwrap(); + std::fs::write(&b, "old B").unwrap(); + let (pa, pb) = (a.to_str().unwrap(), b.to_str().unwrap()); + let staged = std::cell::Cell::new(0); + // The second staged move fails, and so does putting a.kys's backup back. + let rename = |from: &std::path::Path, to: &std::path::Path| { + let f = from.to_string_lossy(); + if f.ends_with("photocraft-tmp") { + staged.set(staged.get() + 1); + if staged.get() == 2 { + return Err(std::io::Error::other("disk gone")); + } + } + if f.ends_with("photocraft-bak") && to == a.as_path() { + return Err(std::io::Error::other("locked")); + } + std::fs::rename(from, to) + }; + let e = write_files_atomically(&[(pa, "new A"), (pb, "new B")], &rename).unwrap_err(); + assert!(e.contains(&format!("the previous {} is at", a.display())), "{e}"); + let backup: Vec<_> = + std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.path()).filter(|p| p.to_string_lossy().ends_with("photocraft-bak")).collect(); + assert_eq!(backup.len(), 1, "{backup:?}"); + assert!(e.contains(&backup[0].display().to_string()), "{e}"); + assert_eq!(std::fs::read_to_string(&backup[0]).unwrap(), "old A"); + assert_eq!(std::fs::read_to_string(&b).unwrap(), "old B"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_wrongly_typed_value_resets_only_that_field() { + // #6611: one bad value used to reset every preference. + let mut s = Session::new(); + let invalid = s + .load_prefs_json( + r#"{"general": {"beepWhenDone": true}, "fileHandling": {"recentFileCount": "ten", "autosaveMinutes": 5}, + "performance": {"historyStates": 20}, "shortcuts": {"edit.fill": "Cmd+Shift+F", "file.open": 7}}"#, + ) + .unwrap(); + assert!(s.prefs().general.beep_when_done); + assert_eq!(s.prefs().file_handling.autosave_minutes, 5, "a sibling in the same section survives"); + assert_eq!(s.prefs().file_handling.recent_file_count, 20, "the bad value takes the default"); + assert_eq!(s.prefs().performance.history_states, 20); + assert_eq!(s.prefs().shortcuts.get("edit.fill").map(String::as_str), Some("Cmd+Shift+F")); + assert!(!s.prefs().shortcuts.contains_key("file.open")); + let paths: Vec = invalid.iter().map(InvalidPref::path).collect(); + assert_eq!(paths, ["fileHandling.recentFileCount", "shortcuts.file.open"]); + assert_eq!(invalid[0].value, json!("ten")); + assert_eq!(invalid[1].keys, ["shortcuts", "file.open"]); +} + +#[test] +fn a_wrongly_typed_section_or_colour_setting_resets_only_that_part() { + let mut s = Session::new(); + let invalid = s + .load_prefs_json( + r#"{"general": "loud", "performance": {"historyStates": 20}, + "colorSettings": {"workingRgb": "display-p3", "askOnMismatch": "yes"}}"#, + ) + .unwrap(); + assert_eq!(s.prefs().general, General::default()); + assert_eq!(s.prefs().performance.history_states, 20); + assert_eq!(s.color.settings.working_rgb, "display-p3", "the rest of the colour settings load"); + let paths: Vec = invalid.iter().map(InvalidPref::path).collect(); + assert_eq!(paths, ["general", "colorSettings.askOnMismatch"]); + // A valid file reports nothing; one that isn't an object still fails as a whole. + assert!(s.load_prefs_json(&s.prefs_to_json()).unwrap().is_empty()); + for bad in ["[1, 2]", "42", "\"prefs\"", "null", "{", ""] { + assert!(s.load_prefs_json(bad).is_err(), "{bad}"); + } + // Deep nesting and odd values don't panic. + let deep = format!("{{\"general\": {}1{}}}", "{\"a\":".repeat(100), "}".repeat(100)); + assert!(s.load_prefs_json(&deep).is_ok()); + assert!(s.load_prefs_json(r#"{"interface": {"theme": 3, "uiScale": [1]}, "workspaces": {"x": 1}, "menus": 1}"#).is_ok()); +} diff --git a/crates/engine/src/preset_import_cmds.rs b/crates/engine/src/preset_import_cmds.rs index e6bff8eb6b..51ce9bf078 100644 --- a/crates/engine/src/preset_import_cmds.rs +++ b/crates/engine/src/preset_import_cmds.rs @@ -4,6 +4,9 @@ //! library as one group named after the file, with the file's own folders nested inside it //! as Photoshop does (Brushes panel › Import Brushes…, Preset Manager). Parsing lives in //! `photocraft-psd` (`abr`), the mapping onto [`BrushSettings`] in `photocraft-io` (`abr_map`). +//! - `brush.presets.importImage` turns an image file (anything File › Open reads: PNG, JPEG, +//! TIFF, PSD…) into a sampled-tip brush preset, as Edit › Define Brush Preset does with a +//! selection: dark pixels paint, white and transparent ones don't (TOOL-213-12). //! - `gradient.presets.importGrd` adds the gradients of a `.grd` file (version 5) to the //! Gradients panel as one group. //! @@ -46,13 +49,32 @@ pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec, S Ok((bytes, stem)) } -/// A name not used by any preset in `taken` (case-insensitive): `name`, `name 2`, `name 3`… -fn unique(name: &str, taken: &[String]) -> String { - let free = |n: &str| !taken.iter().any(|t| t.eq_ignore_ascii_case(n)); - if free(name) { - return name.to_string(); +/// The preset names in use, for naming new presets. +struct UniqueNames { + /// Every name in use, ASCII-lowercased. + taken: std::collections::HashSet, + /// The next number to try for each repeated name (lowercased). + next: std::collections::HashMap, +} + +impl UniqueNames { + fn new<'a>(names: impl IntoIterator) -> Self { + UniqueNames { taken: names.into_iter().map(str::to_ascii_lowercase).collect(), next: std::collections::HashMap::new() } + } + + /// A name not in use (case-insensitive): `name`, `name 2`, `name 3`… It is in use from then on. + fn unique(&mut self, name: &str) -> String { + let key = name.to_ascii_lowercase(); + if self.taken.insert(key.clone()) { + return name.to_string(); + } + // Resume numbering per name, so a file of identical names stays linear. + let from = self.next.get(&key).copied().unwrap_or(2); + let i = (from..).find(|i| !self.taken.contains(&format!("{key} {i}"))).unwrap_or(from); + self.taken.insert(format!("{key} {i}")); + self.next.insert(key, i + 1); + format!("{name} {i}") } - (2..).map(|i| format!("{name} {i}")).find(|n| free(n)).unwrap_or_else(|| name.to_string()) } fn import_abr(s: &mut Session, p: &Value) -> Result { @@ -90,11 +112,10 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io if p.get("replace").and_then(Value::as_bool).unwrap_or(true) { s.tools.presets.retain(|x| x.builtin || x.group != group); } - let mut taken: Vec = s.tools.presets.iter().map(|x| x.name.clone()).collect(); + let mut taken = UniqueNames::new(s.tools.presets.iter().map(|x| x.name.as_str())); let mut names = Vec::with_capacity(imp.presets.len()); for mut preset in imp.presets { - preset.name = unique(&preset.name, &taken); - taken.push(preset.name.clone()); + preset.name = taken.unique(&preset.name); names.push(preset.name.clone()); s.tools.presets.push(preset); } @@ -115,6 +136,86 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io Ok(json!({ "group": group, "imported": names, "count": names.len(), "folders": folders, "version": imp.version, "warnings": imp.warnings })) } +/// The image's paint amount (darkness × alpha of its composite, as Define Brush Preset reads a +/// selection), cropped to the painted extent. The composite is rendered in bands twice: once to +/// find the painted bounds, then only those, so a large mostly-white image never needs a +/// full-size buffer. +fn image_paint(doc: &photocraft_doc::Document, cmd: &str) -> Result<(Vec, usize, usize)> { + let paint = |c: [f32; 4]| ((1.0 - photocraft_color::blend::lum([c[0], c[1], c[2]])) * c[3]).clamp(0.0, 1.0); + let mut bounds: Option = None; + let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> std::result::Result<(), ()> { + let w = band.rect.width() as usize; + for (i, c) in band.px.iter().enumerate() { + if paint(*c) > 1e-4 { + let (x, y) = (band.rect.x0 + (i % w.max(1)) as i32, band.rect.y0 + (i / w.max(1)) as i32); + let px = photocraft_geom::Rect::new(x, y, x.saturating_add(1), y.saturating_add(1)); + bounds = Some(bounds.map_or(px, |b| b.union(&px))); + } + } + Ok(()) + }); + let area = bounds.ok_or_else(|| bad(cmd, "the image contains no paint (all white or transparent)"))?; + let max = crate::brush_cmds::MAX_TIP; + if area.width().max(area.height()) > max { + return Err(bad(cmd, format!("the painted part of the image is too large for a brush tip ({}×{}; max {max} px)", area.width(), area.height()))); + } + let (w, h) = (area.width() as usize, area.height() as usize); + let mut v = vec![0.0f32; w * h]; + let _ = photocraft_compose::render_bands(doc, area, 0, |band| -> std::result::Result<(), ()> { + let bw = band.rect.width() as usize; + for (i, c) in band.px.iter().enumerate() { + let (x, y) = ((band.rect.x0 - area.x0) as usize + i % bw.max(1), (band.rect.y0 - area.y0) as usize + i / bw.max(1)); + if let Some(out) = v.get_mut(y * w + x) { + *out = paint(*c); + } + } + Ok(()) + }); + Ok((v, w, h)) +} + +/// Brushes panel › Import Brushes… with an image file: a new sampled-tip preset named after the +/// file (never overwriting one), made the current brush unless `select` is false. +fn import_image(s: &mut Session, p: &Value) -> Result { + let cmd = "brush.presets.importImage"; + let (bytes, stem) = file_bytes(p, cmd, "image file")?; + let file_name = match p.get("fileName") { + None => None, + Some(v) => Some(v.as_str().map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "`fileName` must be a non-empty string"))?), + }; + // The decoder picks the format by content, falling back to the name's extension. + let decode_name = p.get("path").and_then(Value::as_str).or(file_name).unwrap_or("image"); + let stem = if stem.is_empty() { + file_name.and_then(|n| std::path::Path::new(n).file_stem()).map(|s| s.to_string_lossy().trim().to_string()).unwrap_or_default() + } else { + stem + }; + let name = match p.get("name") { + None if stem.is_empty() => "Imported Brush".to_string(), + None => stem, + Some(v) => v.as_str().map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "`name` must be a non-empty string"))?.to_string(), + }; + let doc = crate::file_cmds::import(decode_name, &bytes)?; + let (v, w, h) = image_paint(&doc, cmd)?; + let (brush, tw, th) = crate::brush_cmds::sampled_brush(cmd, &v, w, h)?; + let name = UniqueNames::new(s.tools.presets.iter().map(|x| x.name.as_str())).unique(&name); + s.tools.presets.push(photocraft_paint::BrushPreset { + name: name.clone(), + brush: brush.clone(), + builtin: false, + group: String::new(), + folder: Vec::new(), + captures: Default::default(), + }); + s.brush_presets_changed(); + if p.get("select").and_then(Value::as_bool).unwrap_or(true) { + s.tools.brush = brush; + s.tools.current_preset = Some(name.clone()); + s.tools.brush_changed(); + } + Ok(json!({ "name": name, "width": tw, "height": th })) +} + /// Brush-file import command specs. pub fn specs() -> Vec { vec![ @@ -128,6 +229,16 @@ pub fn specs() -> Vec { run: import_abr, journal: true, }, + CommandSpec { + id: "brush.presets.importImage", + label: "Import Brush from Image…", + menu: &[], + shortcut: None, + params: r##"{"path":"image file (PNG, JPEG, TIFF, PSD…)"?,"data":base64 bytes?,"fileName":string? (with data: the file's name, for its format and the default preset name),"name":string?=file name,"select":bool=true (make it the current brush)} → {name, width, height}"##, + enabled: always, + run: import_image, + journal: true, + }, CommandSpec { id: "gradient.presets.importGrd", label: "Import Gradients…", @@ -362,10 +473,125 @@ mod tests { assert!(k.iter().all(|v| *v < 0.01), "{k:?}"); } + /// A 40×30 16-bit RGBA PNG: transparent, a white opaque band (no paint) and a black 10×6 block + /// at (5, 8) (full paint). + fn stamp_png16() -> Vec { + let mut px = vec![0u16; 40 * 30 * 4]; + for y in 0..30 { + for x in 0..40 { + let i = (y * 40 + x) * 4; + if (5..15).contains(&x) && (8..14).contains(&y) { + px[i + 3] = u16::MAX; + } else if y >= 20 { + px[i..i + 4].copy_from_slice(&[u16::MAX; 4]); + } + } + } + let img = photocraft_codecs::Image::from_u16(40, 30, photocraft_codecs::ChannelLayout::Rgba, &px).unwrap(); + photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap() + } + + #[test] + fn imports_an_image_as_a_brush_tip() { + use photocraft_paint::TipShape; + let mut s = Session::new(); + let data = photocraft_paint::tile::b64_encode(&stamp_png16()); + let r = s.execute("brush.presets.importImage", json!({"data": data, "fileName": "Stamp.png"})).unwrap(); + assert_eq!(r, json!({"name": "Stamp", "width": 10, "height": 6}), "cropped to the dark block"); + let pr = s.tools.presets.iter().find(|p| p.name == "Stamp").unwrap(); + let TipShape::Sampled(tip) = &pr.brush.tip else { panic!("sampled tip expected") }; + assert_eq!((tip.width, tip.height), (10, 6)); + assert!((pr.brush.size - 10.0).abs() < 1e-6); + assert_eq!(s.tools.current_preset.as_deref(), Some("Stamp"), "the imported brush is current"); + assert!(matches!(s.tools.brush.tip, TipShape::Sampled(_))); + // A second import keeps the first: a new name, and `select: false` leaves the brush alone. + let r = s.execute("brush.presets.importImage", json!({"data": data, "fileName": "Stamp.png", "select": false})).unwrap(); + assert_eq!(r["name"], "Stamp 2"); + assert_eq!(s.tools.current_preset.as_deref(), Some("Stamp")); + + // An 8-bit grayscale JPEG from disk: named after the file. + let px: Vec = (0..24 * 24).map(|i| if (8..16).contains(&(i % 24)) && (4..20).contains(&(i / 24)) { 0 } else { 255 }).collect(); + let img = photocraft_codecs::Image::from_u8(24, 24, photocraft_codecs::ChannelLayout::Gray, px).unwrap(); + let jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).unwrap(); + let path = std::env::temp_dir().join(format!("pc-brush-tip-{}.jpg", std::process::id())); + std::fs::write(&path, jpeg).unwrap(); + let r = s.execute("brush.presets.importImage", json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + let r = r.unwrap(); + assert!(r["name"].as_str().unwrap().starts_with("pc-brush-tip-"), "{r}"); + let (w, h) = (r["width"].as_u64().unwrap(), r["height"].as_u64().unwrap()); + assert!((8..=10).contains(&w) && (16..=18).contains(&h), "JPEG ringing aside, the bar: {r}"); + } + + #[test] + fn image_brush_import_fails_cleanly() { + let mut s = Session::new(); + let white = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Gray, vec![255; 16]).unwrap(); + let white = photocraft_paint::tile::b64_encode(&photocraft_codecs::encode(&white, photocraft_codecs::Format::Png, &Default::default()).unwrap()); + let n = s.tools.presets.len(); + for p in [ + json!({}), + json!({"data": white}), + json!({"data": "@@"}), + json!({"data": photocraft_paint::tile::b64_encode(b"not an image")}), + json!({"data": white, "name": 3}), + json!({"data": white, "fileName": ""}), + json!({"path": "/nope/brush.png"}), + ] { + assert!(s.execute("brush.presets.importImage", p.clone()).is_err(), "{p}"); + } + assert_eq!(s.tools.presets.len(), n, "a failed import adds nothing"); + } + + /// A version 1 brush with a computed tip and no name is named after its tip ("9 px Round"). The + /// import used to number each repeated name by searching the preset names once for every number + /// it tried, so importing 10,000 such brushes took minutes. The test fails when the import takes + /// more than 20 seconds. + #[test] + fn repeated_brush_names_are_numbered_in_linear_time() { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut s = Session::new(); + s.execute("brush.presets.save", json!({"name": "9 PX ROUND 3"})).unwrap(); + let round = LegacyBrush { + name: String::new(), + spacing: 25, + anti_alias: true, + tip: LegacyTip::Computed { diameter: 9, hardness: 100, angle: 0, roundness: 100 }, + }; + let data = photocraft_paint::tile::b64_encode(&write_v12(1, &vec![round; 10_000], false).unwrap()); + let r = s.execute("brush.presets.importAbr", json!({"data": data})).unwrap(); + let names: Vec = r["imported"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); + let _ = tx.send(names); + }); + let names = match rx.recv_timeout(std::time::Duration::from_secs(20)) { + Ok(names) => names, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("importing the brushes did not finish"), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("the import thread panicked"), + }; + // Numbering starts at 2 and skips the number a preset already uses, in any letter case. + assert_eq!(names[..4], ["9 px Round", "9 px Round 2", "9 px Round 4", "9 px Round 5"]); + assert_eq!(names.last(), Some(&format!("9 px Round {}", names.len() + 1))); + assert_eq!(names.iter().collect::>().len(), names.len()); + } + + #[test] + fn repeated_brush_names_take_the_first_free_number() { + let mut s = Session::new(); + s.execute("brush.presets.save", json!({"name": "speckle 3"})).unwrap(); + let tip = AbrSample { id: String::new(), width: 6, height: 4, depth: 8, data: vec![255; 24] }; + let brush = |name: &str| LegacyBrush { name: name.into(), spacing: 25, anti_alias: true, tip: LegacyTip::Sampled(tip.clone()) }; + let file = write_v12(2, &["Speckle", "SPECKLE", "speckle", "Speckle 2", "Speckle", "hard round"].map(brush), true).unwrap(); + let r = s.execute("brush.presets.importAbr", json!({"data": photocraft_paint::tile::b64_encode(&file)})).unwrap(); + // Names that differ only in letter case count as the same name. The file's "Speckle 2" comes + // after "SPECKLE" took that name, and "hard round" clashes with the built-in Hard Round. + assert_eq!(r["imported"], json!(["Speckle", "SPECKLE 2", "speckle 4", "Speckle 2 2", "Speckle 5", "hard round 2"])); + } + #[test] fn unique_names() { - let taken = vec!["A".to_string(), "a 2".to_string()]; - assert_eq!(unique("a", &taken), "a 3"); - assert_eq!(unique("B", &taken), "B"); + let mut taken = UniqueNames::new(["A", "a 2"]); + assert_eq!(taken.unique("a"), "a 3"); + assert_eq!(taken.unique("B"), "B"); } } diff --git a/crates/engine/src/presets/gradients.rs b/crates/engine/src/presets/gradients.rs index 7c92dfba97..2a700aad99 100644 --- a/crates/engine/src/presets/gradients.rs +++ b/crates/engine/src/presets/gradients.rs @@ -604,7 +604,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { let g = preset_param(s, p, CMD)?; let stops = g.doc_stops(s.tools.foreground, s.tools.background); let angle = p.get("angle").and_then(Value::as_f64).unwrap_or(90.0) as f32; - let scale = (p.get("scale").and_then(Value::as_f64).unwrap_or(100.0) as f32 / 100.0).clamp(0.1, 1.5); + let scale = crate::gradient_fill_cmds::clamp_scale(p.get("scale").and_then(Value::as_f64).unwrap_or(100.0) as f32 / 100.0); let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); let id = s.edit("New Gradient Fill Layer", |doc, active| { @@ -696,7 +696,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: leak(format!( - r##"{{"preset":name?=current|{stops},"angle":deg=90,"style":"linear|radial|angle|reflected|diamond"="linear","scale":10..150=100,"reverse":bool}} → {{layer}}"## + r##"{{"preset":name?=current|{stops},"angle":deg=90,"style":"linear|radial|angle|reflected|diamond"="linear","scale":1..1000=100,"reverse":bool}} → {{layer}}"## )), enabled: super::has_doc, run: apply, diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 5a72f781fc..e5c3415f7d 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -83,6 +83,8 @@ pub struct PresetState { pub documents: Vec, /// File › New › Recent: the settings of documents recently created there, newest first. pub recent_documents: Vec, + /// File › Export › Export As: named export configurations. + pub export_presets: Vec, pub clone: clone_source::CloneSources, /// The user defaults "Make Default" in the Layer Style dialog saves /// (effect kind → param set); "Reset to Default" restores them. @@ -105,6 +107,7 @@ impl Default for PresetState { tool_presets: tools::builtin(), documents: Vec::new(), recent_documents: Vec::new(), + export_presets: Vec::new(), clone: Default::default(), layer_defaults: BTreeMap::new(), swatches: swatches::builtin(), @@ -135,6 +138,8 @@ struct Persisted { #[serde(default)] recent_documents: Option, #[serde(default)] + export_presets: Option, + #[serde(default)] custom_shapes: Option>, #[serde(default)] layer_defaults: Option>, @@ -153,6 +158,7 @@ impl PresetState { tool_presets: Some(json!(self.tool_presets)), documents: Some(json!(self.documents)), recent_documents: Some(json!(self.recent_documents)), + export_presets: Some(json!(self.export_presets)), custom_shapes: Some(s.edit_state.custom_shapes.clone()), layer_defaults: Some(self.layer_defaults.clone()), swatches: Some(self.swatches.clone()), @@ -162,10 +168,29 @@ 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"), + 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"), + }; let st = &mut self.presets; if let Some(g) = p.gradients { st.gradients = g; @@ -191,6 +216,9 @@ impl Session { if let Some(r) = p.recent_documents { st.recent_documents = crate::document_preset_cmds::load_recent(r); } + if let Some(e) = p.export_presets { + st.export_presets = crate::export_preset_cmds::load(e); + } if let Some(c) = p.custom_shapes { self.edit_state.custom_shapes = c; } @@ -334,6 +362,9 @@ pub(crate) fn edit_groups(groups: &mut Vec>, action: &str, p: let g = req_str(p, "group", cmd)?; let to = req_str(p, "name", cmd)?.to_string(); let i = groups.iter().position(|x| x.name == g).ok_or_else(|| bad(cmd, format!("no group \"{g}\"")))?; + if g != to && groups.iter().any(|x| x.name == to) { + return Err(bad(cmd, format!("a group named \"{to}\" already exists"))); + } groups[i].name = to.clone(); Ok(json!({"group": to})) } diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index dcfc4e94d8..db719afb1d 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -317,6 +317,23 @@ fn gradient_new_rename_move_delete_groups() { assert!(s.execute("gradient.presets.edit", json!({"action": "explode"})).is_err()); } +#[test] +fn gradient_rename_group_refuses_a_name_another_group_has() { + let mut s = Session::new(); + s.execute("gradient.presets.edit", json!({"action": "newGroup", "name": "Pastels"})).unwrap(); + let names = |s: &mut Session| -> Vec { + s.execute("gradient.presets.list", json!({})).unwrap()["groups"].as_array().unwrap().iter().map(|g| g["name"].clone()).collect() + }; + let before = names(&mut s); + assert!(before.contains(&json!("Blues"))); + let err = s.execute("gradient.presets.edit", json!({"action": "renameGroup", "group": "Pastels", "name": "Blues"})).unwrap_err(); + assert!(err.to_string().contains("already exists"), "{err}"); + assert_eq!(names(&mut s), before, "a refused rename leaves the groups unchanged"); + // Renaming a group to its own name is fine. + s.execute("gradient.presets.edit", json!({"action": "renameGroup", "group": "Pastels", "name": "Pastels"})).unwrap(); + assert_eq!(names(&mut s), before); +} + #[test] fn presets_persist_through_preferences_json() { let mut s = Session::new(); @@ -748,3 +765,47 @@ 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); +} + +// ------------------------------------------------------------------ export presets + +#[test] +fn export_presets_survive_a_presets_json_round_trip() { + let mut s = Session::new(); + s.execute("file.export.presets.save", json!({"name": "Web", "settings": {"format": "jpg", "quality": 70}})).unwrap(); + assert_eq!(s.presets.export_presets.len(), 1); + + let saved = s.presets.to_json(&s); + assert_eq!(saved["export_presets"].as_array().map(Vec::len), Some(1)); + + let mut restored = Session::new(); + restored.load_presets_json(saved); + assert_eq!(restored.presets.export_presets.len(), 1); + assert_eq!(restored.presets.export_presets[0].name, "Web"); + assert_eq!(restored.presets.export_presets[0].settings.format, "jpg"); +} diff --git a/crates/engine/src/presets/tools.rs b/crates/engine/src/presets/tools.rs index 796a66623e..2746c1576e 100644 --- a/crates/engine/src/presets/tools.rs +++ b/crates/engine/src/presets/tools.rs @@ -37,7 +37,7 @@ impl Named for ToolPreset { pub const MAX_PRESETS: usize = 1000; /// A preset name: trimmed, 1–[`MAX_NAME`](crate::brush_preset_cmds::MAX_NAME) characters. -fn checked_name(name: &str, cmd: &str) -> Result { +pub(crate) fn checked_name(name: &str, cmd: &str) -> Result { let name = name.trim(); if name.is_empty() { return Err(bad(cmd, "the preset name is empty")); diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 73324711d9..0a1701a166 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -8,12 +8,20 @@ //! `"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. use std::io::Write as _; +mod windows_marks; + use photocraft_color::ColorMode; use photocraft_doc::{Document, LayerContent, SmartSource}; use serde_json::{Value, json}; @@ -29,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). @@ -341,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. @@ -350,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); @@ -368,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 @@ -388,7 +582,28 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { let send = p.get("send").and_then(Value::as_bool).unwrap_or(output.is_none()); let copies = crate::commands::int(p, "copies").unwrap_or(1).clamp(1, 999) as u32; let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); + // Shared RGB transport handles compatible Windows pages; PDF-only content retains its route. + // 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"); + // 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!({ + "pdf": Value::Null, "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, + "copies": copies, "command": Value::Null, "sent": true, "spooler": printed, "color": color, + })); + } + 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, @@ -408,23 +623,42 @@ 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 { - // The last line of defence: a test or fuzz run must never print a page (`dryRun` still - // renders and reports the command). - if printing_disabled() { - return Err(other( - "printing is disabled in this process (photocraft_engine::print_cmds::disable_printing or PHOTOCRAFT_NO_PRINT); use dryRun: true", - )); - } - spool = json!(craft_print::submit(&pdf, &job).map_err(other)?); + require_printing_enabled()?; + 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}), ) } -/// Print One Copy repeats these. +/// Keep CMYK output profiles and page text on the existing PDF composition path. GDI currently +/// draws RGB/gray and vector marks only; it must never silently omit requested page content. +fn native_rgb_compatible(page: &PrintPage, handling: &str) -> bool { + matches!(page.image.channels, 1 | 3) + && (page.image.icc.is_none() || matches!(handling, "photocraftManages" | "photoshopManages" | "noColorManagement")) + && page.description.as_deref().is_none_or(str::is_empty) + && page.label.as_deref().is_none_or(str::is_empty) +} + +/// Where the settings of the last print are kept in Preferences' remembered dialog settings. +pub const REMEMBERED: &str = "file.print"; + +/// Print One Copy repeats these, and Print opens with them. They are kept in the preferences as +/// well, so they survive a relaunch, as Photoshop's do (#7410). fn remember_print(s: &mut Session, p: &Value) { let mut remembered = p.clone(); if let Some(o) = remembered.as_object_mut() { @@ -433,9 +667,126 @@ fn remember_print(s: &mut Session, p: &Value) { // Output and dry-run choices are not remembered, so a send override must not survive them. o.remove("send"); } + let kept = remembered.clone(); + s.prefs.edit(|prefs| { + prefs.dialogs.insert(REMEMBERED.into(), kept); + }); s.file_menu.last_print = Some(remembered); } +/// Drops the `printer` of the settings `q` (and its driver settings) when the print server lists +/// printers but not that one, so the job goes to the default printer. An empty list can't tell. +fn drop_vanished_printer(q: &mut Value, listed: &[&str]) { + let Some(name) = q.get("printer").and_then(Value::as_str).filter(|n| !n.is_empty()) else { return }; + if listed.is_empty() || listed.iter().any(|l| l.eq_ignore_ascii_case(name)) { + return; + } + if let Some(o) = q.as_object_mut() { + o.remove("printer"); + o.remove("printerSettings"); + } +} + +/// The settings of the last print: this session's, else those remembered from an earlier one. +pub fn last_print(s: &Session) -> Option { + s.file_menu.last_print.clone().or_else(|| s.prefs().dialogs.get(REMEMBERED).filter(|v| v.is_object()).cloned()) +} + +/// The `printer` parameter, or the system's default printer. +fn printer_name(p: &Value) -> Result { + if let Some(name) = p.get("printer").and_then(Value::as_str).filter(|v| !v.trim().is_empty()) { + return Ok(name.to_string()); + } + craft_print::rgb::default_printer().map_err(other)?.ok_or_else(|| other("no printer chosen and no default printer is set")) +} + +/// The driver settings chosen with Print Settings… (`printerSettings`, hex), when any. +fn printer_settings(p: &Value) -> Result> { + match p.get("printerSettings").and_then(Value::as_str).filter(|v| !v.is_empty()) { + Some(hex) => craft_print::rgb::DevMode::from_hex(hex).map(Some).map_err(|e| bad("file.print", e.to_string())), + None => Ok(None), + } +} + +/// The page's image as 8-bit RGB rows (gray expanded), mirrored for Emulsion Down. +pub fn page_rgb(page: &PrintPage) -> Result> { + let img = &page.image; + let (w, h) = (img.width as usize, img.height as usize); + let mut rgb = match img.channels { + 3 => img.data.clone(), + 1 => img.data.iter().flat_map(|g| [*g, *g, *g]).collect(), + _ => return Err(other("this printer profile is CMYK; Windows printers take RGB, so choose the printer's own (RGB) profile")), + }; + let row = w.checked_mul(3).ok_or_else(|| other("print image is too large"))?; + if row == 0 || row.checked_mul(h) != Some(rgb.len()) { + return Err(other("nothing to print")); + } + if page.emulsion_down { + for row in rgb.chunks_exact_mut(row) { + row.as_chunks_mut::<3>().0.reverse(); + } + } + Ok(rgb) +} + +/// The image box with its origin at the sheet's top-left (GDI's), mirrored for Emulsion Down. +pub fn page_rect_top_left(page: &PrintPage) -> (f64, f64, f64, f64) { + let (pw, ph) = page.paper; + let (x, y, w, h) = page.rect; + let x = if page.emulsion_down { pw - x - w } else { x }; + (x, ph - y - h, w, h) +} + +/// Prints `page` on a Windows printer. Corner/centre crop and registration marks are drawn with +/// GDI vector calls, matching the PDF path's geometry; description and label text are still +/// PDF-only (no font/text layout in the GDI path yet). +fn print_windows(page: &PrintPage, p: &Value, title: &str, copies: u32) -> Result { + let printer = printer_name(p)?; + let settings = printer_settings(p)?; + let device_page = craft_print::rgb::page(&printer, settings.as_ref()).map_err(other)?; + if (device_page.paper.0 - page.paper.0).abs() > 2.0 || (device_page.paper.1 - page.paper.1).abs() > 2.0 { + return Err(other("the driver paper or orientation differs from the print layout; choose the matching paper in Print Settings before printing")); + } + let rgb = page_rgb(page)?; + let image = craft_print::rgb::RgbImage { width: page.image.width, height: page.image.height, data: &rgb }; + let rect = page_rect_top_left(page); + let primitives = windows_marks::primitives(page.marks, rect); + let color_policy = match p.get("colorHandling").and_then(Value::as_str) { + Some("photocraftManages" | "photoshopManages") => craft_print::rgb::ColorPolicy::ApplicationManaged, + Some("noColorManagement") => craft_print::rgb::ColorPolicy::Unmanaged, + _ => craft_print::rgb::ColorPolicy::DriverManaged, + }; + let job = craft_print::rgb::Job { + printer: &printer, + settings: settings.as_ref(), + title, + output_file: None, + copies: copies.clamp(1, 999), + color_policy, + primitives: &primitives, + }; + let done = craft_print::rgb::print(&job, &image, rect).map_err(other)?; + Ok( + json!({"printer": printer, "dpi": [done.dpi.0, done.dpi.1], "deviceRect": [done.device_rect.0, done.device_rect.1, done.device_rect.2, done.device_rect.3]}), + ) +} + +fn printers(_: &mut Session, _: &Value) -> Result { + match craft_print::rgb::printers() { + Ok(list) => Ok(json!({"supported": true, "printers": list.iter().map(|p| json!({"name": p.name, "default": p.default})).collect::>()})), + Err(craft_print::rgb::PrintError::Unsupported) => Ok(json!({"supported": false, "printers": []})), + Err(e) => Err(other(e)), + } +} + +fn printer_page(_: &mut Session, p: &Value) -> Result { + let printer = printer_name(p)?; + let pg = craft_print::rgb::page(&printer, printer_settings(p)?.as_ref()).map_err(other)?; + Ok( + json!({"printer": printer, "paper": [pg.paper.0, pg.paper.1], "printable": [pg.printable.0, pg.printable.1, pg.printable.2, pg.printable.3], "dpi": [pg.dpi.0, pg.dpi.1]}), + ) +} + /// Set once by test harnesses (the panic hunt) so no command in the process can reach a printer. static PRINTING_DISABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); @@ -450,13 +801,12 @@ pub fn printing_disabled() -> bool { PRINTING_DISABLED.load(std::sync::atomic::Ordering::Relaxed) || std::env::var_os("PHOTOCRAFT_NO_PRINT").is_some_and(|v| !v.is_empty() && v != "0") } -/// 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 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 require_printing_enabled() -> Result<()> { + if printing_disabled() { + Err(other("printing is disabled in this process (photocraft_engine::print_cmds::disable_printing or PHOTOCRAFT_NO_PRINT); use dryRun: true")) + } else { + Ok(()) + } } fn print(s: &mut Session, p: &Value) -> Result { @@ -464,7 +814,13 @@ fn print(s: &mut Session, p: &Value) -> Result { } fn print_one_copy(s: &mut Session, p: &Value) -> Result { - let mut q = s.file_menu.last_print.clone().unwrap_or_else(|| json!({})); + let mut q = last_print(s).unwrap_or_else(|| json!({})); + // A printer remembered from an earlier session may be gone: then the default takes the job, + // as the Print dialog falls back (#7410). A list that can't be read decides nothing. + if let Ok(listed) = system_printers(s, &q) { + let names: Vec<&str> = listed["printers"].as_array().into_iter().flatten().filter_map(|p| p["name"].as_str()).collect(); + drop_vanished_printer(&mut q, &names); + } if let (Some(o), Some(extra)) = (q.as_object_mut(), p.as_object()) { for (k, v) in extra { o.insert(k.clone(), v.clone()); @@ -479,8 +835,15 @@ 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. + // `folder`, `document` and `links` are built on it with `join`, so it is spelt the same way: + // `/` throughout, not a native prefix with `/` after it. + let dir = std::path::absolute(dir).map_or_else(|_| dir.to_string(), |d| d.to_string_lossy().into_owned()); + let dir = crate::file_cmds::forward_slashes(dir); 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. @@ -489,7 +852,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(); @@ -506,7 +869,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())); @@ -627,9 +995,30 @@ 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##"{"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), + CommandSpec { + id: "file.print.printers", + label: "Printers", + menu: &[], + shortcut: None, + params: r##"{} → {supported, printers: [{name, default}]} (supported: Windows; elsewhere Print spools with lp)"##, + enabled: |_| Ok(()), + journal: false, + run: printers, + }, + CommandSpec { + id: "file.print.page", + label: "Printer Page", + menu: &[], + shortcut: None, + params: r##"{"printer":name? (default printer),"printerSettings":hex?} → {printer, paper: [w,h] pt, printable: [x,y,w,h] pt from the top-left, dpi: [x,y]} (Windows)"##, + enabled: |_| Ok(()), + journal: false, + run: printer_page, + }, CommandSpec { id: "file.printers", label: "Printers", @@ -638,7 +1027,7 @@ pub fn specs() -> Vec { params: r##"{} → {printers:[{name, default}], default:name|null} (the system spooler's printers; a name is Print's "printer")"##, enabled: crate::commands::always, journal: false, - run: printers, + run: system_printers, }, spec!( "file.printOneCopy", diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index bf5f1e3857..c54dddf6f8 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -5,7 +5,9 @@ fn tmp(name: &str) -> String { let d = std::env::temp_dir().join(format!("photocraft-print-{}-{name}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); - d.to_string_lossy().into_owned() + // Spelt the way command results spell paths, so an expectation built with `/` matches on + // Windows too. Win32 takes `/` as a separator, so the directory is still usable as one. + d.to_string_lossy().replace('\\', "/") } fn session(mode: &str, depth: u32) -> Session { @@ -52,7 +54,7 @@ fn print_dry_run_renders_a_pdf_and_reports_the_platform_backend() { assert!(!cmd.contains(&out), "PDF bytes are streamed instead of passing a path"); } #[cfg(windows)] - assert!(cmd.last().unwrap().contains("craft-print PDF spooler")); + assert_eq!(cmd.first().map(String::as_str), Some("xps-print")); assert_eq!(r["sent"], false); assert_eq!(r["pdf"], out); let pdf = std::fs::read(&out).unwrap(); @@ -151,6 +153,60 @@ fn emulsion_down_mirrors_the_whole_page() { let _ = std::fs::remove_dir_all(&dir); } +fn page_of(channels: usize, data: Vec, emulsion_down: bool) -> PrintPage { + PrintPage { + paper: (600.0, 800.0), + image: PrintImage { width: 2, height: 1, channels, data, icc: None }, + rect: (100.0, 200.0, 144.0, 72.0), + marks: Marks::default(), + description: None, + label: None, + emulsion_down, + } +} + +#[test] +fn windows_print_image_is_rgb_mirrored_for_emulsion_down() { + assert_eq!(page_rgb(&page_of(3, vec![1, 2, 3, 4, 5, 6], false)).unwrap(), [1, 2, 3, 4, 5, 6]); + assert_eq!(page_rgb(&page_of(3, vec![1, 2, 3, 4, 5, 6], true)).unwrap(), [4, 5, 6, 1, 2, 3]); + assert_eq!(page_rgb(&page_of(1, vec![7, 9], true)).unwrap(), [9, 9, 9, 7, 7, 7]); + assert!(page_rgb(&page_of(4, vec![0; 8], false)).is_err(), "CMYK can't go to a GDI printer"); + assert!(page_rgb(&page_of(3, vec![0; 5], false)).is_err(), "short data"); + // GDI's origin is the sheet's top-left; Emulsion Down moves the box to the mirrored side. + assert_eq!(page_rect_top_left(&page_of(3, vec![0; 6], false)), (100.0, 528.0, 144.0, 72.0)); + assert_eq!(page_rect_top_left(&page_of(3, vec![0; 6], true)), (356.0, 528.0, 144.0, 72.0)); +} + +#[test] +fn native_rgb_route_preserves_pdf_only_color_and_page_content() { + let mut page = page_of(3, vec![0; 6], false); + assert!(native_rgb_compatible(&page, "printerManages")); + page.label = Some("Print Me".into()); + assert!(!native_rgb_compatible(&page, "printerManages")); + page.label = None; + page.description = Some("Proof description".into()); + assert!(!native_rgb_compatible(&page, "printerManages")); + page.description = Some(String::new()); + assert!(native_rgb_compatible(&page, "printerManages")); + page.image.icc = Some(vec![1, 2, 3]); + assert!(!native_rgb_compatible(&page, "printerManages"), "preserve embedded source ICC in the PDF route"); + assert!(native_rgb_compatible(&page, "photocraftManages")); + assert!(native_rgb_compatible(&page, "photoshopManages")); + assert!(!native_rgb_compatible(&page_of(4, vec![0; 8], false), "photocraftManages")); + assert!(native_rgb_compatible(&page_of(1, vec![0; 2], false), "noColorManagement")); +} + +#[test] +fn printer_commands_answer_without_a_document_and_reject_bad_settings() { + let mut s = Session::new(); + let r = s.execute("file.print.printers", json!({})).unwrap(); + assert_eq!(r["supported"], cfg!(windows)); + assert!(r["printers"].is_array()); + assert!(s.execute("file.print.page", json!({"printer": "Anything", "printerSettings": "zz"})).is_err()); + let mut s = session("rgb", 8); + assert!(s.execute("file.print", json!({"printer": "Anything", "printerSettings": "0102", "send": true})).is_err()); +} + #[test] fn printer_profile_can_be_an_installed_icc_file() { let dir = tmp("icc"); @@ -170,6 +226,48 @@ fn printer_profile_can_be_an_installed_icc_file() { let _ = std::fs::remove_dir_all(&dir); } +/// #7410: the settings of the last print survive a relaunch, as Photoshop's do. They are kept in +/// the preferences (without the output, dry-run and send choices), and a new session with those +/// preferences, before printing anything, repeats them with Print One Copy. +#[test] +fn the_last_print_settings_survive_a_relaunch() { + let mut s = session("rgb", 8); + assert!(last_print(&s).is_none()); + s.execute("file.print", json!({"dryRun": true, "copies": 3, "paper": "a5", "scale": 50, "intent": "perceptual"})).unwrap(); + let kept = s.prefs().dialogs.get(REMEMBERED).cloned().expect("kept in the preferences"); + assert_eq!((&kept["paper"], &kept["scale"], &kept["intent"]), (&json!("a5"), &json!(50), &json!("perceptual"))); + assert!(kept.get("dryRun").is_none() && kept.get("output").is_none() && kept.get("send").is_none(), "{kept}"); + // A relaunch: a new session with these preferences, nothing printed in it yet. + let mut t = session("rgb", 8); + t.prefs.edit(|p| { + p.dialogs.insert(REMEMBERED.into(), kept.clone()); + }); + assert_eq!(last_print(&t), Some(kept)); + let r = t.execute("file.printOneCopy", json!({"dryRun": true})).unwrap(); + assert_eq!(r["copies"], 1); + assert_eq!(r["paper"], json!([419.53, 595.28])); + assert_eq!(r["scale"], 50.0); +} + +/// #7410: a printer remembered from an earlier session that the print server no longer lists is +/// dropped before Print One Copy repeats the settings (with its driver settings), so the default +/// printer takes the job; a listed one (in any case) stays, and an empty list can't tell. +#[test] +fn a_vanished_remembered_printer_falls_back_to_the_default() { + let settings = || json!({"printer": "Office", "printerSettings": "00ff", "paper": "a5"}); + let mut q = settings(); + drop_vanished_printer(&mut q, &["Studio", "Lab"]); + assert_eq!(q, json!({"paper": "a5"}), "gone: the default printer takes it"); + for listed in [&["Studio", "office"][..], &[][..]] { + let mut q = settings(); + drop_vanished_printer(&mut q, listed); + assert_eq!(q, settings(), "{listed:?}"); + } + let mut q = json!({"printer": "", "paper": "a5"}); + drop_vanished_printer(&mut q, &["Studio"]); + assert_eq!(q, json!({"printer": "", "paper": "a5"}), "the default printer stays the default"); +} + #[test] fn print_one_copy_repeats_the_last_settings() { let mut s = session("rgb", 8); @@ -183,7 +281,7 @@ fn print_one_copy_repeats_the_last_settings() { #[cfg(unix)] assert_eq!(&cmd[..3], &["lp", "-n", "1"]); #[cfg(windows)] - assert!(cmd.last().unwrap().contains("craft-print PDF spooler")); + assert_eq!(cmd.first().map(String::as_str), Some("xps-print")); assert!(r["pdf"].is_null()); } @@ -200,7 +298,12 @@ fn package_copies_links_and_relinks() { let copied = format!("{dir}/pkg/Print Me/Links/art.png"); assert_eq!(r["links"], json!([copied])); assert!(std::path::Path::new(&copied).is_file()); + // Reported paths are `/`-spelt on every platform, not the native separator up to `dir` and + // `/` after it: a client hands them back to the control channel, which takes only `/`. let docp = r["document"].as_str().unwrap(); + for p in [r["folder"].as_str().unwrap(), docp, r["links"][0].as_str().unwrap()] { + assert!(!p.contains('\\'), "{p}"); + } assert!(docp.ends_with("Print Me/Print Me.pcraft")); let back = photocraft_io::import("Print Me.pcraft", &std::fs::read(docp).unwrap()).unwrap().document; let linked: Vec = back @@ -306,7 +409,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")); @@ -380,3 +483,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/engine/src/print_cmds/windows_marks.rs b/crates/engine/src/print_cmds/windows_marks.rs new file mode 100644 index 0000000000..857e6335a9 --- /dev/null +++ b/crates/engine/src/print_cmds/windows_marks.rs @@ -0,0 +1,47 @@ +//! App-owned print-mark composition in points from the sheet's top-left. +use super::Marks; +use craft_print::rgb::{Primitive, Shape}; + +pub(super) fn primitives(marks: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> Vec { + let mut out = Vec::with_capacity(24); + let line = |out: &mut Vec, x0, y0, x1, y1| { + out.push(Primitive { shape: Shape::Line { from: (x0, y0), to: (x1, y1) }, width: 0.3, color: [0; 3] }); + }; + let (gap, len) = (6.0, 18.0); + if marks.corner_crop { + for (cx, cy, dx, dy) in [(x, y, -1.0, -1.0), (x + w, y, 1.0, -1.0), (x, y + h, -1.0, 1.0), (x + w, y + h, 1.0, 1.0)] { + line(&mut out, cx + dx * gap, cy, cx + dx * (gap + len), cy); + line(&mut out, cx, cy + dy * gap, cx, cy + dy * (gap + len)); + } + } + if marks.center_crop { + let (mx, my) = (x + w / 2.0, y + h / 2.0); + line(&mut out, mx, y - gap, mx, y - gap - len); + line(&mut out, mx, y + h + gap, mx, y + h + gap + len); + line(&mut out, x - gap, my, x - gap - len, my); + line(&mut out, x + w + gap, my, x + w + gap + len, my); + } + if marks.registration { + let (r, off) = (5.0, gap + len / 2.0); + for (cx, cy) in [(x + w / 2.0, y - off), (x + w / 2.0, y + h + off), (x - off, y + h / 2.0), (x + w + off, y + h / 2.0)] { + out.push(Primitive { shape: Shape::Ellipse { bounds: (cx - r, cy - r, 2.0 * r, 2.0 * r) }, width: 0.3, color: [0; 3] }); + line(&mut out, cx - r * 1.6, cy, cx + r * 1.6, cy); + line(&mut out, cx, cy - r * 1.6, cx, cy + r * 1.6); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn marks_surround_the_final_image_box_in_physical_points() { + let out = primitives(Marks { corner_crop: true, center_crop: true, registration: true }, (100.0, 200.0, 144.0, 72.0)); + assert_eq!(out.len(), 24); + assert_eq!(out[0].shape, Shape::Line { from: (94.0, 200.0), to: (76.0, 200.0) }); + assert_eq!(out[12].shape, Shape::Ellipse { bounds: (167.0, 180.0, 10.0, 10.0) }); + assert!(out.iter().all(|p| p.width == 0.3 && p.color == [0; 3])); + assert!(primitives(Marks::default(), (0.0, 0.0, 10.0, 10.0)).is_empty()); + } +} diff --git a/crates/engine/src/proof_sim.rs b/crates/engine/src/proof_sim.rs index f225a5a7a9..6c7afc15d9 100644 --- a/crates/engine/src/proof_sim.rs +++ b/crates/engine/src/proof_sim.rs @@ -18,6 +18,7 @@ use photocraft_cms::{Builtin, Intent, Lut3d, Transform}; use photocraft_color::SampleType; +use photocraft_color::convert::{linear_to_srgb, srgb_to_linear}; use photocraft_doc::Document; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -91,14 +92,16 @@ impl Default for HdrPreview { } impl HdrPreview { + /// Whether the preview passes values through unchanged. pub fn is_identity(&self) -> bool { self.highlight_compression || (self.exposure == 0.0 && self.gamma == 1.0) } + /// One encoded channel value through the 32-bit preview's exposure and gamma. fn apply(&self, v: f32) -> f32 { if self.is_identity() { return v; } - let lin = srgb_decode(v) * 2f32.powf(self.exposure); + let lin = srgb_to_linear(v) * 2f32.powf(self.exposure); srgb_encode(lin.max(0.0).powf(1.0 / self.gamma).min(1.0)) } } @@ -115,13 +118,9 @@ pub fn signature(c: &ColorState, doc: &Document) -> String { format!("{:?} {:?}", pv.setup.kind, h) } -fn srgb_decode(v: f32) -> f32 { - if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } -} - +/// sRGB-encodes linear light after clamping it to 0..=1. fn srgb_encode(v: f32) -> f32 { - let v = v.clamp(0.0, 1.0); - if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } + linear_to_srgb(v.clamp(0.0, 1.0)) } /// Viénot et al. (1999): linear RGB → LMS. @@ -201,7 +200,7 @@ pub fn display_lut_with(c: &ColorState, doc: &Document, size: usize, include_hdr let protan = kind == ProofKind::Protanopia; Box::new(move |v| { let s = eval3(&to_srgb, &v); - let sim = dichromat(s.map(srgb_decode), protan).map(srgb_encode); + let sim = dichromat(s.map(srgb_to_linear), protan).map(srgb_encode); eval3(&to_mon, &sim) }) } @@ -262,6 +261,7 @@ fn run_kind(s: &mut Session, id: &str) -> Result { set_kind(s, kind) } +/// View › 32-bit Preview Options for the active document. fn preview_32(s: &mut Session, p: &Value) -> Result { const CMD: &str = "view.thirtyTwoBitPreviewOptions"; let id = s.active().ok_or(EngineError::NoDocument)?.doc.id; @@ -298,6 +298,7 @@ macro_rules! kind_spec { }; } +/// The View › Proof Setup commands. pub fn specs() -> Vec { vec![ kind_spec!("view.proofSetup.workingCyanPlate", "Working Cyan Plate"), @@ -325,6 +326,21 @@ pub fn specs() -> Vec { mod tests { use super::*; + /// The shared sRGB functions match the removed copies bit for bit. + #[test] + fn srgb_transfer_output_is_unchanged() { + let decode = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; + let encode = |v: f32| { + let v = v.clamp(0.0, 1.0); + if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } + }; + for i in -2000..=6000 { + let v = i as f32 / 4000.0; + assert_eq!(srgb_to_linear(v).to_bits(), decode(v).to_bits(), "decode {v}"); + assert_eq!(srgb_encode(v).to_bits(), encode(v).to_bits(), "encode {v}"); + } + } + fn session(mode: &str, depth: u32) -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width": 8, "height": 8, "mode": mode, "depth": depth})).unwrap(); diff --git a/crates/engine/src/rasterize_style_cmds/tests.rs b/crates/engine/src/rasterize_style_cmds/tests.rs index 9b5ef7d359..b4391cb6f2 100644 --- a/crates/engine/src/rasterize_style_cmds/tests.rs +++ b/crates/engine/src/rasterize_style_cmds/tests.rs @@ -78,7 +78,6 @@ fn fill_opacity_masks_and_layer_opacity_survive_the_bake() { s.execute("layer.setProps", json!({"layer": id, "fill": 0.4, "opacity": 0.7})).unwrap(); s.execute("select.rect", json!({"x": 0, "y": 0, "width": 34, "height": 48})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); let square = json!({"subpaths": [{"closed": true, "knots": [[4, 4], [60, 4], [60, 26], [4, 26]]}]}); s.execute("layer.vectorMask.add", json!({"layer": id, "path": square})).unwrap(); bake_and_check(&mut s, id, depth); @@ -153,7 +152,7 @@ fn disabled_and_bad_params_fail_gracefully() { let (mut s, id) = scene(8); let bg = doc(&s).layers[0].id.0; assert!(s.execute(ID, json!({"layer": bg})).is_err(), "the Background has no effects"); - assert!(s.execute(ID, json!({"layer": 9999})).is_err(), "no such layer"); + assert!(s.execute(ID, json!({"layer": u64::MAX})).is_err(), "no such layer"); assert!(s.execute(ID, json!({"layer": u64::MAX})).is_err(), "no such layer"); assert_eq!(layer(&s, id).effects.items.len(), 5, "a bad call changes nothing"); s.execute("layer.layerStyle.hideAllEffects", json!({})).unwrap(); diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 9ea7fd5975..8ef8722c16 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -15,7 +15,7 @@ use photocraft_algo::retouch::{ToneRange, dodge_burn, local_blur, local_sharpen, use photocraft_color::{BlendMode, PixelFormat, SampleType}; use photocraft_doc::{Document, LayerContent, LayerId}; use photocraft_geom::Rect; -use photocraft_paint::retouch::{Footprint, Region, Smudge, alpha_index, apply_coverage, apply_dab_stroke, stroke_coverage}; +use photocraft_paint::retouch::{Footprint, Region, Smudge, alpha_index, apply_coverage, apply_dab_stroke}; use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde_json::{Value, json}; @@ -35,6 +35,64 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } +/// A single stroke's accumulated coverage must stay at or under this many pixels (8192², so a +/// stroke corner to corner across a 24–36 MP canvas fits). Larger footprints return an error +/// instead of allocating. +const MAX_STROKE_PIXELS: u64 = 67_108_864; + +/// Refuse a stroke whose footprint would not fit the coverage budget, from the points and brush +/// size alone, before the renderer allocates anything for it. +fn reject_huge_stroke(cmd: &str, stroke: &Stroke) -> Result<()> { + // Dynamics place dabs beyond the input points, so half the tip is not the whole reach: + // Scattering offsets a centre by up to its amount times the tip size, and a Dual Brush dab + // reaches its own radius further. Pad by what those can add so this estimate does not fall + // short of what the renderer will cover. It is only the cheap early-out; the rendered extent + // is admitted for real in `coverage` below. + let b = &stroke.brush; + let size = f64::from(b.size.max(1.0)); + let scatter = if b.scattering.enabled { size * f64::from(b.scattering.scatter.jitter.max(0.0)) } else { 0.0 }; + let dual = if b.dual_brush.enabled { f64::from(b.dual_brush.size.max(0.0)) * 0.5 } else { 0.0 }; + let pad = size.mul_add(0.5, 4.0) + scatter + dual; + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + for q in &stroke.points { + min_x = min_x.min(q.x); + min_y = min_y.min(q.y); + max_x = max_x.max(q.x); + max_y = max_y.max(q.y); + } + if !min_x.is_finite() || !min_y.is_finite() || !max_x.is_finite() || !max_y.is_finite() { + return Err(bad(cmd, "point coordinates must be finite")); + } + let w = (max_x - min_x + pad * 2.0).ceil().max(1.0); + let h = (max_y - min_y + pad * 2.0).ceil().max(1.0); + if !w.is_finite() || !h.is_finite() || w > 1.0e9 || h > 1.0e9 { + return Err(bad(cmd, format!("stroke too large (at most {MAX_STROKE_PIXELS} px)"))); + } + let area = (w as u64).saturating_mul(h as u64); + if area > MAX_STROKE_PIXELS { + return Err(bad(cmd, format!("stroke too large ({w:.0}×{h:.0} px, at most {MAX_STROKE_PIXELS})"))); + } + Ok(()) +} + +/// The stroke's coverage, with the **rendered** extent admitted before the dense buffer exists. +/// +/// `reject_huge_stroke` estimates from the input points; this is the real bound. Rendering is +/// tiled, so it holds only the tiles the dabs touch; the extent is read from those tiles once +/// every dab is placed (tips, dynamics and scattering included) and checked against `max`, and +/// only a stroke that fits is made dense. `max` is a parameter so tests can inject a small budget. +fn coverage_within(cmd: &str, stroke: &Stroke, max: u64) -> Result<(Rect, Vec)> { + photocraft_paint::retouch::stroke_coverage_within(stroke, max).ok_or_else(|| bad(cmd, format!("stroke too large (at most {max} px)"))) +} + +/// [`coverage_within`] at the shared allowance. +fn coverage(cmd: &str, stroke: &Stroke) -> Result<(Rect, Vec)> { + coverage_within(cmd, stroke, MAX_STROKE_PIXELS) +} + /// A pixel layer, a layer with a mask, or a targeted alpha channel / Quick Mask (as the Brush). fn has_pixel_layer(s: &Session) -> std::result::Result<(), String> { if crate::channel_cmds::edits_channel(s) { @@ -312,6 +370,7 @@ fn clamp_samples(fmt: &PixelFormat, data: &mut [f32]) { fn clone_stamp(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.cloneStamp"; let (stroke, id) = parse_brush(s, p, CMD)?; + reject_huge_stroke(CMD, &stroke)?; let which = sample_layers(p, CMD)?; let mode = blend_param(p, CMD)?; let aligned = flag(p, "aligned", true); @@ -336,7 +395,7 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result { return periodic::Composite { target: surf, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection: sel, lock, mode } .draw(bounds, &stroke.brush, &stroke.points, |r| clone_sample(pre, id, &before, which, r, &map, true, CMD)); } - let (bounds, cov) = stroke_coverage(&stroke); + let (bounds, cov) = coverage(CMD, &stroke)?; // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?; Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) @@ -592,13 +651,14 @@ fn heal_region(fmt: &PixelFormat, src: &Region, dst: &Region, mask: &[bool]) -> fn healing_brush(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.healingBrush"; let (stroke, id) = parse_brush(s, p, CMD)?; + reject_huge_stroke(CMD, &stroke)?; let which = sample_layers(p, CMD)?; let mode = blend_param(p, CMD)?; let aligned = flag(p, "aligned", true); let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); let dmg = run_stroke(s, "Healing Brush", id, p, |pre, surf, sel, lock| { - let (bounds, cov) = stroke_coverage(&stroke); + let (bounds, cov) = coverage(CMD, &stroke)?; let (g, cov) = pad_coverage(bounds, &cov, 2); let fmt = surf.format(); let src = clone_sample(pre, id, surf, which, g, &map, false, CMD)?; @@ -631,6 +691,10 @@ fn history_brush(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.historyBrush"; let (stroke, id) = parse_brush(s, p, CMD)?; let mode = blend_param(p, CMD)?; + // The coverage and the source region below are both dense over the stroke's bounds, so a + // thin stroke across a large canvas would still ask for the whole box. Refuse it from the + // points and brush size, before the source surface is cloned or anything is rendered (#4961). + reject_huge_stroke(CMD, &stroke)?; let live = s.live_commit.take().filter(|l| l.matches(s, CMD, p, &stroke.points)); let from_live = live.is_some(); let src_surface = history_source(s, id, p, CMD)?; @@ -641,7 +705,7 @@ fn history_brush(s: &mut Session, p: &Value) -> Result { } let fmt = surf.format(); let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; - let (bounds, cov) = stroke_coverage(&stroke); + let (bounds, cov) = coverage(CMD, &stroke)?; let paint = Region::read(&src, bounds); Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) })?; @@ -718,12 +782,12 @@ enum SpotType { /// Photoshop 25.4 (measured): a dab changes exactly the brush's footprint, and Content-Aware takes /// its texture from around it, as Edit › Fill does from its window /// ([`crate::fill_cmds::sampling_window`] of the stroke's bounds), which is what this samples. -fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: SpotType, all: bool, sel: Option<&Surface>, lock: bool) -> Rect { - let (bounds, cov) = stroke_coverage(stroke); +fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: SpotType, all: bool, sel: Option<&Surface>, lock: bool) -> Result { + let (bounds, cov) = coverage("paint.spotHealing", stroke)?; let size = stroke.brush.size; let region_rect = crate::fill_cmds::sampling_window(bounds, pre.bounds()); if region_rect.is_empty() { - return Rect::EMPTY; + return Ok(Rect::EMPTY); } // Proximity Match looks for its source anywhere in that window. let margin = (region_rect.width().max(region_rect.height()) / 2) as i32; @@ -740,7 +804,7 @@ fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: } let hole: Vec = stroke_cov.iter().map(|c| *c > 0.0).collect(); if !hole.iter().any(|h| *h) { - return Rect::EMPTY; + return Ok(Rect::EMPTY); } // Synthesise over a slightly larger domain than the Poisson region, so the solve's boundary pixels // carry synthesised values that differ from the untouched surroundings: that mismatch is what the @@ -764,12 +828,13 @@ fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: let healed = heal_region(&fmt, &src, &img, &heal_mask); let dmg = bounds.intersect(®ion_rect); let dmg_cov: Vec = (dmg.y0..dmg.y1).flat_map(|y| (dmg.x0..dmg.x1).map(move |x| (x, y))).map(|(x, y)| stroke_cov[img.index(x, y)]).collect(); - apply_coverage(surf, dmg, &dmg_cov, stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal) + Ok(apply_coverage(surf, dmg, &dmg_cov, stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal)) } fn spot_healing(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.spotHealing"; let (stroke, id) = parse_brush(s, p, CMD)?; + reject_huge_stroke(CMD, &stroke)?; let kind = match string(p, "type", "contentAware") { "contentAware" => SpotType::ContentAware, "createTexture" => SpotType::CreateTexture, @@ -777,7 +842,7 @@ fn spot_healing(s: &mut Session, p: &Value) -> Result { o => return Err(bad(CMD, format!("unknown type `{o}` (contentAware|createTexture|proximityMatch)"))), }; let all = sample_all_layers(p); - let dmg = run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| Ok(spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock)))?; + let dmg = run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock))?; Ok(json!({ "damage": damage_json(dmg) })) } diff --git a/crates/engine/src/retouch_cmds/pattern_stamp.rs b/crates/engine/src/retouch_cmds/pattern_stamp.rs index 0dc9f102ac..b4b71745ae 100644 --- a/crates/engine/src/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/retouch_cmds/pattern_stamp.rs @@ -8,19 +8,14 @@ use photocraft_color::{BlendMode, PixelFormat}; use photocraft_compose::pattern::{Placement, Tile, render}; use photocraft_doc::Pattern; use photocraft_geom::Rect; -use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke, over_native, stroke_coverage}; +use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke, over_native}; use photocraft_paint::{Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde_json::{Value, json}; -use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush}; +use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush, reject_huge_stroke}; use crate::{Result, Session}; -/// A single stroke's accumulated coverage must stay at or under this many pixels (8192², so a -/// stroke corner to corner across a 24–36 MP canvas fits). Larger footprints return an error -/// instead of allocating. -const MAX_STROKE_PIXELS: u64 = 67_108_864; - const STREAM_PHASE_X: u64 = 101; const STREAM_PHASE_Y: u64 = 102; @@ -123,45 +118,6 @@ fn stamp_phase(p: &Value, cmd: &str, aligned: bool, first: &StrokePoint) -> Resu } } -fn reject_huge_stroke(cmd: &str, stroke: &Stroke) -> Result<()> { - let pad = f64::from(stroke.brush.size.max(1.0)).mul_add(0.5, 4.0); - let mut min_x = f64::INFINITY; - let mut min_y = f64::INFINITY; - let mut max_x = f64::NEG_INFINITY; - let mut max_y = f64::NEG_INFINITY; - for q in &stroke.points { - min_x = min_x.min(q.x); - min_y = min_y.min(q.y); - max_x = max_x.max(q.x); - max_y = max_y.max(q.y); - } - if !min_x.is_finite() || !min_y.is_finite() || !max_x.is_finite() || !max_y.is_finite() { - return Err(bad(cmd, "point coordinates must be finite")); - } - let w = (max_x - min_x + pad * 2.0).ceil().max(1.0); - let h = (max_y - min_y + pad * 2.0).ceil().max(1.0); - if !w.is_finite() || !h.is_finite() || w > 1.0e9 || h > 1.0e9 { - return Err(bad(cmd, format!("stroke too large (at most {MAX_STROKE_PIXELS} px)"))); - } - let area = (w as u64).saturating_mul(h as u64); - if area > MAX_STROKE_PIXELS { - return Err(bad(cmd, format!("stroke too large ({w:.0}×{h:.0} px, at most {MAX_STROKE_PIXELS})"))); - } - Ok(()) -} - -fn reject_bounds(cmd: &str, bounds: Rect) -> Result<()> { - if bounds.is_empty() { - return Ok(()); - } - let (w, h) = (u64::from(bounds.width()), u64::from(bounds.height())); - let area = w.saturating_mul(h); - if area > MAX_STROKE_PIXELS { - return Err(bad(cmd, format!("stroke too large ({w}×{h} px, at most {MAX_STROKE_PIXELS})"))); - } - Ok(()) -} - fn placement(phase: (f32, f32), scale: f32, angle: f32) -> Placement { Placement::new(Rect::EMPTY, false, phase, scale, angle) } @@ -194,8 +150,7 @@ fn paint_aligned( mode: BlendMode, cmd: &str, ) -> Result { - let (bounds, cov) = stroke_coverage(stroke); - reject_bounds(cmd, bounds)?; + let (bounds, cov) = super::coverage(cmd, stroke)?; if bounds.is_empty() { return Ok(Rect::EMPTY); } @@ -303,9 +258,9 @@ mod tests { #[test] fn a_diagonal_across_a_24_mp_canvas_is_not_too_large() { - assert!(reject_huge_stroke("t", &stroke(&[(0.0, 0.0), (6015.0, 3999.0)], 100.0)).is_ok()); - assert!(reject_bounds("t", Rect::from_xywh(-60, -60, 6136, 4120)).is_ok()); + let across = stroke(&[(0.0, 0.0), (6015.0, 3999.0)], 100.0); + assert!(reject_huge_stroke("t", &across).is_ok()); + assert!(super::super::coverage("t", &across).is_ok(), "the rendered extent fits too"); assert!(reject_huge_stroke("t", &stroke(&[(0.0, 0.0), (9000.0, 9000.0)], 100.0)).is_err()); - assert!(reject_bounds("t", Rect::from_xywh(0, 0, 9000, 9000)).is_err()); } } diff --git a/crates/engine/src/retouch_cmds/remove.rs b/crates/engine/src/retouch_cmds/remove.rs index 4e256fb7fc..d298e8f02c 100644 --- a/crates/engine/src/retouch_cmds/remove.rs +++ b/crates/engine/src/retouch_cmds/remove.rs @@ -71,6 +71,7 @@ fn stroke_bounds(stroke: &Stroke) -> Rect { fn parse_strokes(s: &Session, p: &Value) -> Result<(Vec, Option)> { let Some(list) = p.get("strokes") else { let (stroke, id) = parse_brush(s, p, CMD)?; + reject_huge_stroke(CMD, &stroke)?; return Ok((vec![stroke], id)); }; let list = list.as_array().ok_or_else(|| bad(CMD, "`strokes` is a list of point lists"))?; @@ -86,6 +87,7 @@ fn parse_strokes(s: &Session, p: &Value) -> Result<(Vec, Option o.insert("points".into(), points.clone()); } let (stroke, layer) = parse_brush(s, &one, CMD)?; + reject_huge_stroke(CMD, &stroke)?; strokes.push(stroke); id = layer; } @@ -115,12 +117,12 @@ fn plan(s: &Session, p: &Value) -> Result { /// The hole over the plan's window: the strokes' footprints, their enclosed areas and a margin, /// limited to the selection. Empty when nothing would change. -fn hole(plan: &Plan, sel: Option<&Surface>) -> Vec { +fn hole(plan: &Plan, sel: Option<&Surface>) -> Result> { let win = plan.window; let (w, h) = (win.width() as usize, win.height() as usize); let mut m = vec![false; w * h]; for stroke in &plan.strokes { - let (bounds, cov) = stroke_coverage(stroke); + let (bounds, cov) = super::coverage(CMD, stroke)?; let bw = bounds.width() as usize; for (i, c) in cov.iter().enumerate() { let (x, y) = (bounds.x0 + (i % bw.max(1)) as i32, bounds.y0 + (i / bw.max(1)) as i32); @@ -141,13 +143,13 @@ fn hole(plan: &Plan, sel: Option<&Surface>) -> Vec { *v &= c > 0.0; } } - m + Ok(m) } /// The filled window and the hole it fills, computed on `doc` (a snapshot). `None` when the /// layer's own window is fully transparent: nothing to remove and nothing to fill from. fn run_remove(doc: &mut Document, plan: &Plan, p: &Value, ctx: &crate::jobs::JobCtx, label: &str) -> Result)>> { - let hole = hole(plan, doc.selection.as_ref()); + let hole = hole(plan, doc.selection.as_ref())?; if !hole.iter().any(|m| *m) { return Err(bad(CMD, "the stroke doesn't cover anything that can change (check the selection)")); } diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index 9a0ae22f08..edbf33c317 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -544,6 +544,96 @@ fn history_brush_restores_earlier_pixels() { } } +/// #4961 and #6110: every retouch stroke renders its coverage densely over the stroke's bounds +/// and reads a source region over the same box, so a stroke whose box dwarfs the canvas is +/// refused from the points and brush size alone, before anything is cloned or rendered. The +/// document is left as it was, and ordinary strokes still paint at every depth. +#[test] +fn history_brush_refuses_a_stroke_too_large_to_render() { + for depth in DEPTHS { + let mut s = session(40, 20, depth, "rgb"); + s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 12, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + let before = rgba(&s, 20, 10); + + let err = s.execute("paint.historyBrush", json!({"points": [[0, 0], [60000, 60000]], "size": 6, "hardness": 100})).unwrap_err(); + assert!(err.to_string().contains("stroke too large"), "depth {depth}: {err}"); + assert_eq!(rgba(&s, 20, 10), before, "depth {depth}: a refused stroke leaves the pixels alone"); + + // The guard is sized for real work: a stroke corner to corner across the canvas still runs. + s.execute("paint.historyBrush", json!({"points": [[0, 0], [40, 20]], "size": 6, "hardness": 100})).unwrap(); + assert!(rgba(&s, 20, 10).iter().all(|v| *v > 0.99), "depth {depth}: the Open snapshot is white"); + } +} + +/// #6110: Pattern Stamp already refused a stroke it could not render; every other retouch stroke +/// reached the same dense conversion unguarded. Each command refuses the oversized stroke without +/// touching the pixels or the history, and an ordinary stroke still succeeds. +#[test] +fn every_retouch_stroke_refuses_a_box_it_cannot_render() { + let huge = json!([[0, 0], [900_000, 900_000]]); + let ok = json!([[2, 2], [30, 16]]); + // Fixtures that are valid for a plain RGB document with one pixel layer. + let cases: [(&str, Value); 4] = [ + ("paint.cloneStamp", json!({"offset": [4, 0], "size": 8, "hardness": 100})), + ("paint.healingBrush", json!({"offset": [4, 0], "size": 8, "hardness": 100})), + ("paint.spotHealing", json!({"size": 8, "hardness": 100})), + ("paint.historyBrush", json!({"size": 8, "hardness": 100})), + ]; + for (cmd, base) in cases { + let mut s = session(40, 20, 8, "rgb"); + s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 12, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + let before = rgba(&s, 20, 10); + let steps = s.active().unwrap().history.past_len(); + let with = |points: &Value| { + let mut v = base.clone(); + if let Some(o) = v.as_object_mut() { + o.insert("points".into(), points.clone()); + } + v + }; + let err = s.execute(cmd, with(&huge)).unwrap_err().to_string(); + assert!(err.contains("stroke too large"), "{cmd}: {err}"); + assert_eq!(rgba(&s, 20, 10), before, "{cmd}: a refused stroke leaves the pixels alone"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{cmd}: and records no history step"); + + // The allowance still covers ordinary work: this must succeed, not merely avoid the error. + s.execute(cmd, with(&ok)).unwrap_or_else(|e| panic!("{cmd}: an ordinary stroke must work: {e}")); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{cmd}: and records one step"); + } + + // Remove takes a list of strokes; each one is charged as it is parsed. + let mut s = session(40, 20, 8, "rgb"); + let err = s.execute("paint.remove", json!({"strokes": [[[0, 0], [900_000, 900_000]]], "size": 8})).unwrap_err().to_string(); + assert!(err.contains("stroke too large"), "paint.remove: {err}"); +} + +/// The review on #6110: the estimate from the input points is not the bound. Brush dynamics place +/// dabs beyond those points, so the budget is applied to the extent the renderer actually produced, +/// before the dense buffer is allocated. An injected budget makes that observable without asking +/// for a stroke big enough to exhaust anything. +#[test] +fn the_budget_is_applied_to_the_rendered_extent_not_the_input_points() { + use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; + let mut brush = BrushSettings { size: 24.0, hardness: 1.0, ..Default::default() }; + brush.scattering.enabled = true; + brush.scattering.scatter.jitter = 6.0; + let stroke = Stroke { brush, points: vec![StrokePoint::new(10.0, 10.0, 1.0), StrokePoint::new(40.0, 10.0, 1.0)] }; + + // What the renderer really covered, once scattering has placed every dab. + let (bounds, cov) = super::coverage_within("t", &stroke, u64::MAX).unwrap(); + let rendered = u64::from(bounds.width()) * u64::from(bounds.height()); + assert_eq!(cov.len() as u64, rendered, "the dense buffer is exactly the rendered extent"); + + // Scattering reaches past the points plus half the tip, which is what the estimate alone sees. + let points_only = (40.0 - 10.0 + 24.0 + 8.0) * (24.0 + 8.0); + assert!(rendered as f64 > points_only, "scattering expanded the stroke: {rendered} vs {points_only}"); + + // A budget one pixel under what it rendered refuses it; exactly the rendered extent fits. + let err = super::coverage_within("t", &stroke, rendered - 1).unwrap_err().to_string(); + assert!(err.contains("stroke too large"), "{err}"); + assert!(super::coverage_within("t", &stroke, rendered).is_ok(), "the rendered extent itself fits"); +} + #[test] fn history_brush_reads_a_source_when_its_pixels_were_locked() { // Given a white layer locked in history state 1, then unlocked and filled red. @@ -993,7 +1083,7 @@ fn patch_fails_gracefully() { json!({"offset": [0, -30]}), json!({"offset": [10, 0], "target": "mask"}), json!({"offset": [10, 0], "target": {"channel": 7}}), - json!({"offset": [10, 0], "layer": 999}), + json!({"offset": [10, 0], "layer": u64::MAX}), ] { assert!(s.execute("paint.patch", p.clone()).is_err(), "{p}"); } @@ -1269,7 +1359,7 @@ fn content_aware_move_fails_gracefully() { json!({"offset": [0, -30]}), json!({"offset": [10, 0], "target": "mask"}), json!({"offset": [10, 0], "target": {"channel": 7}}), - json!({"offset": [10, 0], "layer": 999}), + json!({"offset": [10, 0], "layer": u64::MAX}), json!({"offset": [10, 0], "scaleX": 0}), json!({"offset": [10, 0], "scaleY": 0.5}), json!({"offset": [10, 0], "scaleX": 1e9}), @@ -1727,7 +1817,7 @@ fn remove_bad_params_are_errors() { json!({"points": []}), json!({"points": "x"}), json!({"points": [[1, 1]], "size": "big", "closeLoops": "yes", "sampleAllLayers": 3}), - json!({"points": [[1, 1]], "layer": 999_999}), + json!({"points": [[1, 1]], "layer": u64::MAX}), ] { let _ = s.execute("paint.remove", p); } diff --git a/crates/engine/src/sampler_cmds.rs b/crates/engine/src/sampler_cmds.rs index 472ecb672a..57787b2e83 100644 --- a/crates/engine/src/sampler_cmds.rs +++ b/crates/engine/src/sampler_cmds.rs @@ -365,7 +365,7 @@ mod tests { #[test] fn a_right_angle_rotation_moves_points() { - // 90° clockwise about the centre: the doc centre stays put. + // 90° clockwise about the centre: pixel (x, y) of the 100×80 canvas goes to (79 - y, x). let mut s = session(); s.execute("view.colorSamplers.add", json!({"x": 50, "y": 40})).unwrap(); s.execute("image.rotation.arbitrary", json!({"angle": 90, "direction": "cw"})).unwrap(); @@ -373,19 +373,78 @@ mod tests { assert_eq!(p.len(), 1); let pos = p[0]["position"].as_array().unwrap(); let (x, y) = (pos[0].as_f64().unwrap(), pos[1].as_f64().unwrap()); - // The new canvas is 80×100, so the centre is (40, 50). - assert!((x - 40.0).abs() < 1e-6 && (y - 50.0).abs() < 1e-6, "{pos:?}"); + assert!((x - 39.0).abs() < 1e-6 && (y - 50.0).abs() < 1e-6, "{pos:?}"); + } + + #[test] + fn flips_and_right_angle_turns_keep_points_on_their_pixel() { + use crate::canvas_geom::Turn; + for (id, turn) in [ + ("image.imageRotation.flipCanvasHorizontal", Turn::FlipHorizontal), + ("image.imageRotation.flipCanvasVertical", Turn::FlipVertical), + ("image.imageRotation.180", Turn::Rotate180), + ("image.imageRotation.90cw", Turn::Cw90), + ("image.imageRotation.90ccw", Turn::Ccw90), + ] { + // A black pixel at (3, 3) of a white 100×80 document, with a point on it. + let mut s = session(); + s.edit("paint", |doc, _| { + let surf = doc.layers[0].surface_mut().unwrap(); + let black = photocraft_raster::from_rgba(&surf.format(), [0.0, 0.0, 0.0, 1.0]); + surf.fill_rect(Rect::new(3, 3, 4, 4), &black); + Ok(()) + }) + .unwrap(); + s.execute("view.colorSamplers.add", json!({"x": 3, "y": 3})).unwrap(); + s.execute(id, json!({})).unwrap(); + let (x, y) = turn.pixel_map(100, 80)(3, 3); + let p = points(&mut s); + assert_eq!(p.len(), 1, "{id}"); + assert_eq!(p[0]["position"], json!([f64::from(x), f64::from(y)]), "{id}"); + assert_eq!(p[0]["rgb"], json!([0, 0, 0]), "{id}"); + } + } + + #[test] + fn an_arbitrary_rotation_keeps_points_on_their_pixel() { + // The pixel at (3, 3) of the 100×80 document, and where each turn moves it. + for (angle, direction, at) in [(90, "cw", [76.0, 3.0]), (90, "ccw", [3.0, 96.0]), (180, "cw", [96.0, 76.0])] { + let mut s = session(); + s.edit("paint", |doc, _| { + let surf = doc.layers[0].surface_mut().unwrap(); + let black = photocraft_raster::from_rgba(&surf.format(), [0.0, 0.0, 0.0, 1.0]); + surf.fill_rect(Rect::new(3, 3, 4, 4), &black); + Ok(()) + }) + .unwrap(); + s.execute("view.colorSamplers.add", json!({"x": 3, "y": 3})).unwrap(); + s.execute("view.colorSamplers.add", json!({"x": 0, "y": 0})).unwrap(); + s.execute("image.rotation.arbitrary", json!({"angle": angle, "direction": direction})).unwrap(); + assert_eq!(s.take_samplers_removed(), 0, "{angle} {direction}: the corner point stays on the canvas"); + let p = points(&mut s); + assert_eq!(p.len(), 2, "{angle} {direction}: the corner point stays on the canvas"); + assert_eq!(p[0]["position"], json!(at), "{angle} {direction}"); + assert_eq!(p[0]["rgb"], json!([0, 0, 0]), "{angle} {direction}"); + } } #[test] fn a_rotated_crop_moves_points() { - // The crop frame is centred on the point, so the point lands at the new canvas centre. + // The crop frame is centred on the point's pixel; turned 90° that pixel lands at (10, 9). let mut s = session(); + s.edit("paint", |doc, _| { + let surf = doc.layers[0].surface_mut().unwrap(); + let black = photocraft_raster::from_rgba(&surf.format(), [0.0, 0.0, 0.0, 1.0]); + surf.fill_rect(Rect::new(50, 40, 51, 41), &black); + Ok(()) + }) + .unwrap(); s.execute("view.colorSamplers.add", json!({"x": 50, "y": 40})).unwrap(); s.execute("image.crop", json!({"x": 40, "y": 30, "width": 20, "height": 20, "angle": 90})).unwrap(); let p = points(&mut s); assert_eq!(p.len(), 1); - assert_eq!(p[0]["position"], json!([10.0, 10.0])); + assert_eq!(p[0]["position"], json!([10.0, 9.0])); + assert_eq!(p[0]["rgb"], json!([0, 0, 0])); } #[test] diff --git a/crates/engine/src/selection_cmds.rs b/crates/engine/src/selection_cmds.rs index 8efb8d7b94..d6fc50aeea 100644 --- a/crates/engine/src/selection_cmds.rs +++ b/crates/engine/src/selection_cmds.rs @@ -30,10 +30,6 @@ fn mode(p: &Value) -> SelectionMode { /// A Color Range replacement is scoped to the selection that was active when it opened. /// Explicit add/subtract/intersect operations retain their normal selection-combine semantics. -fn point_in_selection(doc: &Document, x: i32, y: i32) -> bool { - doc.selection.as_ref().is_none_or(|selection| selection.sample_channel(x, y, 0) > 0.0) -} - fn scope_color_range(mask: &mut [f32], doc: &Document, area: Rect, m: SelectionMode) { if m != SelectionMode::Replace { return; @@ -50,10 +46,17 @@ fn scope_color_range(mask: &mut [f32], doc: &Document, area: Rect, m: SelectionM fn sample_pixels(s: &Session, all_layers: bool) -> Result<(Rect, Vec<[f32; 4]>)> { let d = s.active().ok_or(EngineError::NoDocument)?; let area = d.doc.bounds(); + // Match the decoders' raster budget before either the layer or composite path allocates. + let len = area.size().area(); + let max_bytes: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 }; + if len > max_bytes / std::mem::size_of::<[f32; 4]>() as u64 { + return Err(EngineError::Other("the canvas is too large to sample for a selection".into())); + } let layer = d.active_layer.and_then(|id| d.doc.layer(id)).and_then(|l| l.surface()); match (all_layers, layer) { (false, Some(surf)) => { - let mut px = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize]; + let len = usize::try_from(len).map_err(|_| EngineError::Other("the canvas is too large to sample for a selection".into()))?; + let mut px = crate::allocation::filled(len, [0.0f32; 4], "sampling pixels for a selection")?; surf.read_rgba_into(area, &mut px); Ok((area, px)) } @@ -146,14 +149,7 @@ fn parse_color(p: &Value) -> [f32; 3] { /// Eyedropper samples from `key` (`[[x, y], …]` in document pixels): the colour under each point /// and where it was picked (area-local, for Localized Color Clusters). -fn eyedropper_samples( - p: &Value, - key: &str, - area: Rect, - px: &[[f32; 4]], - allowed: &impl Fn(i32, i32) -> bool, - bad: &impl Fn(String) -> EngineError, -) -> Result> { +fn eyedropper_samples(p: &Value, key: &str, area: Rect, px: &[[f32; 4]], bad: &impl Fn(String) -> EngineError) -> Result> { let Some(pts) = p.get(key) else { return Ok(Vec::new()) }; let pts = pts.as_array().ok_or_else(|| bad(format!("`{key}` must be [[x, y], …]")))?; let w = area.width() as usize; @@ -171,9 +167,6 @@ fn eyedropper_samples( let Some(&[r, g, b, _]) = q else { return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the canvas"))); }; - if !allowed(xi, yi) { - return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the active selection"))); - } out.push(sel::RangeSample { color: [r, g, b], at: Some(((xi - area.x0) as f32, (yi - area.y0) as f32)) }); } Ok(out) @@ -244,9 +237,6 @@ impl ColorRangeSamples { if !area.contains(x, y) { return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the canvas"))); } - if mode(p) == SelectionMode::Replace && !point_in_selection(doc, x, y) { - return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the active selection"))); - } let r = Rect::from_xywh(x, y, 1, 1); let pixel = if !b(p, "sampleAllLayers", true) { s.active().and_then(|d| d.active_layer.and_then(|id| d.doc.layer(id))).and_then(|l| l.surface()).map(|surf| { @@ -370,10 +360,10 @@ fn color_range(s: &mut Session, p: &Value) -> Result { let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; let w = area.width() as usize; // The dialog's eyedroppers: colours picked on the image, at their positions. - let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; - let allowed = |x, y| selection_mode != SelectionMode::Replace || point_in_selection(doc, x, y); - let plus = eyedropper_samples(p, "points", area, &px, &allowed, &bad)?; - let minus = eyedropper_samples(p, "subtractPoints", area, &px, &allowed, &bad)?; + // Like Photoshop's, they may sample anywhere on the canvas: an active selection only + // limits the result (scope_color_range), not where colours are picked (#5244). + let plus = eyedropper_samples(p, "points", area, &px, &bad)?; + let minus = eyedropper_samples(p, "subtractPoints", area, &px, &bad)?; let query = ColorRangeSamples::with_samples(s, p, area, &plus, &minus)?; let mask = query.coverage(&px, w, 1.0, [0.0, 0.0]); (area, mask) @@ -461,7 +451,12 @@ fn modify(s: &mut Session, p: &Value, op: &str) -> Result { // continue past the canvas. Border has no such option: its band always follows the canvas edge, // or a Select All would border to nothing. let at_bounds = op == "border" || b(p, "applyAtCanvasBounds", false); - let (area, m) = current_mask(&s.active().ok_or(EngineError::NoDocument)?.doc); + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let area = doc.bounds(); + if area.size().area() > MAX_MODIFY_PIXELS as u64 { + return Err(EngineError::BadParams { cmd: format!("select.modify.{op}"), msg: "the selection is too large to modify".into() }); + } + let (_, m) = current_mask(doc); let (w, h) = (area.width() as usize, area.height() as usize); // Feather reaches 3σ = 3 r (σ = r, as in Photoshop); the others reach r. A margin past that // keeps the canvas edge out of reach. A selection clear of the canvas edge needs none: both @@ -765,6 +760,60 @@ mod tests { assert!(err.to_string().contains("detectFaces"), "{err}"); } + #[test] + fn selection_sampling_refuses_oversized_canvases_before_reading_pixels() { + let mut s = Session::new(); + s.add_document( + photocraft_doc::Document::new( + "large", + photocraft_geom::Size::new(300_000, 300_000), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + ), + None, + ); + for all_layers in [false, true] { + assert!(sample_pixels(&s, all_layers).unwrap_err().to_string().contains("too large to sample")); + } + assert!(!s.active().unwrap().history.can_undo()); + } + + #[test] + fn selection_sampling_allocation_failure_keeps_the_selection() { + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 4, "height": 4})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + crate::allocation::fail_next_for_test(); + let result = s.execute("select.similar", json!({"sampleAllLayers": false})); + let _ = crate::allocation::capacity::(0, "reset failpoint"); + assert!(result.is_err()); + assert_eq!(*s.active().unwrap().doc, *before); + assert_eq!(s.active().unwrap().history.entries(), history); + } + + #[test] + fn modify_rejects_oversized_canvases_before_reading_the_mask() { + let mut s = Session::new(); + let mut doc = photocraft_doc::Document::new( + "large", + photocraft_geom::Size::new(300_000, 300_000), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + ); + let mut selection = photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8); + selection.write_pixel(0, 0, &[1.0]); + doc.selection = Some(selection); + s.add_document(doc, None); + let before = s.active().unwrap().doc.clone(); + for op in ["expand", "contract", "border", "smooth", "feather"] { + let error = s.execute(&format!("select.modify.{op}"), json!({"radius": 1})).unwrap_err(); + assert!(error.to_string().contains("too large to modify")); + assert_eq!(*s.active().unwrap().doc, *before); + assert!(!s.active().unwrap().history.can_undo()); + } + } + #[test] fn color_range_replace_is_bounded_by_existing_selection() { for params in [json!({"color": "#ff0000", "fuzziness": 0}), json!({"select": "reds"})] { @@ -802,15 +851,24 @@ mod tests { assert_eq!(coverage(&s, 30, 10), 0.0); } + #[test] + fn color_range_eyedropper_samples_anywhere_but_the_result_stays_in_the_selection() { + // #5244: as in Photoshop, a colour picked outside the active selection is accepted; only + // the result is limited to the selection. + let mut s = session(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap(); + let outside = json!({"points": [[30, 10]], "fuzziness": 0}); + assert!(ColorRangeSamples::new(&s, &outside).is_ok()); + s.execute("select.colorRange", outside).unwrap(); + assert_eq!(coverage(&s, 10, 10), 1.0, "the red inside the selection is selected"); + assert_eq!(coverage(&s, 30, 10), 0.0, "the sampled red outside it stays out"); + assert_eq!(coverage(&s, 2, 2), 0.0, "white is not the sampled colour"); + } + #[test] fn color_range_eyedropper_respects_selection_but_add_can_extend_it() { let mut s = session(); s.execute("select.rect", json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap(); - let before = current_mask(&s.active().unwrap().doc).1; - let outside = json!({"points": [[30, 10]], "fuzziness": 0}); - assert!(s.execute("select.colorRange", outside.clone()).unwrap_err().to_string().contains("outside the active selection")); - assert!(ColorRangeSamples::new(&s, &outside).err().unwrap().to_string().contains("outside the active selection")); - assert_eq!(current_mask(&s.active().unwrap().doc).1, before); s.execute("select.colorRange", json!({"points": [[10, 10]], "fuzziness": 0})).unwrap(); assert_eq!(coverage(&s, 10, 10), 1.0); assert_eq!(coverage(&s, 30, 10), 0.0); @@ -850,7 +908,6 @@ mod tests { let mut s = session(); s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); s.execute("view.layerMask", json!({"mode": "gray"})).unwrap(); s.execute("select.magicWand", json!({"x": 7, "y": 7, "tolerance": 0, "antiAlias": false})).unwrap(); assert_eq!(coverage(&s, 2, 2), 1.0, "gray mask view selects the mask"); @@ -977,6 +1034,45 @@ mod tests { assert!(lighter > redder + 0.15 && redder > 0.0, "lighter {lighter}, redder {redder}"); } + #[test] + fn color_range_fuzziness_zero_selects_exact_match_only() { + // Sampled Colors at Fuzziness 0 selects only the exact match (TOOL-214-3, #214). + let mut s = Session::new(); + s.execute("file.new", json!({"width": 50, "height": 10})).unwrap(); + s.edit("paint", |doc, _| { + let bg = doc.layers[0].surface_mut().unwrap(); + let v = |c: u8| f32::from(c) / 255.0; + // Exact target color [200, 140, 120] + bg.fill_rect(Rect::new(0, 0, 10, 10), &[v(200), v(140), v(120), 1.0]); + // Off by 1 level in red [201, 140, 120] + bg.fill_rect(Rect::new(10, 0, 20, 10), &[v(201), v(140), v(120), 1.0]); + // Off by 1 level in green [200, 141, 120] + bg.fill_rect(Rect::new(20, 0, 30, 10), &[v(200), v(141), v(120), 1.0]); + // Off by 2 levels in blue [200, 140, 122] (crosses 8-bit Lab grid threshold) + bg.fill_rect(Rect::new(30, 0, 40, 10), &[v(200), v(140), v(122), 1.0]); + // Exact target color again at a different position + bg.fill_rect(Rect::new(40, 0, 50, 10), &[v(200), v(140), v(120), 1.0]); + Ok(()) + }) + .unwrap(); + + // Sampled via eyedropper point at [5, 5] with Fuzziness 0 + s.execute("select.colorRange", json!({"points": [[5, 5]], "fuzziness": 0})).unwrap(); + assert_eq!(coverage(&s, 5, 5), 1.0, "sampled pixel itself is fully selected"); + assert_eq!(coverage(&s, 45, 5), 1.0, "identical color elsewhere on canvas is fully selected"); + assert_eq!(coverage(&s, 15, 5), 0.0, "1 level off in red is unselected at fuzziness 0"); + assert_eq!(coverage(&s, 25, 5), 0.0, "1 level off in green is unselected at fuzziness 0"); + assert_eq!(coverage(&s, 35, 5), 0.0, "1 level off in blue is unselected at fuzziness 0"); + + // Sampled via hex color with Fuzziness 0 + s.execute("select.colorRange", json!({"color": "#c88c78", "fuzziness": 0})).unwrap(); + assert_eq!(coverage(&s, 5, 5), 1.0, "exact color match from hex is fully selected"); + assert_eq!(coverage(&s, 45, 5), 1.0, "identical color elsewhere on canvas is fully selected"); + assert_eq!(coverage(&s, 15, 5), 0.0, "1 level off in red is unselected for hex at fuzziness 0"); + assert_eq!(coverage(&s, 25, 5), 0.0, "1 level off in green is unselected for hex at fuzziness 0"); + assert_eq!(coverage(&s, 35, 5), 0.0, "1 level off in blue is unselected for hex at fuzziness 0"); + } + /// Red squares (from `session`), a blue square, a black and a mid-grey strip on white. fn session_colours() -> Session { let mut s = session(); diff --git a/crates/engine/src/shell_commands.rs b/crates/engine/src/shell_commands.rs new file mode 100644 index 0000000000..2bd765673f --- /dev/null +++ b/crates/engine/src/shell_commands.rs @@ -0,0 +1,194 @@ +//! The commands the app's shell carries out itself (view, panels, windows: they change no +//! document), and the tools, as data: what they're called and which keys they have by default. +//! The shell implements them (`photocraft_ui_egui::menus::UI_COMMANDS`, its `Tool`); the engine +//! knows them so a headless session sees every bindable command: Edit › Keyboard Shortcuts' +//! list, conflicts, `.kys` import and export all work without the UI (the UI is a layer on +//! top). The app's tests keep these tables equal to its own. + +/// Shell commands: id, label, menu path, default shortcut. +pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ + ("ui.symmetryTransform", "Transform Symmetry", &[], None), + // Right-click the options bar's tool icon (#2825); `tool.resetAll` asks first. + ("tool.reset", "Reset Tool", &[], None), + ("tool.resetAll", "Reset All Tools", &[], None), + ("window.panel.group", "Change Panel Group", &[], None), + ("window.panel.layout", "Change Panel Layout", &[], None), + ("window.panel.move", "Move Panel", &[], None), + ("window.panel.float", "Float Panel", &[], None), + ("window.panel.dock", "Dock Panel", &[], None), + ("window.panel.activate", "Activate Panel", &[], None), + ("window.panel.close", "Close Panel", &[], None), + ("file.open", "Open…", &["File"], Some("Cmd+O")), + ("file.save", "Save", &["File"], Some("Cmd+S")), + ("file.saveAs", "Save As…", &["File"], Some("Cmd+Shift+S")), + ("file.exit", "Exit", &["File"], Some("Cmd+Q")), + ("edit.freeTransform", "Free Transform", &["Edit"], Some("Cmd+T")), + ("file.export.exportAs", "Export As…", &["File", "Export"], Some("Cmd+Alt+Shift+W")), + ("file.export.quickExportAsPng", "Quick Export as PNG", &["File", "Export"], None), + ("edit.transform.scale", "Scale", &["Edit", "Transform"], None), + ("edit.transform.rotate", "Rotate", &["Edit", "Transform"], None), + ("edit.transform.skew", "Skew", &["Edit", "Transform"], None), + ("edit.transform.distort", "Distort", &["Edit", "Transform"], None), + ("edit.transform.perspective", "Perspective", &["Edit", "Transform"], None), + ("select.selectAndMask", "Select and Mask…", &["Select"], Some("Cmd+Alt+R")), + ("view.rulers", "Rulers", &["View"], Some("Cmd+R")), + ("view.show.grid", "Grid", &["View", "Show"], Some("Cmd+'")), + ("view.show.guides", "Guides", &["View", "Show"], Some("Cmd+;")), + ("view.snap", "Snap", &["View"], Some("Cmd+Shift+;")), + ("view.lockGuides", "Lock Guides", &["View"], Some("Cmd+Alt+;")), + ("view.extras", "Extras", &["View"], Some("Cmd+H")), + ("view.show.targetPath", "Target Path", &["View", "Show"], Some("Cmd+Shift+H")), + ("view.screenMode.cycle", "Cycle Screen Mode", &[], Some("F")), + // Photoshop's interface brightness shortcuts: step through the four Pro themes. + ("view.darkenInterface", "Darken Interface", &[], Some("Cmd+Shift+F1")), + ("view.lightenInterface", "Lighten Interface", &[], Some("Cmd+Shift+F2")), + ("edit.freeTransformCopy", "Free Transform a Copy", &[], Some("Cmd+Alt+T")), + ("type.editText", "Edit Type", &[], None), + ("view.zoomIn", "Zoom In", &["View"], Some("Cmd+=")), + ("view.zoomOut", "Zoom Out", &["View"], Some("Cmd+-")), + ("view.fitOnScreen", "Fit on Screen", &["View"], Some("Cmd+0")), + ("view.actualPixels", "100%", &["View"], Some("Cmd+1")), + ("view.rotateView", "Rotate View", &[], None), + ("view.resetView", "Reset View", &["View"], None), + ("window.newWindowForDocument", "New Window for Document", &["Window", "Arrange"], None), + ("window.toggle.layers", "Layers", &["Window"], Some("F7")), + ("window.toggle.history", "History", &["Window"], None), + ("window.toggle.properties", "Properties", &["Window"], None), + ("window.toggle.color", "Color", &["Window"], Some("F6")), + ("window.toggle.brushSettings", "Brush Settings", &["Window"], Some("F5")), + ("window.toggle.brushes", "Brushes", &["Window"], None), + ("window.toggle.navigator", "Navigator", &["Window"], None), + ("window.toggle.toolbar", "Tools", &["Window"], None), + // Photoshop's Tab and ⇧Tab: all panels (Tools, options bar and the dock), or only the dock. + ("window.togglePanels", "Show/Hide All Panels", &[], Some("Tab")), + ("window.toggle.dock", "Show/Hide Panels", &[], Some("Shift+Tab")), + ("window.toggle.options", "Options", &["Window"], None), + // Photoshop's Ctrl+Tab and Ctrl+Shift+Tab, on macOS too (⌘Tab belongs to the system, #2340). + ("window.nextDocument", "Next Document", &[], Some("Ctrl+Tab")), + ("window.previousDocument", "Previous Document", &[], Some("Ctrl+Shift+Tab")), + ("window.theme.toggle", "Next Appearance Mode", &["Window"], None), + ("window.theme.pro", "Pro Theme", &["Window", "Theme"], None), + ("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None), + ("window.theme.proLightGray", "Pro Light Gray Theme", &["Window", "Theme"], None), + ("window.theme.proLight", "Pro Light Theme", &["Window", "Theme"], None), + ("window.theme.studio", "Studio Theme", &["Window", "Theme"], None), + ("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None), + ("window.theme.classic", "Classic Theme", &["Window", "Theme"], None), + ("window.theme.solarizedDark", "Solarized Dark Theme", &["Window", "Theme"], None), + ("window.theme.adwaita", "Adwaita Light Theme", &["Window", "Theme"], None), + ("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None), + ("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None), + ("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None), + ("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None), + ("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None), + ("window.theme.system", "Sync with system", &["Window", "Theme"], None), + ("edit.search", "Search…", &["Edit"], Some("Cmd+F")), + ("help.discord", "Join the ArtCraft Discord…", &["Help"], None), + ("help.website", "PhotoCraft Website", &["Help"], None), + ("help.artcraftWebsite", "ArtCraft Website", &["Help"], None), + ("help.github", "PhotoCraft on GitHub", &["Help"], None), + ("help.reportIssue", "Report an Issue…", &["Help"], None), + ("help.systemInfo", "System Info…", &["Help"], None), + ("help.about", "About PhotoCraft", &["Help"], None), +]; + +/// The tools, in toolbar order: shortcut id (`tools.select.`), Photoshop's name for it +/// (ours too), and its default key. Tools that share a key cycle with Shift (Photoshop's +/// tool groups), so a shared default letter isn't a conflict. +pub const TOOLS: &[(&str, &str, Option<&str>)] = &[ + ("tools.select.move", "Move Tool", Some("V")), + ("tools.select.artboard", "Artboard Tool", Some("V")), + ("tools.select.rectMarquee", "Rectangular Marquee Tool", Some("M")), + ("tools.select.ellipseMarquee", "Elliptical Marquee Tool", Some("M")), + ("tools.select.singleRowMarquee", "Single Row Marquee Tool", None), + ("tools.select.singleColumnMarquee", "Single Column Marquee Tool", None), + ("tools.select.lasso", "Lasso Tool", Some("L")), + ("tools.select.polygonLasso", "Polygonal Lasso Tool", Some("L")), + ("tools.select.magneticLasso", "Magnetic Lasso Tool", Some("L")), + ("tools.select.magicWand", "Magic Wand Tool", Some("W")), + ("tools.select.crop", "Crop Tool", Some("C")), + ("tools.select.perspectiveCrop", "Perspective Crop Tool", Some("C")), + ("tools.select.eyedropper", "Eyedropper Tool", Some("I")), + ("tools.select.colorSampler", "Color Sampler Tool", Some("I")), + ("tools.select.ruler", "Ruler Tool", Some("I")), + ("tools.select.note", "Note Tool", Some("I")), + ("tools.select.count", "Count Tool", Some("I")), + ("tools.select.brush", "Brush Tool", Some("B")), + ("tools.select.pencil", "Pencil Tool", Some("B")), + ("tools.select.mixerBrush", "Mixer Brush Tool", Some("B")), + ("tools.select.colorReplacement", "Color Replacement Tool", Some("B")), + ("tools.select.eraser", "Eraser Tool", Some("E")), + ("tools.select.backgroundEraser", "Background Eraser Tool", Some("E")), + ("tools.select.magicEraser", "Magic Eraser Tool", Some("E")), + ("tools.select.gradient", "Gradient Tool", Some("G")), + ("tools.select.paintBucket", "Paint Bucket Tool", Some("G")), + ("tools.select.type", "Horizontal Type Tool", Some("T")), + ("tools.select.verticalType", "Vertical Type Tool", Some("T")), + ("tools.select.horizontalTypeMask", "Horizontal Type Mask Tool", Some("T")), + ("tools.select.verticalTypeMask", "Vertical Type Mask Tool", Some("T")), + ("tools.select.hand", "Hand Tool", Some("H")), + ("tools.select.rotateView", "Rotate View Tool", Some("R")), + ("tools.select.zoom", "Zoom Tool", Some("Z")), + ("tools.select.remove", "Remove Tool", Some("J")), + ("tools.select.spotHealing", "Spot Healing Brush Tool", Some("J")), + ("tools.select.healing", "Healing Brush Tool", Some("J")), + ("tools.select.patch", "Patch Tool", Some("J")), + ("tools.select.contentAwareMove", "Content-Aware Move Tool", Some("J")), + ("tools.select.redEye", "Red Eye Tool", Some("J")), + ("tools.select.cloneStamp", "Clone Stamp Tool", Some("S")), + ("tools.select.patternStamp", "Pattern Stamp Tool", Some("S")), + ("tools.select.historyBrush", "History Brush Tool", Some("Y")), + ("tools.select.blur", "Blur Tool", None), + ("tools.select.sharpen", "Sharpen Tool", None), + ("tools.select.smudge", "Smudge Tool", None), + ("tools.select.dodge", "Dodge Tool", Some("O")), + ("tools.select.burn", "Burn Tool", Some("O")), + ("tools.select.sponge", "Sponge Tool", Some("O")), + ("tools.select.quickSelection", "Quick Selection Tool", Some("W")), + ("tools.select.objectSelection", "Object Selection Tool", Some("W")), + ("tools.select.pen", "Pen Tool", Some("P")), + ("tools.select.freeformPen", "Freeform Pen Tool", Some("P")), + ("tools.select.curvaturePen", "Curvature Pen Tool", Some("P")), + ("tools.select.addAnchorPoint", "Add Anchor Point Tool", Some("P")), + ("tools.select.deleteAnchorPoint", "Delete Anchor Point Tool", Some("P")), + ("tools.select.convertPoint", "Convert Point Tool", Some("P")), + ("tools.select.pathSelection", "Path Selection Tool", Some("A")), + ("tools.select.directSelection", "Direct Selection Tool", Some("A")), + ("tools.select.rectangle", "Rectangle Tool", Some("U")), + ("tools.select.ellipseShape", "Ellipse Tool", Some("U")), + ("tools.select.triangle", "Triangle Tool", Some("U")), + ("tools.select.polygon", "Polygon Tool", Some("U")), + ("tools.select.line", "Line Tool", Some("U")), + ("tools.select.customShape", "Custom Shape Tool", Some("U")), + ("tools.select.slice", "Slice Tool", Some("C")), + ("tools.select.sliceSelect", "Slice Select Tool", Some("C")), + ("tools.select.frame", "Frame Tool", Some("K")), +]; + +/// The default key of tool shortcut `id`, if it's a tool. +pub fn tool_default(id: &str) -> Option> { + TOOLS.iter().find(|t| t.0 == id).map(|t| t.2) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_are_unique_and_keys_are_valid() { + let mut ids: Vec<&str> = SHELL_COMMANDS.iter().map(|c| c.0).chain(TOOLS.iter().map(|t| t.0)).collect(); + let n = ids.len(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), n, "duplicate ids"); + for (id, _, _, sc) in SHELL_COMMANDS { + assert!(sc.is_none_or(|k| crate::prefs::normalize_shortcut(k).is_some()), "{id}: {sc:?}"); + } + for (id, name, key) in TOOLS { + assert!(id.starts_with("tools.select.") && name.ends_with("Tool"), "{id} {name}"); + assert!(key.is_none_or(|k| k.len() == 1), "{id}: {key:?}"); + } + assert_eq!(tool_default("tools.select.move"), Some(Some("V"))); + assert_eq!(tool_default("edit.copy"), None); + } +} diff --git a/crates/engine/src/shortcut_sets.rs b/crates/engine/src/shortcut_sets.rs new file mode 100644 index 0000000000..26062def96 --- /dev/null +++ b/crates/engine/src/shortcut_sets.rs @@ -0,0 +1,359 @@ +//! Edit › Keyboard Shortcuts › Set: named shortcut sets, as in Photoshop. The bindings in use +//! stay in [`Preferences::shortcuts`] / [`Preferences::alternate_shortcuts`] (what dispatch +//! reads); a set is a saved copy of them, `Preferences::shortcut_sets` keyed by name, and +//! `Preferences::shortcut_set` names the one in use ("" for the defaults). The bindings differ +//! from their set after an edit until it is saved ("modified", as Photoshop marks the set). +//! A set saves to and loads from a `.kys` file (`crate::kys::write` / the import), and +//! [`summarize`] writes the HTML table of Photoshop's Summarize button. +//! +//! `edit.keyboardShortcuts` takes the set parameters: `useSet` (load a set's bindings, before +//! the call's other changes), then `saveSet` (save the bindings as they end up under a name and +//! use it), `deleteSet`, `sets` (replace every saved set: the dialog's OK) and `activeSet`. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::prefs::{Preferences, alternate_keys, normalize_shortcut}; + +/// The built-in set's name: the default bindings. +pub const DEFAULTS_NAME: &str = "PhotoCraft Defaults"; +/// Saved sets are small; the cap keeps a hostile settings file or call bounded. +pub const MAX_SETS: usize = 64; +/// Longest set name, in characters. +pub const MAX_NAME: usize = 64; + +/// A saved set: the overrides of the default bindings, in the shape the preferences keep them. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ShortcutSet { + /// Command id → main key; "" removes the default key. + pub shortcuts: BTreeMap, + /// Command id → further keys, replacing the defaults. + pub alternate_shortcuts: BTreeMap>, +} + +impl ShortcutSet { + /// The bindings `p` uses now. + pub fn of(p: &Preferences) -> Self { + Self { shortcuts: p.shortcuts.clone(), alternate_shortcuts: p.alternate_shortcuts.clone() } + } + + /// Use this set's bindings in `p`. + pub fn load_into(&self, p: &mut Preferences) { + p.shortcuts = self.shortcuts.clone(); + p.alternate_shortcuts = self.alternate_shortcuts.clone(); + } + + /// Check every key (a set from a call or a hand-edited file must not hold garbage). + fn validate(&self) -> Result<(), String> { + for (id, k) in &self.shortcuts { + if !k.is_empty() && normalize_shortcut(k).is_none() { + return Err(format!("`{k}` for `{id}` is not a valid shortcut (e.g. Cmd+Shift+N, F7)")); + } + } + for (id, keys) in &self.alternate_shortcuts { + alternate_keys(&json!(keys)).map_err(|e| format!("`{id}`: {e}"))?; + } + Ok(()) + } +} + +/// A set name as given, trimmed; an error for an empty, overlong or reserved name. +pub fn set_name(name: &str) -> Result { + let n = name.trim(); + if n.is_empty() { + return Err("a set needs a name".into()); + } + if n.chars().count() > MAX_NAME { + return Err(format!("a set name is at most {MAX_NAME} characters")); + } + if n.chars().any(char::is_control) { + return Err("a set name can't contain control characters".into()); + } + if n.eq_ignore_ascii_case(DEFAULTS_NAME) { + return Err(format!("`{DEFAULTS_NAME}` is the built-in set")); + } + Ok(n.to_string()) +} + +/// The saved copy of the set in use: `None` for the defaults (or a set that no longer exists). +fn saved(p: &Preferences) -> Option<&ShortcutSet> { + p.shortcut_sets.get(&p.shortcut_set) +} + +/// Do the bindings differ from the set in use? +pub fn modified(p: &Preferences) -> bool { + let now = ShortcutSet::of(p); + match saved(p) { + Some(s) => *s != now, + None => now != ShortcutSet::default(), + } +} + +/// Before the call's binding changes: `useSet` loads a set ("" or [`DEFAULTS_NAME`]: the +/// defaults) and makes it the one in use. Returns whether it changed anything. +pub fn load(next: &mut Preferences, p: &Value) -> Result { + let Some(v) = p.get("useSet") else { return Ok(false) }; + let name = v.as_str().ok_or("`useSet` is a set name")?.trim(); + if name.is_empty() || name.eq_ignore_ascii_case(DEFAULTS_NAME) { + ShortcutSet::default().load_into(next); + next.shortcut_set = String::new(); + } else { + let set = next.shortcut_sets.get(name).cloned().ok_or_else(|| format!("no shortcut set named `{name}`"))?; + set.load_into(next); + next.shortcut_set = name.to_string(); + } + Ok(true) +} + +/// After the call's binding changes: `sets` (replace every saved set), `deleteSet`, `saveSet` +/// (save the bindings under a name and use it) and `activeSet`. Returns whether it changed +/// anything. +pub fn store(next: &mut Preferences, p: &Value) -> Result { + let mut changed = false; + if let Some(v) = p.get("sets") { + let obj = v.as_object().ok_or("`sets` maps set names to {shortcuts, alternateShortcuts}")?; + if obj.len() > MAX_SETS { + return Err(format!("at most {MAX_SETS} shortcut sets")); + } + let mut sets = BTreeMap::new(); + for (name, s) in obj { + let name = set_name(name)?; + let set: ShortcutSet = serde_json::from_value(s.clone()).map_err(|e| format!("set `{name}`: {e}"))?; + set.validate().map_err(|e| format!("set `{name}`: {e}"))?; + sets.insert(name, set); + } + next.shortcut_sets = sets; + changed = true; + } + if let Some(v) = p.get("deleteSet") { + let name = v.as_str().ok_or("`deleteSet` is a set name")?.trim(); + if next.shortcut_sets.remove(name).is_none() { + return Err(format!("no shortcut set named `{name}`")); + } + if next.shortcut_set == name { + next.shortcut_set = String::new(); + } + changed = true; + } + if let Some(v) = p.get("saveSet") { + let name = set_name(v.as_str().ok_or("`saveSet` is a set name")?)?; + if !next.shortcut_sets.contains_key(&name) && next.shortcut_sets.len() >= MAX_SETS { + return Err(format!("at most {MAX_SETS} shortcut sets")); + } + next.shortcut_sets.insert(name.clone(), ShortcutSet::of(next)); + next.shortcut_set = name; + changed = true; + } + if let Some(v) = p.get("activeSet") { + let name = v.as_str().ok_or("`activeSet` is a set name")?.trim(); + if name.is_empty() || name.eq_ignore_ascii_case(DEFAULTS_NAME) { + next.shortcut_set = String::new(); + } else if next.shortcut_sets.contains_key(name) { + next.shortcut_set = name.to_string(); + } else { + return Err(format!("no shortcut set named `{name}`")); + } + changed = true; + } + Ok(changed) +} + +/// The sets, the one in use and whether the bindings differ from it, for the command's reply. +pub fn describe(p: &Preferences) -> Value { + json!({ + "sets": p.shortcut_sets.keys().collect::>(), + "activeSet": if saved(p).is_some() { p.shortcut_set.as_str() } else { "" }, + "defaultsName": DEFAULTS_NAME, + "modified": modified(p), + }) +} + +/// A key as the Summarize table shows it on `mac` (⌘ spelled Cmd, ⌥ Option) or elsewhere +/// (Ctrl, Alt). +fn key_label(sc: &str, mac: bool) -> String { + let Some(n) = normalize_shortcut(sc) else { return sc.to_string() }; + // `Cmd++` splits into an empty part before the key, which joins back as `++`. + let mut parts: Vec<&str> = Vec::new(); + for part in n.split('+') { + let shown = match (part, mac) { + ("Ctrl", true) => "Control", + ("Cmd", false) | ("Ctrl", false) => "Ctrl", + ("Alt", true) => "Option", + (p, _) => p, + }; + // Off the Mac, ⌘ and Control are both Ctrl: say it once. + if shown == "Ctrl" && parts.contains(&"Ctrl") { + continue; + } + parts.push(shown); + } + parts.join("+") +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +/// A Summarize row: a command's label and its keys, the main one first. +pub type Row = (String, Vec); +/// A Summarize section: its title and rows. +pub type Section = (String, Vec); + +/// Photoshop's Summarize: an HTML page listing every command with a key, by section +/// (`sections` in display order: section title, then (command label, keys with the main one +/// first)). Commands without a key are left out. +pub fn summarize(set: &str, sections: &[Section], mac: bool) -> String { + let mut out = String::new(); + out.push_str("\n"); + out.push_str(&html_escape(set)); + out.push_str("\n\n\n

"); + out.push_str(&html_escape(set)); + out.push_str("

\n"); + for (title, rows) in sections { + let rows: Vec<&Row> = rows.iter().filter(|(_, keys)| !keys.is_empty()).collect(); + if rows.is_empty() { + continue; + } + out.push_str("

"); + out.push_str(&html_escape(title)); + out.push_str("

\n\n"); + for (label, keys) in rows { + let keys: Vec = keys.iter().map(|k| html_escape(&key_label(k, mac))).collect(); + out.push_str("\n"); + } + out.push_str("
CommandShortcut
"); + out.push_str(&html_escape(label)); + out.push_str(""); + out.push_str(&keys.join(", ")); + out.push_str("
\n"); + } + out.push_str("\n"); + out +} + +/// A command (or tool) with a key under some bindings. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Bound { + pub id: String, + pub label: String, + /// Its top-level menu ("Tools" for tools). + pub menu: String, + /// Main key first, then the further keys. + pub keys: Vec, + pub tool: bool, +} + +/// Every bindable command with a key under the bindings of `p`: the engine's and the shell's +/// commands, the tools and the temporary tools ([`crate::prefs::bindable_items`]). +pub fn bound(p: &Preferences) -> Vec { + crate::prefs::bindable_items() + .filter_map(|(id, label, menu, def, kind)| { + let main = p.shortcut(id, def); + let mut keys: Vec = main.and_then(normalize_shortcut).into_iter().collect(); + keys.extend(p.alternates(id, main)); + let menu = menu.first().copied().unwrap_or("Other").to_string(); + (!keys.is_empty()).then(|| Bound { id: id.to_string(), label: label.to_string(), menu, keys, tool: kind == crate::prefs::BindKind::Tool }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn prefs() -> Preferences { + Preferences::default() + } + + #[test] + fn save_use_modify_and_delete_a_set() { + let mut p = prefs(); + assert!(!modified(&p)); + p.shortcuts.insert("edit.copy".into(), "F9".into()); + assert!(modified(&p), "an edit to the defaults"); + assert!(store(&mut p, &json!({"saveSet": " Mine "})).unwrap()); + assert_eq!((p.shortcut_set.as_str(), modified(&p)), ("Mine", false)); + p.shortcuts.insert("edit.cut".into(), "F8".into()); + assert!(modified(&p)); + // Back to the defaults, then to the saved set as it was saved. + assert!(load(&mut p, &json!({"useSet": ""})).unwrap()); + assert!(p.shortcuts.is_empty() && p.shortcut_set.is_empty() && !modified(&p)); + load(&mut p, &json!({"useSet": "Mine"})).unwrap(); + assert_eq!(p.shortcuts.get("edit.copy").map(String::as_str), Some("F9")); + assert_eq!(p.shortcuts.get("edit.cut"), None, "the unsaved edit isn't in the set"); + assert_eq!(describe(&p)["sets"], json!(["Mine"])); + // Deleting the set in use falls back to the defaults' name; the bindings stay. + store(&mut p, &json!({"deleteSet": "Mine"})).unwrap(); + assert!(p.shortcut_sets.is_empty() && p.shortcut_set.is_empty()); + assert_eq!(describe(&p)["activeSet"], ""); + assert!(modified(&p), "F9 differs from the defaults"); + } + + #[test] + fn bad_names_sets_and_params_are_errors() { + let mut p = prefs(); + for name in ["", " ", "PhotoCraft defaults", "a\u{7}b", &"x".repeat(65)] { + assert!(store(&mut p, &json!({"saveSet": name})).is_err(), "{name:?}"); + } + assert!(load(&mut p, &json!({"useSet": "Nope"})).is_err()); + assert!(load(&mut p, &json!({"useSet": 3})).is_err()); + assert!(store(&mut p, &json!({"deleteSet": "Nope"})).is_err()); + assert!(store(&mut p, &json!({"activeSet": "Nope"})).is_err()); + assert!(store(&mut p, &json!({"sets": []})).is_err()); + assert!(store(&mut p, &json!({"sets": {"A": {"shortcuts": {"edit.copy": "Cmd+Shift+Ctrl"}}}})).is_err(), "no key"); + let nine: Vec = (1..=9).map(|i| format!("F{i}")).collect(); + assert!(store(&mut p, &json!({"sets": {"A": {"alternateShortcuts": {"edit.copy": nine}}}})).is_err()); + assert!(store(&mut p, &json!({"sets": {"A": {"shortcuts": 5}}})).is_err()); + let many: serde_json::Map = (0..=MAX_SETS).map(|i| (format!("S{i}"), json!({}))).collect(); + assert!(store(&mut p, &json!({ "sets": many })).is_err()); + for i in 0..MAX_SETS { + store(&mut p, &json!({"saveSet": format!("S{i}")})).unwrap(); + } + assert!(store(&mut p, &json!({"saveSet": "One more"})).is_err()); + assert!(store(&mut p, &json!({"saveSet": "S3"})).is_ok(), "saving over a set is fine at the cap"); + } + + #[test] + fn sets_replace_all_and_active_set_follows() { + let mut p = prefs(); + store(&mut p, &json!({"sets": {"A": {"shortcuts": {"edit.copy": "F9"}}, "B": {}}, "activeSet": "A"})).unwrap(); + assert_eq!(p.shortcut_set, "A"); + assert!(modified(&p), "activeSet names the set; it doesn't load it"); + load(&mut p, &json!({"useSet": "A"})).unwrap(); + assert!(!modified(&p)); + store(&mut p, &json!({"activeSet": "PhotoCraft Defaults"})).unwrap(); + assert_eq!(p.shortcut_set, ""); + } + + #[test] + fn summarize_lists_bound_commands_by_section_for_each_platform() { + let sections = vec![ + ("Edit".to_string(), vec![("Copy".to_string(), vec!["Cmd+C".to_string(), "F3".to_string()]), ("Unbound".to_string(), vec![])]), + ("Empty".to_string(), vec![("Nothing".to_string(), vec![])]), + ("Filter ".to_string(), vec![("Gaussian Blur & more".to_string(), vec!["Ctrl+Alt+Cmd+G".to_string()])]), + ]; + let mac = summarize("My ", §ions, true); + assert!(mac.contains("My <Set>") && mac.contains("CopyCmd+C, F3"), "{mac}"); + assert!(mac.contains("Cmd+Control+Option+G") && mac.contains("Gaussian Blur & more") && mac.contains("Filter <x>"), "{mac}"); + assert!(!mac.contains("Unbound") && !mac.contains("Empty"), "{mac}"); + let pc = summarize("S", §ions, false); + assert!(pc.contains("Ctrl+C, F3") && pc.contains("Ctrl+Alt+G"), "{pc}"); + assert_eq!(key_label("Cmd++", true), "Cmd++"); + } + + #[test] + fn bound_lists_main_keys_then_alternates() { + let p = prefs(); + let b = bound(&p); + let copy = b.iter().find(|x| x.id == "edit.copy").expect("Copy has keys"); + assert_eq!(copy.keys, vec!["Cmd+C".to_string(), "F3".to_string()]); + assert!(b.iter().any(|x| x.id == "tools.temporary.hand")); + // The shell's commands and the tools too: a set saved headless is the whole set. + assert!(b.iter().any(|x| x.id == "view.zoomIn" && x.keys.len() >= 2), "Zoom In with its Cmd++"); + let mv = b.iter().find(|x| x.id == "tools.select.move").expect("Move Tool"); + assert!(mv.tool && mv.keys == ["V"] && mv.menu == "Tools"); + } +} diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 295b845df0..3ca10083b9 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 @@ -298,62 +303,35 @@ fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32) out } -/// Mixes `filtered` over `base` through the filter mask (premultiplied, any channel layout). -fn mask_mix(base: &Surface, filtered: &Surface, mask: &LayerMask) -> Surface { - let fmt = base.format(); - let area = base.content_bounds().union(&filtered.content_bounds()); - let mut out = filtered.clone(); - if area.is_empty() { - return out; - } - let n = fmt.channels(); - let (b, f) = (base.read_region(area), filtered.read_region(area)); - let mut k = Vec::new(); - mask.values_into(area, &mut k); - let mut o = vec![0.0f32; b.len()]; - for (i, ((bp, fp), op)) in b.chunks_exact(n).zip(f.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() { - let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0); - if !fmt.alpha { - for c in 0..n { - op[c] = bp[c] + (fp[c] - bp[c]) * m; - } - continue; - } - let a = n - 1; - let oa = bp[a] + (fp[a] - bp[a]) * m; - op[a] = oa; - for c in 0..a { - let pm = bp[c] * bp[a] + (fp[c] * fp[a] - bp[c] * bp[a]) * m; - op[c] = if oa > 0.0 { (pm / oa).clamp(0.0, 1.0) } else { 0.0 }; - } - } - out.write_region(area, &o); - out.prune(); - out -} - -/// Runs the smart filter stack on the placed (unfiltered) content. -pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> Surface { +/// Runs the smart filter stack on the placed (unfiltered) content. A filter the session's +/// authority denies surfaces its error (the trusted compositor path has no gate installed, so +/// this reads ambiently only when the caller established one and it refuses). +pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> Result { if !sm.filters_enabled || !sm.smart_filters.iter().any(|f| f.visible) { - return placed.clone(); + return Ok(placed.clone()); } let mut cur = placed.clone(); // Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters. let extent = canvas.union(&placed.content_bounds()); for f in sm.smart_filters.iter().filter(|f| f.visible) { - cur = apply_smart_filter(f, &cur, canvas, extent); + cur = apply_smart_filter(f, &cur, canvas, extent)?; } - match sm.filter_mask.as_ref().filter(|m| m.enabled) { - Some(m) => mask_mix(placed, &cur, m), + Ok(match sm.filter_mask.as_ref().filter(|m| m.enabled) { + Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m), None => cur, - } + }) } -/// One visible smart filter over `cur`, blended with its blend mode and opacity. -fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect) -> Surface { - // Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone. - let Some(out) = crate::filters::apply_filter_to_surface(&f.command, &f.params, cur, canvas, None, extent) else { return cur.clone() }; - if f.blend == BlendMode::Normal && f.opacity >= 1.0 { out } else { blend_surfaces(cur, &out, f.blend, f.opacity.clamp(0.0, 1.0)) } +/// One visible smart filter over `cur`, blended with its blend mode and opacity. `Err` when the +/// requesting session's authority denies the filter's params: callers surface the error instead of +/// rendering (and caching) unfiltered pixels. +fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect) -> Result { + let out = match crate::filters::apply_filter_to_surface(&f.command, &f.params, cur, canvas, None, extent)? { + // Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone. + Some(out) => out, + None => cur.clone(), + }; + if f.blend == BlendMode::Normal && f.opacity >= 1.0 { Ok(out) } else { Ok(blend_surfaces(cur, &out, f.blend, f.opacity.clamp(0.0, 1.0))) } } /// The source placed in document pixels, before any smart filter. @@ -411,6 +389,11 @@ pub fn render(doc: &Document, sm: &SmartObject) -> Result> { /// is cancelled. Gives the same pixels as [`apply_smart_filters`] on the placed source, but /// starts from the deepest stage of the stack already rendered (see [`stack_cache`]). fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result> { + // Stored filters can come from a file or another layer. Check before looking up cached + // stages too: a trusted UI render may have populated this process-wide cache earlier. + for filter in sm.smart_filters.iter().filter(|f| sm.filters_enabled && f.visible) { + crate::jobs::authorize_render_command(&filter.command, &filter.params)?; + } let Some((name, bytes)) = source_bytes(&doc.metadata, &sm.source) else { return Ok(None) }; let (fmt, canvas) = (doc.pixel_format(), doc.bounds()); let placed_key = stack_cache::placed_key(&bytes, fmt, sm, canvas); @@ -444,12 +427,12 @@ fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result mask_mix(&placed()?.surface, &cur.surface, m), + Some(m) => photocraft_compose::filter_mask::mix(&placed()?.surface, &cur.surface, m), None => cur.surface, })) } @@ -514,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> { @@ -556,7 +543,7 @@ pub(crate) fn add_smart_filter(doc: &mut Document, id: LayerId, sf: photocraft_d } let bounds = selection.map(Surface::content_bounds).filter(|b| !b.is_empty()).unwrap_or(canvas); let cache = sm.cache.as_ref().ok_or_else(|| other("smart object has no pixels"))?; - let out = crate::filters::apply_filter_to_surface(&sf.command, &sf.params, cache, bounds, selection, canvas) + let out = crate::filters::apply_filter_to_surface(&sf.command, &sf.params, cache, bounds, selection, canvas)? .ok_or_else(|| other(format!("unknown filter {}", sf.command)))?; sm.cache = Some(out); sm.smart_filters.push(sf); @@ -827,21 +814,24 @@ fn set_source(s: &mut Session, p: &Value, label: &str, keep_psd: bool, make: imp // Keep the shared-instance transaction out of the per-command source-builder instantiations. fn set_shared_source(s: &mut Session, id: LayerId, label: &str, keep_psd: bool, new: SmartSource) -> Result { - let (parent, contents_id) = s.edit(label, |doc, _| { + let (parent, contents_id, changed) = s.edit(label, |doc, _| { let contents_id = smart(doc, id)?.contents_id; - for instance in instances(doc, contents_id) { + let changed = instances(doc, contents_id); + for &instance in &changed { smart_mut(doc, instance)?.source = new.clone(); if !keep_psd { detach_psd(doc.layer_mut(instance).ok_or(EngineError::NoLayer(instance))?); } refresh_or_fail(doc, instance)?; } - Ok((doc.id, contents_id)) + Ok((doc.id, contents_id, changed)) })?; // An old editor must not overwrite newly replaced/relinked contents on close. Keep its // document open, but the next Edit Contents opens the replacement source. s.smart_links.retain(|l| l.parent != parent || l.contents_id != contents_id); - Ok(json!({"layer": id.0})) + // Every instance changed, not only `layer`: list them, so a caller doesn't repeat the + // command once per copy (#4075). + Ok(json!({"layer": id.0, "instances": changed.iter().map(|l| l.0).collect::>()})) } fn replace_contents(s: &mut Session, p: &Value) -> Result { @@ -1031,6 +1021,7 @@ fn set_filter_visible(s: &mut Session, p: &Value) -> Result { fn set_filter_params(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.smartFilter.setParams"; let new = p.get("params").cloned().ok_or_else(|| bad(CMD, "pass `params` (merged into the filter's parameters)"))?; + let authorize = s.authorize; edit_filters(s, p, "Edit Smart Filter", |sm| { let i = filter_index(CMD, p, sm)?; let f = &mut sm.smart_filters[i]; @@ -1041,6 +1032,11 @@ fn set_filter_params(s: &mut Session, p: &Value) -> Result { (Value::Object(old), Value::Object(n)) => old.extend(n), (slot, n) => *slot = n, } + // Authorize the underlying command with the complete merged params, including paths + // already stored in the document, before committing the edit or starting a worker. + if let Some(gate) = authorize { + gate(&f.command, &f.params)?; + } Ok(()) }) } @@ -1069,6 +1065,9 @@ fn delete_filter(s: &mut Session, p: &Value) -> Result { edit_filters(s, p, "Delete Smart Filter", |sm| { let i = filter_index("layer.smartFilter.delete", p, sm)?; sm.smart_filters.remove(i); + if sm.smart_filters.is_empty() { + sm.filter_mask = None; + } Ok(()) }) } @@ -1138,6 +1137,9 @@ fn transfer_filters(s: &mut Session, p: &Value) -> Result { match index { Some(i) => { src.smart_filters.remove(i); + if src.smart_filters.is_empty() { + src.filter_mask = None; + } } None => { src.smart_filters.clear(); diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index a764712c9f..51d8d007f7 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -243,7 +243,7 @@ fn smart_filter_commands_check_an_explicit_layer_target() { assert_eq!(active(&s), curves, "the active layer stays"); // A target that isn't a smart object with filters is still refused, and the active layer stays. assert!(matches!(s.execute("layer.smartFilter.setParams", json!({"layer": curves, "params": {}})), Err(EngineError::Disabled(..)))); - assert!(s.execute("layer.smartFilter.setParams", json!({"layer": 999_999, "params": {}})).is_err()); + assert!(s.execute("layer.smartFilter.setParams", json!({"layer": u64::MAX, "params": {}})).is_err()); assert_eq!(active(&s), curves); } @@ -330,6 +330,37 @@ fn selection_becomes_the_filter_mask() { assert!(!s.is_enabled("layer.smartFilter.deleteFilterMask")); } +/// #5619: deleting (or dragging away) the last smart filter removes the filter mask with it, so +/// the next filter takes the new selection instead of the old one. +#[test] +fn removing_the_last_filter_removes_its_filter_mask() { + let mut s = session(8); + paint(&mut s); + let original = flat(&s); + let a = convert(&mut s); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 48})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + assert!(active_smart(&s).filter_mask.is_some()); + s.execute("layer.smartFilter.delete", json!({})).unwrap(); + assert!(active_smart(&s).filter_mask.is_none(), "the last filter takes its mask along"); + s.execute("select.rect", json!({"x": 20, "y": 0, "width": 20, "height": 48})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + let px = flat(&s); + let at = |v: &Vec<[f32; 4]>, x: i32, y: i32| v[(y * W + x) as usize]; + assert_eq!(at(&px, 6, 20), at(&original, 6, 20), "the old selection is not filtered"); + assert_ne!(at(&px, 30, 20), at(&original, 30, 20), "the new selection is filtered"); + + // Dragging the only filter to another smart object leaves no mask behind either. + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s); + let b = convert(&mut s); + s.execute("layer.smartFilter.transfer", json!({"from": a, "to": b, "filter": 0})).unwrap(); + let doc = &s.active().unwrap().doc; + let LayerContent::Smart(sm) = &doc.layer(LayerId(a)).unwrap().content else { panic!("not smart") }; + assert!(sm.smart_filters.is_empty() && sm.filter_mask.is_none()); +} + /// Scales `id` to 10 % around the origin and back, returning the composite. fn shrink_and_grow(s: &mut Session, id: u64) -> Vec<[f32; 4]> { s.execute("edit.transform", json!({"layer": id, "matrix": [0.1, 0, 0, 0.1, 0, 0]})).unwrap(); diff --git a/crates/engine/src/smart_cmds/tests/stack_cache.rs b/crates/engine/src/smart_cmds/tests/stack_cache.rs index 7675d71843..9465fb8387 100644 --- a/crates/engine/src/smart_cmds/tests/stack_cache.rs +++ b/crates/engine/src/smart_cmds/tests/stack_cache.rs @@ -17,7 +17,7 @@ fn uncached(s: &Session) -> Surface { let doc = &s.active().unwrap().doc; let sm = active_smart(s); let (name, bytes) = source_bytes(&doc.metadata, &sm.source).unwrap(); - apply_smart_filters(&place(&name, &bytes, doc.pixel_format(), &sm).unwrap(), &sm, doc.bounds()) + apply_smart_filters(&place(&name, &bytes, doc.pixel_format(), &sm).unwrap(), &sm, doc.bounds()).unwrap() } #[test] diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 651db7cdc0..380b6a7200 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -217,10 +217,23 @@ impl ObjectFinderCache { } } +/// Object Selection samples only the active layer by default: when that layer has no pixels in +/// `area`, say so instead of quietly finding nothing (#5246; scripts often add an empty layer +/// above the photo first). +fn check_sampled_pixels(doc: &Document, layer: Option, all_layers: bool, area: Rect) -> Result<()> { + let Some(surf) = sampled_target(doc, layer, all_layers).and_then(|id| doc.layer(id)).and_then(Layer::surface) else { return Ok(()) }; + if surf.content_bounds().intersect(&area).is_empty() { + let msg = if surf.content_bounds().is_empty() { "the active layer is empty" } else { "the active layer has no pixels in that area" }; + return Err(bad("select.object", &format!("{msg}; set \"sampleAllLayers\": true or select a layer with pixels"))); + } + Ok(()) +} + /// `select.object` with `"point"`: the Object Finder's object under the point (cached). fn object_at_point(s: &mut Session, p: &Value, (x, y): (f32, f32)) -> Result { let all_layers = b(p, "sampleAllLayers", false); let d = s.active().ok_or(EngineError::NoDocument)?; + check_sampled_pixels(&d.doc, d.active_layer, all_layers, Rect::from_xywh(x.floor() as i32, y.floor() as i32, 1, 1))?; let target = sampled_target(&d.doc, d.active_layer, all_layers); let region = match s.object_finder.lookup(&d.doc, target, x, y) { Some(r) => Some(r), @@ -248,7 +261,10 @@ fn object_selection(s: &mut Session, p: &Value) -> Result { return object_at_point(s, p, pt); } let rect = rect_param(p).ok_or_else(|| bad("select.object", "needs \"rect\": [x, y, w, h] or \"point\": [x, y]"))?; - let region = with_sampler(s, b(p, "sampleAllLayers", false), |smp, doc| grabcut::object_select(smp, doc.bounds(), rect, 160_000))?; + let all_layers = b(p, "sampleAllLayers", false); + let d = s.active().ok_or(EngineError::NoDocument)?; + check_sampled_pixels(&d.doc, d.active_layer, all_layers, rect)?; + let region = with_sampler(s, all_layers, |smp, doc| grabcut::object_select(smp, doc.bounds(), rect, 160_000))?; apply(s, "Object Selection", region.as_ref(), mode(p, "replace")) } @@ -417,6 +433,7 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result { s.edit("Select and Mask", |doc, _| { let surface = region.as_ref().map(matting::region_surface).unwrap_or_else(|| photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8)); + crate::extra_cmds::background_to_layer_for_mask(doc, id); let existing = doc.layer(id).and_then(|l| l.mask.as_ref()).cloned().unwrap_or_else(LayerMask::reveal_all); doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(LayerMask { surface, ..existing }); doc.selection = None; @@ -703,7 +720,7 @@ mod tests { // Any point on the object hits; the background and another target miss. assert!(std::sync::Arc::ptr_eq(&s.object_finder.lookup(&doc, target, 120.0, 90.0).unwrap(), &r)); assert!(s.object_finder.lookup(&doc, target, 3.0, 3.0).is_none()); - assert!(s.object_finder.lookup(&doc, Some(LayerId(9999)), 100.0, 80.0).is_none()); + assert!(s.object_finder.lookup(&doc, Some(LayerId(u64::MAX)), 100.0, 80.0).is_none()); // The click reuses it, and a selection-only edit keeps it. s.execute("select.object", json!({"point": [110, 70]})).unwrap(); assert_eq!(s.object_finder.len(), 1); @@ -730,6 +747,26 @@ mod tests { assert!(cov(&s, 100, 80) >= 0.5); } + #[test] + fn object_selection_on_an_empty_active_layer_says_so() { + // #5246: an empty layer above the photo used to find nothing without a word. + let (mut s, inside) = disc(8); + s.execute("layer.new.layer", json!({})).unwrap(); + for p in [json!({"rect": [40, 20, 120, 120]}), json!({"point": [100, 80]})] { + let e = s.execute("select.object", p.clone()).unwrap_err().to_string(); + assert!(e.contains("active layer is empty") && e.contains("sampleAllLayers"), "{p}: {e}"); + assert!(s.active().unwrap().doc.selection.is_none()); + } + // Sampling all layers finds the disc below. + s.execute("select.object", json!({"rect": [40, 20, 120, 120], "sampleAllLayers": true})).unwrap(); + assert!(sel_iou(&s, 200, 160, &inside) > 0.95); + // A layer with pixels elsewhere: the rectangle holds none of them. + s.execute("select.deselect", json!({})).unwrap(); + s.execute("paint.pencil", json!({"points": [[5, 5], [10, 5]], "size": 2, "color": "#000000"})).unwrap(); + let e = s.execute("select.object", json!({"rect": [60, 60, 100, 80]})).unwrap_err().to_string(); + assert!(e.contains("no pixels in that area"), "{e}"); + } + #[test] fn select_subject_centred_object() { let (mut s, inside) = disc(8); @@ -970,7 +1007,6 @@ mod tests { fn refine_edge_works_on_layer_mask_without_selection() { let mut s = blurred_edge_selected(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); assert!(s.active().unwrap().doc.selection.is_none()); assert!(s.active().unwrap().doc.layers[0].mask.is_some()); assert!(s.is_enabled("select.refineEdge")); diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 3ab1ad73bf..ef0e72f5db 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -89,10 +89,21 @@ pub fn layer_rect(doc: &Document, id: LayerId) -> Option<[f64; 4]> { } fn rect_of(doc: &Document, l: &photocraft_doc::Layer) -> Option<[f64; 4]> { - if l.is_group() || (matches!(l.content, LayerContent::Adjustment(_)) && l.mask.is_none()) { - return None; - } - let b = crate::transform_cmds::transform_bounds(doc, l); + snappable(l).then(|| crate::transform_cmds::transform_bounds(doc, l)).and_then(to_rect) +} + +/// Bounds of a layer as a Snap To Layers target: all of its content, whatever the selection +/// holds of it (#6290: clipped to the selection, every layer's edges and centre were the +/// selection's, so a moved selection snapped back to where it started). +fn target_rect_of(l: &photocraft_doc::Layer) -> Option<[f64; 4]> { + snappable(l).then(|| crate::transform_cmds::whole_layer_bounds(l)).and_then(to_rect) +} + +fn snappable(l: &photocraft_doc::Layer) -> bool { + !(l.is_group() || (matches!(l.content, LayerContent::Adjustment(_)) && l.mask.is_none())) +} + +fn to_rect(b: photocraft_geom::Rect) -> Option<[f64; 4]> { (!b.is_empty()).then_some([b.x0 as f64, b.y0 as f64, b.x1 as f64, b.y1 as f64]) } @@ -121,7 +132,7 @@ impl SnapTargets { if !l.visible || exclude.contains(&l.id) { continue; } - if let Some(r) = rect_of(doc, l) { + if let Some(r) = target_rect_of(l) { t.add_rect(r, SnapKind::LayerEdge, SnapKind::LayerCenter); } } @@ -365,6 +376,17 @@ mod tests { let t = SnapTargets::from_document(&doc, &SnapOptions { guides: false, ..opts }, &[id]); assert!(!t.x.iter().any(|t| t.kind == SnapKind::Guide || (t.kind == SnapKind::LayerEdge && t.pos == 30.0))); assert_eq!(layer_rect(&doc, id), Some([10.0, 20.0, 30.0, 60.0])); + // #6290: a selection doesn't move the layers' targets onto its own bounds. + let mut sel = photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8); + sel.fill_rect(photocraft_geom::Rect::new(64, 66, 124, 86), &[1.0]); + doc.selection = Some(sel); + let t = SnapTargets::from_document(&doc, &SnapOptions { selection: false, ..opts }, &[]); + assert!(t.x.iter().any(|t| t.kind == SnapKind::LayerEdge && t.pos == 30.0)); + assert!( + !t.x.iter() + .chain(&t.y) + .any(|t| matches!(t.kind, SnapKind::LayerEdge | SnapKind::LayerCenter) && [64.0, 66.0, 76.0, 86.0, 94.0, 124.0].contains(&t.pos)) + ); assert_eq!(union([[0.0, 0.0, 1.0, 1.0], [2.0, -1.0, 3.0, 0.5]]), Some([0.0, -1.0, 3.0, 1.0])); } } diff --git a/crates/engine/src/stamp_cmds.rs b/crates/engine/src/stamp_cmds.rs index a525d138b5..85ac4078c7 100644 --- a/crates/engine/src/stamp_cmds.rs +++ b/crates/engine/src/stamp_cmds.rs @@ -1,7 +1,8 @@ //! Stamping (#217): merge layers into a layer while keeping the originals. //! //! - **Stamp Visible** (⌘⌥⇧E, or ⌥ + Layer › Merge Visible): every visible layer composited into a -//! new layer above the active one. +//! new layer above the active one, or into the active layer when it is an empty pixel layer (as +//! in Photoshop, so a recorded New Layer + Stamp Visible leaves one layer, #6785). //! - **Stamp Down** (⌘⌥E, or ⌥ + Layer › Merge Down): a copy of the active layer merged into the //! pixel layer below it; with several layers selected, a copy of them merged into a new layer //! above the top one. @@ -65,6 +66,13 @@ fn stamp_visible(s: &mut Session, p: &Value) -> Result { if !doc.walk().iter().any(|(path, _, l)| !l.is_group() && (1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|a| a.visible))) { return Err(EngineError::Other("there are no visible layers to stamp".into())); } + // Like Photoshop, an empty pixel layer that is active (Layer > New > Layer, then Stamp + // Visible, as recorded actions do) takes the stamp itself instead of getting a second layer. + if let Some(empty) = (*active).filter(|id| is_empty_pixel_layer(doc, *id)) { + let surf = photocraft_compose::flatten_to_surface(doc, with_alpha(doc), None); + *crate::pixels_mut(doc.layer_mut(empty).ok_or(EngineError::NoLayer(empty))?)? = surf; + return Ok(empty); + } let layer = stamp_layer(doc, doc.next_layer_name("Layer"))?; let id = doc.insert_above(*active, layer); *active = Some(id); @@ -74,6 +82,18 @@ fn stamp_visible(s: &mut Session, p: &Value) -> Result { Ok(json!({ "layer": id.0 })) } +/// A plain, unlocked pixel layer with no pixels, mask or clipping: safe to stamp into. +fn is_empty_pixel_layer(doc: &Document, id: LayerId) -> bool { + let Some(l) = doc.layer(id) else { return false }; + let locks = doc.effective_locks(id); + matches!(&l.content, LayerContent::Raster(s) if s.content_bounds().is_empty()) + && l.visible + && l.mask.is_none() + && l.vector_mask.is_none() + && !l.clipped + && !(locks.all || locks.pixels || locks.transparency) +} + fn stamp_down(s: &mut Session, p: &Value) -> Result { object(p, "layer.stampDown")?; let sel = crate::layer_multi_cmds::selected(s); @@ -148,7 +168,7 @@ pub fn specs() -> Vec { label: "Stamp Visible", menu: &[], shortcut: Some("Cmd+Alt+Shift+E"), - params: r##"{} → {layer} (every visible layer merged into a new layer above the active one; the originals stay)"##, + params: r##"{} → {layer} (visible layers merged into a new layer above the active one, or into the active one if it is an empty pixel layer)"##, enabled: has_doc, journal: true, run: stamp_visible, @@ -235,6 +255,25 @@ mod tests { } } + /// Recorded actions do New Layer, then Stamp Visible: the stamp lands in the new empty layer. + #[test] + fn stamp_visible_fills_an_empty_active_layer_instead_of_adding_one() { + let (mut s, bg, hidden, blue) = session(8); + s.execute("layer.new.layer", json!({"name": "Empty"})).unwrap(); + let empty = s.active().unwrap().active_layer.unwrap(); + let r = s.execute("layer.stampVisible", json!({})).unwrap(); + assert_eq!(r["layer"].as_u64(), Some(empty.0)); + assert_eq!(ids(&s), [bg, hidden, blue, empty], "no extra layer"); + let st = s.active().unwrap(); + assert_eq!((st.active_layer, st.selected_layers.clone()), (Some(empty), vec![empty])); + assert!(close(rgba(&s, empty, 30, 30), [0.0, 0.0, 1.0, 1.0])); + assert!(close(rgba(&s, empty, 2, 2), [1.0, 0.0, 0.0, 1.0])); + // A second stamp: the layer now has pixels, so this one adds a layer. + let r2 = s.execute("layer.stampVisible", json!({})).unwrap(); + assert_ne!(r2["layer"].as_u64(), Some(empty.0)); + assert_eq!(ids(&s).len(), 5); + } + #[test] fn stamp_visible_respects_opacity_blend_and_groups() { let (mut s, bg, _, blue) = session(8); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 08e17bd39c..0849cd941a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -553,7 +553,7 @@ fn clipping_masks_apply_to_every_selected_layer() { // A `layer` param still acts on that layer alone. s.execute("layer.releaseClippingMask", json!({"layer": ids[2]})).unwrap(); assert_eq!(clipped(&s), [false, true, false]); - assert!(s.execute("layer.createClippingMask", json!({"layer": 999_999})).is_err()); + assert!(s.execute("layer.createClippingMask", json!({"layer": u64::MAX})).is_err()); } #[test] @@ -702,6 +702,38 @@ fn fill_layer_pixel_rewrites_refresh_the_effect_maps() { assert_eq!(after, fresh, "the effect maps track the cache surface, not the Fill spec alone"); } +#[test] +fn growing_the_canvas_re_renders_a_cached_fill_layer() { + // A PSD fill layer keeps its stored pixels as a fill cache covering only the old canvas. + // Canvas Size, an extending Crop and Reveal All must re-render the fill over the new area, + // as Photoshop does, instead of leaving the added bands transparent (#7362). + let grow: [(&str, Value); 2] = [ + ("image.canvasSize", json!({"width": 100, "anchor": "left"})), + ("image.crop", json!({"x": 0, "y": 0, "width": 100, "height": 48, "deleteCroppedPixels": false})), + ]; + for (cmd, params) in grow { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "background": "transparent"})).unwrap(); + s.execute("layer.newFillLayer.solidColor", json!({"color": "#ff0000"})).unwrap(); + s.edit("cache", |doc, active| { + let fmt = doc.pixel_format(); + let l = doc.layer_mut(active.unwrap()).unwrap(); + let photocraft_doc::LayerContent::Fill(f) = &l.content else { return Err(EngineError::Other("not a fill layer".into())) }; + let fill = f.clone(); + let mut surface = photocraft_raster::Surface::new(fmt); + surface.fill_rect(photocraft_geom::Rect::new(0, 0, 64, 48), &[1.0, 0.0, 0.0, 1.0]); + l.fill_cache = Some(photocraft_doc::FillCache { fill, surface }); + Ok(()) + }) + .unwrap(); + s.execute(cmd, params).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.size.width, 100, "{cmd} grows the canvas"); + let px = photocraft_compose::render(d, d.bounds()).get(90, 20); + assert!(px[3] > 0.99 && px[0] > 0.99 && px[1] < 0.01, "{cmd}: the fill covers the added area, got {px:?}"); + } +} + #[test] fn group_effect_maps_follow_child_visibility_and_opacity() { // A styled group's maps come from its children's composite: hiding a child or changing its @@ -767,6 +799,38 @@ fn command_list_reports_enablement() { assert_eq!(find("layer.new.layer")["enabled"], false); } +/// The Elliptical and Single Row/Column Marquee are `select.rect` options: a command-list filter +/// for "ellipse" or "marquee" finds it through its keywords (#5060). +#[test] +fn command_list_filter_finds_the_marquees_by_keyword() { + let mut s = Session::new(); + let list = s.execute("command.list", json!({})).unwrap(); + let rows = list.as_array().unwrap(); + let found = |needle: &str| -> Vec { + rows.iter().filter(|c| crate::commands::list_entry_matches(c, needle)).map(|c| c["id"].as_str().unwrap().to_string()).collect() + }; + for needle in ["ellipse", "Elliptical Marquee", "marquee", "single row", "SELECT.RECT"] { + assert!(found(needle).contains(&"select.rect".to_string()), "{needle}: {:?}", found(needle)); + } + let rect = rows.iter().find(|c| c["id"] == "select.rect").unwrap(); + assert!(rect["keywords"].as_str().is_some_and(|k| k.contains("Elliptical Marquee")), "{rect}"); + // Commands without keywords don't get an empty field, and the filter still reads id and label. + assert!(rows.iter().find(|c| c["id"] == "file.new").unwrap().get("keywords").is_none()); + assert_eq!(found("gaussian blur"), ["filter.blur.gaussianBlur"]); + assert!(found("no command is called this").is_empty()); +} + +/// Every keyword entry names a real command, so a renamed id can't leave one behind. +#[test] +fn search_keywords_name_real_commands() { + for (id, words) in crate::commands::SEARCH_KEYWORDS { + assert!(crate::commands::find(id).is_some(), "keywords for unknown command {id}"); + assert!(!words.trim().is_empty(), "{id}"); + } + assert!(crate::commands::search_keywords("select.rect").is_some()); + assert!(crate::commands::search_keywords("no.such.command").is_none()); +} + /// With no document open, a document command says so instead of only `enabled: false` (#3664). #[test] fn command_list_tells_no_document_from_other_preconditions() { @@ -992,8 +1056,8 @@ fn move_to_copy_rejects_bad_params() { for p in [ json!({"layer": a, "copy": true}), json!({"layer": a, "target": "x", "copy": true}), - json!({"layer": a, "target": 9_999_999, "copy": true}), - json!({"layer": 9_999_999, "target": a, "copy": true}), + json!({"layer": a, "target": u64::MAX, "copy": true}), + json!({"layer": u64::MAX, "target": a, "copy": true}), json!({"layer": a, "target": a, "position": "into", "copy": true}), ] { assert!(s.execute("layer.moveTo", p.clone()).is_err(), "{p}"); @@ -1123,6 +1187,47 @@ fn coalesced_edits_share_one_history_step() { assert!((l - 1.0).abs() < 1e-6, "{l}"); } +/// #2259: the commands behind the arrow-key nudges take the `coalesce` key, so a run of them is +/// one step an agent can drive too, and one Undo takes the whole run back. +#[test] +fn translate_and_transform_selection_coalesce_into_one_step() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 60, "height": 60})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let steps = |s: &Session| s.active().unwrap().history.past_len(); + let left = |s: &Session| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds().x0 + }; + let sel = |s: &Session| s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds().x0; + + let base = steps(&s); + for _ in 0..5 { + s.execute("layer.translate", json!({"dx": 1, "dy": 0, "coalesce": "nudge:1"})).unwrap(); + } + assert_eq!((steps(&s), left(&s)), (base + 1, 15), "five nudges, one step"); + s.execute("layer.translate", json!({"dx": 1, "dy": 0, "coalesce": "nudge:2"})).unwrap(); + s.execute("layer.translate", json!({"dx": 1, "dy": 0})).unwrap(); + s.execute("layer.translate", json!({"dx": 1, "dy": 0, "coalesce": "nudge:2"})).unwrap(); + assert_eq!(steps(&s), base + 4, "another key, an uncoalesced move and a repeated key each start a step"); + for _ in 0..3 { + assert!(s.undo()); + } + assert_eq!(left(&s), 15); + assert!(s.undo()); + assert_eq!(left(&s), 10, "Undo takes the run of five back at once"); + + let base = steps(&s); + for _ in 0..4 { + s.execute("select.transformSelection", json!({"dx": 1, "dy": 0, "coalesce": "nudge:3"})).unwrap(); + } + assert_eq!((steps(&s), sel(&s)), (base + 1, 14)); + assert!(s.undo()); + assert_eq!(sel(&s), 10); +} + #[test] fn type_edit_rerenders_cache_to_new_text() { let mut s = Session::new(); diff --git a/crates/engine/src/timeline_cmds.rs b/crates/engine/src/timeline_cmds.rs index 197c8e9ccc..716fc8b4c8 100644 --- a/crates/engine/src/timeline_cmds.rs +++ b/crates/engine/src/timeline_cmds.rs @@ -12,7 +12,7 @@ use crate::{EngineError, Result, Session}; /// Longest timeline, in frames: every frame becomes a surface in a new video layer and a file in /// Render Video, so `duration` is capped well before it can exhaust memory or overflow (#1009). -const MAX_DURATION: usize = 1_000_000; +pub(crate) const MAX_DURATION: usize = 1_000_000; /// Highest frame rate; larger values only come from mistakes and become `inf` as `f32` (#1009). const MAX_FPS: f32 = 1000.0; @@ -59,7 +59,7 @@ fn duration_param(cmd: &str, p: &Value) -> Result> { } /// The optional `fps` param, rejected unless it's a finite number in `(0, MAX_FPS]` (#1009). -fn fps_param(cmd: &str, p: &Value) -> Result> { +pub(crate) fn fps_param(cmd: &str, p: &Value) -> Result> { let Some(v) = p.get("fps").filter(|v| !v.is_null()) else { return Ok(None) }; v.as_f64() .filter(|f| f.is_finite() && *f > 0.0 && *f <= f64::from(MAX_FPS)) diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index bcf97126cc..2452e7443b 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -1,6 +1,6 @@ //! Edit › Free Transform / Transform: projective transforms of layers (and the selection). -use photocraft_algo::transform::{Homography, Interp, warp_output_bounds, warp_surface, warp_surface_with}; +use photocraft_algo::transform::{Homography, Interp, warp_output_size, warp_surface, warp_surface_with}; use photocraft_color::PixelFormat; use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks}; use photocraft_geom::{Affine, Rect}; @@ -36,8 +36,25 @@ pub fn transform_bounds(doc: &Document, layer: &Layer) -> Rect { } } +/// The box Free Transform opens on `layer`, and the Move tool's transform controls show (#5297). +/// With a selection that limits the transform it is the selection's own bounds, as in Photoshop: +/// the box and the marching ants are one rectangle, however little of the selection the layer +/// fills. Empty, like [`transform_bounds`], when the selection holds none of the layer. +pub fn transform_frame(doc: &Document, layer: &Layer) -> Rect { + let b = transform_bounds(doc, layer); + if moves_whole(layer) { b } else { framed_by_selection(doc, b) } +} + +/// `bounds` (what a selection left of a transform's content), or the selection's own bounds. +fn framed_by_selection(doc: &Document, bounds: Rect) -> Rect { + match &doc.selection { + Some(sel) if !bounds.is_empty() => sel.content_bounds(), + _ => bounds, + } +} + /// A group moves all its descendants, so its frame must not clip raster children to the selection. -fn whole_layer_bounds(layer: &Layer) -> Rect { +pub(crate) fn whole_layer_bounds(layer: &Layer) -> Rect { let content = match &layer.content { LayerContent::Group(g) => g.children.iter().map(whole_layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)), _ => layer.surface().map_or(Rect::EMPTY, photocraft_compose::bounds::content_bounds), @@ -199,6 +216,12 @@ pub fn target_bounds(doc: &Document, surf: &Surface) -> Rect { } } +/// The box Free Transform opens on a lone target: like [`transform_frame`], the selection's own +/// bounds when there is one (#5297). +pub fn target_frame(doc: &Document, surf: &Surface) -> Rect { + framed_by_selection(doc, target_bounds(doc, surf)) +} + /// The locks `id` inherits from the groups around it. pub(crate) fn group_locks(doc: &Document, id: LayerId) -> Locks { doc.path_of(id).and_then(|p| Some(doc.locks_at(p.split_last()?.1))).unwrap_or_default() @@ -250,10 +273,13 @@ const BUDGET: u64 = 1 << 20; /// Refuse a warp of the content inside `src` by `h` whose result would exceed the pixel budget, /// before anything is built. `what` names the surface in the message. pub(crate) fn check_budget(src: Rect, h: &Homography, what: &str) -> Result<()> { - let Some(b) = warp_output_bounds(src, h) else { return Ok(()) }; - let (w, ht) = (u64::from(b.width()), u64::from(b.height())); - if w.saturating_mul(ht) > BUDGET { - let detail = format!("the transformed {what} would be {w} x {ht} px, more than the {BUDGET} px limit"); + check_budget_with_limit(src, h, what, BUDGET) +} + +fn check_budget_with_limit(src: Rect, h: &Homography, what: &str, budget: u64) -> Result<()> { + let Some((w, ht)) = warp_output_size(src, h) else { return Ok(()) }; + if w.saturating_mul(ht) > budget { + let detail = format!("the transformed {what} would be {w} x {ht} px, more than the {budget} px limit"); return Err(crate::allocation::error("transforming pixels", &detail)); } Ok(()) @@ -282,7 +308,12 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R } _ => { if let Some(surf) = l.surface() { - check_budget(surf.content_bounds(), h, &format!("layer \"{}\"", l.name))?; + // With a selection only the lifted pixels are warped (`split_selected`). + let src = match sel { + Some(sel) => surf.content_bounds().intersect(&sel.content_bounds()), + None => surf.content_bounds(), + }; + check_budget(src, h, &format!("layer \"{}\"", l.name))?; } } } @@ -347,6 +378,9 @@ fn apply_transform( // With a selection only the selected pixels move, and so only the same region of a linked // mask (#205). Groups, type, shapes and smart objects move whole. let mask_sel = doc_sel.filter(|_| !moves_whole(l)); + // The masks (and so a fill's frame) move, but a fill layer's cached render isn't warped: + // drop it, the fill re-renders from its model (as `transform_layer_geometry` does). + l.fill_cache = None; match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { @@ -383,17 +417,20 @@ fn apply_transform( } _ => { if let Some(surf) = l.surface_mut() { - let src = surf.content_bounds(); *surf = match doc_sel { - // Only the selected pixels move: lift them, clear them, warp and paste back. + // Only the selected pixels move: lift them, clear them, warp and paste back. The + // lifted pixels' own bounds are the edge a stretch repeats (#3773). Some(sel) => { let (lifted, mut rest) = split_selected(surf, sel)?; - let moved = warp_surface_with(&lifted, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?; + let moved = warp_surface_with(&lifted, lifted.content_bounds(), h, interp, ctl)?.ok_or(EngineError::Cancelled)?; composite_over(&mut rest, &moved); rest.prune(); rest } - None => warp_surface_with(surf, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?, + None => { + let src = surf.content_bounds(); + warp_surface_with(surf, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)? + } }; } } @@ -445,6 +482,11 @@ pub fn split_selected(surf: &Surface, sel: &Surface) -> Result<(Surface, Surface let k = sel.sample_channel(x, y, 0); l[n - 1] *= k; r[n - 1] *= 1.0 - k; + // Likewise an unselected pixel leaves nothing in the lifted surface, so its bounds are the + // selected pixels' (what a stretch repeats at the edge, #3773). + if l[n - 1] <= 0.0 { + l.fill(0.0); + } if r[n - 1] <= 0.0 { r.iter_mut().zip(&rest_clear).for_each(|(d, c)| *d = *c); } @@ -699,6 +741,24 @@ mod tests { // ---- Pixel budget (#3773). Tests run with a 2²⁰ px budget, so nothing here comes near the real one. ---- + #[test] + fn huge_transform_scales_exceed_the_production_budget_before_clipping() { + let src = Rect::new(0, 0, 24, 16); + for scale in [1e7, 1e28, 1e306] { + for (sx, sy) in [(scale, 1.0), (1.0, scale), (-scale, 1.0)] { + let h = Homography([sx, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 1.0]); + assert!(check_budget_with_limit(src, &h, "layer", MAX_TRANSFORM_PIXELS).is_err(), "{sx} x {sy} must be refused"); + } + } + } + + #[test] + fn an_ordinary_transform_remains_within_the_production_budget() { + let src = Rect::new(0, 0, 24, 16); + let h = Homography([10_000.0, 0.0, 900_000.0, 0.0, 1.0, -900_000.0, 0.0, 0.0, 1.0]); + assert!(check_budget_with_limit(src, &h, "layer", MAX_TRANSFORM_PIXELS).is_ok()); + } + /// A 16 x 16 document whose active layer is a 1 x 8 column at x = 10. fn column() -> Session { let mut s = Session::new(); @@ -935,6 +995,57 @@ mod tests { assert!(!active_bounds(&s).is_empty()); } + /// A gradient fill layer whose mask (a selection when it was made) frames the gradient over + /// 10..40, with the cached render a PSD import gives it: exactly the pixels the fill renders. + fn cached_gradient_fill() -> Session { + let mut s = session(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + s.execute("layer.newFillLayer.gradient", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.edit("cache", |doc, active| { + let (fmt, canvas) = (doc.pixel_format(), doc.bounds()); + let l = doc.layer_mut(active.unwrap()).unwrap(); + let LayerContent::Fill(f) = &l.content else { panic!("a fill layer") }; + let buf = photocraft_compose::render_fill_content(l, f, canvas, &[]); + let mut surface = Surface::new(fmt); + let vals: Vec = buf.px.iter().flat_map(|p| photocraft_raster::from_rgba(&fmt, *p)).collect(); + surface.write_region(canvas, &vals); + l.fill_cache = Some(photocraft_doc::FillCache { fill: f.clone(), surface }); + Ok(()) + }) + .unwrap(); + s + } + + /// How far the composite is from the composite with the fill re-rendered from its model + /// (cache dropped): the largest channel difference. + fn off_from_fresh(s: &Session) -> f32 { + let st = s.active().unwrap(); + let mut fresh: Document = (*st.doc).clone(); + fresh.layer_mut(st.active_layer.unwrap()).unwrap().fill_cache = None; + let (a, b) = (photocraft_compose::render(&st.doc, st.doc.bounds()).px, photocraft_compose::render(&fresh, fresh.bounds()).px); + a.iter().zip(&b).flat_map(|(p, q)| p.iter().zip(q).map(|(x, y)| (x - y).abs())).fold(0.0, f32::max) + } + + #[test] + fn transforming_a_fill_layer_drops_its_stale_cached_render() { + let mut s = cached_gradient_fill(); + let off = off_from_fresh(&s); + assert!(off < 0.01, "the cache is the fill's own render: {off}"); + // The mask (and so the gradient's frame) moves 50 px right and doubles in height. + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 2, 50, 0]})).unwrap(); + let off = off_from_fresh(&s); + assert!(off < 0.01, "the composite shows the fill laid out in its new frame, not the old render: {off}"); + } + + #[test] + fn warping_a_fill_layer_drops_its_stale_cached_render() { + let mut s = cached_gradient_fill(); + s.execute("edit.transform.warp", json!({"style": "arc", "bend": 50})).unwrap(); + let off = off_from_fresh(&s); + assert!(off < 0.01, "the composite shows the fill laid out in its new frame, not the old render: {off}"); + } + #[test] fn type_moves_with_the_move_command() { let mut s = session(); @@ -1151,6 +1262,32 @@ mod tests { assert_eq!(transform_bounds(&st.doc, st.doc.layer(st.active_layer.unwrap()).unwrap()), Rect::new(10, 10, 15, 20)); } + /// #5297: the box Free Transform opens with a selection is the selection's own rectangle, + /// not the part of it the layer fills; type still moves whole, and a selection that holds none + /// of the layer leaves nothing to transform. + #[test] + fn the_frame_is_the_selections_own_bounds() { + let mut s = session(); + let frame = |s: &Session| { + let st = s.active().unwrap(); + let l = st.doc.layer(st.active_layer.unwrap()).unwrap(); + (transform_frame(&st.doc, l), transform_bounds(&st.doc, l)) + }; + let content = Rect::new(10, 10, 30, 20); + assert_eq!(frame(&s), (content, content), "no selection: the content"); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 15, "height": 40})).unwrap(); + assert_eq!(frame(&s), (Rect::new(5, 5, 20, 45), Rect::new(10, 10, 20, 20))); + s.execute("select.rect", json!({"x": 60, "y": 60, "width": 10, "height": 10})).unwrap(); + assert!(frame(&s).0.is_empty(), "nothing of the layer is selected"); + + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 120})).unwrap(); + s.execute("type.create", json!({"x": 20, "y": 60, "text": "Hello", "size": 36})).unwrap(); + let whole = active_bounds(&s); + s.execute("select.rect", json!({"x": whole.x0, "y": whole.y0, "width": whole.width() / 2, "height": whole.height()})).unwrap(); + assert_eq!(frame(&s), (whole, whole), "type moves whole"); + } + #[test] fn group_transform_ignores_selections_inside_its_children() { group_transform_with_selection(Rect::new(10, 10, 15, 15)); @@ -1249,6 +1386,93 @@ mod tests { assert!(s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})).is_err(), "locked layer refuses identity transform"); } + /// A 1 x 8 px column on a 256 x 256 canvas (#3773). + fn column_session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 256, "height": 256})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let l = doc.layer_mut(active.unwrap()).unwrap(); + l.surface_mut().unwrap().fill_rect(Rect::new(100, 100, 101, 108), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s + } + + fn opaque_green(s: &Session, x: i32, y: i32) -> bool { + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + (surf.sample_channel(x, y, 1) - 1.0).abs() < 1e-3 && (surf.sample_channel(x, y, 3) - 1.0).abs() < 1e-3 + } + + /// The "pixel stretch" (#3773): scales far past 1000% are taken, and the stretched column stays + /// solid along its whole length. + #[test] + fn a_one_pixel_column_stretches_5000_percent() { + let mut s = column_session(); + s.execute("edit.transform", json!({"quad": [[100, 100], [150, 100], [150, 108], [100, 108]]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(100, 100, 150, 108)); + for x in [100, 101, 125, 149] { + for y in 100..108 { + assert!(opaque_green(&s, x, y), "({x}, {y})"); + } + } + } + + /// The technique as done in Photoshop: select a 1 px column of an image and stretch it across. + #[test] + fn a_selected_column_stretches_solid_over_the_image() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 256, "height": 256})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + surf.fill_rect(Rect::new(0, 0, 256, 256), &[1.0, 0.0, 0.0, 1.0]); + surf.fill_rect(Rect::new(10, 0, 11, 256), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 0, "width": 1, "height": 256})).unwrap(); + s.execute("edit.transform", json!({"quad": [[10, 0], [250, 0], [250, 256], [10, 256]]})).unwrap(); + for x in [10, 11, 100, 200, 249] { + assert!(opaque_green(&s, x, 128), "x = {x}"); + } + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + assert!(surf.sample_channel(250, 128, 0) > 0.999 && surf.sample_channel(9, 128, 0) > 0.999, "the image either side is untouched"); + } + + /// Free Transform keeps pixels moved off the canvas, as Photoshop does: an 8 x 8 layer moved + /// three canvas widths right and back comes back whole. + #[test] + fn a_layer_moved_far_off_the_canvas_and_back_keeps_its_pixels() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + for y in 0..8 { + for x in 0..8 { + surf.fill_rect(Rect::new(20 + x, 20 + y, 21 + x, 21 + y), &[x as f32 / 8.0, y as f32 / 8.0, 0.5, 1.0]); + } + } + Ok(()) + }) + .unwrap(); + let pixels = |s: &Session| { + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + (0..8 * 8 * 4).map(|i| surf.sample_channel(20 + i / 4 % 8, 20 + i / 32, (i % 4) as usize)).collect::>() + }; + let before = pixels(&s); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 192, 0]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(212, 20, 220, 28)); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, -192, 0]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(20, 20, 28, 28)); + assert_eq!(pixels(&s), before); + } + #[test] fn relative_params_scale_and_rotate_about_the_frame_centre() { let mut s = Session::new(); diff --git a/crates/engine/src/trap_cmds.rs b/crates/engine/src/trap_cmds.rs index 03d899472c..6bd99200ac 100644 --- a/crates/engine/src/trap_cmds.rs +++ b/crates/engine/src/trap_cmds.rs @@ -24,6 +24,7 @@ fn trap(s: &mut Session, p: &Value) -> Result { } s.edit("Trap", |doc, active| { let id = active.ok_or(EngineError::NoDocument)?; + crate::commands::check_pixels_unlocked(doc, id)?; let bounds = doc.bounds(); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let LayerContent::Raster(surf) = &mut l.content else { @@ -63,11 +64,8 @@ mod tests { use super::*; use photocraft_geom::Rect; - #[test] - fn traps_a_cmyk_document() { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 8, "height": 4, "mode": "cmyk"})).unwrap(); - // Paint cyan | magenta halves on the Background. + /// Paints cyan | magenta halves on the active layer. + fn paint_halves(s: &mut Session) { s.edit("paint", |doc, active| { let id = active.unwrap(); let fmt = doc.pixel_format(); @@ -93,6 +91,13 @@ mod tests { Ok(()) }) .unwrap(); + } + + #[test] + fn traps_a_cmyk_document() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 4, "mode": "cmyk"})).unwrap(); + paint_halves(&mut s); let r = s.execute("image.trap", json!({"width": 1})).unwrap(); assert_eq!(r["trapped"], true); @@ -109,6 +114,35 @@ mod tests { assert_eq!(st.history.entries().last().map(|e| e.as_str()), Some("Trap")); } + #[test] + fn refuses_locked_layers() { + // Lock All or the pixel lock, on the layer itself or on its group: Trap refuses, leaving the + // pixels and the history as they were. + for lock in [json!({"all": true}), json!({"pixels": true})] { + for on_group in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 4, "mode": "cmyk"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint_halves(&mut s); + let layer = s.active().unwrap().active_layer.unwrap(); + if on_group { + s.execute("layer.new.groupFromLayers", json!({})).unwrap(); + } + s.execute("layer.lockLayers", lock.clone()).unwrap(); + s.execute("layer.select", json!({"layer": layer.0})).unwrap(); + let locks = s.active().unwrap().doc.effective_locks(layer); + assert!(locks.all || locks.pixels, "the lock reaches the layer ({lock}, group: {on_group})"); + let steps = s.active().unwrap().history.past_len(); + let before = s.active().unwrap().doc.layer(layer).unwrap().surface().cloned(); + let r = s.execute("image.trap", json!({"width": 1})); + assert!(r.is_err(), "trapped a layer locked with {lock} (group: {on_group}): {r:?}"); + let st = s.active().unwrap(); + assert_eq!(st.doc.layer(layer).unwrap().surface().cloned(), before, "{lock} group: {on_group}"); + assert_eq!(st.history.past_len(), steps, "{lock} group: {on_group}: no history step"); + } + } + } + #[test] fn rejects_non_cmyk() { let mut s = Session::new(); diff --git a/crates/engine/src/type_caret_cmds.rs b/crates/engine/src/type_caret_cmds.rs index fbc9322c62..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": 9_999_999, "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/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index 4bf193b679..439d1f008b 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -34,14 +34,14 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { } fn f32p(p: &Value, k: &str) -> Option { - p.get(k).and_then(Value::as_f64).map(|v| v as f32) + p.get(k).and_then(Value::as_f64).map(|v| v as f32).filter(|v| v.is_finite()) } fn color(v: &Value) -> Option { match v { Value::Array(a) if a.len() >= 3 => { let c: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect(); - Some(Color::rgba(c[0], c[1], c[2], c.get(3).copied().unwrap_or(1.0))) + c.iter().take(4).all(|v| v.is_finite()).then(|| Color::rgba(c[0], c[1], c[2], c.get(3).copied().unwrap_or(1.0))) } Value::String(s) => { let s = s.trim_start_matches('#'); @@ -89,6 +89,8 @@ pub const TYPE_SIZE_MIN_PT: f64 = 0.1; pub const TYPE_SIZE_MAX_PT: f64 = 1296.0; pub const TRACKING_MIN: f64 = -1000.0; pub const TRACKING_MAX: f64 = 10_000.0; +/// Photoshop's horizontal and vertical scale range, in percent. +pub const SCALE_MAX_PERCENT: f64 = 1000.0; /// Validates the `kerning` key of character params: a number in [`KERN_MIN`]..=[`KERN_MAX`] or /// `"metrics"`, `"optical"`, `"off"` (`"none"`, `"0"`). @@ -104,7 +106,8 @@ pub fn check_kerning(p: &Value) -> std::result::Result<(), String> { } } -/// Validates text metrics that can otherwise exceed the rasterizer's supported range. +/// Validates text metrics (size, tracking, leading, baseline shift, scales) that can otherwise +/// exceed the rasterizer's supported range or overflow `f32`, which a .pcraft manifest cannot hold. pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> { if let Some(v) = p.get("size") { match v.as_f64() { @@ -118,6 +121,30 @@ pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> { _ => return Err(format!("tracking must be between {TRACKING_MIN} and {TRACKING_MAX} (1/1000 em)")), } } + if let Some(v) = p.get("leading") { + let (min, max) = (f64::from(LEADING_MIN_PT), f64::from(LEADING_MAX_PT)); + match v { + Value::Null => {} + Value::String(a) if a == "auto" => {} + Value::Number(n) if n.as_f64().is_some_and(|x| x.is_finite() && (min..=max).contains(&x)) => {} + _ => return Err(format!("leading must be between {min} and {max} pt, or \"auto\"")), + } + } + if let Some(v) = p.get("baselineShift") { + let max = f64::from(BASELINE_SHIFT_MAX_PT); + match v.as_f64() { + Some(shift) if shift.is_finite() && shift.abs() <= max => {} + _ => return Err(format!("baselineShift must be between -{max} and {max} pt")), + } + } + for key in ["horizontalScale", "verticalScale"] { + if let Some(v) = p.get(key) { + match v.as_f64() { + Some(scale) if scale.is_finite() && (0.0..=SCALE_MAX_PERCENT).contains(&scale) => {} + _ => return Err(format!("{key} must be between 0 and {SCALE_MAX_PERCENT} %")), + } + } + } Ok(()) } @@ -477,8 +504,9 @@ pub fn style_paragraphs(t: &mut TextLayer, a: usize, b: usize, f: &dyn Fn(&mut P t.paragraphs = out.into_iter().map(|(len, style)| ParagraphRun { len, style }).collect(); } -/// Replaces bytes `a..b` with `new`, keeping styles: inserted text takes the style of the -/// character before it (or after it at the start). +/// Replaces bytes `a..b` with `new`, keeping styles: replacing a selection keeps the style of its +/// first character (as Photoshop does, so retyping a smaller second line stays small); inserting +/// at a caret takes the style of the character before it (or after it at the start). pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { fn adjust(runs: Vec<(usize, S)>, a: usize, b: usize, ins: usize, trailing_paragraph: bool) -> Vec<(usize, S)> { let mut out: Vec<(usize, S)> = Vec::new(); @@ -494,7 +522,15 @@ pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { *acc += r.0; Some((s, s + r.0)) }) - .position(|(s, e)| if a == 0 { e > 0 || s == 0 } else { a > s && a <= e }) + .position(|(s, e)| { + if a == 0 { + e > 0 || s == 0 + } else if a < b { + a >= s && a < e + } else { + a > s && a <= e + } + }) .unwrap_or(0) }; for (i, (len, st)) in runs.into_iter().enumerate() { @@ -628,6 +664,10 @@ pub(crate) fn with_text_targets( mut f: impl FnMut(&mut TextLayer, (usize, usize)) -> Result<()>, ) -> Result> { let ids = format_targets(s, p)?; + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + for id in &ids { + check_text_unlocked(doc, *id)?; + } let damage = s.edit(label, |doc, _| { let snapshot = doc.clone(); let mut damage = Some(photocraft_geom::Rect::EMPTY); @@ -743,6 +783,7 @@ fn info(s: &Session, p: &Value) -> Result { /// paragraph `box`, `orientation`, character and paragraph keys, starting from the default type /// styles in the foreground colour. Shared with the Type Mask tools' `type.createSelection`. pub(crate) fn new_text_layer(s: &Session, p: &Value, cmd: &str) -> Result { + crate::commands::check_f32_values(cmd, p)?; let orientation = match p.get("orientation") { None => Orientation::Horizontal, Some(Value::String(value)) if value == "horizontal" => Orientation::Horizontal, @@ -1184,7 +1225,7 @@ mod tests { st.saved_revision = st.revision; } let rev = s.active().unwrap().revision; - let unknown = [(doc_id, LayerId(9999)), (photocraft_doc::DocId(u64::MAX), id)]; + let unknown = [(doc_id, LayerId(u64::MAX)), (photocraft_doc::DocId(u64::MAX), id)]; assert!(s.refresh_type_layers(&[&[(doc_id, id)][..], &unknown].concat()).is_empty()); assert!(text_layer(&s, id.0).cache.is_some()); let st = s.active().unwrap(); @@ -1384,6 +1425,35 @@ mod tests { assert_eq!(s.active().unwrap().history.past_len(), history); } + #[test] + fn all_locked_type_layer_rejects_formatting_without_history() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 20, "y": 20, "text": "a", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + let other = s.execute("type.create", json!({"x": 20, "y": 60, "text": "b", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": id, "locked": true})).unwrap(); + let before = text_layer(&s, id); + let history = s.active().unwrap().history.past_len(); + + for (cmd, p) in [ + ("type.setStyle", json!({"layer": id, "size": 40, "fauxBold": true})), + ("type.setStyle", json!({"layers": [other, id], "size": 40})), + ("type.warpText", json!({"layer": id, "style": "arc"})), + ("type.antiAlias.none", json!({"layer": id})), + ("type.orientation.vertical", json!({"layer": id})), + ("type.openType.swash", json!({"layer": id})), + ] { + let result = s.execute(cmd, p); + assert!(matches!(&result, Err(EngineError::Other(message)) if message.contains("locked")), "{cmd}: {result:?}"); + } + assert_eq!(text_layer(&s, id), before); + assert_eq!(text_layer(&s, other).size_pt, 12.0); + assert_eq!(s.active().unwrap().history.past_len(), history); + + s.execute("layer.setProps", json!({"layer": id, "locked": false})).unwrap(); + s.execute("type.setStyle", json!({"layer": id, "size": 40})).unwrap(); + assert_eq!(text_layer(&s, id).size_pt, 40.0); + } + #[test] fn trailing_empty_paragraph_styles_without_changing_previous_lines() { let mut s = session(); @@ -1519,6 +1589,25 @@ mod tests { assert_eq!(t.runs[0].style.size_pt, 40.0); } + /// Replacing a selected smaller second line (by typing or pasting) keeps its size, not the + /// first line's, whose line break precedes the selection. + #[test] + fn replacing_a_selection_keeps_its_first_characters_style() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 5, "y": 50, "text": "BIG\nsmall", "size": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.edit", json!({"layer": id, "runs": [{"start": 4, "end": 9, "size": 12}]})).unwrap(); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 4, "end": 9, "text": "AIR"}})).unwrap(); + let sizes = |s: &Session| text_layer(s, id).char_runs().iter().map(|r| (r.len, r.style.size_pt)).collect::>(); + assert_eq!(text_layer(&s, id).text, "BIG\nAIR"); + assert_eq!(sizes(&s), [(4, 40.0), (3, 12.0)]); + // A mixed selection takes its first character's style. + s.execute("type.edit", json!({"layer": id, "replace": {"start": 2, "end": 6, "text": "xy"}})).unwrap(); + assert_eq!(sizes(&s), [(4, 40.0), (1, 12.0)]); + // Inserting at a caret still follows the character before it. + s.execute("type.edit", json!({"layer": id, "replace": {"start": 4, "end": 4, "text": "!"}})).unwrap(); + assert_eq!(sizes(&s), [(5, 40.0), (1, 12.0)]); + } + fn kerning_of(s: &Session, id: u64) -> Vec<(Kerning, f32)> { let t = text_layer(s, id); let mut out = Vec::new(); diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index efa2d791cc..91d75f033e 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -13,7 +13,7 @@ use photocraft_geom::Point; use photocraft_text::render::PathEl; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, layer_param}; +use crate::commands::{CommandSpec, explicit_layer, layer_param}; use crate::type_cmds::{ check_text_unlocked, format_targets, has_format_target, is_auto_named, layer_name, refresh, replace_text, style_paragraphs, style_range, with_text_targets, }; @@ -477,7 +477,10 @@ fn toggle_opentype(s: &mut Session, p: &Value, tag: &'static str) -> Result Result { - if has_text(s).is_ok() && p.get("new").and_then(Value::as_bool) != Some(true) { + // Into the layer `with_text` edits: an explicit "layer" (an error unless it is a type layer), + // else the active layer when it is a type layer. + let new = p.get("new").and_then(Value::as_bool) == Some(true); + if !new && (explicit_layer(s, p)?.is_some() || has_text(s).is_ok()) { let caret = with_text(s, p, "Paste Lorem Ipsum", |t, _| { let at = p.get("at").and_then(Value::as_u64).map_or(t.text.len(), |c| byte_index(&t.text, c as usize)); replace_text(t, at, at, LOREM_IPSUM); diff --git a/crates/engine/src/type_extra_cmds/tests.rs b/crates/engine/src/type_extra_cmds/tests.rs index 9f604eed1b..687a56471a 100644 --- a/crates/engine/src/type_extra_cmds/tests.rs +++ b/crates/engine/src/type_extra_cmds/tests.rs @@ -108,8 +108,9 @@ fn vertical_orientation_renders_columns_inside_the_canvas() { assert!(l.vertical && l.lines.len() == 2); let [x0, y0, x1, y1] = l.bounds().unwrap(); assert!(x1 > 0.0 && x0 < -40.0 && y0 >= -1e-3 && y1 > 60.0, "{:?}", l.bounds()); - // Bad layer ids fail cleanly. - assert!(s.execute("type.orientation.vertical", json!({"layer": 9999})).is_err()); + // Bad layer ids fail cleanly. Not a small number like 9999: ids come from one process-wide + // counter, so in a full test run a layer made above can get it (#3343). + assert!(s.execute("type.orientation.vertical", json!({"layer": u64::MAX})).is_err()); } #[test] @@ -299,7 +300,7 @@ fn type_attaches_to_a_path_and_releases_it() { .unwrap(); assert!(s.execute("type.attachToPath", json!({"subpath": 5})).is_err()); assert!(s.execute("type.attachToPath", json!({"start": "far"})).is_err()); - assert!(s.execute("type.attachToPath", json!({"shape": 9999})).is_err()); + assert!(s.execute("type.attachToPath", json!({"shape": u64::MAX})).is_err()); s.execute("type.attachToPath", json!({"start": 4})).unwrap(); assert!(matches!(text(&s, id).shape, TextShape::Path { start, .. } if start == 4.0)); let on_path = ink(&s, id); @@ -328,6 +329,27 @@ fn all_locked_layer_rejects_paste_lorem_ipsum_without_history() { } } +#[test] +fn paste_lorem_ipsum_inserts_into_the_layer_param_not_the_active_layer() { + // #5659: with a pixel layer active, an explicit type "layer" got a new text box instead. + let mut s = session(8); + let id = text_layer(&mut s, "Hi"); + s.execute("layer.new.layer", json!({})).unwrap(); + let pixel = s.active().unwrap().active_layer.unwrap(); + assert_ne!(pixel, id); + let count = s.active().unwrap().doc.walk().len(); + let r = s.execute("type.pasteLoremIpsum", json!({"layer": id.0, "at": 0})).unwrap(); + assert!(text(&s, id).text.starts_with(LOREM_IPSUM), "{}", text(&s, id).text); + assert!(text(&s, id).text.ends_with("Hi")); + assert_eq!(r["caret"], LOREM_IPSUM.chars().count()); + assert_eq!(s.active().unwrap().doc.walk().len(), count); + // An explicit layer that is not a type layer is an error, not a new layer. + let history = s.active().unwrap().history.past_len(); + assert!(s.execute("type.pasteLoremIpsum", json!({"layer": pixel.0})).is_err()); + assert_eq!(s.active().unwrap().doc.walk().len(), count); + assert_eq!(s.active().unwrap().history.past_len(), history); +} + /// A type layer stored with a family label that is not installed, as a PSD keeps the family it /// guessed from a PostScript name whose face was absent when the file was opened. fn guessed_family_layer(s: &mut Session, text: &str, ps: Option<&str>) -> (LayerId, Value) { @@ -398,3 +420,21 @@ fn exact_postscript_face_outside_the_guessed_family_is_not_missing() { assert!(!fonts.has_family(&photocraft_text::fonts::guess_from_postscript("UnrelatedPS30").family)); assert_eq!((style_font_missing(&mut fonts, &style("UnrelatedPS30")), style_font_missing(&mut fonts, &style("UnrelatedPS31"))), (false, true)); } + +/// #5044: PostScript names whose family has its space elsewhere (`BMDoHyeon`, family +/// "BM DoHyeon") or an underscore style suffix (`Daum_SemiBold`). Guessing splits them as +/// "BM Do Hyeon" and "Daum_Semi Bold", neither installed; the exact installed face resolves them. +#[test] +fn camel_case_and_underscore_postscript_names_resolve_to_their_installed_faces() { + let mut fonts = photocraft_text::FontDb::new(); + fonts.register_font_data(renamed_face(&photocraft_text::fonts::INTER_REGULAR, "BM DoHyeon", "BMDoHyeon")); + fonts.register_font_data(renamed_face(&photocraft_text::fonts::INTER_REGULAR, "Daum", "Daum_SemiBold")); + for (ps, family) in [("BMDoHyeon", "BM DoHyeon"), ("Daum_SemiBold", "Daum")] { + let guess = photocraft_text::fonts::guess_from_postscript(ps).family; + assert!(!fonts.has_family(&guess), "{ps}: the guess {guess:?} is not an installed family"); + let r = fonts.resolve_postscript(ps); + assert_eq!((r.family.as_str(), r.exact), (family, true), "{ps}"); + let style = photocraft_doc::text::CharStyle { font_family: guess.clone(), postscript_name: Some(ps.into()), ..Default::default() }; + assert!(!style_font_missing(&mut fonts, &style), "{ps} stored as {guess:?} is not missing"); + } +} diff --git a/crates/engine/src/variables_cmds.rs b/crates/engine/src/variables_cmds.rs index f42b744844..0a49d780d5 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); @@ -284,6 +286,8 @@ fn import_data_sets(s: &mut Session, p: &Value) -> Result { /// file beneath its read root and hands the text over (#3216). pub fn import_data_sets_text(s: &mut Session, text: &str, p: &Value) -> Result { let delim = p.get("delimiter").and_then(Value::as_str).and_then(|d| d.bytes().next()).unwrap_or(b','); + // Excel's "CSV UTF-8" starts with a byte order mark that is not part of the first header cell. + let text = text.strip_prefix('\u{feff}').unwrap_or(text); let vars = s.active().ok_or(EngineError::NoDocument)?.doc.variables.clone(); let mut records = csv_records(text).filter(|r| !r.trim().is_empty()); let header: Vec = records.next().map(|h| split_csv(h, delim)).unwrap_or_default(); diff --git a/crates/engine/src/variables_cmds/tests.rs b/crates/engine/src/variables_cmds/tests.rs index 28fc14a36f..8a0c591e71 100644 --- a/crates/engine/src/variables_cmds/tests.rs +++ b/crates/engine/src/variables_cmds/tests.rs @@ -105,7 +105,7 @@ fn listed_pixel_methods_can_be_used_to_redefine_variables() { #[test] fn bad_params_are_errors() { let (mut s, _p, badge, _t) = session(); - assert!(s.execute("image.variables.define", json!({"defs": [{"name": "x", "layer": 999999, "type": "visibility"}]})).is_err()); + assert!(s.execute("image.variables.define", json!({"defs": [{"name": "x", "layer": u64::MAX, "type": "visibility"}]})).is_err()); assert!(s.execute("image.variables.define", json!({"defs": [{"name": "x", "layer": badge.0, "type": "nope"}]})).is_err()); assert!(s.execute("image.applyDataSet", json!({"name": "missing"})).is_err()); } @@ -136,6 +136,38 @@ fn csv_import_and_apply() { assert_eq!(text_of(&s, title), "On Sale"); } +#[test] +fn csv_import_ignores_utf8_bom() { + let dir = tmp("csv-bom"); + let csv = format!("{dir}/sets.csv"); + + // BOM before the `DataSet` name column: sets keep their names. + let (mut s, _p, badge, title) = session(); + s.execute( + "image.variables.define", + json!({"defs": [ + {"name": "showBadge", "layer": badge.0, "type": "visibility"}, + {"name": "headline", "layer": title.0, "type": "textReplacement"}, + ]}), + ) + .unwrap(); + std::fs::write(&csv, "\u{feff}DataSet,showBadge,headline\nrow-on,true,On Sale\n").unwrap(); + let r = s.execute("file.import.variableDataSets", json!({"path": csv})).unwrap(); + assert_eq!(r["imported"], 1); + s.execute("image.applyDataSet", json!({"name": "row-on"})).unwrap(); + assert!(doc(&s).layer(badge).unwrap().visible); + assert_eq!(text_of(&s, title), "On Sale"); + + // BOM before a variable column (no name column): the variable is still imported. + let (mut s, _p, badge, title) = session(); + s.execute("image.variables.define", json!({"defs": [{"name": "headline", "layer": title.0, "type": "textReplacement"}]})).unwrap(); + let _ = badge; + let r = import_data_sets_text(&mut s, "\u{feff}headline\nHello\n", &json!({})).unwrap(); + assert_eq!(r["imported"], 1); + s.execute("image.applyDataSet", json!({"index": 0})).unwrap(); + assert_eq!(text_of(&s, title), "Hello"); +} + #[test] fn csv_fields_preserve_unicode_and_quoting() { for (line, delimiter, expected) in [ diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 14579e5954..15c14e5a1c 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1091,9 +1091,25 @@ fn path_rename(s: &mut Session, p: &Value) -> Result { fn path_to_selection(s: &mut Session, p: &Value) -> Result { let path = resolve_path(s, p.get("name").and_then(Value::as_str))?; - let feather = f64p(p, "feather").unwrap_or(0.0) as f32; - let aa = p.get("antiAlias").and_then(Value::as_bool).unwrap_or(true); - let mode = SelectionMode::parse(p.get("mode").and_then(Value::as_str).unwrap_or("replace")); + // Validate before rasterizing or touching selection history. An unchecked feather value + // could silently become zero (negative/string), or expand into an excessive blur. + let feather = match p.get("feather") { + None => 0.0, + Some(v) => v + .as_f64() + .filter(|n| n.is_finite() && (0.0..=1000.0).contains(n)) + .ok_or_else(|| bad("path.toSelection", "`feather` must be a finite number within 0..1000"))? as f32, + }; + let aa = match p.get("antiAlias") { + None => true, + Some(Value::Bool(v)) => *v, + _ => return Err(bad("path.toSelection", "`antiAlias` must be a boolean")), + }; + let mode = match p.get("mode") { + None => SelectionMode::Replace, + Some(Value::String(v)) if matches!(v.as_str(), "replace" | "add" | "subtract" | "intersect") => SelectionMode::parse(v), + _ => return Err(bad("path.toSelection", "`mode` must be replace, add, subtract or intersect")), + }; let area = s.active().ok_or(EngineError::NoDocument)?.doc.bounds(); let mut mask = vector::fill_rasterizer(&path, vector::DEFAULT_TOLERANCE).render(area); if !aa { diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 4c2a9a9071..a2e8047115 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -335,6 +335,63 @@ fn path_to_selection_matches_analytic_shape() { assert!(m[100 * 200 + 160] > 0.2 && m[100 * 200 + 160] < 0.8); } +#[test] +fn path_make_selection_rejects_invalid_options_without_changing_history() { + let mut s = session(80, 80, 8); + s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[12, 12], [60, 12], [60, 60], [12, 60]]}]}})).unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 20, "width": 12, "height": 12})).unwrap(); + let before = doc(&s).clone(); + let history = s.active().unwrap().history.past_len(); + for opts in [ + json!({"feather": -1}), + json!({"feather": 1001}), + json!({"feather": 1e100}), + json!({"feather": "4"}), + json!({"feather": true}), + json!({"antiAlias": "false"}), + json!({"antiAlias": 1}), + json!({"mode": "union"}), + json!({"mode": 42}), + ] { + assert!(s.execute("path.toSelection", opts.clone()).is_err(), "accepted invalid Make Selection options: {opts}"); + assert_eq!(*doc(&s), before, "invalid Make Selection modified the document: {opts}"); + assert_eq!(s.active().unwrap().history.past_len(), history, "invalid Make Selection changed history: {opts}"); + } +} + +#[test] +fn open_pen_paths_make_the_same_selection_as_closed_paths() { + // The pen's unfinished work path should be implicitly closed when rasterized into a selection. + let mut s = session(80, 80, 8); + let knots = json!([[12, 12], [52, 12], [52, 52], [12, 52]]); + for (aa, feather) in [(false, 0.0), (true, 0.0), (false, 3.0), (true, 3.0)] { + let mut masks = Vec::new(); + for closed in [false, true] { + s.execute("path.set", json!({"path": {"subpaths": [{"closed": closed, "knots": knots}]}})).unwrap(); + s.execute("path.toSelection", json!({"antiAlias": aa, "feather": feather, "mode": "replace"})).unwrap(); + masks.push(selection_mask(&s)); + } + assert_eq!(masks[0], masks[1], "open and closed paths differ (antiAlias={aa}, feather={feather})"); + assert!(masks[0][32 * 80 + 32] > 0.99); + assert_eq!(masks[0][4 * 80 + 4], 0.0); + if feather > 0.0 { + assert!(masks[0].iter().any(|v| *v > 0.0 && *v < 1.0), "feather did not soften the selection"); + } + } + + // All boolean operations should use the same selection semantics for an open Pen path. + for mode in ["replace", "add", "subtract", "intersect"] { + s.execute("select.rect", json!({"x": 30, "y": 30, "width": 34, "height": 25})).unwrap(); + s.execute("path.set", json!({"path": {"subpaths": [{"closed": false, "knots": knots}]}})).unwrap(); + s.execute("path.toSelection", json!({"mode": mode, "antiAlias": false})).unwrap(); + let mask = selection_mask(&s); + let at = |x: usize, y: usize| mask[y * 80 + x]; + assert_eq!(at(20, 20), if matches!(mode, "replace" | "add") { 1.0 } else { 0.0 }, "{mode}: path-only"); + assert_eq!(at(60, 40), if matches!(mode, "add" | "subtract") { 1.0 } else { 0.0 }, "{mode}: previous-only"); + assert_eq!(at(40, 40), if mode == "subtract" { 0.0 } else { 1.0 }, "{mode}: overlap"); + } +} + #[test] fn selection_to_work_path_roundtrip_iou() { let mut s = session(240, 180, 8); @@ -646,7 +703,6 @@ fn rasterizing_a_vector_mask_keeps_what_the_masks_show() { if let Some((feather, density)) = pm { s.execute("select.rect", json!({"x": 0, "y": 0, "width": 40, "height": 64})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); s.edit("mask settings", |doc, _| { let m = doc.layer_mut(id).unwrap().mask.as_mut().unwrap(); (m.feather, m.density) = (feather, density); @@ -925,7 +981,7 @@ fn combine_shapes_fails_gracefully() { let (mut s, a, _) = two_selected_rects(8); let before = doc(&s).clone(); let history = s.active().unwrap().history.past_len(); - let bad_params = [json!({"layer": 999_999}), json!({"layer": a, "subpath": 99}), json!({"subpath": 99}), json!({"tolerance": "x", "layer": 999_999})]; + let bad_params = [json!({"layer": u64::MAX}), json!({"layer": a, "subpath": 99}), json!({"subpath": 99}), json!({"tolerance": "x", "layer": u64::MAX})]; for cmd in [ "layer.combineShapes.unite", "layer.combineShapes.subtractFrontShape", diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 2a9f8ae8b4..0d27d3c827 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -11,6 +11,10 @@ use serde_json::{Value, json}; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; +/// Maximum dense RGBA staging pixels in one animated GIF frame. A GIF also keeps the +/// quantised frame and palette while rendering, so the document canvas limit is not enough. +const MAX_GIF_FRAME_PIXELS: u64 = 64 << 20; + /// Set every video layer's displayed content to its frame at the playhead. pub fn sync(doc: &mut Document) { let cur = doc.timeline.as_ref().map_or(0, |t| t.current); @@ -297,7 +301,7 @@ fn new_from_file(s: &mut Session, p: &Value) -> Result { let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); let Footage { frames, fps: footage_fps } = load_frames(&path, fmt)?; // An explicit `fps` wins, then the rate the footage implies, then 30. - let fps = p.get("fps").and_then(Value::as_f64).map(|f| f as f32).or(footage_fps).unwrap_or(30.0); + let fps = crate::timeline_cmds::fps_param("layer.videoLayers.newVideoLayerFromFile", p)?.or(footage_fps).unwrap_or(30.0); let n = frames.len(); let id = s.edit("New Video Layer from File", |doc, active| { store(doc); @@ -337,10 +341,10 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result { } fn interpret_footage(s: &mut Session, p: &Value) -> Result { - let fps = p.get("fps").and_then(Value::as_f64); + let fps = crate::timeline_cmds::fps_param("layer.videoLayers.interpretFootage", p)?; edit_video(s, "Interpret Footage", move |v, _| { if let Some(f) = fps { - v.fps = f as f32; + v.fps = f; } Ok(()) }) @@ -361,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; @@ -416,7 +422,13 @@ pub fn render_video_with( let mut base = (*st.doc).clone(); store(&mut base); let frames = base.timeline.as_ref().map_or(1, |t| t.duration).max(1); - let fps = base.timeline.as_ref().map_or(30.0, |t| t.fps).max(1.0); + // Loaded timelines bypass the duration validation used by timeline.create/setProps. + if frames > crate::timeline_cmds::MAX_DURATION { + return Err(EngineError::Other(format!("the timeline is {frames} frames; Render Video is limited to {} frames", crate::timeline_cmds::MAX_DURATION))); + } + // Only the GIF delay reads the rate (frames come from the duration); rates under 1 fps are + // valid timelines, so only an unusable loaded rate falls back to 30. + let fps = base.timeline.as_ref().map(|t| t.fps).filter(|f| f.is_finite() && *f > 0.0).unwrap_or(30.0); let stem = base.name.rsplit_once('.').map_or(base.name.as_str(), |(a, _)| a).to_string(); let fmt = base.pixel_format(); let bounds = base.bounds(); @@ -429,6 +441,11 @@ pub fn render_video_with( // Animated GIF: one file, every frame quantised to its own palette. if format.eq_ignore_ascii_case("gif") { + // The whole canvas is staged as f32 RGBA for each frame; reject over-budget + // dimensions before an infallible allocation can abort the process. + if (w as u64) * (h as u64) > MAX_GIF_FRAME_PIXELS { + return Err(EngineError::Other(format!("{w} × {h} is too large to render as an animated GIF (limit {MAX_GIF_FRAME_PIXELS} pixels per frame)"))); + } let delay = (100.0 / fps).round().clamp(1.0, 65535.0) as u16; let mut gframes = Vec::with_capacity(frames); for f in 0..frames { diff --git a/crates/engine/src/video_cmds/tests.rs b/crates/engine/src/video_cmds/tests.rs index 22d639d49b..3cdda8adc0 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -151,6 +151,39 @@ fn a_fully_transparent_animated_gif_frame_encodes() { assert!(f.indices.iter().all(|&i| Some(i) == f.transparent)); } +/// The GIF frame delays (centiseconds, from each Graphic Control Extension) of a Render Video. +fn rendered_gif_delays(s: &Session) -> Vec { + let mut gif = Vec::new(); + render_video_with( + s, + &json!({"format": "gif"}), + |_, bytes| { + gif = bytes.to_vec(); + Ok(()) + }, + |_, _| true, + ) + .unwrap(); + gif.windows(6).filter(|w| w[..3] == [0x21, 0xF9, 0x04]).map(|w| u16::from_le_bytes([w[4], w[5]])).collect() +} + +#[test] +fn animated_gif_delay_follows_timelines_under_one_fps() { + // 0.5 fps is a valid timeline rate; its frames last 2 s, not the 1 s a 1 fps floor gave. + let mut s = session(); + s.execute("timeline.create", json!({"duration": 2, "fps": 0.5})).unwrap(); + assert_eq!(rendered_gif_delays(&s), [200, 200]); + // A loaded timeline with an unusable rate falls back to 30 fps (3 cs), as with no timeline. + let mut doc = (*s.active().unwrap().doc).clone(); + doc.timeline = Some(Timeline::new(2, f32::NAN)); + { + let st = s.active_mut().unwrap(); + st.doc = std::sync::Arc::new(doc); + st.revision += 1; + } + assert_eq!(rendered_gif_delays(&s), [3, 3]); +} + #[test] fn inspect_reports_video_layer() { let dir = tmpdir("vinsp"); @@ -330,6 +363,32 @@ fn a_still_gif_is_one_frame_at_the_default_rate() { assert_eq!(s.active().unwrap().doc.layer(id).unwrap().video.as_ref().unwrap().fps, 10.0); } +/// Interpret Footage and New Video Layer from File reject an fps that isn't a finite number in +/// `(0, MAX_FPS]` and keep the layer as it was (#5657). 1e39 used to become an infinite rate the +/// next `.pcraft` save refused. +#[test] +fn an_invalid_frame_rate_is_an_error_and_changes_nothing() { + let mut s = session(); + let id = LayerId(s.execute("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap()["layer"].as_u64().unwrap()); + let fps = |s: &Session| s.active().unwrap().doc.layer(id).unwrap().video.as_ref().unwrap().fps; + assert_eq!(fps(&s), 30.0); + for bad in [json!(0), json!(-1), json!(1e39), json!("x")] { + assert!(s.execute("layer.videoLayers.interpretFootage", json!({"fps": bad})).is_err(), "fps {bad}"); + assert_eq!(fps(&s), 30.0, "fps {bad}"); + } + s.execute("layer.videoLayers.interpretFootage", json!({"fps": 24})).unwrap(); + assert_eq!(fps(&s), 24.0); + + let dir = tmpdir("gifin-badfps"); + let path = format!("{dir}/anim.gif"); + write_gif(&path, 8, 8, &[([255, 0, 0], 10), ([0, 255, 0], 10)]); + let layers = s.active().unwrap().doc.layer_count(); + for bad in [json!(0), json!(-1), json!(1e39), json!("x")] { + assert!(s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": path, "fps": bad})).is_err(), "fps {bad}"); + assert_eq!(s.active().unwrap().doc.layer_count(), layers, "fps {bad}"); + } +} + #[test] fn video_frames_to_layers_takes_every_gif_frame() { let dir = tmpdir("gifin-ftl"); @@ -498,3 +557,25 @@ fn grow_duration_never_shrinks_and_keeps_the_work_area_valid() { grow_duration(&mut t, 12); assert_eq!((t.work_start, t.work_end, t.duration), (0, 6, 12)); } + +#[test] +fn render_video_rejects_a_loaded_timeline_past_the_frame_cap() { + let mut s = session(); + let mut doc = (*s.active().unwrap().doc).clone(); + doc.timeline = Some(Timeline::new(usize::MAX, 30.0)); + { + let st = s.active_mut().unwrap(); + st.doc = std::sync::Arc::new(doc); + st.revision += 1; + } + let err = s.execute("file.export.renderVideo", json!({"dir": tmpdir("overlong"), "format": "gif"})).unwrap_err(); + assert!(err.to_string().contains("limited to"), "{err}"); +} + +#[test] +fn render_video_rejects_gif_frames_too_large_to_stage() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 9000, "height": 8000})).unwrap(); + let err = s.execute("file.export.renderVideo", json!({"dir": tmpdir("large-gif"), "format": "gif"})).unwrap_err(); + assert!(err.to_string().contains("too large"), "{err}"); +} diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index 5142b0a940..8e2ab3c4fd 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -121,6 +121,9 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, // A selection limits the linked mask exactly as it limits the pixels (#4175, as Free Transform // and Perspective Warp do, #3083); a layer that moves whole takes its whole mask along. let mask_sel = doc_sel.filter(|_| !crate::transform_cmds::moves_whole(l)); + // The masks (and so a fill's frame) move, but a fill layer's cached render isn't warped: + // drop it, the fill re-renders from its model. + l.fill_cache = None; match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 595ad21956..755ba07320 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -70,6 +70,9 @@ pub enum WebMetadata { None, Copyright, CopyrightAndContact, + /// All the XMP, none of the photo's EXIF (camera, date taken, GPS). + AllExceptCameraInfo, + /// All the XMP and the photo's EXIF. All, } @@ -203,8 +206,9 @@ impl WebSettings { "none" => WebMetadata::None, "copyright" => WebMetadata::Copyright, "copyrightAndContact" | "contact" => WebMetadata::CopyrightAndContact, - "all" | "allExceptCameraInfo" => WebMetadata::All, - m => return Err(bad(cmd, format!("unknown metadata `{m}` (none|copyright|copyrightAndContact|all)"))), + "allExceptCameraInfo" => WebMetadata::AllExceptCameraInfo, + "all" => WebMetadata::All, + m => return Err(bad(cmd, format!("unknown metadata `{m}` (none|copyright|copyrightAndContact|allExceptCameraInfo|all)"))), }; } if let Some(table) = p.get("colorTable") { @@ -262,6 +266,7 @@ impl WebSettings { WebMetadata::None => "none", WebMetadata::Copyright => "copyright", WebMetadata::CopyrightAndContact => "copyrightAndContact", + WebMetadata::AllExceptCameraInfo => "allExceptCameraInfo", WebMetadata::All => "all", }; let matte = self.matte.map_or("none".to_string(), |c| format!("#{:02x}{:02x}{:02x}", to8(c[0]), to8(c[1]), to8(c[2]))); @@ -387,7 +392,7 @@ pub fn web_xmp(doc: &Document, m: WebMetadata) -> Option { let xmp = doc.metadata.xmp.as_deref()?; match m { WebMetadata::None => None, - WebMetadata::All => Some(xmp.to_string()), + WebMetadata::All | WebMetadata::AllExceptCameraInfo => Some(xmp.to_string()), WebMetadata::Copyright | WebMetadata::CopyrightAndContact => { let info = crate::file_cmds::read_file_info(Some(xmp)); let mut keep = serde_json::Map::new(); @@ -405,10 +410,33 @@ pub fn web_xmp(doc: &Document, m: WebMetadata) -> Option { } } +/// The photo's EXIF to embed for the metadata choice: only "All" carries it (#7436). +pub fn web_exif(doc: &Document, m: WebMetadata) -> Option<&[u8]> { + doc.metadata.exif.as_deref().map(Vec::as_slice).filter(|_| m == WebMetadata::All) +} + +/// The response's warnings: EXIF that a JPEG couldn't hold (one segment, about 64 KB). +fn web_warnings(st: &WebSettings, exif: Option<&[u8]>, dpi: f32) -> Vec { + exif.filter(|x| st.format == WebFormat::Jpeg && !photocraft_codecs::web::jpeg_exif_fits(x, Some(dpi))) + .map(|x| photocraft_codecs::FidelityWarning::MetadataTooLarge { what: "EXIF", bytes: x.len() }.to_string()) + .into_iter() + .collect() +} + /// Optimises `rect` of `px` (straight RGBA, `bw` wide) with `st`. `preview` also returns what /// the file looks like (the dialog's panes); exports skip it. #[allow(clippy::too_many_arguments)] -pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: Option<&[u8]>, xmp: Option<&str>, dpi: f32, preview: bool) -> Result { +pub fn optimize( + px: &[[f32; 4]], + bw: usize, + rect: Rect, + st: &WebSettings, + icc: Option<&[u8]>, + exif: Option<&[u8]>, + xmp: Option<&str>, + dpi: f32, + preview: bool, +) -> Result { if bw == 0 || rect.x0 < 0 || rect.y0 < 0 || rect.is_empty() || rect.x1 as usize > bw || (rect.y1 as usize).checked_mul(bw).is_none_or(|end| end > px.len()) { return Err(other("slice is outside the pixel buffer")); @@ -460,8 +488,9 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O } // Save for Web's 0–100 quality → encoder 1–100. let q = st.quality.max(1); - let bytes = photocraft_codecs::web::encode_jpeg_rgb8(ww, hh, &rgb, q, st.progressive, st.optimized, icc.filter(|_| st.embed_icc), Some(dpi), xmp) - .map_err(e)?; + let bytes = + photocraft_codecs::web::encode_jpeg_rgb8(ww, hh, &rgb, q, st.progressive, st.optimized, icc.filter(|_| st.embed_icc), Some(dpi), exif, xmp) + .map_err(e)?; if preview { // The preview shows the compression artefacts. look = match photocraft_codecs::decode(&bytes) { @@ -557,7 +586,7 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O let bytes = if st.format == WebFormat::Gif { photocraft_codecs::web::encode_gif_indexed(ww, hh, &idx, &pal, tt, st.interlaced).map_err(e)? } else { - photocraft_codecs::encode_png_indexed(ww, hh, &idx, &pal, tt, None).map_err(e)? + photocraft_codecs::encode_png_indexed(ww, hh, &idx, &pal, tt, None, None).map_err(e)? }; if preview { indices = idx; @@ -873,7 +902,9 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u let bw = wdoc.size.width as usize; let icc = wdoc.icc_profile.as_deref().map(|v| v.as_slice()); let xmp = web_xmp(&doc, st.metadata); + let exif = web_exif(&doc, st.metadata); let dpi = wdoc.resolution_dpi; + let warnings = web_warnings(&st, exif, dpi); let all = slices::resolve(&doc); let pick = p.get("slices").and_then(Value::as_str).unwrap_or("all"); let numbers: Option> = p.get("numbers").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()); @@ -895,18 +926,18 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u } // Estimate only (the dialog's annotations): the whole image, or each chosen slice. if single.is_none() && dir.is_none() { - let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?; + let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?; return Ok( json!({"format": o.ext, "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "colorTable": o.color_table.iter().map(|c| rgb_hex(*c)).collect::>(), "transparentIndex": o.transparent_index}), ); } if let Some(path) = single.filter(|_| dir.is_none() && (all.len() == 1 || p.get("slices").is_none())) { - let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?; + let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?; write(path, &o.bytes)?; remember_web_settings(s, cmd, &last_web); s.file_menu.last_web = Some(last_web); crate::automate_cmds::fire_event(s, "export"); - return Ok(json!({"files": [path], "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors})); + return Ok(json!({"files": [path], "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "warnings": warnings})); } let dir = dir.map(str::to_string).or_else(|| single.and_then(|f| std::path::Path::new(f).parent().map(|d| d.to_string_lossy().into_owned()))).unwrap_or_default(); @@ -961,7 +992,7 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u cells.push((piece, bg, text)); continue; } - let o = optimize(&buf.px, bw, piece, &st, icc, xmp.as_deref(), dpi, false)?; + let o = optimize(&buf.px, bw, piece, &st, icc, exif, xmp.as_deref(), dpi, false)?; let stem = if split { format!("{}_{:02}", slice_file(&r.name), k + 1) } else { slice_file(&r.name) }; let name = unique(&stem, o.ext); let rel = if images.is_empty() { name.clone() } else { format!("{images}/{name}") }; @@ -997,7 +1028,9 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u remember_web_settings(s, cmd, &last_web); s.file_menu.last_web = Some(last_web); crate::automate_cmds::fire_event(s, "export"); - Ok(json!({"files": files, "html": html_path, "bytes": total, "slices": chosen.len(), "width": wdoc.size.width, "height": wdoc.size.height})) + Ok( + json!({"files": files, "html": html_path, "bytes": total, "slices": chosen.len(), "width": wdoc.size.width, "height": wdoc.size.height, "warnings": warnings}), + ) } // ---------- Export Preferences / Quick Export ---------- @@ -1024,27 +1057,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( @@ -1087,19 +1141,21 @@ fn quick_export(s: &mut Session, p: &Value) -> Result { let (wdoc, _, _) = web_document(&doc, &json!({}), &st)?; let buf = photocraft_compose::flatten(&wdoc); let xmp = web_xmp(&doc, st.metadata); + let exif = web_exif(&doc, st.metadata); let opt = optimize( &buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, wdoc.icc_profile.as_deref().map(|v| v.as_slice()), + exif, xmp.as_deref(), wdoc.resolution_dpi, false, )?; write_file(&path, &opt.bytes)?; crate::automate_cmds::fire_event(s, "export"); - Ok(json!({"path": path, "format": fmt, "bytes": opt.bytes.len()})) + Ok(json!({"path": path, "format": fmt, "bytes": opt.bytes.len(), "warnings": web_warnings(&st, exif, wdoc.resolution_dpi)})) } // ---------- Generate › Image Assets ---------- @@ -1168,8 +1224,12 @@ fn parse_size(item: &str, dpi: f64) -> (Option, Option, Option, & && toks[1].eq_ignore_ascii_case("x") && let (Some(w), Some(h)) = (unit_px(toks[0], dpi), unit_px(toks[2], dpi)) { - let pos = item.find(toks[3]).unwrap_or(item.len()); - return (None, w, h, &item[pos..]); + // Skip exactly the three size tokens; searching for the name's first word could match inside the size. + let mut rest = item; + for _ in 0..3 { + rest = rest.trim_start().trim_start_matches(|c: char| !c.is_whitespace()); + } + return (None, w, h, rest.trim_start()); } (None, None, None, item) } @@ -1337,10 +1397,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); @@ -1349,22 +1414,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)); @@ -1392,12 +1461,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 o = optimize(&buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, None, 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 { @@ -1468,7 +1539,7 @@ pub fn specs() -> Vec { "Save for Web (Legacy)…", &["File", "Export"], Some("Cmd+Alt+Shift+S"), - r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|all"="copyright","convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes}"##, + r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|allExceptCameraInfo|all"="copyright" (EXIF only with all),"convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes, warnings}"##, has_doc, save_for_web ), @@ -1486,7 +1557,7 @@ pub fn specs() -> Vec { "Quick Export", &[], None, - r##"{"path":str? (required unless Export Preferences › Location is "sameFolder" and the document is saved)} → {path, format, bytes}"##, + r##"{"path":str? (required unless Export Preferences › Location is "sameFolder" and the document is saved)} → {path, format, bytes, warnings}"##, native_doc, quick_export ), diff --git a/crates/engine/src/web_cmds/color_table_tests.rs b/crates/engine/src/web_cmds/color_table_tests.rs index 1d319cb382..0c25ce0d24 100644 --- a/crates/engine/src/web_cmds/color_table_tests.rs +++ b/crates/engine/src/web_cmds/color_table_tests.rs @@ -7,13 +7,13 @@ fn color_replacement_preserves_pixel_membership_and_transparency() { for dither in ["none", "diffusion", "pattern", "noise"] { let p = json!({"format": format, "colorTable": ["#ff0000", "#0000ff"], "dither": dither, "matte": "none"}); let original = WebSettings::from_params(&p, "test").unwrap(); - let before = optimize(&px, 4, Rect::new(0, 0, 4, 1), &original, None, None, 72.0, true).unwrap(); + let before = optimize(&px, 4, Rect::new(0, 0, 4, 1), &original, None, None, None, 72.0, true).unwrap(); let mut edited = p.clone(); // Replace red with blue: re-quantization would now send all opaque pixels to blue. edited["colorTableEdits"] = json!({"0": "#0000ff", "1": "#00ff00"}); let settings = WebSettings::from_params(&edited, "test").unwrap(); assert_eq!(WebSettings::from_params(&settings.to_params(), "test").unwrap(), settings); - let after = optimize(&px, 4, Rect::new(0, 0, 4, 1), &settings, None, None, 72.0, true).unwrap(); + let after = optimize(&px, 4, Rect::new(0, 0, 4, 1), &settings, None, None, None, 72.0, true).unwrap(); for (old, new) in before.preview.as_chunks::<4>().0.iter().zip(after.preview.as_chunks::<4>().0) { if old[3] == 0 { assert_eq!(new[3], 0); @@ -81,8 +81,8 @@ fn malformed_tables_and_out_of_bounds_slices_return_errors() { } let settings = WebSettings::default(); for (bw, rect) in [(0, Rect::new(0, 0, 1, 1)), (1, Rect::new(-1, 0, 1, 1)), (1, Rect::new(0, 0, 2, 1)), (1, Rect::new(0, 0, 1, i32::MAX))] { - assert!(optimize(&[[0.0; 4]], bw, rect, &settings, None, None, 72.0, false).is_err()); + assert!(optimize(&[[0.0; 4]], bw, rect, &settings, None, None, None, 72.0, false).is_err()); } let settings = WebSettings::from_params(&json!({"format": "gif", "colorTable": vec!["#000000"; 256]}), "test").unwrap(); - assert!(optimize(&[[0.0; 4]], 1, Rect::new(0, 0, 1, 1), &settings, None, None, 72.0, true).is_err()); + assert!(optimize(&[[0.0; 4]], 1, Rect::new(0, 0, 1, 1), &settings, None, None, None, 72.0, true).is_err()); } diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index c3ffe6edce..5791e5f27c 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -437,6 +437,51 @@ 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"); + 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"); @@ -485,6 +530,20 @@ fn webp_export_preferences_select_lossless_or_lossy_output() { } } +#[test] +fn generator_spaced_size_keeps_only_the_file_name() { + for (name, w, h, file) in [ + ("2 x 2 2 icon.png", 2.0, 2.0, "2 icon.png"), + ("1in x 1in in.png", 72.0, 72.0, "in.png"), + ("2px x 1px x.png", 2.0, 1.0, "x.png"), + ("2in x 1in label name.png", 144.0, 72.0, "label name.png"), + ] { + let a = parse_asset_name(name, 72.0); + assert_eq!(a.len(), 1, "{name}"); + assert_eq!((a[0].width, a[0].height, a[0].file.as_str()), (Some(w), Some(h), file), "{name}"); + } +} + #[test] fn generator_naming_grammar() { let a = parse_asset_name("200% foo@2x.png, 48x48 icons/bar.png8 + photo.jpg8", 72.0); @@ -546,6 +605,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"); @@ -635,7 +735,7 @@ fn previews_show_the_optimised_pixels() { let buf = photocraft_compose::flatten(&wd); for f in ["jpeg", "gif", "png8", "png24", "wbmp"] { let st = WebSettings::from_params(&json!({"format": f, "quality": 90, "transparency": false}), "x").unwrap(); - let o = optimize(&buf.px, 64, wd.bounds(), &st, None, None, 72.0, true).unwrap(); + let o = optimize(&buf.px, 64, wd.bounds(), &st, None, None, None, 72.0, true).unwrap(); assert_eq!(o.preview.len(), 64 * 48 * 4, "{f}"); // Red square centre stays red (WBMP: dark). let i = (16 * 64 + 16) * 4; @@ -645,7 +745,7 @@ fn previews_show_the_optimised_pixels() { } else { assert!(px[0] > 200 && px[1] < 60 && px[2] < 60, "{f}: {px:?}"); } - let none = optimize(&buf.px, 64, wd.bounds(), &st, None, None, 72.0, false).unwrap(); + let none = optimize(&buf.px, 64, wd.bounds(), &st, None, None, None, 72.0, false).unwrap(); assert!(none.preview.is_empty()); assert_eq!(none.bytes, o.bytes); } @@ -656,7 +756,7 @@ fn jpeg_preview_at_odd_sizes() { for (w, h, q) in [(640usize, 431usize, 90), (640, 430, 60), (333, 222, 30)] { let px: Vec<[f32; 4]> = (0..w * h).map(|i| [((i % w) as f32 / w as f32), 0.3, ((i / w) as f32 / h as f32), 1.0]).collect(); let st = WebSettings::from_params(&json!({"format": "jpeg", "quality": q}), "x").unwrap(); - let o = optimize(&px, w, Rect::new(0, 0, w as i32, h as i32), &st, None, None, 72.0, true).unwrap(); + let o = optimize(&px, w, Rect::new(0, 0, w as i32, h as i32), &st, None, None, None, 72.0, true).unwrap(); for &(x, y) in &[(10usize, 10usize), (w - 10, h / 2), (w / 2, h - 5)] { let i = (y * w + x) * 4; let want = [(x as f32 / w as f32 * 255.0) as i32, 76, (y as f32 / h as f32 * 255.0) as i32]; @@ -714,3 +814,166 @@ fn overlapping_slices_keep_the_later_slice_in_html() { #[path = "color_table_tests.rs"] mod color_table_tests; + +/// A little-endian camera EXIF: IFD0 with Make, Orientation 6, 300 ppi and pointers to an Exif +/// IFD (DateTimeOriginal) and a GPS IFD (GPSLatitudeRef); IFD0 links to an IFD1 thumbnail. +fn camera_exif() -> Vec { + let entry = |v: &mut Vec, tag: u16, ty: u16, count: u32, value: u32| { + v.extend_from_slice(&tag.to_le_bytes()); + v.extend_from_slice(&ty.to_le_bytes()); + v.extend_from_slice(&count.to_le_bytes()); + v.extend_from_slice(&value.to_le_bytes()); + }; + let (ifd0, data0) = (8u32, 8 + 2 + 7 * 12 + 4); + let (make, xres, yres) = (data0, data0 + 10, data0 + 18); + let exif_ifd = data0 + 26; + let date = exif_ifd + 2 + 12 + 4; + let gps_ifd = date + 20; + let ifd1 = gps_ifd + 2 + 12 + 4; + let mut v = b"II*\0".to_vec(); + v.extend_from_slice(&ifd0.to_le_bytes()); + v.extend_from_slice(&7u16.to_le_bytes()); + entry(&mut v, 0x010F, 2, 9, make); + entry(&mut v, 0x0112, 3, 1, 6); + entry(&mut v, 282, 5, 1, xres); + entry(&mut v, 283, 5, 1, yres); + entry(&mut v, 296, 3, 1, 2); + entry(&mut v, 0x8769, 4, 1, exif_ifd); + entry(&mut v, 0x8825, 4, 1, gps_ifd); + v.extend_from_slice(&ifd1.to_le_bytes()); + v.extend_from_slice(b"TestMake\0\0"); + for _ in 0..2 { + v.extend_from_slice(&300u32.to_le_bytes()); + v.extend_from_slice(&1u32.to_le_bytes()); + } + v.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut v, 0x9003, 2, 20, date); + v.extend_from_slice(&0u32.to_le_bytes()); + v.extend_from_slice(b"2024:05:01 10:30:00\0"); + v.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut v, 0x0001, 2, 2, u32::from_le_bytes(*b"N\0\0\0")); + v.extend_from_slice(&0u32.to_le_bytes()); + v.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut v, 0x0103, 3, 1, 6); + v.extend_from_slice(&0u32.to_le_bytes()); + assert_eq!(v.len(), ifd1 as usize + 18); + v +} + +/// The payload (after `Exif\0\0`) of the file's EXIF APP1 segment, read from the markers. +fn exif_app1(bytes: &[u8]) -> Option> { + let mut i = 2; + while i + 4 <= bytes.len() && bytes[i] == 0xFF && bytes[i + 1] != 0xDA { + let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize; + let body = &bytes[i + 4..i + 2 + len]; + if bytes[i + 1] == 0xE1 && body.starts_with(b"Exif\0\0") { + return Some(body[6..].to_vec()); + } + i += 2 + len; + } + None +} + +/// IFD0's entries (tag → inline value) and its link to the next IFD, little-endian only. +fn ifd0(tiff: &[u8]) -> (std::collections::HashMap, u32) { + let u16_at = |o: usize| u16::from_le_bytes([tiff[o], tiff[o + 1]]); + let u32_at = |o: usize| u32::from_le_bytes([tiff[o], tiff[o + 1], tiff[o + 2], tiff[o + 3]]); + assert_eq!(&tiff[..4], b"II*\0"); + let at = u32_at(4) as usize; + let n = u16_at(at) as usize; + let tags = (0..n) + .map(|k| { + let e = at + 2 + k * 12; + let value = if u16_at(e + 2) == 3 { u32::from(u16_at(e + 8)) } else { u32_at(e + 8) }; + (u16_at(e), value) + }) + .collect(); + (tags, u32_at(at + 2 + n * 12)) +} + +fn photo_session(exif: Vec) -> Session { + let mut s = session(8); + s.execute("file.fileInfo", json!({"copyright": "© Example"})).unwrap(); + s.edit("exif", |doc, _| { + doc.metadata.exif = Some(std::sync::Arc::new(exif)); + Ok(()) + }) + .unwrap(); + s +} + +/// #7436: Save for Web's "All" writes the photo's EXIF, as Save a Copy does: upright +/// (Orientation 1), without the source's thumbnail and at the exported resolution. Every other +/// choice, "All Except Camera Info" included, leaves the camera data (and GPS) out. +#[test] +fn save_for_web_all_writes_the_photos_exif() { + let dir = tmp("exif"); + let mut s = photo_session(camera_exif()); + let export = |s: &mut Session, metadata: &str| { + let path = format!("{dir}/{metadata}.jpg"); + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "jpeg", "metadata": metadata, "path": path})).unwrap(); + (std::fs::read(&path).unwrap(), r) + }; + let (all, r) = export(&mut s, "all"); + let exif = exif_app1(&all).expect("an EXIF APP1 segment"); + let camera = photocraft_algo::exif::read(&exif); + assert_eq!(camera.make.as_deref(), Some("TestMake")); + assert_eq!(camera.date_taken.as_deref(), Some("2024:05:01 10:30:00")); + let (tags, next) = ifd0(&exif); + assert_eq!(tags.get(&0x0112), Some(&1), "the exported pixels are already upright"); + assert!(tags.contains_key(&0x8825), "GPS kept"); + assert_eq!(next, 0, "the source's thumbnail is not linked"); + assert_eq!(photocraft_codecs::exif_resolution(&exif), Some((72.0, 72.0)), "the document's resolution, not the source's 300"); + assert!(decode(&format!("{dir}/all.jpg")).meta.xmp.unwrap_or_default().contains("Example")); + for m in ["none", "copyright", "copyrightAndContact", "allExceptCameraInfo"] { + let (bytes, r) = export(&mut s, m); + assert_eq!(exif_app1(&bytes), None, "{m}"); + assert_eq!(r["warnings"], json!([]), "{m}"); + } + assert_eq!(r["warnings"], json!([]), "it fits"); + let xmp = decode(&format!("{dir}/allExceptCameraInfo.jpg")).meta.xmp.unwrap_or_default(); + assert!(xmp.contains("Example"), "all the XMP"); + assert_eq!(WebSettings::from_params(&json!({"metadata": "allExceptCameraInfo"}), "x").unwrap().to_params()["metadata"], "allExceptCameraInfo"); +} + +#[test] +fn quick_export_all_writes_the_photos_exif() { + let dir = tmp("quick_exif"); + let mut s = photo_session(camera_exif()); + let export = |s: &mut Session, metadata: &str| { + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "jpg", "metadata": metadata})).unwrap(); + let path = format!("{dir}/{metadata}.jpg"); + let r = s.execute("file.export.quickExport", json!({"path": path})).unwrap(); + (std::fs::read(&path).unwrap(), r) + }; + let (all, r) = export(&mut s, "all"); + assert_eq!(photocraft_algo::exif::read(&exif_app1(&all).expect("EXIF")).make.as_deref(), Some("TestMake")); + assert_eq!(r["warnings"], json!([])); + assert_eq!(exif_app1(&export(&mut s, "copyright").0), None); + assert_eq!(exif_app1(&export(&mut s, "none").0), None); + s.execute("prefs.reset", json!({"path": "export"})).unwrap(); +} + +/// EXIF too large for its one segment is left out and said so; a truncated one doesn't panic. +#[test] +fn web_exif_that_does_not_fit_is_reported_and_bad_exif_is_harmless() { + let dir = tmp("exif_bad"); + let mut big = camera_exif(); + big.resize(70_000, 0); + let mut s = photo_session(big); + let path = format!("{dir}/big.jpg"); + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "jpeg", "metadata": "all", "path": path})).unwrap(); + assert_eq!(exif_app1(&std::fs::read(&path).unwrap()), None); + assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("EXIF (70000 bytes)")), "{r}"); + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "jpg", "metadata": "all"})).unwrap(); + let r = s.execute("file.export.quickExport", json!({"path": format!("{dir}/quick.jpg")})).unwrap(); + assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("EXIF")), "{r}"); + s.execute("prefs.reset", json!({"path": "export"})).unwrap(); + + for cut in [3, 9, 40, 100] { + let mut s = photo_session(camera_exif()[..cut].to_vec()); + let path = format!("{dir}/cut{cut}.jpg"); + s.execute("file.export.saveForWebLegacy", json!({"format": "jpeg", "metadata": "all", "path": path})).unwrap(); + assert_eq!(decode(&path).dimensions(), (64, 48)); + } +} 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()); +} diff --git a/crates/engine/tests/camera_raw_exposure_clamp.rs b/crates/engine/tests/camera_raw_exposure_clamp.rs new file mode 100644 index 0000000000..ee624f7e28 --- /dev/null +++ b/crates/engine/tests/camera_raw_exposure_clamp.rs @@ -0,0 +1,40 @@ +//! `filter.cameraRaw` must clamp `exposure` to its documented -5..5 range. Before the fix, +//! `raw_params` passed it through, so an exposure of 128 or more overflowed `2^exposure` to +//! infinity and a 32-bit layer was left with non-finite samples. + +use photocraft_engine::Session; +use serde_json::json; + +#[test] +fn camera_raw_clamps_exposure() { + let hi = photocraft_engine::lens_cmds::raw_params("filter.cameraRaw", &json!({"exposure": 255.0})).expect("params parse"); + assert_eq!(hi.exposure, 5.0, "exposure 255 must clamp to 5"); + let lo = photocraft_engine::lens_cmds::raw_params("filter.cameraRaw", &json!({"exposure": -255.0})).expect("params parse"); + assert_eq!(lo.exposure, -5.0, "exposure -255 must clamp to -5"); + let mid = photocraft_engine::lens_cmds::raw_params("filter.cameraRaw", &json!({"exposure": 1.5})).expect("params parse"); + assert_eq!(mid.exposure, 1.5, "in-range exposure stays untouched"); +} + +#[test] +fn camera_raw_out_of_range_exposure_keeps_32_bit_layer_finite() { + for exposure in [255.0, -255.0] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": 32})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("filter.cameraRaw", json!({"exposure": exposure})).unwrap(); + let st = s.active().unwrap(); + let layer = st.active_layer.and_then(|id| st.doc.layer(id)).unwrap(); + let surf = layer.surface().unwrap(); + let mut bad = Vec::new(); + for y in 0..8 { + for x in 0..8 { + let px = surf.rgba(x, y); + if px.iter().any(|v| !v.is_finite()) { + bad.push(px); + } + } + } + assert!(bad.is_empty(), "exposure {exposure}: {} non-finite pixels, e.g. {:?}", bad.len(), bad.first()); + } +} diff --git a/crates/engine/tests/clipping_semantics.rs b/crates/engine/tests/clipping_semantics.rs new file mode 100644 index 0000000000..1a4aa562a7 --- /dev/null +++ b/crates/engine/tests/clipping_semantics.rs @@ -0,0 +1,75 @@ +//! Clip groups through Release Clipping Mask, Merge Down, Merge Layers and Delete Hidden Layers, +//! as in Photoshop (layers audit L2). The stack: Background, B, C1*, C2* (C1 and C2 clipped to B). + +use photocraft_engine::Session; +use serde_json::json; + +fn stack() -> (Session, [u64; 3]) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap(); + let mut ids = [0u64; 3]; + for (i, color) in ["#ff0000", "#00ff00", "#0000ff"].iter().enumerate() { + ids[i] = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 4 * i, "y": 4, "width": 12, "height": 12})).unwrap(); + s.execute("edit.fill", json!({"color": color})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + } + for c in &ids[1..] { + s.execute("layer.setProps", json!({"layer": c, "clipped": true})).unwrap(); + } + (s, ids) +} + +fn clipped(s: &Session, id: u64) -> bool { + s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clipped +} + +#[test] +fn releasing_a_middle_layer_releases_the_ones_above_it() { + let (mut s, [_, c1, c2]) = stack(); + let r = s.execute("layer.releaseClippingMask", json!({"layer": c1})).unwrap(); + assert!(!clipped(&s, c1) && !clipped(&s, c2), "C2 isn't left clipped to the released C1: {r}"); + // Releasing the top one leaves the one below it alone. + let (mut s, [_, c1, c2]) = stack(); + s.execute("layer.releaseClippingMask", json!({"layer": c2})).unwrap(); + assert!(clipped(&s, c1) && !clipped(&s, c2)); +} + +#[test] +fn merging_clipped_layers_keeps_the_result_clipped() { + let (mut s, [_, c1, c2]) = stack(); + s.execute("layer.select", json!({"layer": c2})).unwrap(); + let r = s.execute("layer.mergeDown", json!({})).unwrap(); + let merged = r["layer"].as_u64().unwrap_or(c1); + assert!(clipped(&s, merged), "C2 merged down into C1 stays clipped to B"); + let (mut s, [_, c1, c2]) = stack(); + s.execute("layer.select", json!({"layer": c1})).unwrap(); + s.execute("layer.select", json!({"layer": c2, "mode": "add"})).unwrap(); + let r = s.execute("layer.mergeLayers", json!({})).unwrap(); + assert!(clipped(&s, r["layer"].as_u64().unwrap()), "C1 + C2 merged stay clipped"); + // Clipped layers of two chains (Background, B, C1*, E, D*: C1 clipped to B, D to E) merge + // into an ordinary layer: kept clipped at D's slot, C1's pixels would clip to E. + let (mut s, [_, c1, e]) = stack(); + s.execute("layer.setProps", json!({"layer": e, "clipped": false})).unwrap(); + let d = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("edit.fill", json!({"color": "#808080"})).unwrap(); + s.execute("layer.setProps", json!({"layer": d, "clipped": true})).unwrap(); + s.execute("layer.select", json!({"layer": c1})).unwrap(); + s.execute("layer.select", json!({"layer": d, "mode": "add"})).unwrap(); + let r = s.execute("layer.mergeLayers", json!({})).unwrap(); + assert!(!clipped(&s, r["layer"].as_u64().unwrap()), "C1 + D come from two clip chains"); + // Merging the base with its clipped layers gives an ordinary layer. + let (mut s, [b, _, c2]) = stack(); + s.execute("layer.select", json!({"layer": b})).unwrap(); + s.execute("layer.select", json!({"layer": c2, "mode": "add"})).unwrap(); + let r = s.execute("layer.mergeLayers", json!({})).unwrap(); + assert!(!clipped(&s, r["layer"].as_u64().unwrap())); +} + +#[test] +fn deleting_a_hidden_base_releases_its_clipped_layers() { + let (mut s, [b, c1, c2]) = stack(); + s.execute("layer.setProps", json!({"layer": b, "visible": false})).unwrap(); + s.execute("layer.delete.hiddenLayers", json!({})).unwrap(); + assert!(!clipped(&s, c1) && !clipped(&s, c2), "they don't clip to the layer below the deleted base"); +} diff --git a/crates/engine/tests/crop_content_aware.rs b/crates/engine/tests/crop_content_aware.rs new file mode 100644 index 0000000000..f41d46a9b5 --- /dev/null +++ b/crates/engine/tests/crop_content_aware.rs @@ -0,0 +1,159 @@ +//! Crop tool › Fill: Content-Aware Fill (#3823). A rotated crop, or one that extends past the +//! canvas, fills the area the image doesn't cover from its surroundings instead of the background +//! colour, in the crop's one undo step; an unrotated crop inside the canvas is unaffected. + +use photocraft_engine::Session; +use serde_json::{Value, json}; + +const MODES: [(&str, u32); 5] = [("rgb", 8), ("rgb", 16), ("rgb", 32), ("grayscale", 16), ("cmyk", 8)]; + +/// A 64 × 48 document whose Background is a light green, with black as the background colour (what +/// a plain crop puts in the uncovered area). +fn doc(mode: &str, depth: u32, background: &str) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "mode": mode, "depth": depth, "background": background})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#c0e0a0", "background": "#000000"})).unwrap(); + s.execute("edit.fill", json!({"contents": "foreground"})).unwrap(); + s +} + +fn layer(s: &Session) -> &photocraft_raster::Surface { + let d = &s.active().unwrap().doc; + d.layers[0].surface().unwrap() +} + +fn size(s: &Session) -> (u32, u32) { + let d = &s.active().unwrap().doc; + (d.size.width, d.size.height) +} + +fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < tol) +} + +/// Every pixel of the cropped layer is the image's colour: no black (or transparent) area left. +fn assert_all_image(s: &Session, want: [f32; 4], what: &str) { + let (w, h) = size(s); + let surf = layer(s); + for y in 0..h as i32 { + for x in 0..w as i32 { + let px = surf.rgba(x, y); + assert!(close(px, want, 0.06), "{what}: ({x}, {y}) is {px:?}, want {want:?}"); + } + } +} + +fn crop(s: &mut Session, mut p: Value, content_aware: bool) -> photocraft_engine::Result { + p["contentAware"] = json!(content_aware); + s.execute("image.crop", p) +} + +#[test] +fn rotated_crop_fills_the_corners_in_every_mode() { + for (mode, depth) in MODES { + let frame = json!({"x": 0, "y": 0, "width": 64, "height": 48, "angle": 10}); + let mut plain = doc(mode, depth, "white"); + let want = layer(&plain).rgba(32, 24); + crop(&mut plain, frame.clone(), false).unwrap(); + assert!(close(layer(&plain).rgba(0, 0), [0.0, 0.0, 0.0, 1.0], 0.06), "{mode} {depth}: a plain rotated crop leaves black corners"); + + let mut s = doc(mode, depth, "white"); + let past = s.active().unwrap().history.past_len(); + crop(&mut s, frame, true).unwrap(); + assert_eq!(size(&s), (64, 48), "{mode} {depth}"); + assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{mode} {depth}: one undo step"); + assert_all_image(&s, want, &format!("{mode} {depth} rotated")); + } +} + +#[test] +fn extending_crop_fills_the_added_canvas_in_every_mode() { + for (mode, depth) in MODES { + let frame = json!({"x": -8, "y": -6, "width": 80, "height": 60}); + let mut plain = doc(mode, depth, "white"); + let want = layer(&plain).rgba(32, 24); + crop(&mut plain, frame.clone(), false).unwrap(); + assert!(close(layer(&plain).rgba(0, 0), [0.0, 0.0, 0.0, 1.0], 0.06), "{mode} {depth}"); + + let mut s = doc(mode, depth, "white"); + crop(&mut s, frame, true).unwrap(); + assert_eq!(size(&s), (80, 60), "{mode} {depth}"); + assert_all_image(&s, want, &format!("{mode} {depth} extended")); + } +} + +/// Without a Background the active pixel layer is filled: no transparent strip is left. +#[test] +fn extending_crop_fills_the_active_layer_without_a_background() { + let mut s = doc("rgb", 16, "transparent"); + let want = layer(&s).rgba(32, 24); + crop(&mut s, json!({"x": 0, "y": -10, "width": 64, "height": 58}), true).unwrap(); + assert_all_image(&s, want, "transparent document"); +} + +/// Undo takes the crop and the fill back in one step. +#[test] +fn undo_restores_crop_and_fill_in_one_step() { + let mut s = doc("rgb", 8, "white"); + s.execute("tools.setColors", json!({"foreground": "#ff0000", "background": "#000000"})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap(); + s.execute("edit.fill", json!({"contents": "foreground"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let before = s.active().unwrap().doc.clone(); + crop(&mut s, json!({"x": -4, "y": -4, "width": 40, "height": 40, "angle": -6}), true).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(size(&s), (64, 48)); + let after = layer(&s); + let orig = before.layers[0].surface().unwrap(); + for y in 0..48 { + for x in 0..64 { + assert_eq!(after.rgba(x, y), orig.rgba(x, y), "({x}, {y})"); + } + } +} + +/// `contentAware: false` (or absent), and a crop that uncovers nothing, are the plain crop. +#[test] +fn nothing_uncovered_or_off_is_the_plain_crop() { + for (frame, ca) in [ + (json!({"x": 5, "y": 4, "width": 30, "height": 20}), true), + (json!({"x": -5, "y": -4, "width": 80, "height": 60}), false), + (json!({"x": 20, "y": 15, "width": 20, "height": 14, "angle": 4}), true), + ] { + let mut a = doc("rgb", 16, "white"); + let mut b = doc("rgb", 16, "white"); + a.execute("image.crop", frame.clone()).unwrap(); + crop(&mut b, frame.clone(), ca).unwrap(); + assert_eq!(size(&a), size(&b), "{frame}"); + let (w, h) = size(&a); + for y in 0..h as i32 { + for x in 0..w as i32 { + assert_eq!(layer(&a).rgba(x, y), layer(&b).rgba(x, y), "{frame} ({x}, {y})"); + } + } + } +} + +/// Bad params and an unfillable crop fail without changing the document. +#[test] +fn bad_params_and_unfillable_crops_fail_cleanly() { + let frame = json!({"x": -4, "y": 0, "width": 70, "height": 48}); + for v in [json!("yes"), json!(1), json!([true]), json!({"on": true})] { + let mut s = doc("rgb", 8, "white"); + let past = s.active().unwrap().history.past_len(); + let mut p = frame.clone(); + p["contentAware"] = v.clone(); + assert!(s.execute("image.crop", p).is_err(), "{v}"); + assert_eq!(size(&s), (64, 48), "{v}"); + assert_eq!(s.active().unwrap().history.past_len(), past, "{v}"); + } + // Entirely off the canvas: nothing of the image to sample from (both paths). + for p in [json!({"x": 500, "y": 500, "width": 20, "height": 20}), json!({"x": 500, "y": 500, "width": 20, "height": 20, "angle": 30})] { + let mut s = doc("rgb", 8, "white"); + let past = s.active().unwrap().history.past_len(); + let err = crop(&mut s, p.clone(), true).unwrap_err(); + assert!(err.to_string().contains("sample"), "{p}: {err}"); + assert_eq!(size(&s), (64, 48), "{p}"); + assert_eq!(s.active().unwrap().history.past_len(), past, "{p}"); + } +} diff --git a/crates/engine/tests/duplicate_slices.rs b/crates/engine/tests/duplicate_slices.rs new file mode 100644 index 0000000000..5f5d21c621 --- /dev/null +++ b/crates/engine/tests/duplicate_slices.rs @@ -0,0 +1,35 @@ +use photocraft_doc::SliceOrigin; +use photocraft_engine::Session; +use serde_json::{Value, json}; + +fn sliced() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("layer.new.layer", json!({"name": "logo"})).unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 10], [20, 10]], "size": 8, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + s.execute("layer.newLayerBasedSlice", json!({"url": "https://example.com", "alt": "logo"})).unwrap(); + s +} + +fn layer_slice(s: &mut Session) -> Option { + let list = s.execute("slice.list", json!({})).unwrap(); + list["slices"].as_array().unwrap().iter().find(|sl| sl["origin"] == "layer").cloned() +} + +#[test] +fn duplicating_a_document_keeps_its_layer_based_slices() { + for cmd in ["image.duplicate", "history.newDocument"] { + let mut s = sliced(); + s.execute(cmd, json!({})).unwrap(); + let st = s.active().unwrap(); + let logo = st.doc.walk().into_iter().find(|(_, _, l)| l.name == "logo").unwrap().2.id; + let slices: Vec<_> = st.doc.slices.list.iter().filter(|sl| sl.origin == SliceOrigin::Layer).collect(); + assert_eq!(slices.len(), 1, "{cmd}"); + assert_eq!(slices[0].layer, Some(logo), "{cmd}"); + assert_eq!((slices[0].url.as_str(), slices[0].alt.as_str()), ("https://example.com", "logo"), "{cmd}"); + let before = layer_slice(&mut s).unwrap()["rect"].clone(); + s.execute("layer.translate", json!({"dx": 5, "dy": 3})).unwrap(); + let after = layer_slice(&mut s).unwrap_or_else(|| panic!("{cmd} lost the slice after a move"))["rect"].clone(); + assert_ne!(before, after, "{cmd}: the slice no longer follows its layer"); + } +} diff --git a/crates/engine/tests/duplicate_variables.rs b/crates/engine/tests/duplicate_variables.rs new file mode 100644 index 0000000000..48d197b51e --- /dev/null +++ b/crates/engine/tests/duplicate_variables.rs @@ -0,0 +1,146 @@ +//! Variable definitions must continue targeting their layers after a copied document gets +//! fresh ids (#4969), and a merged-only copy must not keep bindings to discarded layers. + +use photocraft_doc::{Document, Layer, LayerContent, LayerId, Size, TextLayer}; +use photocraft_engine::Session; +use serde_json::json; + +fn scene() -> (Session, LayerId, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let ids = s + .edit("setup", |doc, _| { + let fmt = doc.pixel_format(); + let mut photo = Layer::raster("photo", fmt); + photo.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 64, 48), &photocraft_raster::from_rgba(&fmt, [0.5, 0.5, 0.5, 1.0])); + let badge = Layer::raster("badge", fmt); + let title = Layer::new("title", LayerContent::Text(TextLayer { text: "Old".into(), ..Default::default() })); + let (p, b, t) = (photo.id, badge.id, title.id); + doc.layers.push(photo); + doc.layers.push(badge); + doc.layers.push(title); + Ok((p, b, t)) + }) + .unwrap(); + (s, ids.0, ids.1, ids.2) +} + +fn red_png(dir: &str) -> String { + let doc = Document::with_background( + "red", + Size::new(4, 4), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_doc::Color::rgb(1.0, 0.0, 0.0), + ); + let bytes = photocraft_io::export(&doc, "png", &photocraft_io::ExportOptions::default()).unwrap().bytes; + let path = format!("{dir}/replacement.png"); + std::fs::write(&path, bytes).unwrap(); + path +} + +fn copied_layer(s: &Session, name: &str) -> LayerId { + s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id +} + +fn define_all(s: &mut Session, photo: LayerId, badge: LayerId, title: LayerId, png: &str) { + s.execute( + "image.variables.define", + json!({"defs": [ + {"name": "showBadge", "layer": badge.0, "type": "visibility"}, + {"name": "headline", "layer": title.0, "type": "textReplacement"}, + {"name": "photo", "layer": photo.0, "type": "pixelReplacement", "method": "conform"}, + ]}), + ) + .unwrap(); + s.execute( + "image.variables.dataSets", + json!({"dataSets": [ + {"name": "A", "values": [ + {"variable": "showBadge", "kind": "visibility", "value": false}, + {"variable": "headline", "kind": "text", "value": "Hello"}, + {"variable": "photo", "kind": "pixels", "value": png}, + ]}, + ]}), + ) + .unwrap(); +} + +#[test] +fn duplicated_documents_keep_variables_targeting_the_copied_layers() { + let dir = std::env::temp_dir().join(format!("pc-dup-vars-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = red_png(&dir.to_string_lossy()); + + for command in ["image.duplicate", "history.newDocument"] { + let (mut s, photo, badge, title) = scene(); + define_all(&mut s, photo, badge, title, &png); + s.execute(command, json!({})).unwrap(); + + let doc = &s.active().unwrap().doc; + let new_photo = copied_layer(&s, "photo"); + let new_badge = copied_layer(&s, "badge"); + let new_title = copied_layer(&s, "title"); + assert_ne!(new_photo, photo, "{command}: layers must get new identities"); + let targets: Vec = doc.variables.defs.iter().map(|d| d.layer).collect(); + assert_eq!(targets, vec![new_badge, new_title, new_photo], "{command}: variable defs follow their layers"); + + let r = s.execute("image.applyDataSet", json!({"name": "A"})).unwrap(); + assert_eq!(r["applied"], "A"); + let doc = &s.active().unwrap().doc; + assert!(!doc.layer(new_badge).unwrap().visible, "{command}: visibility drives the copy"); + match &doc.layer(new_title).unwrap().content { + LayerContent::Text(t) => assert_eq!(t.text, "Hello", "{command}: text drives the copy"), + _ => panic!("not a text layer"), + } + let px = doc.layer(new_photo).unwrap().surface().unwrap().pixel(0, 0); + assert!(px[0] > 0.9 && px[1] < 0.1, "{command}: pixel replacement drives the copy: {px:?}"); + + // The source document still targets its own layers and is untouched. + s.set_active(0); + let src = &s.active().unwrap().doc; + let src_targets: Vec = src.variables.defs.iter().map(|d| d.layer).collect(); + assert_eq!(src_targets, vec![badge, title, photo], "{command}: source defs untouched"); + assert!(src.layer(badge).unwrap().visible); + assert!(src.layer(photo).unwrap().surface().unwrap().pixel(0, 0)[0] < 0.9); + } +} + +#[test] +fn dangling_variable_targets_stay_dangling_after_duplication() { + let (mut s, photo, badge, title) = scene(); + define_all(&mut s, photo, badge, title, "unused.png"); + // A def may already refer to a deleted layer before duplication. + let missing = LayerId(u64::MAX); + s.edit("Break one def", |doc, _| { + doc.variables.defs.iter_mut().find(|d| d.name == "showBadge").unwrap().layer = missing; + Ok(()) + }) + .unwrap(); + s.execute("image.duplicate", json!({})).unwrap(); + + let doc = &s.active().unwrap().doc; + let def = doc.variables.defs.iter().find(|d| d.name == "showBadge").unwrap(); + assert_eq!(def.layer, missing, "a deleted target must not be rewritten to an unrelated new id"); + let photo_def = doc.variables.defs.iter().find(|d| d.name == "photo").unwrap(); + assert_eq!(photo_def.layer, copied_layer(&s, "photo"), "live targets still remap"); +} + +#[test] +fn merged_only_duplicate_does_not_carry_variables_for_removed_layers() { + let dir = std::env::temp_dir().join(format!("pc-dup-vars-merged-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = red_png(&dir.to_string_lossy()); + let (mut s, photo, badge, title) = scene(); + define_all(&mut s, photo, badge, title, &png); + + s.execute("image.duplicate", json!({"mergedOnly": true})).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.layers.len(), 1, "merged-only copy is a single composite layer"); + assert!(d.variables.defs.is_empty(), "old variables cannot address discarded layers"); + assert!(d.variables.data_sets.is_empty()); + assert_eq!(d.variables.active, None); + + s.set_active(0); + assert_eq!(s.active().unwrap().doc.variables.defs.len(), 3, "original variables are preserved"); +} diff --git a/crates/engine/tests/eraser_locked.rs b/crates/engine/tests/eraser_locked.rs index ae160ca277..d7405a7311 100644 --- a/crates/engine/tests/eraser_locked.rs +++ b/crates/engine/tests/eraser_locked.rs @@ -122,7 +122,7 @@ fn eraser_bad_params_fail_gracefully() { assert!(s.execute("paint.stroke", json!({"erase": true})).is_err()); assert!(s.execute("paint.stroke", json!({"erase": true, "points": []})).is_err()); assert!(s.execute("paint.stroke", json!({"erase": true, "points": "x"})).is_err()); - assert!(erase(&mut s, json!({"layer": 99999})).is_err()); + assert!(erase(&mut s, json!({"layer": u64::MAX})).is_err()); assert!(erase(&mut s, json!({"target": "mask"})).is_err(), "no mask"); // Absurd coordinates are refused up front (a stroke that long would run billions of dabs). assert!(erase(&mut s, json!({"points": [[0, 0], [f64::MAX, 0.0]]})).is_err()); diff --git a/crates/engine/tests/explicit_layer_target.rs b/crates/engine/tests/explicit_layer_target.rs index 6479b9c6d7..6d968c410a 100644 --- a/crates/engine/tests/explicit_layer_target.rs +++ b/crates/engine/tests/explicit_layer_target.rs @@ -67,7 +67,7 @@ fn a_named_layer_of_the_wrong_kind_or_that_does_not_exist_is_refused() { let r = s.execute("layer.smartObjects.rasterize", json!({"layer": pixels})); assert!(matches!(r, Err(EngineError::Disabled(..))), "{r:?}"); // A layer that doesn't exist falls back to the active layer's check, and still fails. - assert!(s.execute("layer.smartObjects.rasterize", json!({"layer": 999_999})).is_err()); + assert!(s.execute("layer.smartObjects.rasterize", json!({"layer": u64::MAX})).is_err()); assert!(s.execute("layer.smartObjects.rasterize", json!({"layer": "nope"})).is_err()); assert_eq!(s.active().unwrap().doc, before); assert_eq!(active(&s), pixels); diff --git a/crates/engine/tests/finite_command_params.rs b/crates/engine/tests/finite_command_params.rs new file mode 100644 index 0000000000..0350d74493 --- /dev/null +++ b/crates/engine/tests/finite_command_params.rs @@ -0,0 +1,154 @@ +//! #6424: rejecting an overflowing f32 parameter must leave a saveable document and history. +use photocraft_engine::{EngineError, Session}; +use serde_json::{Value, json}; + +fn fresh(mode: &str, depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 12, "mode": mode, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s +} + +fn saved(s: &Session) -> Vec { + photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap() +} + +fn rejected(cases: Vec<(&str, Value)>) { + let mut failures = Vec::new(); + for (id, p) in cases { + let mut s = fresh("rgb", 8); + let before = saved(&s); + let undo = s.active().unwrap().history.undo_label().map(str::to_owned); + let result = s.execute(id, p.clone()); + let save = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()); + if !matches!(result, Err(EngineError::BadParams { .. })) { + failures.push(format!("{id} {p}: result={result:?}, save error={:?}", save.as_ref().err())); + continue; + } + assert_eq!(save.unwrap(), before, "rejected {id} must not change the document"); + assert_eq!(s.active().unwrap().history.undo_label(), undo.as_deref(), "rejected {id} must not add history"); + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn overflowing_layer_styles_are_rejected_before_mutation() { + let mut cases = Vec::new(); + for value in [1e39, -1e39] { + for (kind, key) in [ + ("dropShadow", "angle"), + ("innerShadow", "angle"), + ("outerGlow", "size"), + ("innerGlow", "size"), + ("stroke", "size"), + ("satin", "angle"), + ("bevelEmboss", "depth"), + ("gradientOverlay", "angle"), + ("patternOverlay", "phaseX"), + ] { + let id = match kind { + "dropShadow" => "layer.layerStyle.dropShadow", + "innerShadow" => "layer.layerStyle.innerShadow", + "outerGlow" => "layer.layerStyle.outerGlow", + "innerGlow" => "layer.layerStyle.innerGlow", + "stroke" => "layer.layerStyle.stroke", + "satin" => "layer.layerStyle.satin", + "bevelEmboss" => "layer.layerStyle.bevelEmboss", + "gradientOverlay" => "layer.layerStyle.gradientOverlay", + _ => "layer.layerStyle.patternOverlay", + }; + let params = json!({key: value}); + cases.push((id, params.clone())); + cases.push(("layer.layerStyle.replace", json!({"effects": [{"kind": kind, "params": params}]}))); + } + cases.push(("layer.layerStyle.colorOverlay", json!({"color": [value, 0, 0]}))); + } + rejected(cases); +} + +#[test] +fn overflowing_document_colors_are_rejected_before_mutation() { + let mut cases = Vec::new(); + for id in ["layer.newFillLayer.solidColor", "channel.new", "channel.newSpot", "layer.new.artboard", "count.newGroup"] { + for value in [1e39, -1e39] { + for component in 0..4 { + let mut color = [0.5; 4]; + color[component] = value; + cases.push((id, json!({"color": color}))); + } + } + } + rejected(cases); +} + +#[test] +fn overflowing_type_positions_and_colors_are_rejected_before_mutation() { + rejected(vec![ + ("type.create", json!({"text": "hello", "x": 1e39})), + ("type.create", json!({"text": "hello", "y": -1e39})), + ("type.create", json!({"text": "hello", "color": [1e39, 0, 0]})), + ]); +} + +#[test] +fn valid_color_and_style_edits_still_save_and_undo_at_each_depth() { + for mode in ["rgb", "gray", "cmyk", "lab"] { + for depth in [8, 16, 32] { + let mut s = fresh(mode, depth); + let before = saved(&s); + s.execute("layer.layerStyle.dropShadow", json!({"angle": -45, "color": [0.1, 0.3, 0.8]})).unwrap(); + let edited = saved(&s); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(saved(&s), edited); + s.execute("layer.newFillLayer.solidColor", json!({"color": "#4080c0"})).unwrap(); + let bytes = saved(&s); + photocraft_format::load_from_bytes(&bytes).unwrap(); + } + } +} + +#[test] +fn effect_snapshots_cannot_bypass_numeric_validation() { + let mut fx = serde_json::to_value(photocraft_engine::layer_style::effect_from_params("dropShadow", &json!({})).unwrap()).unwrap(); + fx["DropShadow"]["angle"] = json!(1e39); + rejected(vec![( + "layer.layerStyle.replace", + json!({"effects": [ + {"kind": "colorOverlay", "params": {"color": "#00ff00"}}, + {"kind": "dropShadow", "fx": fx, "params": {}} + ]}), + )]); +} + +#[test] +fn direct_effect_helpers_do_not_introduce_infinity() { + let original = photocraft_engine::layer_style::effect_from_params("dropShadow", &json!({})).unwrap(); + for p in [json!({"angle": 1e39}), json!({"color": [0, -1e39, 0]})] { + assert!(photocraft_engine::layer_style::effect_from_params("dropShadow", &p).is_none()); + let mut edited = original.clone(); + photocraft_engine::layer_style::overlay_effect(&mut edited, &p); + assert_eq!(edited, original); + } +} + +#[test] +fn bad_background_color_does_not_partially_change_tool_colors() { + let mut s = fresh("rgb", 32); + let before = (s.tools.foreground, s.tools.background); + let result = s.execute("tools.setColors", json!({"foreground": "#40a0c0", "background": [0, 1e39, 0]})); + assert!(matches!(result, Err(EngineError::BadParams { .. }))); + assert_eq!((s.tools.foreground, s.tools.background), before); +} + +#[test] +fn finite_hdr_colors_are_preserved_without_unit_clamping() { + let mut s = fresh("rgb", 32); + let id = s.execute("layer.newFillLayer.solidColor", json!({"color": [4.0, -0.5, 0.25]})).unwrap()["layer"].as_u64().unwrap(); + let bytes = saved(&s); + let reopened = photocraft_format::load_from_bytes(&bytes).unwrap(); + let layer = reopened.layer(photocraft_doc::LayerId(id)).unwrap(); + let photocraft_doc::LayerContent::Fill(photocraft_doc::Fill::Solid(c)) = &layer.content else { panic!("expected a solid fill") }; + assert_eq!(c.to_rgb(), [4.0, -0.5, 0.25]); +} diff --git a/crates/engine/tests/gradient_overlay_strokes.rs b/crates/engine/tests/gradient_overlay_strokes.rs new file mode 100644 index 0000000000..e233f8984d --- /dev/null +++ b/crates/engine/tests/gradient_overlay_strokes.rs @@ -0,0 +1,89 @@ +//! A Gradient Overlay spans one gradient over the layer's current bounds, so a stroke that grows +//! the layer re-lays the gradient out over all of it (#4053). The damage a stroke reports must +//! cover that, or a canvas that redraws only the damage keeps the old gradient around the earlier +//! strokes: one gradient per stroke instead of one over the whole layer. + +use photocraft_engine::Session; +use serde_json::json; + +/// What a canvas that redraws only each edit's damage shows: the cold render before the edit, +/// with the edit's damage (grown by the effects' reach, as the canvas grows it) rendered again. +fn redraw_damage(shown: &mut photocraft_compose::Buffer, s: &Session) { + let st = s.active().unwrap(); + let reach = st.doc.layers.iter().filter(|l| photocraft_compose::effects::has_effects(l)).map(photocraft_compose::effects::margin).max().unwrap_or(0); + let damage = st.last_damage.expect("a stroke reports bounded damage").inflate(reach).intersect(&st.doc.bounds()); + let (w, h) = (st.doc.size.width, st.doc.size.height); + let patch = photocraft_compose::render_reduced_damage(&st.doc, w, h, damage); + for y in patch.rect.y0..patch.rect.y1 { + for x in patch.rect.x0..patch.rect.x1 { + let i = (y * w as i32 + x) as usize; + shown.px[i] = patch.get(x, y); + } + } +} + +fn session(effect: &str, params: serde_json::Value) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 160, "height": 80, "background": "white"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute(effect, params).unwrap(); + s +} + +const GREY: &str = "#808080"; + +/// Each stroke redrawn over the last cold render equals a cold render after it. +fn strokes_redraw_like_a_cold_render(mut s: Session, strokes: &[serde_json::Value]) { + s.execute("paint.stroke", json!({"points": [[20, 20], [40, 30]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap(); + for (i, p) in strokes.iter().enumerate() { + let st = s.active().unwrap(); + let mut shown = photocraft_compose::render(&st.doc, st.doc.bounds()); + s.execute("paint.stroke", p.clone()).unwrap(); + redraw_damage(&mut shown, &s); + let st = s.active().unwrap(); + let cold = photocraft_compose::render(&st.doc, st.doc.bounds()); + let worst = shown.px.iter().zip(&cold.px).map(|(a, b)| (0..4).map(|c| (a[c] - b[c]).abs()).fold(0.0f32, f32::max)).fold(0.0f32, f32::max); + assert!(worst < 1e-6, "after stroke {i} the redrawn canvas differs from a cold render by {worst}"); + } +} + +#[test] +fn a_gradient_overlay_is_laid_out_again_after_each_stroke() { + let s = session("layer.layerStyle.gradientOverlay", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})); + strokes_redraw_like_a_cold_render( + s, + &[ + // Far from the first: the layer grows to the right and down. + json!({"points": [[120, 50], [140, 64]], "size": 12, "hardness": 1.0, "color": GREY}), + // Up and to the left of both. + json!({"points": [[6, 6], [10, 8]], "size": 6, "hardness": 1.0, "color": GREY}), + // Erasing the far stroke shrinks the layer again. + json!({"points": [[110, 40], [150, 74]], "size": 40, "hardness": 1.0, "erase": true}), + ], + ); +} + +#[test] +fn a_stroke_inside_the_layer_keeps_its_own_damage() { + let mut s = session("layer.layerStyle.gradientOverlay", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})); + s.execute("paint.stroke", json!({"points": [[10, 10], [150, 70]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap(); + // Within the layer's bounds: the gradient's frame stays, so only the stroke repaints. + let r = s.execute("paint.stroke", json!({"points": [[60, 40], [70, 40]], "size": 6, "hardness": 1.0, "color": GREY})).unwrap(); + let d = &r["damage"]; + let reported = photocraft_geom::Rect::new( + d[0].as_i64().unwrap() as i32, + d[1].as_i64().unwrap() as i32, + (d[0].as_i64().unwrap() + d[2].as_i64().unwrap()) as i32, + (d[1].as_i64().unwrap() + d[3].as_i64().unwrap()) as i32, + ); + assert_eq!(s.active().unwrap().last_damage, Some(reported)); +} + +#[test] +fn effects_not_laid_out_over_the_layer_keep_the_stroke_damage() { + let mut s = session("layer.layerStyle.colorOverlay", json!({"color": "#00ff00"})); + s.execute("paint.stroke", json!({"points": [[20, 20], [40, 30]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap(); + s.execute("paint.stroke", json!({"points": [[120, 50], [140, 64]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap(); + let d = s.active().unwrap().last_damage.unwrap(); + assert!(d.x0 >= 100, "a Color Overlay repaints only the stroke: {d:?}"); +} diff --git a/crates/engine/tests/layer_mask_depth.rs b/crates/engine/tests/layer_mask_depth.rs index 082761286c..7093164a5f 100644 --- a/crates/engine/tests/layer_mask_depth.rs +++ b/crates/engine/tests/layer_mask_depth.rs @@ -45,7 +45,12 @@ fn newly_created_layer_masks_follow_document_depth() { assert_eq!(m.value(20, 20), white); if selected { assert_eq!(m.value(0, 0), 0.0); - assert_eq!(d.doc.selection, before.selection, "the selection is unchanged"); + if command == "layer.layerMask.revealSelection" { + // The selection became the mask: Photoshop drops it, as Hide Selection does. + assert!(d.doc.selection.is_none(), "{command}: the selection is dropped"); + } else { + assert_eq!(d.doc.selection, before.selection, "the selection is unchanged"); + } } else { assert_eq!(m.surface.tile_count(), 0, "uniform masks stay sparse"); assert_eq!(m.value(-300_000, 300_000), white); @@ -133,7 +138,8 @@ fn mask_creation_preserves_selection_coverage_and_existing_masks() { before.selection.as_ref().unwrap().read_region(before.bounds()), "{depth}-bit coverage is unchanged" ); - assert_eq!(after.selection, before.selection); + let kept = if command == "layer.layerMask.revealSelection" { None } else { before.selection.clone() }; + assert_eq!(after.selection, kept, "{command}"); assert_eq!(after.layers[0].mask, before.layers[0].mask, "creating a mask does not requantize an existing mask"); } } diff --git a/crates/engine/tests/layer_props_several.rs b/crates/engine/tests/layer_props_several.rs new file mode 100644 index 0000000000..beee852d1c --- /dev/null +++ b/crates/engine/tests/layer_props_several.rs @@ -0,0 +1,72 @@ +//! `layer.setProps` on several layers at once (the Layers panel with several selected, as in +//! Photoshop), and Photoshop's History names for each kind of change. + +use photocraft_engine::Session; +use serde_json::json; + +fn doc() -> (Session, Vec) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap(); + let ids = (0..3).map(|_| s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()).collect(); + (s, ids) +} + +fn layer(s: &Session, id: u64) -> photocraft_doc::Layer { + s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone() +} + +fn last_step(s: &Session) -> String { + s.active().unwrap().history.entries().last().cloned().unwrap_or_default() +} + +#[test] +fn one_change_reaches_every_listed_layer_in_one_step() { + let (mut s, ids) = doc(); + let steps = s.active().unwrap().history.entries().len(); + s.execute("layer.setProps", json!({"layers": [ids[0], ids[2]], "opacity": 0.4})).unwrap(); + assert_eq!(s.active().unwrap().history.entries().len(), steps + 1, "one step"); + assert_eq!(last_step(&s), "Opacity Change"); + assert_eq!((layer(&s, ids[0]).opacity, layer(&s, ids[1]).opacity, layer(&s, ids[2]).opacity), (0.4, 1.0, 0.4)); + s.execute("layer.setProps", json!({"layers": ids, "blend": "Multiply"})).unwrap(); + assert_eq!(last_step(&s), "Blending Change"); + assert!(ids.iter().all(|id| layer(&s, *id).blend == photocraft_color::BlendMode::Multiply)); + s.execute("layer.setProps", json!({"layers": [ids[1], ids[1], ids[2]], "locks": {"all": true}})).unwrap(); + assert!(layer(&s, ids[1]).locks.all && layer(&s, ids[2]).locks.all && !layer(&s, ids[0]).locks.all, "duplicates are harmless"); + s.execute("layer.setProps", json!({"layers": [ids[0]], "fill": 0.5})).unwrap(); + assert_eq!(last_step(&s), "Fill Opacity Change"); + s.execute("layer.setProps", json!({"layer": ids[0], "name": "Sky"})).unwrap(); + assert_eq!(last_step(&s), "Rename Layer"); + s.execute("layer.setProps", json!({"layer": ids[0], "opacity": 0.3, "fill": 0.3})).unwrap(); + assert_eq!(last_step(&s), "Layer Properties", "a mix keeps the general name"); +} + +#[test] +fn bad_layer_lists_change_nothing() { + let (mut s, ids) = doc(); + let before = s.active().unwrap().doc.clone(); + for p in [ + json!({"layers": [], "opacity": 0.5}), + json!({"layers": "all", "opacity": 0.5}), + json!({"layers": ["x"], "opacity": 0.5}), + json!({"layers": [ids[0], 999_999], "opacity": 0.5}), + json!({"layers": [ids[0], ids[1]], "name": "Both"}), + json!({"layers": [ids[0], ids[1]], "opacity": 2.0}), + ] { + assert!(s.execute("layer.setProps", p.clone()).is_err(), "{p}"); + assert_eq!(s.active().unwrap().doc.layers, before.layers, "{p}"); + } +} + +#[test] +fn pass_through_is_for_groups_only() { + let (mut s, ids) = doc(); + let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap(); + // A pixel layer, alone or with a group, can't take Pass Through; nothing changes. + assert!(s.execute("layer.setProps", json!({"layer": ids[0], "blend": "Pass Through"})).is_err()); + let before = (layer(&s, ids[0]).blend, layer(&s, g).blend); + assert!(s.execute("layer.setProps", json!({"layers": [g, ids[0]], "blend": "Pass Through"})).is_err()); + assert_eq!((layer(&s, ids[0]).blend, layer(&s, g).blend), before); + s.execute("layer.setProps", json!({"layers": [g], "blend": "Normal"})).unwrap(); + s.execute("layer.setProps", json!({"layer": g, "blend": "Pass Through"})).unwrap(); + assert_eq!(layer(&s, g).blend, photocraft_color::BlendMode::PassThrough); +} diff --git a/crates/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}"); +} diff --git a/crates/engine/tests/nonfinite_style.rs b/crates/engine/tests/nonfinite_style.rs new file mode 100644 index 0000000000..994f3dbe6e --- /dev/null +++ b/crates/engine/tests/nonfinite_style.rs @@ -0,0 +1,56 @@ +//! Character style numbers that overflow `f32` are refused, so the document stays saveable. + +use photocraft_engine::{EngineError, Session}; +use serde_json::json; + +fn fixture() -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + let id = s.execute("type.create", json!({"text": "Hi", "size": 24})).unwrap()["layer"].as_u64().unwrap(); + (s, id) +} + +#[test] +fn out_of_range_style_numbers_are_refused() { + for key in ["leading", "baselineShift", "horizontalScale", "verticalScale"] { + let (mut s, id) = fixture(); + let past = s.active().unwrap().history.past_len(); + for value in [1e39, -1e39, 1e12] { + let r = s.execute("type.setStyle", json!({"layer": id, key: value})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{key}={value}: {r:?}"); + } + assert_eq!(s.active().unwrap().history.past_len(), past, "{key}: no history step"); + let r = s.execute("type.create", json!({"text": "x", key: 1e39})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "create {key}: {r:?}"); + let dir = std::env::temp_dir().join(format!("pc_nonfinite_style_{key}_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("t.pcraft"); + let saved = s.execute("file.saveACopy", json!({"path": path.to_string_lossy()})); + std::fs::remove_dir_all(&dir).ok(); + saved.unwrap_or_else(|e| panic!("{key}: save failed: {e:?}")); + } +} + +#[test] +fn in_range_style_numbers_still_apply_and_save() { + let (mut s, id) = fixture(); + s.execute("type.setStyle", json!({"layer": id, "leading": 30, "baselineShift": -4, "horizontalScale": 120, "verticalScale": 80})).unwrap(); + s.execute("type.setStyle", json!({"layer": id, "leading": "auto"})).unwrap(); + let dir = std::env::temp_dir().join(format!("pc_nonfinite_style_ok_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("t.pcraft"); + let saved = s.execute("file.saveACopy", json!({"path": path.to_string_lossy()})); + std::fs::remove_dir_all(&dir).ok(); + saved.unwrap_or_else(|e| panic!("save failed: {e:?}")); +} + +#[test] +fn non_numeric_leading_is_refused() { + let (mut s, id) = fixture(); + let past = s.active().unwrap().history.past_len(); + for value in [json!("18pt"), json!("Auto "), json!(true), json!([12])] { + let r = s.execute("type.setStyle", json!({"layer": id, "leading": value})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "leading={value}: {r:?}"); + } + assert_eq!(s.active().unwrap().history.past_len(), past, "no history step"); +} diff --git a/crates/engine/tests/panic_hunt.rs b/crates/engine/tests/panic_hunt.rs index c73980a2f2..893ba5c23d 100644 --- a/crates/engine/tests/panic_hunt.rs +++ b/crates/engine/tests/panic_hunt.rs @@ -5,7 +5,8 @@ use photocraft_engine::{Session, command_specs}; use serde_json::{Value, json}; use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::sync::mpsc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Mutex, Once, PoisonError, mpsc}; use std::time::Duration; fn fresh() -> Session { @@ -22,19 +23,41 @@ fn fresh() -> Session { /// 0.3 s even on a heavily loaded machine, so this only catches real hangs. const DEADLINE: Duration = Duration::from_secs(4); -/// Run `id` with `p` on a fresh session, on a worker thread. `None` if it timed out, else whether -/// it finished without panicking (returning Ok or a graceful Err both count). -fn run_once(id: &str, p: &Value) -> Option { - let (tx, rx) = mpsc::channel(); - let (id, p) = (id.to_string(), p.clone()); - std::thread::spawn(move || { - let r = catch_unwind(AssertUnwindSafe(|| { - let mut s = fresh(); - let _ = s.execute(&id, p); +/// Panics anywhere in the process, counted by a hook. `Session::execute` contains a command's +/// panic and returns an ordinary `Err`, so the unwind never reaches `catch_unwind` here; the hook +/// still sees it, on whichever thread (rayon workers included) it happened (#4982). +static PANICS: AtomicUsize = AtomicUsize::new(0); +/// One run at a time, so a panic is charged to the run that caused it. +static RUN: Mutex<()> = Mutex::new(()); + +/// Run `f` on a worker thread. `None` if it timed out, else whether it finished without +/// panicking, contained or not (returning Ok or a graceful Err both count). +fn run_watched(f: impl FnOnce() + Send + 'static) -> Option { + static HOOK: Once = Once::new(); + HOOK.call_once(|| { + let default = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + PANICS.fetch_add(1, Ordering::SeqCst); + default(info); })); + }); + let _one = RUN.lock().unwrap_or_else(PoisonError::into_inner); + let before = PANICS.load(Ordering::SeqCst); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let r = catch_unwind(AssertUnwindSafe(f)); let _ = tx.send(r.is_ok()); }); - rx.recv_timeout(DEADLINE).ok() + rx.recv_timeout(DEADLINE).ok().map(|ok| ok && PANICS.load(Ordering::SeqCst) == before) +} + +/// Run `id` with `p` on a fresh session; see [`run_watched`]. +fn run_once(id: &str, p: &Value) -> Option { + let (id, p) = (id.to_string(), p.clone()); + run_watched(move || { + let mut s = fresh(); + let _ = s.execute(&id, p); + }) } /// `Err` with a reason on panic or hang, `Ok` otherwise. @@ -60,13 +83,18 @@ fn no_command_panics_or_hangs_on_adversarial_params() { json!({"x": 1e5, "y": -1e5, "radius": 300, "amount": 300, "angle": 1e5, "opacity": -100, "scale": -5, "gamma": -1}), json!({"points": [], "from": [0, 0], "to": [0, 0], "stops": [], "matrix": [0, 0, 0, 0, 0, 0], "colors": [], "name": "", "mode": "", "style": ""}), ]; - // Commands whose success reaches outside the process: `file.print` and `file.printOneCopy` - // spool to CUPS, so a fuzz run with `{}` printed a blank page on the default printer. Their - // params are validated like everyone else's; the spooling itself is not fuzzed. - const EXTERNAL: &[&str] = &["file.print", "file.printOneCopy"]; let mut bad: Vec = Vec::new(); - for spec in command_specs().iter().filter(|spec| !EXTERNAL.contains(&spec.id)) { + for spec in command_specs() { for p in ¶ms { + // Print and Print One Copy reach real printers (lp, or the Windows driver): fuzz them + // as dry runs so the hunt never prints a page. + let mut p = p.clone(); + if spec.id.starts_with("file.print") + && let Some(o) = p.as_object_mut() + { + o.insert("dryRun".into(), json!(true)); + } + let p = &p; if let Err(why) = run_guarded(spec.id, p) { bad.push(format!("{} [{why}] <- {p}", spec.id)); break; @@ -75,3 +103,17 @@ fn no_command_panics_or_hangs_on_adversarial_params() { } assert!(bad.is_empty(), "{} commands panicked or hung:\n{}", bad.len(), bad.join("\n")); } + +/// #4982: a panic the code under test contains still fails the run; a graceful `Err` does not. +#[test] +fn contained_panics_count_and_graceful_errors_do_not() { + let contained = run_watched(|| { + let _ = catch_unwind(|| panic!("contained, as Session::execute does")); + }); + assert_eq!(contained, Some(false)); + let graceful = run_watched(|| { + let mut s = fresh(); + assert!(s.execute("layer.delete", json!({"layer": 999999})).is_err()); + }); + assert_eq!(graceful, Some(true)); +} diff --git a/crates/engine/tests/precheck.rs b/crates/engine/tests/precheck.rs new file mode 100644 index 0000000000..f549fbb8c8 --- /dev/null +++ b/crates/engine/tests/precheck.rs @@ -0,0 +1,78 @@ +//! `Session::precheck` accepts what `execute` accepts for the commands it covers (all but the +//! group nesting limit, which only the edit checks) +//! (`PRECHECKED`): Free Transform applies its box before them only when they will run (#5859). + +use photocraft_engine::{PRECHECKED, Session}; +use serde_json::{Value, json}; + +/// Documents in different states: a Background only, a pixel layer active, a group active, a +/// selection, no document. +fn states() -> Vec<(&'static str, Session)> { + let mut out = Vec::new(); + out.push(("no document", Session::new())); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + out.push(("background only", s)); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + out.push(("pixel layer", s)); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap(); + s.execute("layer.new.group", json!({})).unwrap(); + out.push(("group, no background", s)); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap(); + out.push(("selection", s)); + out +} + +fn params() -> Vec { + vec![ + Value::Null, + json!({}), + json!([1]), + json!("x"), + json!({"mode": "Bogus"}), + json!({"mode": "Multiply"}), + json!({"fillNeutral": true}), + json!({"mode": "Overlay", "fillNeutral": true}), + json!({"position": "x"}), + json!({"position": "below"}), + json!({"opacity": "half"}), + json!({"name": 5}), + json!({"name": "ok"}), + json!({"color": "chartreuse"}), + json!({"layer": 999_999}), + json!({"layer": 1}), + json!({"layer": 2}), + json!({"layer": null}), + json!({"layer": "Layer 1"}), + json!({"layer": "no such layer"}), + json!({"layer": true}), + json!({"ifPresent": true}), + ] +} + +#[test] +fn precheck_agrees_with_execute() { + let mut disagreements = Vec::new(); + for id in PRECHECKED { + for p in params() { + for (state, _) in states() { + // A fresh copy of the state for each call: execute changes it. + let mut s = states().into_iter().find(|(n, _)| *n == state).map(|(_, s)| s).unwrap(); + let pre = s.precheck(id, &p).is_ok(); + let ran = s.execute(id, p.clone()).is_ok(); + if pre != ran { + disagreements.push(format!("{id} {p} on {state}: precheck {pre}, execute {ran}")); + } + } + } + } + assert!(disagreements.is_empty(), "{disagreements:#?}"); +} diff --git a/crates/engine/tests/rasterize_selected_layers.rs b/crates/engine/tests/rasterize_selected_layers.rs index 40b962aca8..a738b51681 100644 --- a/crates/engine/tests/rasterize_selected_layers.rs +++ b/crates/engine/tests/rasterize_selected_layers.rs @@ -140,6 +140,6 @@ fn bad_targets_fail_gracefully() { } let mut sc = scene(); for id in ["layer.rasterize.layer", "layer.rasterize.shape", "layer.rasterize.smartObject"] { - assert!(sc.s.execute(id, json!({"layer": 9_999_999})).is_err(), "{id}: no such layer"); + assert!(sc.s.execute(id, json!({"layer": u64::MAX})).is_err(), "{id}: no such layer"); } } diff --git a/crates/engine/tests/selection_layer_commands.rs b/crates/engine/tests/selection_layer_commands.rs index 8d144d44de..44f63cf5d2 100644 --- a/crates/engine/tests/selection_layer_commands.rs +++ b/crates/engine/tests/selection_layer_commands.rs @@ -148,6 +148,6 @@ fn bad_targets_fail_gracefully() { } let mut sc = scene(); for id in ["layer.layerMask.revealAll", "layer.layerStyle.pasteLayerStyle", "layer.layerStyle.clear"] { - assert!(sc.s.execute(id, json!({"layer": 9_999_999})).is_err(), "{id}: no such layer"); + assert!(sc.s.execute(id, json!({"layer": u64::MAX})).is_err(), "{id}: no such layer"); } } diff --git a/crates/engine/tests/shortcut_sets.rs b/crates/engine/tests/shortcut_sets.rs new file mode 100644 index 0000000000..c00e6a069d --- /dev/null +++ b/crates/engine/tests/shortcut_sets.rs @@ -0,0 +1,60 @@ +//! Edit › Keyboard Shortcuts › Set through `edit.keyboardShortcuts`: save, switch, delete, +//! export to `.kys` and back, Summarize, and bad params as errors. + +use photocraft_engine::Session; +use serde_json::{Value, json}; + +fn ks(s: &mut Session, p: Value) -> Value { + s.execute("edit.keyboardShortcuts", p).expect("edit.keyboardShortcuts") +} + +#[test] +fn a_set_saves_switches_exports_and_imports_back() { + let mut s = Session::new(); + let r = ks(&mut s, json!({})); + assert_eq!((r["activeSet"].as_str(), r["modified"].as_bool(), r["defaultsName"].as_str()), (Some(""), Some(false), Some("PhotoCraft Defaults"))); + // Edit, then save the bindings as they end up after this call's edits. + let r = ks(&mut s, json!({"set": {"edit.copy": "F9"}, "alternates": {"edit.copy": []}, "saveSet": "Mine"})); + assert_eq!((r["activeSet"].as_str(), r["modified"].as_bool(), r["sets"].clone()), (Some("Mine"), Some(false), json!(["Mine"]))); + // Export the set, go back to the defaults, then import the file: the bindings return. + let xml = ks(&mut s, json!({"exportKys": true}))["kys"].as_str().expect("kys").to_string(); + assert!(xml.contains("KeyboardCustomization/Mine=Mine") && xml.contains("pc-id=\"edit.copy\">F9
"), "{xml}"); + let r = ks(&mut s, json!({"useSet": ""})); + assert_eq!((r["overrides"].clone(), r["activeSet"].as_str()), (json!({}), Some(""))); + let r = ks(&mut s, json!({"reset": true, "importKys": xml})); + assert_eq!(r["overrides"]["edit.copy"], "F9"); + assert_eq!(r["alternateOverrides"]["edit.copy"], json!([]), "Copy's F3 stays removed"); + assert_eq!(r["modified"], true, "imported over the defaults"); + // Switch back to the saved set; a later unsaved edit marks it modified. + ks(&mut s, json!({"useSet": "Mine"})); + let r = ks(&mut s, json!({"set": {"edit.cut": "F8"}})); + assert_eq!((r["activeSet"].as_str(), r["modified"].as_bool()), (Some("Mine"), Some(true))); + // Summarize lists what's bound, per platform. + let html = ks(&mut s, json!({"summarize": {"mac": false}}))["html"].as_str().expect("html").to_string(); + assert!(html.contains("Mine") && html.contains("CopyF9") && html.contains("CutF8"), "{html}"); + // Delete the set in use: the defaults' name, the bindings kept. + let r = ks(&mut s, json!({"deleteSet": "Mine"})); + assert_eq!((r["sets"].clone(), r["activeSet"].as_str(), r["overrides"]["edit.copy"].as_str()), (json!([]), Some(""), Some("F9"))); +} + +#[test] +fn bad_set_params_change_nothing() { + let mut s = Session::new(); + ks(&mut s, json!({"saveSet": "Keep"})); + let before = s.prefs().clone(); + for p in [ + json!({"useSet": "Missing"}), + json!({"useSet": 7}), + json!({"saveSet": ""}), + json!({"saveSet": "PhotoCraft Defaults"}), + json!({"deleteSet": "Missing"}), + json!({"activeSet": "Missing"}), + json!({"sets": "nope"}), + json!({"sets": {"A": {"shortcuts": {"edit.copy": "Cmd+Shift+Ctrl"}}}}), + // A bad key elsewhere in the call keeps the set from being saved too. + json!({"set": {"edit.copy": "Cmd+Shift+Ctrl"}, "saveSet": "New"}), + ] { + assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}"); + assert_eq!(s.prefs(), &before, "{p} changed the preferences"); + } +} diff --git a/crates/engine/tests/shortcuts_headless.rs b/crates/engine/tests/shortcuts_headless.rs new file mode 100644 index 0000000000..2c8bbd2ca0 --- /dev/null +++ b/crates/engine/tests/shortcuts_headless.rs @@ -0,0 +1,146 @@ +//! Edit › Keyboard Shortcuts without the UI: the shell's commands and the tools are bindable, +//! listed, imported and exported; tools share keys without conflicts; system keys are refused; +//! sets read and write files by path. + +use photocraft_engine::Session; +use serde_json::{Value, json}; + +fn ks(s: &mut Session, p: Value) -> Value { + s.execute("edit.keyboardShortcuts", p).expect("edit.keyboardShortcuts") +} + +fn listed<'a>(r: &'a Value, id: &str) -> &'a Value { + r["commands"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap_or_else(|| panic!("{id} listed")) +} + +#[test] +fn shell_commands_and_tools_are_bindable_without_the_ui() { + let mut s = Session::new(); + let r = ks(&mut s, json!({"list": true})); + assert_eq!(listed(&r, "view.zoomIn")["alternates"], json!(["Cmd++"]), "the shell's View › Zoom In"); + let mv = listed(&r, "tools.select.move"); + assert_eq!((mv["tool"].clone(), mv["shortcut"].clone(), mv["menu"].clone()), (json!(true), json!("V"), json!(["Tools"]))); + assert!( + r["conflicts"].as_array().unwrap().iter().all(|c| c["commands"] + .as_array() + .unwrap() + .iter() + .any(|id| !id.as_str().unwrap().starts_with("tools.select."))), + "tools sharing a letter cycle, they don't conflict: {}", + r["conflicts"] + ); + // No allowUnknown needed any more. + let r = ks(&mut s, json!({"set": {"tools.select.pencil": "F6", "view.zoomIn": "Cmd+Shift+Z"}})); + assert_eq!((r["overrides"]["tools.select.pencil"].clone(), r["overrides"]["view.zoomIn"].clone()), (json!("F6"), json!("Cmd+Shift+Z"))); +} + +#[test] +fn tools_may_share_a_key_but_a_command_takes_it() { + let mut s = Session::new(); + // Pencil joins the V group: Move and Artboard keep V. + let r = ks(&mut s, json!({"set": {"tools.select.pencil": "V"}})); + assert!(r["overrides"].get("tools.select.move").is_none(), "{}", r["overrides"]); + // A command taking V removes it from every tool that held it. + let r = ks(&mut s, json!({"set": {"edit.copy": "V"}})); + for t in ["tools.select.move", "tools.select.artboard", "tools.select.pencil"] { + assert_eq!(r["overrides"][t], json!(""), "{t}: {}", r["overrides"]); + } +} + +#[test] +fn system_keys_are_refused_and_dropped_from_imports() { + let mut s = Session::new(); + let key = if cfg!(target_os = "macos") { "Cmd+Tab" } else { "Alt+F4" }; + let before = s.prefs().clone(); + assert!(s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": key}})).is_err()); + assert!(s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": ["F9", key]}})).is_err()); + assert_eq!(s.prefs(), &before); + let set = format!( + "F9{key}{key}NV" + ); + let r = ks(&mut s, json!({"importKys": set})); + assert_eq!(r["overrides"]["edit.copy"], json!("F9")); + assert!(r["overrides"].get("edit.cut").is_none(), "the reserved key is left out: {}", r["overrides"]); + assert_eq!(r["import"]["reserved"].as_array().map(Vec::len), Some(1)); + assert_eq!(r["overrides"]["tools.select.pencil"], json!("N"), "tool keys import headless"); + assert!(r["overrides"].get("tools.select.move").is_none(), "Move's own V keeps its cycling"); +} + +#[test] +fn sets_read_and_write_files_and_export_everything() { + let dir = std::env::temp_dir().join(format!("photocraft-shortcuts-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let kys = dir.join("mine.kys"); + let html = dir.join("mine.html"); + let mut s = Session::new(); + ks(&mut s, json!({"set": {"tools.select.pencil": "F6", "edit.copy": "F9"}, "saveSet": "Mine"})); + let r = ks(&mut s, json!({"exportKys": {"path": kys.to_str().unwrap()}, "summarize": {"path": html.to_str().unwrap(), "mac": false}})); + assert!(r.get("kys").is_none() && r["kysPath"].is_string() && r["htmlPath"].is_string(), "{r}"); + let text = std::fs::read_to_string(&kys).unwrap(); + assert!(text.contains("pc-id=\"tools.select.pencil\">F6
") && text.contains("pc-id=\"view.zoomIn\""), "the whole set: {text}"); + assert!(std::fs::read_to_string(&html).unwrap().contains("

Tools

")); + // Back to the defaults, then the file restores the set, tools included. + ks(&mut s, json!({"useSet": ""})); + let r = ks(&mut s, json!({"reset": true, "importKys": {"path": kys.to_str().unwrap()}})); + assert_eq!((r["overrides"]["tools.select.pencil"].clone(), r["overrides"]["edit.copy"].clone()), (json!("F6"), json!("F9"))); + // Bad paths and shapes are errors, not panics. + for p in [ + json!({"importKys": {"path": dir.join("missing.kys").to_str().unwrap()}}), + json!({"importKys": {"path": ""}}), + json!({"importKys": 5}), + json!({"exportKys": "yes"}), + json!({"exportKys": {"path": 3}}), + json!({"summarize": {"path": ""}}), + ] { + assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}"); + } + // A change paired with an export that fails (a folder that doesn't exist, a bad summarize + // value) changes nothing. + let before = ks(&mut s, json!({}))["overrides"].clone(); + for p in [ + json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": dir.join("no").join("such").join("x.kys").to_str().unwrap()}}), + json!({"set": {"edit.copy": "F10"}, "summarize": 7}), + json!({"set": {"edit.copy": "F10"}, "saveSet": "Other", "summarize": {"path": dir.join("no").join("x.html").to_str().unwrap()}}), + // A good .kys path with a bad summarize: neither file is written. + json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": {"path": dir.join("no").join("x.html").to_str().unwrap()}}), + json!({"exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": 7}), + json!({"exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": {"path": dir.to_str().unwrap()}}), + ] { + assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}"); + let r = ks(&mut s, json!({})); + assert_eq!(r["overrides"], before, "{p}"); + assert!(r["sets"].get("Other").is_none(), "{p}: {r}"); + assert!(!dir.join("both.kys").exists(), "{p}: no file written"); + } + // A second file that passes the checks but can't be written (a NUL in its name): the user's + // existing first file is left exactly as it was, and no staged file stays behind. + let mine = dir.join("existing.kys"); + std::fs::write(&mine, "the user's own file").unwrap(); + let html = format!("{}/a\u{0}.html", dir.to_str().unwrap()); + let p = json!({"exportKys": {"path": mine.to_str().unwrap()}, "summarize": {"path": html}}); + assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}"); + assert_eq!(std::fs::read_to_string(&mine).unwrap(), "the user's own file"); + let left: Vec<_> = + std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name()).filter(|n| n.to_string_lossy().contains("photocraft-tmp")).collect(); + assert!(left.is_empty(), "{left:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_read_only_target_is_refused_before_anything_is_written() { + let dir = std::env::temp_dir().join(format!("photocraft-readonly-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let ro = dir.join("locked.kys"); + std::fs::write(&ro, "locked").unwrap(); + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&ro, perms.clone()).unwrap(); + let mut s = Session::new(); + let r = s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": ro.to_str().unwrap()}})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("read-only")), "{r:?}"); + assert_eq!(std::fs::read_to_string(&ro).unwrap(), "locked"); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + let _ = std::fs::set_permissions(&ro, perms); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/tests/smart_duplicates.rs b/crates/engine/tests/smart_duplicates.rs index 12f5ec5486..554fd23f0f 100644 --- a/crates/engine/tests/smart_duplicates.rs +++ b/crates/engine/tests/smart_duplicates.rs @@ -4,7 +4,7 @@ use photocraft_doc::{Layer, LayerContent, LayerId, SmartObject}; use photocraft_engine::Session; use photocraft_engine::smart_cmds::{decode_source, source_bytes}; use photocraft_geom::Rect; -use serde_json::json; +use serde_json::{Value, json}; fn session(depth: u64) -> (Session, u64, u64) { let mut s = Session::new(); @@ -195,3 +195,104 @@ fn replacing_shared_contents_does_not_reuse_or_commit_a_stale_editor() { s.execute("layer.smartObjects.editContents", json!({"layer": original})).unwrap(); assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px[0], [0.0, 1.0, 0.0, 1.0]); } + +/// A `.pcraft` file whose contents are one solid colour, for Replace Contents. +fn contents_file(rgba: [f32; 4], tag: &str) -> std::path::PathBuf { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s, rgba); + let path = std::env::temp_dir().join(format!("photocraft-{tag}-{}.pcraft", std::process::id())); + std::fs::write(&path, photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap()).unwrap(); + path +} + +/// The node named `name` in `document.inspect`'s layer tree, searched through groups. +fn inspected<'a>(layers: &'a Value, name: &str) -> Option<&'a Value> { + layers.as_array()?.iter().find_map(|l| if l["name"] == name { Some(l) } else { inspected(&l["children"], name) }) +} + +/// Placed layers sharing a source (a PSD's copies of one smart object) share their contents, so +/// one Replace Contents updates every copy. Nothing showed it: `linkGroup` is Link Layers, which +/// stays unset (#4075). Inspect gives each smart object its contents identity and instance count, +/// and Replace Contents lists every layer it changed. +#[test] +fn inspect_and_replace_contents_report_shared_instances() { + let (mut s, original, duplicate) = session(8); + let independent = s.execute("layer.smartObjects.newSmartObjectViaCopy", json!({"layer": original})).unwrap()["layer"].as_u64().unwrap(); + s.edit("name and group", |doc, _| { + for (id, name) in [(original, "original"), (duplicate, "duplicate"), (independent, "independent")] { + doc.layer_mut(LayerId(id)).unwrap().name = name.into(); + } + // An instance inside a group counts too. + let copy = doc.remove(LayerId(duplicate)).unwrap(); + doc.layers.push(Layer::group("Copies", vec![copy])); + Ok(()) + }) + .unwrap(); + let out = photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap(); + let mut s = Session::new(); + s.add_document(photocraft_io::import("copies.psd", &out.bytes).unwrap().document, None); + + let tree = s.execute("document.inspect", json!({})).unwrap(); + let [o, d, i] = ["original", "duplicate", "independent"].map(|name| inspected(&tree["layers"], name).unwrap().clone()); + assert_eq!(o["smartContents"]["id"], d["smartContents"]["id"]); + assert_ne!(o["smartContents"]["id"], i["smartContents"]["id"]); + assert_eq!([&o, &d, &i].map(|l| l["smartContents"]["instances"].as_u64()), [Some(2), Some(2), Some(1)]); + assert!([&o, &d, &i].iter().all(|l| l["linkGroup"].is_null()), "shared contents don't link the layers"); + + let [o, d, i] = [&o, &d, &i].map(|l| l["id"].as_u64().unwrap()); + let path = contents_file([0.0, 1.0, 0.0, 1.0], "replace-instances"); + let replaced = s.execute("layer.smartObjects.replaceContents", json!({"layer": d, "path": path})); + std::fs::remove_file(path).unwrap(); + let mut changed: Vec = replaced.unwrap()["instances"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + changed.sort_unstable(); + assert_eq!(changed, if o < d { [o, d] } else { [d, o] }); + assert_eq!([o, d, i].map(|id| contents_pixel(&s, id)), [[0.0, 1.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]]); + for (id, expected) in [(o, [0.0, 1.0, 0.0, 1.0]), (d, [0.0, 1.0, 0.0, 1.0]), (i, [0.0, 0.0, 1.0, 1.0])] { + assert_eq!(smart(&s, id).cache.as_ref().unwrap().rgba(4, 3), expected, "instance {id} must display its current contents"); + } +} + +/// The same with the placed layer Photoshop wrote, twice: Photoshop's copies of a smart object +/// each carry a `SoLd` naming the one embedded file (`Idnt`), so they import as one contents. +#[cfg(feature = "corpus")] +#[test] +fn photoshop_placed_copies_import_as_shared_instances() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop/smart-filters/sf-none-plain.psd"); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e} (run `cargo xtask corpus --all`)", path.display())); + let mut file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let placed = file.layers().iter().find(|r| r.block(b"SoLd").is_some()).expect("a placed layer").clone(); + file.layers_mut().push(placed); + let mut s = Session::new(); + s.add_document(photocraft_io::psd_to_document(&file).0, None); + + let tree = s.execute("document.inspect", json!({})).unwrap(); + let copies: Vec<&Value> = tree["layers"].as_array().unwrap().iter().filter(|l| l["kind"] == "Smart Object").collect(); + assert_eq!(copies.len(), 2); + assert_eq!(copies[0]["smartContents"], copies[1]["smartContents"]); + assert_eq!(copies[0]["smartContents"]["instances"], 2); + for copy in &copies { + assert_eq!(copy["smartSource"]["kind"], "embedded"); + assert!(copy["smartSource"]["uuid"].as_str().is_some()); + } + let mut ids: Vec = copies.iter().map(|l| l["id"].as_u64().unwrap()).collect(); + let path = contents_file([0.0, 1.0, 0.0, 1.0], "photoshop-copies"); + let replaced = s.execute("layer.smartObjects.replaceContents", json!({"layer": ids[0], "path": path})); + std::fs::remove_file(path).unwrap(); + let mut changed: Vec = replaced.unwrap()["instances"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + changed.sort_unstable(); + ids.sort_unstable(); + assert_eq!(changed, ids); + for id in ids { + assert_eq!(contents_pixel(&s, id), [0.0, 1.0, 0.0, 1.0]); + let cache = smart(&s, id).cache.as_ref().unwrap(); + let bounds = cache.content_bounds(); + assert!(!bounds.is_empty(), "instance {id} must have rendered pixels"); + assert_eq!( + cache.rgba(bounds.x0 + bounds.width() as i32 / 2, bounds.y0 + bounds.height() as i32 / 2), + [0.0, 1.0, 0.0, 1.0], + "instance {id} must refresh its imported cache" + ); + } +} diff --git a/crates/engine/tests/smart_psd.rs b/crates/engine/tests/smart_psd.rs index f7b86048b9..2586f24cc9 100644 --- a/crates/engine/tests/smart_psd.rs +++ b/crates/engine/tests/smart_psd.rs @@ -615,3 +615,58 @@ fn cs2_plld_only_smart_object_saves_its_new_transform() { } assert!(photocraft_engine::smart_cmds::contents_of(&again, photocraft_doc::LayerId(aid)).is_ok(), "the contents are available"); } + +/// Photoshop stores a smart object's filtered pixels without its filter mask and mixes the +/// unfiltered ones back in through the mask (`compose::filter_mask`): the layer record we write +/// holds the filtered pixels wherever the mask shows any of the filter, and opening the file +/// mixes them back to what the document showed. +#[test] +fn a_soft_filter_mask_is_stored_the_way_photoshop_stores_it() { + use photocraft_doc::LayerMask; + use photocraft_raster::Surface; + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 4})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + // A mask ramping from hiding the blur (left) to showing it (right). + s.edit("mask", |doc, _| { + let snapshot = doc.clone(); + let l = doc.layer_mut(id).unwrap(); + let LayerContent::Smart(sm) = &mut l.content else { panic!("smart") }; + let mut m = LayerMask::reveal_all(); + m.surface = Surface::new(photocraft_color::PixelFormat::GRAY8); + let r = Rect::new(0, 0, W, H); + m.surface.write_region(r, &(0..H).flat_map(|_| (0..W).map(|x| x as f32 / (W - 1) as f32)).collect::>()); + sm.filter_mask = Some(m); + refresh_layer(&snapshot, l).map(|_| ()) + }) + .unwrap(); + let doc = s.active().unwrap().doc.clone(); + let (_, sm) = smart(&doc); + let mixed = sm.cache.clone().unwrap(); + // The same smart object with the mask switched off: filtered everywhere. + let mut unmasked = sm.clone(); + unmasked.filter_mask.as_mut().unwrap().enabled = false; + let filtered = photocraft_engine::smart_cmds::render(&doc, &unmasked).unwrap().unwrap(); + + let out = photocraft_io::export(&doc, "psd", &Default::default()).unwrap(); + let file = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); + let name = doc.layer(id).unwrap().name.clone(); + let record = (0..file.layers().len()).filter_map(|i| file.layer(i)).find(|l| l.name() == name).unwrap(); + let img = record.rgba8().unwrap(); + // A column where the mask shows about half the blur, over opaque content. + let (x, y) = (W / 2, 20); + let at = (((y - img.top) as u32 * img.width + (x - img.left) as u32) * 4) as usize; + let stored = &img.data[at..at + 4]; + let want = filtered.pixel(x, y); + let half = mixed.pixel(x, y); + let gap = (0..3).map(|c| (want[c] - half[c]).abs()).fold(0.0f32, f32::max); + assert!(gap > 3.0 / 255.0, "the mask's mix must show at ({x}, {y}) for this test to mean anything"); + for c in 0..3 { + assert!((f32::from(stored[c]) / 255.0 - want[c]).abs() <= 1.5 / 255.0, "channel {c}: stored {stored:?}, filtered {want:?}, mixed {half:?}"); + } + // Opening the file mixes the unfiltered pixels back in: it shows what the document showed. + let back = photocraft_io::import("x.psd", &out.bytes).unwrap().document; + let d = max_diff(&photocraft_compose::flatten(&back).px, &photocraft_compose::flatten(&doc).px); + assert!(d <= 2.0 / 255.0, "re-opened differs by {d}"); +} diff --git a/crates/engine/tests/style_and_align.rs b/crates/engine/tests/style_and_align.rs new file mode 100644 index 0000000000..9af9bed0fa --- /dev/null +++ b/crates/engine/tests/style_and_align.rs @@ -0,0 +1,115 @@ +//! Copy / Paste Layer Style carries the whole style, Blending Options included (Blend If and the +//! channel restrictions); align with an active selection aligns to it however many layers are +//! selected (layers audit L2). + +use photocraft_engine::Session; +use serde_json::json; + +fn layer(s: &Session, id: u64) -> photocraft_doc::Layer { + s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone() +} + +fn new_doc(s: &mut Session, mode: &str) -> (u64, u64) { + s.execute("file.new", json!({"width": 64, "height": 64, "mode": mode, "background": "white"})).unwrap(); + let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + (a, b) +} + +#[test] +fn paste_layer_style_carries_blend_if_and_channels() { + let mut s = Session::new(); + let (a, b) = new_doc(&mut s, "rgb"); + s.execute( + "layer.layerStyle.blendingOptions", + json!({"layer": a, "blendIf": [{"channel": "gray", "underlying": [0, 128]}, {"channel": "red", "thisLayer": [10, 200]}]}), + ) + .unwrap(); + s.execute("layer.setProps", json!({"layer": a, "channels": [true, false, true]})).unwrap(); + s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": b})).unwrap(); + let (la, lb) = (layer(&s, a), layer(&s, b)); + assert_eq!(lb.blend_if, la.blend_if, "Blend If, Gray and channel ranges"); + assert_eq!(lb.excluded_channels, la.excluded_channels, "the unchecked G channel"); + // Into a CMYK document: only the Gray range carries over; channels reset to all. + let (c, _) = new_doc(&mut s, "cmyk"); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": c})).unwrap(); + let lc = layer(&s, c); + assert_eq!(lc.blend_if.get(0), la.blend_if.get(0)); + assert_eq!(lc.blend_if.ranges.len(), 1, "no RGB channel range on CMYK"); + assert_eq!(lc.excluded_channels, 0); +} + +#[test] +fn paste_layer_style_into_another_mode_keeps_blend_if_normalised() { + // A full Gray range and only a Red range: in CMYK nothing is left, so it is the default + // (which compares equal, so the paste isn't seen as a Blend If change). + let mut s = Session::new(); + let (a, _) = new_doc(&mut s, "rgb"); + s.execute("layer.layerStyle.blendingOptions", json!({"layer": a, "blendIf": [{"channel": "red", "thisLayer": [10, 200]}]})).unwrap(); + s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap(); + let (c, _) = new_doc(&mut s, "cmyk"); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": c})).unwrap(); + assert_eq!(layer(&s, c).blend_if, photocraft_doc::BlendIf::default()); +} + +#[test] +fn paste_layer_style_on_several_layers_is_one_undo_step() { + // 16-bit, onto a pixel layer and a group: one History step, and one Undo restores both. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 64, "depth": 16, "background": "white"})).unwrap(); + let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.layerStyle.blendingOptions", json!({"layer": a, "blendIf": [{"channel": "gray", "underlying": [0, 128]}]})).unwrap(); + s.execute("layer.setProps", json!({"layer": a, "fill": 0.5, "blend": "multiply"})).unwrap(); + s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap(); + let b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap(); + let before = (layer(&s, b), layer(&s, g)); + s.execute("layer.select", json!({"layer": b})).unwrap(); + s.execute("layer.select", json!({"layer": g, "mode": "add"})).unwrap(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({})).unwrap(); + let la = layer(&s, a); + for id in [b, g] { + let l = layer(&s, id); + assert_eq!((l.blend_if.clone(), l.fill_opacity, l.blend), (la.blend_if.clone(), la.fill_opacity, la.blend), "layer {id}"); + } + s.execute("edit.undo", json!({})).unwrap(); + let after = (layer(&s, b), layer(&s, g)); + assert_eq!((after.0.blend_if, after.0.blend, after.1.blend_if, after.1.blend), (before.0.blend_if, before.0.blend, before.1.blend_if, before.1.blend)); + assert_eq!((after.0.fill_opacity, after.1.fill_opacity), (before.0.fill_opacity, before.1.fill_opacity)); +} + +#[test] +fn several_layers_align_to_an_active_selection() { + let mut s = Session::new(); + let (a, b) = new_doc(&mut s, "rgb"); + for (id, x) in [(a, 4), (b, 30)] { + s.execute("layer.select", json!({"layer": id})).unwrap(); + s.execute("select.rect", json!({"x": x, "y": 10, "width": 8, "height": 8})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + } + s.execute("select.rect", json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap(); + s.execute("layer.select", json!({"layer": a})).unwrap(); + s.execute("layer.select", json!({"layer": b, "mode": "add"})).unwrap(); + s.execute("layer.align.leftEdges", json!({})).unwrap(); + for id in [a, b] { + let x0 = layer(&s, id).surface().unwrap().content_bounds().x0; + assert_eq!(x0, 40, "layer {id} aligned to the selection's left edge"); + } +} + +#[test] +fn a_groups_pass_through_pastes_onto_a_pixel_layer_as_normal() { + let mut s = Session::new(); + let (a, _) = new_doc(&mut s, "rgb"); + let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(layer(&s, g).blend, photocraft_color::BlendMode::PassThrough); + s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": g})).unwrap(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": a})).unwrap(); + assert_eq!(layer(&s, a).blend, photocraft_color::BlendMode::Normal, "Pass Through isn't a pixel layer's mode"); + // Onto another group it stays Pass Through. + let g2 = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": g2, "blend": "Multiply"})).unwrap(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": g2})).unwrap(); + assert_eq!(layer(&s, g2).blend, photocraft_color::BlendMode::PassThrough); +} diff --git a/crates/engine/tests/transform_budget_unclamped.rs b/crates/engine/tests/transform_budget_unclamped.rs new file mode 100644 index 0000000000..c603bee7b5 --- /dev/null +++ b/crates/engine/tests/transform_budget_unclamped.rs @@ -0,0 +1,49 @@ +//! #6430: integration tests use the production pixel budget, not the unit tests' reduced one. +use photocraft_engine::Session; +use photocraft_geom::Rect; +use serde_json::{Value, json}; + +fn painted() -> Session { + let mut session = Session::new(); + session.execute("file.new", json!({"width": 24, "height": 16})).unwrap(); + session.execute("layer.new.layer", json!({})).unwrap(); + session + .edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 2, 11, 10), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + session +} + +fn assert_refused_without_changes(session: &mut Session, command: &str, params: Value) { + let state = session.active().unwrap(); + let (document, revision, history) = (state.doc.clone(), state.revision, state.history.past_len()); + let error = session.execute(command, params).unwrap_err().to_string(); + assert!(error.contains("limit"), "{error}"); + let state = session.active().unwrap(); + assert!(state.doc == document, "refusal changed the document"); + assert_eq!((state.revision, state.history.past_len()), (revision, history)); +} + +#[test] +fn extreme_layer_scales_are_refused_before_a_clipped_strip_is_warped() { + let mut session = painted(); + session.execute("filter.render.clouds", json!({})).unwrap(); + for scale in [1e9, 1e30, 1e308, -1e308] { + for axis in ["scaleX", "scaleY"] { + assert_refused_without_changes(&mut session, "edit.transform", json!({(axis): scale})); + } + } +} + +#[test] +fn extreme_selection_scales_are_refused_without_losing_the_selection() { + let mut session = painted(); + session.execute("select.rect", json!({"x": 10, "y": 2, "width": 1, "height": 8})).unwrap(); + for scale in [1e30, 1e308] { + for axis in ["scaleX", "scaleY"] { + assert_refused_without_changes(&mut session, "select.transformSelection", json!({(axis): scale})); + } + } +} diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 45d7a4ccf3..4267746633 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -16,13 +16,13 @@ photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-codecs = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true, features = ["unbounded_depth"] } +serde_json = { workspace = true, features = ["unbounded_depth", "float_roundtrip"] } thiserror = { workspace = true } flate2 = { workspace = true } # Same IEEE CRC-32 the ZIP container needs; already in Cargo.lock through flate2. crc32fast = "1.5" blake3 = { version = "1.8", default-features = false, features = ["pure"] } -ruzstd = "0.9" +ruzstd = "0.9.1" # Unix directory cleanup uses no-follow descriptor-relative operations; the same # dependency provides the plain-fsync fallback on Apple file systems. @@ -34,5 +34,16 @@ rustix = { version = "1.1", default-features = false, features = ["std", "fs"] } [target.'cfg(target_vendor = "apple")'.dependencies] libc = "0.2" +# Replacing a file keeps its ACL (#7073). `fcopyfile(COPYFILE_ACL)` is an `unsafe fn` and +# `fclonefileat(CLONE_ACL)` is APFS-only; exacl is a safe wrapper over acl_get_file/acl_set_file. +[target.'cfg(target_os = "macos")'.dependencies] +exacl = "0.13" + +# `ReplaceFileW` is how Windows replaces a file without dropping its DACL, named streams +# (Zone.Identifier, the Mark of the Web) and creation time (#7094, #7096). winsafe's `kernel` +# feature is a dependency-free safe wrapper; the workspace forbids writing the FFI call here. +[target.'cfg(windows)'.dependencies] +winsafe = { version = "0.0.29", features = ["kernel"] } + [dev-dependencies] proptest = { workspace = true } diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index bf39fe2761..3a4aeca6c0 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). @@ -21,8 +25,15 @@ //! this module is never reached there; on `wasm32` every call returns the `std` "unsupported" //! error. //! +//! Replacing a file also carries its metadata across — permission bits, extended attributes, +//! the "downloaded from the internet" mark and the access-control list — so that saving a +//! document does not quietly undo a security decision someone made about the file. That part is +//! [`meta`], and it is best effort: it never fails a save and never touches the bytes. +//! //! [`atomic_write_with`] takes an [`AtomicFs`] so tests can inject failures at each step. +pub mod meta; + use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -30,6 +41,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 @@ -44,9 +61,16 @@ pub trait AtomicFs { fn remove(&self, path: &Path) -> io::Result<()>; /// Flush a directory entry change (Unix); a no-op elsewhere. fn sync_dir(&self, dir: &Path) -> io::Result<()>; - /// Copy the destination's permissions onto the temporary file, so replacing a file keeps its - /// mode. Best effort. - fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()>; + /// Copy the metadata a replacement should keep — mode, extended attributes, ACL — from the + /// file about to be replaced onto the temporary file. Best effort; see [`meta`]. + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()>; + /// Replace the **existing** file `to` with `from`. Everywhere but Windows this is + /// [`rename`](Self::rename); on Windows it keeps the destination's DACL, named streams and + /// creation time ([`meta::replace_existing`]). The default is `rename`, which is what the + /// failure-injecting test file systems want. + fn replace_existing(&self, from: &Path, to: &Path) -> io::Result<()> { + self.rename(from, to) + } } /// The real file system. @@ -87,9 +111,14 @@ impl AtomicFs for RealFs { Ok(()) } } - fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> { - let perms = std::fs::metadata(from)?.permissions(); - std::fs::set_permissions(to, perms) + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { + meta::copy_metadata(from, to) + } + fn replace_existing(&self, from: &Path, to: &Path) -> io::Result<()> { + meta::replace_existing(from, to) + } + fn is_readonly(&self, path: &Path) -> bool { + std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().readonly()) } } @@ -162,13 +191,16 @@ pub fn atomic_write_with(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, byt /// [`atomic_write_with`] reporting write progress (see [`atomic_write_progress`]). pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> io::Result<()> { - let target = resolve_symlink(path); + let target = resolve_symlink(path).map_err(|e| context(e, format!("cannot save {}", path.display())))?; let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?; let dir = match target.parent() { Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(), _ => 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. @@ -190,12 +222,19 @@ pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: & } let tmp = tmp.ok_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: could not create a temporary file", dir.display())))?; - // Keep the existing file's mode (e.g. 0600) instead of the process default. - if target.exists() { - let _ = fs.copy_permissions(&target, &tmp); + // Keep what the file being replaced carried: its mode (e.g. 0600) instead of the process + // default, its extended attributes, and its ACL. Best effort, and before the swap, so the + // file the user ends up with never passes through a more permissive state. + let replacing = target.exists(); + if replacing { + let _ = fs.copy_metadata(&target, &tmp); } - if let Err(e) = retry_rename(retry, || fs.rename(&tmp, &target)) { + // Replacing an existing file goes through `replace_existing`, which on Windows is the one + // call that can keep the destination's DACL and named streams; creating a new one is a + // plain rename, with nothing to preserve. + let swap = || if replacing { fs.replace_existing(&tmp, &target) } else { fs.rename(&tmp, &target) }; + if let Err(e) = retry_rename(retry, swap) { let _ = fs.remove(&tmp); return Err(rename_error(e, path)); } @@ -229,12 +268,37 @@ fn sleep_ms(ms: u64) { #[cfg(target_arch = "wasm32")] fn sleep_ms(_ms: u64) {} +/// How deep a symlink chain we follow before giving up, like the OS limit. +const MAX_LINK_DEPTH: u32 = 40; + /// Write through a symlink to the file it points at, instead of replacing the link. -fn resolve_symlink(path: &Path) -> PathBuf { - match std::fs::symlink_metadata(path) { - Ok(m) if m.file_type().is_symlink() => std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()), - _ => path.to_path_buf(), +/// +/// A dangling link (its target, or a folder on the way to it, doesn't exist yet) resolves to its +/// target too, so saving through one creates the target instead of silently turning the link +/// into a regular file (#6605). A chain that never ends is an error, never a link replacement. +fn resolve_symlink(path: &Path) -> io::Result { + if !std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) { + return Ok(path.to_path_buf()); } + // A resolvable chain: canonicalize follows every link and relative target in one go. + if let Ok(real) = std::fs::canonicalize(path) { + return Ok(real); + } + // Dangling chain: walk the links lexically; the final target may not exist yet. + let mut current = path.to_path_buf(); + for _ in 0..MAX_LINK_DEPTH { + let Ok(link) = std::fs::read_link(¤t) else { + return Ok(current); // not (or no longer) a link: the chain ends here + }; + let base = current.parent().unwrap_or(Path::new(".")).to_path_buf(); + // `..` stays in the path for the OS to resolve: folding it by text is wrong when a folder + // on the way is itself a symlink. + current = if link.is_absolute() { link } else { base.join(link) }; + if !std::fs::symlink_metadata(¤t).is_ok_and(|m| m.file_type().is_symlink()) { + return Ok(current); + } + } + Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{}: too many levels of symbolic links", path.display()))) } /// A unique hidden temporary file name to write beside `file` before renaming over it. diff --git a/crates/format/src/atomic/meta.rs b/crates/format/src/atomic/meta.rs new file mode 100644 index 0000000000..10008faa9f --- /dev/null +++ b/crates/format/src/atomic/meta.rs @@ -0,0 +1,280 @@ +//! What a replacement keeps from the file it replaces. +//! +//! Saving writes a new file and renames it over the old one, so the user gets a file with the +//! right bytes and none of the old file's history: everything the operating system and other +//! programs had attached to the file that was just unlinked is gone. Some of it is a security +//! decision — *this came from the internet*, *only these people may read it* — so a save that +//! drops it quietly weakens the user's protections (#6603, #7073, #7094, #7095, #7096). +//! +//! | | macOS | Linux | Windows | +//! |---|---|---|---| +//! | permission bits | [`copy_metadata`] | [`copy_metadata`] | [`copy_metadata`] (read-only flag) | +//! | extended attributes (Finder tags, `user.*`) | `copy_xattrs` | `copy_xattrs` | — | +//! | "downloaded from the internet" | `com.apple.quarantine`, an xattr | — | `Zone.Identifier`, a named stream kept by [`replace_existing`] | +//! | access-control list | `copy_acl` (`exacl`) | not carried over (see below) | DACL, kept by [`replace_existing`] | +//! | creation time | not carried over (#6603) | — | kept by [`replace_existing`] | +//! +//! Everything here except [`replace_existing`] is **best effort and never touches the file's +//! bytes**: a save that cannot carry a piece of metadata across is still a save, because a +//! document the user can open matters more than a Finder tag. [`replace_existing`] *is* the +//! replacement step, so it holds the atomicity guarantee instead: it never leaves the +//! destination missing, and it falls back to a plain rename rather than fail a save. +//! +//! Not carried over, and why: Linux POSIX ACLs live in `system.posix_acl_access`, which +//! `listxattr` does not report, so copying the listed attributes misses them; they would need +//! their own step. macOS creation time needs `setattrlist`, which has no safe wrapper here. + +use std::io; +use std::path::Path; + +#[cfg(unix)] +use std::os::fd::BorrowedFd; + +/// Largest attribute-name list [`copy_xattrs`] will read. Names are short; a file with a +/// megabyte of them is not something a save should spend memory on. +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +const MAX_XATTR_NAMES: usize = 1 << 20; + +/// Largest single attribute value [`copy_xattrs`] will carry over. Finder tags and `user.*` +/// attributes are tens of bytes; a resource fork can be large, and past this it is not worth +/// holding a copy of it in memory during a save. +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +const MAX_XATTR_VALUE: usize = 16 << 20; + +/// First read size for an attribute list or value; grown on `ERANGE`. +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +const XATTR_CHUNK: usize = 4 << 10; + +/// Copy, best effort, everything [`crate::atomic::atomic_write`] should carry from the file it +/// is about to replace (`from`) onto the temporary file that will replace it (`to`). +/// +/// Errors are the caller's to ignore: each piece is attempted on its own, and the first failure +/// is returned only so a caller that wants to log one can. +pub fn copy_metadata(from: &Path, to: &Path) -> io::Result<()> { + // The permission bits go on last. Writing an attribute needs the destination open for + // writing, and replacing a read-only (0444) file would copy that mode onto the temporary + // file and then be unable to touch it again. + #[cfg(unix)] + let attributes = copy_xattrs_at(from, to); + #[cfg(not(unix))] + let attributes = Ok(()); + + #[cfg(target_os = "macos")] + let acl = copy_acl(from, to); + #[cfg(not(target_os = "macos"))] + let acl = Ok(()); + + // A save over a 0600 file must not widen it to whatever the process default is. + let permissions = std::fs::metadata(from).and_then(|m| std::fs::set_permissions(to, m.permissions())); + + attributes.and(acl).and(permissions) +} + +/// [`copy_metadata`]'s extended-attribute step, by path: open both files and hand the +/// descriptors to [`copy_xattrs`]. +#[cfg(unix)] +fn copy_xattrs_at(from: &Path, to: &Path) -> io::Result<()> { + use std::os::fd::AsFd; + let old = std::fs::File::open(from)?; + // Writing an attribute needs write access to the file, not only to its directory. + let new = std::fs::OpenOptions::new().write(true).open(to)?; + copy_xattrs(old.as_fd(), new.as_fd()) +} + +/// Copy every extended attribute of `from` onto `to`, best effort, through the two open files +/// rather than their paths. +/// +/// Descriptors, not paths, because the automation write path holds capability-checked handles +/// and must never reach back out to an ambient path to finish a write (#7095). The same reason +/// makes this the right shape for [`copy_metadata`] too. +/// +/// An attribute that cannot be read or cannot be set is skipped: `com.apple.provenance` and +/// `com.apple.rootless` on macOS and `security.*` on Linux belong to the system and refuse to +/// be written by an ordinary process, and failing a document save over one would be absurd. +/// `Err` means the attribute *list* could not be read, so nothing was copied. +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +pub fn copy_xattrs(from: BorrowedFd<'_>, to: BorrowedFd<'_>) -> io::Result<()> { + use rustix::fs::{XattrFlags, fsetxattr}; + + for name in list_xattrs(from)?.split(|b| *b == 0) { + if name.is_empty() || skip_xattr(name) { + continue; + } + let Some(value) = read_xattr(from, name) else { continue }; + let _ = fsetxattr(to, name, &value, XattrFlags::empty()); + } + Ok(()) +} + +/// [`copy_xattrs`] where rustix offers no extended-attribute calls (FreeBSD and the other BSDs): +/// nothing is copied, as on Windows. +#[cfg(all(unix, not(any(target_os = "linux", target_os = "android", target_vendor = "apple"))))] +pub fn copy_xattrs(_from: BorrowedFd<'_>, _to: BorrowedFd<'_>) -> io::Result<()> { + Ok(()) +} + +/// Attributes that describe how the *old* file's bytes are stored rather than anything about +/// the document, and so must never be pinned onto a file holding different bytes. +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +fn skip_xattr(name: &[u8]) -> bool { + // `com.apple.decmpfs` marks a file whose data lives compressed inside the attribute itself. + // The kernel hides it from a plain `listxattr` and refuses to set it, but a file claiming + // compressed storage it does not have would read as garbage, so never try. + name == b"com.apple.decmpfs" +} + +/// The NUL-separated attribute names of `fd`. +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +fn list_xattrs(fd: BorrowedFd<'_>) -> io::Result> { + let mut buf = vec![0u8; XATTR_CHUNK]; + loop { + match rustix::fs::flistxattr(fd, buf.as_mut_slice()) { + Ok(len) => { + buf.truncate(len.min(buf.len())); + return Ok(buf); + } + Err(e) if e == rustix::io::Errno::RANGE && buf.len() < MAX_XATTR_NAMES => { + buf.resize(buf.len().saturating_mul(4).min(MAX_XATTR_NAMES), 0); + } + Err(e) => return Err(e.into()), + } + } +} + +/// The value of one attribute, or `None` if it cannot be read or is larger than +/// [`MAX_XATTR_VALUE`]. A missing value is normal: another process may remove an attribute +/// between the listing and the read. +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +fn read_xattr(fd: BorrowedFd<'_>, name: &[u8]) -> Option> { + let mut buf = vec![0u8; XATTR_CHUNK]; + loop { + match rustix::fs::fgetxattr(fd, name, buf.as_mut_slice()) { + Ok(len) => { + buf.truncate(len.min(buf.len())); + return Some(buf); + } + Err(e) if e == rustix::io::Errno::RANGE && buf.len() < MAX_XATTR_VALUE => { + buf.resize(buf.len().saturating_mul(4).min(MAX_XATTR_VALUE), 0); + } + Err(_) => return None, + } + } +} + +/// Copy an explicit macOS access-control list (`chmod +a`) from `from` onto `to` (#7073). +/// +/// `fcopyfile(COPYFILE_ACL)` is the obvious call and is an `unsafe fn`, which this workspace +/// forbids; `fclonefileat` with `CLONE_ACL` is safe but only works on APFS and would mean +/// writing every save as a clone-then-truncate. `exacl` is a small, safe wrapper over +/// `acl_get_file`/`acl_set_file` that works on every file system that has ACLs at all. +/// +/// An old file with no ACL leaves `to` alone: the new file may have picked up the folder's +/// inherited entries, and taking access away is a worse surprise than leaving it as the folder +/// says it should be. +#[cfg(target_os = "macos")] +fn copy_acl(from: &Path, to: &Path) -> io::Result<()> { + let entries = exacl::getfacl(from, None)?; + if entries.is_empty() { + return Ok(()); + } + exacl::setfacl(&[to], &entries, None) +} + +/// Replace the **existing** file `to` with `from`, keeping what the destination owns rather than +/// its bytes. +/// +/// Everywhere but Windows this is exactly [`std::fs::rename`], and the metadata is carried by +/// [`copy_metadata`] onto `from` beforehand. On Windows it is `ReplaceFileW`, which preserves +/// the replaced file's DACL (#7096), its named streams — `Zone.Identifier`, the Mark of the Web +/// (#7094) — its creation time, object identifier, compression and encryption, in the one call +/// Microsoft documents for the save-through-a-temporary-file pattern. None of those have a safe +/// path-based API to copy by hand. +pub fn replace_existing(from: &Path, to: &Path) -> io::Result<()> { + #[cfg(windows)] + { + windows_replace(from, to) + } + #[cfg(not(windows))] + { + std::fs::rename(from, to) + } +} + +/// `ReplaceFileW`, with the plain rename as the fallback. See [`replace_existing`]. +/// +/// **A backup name is not optional.** `ReplaceFile` without one deletes the replaced file before +/// the final rename, and documents `ERROR_UNABLE_TO_MOVE_REPLACEMENT`: "the replaced file no +/// longer exists and the replacement file exists under its original name" — the user's document +/// gone, its replacement left under a hidden temporary name. With a backup name the same failure +/// leaves both files where they were. So this always passes one, and deletes it on the way out. +/// +/// The failures and what each leaves behind (from the `ReplaceFileW` reference): +/// +/// - `ERROR_UNABLE_TO_MOVE_REPLACEMENT_2` (1177) is the only one that moves anything: the +/// replacement keeps its name but has already inherited the destination's streams and +/// attributes, and the destination's old content is at the backup name. Finishing the rename +/// by hand completes the save *with* its metadata; if even that fails, the backup goes back to +/// the destination so the user's file is where they left it. +/// - Every other error leaves the replaced and replacement files under their own names, so a +/// plain rename still saves the document — without the metadata, which is where this started. +#[cfg(windows)] +fn windows_replace(from: &Path, to: &Path) -> io::Result<()> { + use winsafe::co::REPLACEFILE; + + /// `ERROR_UNABLE_TO_MOVE_REPLACEMENT_2`: the destination now lives under the backup name. + const MOVE_REPLACEMENT_2: i32 = 1177; + + let backup = backup_path(to); + let paths = (to.to_str(), from.to_str(), backup.as_deref().and_then(Path::to_str)); + // `ReplaceFileW` takes `&str`; a path that is not valid Unicode, or one past MAX_PATH, is + // the plain rename's job. + let (Some(replaced), Some(replacement), Some(backup_str)) = paths else { + return std::fs::rename(from, to); + }; + + let flags = REPLACEFILE::IGNORE_MERGE_ERRORS | REPLACEFILE::IGNORE_ACL_ERRORS; + match winsafe::ReplaceFile(replaced, replacement, Some(backup_str), flags) { + Ok(()) => { + remove_backup(backup.as_deref()); + Ok(()) + } + Err(e) if e.raw() as i32 == MOVE_REPLACEMENT_2 => match std::fs::rename(from, to) { + Ok(()) => { + remove_backup(backup.as_deref()); + Ok(()) + } + Err(rename_failed) => { + if let Some(backup) = backup.as_deref() { + let _ = std::fs::rename(backup, to); + } + Err(rename_failed) + } + }, + Err(_) => { + remove_backup(backup.as_deref()); + std::fs::rename(from, to) + } + } +} + +/// A unique name beside `to` for `ReplaceFile` to park the replaced file under. It shares the +/// temporary-file suffix, so the save's own cleanup and the tests' leftover check cover it. +#[cfg(windows)] +fn backup_path(to: &Path) -> Option { + let name = to.file_name()?.to_string_lossy().into_owned(); + let dir = match to.parent() { + Some(d) if !d.as_os_str().is_empty() => d, + _ => Path::new("."), + }; + Some(dir.join(super::temp_name(&name))) +} + +#[cfg(windows)] +fn remove_backup(backup: Option<&Path>) { + if let Some(backup) = backup { + let _ = std::fs::remove_file(backup); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/format/src/atomic/meta/tests.rs b/crates/format/src/atomic/meta/tests.rs new file mode 100644 index 0000000000..834e87e66e --- /dev/null +++ b/crates/format/src/atomic/meta/tests.rs @@ -0,0 +1,310 @@ +//! What a save keeps from the file it replaces. +//! +//! The Unix tests run for real. The Windows ones exercise `ReplaceFileW` through +//! [`crate::atomic::atomic_write`] and can only run on Windows, so they are `#[cfg(windows)]` +//! and are covered by the Windows CI job, not by a developer on a Mac. + +use crate::atomic::atomic_write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +struct TempDir(PathBuf); +impl TempDir { + fn new(tag: &str) -> Self { + static N: AtomicU64 = AtomicU64::new(0); + let d = std::env::temp_dir().join(format!("pc-meta-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + Self(d) + } + fn join(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// No temporary or backup file is left beside the saved document. +fn assert_no_temp(dir: &Path) { + let leftovers: Vec<_> = + std::fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".tmp")).collect(); + assert!(leftovers.is_empty(), "temporary files left behind: {leftovers:?}"); +} + +// ---------------------------------------------------------------- extended attributes (Unix) + +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +mod xattrs { + use super::*; + + /// macOS wants a reverse-DNS name; Linux only lets an unprivileged process write `user.*`. + #[cfg(target_os = "macos")] + const TAG: &str = "com.apple.metadata:_kMDItemUserTags"; + #[cfg(not(target_os = "macos"))] + const TAG: &str = "user.photocraft.test"; + + fn set(path: &Path, name: &str, value: &[u8]) -> bool { + rustix::fs::setxattr(path, name, value, rustix::fs::XattrFlags::empty()).is_ok() + } + + fn get(path: &Path, name: &str) -> Option> { + let mut buf = vec![0u8; 1 << 20]; + let len = rustix::fs::getxattr(path, name, buf.as_mut_slice()).ok()?; + buf.truncate(len); + Some(buf) + } + + /// #6603: the attributes of the replaced file — a Finder tag, a `user.*` attribute — are on + /// the file the user is left with. + #[test] + fn replacing_a_file_keeps_its_extended_attributes() { + let d = TempDir::new("xattr"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + if !set(&p, TAG, b"tagged") { + // tmpfs and some CI file systems have no xattr support; nothing to assert. + return; + } + + atomic_write(&p, b"second, longer").unwrap(); + + assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(get(&p, TAG).as_deref(), Some(&b"tagged"[..]), "the replaced file's attribute was dropped"); + assert_no_temp(&d.0); + } + + /// A large value still comes across (and is not silently truncated). + #[test] + fn a_large_attribute_value_survives() { + let d = TempDir::new("xattr-big"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + // Comfortably past the first read buffer, so the grow-on-ERANGE path runs. + let value = vec![0xABu8; 64 << 10]; + if !set(&p, TAG, &value) { + return; + } + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(get(&p, TAG), Some(value)); + } + + /// A read-only file is refused outright (#6600), so a save never reaches the metadata copy and + /// the file keeps everything it had. The writable cases above cover the copy itself. + #[test] + fn a_read_only_file_keeps_its_attributes_and_its_mode() { + use std::os::unix::fs::PermissionsExt; + let d = TempDir::new("xattr-ro"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + if !set(&p, TAG, b"tagged") { + return; + } + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o444)).unwrap(); + + // #6600: a read-only file is refused before anything is written, so the question this test + // asks — does a replace keep the attributes and the mode — is answered by the save not + // happening. The writable case above covers attributes surviving a replace. + let e = atomic_write(&p, b"second, longer").expect_err("a read-only file is refused"); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied); + + assert_eq!(std::fs::read(&p).unwrap(), b"first", "the original was left alone"); + assert_eq!(get(&p, TAG).as_deref(), Some(&b"tagged"[..]), "a read-only file lost its attribute"); + assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o444); + } + + /// Creating a file that was not there sets no attributes and does not fail. + #[test] + fn creating_a_new_file_needs_no_source() { + let d = TempDir::new("xattr-new"); + let p = d.join("fresh.psd"); + atomic_write(&p, b"bytes").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"bytes"); + assert!(get(&p, TAG).is_none()); + } + + /// The descriptor-based copy is what the automation write path uses (#7095): it works on two + /// open files and never looks at a path. + #[test] + fn copy_xattrs_works_on_descriptors() { + use std::os::fd::AsFd; + let d = TempDir::new("xattr-fd"); + let (from, to) = (d.join("from"), d.join("to")); + std::fs::write(&from, b"a").unwrap(); + std::fs::write(&to, b"b").unwrap(); + if !set(&from, TAG, b"carried") { + return; + } + + let old = std::fs::File::open(&from).unwrap(); + let new = std::fs::OpenOptions::new().write(true).open(&to).unwrap(); + super::super::copy_xattrs(old.as_fd(), new.as_fd()).unwrap(); + + assert_eq!(get(&to, TAG).as_deref(), Some(&b"carried"[..])); + assert_eq!(std::fs::read(&to).unwrap(), b"b", "copying attributes must not touch the bytes"); + } +} + +// --------------------------------------------------------------------------- mode (Unix) + +/// The mode was already kept before any of this; it still is. +#[cfg(unix)] +#[test] +fn replacing_a_file_keeps_its_mode() { + use std::os::unix::fs::PermissionsExt; + let d = TempDir::new("mode"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap(); + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600); + assert_no_temp(&d.0); +} + +// ----------------------------------------------------------------------------- ACL (macOS) + +#[cfg(target_os = "macos")] +mod acl { + use super::*; + + /// #7073: an ACL entry added with `chmod +a` is still there after a save. + #[test] + fn replacing_a_file_keeps_its_acl() { + let d = TempDir::new("acl"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + + // The same thing `chmod +a` does: one explicit entry for the user running the test, so + // no second account has to exist. + let Ok(user) = std::env::var("USER") else { return }; + let entries = [exacl::AclEntry::allow_user(&user, exacl::Perm::READ | exacl::Perm::WRITE, None)]; + if exacl::setfacl(&[&p], &entries, None).is_err() { + // A file system without ACL support (a RAM disk, some CI volumes). + return; + } + let before = exacl::getfacl(&p, None).unwrap(); + assert!(!before.is_empty(), "the test could not set an ACL to begin with"); + + atomic_write(&p, b"second, longer").unwrap(); + + assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(exacl::getfacl(&p, None).unwrap(), before, "the replaced file's ACL was dropped"); + assert_no_temp(&d.0); + } + + /// A file with no ACL keeps having no ACL, rather than gaining an empty one. + #[test] + fn a_file_without_an_acl_is_left_alone() { + let d = TempDir::new("acl-none"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + let before = exacl::getfacl(&p, None).unwrap(); + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(exacl::getfacl(&p, None).unwrap(), before); + } +} + +// --------------------------------------------------------------- named streams and ACL (Windows) + +/// Unverified on a Mac: these run only on Windows (CI's Windows job). +#[cfg(windows)] +mod windows { + use super::*; + + /// Open a named stream of `path`, e.g. `Zone.Identifier`. + fn stream(path: &Path, name: &str) -> PathBuf { + let mut s = path.as_os_str().to_os_string(); + s.push(":"); + s.push(name); + PathBuf::from(s) + } + + /// #7094: a file marked as downloaded from the internet is still marked after a save, so the + /// shell and other applications keep warning about it. + #[test] + fn replacing_a_file_keeps_its_mark_of_the_web() { + let d = TempDir::new("motw"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + let motw = b"[ZoneTransfer]\r\nZoneId=3\r\n"; + std::fs::write(stream(&p, "Zone.Identifier"), motw).unwrap(); + + atomic_write(&p, b"second, longer").unwrap(); + + assert_eq!(std::fs::read(&p).unwrap(), b"second, longer"); + assert_eq!(std::fs::read(stream(&p, "Zone.Identifier")).unwrap(), motw, "the Mark of the Web was dropped"); + assert_no_temp(&d.0); + } + + /// Any other named stream rides along too, because `ReplaceFileW` keeps the lot. + #[test] + fn replacing_a_file_keeps_other_named_streams() { + let d = TempDir::new("ads"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + std::fs::write(stream(&p, "photocraft.test"), b"side data").unwrap(); + + atomic_write(&p, b"second").unwrap(); + + assert_eq!(std::fs::read(stream(&p, "photocraft.test")).unwrap(), b"side data"); + } + + /// #7096: an explicit ACL entry survives, instead of the file falling back to whatever the + /// folder hands down. Compared as the string `icacls` prints, which is what a user checks. + #[test] + fn replacing_a_file_keeps_its_explicit_acl() { + let d = TempDir::new("dacl"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + + // Grant the current user an entry that inheritance would not have produced. + let user = std::env::var("USERNAME").unwrap_or_default(); + if user.is_empty() { + return; + } + let granted = std::process::Command::new("icacls").arg(&p).arg("/grant").arg(format!("{user}:(RX)")).output(); + if !granted.map(|o| o.status.success()).unwrap_or(false) { + return; + } + let before = std::process::Command::new("icacls").arg(&p).output().unwrap().stdout; + + atomic_write(&p, b"second, longer").unwrap(); + + let after = std::process::Command::new("icacls").arg(&p).output().unwrap().stdout; + assert_eq!(String::from_utf8_lossy(&after), String::from_utf8_lossy(&before), "the explicit ACL was dropped"); + assert_no_temp(&d.0); + } + + /// `ReplaceFileW` also keeps the creation time, which the rename used to reset (#6603). + #[test] + fn replacing_a_file_keeps_its_creation_time() { + let d = TempDir::new("born"); + let p = d.join("doc.psd"); + atomic_write(&p, b"first").unwrap(); + let before = std::fs::metadata(&p).unwrap().created().unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(50)); + atomic_write(&p, b"second").unwrap(); + + assert_eq!(std::fs::metadata(&p).unwrap().created().unwrap(), before); + } + + /// Creating a file where there was none still works: there is nothing to replace, so the + /// write never reaches `ReplaceFileW`. + #[test] + fn creating_a_new_file_still_works() { + let d = TempDir::new("new"); + let p = d.join("fresh.psd"); + atomic_write(&p, b"bytes").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"bytes"); + assert_no_temp(&d.0); + } +} diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index 8e71bc7ce3..d4381705a0 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -69,8 +69,8 @@ impl AtomicFs for Faulty { 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) + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { + RealFs.copy_metadata(from, to) } } @@ -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_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { + RealFs.copy_metadata(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() { @@ -221,6 +296,52 @@ fn writes_through_a_symlink() { assert_eq!(std::fs::read(&real).unwrap(), b"new"); } +/// #6605: saving onto a dangling symlink writes through to its target instead of replacing the +/// link with a regular file, even when a folder on the way to the target doesn't exist yet. +#[cfg(unix)] +#[test] +fn writes_through_a_dangling_symlink() { + let d = TempDir::new("dangling"); + let real = d.0.join("missing/dir/a.psd"); + let link = d.0.join("link.psd"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + atomic_write(&link, b"new").unwrap(); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link survives"); + assert_eq!(std::fs::read(&real).unwrap(), b"new", "the bytes land at the target"); + assert_no_temp(real.parent().unwrap_or(Path::new("."))); +} + +/// A dangling link with a relative target resolves against the link's own folder. +#[cfg(unix)] +#[test] +fn writes_through_a_dangling_relative_symlink() { + let d = TempDir::new("dangling-rel"); + let here = d.0.join("here"); + std::fs::create_dir(&here).unwrap(); + let link = here.join("link.psd"); + std::os::unix::fs::symlink("../elsewhere/real.psd", &link).unwrap(); + atomic_write(&link, b"new").unwrap(); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link survives"); + assert_eq!(std::fs::read(d.0.join("elsewhere/real.psd")).unwrap(), b"new"); + assert_no_temp(&d.0.join("elsewhere")); +} + +/// A symlink loop is an error; it never replaces either link. +#[cfg(unix)] +#[test] +fn symlink_loop_errors_without_replacing_the_links() { + let d = TempDir::new("loop"); + let a = d.0.join("a.psd"); + let b = d.0.join("b.psd"); + std::os::unix::fs::symlink(&b, &a).unwrap(); + std::os::unix::fs::symlink(&a, &b).unwrap(); + let e = atomic_write(&a, b"new").unwrap_err(); + assert!(e.to_string().contains("symbolic links"), "{e}"); + assert!(std::fs::symlink_metadata(&a).unwrap().file_type().is_symlink()); + assert!(std::fs::symlink_metadata(&b).unwrap().file_type().is_symlink()); + assert_no_temp(&d.0); +} + #[test] fn directory_target_or_bare_root_is_an_error_not_a_panic() { let d = TempDir::new("dir"); diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index 4767413617..06150e60ed 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -199,12 +199,33 @@ pub fn list_recovery(recovery_dir: &Path) -> Vec { if bundle.join(crate::store::MANIFEST).is_file() { out.push(RecoveryEntry { info, bundle }); } + } else if e.file_type().is_ok_and(|ft| ft.is_dir()) + && let Some(entry) = sidecarless_entry(recovery_dir, &p) + { + out.push(entry); } } out.sort_by(|a, b| b.info.saved_at.cmp(&a.info.saved_at).then(a.info.key.cmp(&b.info.key))); out } +/// A committed `.pcraft` bundle (its manifest is written last) whose sidecar was never +/// published, e.g. a crash during a document's first autosave. The metadata is rebuilt from the +/// manifest (read like a load reads it) and its modification time. Incomplete bundles (no +/// readable manifest) and bundles with anything at the sidecar's path are not entries. +fn sidecarless_entry(recovery_dir: &Path, bundle: &Path) -> Option { + let key = bundle.file_name()?.to_str()?.strip_suffix(".pcraft")?; + if key.is_empty() || sanitize(key) != key || std::fs::symlink_metadata(recovery_dir.join(format!("{key}.json"))).is_ok() { + return None; + } + let meta = std::fs::symlink_metadata(bundle.join(crate::store::MANIFEST)).ok().filter(|m| m.is_file())?; + let source = crate::store::DirSource { root: bundle.to_path_buf() }; + let manifest = crate::store::read_manifest(&source, &crate::LoadOptions::default()).ok()?; + let saved_at = meta.modified().ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok()).map_or(0, |d| d.as_secs()); + let info = RecoveryInfo { key: key.to_string(), document_name: manifest.document.name, original_path: None, saved_at, revision: 0 }; + Some(RecoveryEntry { info, bundle: bundle.to_path_buf() }) +} + /// Load a recovered document. pub fn recover(entry: &RecoveryEntry) -> Result { crate::load_path(&entry.bundle) @@ -292,8 +313,9 @@ impl RecoveryStore { const ORPHAN_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60); /// Best-effort removal of recovery data nothing can ever load or clean up: -/// - `.pcraft` bundle folders with no `.json` sidecar (a crash during a document's -/// first autosave: the sidecar is written after the bundle, and `list_recovery` skips them); +/// - `.pcraft` bundle folders with no `.json` sidecar and no readable manifest (a +/// crash in the middle of a document's first autosave). A sidecar-less bundle whose manifest +/// was committed is complete and listed by `list_recovery`, so it is kept; /// - `*.pcsave.tmp` files left by an interrupted atomic write (see [`crate::atomic::temp_name`]) /// that another process created. /// @@ -321,7 +343,8 @@ fn sweep_orphans(recovery_dir: &Path, min_age: std::time::Duration) { // Anything at the sidecar's path (even a broken one) keeps the bundle. let has_sidecar = std::fs::symlink_metadata(recovery_dir.join(format!("{key}.json"))).is_ok(); let parts = [path.clone(), path.join("tiles"), path.join("blobs")]; - if !has_sidecar && parts.iter().all(|p| untouched_for(p, min_age, true)) { + // A committed bundle is recoverable work: keep exactly what `list_recovery` lists. + if !has_sidecar && sidecarless_entry(recovery_dir, &path).is_none() && parts.iter().all(|p| untouched_for(p, min_age, true)) { let _ = std::fs::remove_dir_all(&path); } else { bundles.extend(parts); @@ -437,4 +460,41 @@ mod tests { assert_eq!(recover(&entries[0]).unwrap(), *doc); let _ = std::fs::remove_dir_all(&dir); } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_committed_bundle_without_a_sidecar_is_listed_and_survives_the_sweep() { + let dir = std::env::temp_dir().join(format!( + "pcraft-nosidecar-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0) + )); + let doc = + Arc::new(Document::new("Lost", photocraft_doc::Size { width: 4, height: 4 }, photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8)); + let saver = Autosaver::new(&dir, "first"); + saver.request(doc.clone(), 1, None, SaveOptions::default()); + assert!(saver.flush().is_some_and(|r| r.is_ok())); + // The crash: the bundle was committed, the sidecar never published. + std::fs::remove_file(dir.join("first.json")).unwrap(); + + let entries = list_recovery(&dir); + assert_eq!(entries.len(), 1, "the committed bundle is discoverable"); + assert_eq!((entries[0].info.key.as_str(), entries[0].info.document_name.as_str()), ("first", "Lost")); + assert_eq!(recover(&entries[0]).unwrap(), *doc); + + sweep_orphans(&dir, std::time::Duration::ZERO); + assert!(dir.join("first.pcraft").is_dir(), "aged cleanup keeps a committed bundle"); + + // Incomplete bundles (no manifest, or one that does not read) are neither listed nor kept. + let partial = dir.join("partial.pcraft"); + std::fs::create_dir_all(partial.join("tiles")).unwrap(); + let broken = dir.join("broken.pcraft"); + std::fs::create_dir_all(&broken).unwrap(); + std::fs::write(broken.join("manifest.json"), b"{").unwrap(); + assert_eq!(list_recovery(&dir).len(), 1); + sweep_orphans(&dir, std::time::Duration::ZERO); + assert!(!partial.exists() && !broken.exists()); + assert!(dir.join("first.pcraft").is_dir()); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 51d6183baa..95d45fafb9 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -617,6 +617,15 @@ impl Loader<'_> { } } } + // Variable targets follow their layers through id remapping; a dangling one keeps a + // non-colliding placeholder so it stays a missing-layer skip rather than driving an + // unrelated layer (#4969). With preserved ids the map is the identity and defs stay as saved. + let mut variables = m.variables.clone(); + if !self.preserve_ids { + for vd in &mut variables.defs { + vd.layer = self.id_map.get(&vd.layer.0).copied().unwrap_or(LayerId(u64::MAX - vd.layer.0.min(u64::MAX / 2))); + } + } let id = if self.preserve_ids { DocId(m.id) } else { DocId::fresh() }; Ok(Document { id, @@ -641,7 +650,7 @@ impl Loader<'_> { patterns, color_table: m.color_table.clone(), duotone: m.duotone.clone(), - variables: m.variables.clone(), + variables, timeline: m.timeline.clone(), file_encoding: m.file_encoding, layer_comps, diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index d047668cb0..7da5dd6b53 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -163,6 +163,10 @@ pub(crate) fn read_manifest(src: &dyn ByteSource, opts: &LoadOptions) -> Result< let layers = take_array(&mut v, "/document/layers"); let mut m: Manifest = serde_json::from_value(v)?; m.document.layers = layers_from_json(layers)?; + // A number past f32's range decodes as infinity, which no save could write back (#6862). + if let Err(crate::finite::Problem::NonFinite { path }) = crate::finite::check(&m) { + return Err(FormatError::corrupt(format!("`{path}` is out of range"))); + } Ok(m) } diff --git a/crates/format/tests/corruption.rs b/crates/format/tests/corruption.rs index d293abed1e..727b37cb70 100644 --- a/crates/format/tests/corruption.rs +++ b/crates/format/tests/corruption.rs @@ -94,6 +94,24 @@ fn bad_manifest_json() { assert!(matches!(load_from_bytes(&b), Err(FormatError::Json(_)))); } +/// A number past f32's range would load as infinity, and every later save would then fail +/// (#6862): it is refused when the file is opened, naming the value. +#[test] +fn out_of_range_number_refused_on_open() { + let b = rebuild(&sample(), |n, d| { + Some(if n == "manifest.json" { + let mut v: serde_json::Value = serde_json::from_slice(&d).unwrap(); + v["document"]["layers"][0]["opacity"] = serde_json::json!(1e39); + serde_json::to_vec(&v).unwrap() + } else { + d + }) + }); + let Err(e) = load_from_bytes(&b) else { panic!("a manifest with opacity 1e39 opened") }; + assert!(matches!(e, FormatError::Corrupt(_)), "{e}"); + assert!(e.to_string().contains("document.layers[0].opacity"), "{e}"); +} + /// Hostile nesting fails with an error instead of overflowing the stack; nesting as deep as the /// deepest groups a bundle may hold (three JSON levels each) parses, and is then rejected for /// lacking a format version. diff --git a/crates/format/tests/float_roundtrip.rs b/crates/format/tests/float_roundtrip.rs new file mode 100644 index 0000000000..da6e7626d1 --- /dev/null +++ b/crates/format/tests/float_roundtrip.rs @@ -0,0 +1,27 @@ +//! `.pcraft` is lossless for f64: every coordinate reloads bit-for-bit. + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, Size}; + +#[test] +fn f64_coordinates_reload_bit_exact() { + let x = 11.183221215612905_f64; + let y = 0.1 + 0.2; + let mut doc = Document::new("Floats", Size::new(2, 2), ColorMode::Rgb, SampleType::U8); + doc.layers.push(Layer::new( + "Shape", + LayerContent::Shape(photocraft_doc::ShapeLayer { + path: photocraft_doc::Path::new(vec![photocraft_doc::Subpath::polygon(&[(x, y), (40.0, 10.0), (25.0, 40.0)])]), + ..Default::default() + }), + )); + doc.guides.horizontal.push(x); + doc.guides.vertical.push(y); + doc.ruler_origin = [x, y]; + let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap(); + let back = photocraft_format::load_from_bytes(&bytes).unwrap(); + assert_eq!(back.guides.horizontal[0].to_bits(), x.to_bits()); + assert_eq!(back.guides.vertical[0].to_bits(), y.to_bits()); + assert_eq!(back.ruler_origin.map(f64::to_bits), [x, y].map(f64::to_bits)); + assert_eq!(back, doc); +} diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index 8b74a00c4b..f78e93947e 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -5,7 +5,7 @@ use common::*; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::text::{CharStyle, ParagraphStyle, TextRun}; use photocraft_doc::text_styles::{CharacterStyleDef, ParagraphStyleDef}; -use photocraft_doc::{Document, Layer, LayerContent, LayerId, Slice, TextLayer}; +use photocraft_doc::{Document, Layer, LayerContent, LayerId, Slice, TextLayer, VarKind, VariableDef}; use photocraft_format::*; use photocraft_raster::Rgba8Image; @@ -729,3 +729,25 @@ fn a_non_finite_float_is_refused_at_save_rather_than_breaking_every_later_load() let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); assert_eq!(back, doc); } + +#[test] +fn variables_follow_layer_id_remapping() { + let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let id_of = |d: &Document, name: &str| d.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id; + doc.variables.defs = vec![ + VariableDef { name: "head".into(), layer: id_of(&doc, "Paint"), kind: VarKind::TextReplacement }, + VariableDef { name: "badge".into(), layer: id_of(&doc, "Inner"), kind: VarKind::Visibility }, + VariableDef { name: "gone".into(), layer: LayerId(u64::MAX), kind: VarKind::Visibility }, + ]; + let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); + // Preserved ids keep every definition exactly as saved, dangling ones included. + assert_eq!(load_from_bytes(&bytes).unwrap(), doc); + // Remapped ids carry the definitions to the copied layers; a dangling target stays + // dangling and must not collide with a live layer (#4969). + let back = load_from_bytes_with(&bytes, &LoadOptions { preserve_ids: false, ..Default::default() }).unwrap(); + let defs = &back.variables.defs; + assert_eq!(defs.iter().find(|d| d.name == "head").unwrap().layer, id_of(&back, "Paint")); + assert_eq!(defs.iter().find(|d| d.name == "badge").unwrap().layer, id_of(&back, "Inner")); + let gone = defs.iter().find(|d| d.name == "gone").unwrap().layer; + assert!(back.walk().into_iter().all(|(_, _, l)| l.id != gone), "dangling target must not address a live layer"); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 1a6b60fca3..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). @@ -46,7 +46,12 @@ impl Rect { Self { x0, y0, x1, y1 } } pub fn from_xywh(x: i32, y: i32, w: u32, h: u32) -> Self { - Self::new(x, y, x.saturating_add(w as i32), y.saturating_add(h as i32)) + // Widen before adding: `w as i32` wraps negative for widths above `i32::MAX`, which + // would turn the far edge backwards (an empty, inverted rect) instead of clamping it + // to `i32::MAX` the way `saturating_add` alone would suggest. + let x1 = (i64::from(x) + i64::from(w)).min(i64::from(i32::MAX)) as i32; + let y1 = (i64::from(y) + i64::from(h)).min(i64::from(i32::MAX)) as i32; + Self::new(x, y, x1, y1) } pub fn from_size(size: Size) -> Self { Self::from_xywh(0, 0, size.width, size.height) @@ -117,7 +122,9 @@ impl TileCoord { Self::new(x.div_euclid(TILE_SIZE), y.div_euclid(TILE_SIZE)) } pub fn rect(&self) -> Rect { - Rect::from_xywh(self.tx * TILE_SIZE, self.ty * TILE_SIZE, TILE_SIZE as u32, TILE_SIZE as u32) + // `tx`/`ty` can come from a file (this type is deserialized), so saturate instead of + // overflowing (a debug-build panic / release-build wrap) on extreme coordinates. + Rect::from_xywh(self.tx.saturating_mul(TILE_SIZE), self.ty.saturating_mul(TILE_SIZE), TILE_SIZE as u32, TILE_SIZE as u32) } } @@ -258,6 +265,25 @@ mod tests { assert_eq!(grown.y0, i32::MIN); } + #[test] + fn from_xywh_clamps_huge_sizes_instead_of_wrapping() { + // Widths above `i32::MAX` used to wrap negative through `w as i32`, producing an + // empty inverted rect instead of clamping the far edge to `i32::MAX`. + assert_eq!(Rect::from_xywh(0, 0, u32::MAX, 10), Rect::new(0, 0, i32::MAX, 10)); + assert_eq!(Rect::from_xywh(-100, 5, u32::MAX, u32::MAX), Rect::new(-100, 5, i32::MAX, i32::MAX)); + assert_eq!(Rect::from_xywh(10, 20, i32::MAX as u32 + 1, 40).width(), i32::MAX as u32 - 10); + // Ordinary sizes are unchanged. + assert_eq!(Rect::from_xywh(10, 20, 30, 40), Rect::new(10, 20, 40, 60)); + } + + #[test] + fn tile_rect_saturates_on_extreme_coords() { + // Tile coords are deserialized from files; `tx * TILE_SIZE` used to overflow. + let r = TileCoord::new(i32::MAX, i32::MIN).rect(); + assert_eq!((r.x0, r.y0), (i32::MAX, i32::MIN)); + assert_eq!(TileCoord::new(2, -3).rect(), Rect::new(512, -768, 768, -512)); + } + #[test] fn width_and_height_do_not_overflow_on_extreme_edges() { // Inverted extreme rect: empty, so 0 (x1 - x0 used to overflow and panic). diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index e9d1b370ca..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 @@ -473,6 +473,17 @@ fn posterize(v: f32, n: f32) -> f32 { return floor(bin * 255.0 / (n - 1.0)) / 255.0; } +// compose::adjust::posterize_in at 16 bits: bins of the 16-bit sample, landing on 8-bit levels +// through a 16-bit step. Every product and sum here is a whole number below 2^24, exact in f32; +// 16-bit layers reach the GPU as half floats, though, so a sample within their precision of a +// bin's edge can land in the neighbouring bin (the CPU, which exports, is exact). +fn posterize16(v: f32, n: f32) -> f32 { + let x = floor(clamp(v, 0.0, 1.0) * 32768.0 + 0.5); + let bin = min(floor(x * n / 32768.0), n - 1.0); + let step = floor(bin * 32768.0 / (n - 1.0)); + return floor((step * 255.0 + 16384.0) / 32768.0) / 255.0; +} + // Document pixel being adjusted (for ordered dither). var adj_px: vec2; @@ -580,49 +591,37 @@ fn tone_channel(row: i32, v: f32, identity: f32) -> f32 { return lut(row, v); } -// Modern Brightness curve: line of slope 1.375^(b/50) rolled off to (1,1) by a v^P white anchor. -// Mirrors compose::adjust::modern_brightness. -fn mbright(v: f32, b: f32) -> f32 { - if (b == 0.0) { return v; } - let s = pow(1.375, b / 50.0); - var p: f32; - if (b >= 0.0) { p = max(4.5 - 0.013 * b, 2.0); } else { p = 5.0 - 0.072 * b; } - let vv = clamp(v, 0.0, 1.0); - return clamp(s * vv + (1.0 - s) * pow(vv, p), 0.0, 1.0); -} - -// Modern Contrast curve: symmetric cubic-Hermite S pivoting at 0.5. -// Mirrors compose::adjust::modern_contrast. -fn mcontrast(v: f32, ct: f32) -> f32 { - if (ct == 0.0) { return v; } - let k = ct / 128.0; - let e = 1.0 - k; let m = 1.0 + k; - let vv = clamp(v, 0.0, 1.0); - var t: f32; - if (vv <= 0.5) { t = vv / 0.5; } else { t = (1.0 - vv) / 0.5; } - let h = e * 0.5 * (t * t * t - 2.0 * t * t + t) + (-2.0 * t * t * t + 3.0 * t * t) * 0.5 + m * 0.5 * (t * t * t - t * t); - if (vv <= 0.5) { return h; } - return 1.0 - h; -} - fn adjust(c: vec3) -> vec3 { + let hdr = (op.flags & F_HDR) != 0u; let p0 = op.p0; let p1 = op.p1; let p2 = op.p2; let p3 = op.p3; switch op.kind { case 1: { return 1.0 - c; } // Invert - case 2: { // Threshold (p0.x = 8-bit level) - return vec3(select(0.0, 1.0, round_half_up(gray(c) * 255.0) >= p0.x)); + case 2: { // Threshold (compose::adjust::threshold_white) + // p0.x = 8-bit level; p0.y = 1: 8-bit levels, 2: 16-bit samples against p0.z, 0: float. + // Sums of 30 R + 59 G + 11 B over whole samples are exact in f32. + var white = lum(c) * 255.0 >= p0.x; + if p0.y > 0.5 { + let q = select(32768.0, 255.0, p0.y < 1.5); + let v = floor(clamp(c, vec3(0.0), vec3(1.0)) * q + 0.5); + let s = 30.0 * v.r + 59.0 * v.g + 11.0 * v.b; + white = select(s >= 100.0 * p0.z, s > 100.0 * p0.x - 50.0, p0.y < 1.5); + } + return vec3(select(0.0, 1.0, white)); } - case 3: { return vec3(posterize(c.r, p0.x), posterize(c.g, p0.x), posterize(c.b, p0.x)); } - case 4: { return clamp((c + p0.x - p0.z) * p0.y + p0.z + p0.w, vec3(0.0), vec3(1.0)); } // B/C legacy: brightness before (p0.x) or after (p0.w) contrast around compose::adjust::LEGACY_PIVOT (p0.z) - case 5: { // B/C modern - let b = p0.x; let ct = p0.y; - return vec3(mcontrast(mbright(c.r, b), ct), mcontrast(mbright(c.g, b), ct), mcontrast(mbright(c.b, b), ct)); + case 3: { // Posterize (p0.x levels; p0.y = 1: 16-bit) + if p0.y > 0.5 { return vec3(posterize16(c.r, p0.x), posterize16(c.g, p0.x), posterize16(c.b, p0.x)); } + return vec3(posterize(c.r, p0.x), posterize(c.g, p0.x), posterize(c.b, p0.x)); + } + case 4: { // B/C legacy: brightness before (p0.x) or after (p0.w) contrast around the pivot (p0.z); 32-bit unclipped + let v = (c + p0.x - p0.z) * p0.y + p0.z + p0.w; + if (hdr) { return v; } + return clamp(v, vec3(0.0), vec3(1.0)); } case 6: { return vec3(exposure(c.r, p0), exposure(c.g, p0), exposure(c.b, p0)); } // Exposure - case 7: { return vec3(tone_channel(0, c.r, p0.x), tone_channel(1, c.g, p0.y), tone_channel(2, c.b, p0.z)); } // Levels / Curves + case 7: { return vec3(tone_channel(0, c.r, p0.x), tone_channel(1, c.g, p0.y), tone_channel(2, c.b, p0.z)); } // Levels / Curves / modern Brightness/Contrast (compose::adjust::modern_lut) case 17: { return vec3(levels_float(0, levels_float(1, c.r)), levels_float(0, levels_float(2, c.g)), levels_float(0, levels_float(3, c.b))); } case 8: { // Hue/Saturation let hsl = rgb_to_hsl(c); @@ -678,13 +677,17 @@ fn adjust(c: vec3) -> vec3 { l = clamp(l, vec3(0.0), vec3(p0.w)); return vec3(t_encode(l.r, p0.z), t_encode(l.g, p0.z), t_encode(l.b, p0.z)); } - case 10: { // Channel mixer - let r = clamp(dot(p0.xyz, c) + p0.w, 0.0, 1.0); + case 10: { // Channel mixer (32-bit unclipped) + let lo = select(0.0, -F32_MAX, hdr); + let hi = select(1.0, F32_MAX, hdr); + let r = clamp(dot(p0.xyz, c) + p0.w, lo, hi); if (p3.x > 0.5) { return vec3(r); } - return vec3(r, clamp(dot(p1.xyz, c) + p1.w, 0.0, 1.0), clamp(dot(p2.xyz, c) + p2.w, 0.0, 1.0)); + return vec3(r, clamp(dot(p1.xyz, c) + p1.w, lo, hi), clamp(dot(p2.xyz, c) + p2.w, lo, hi)); } case 11: { // Photo filter // compose::adjust::photo_filter_matrix (rows p0..p2) in linear light, then SetLum. + // 32-bit samples are linear already and stay unclipped. + if (hdr) { return vec3(dot(p0.xyz, c), dot(p1.xyz, c), dot(p2.xyz, c)); } let g = p3.y; let lin = vec3(t_decode(c.r, g), t_decode(c.g, g), t_decode(c.b, g)); var f = vec3(t_encode(dot(p0.xyz, lin), g), t_encode(dot(p1.xyz, lin), g), t_encode(dot(p2.xyz, lin), g)); @@ -703,12 +706,13 @@ fn adjust(c: vec3) -> vec3 { if (c.b <= c.r && c.b <= c.g) { secondary = 1; } else if (c.r <= c.g) { secondary = 3; } let g = clamp(mn + (mid - mn) * w[secondary] / 100.0 + (mx - mid) * w[primary] / 100.0, 0.0, 1.0); if (p1.z > 0.5) { + if (hdr) { return set_lum(p2.rgb, g, F32_MAX); } // 32-bit: nothing clips at 1 return clamp(set_lum(p2.rgb, g, 1.0), vec3(0.0), vec3(1.0)); // Color blend of the tint } return vec3(g); } - case 13: { // Gradient map - var t = gray(c); + case 13: { // Gradient map (driven by the W3C luminosity) + var t = lum(c); if (p0.x > 0.5) { t = 1.0 - t; } var o = vec3(lut(0, t), lut(1, t), lut(2, t)); if (p0.y > 0.5) { o = clamp(o + bayer4(adj_px) / 255.0, vec3(0.0), vec3(1.0)); } diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 2149aaa412..994eb5e149 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -203,6 +203,11 @@ impl B { self.fields.push((kind, reach_for(width))); In::Field(kind) } + /// A chiselled bevel's inside and outside fields, always asked for together ([`bevel_fields`] + /// computes them as one). + fn bevel_fields(&mut self, width: f32) -> [In; 2] { + [FieldKind::BevelInside, FieldKind::BevelOutside].map(|k| self.field(k, width)) + } /// `Map::shifted` by whole pixels (`invert`: of `1 - a`); identity shifts of the plain map /// are skipped. fn shift(&mut self, src: In, dx: f32, dy: f32, outside: f32, invert: bool) -> In { @@ -374,8 +379,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, let mut h = if bv.technique == BevelTechnique::Smooth { b.conv(In::Shape, photocraft_compose::effects::tent_kernel(g.width)) } else { - let din = b.field(FieldKind::BevelInside, size); - let dout = b.field(FieldKind::BevelOutside, size); + let [din, dout] = b.bevel_fields(size); let soft = g.chisel_soft > 0.0; let h = b.push(stage(Kernel::MBevelH, Some(din), Some(dout), [paint(g.paint), size, f32::from(u8::from(soft)), 0.0], 0)); // As `effects::bevel_height`: only emboss styles blur a Chisel Soft ramp. @@ -542,20 +546,51 @@ fn threaded_inside(kind: FieldKind) -> bool { /// fields run on all threads inside `distance_field`, from one window. That is also the window the CPU /// compositor computes them from, so a rebuilt field is exactly the one it gives. fn field_banded(kind: FieldKind, reach: i32, shape: &[f32], region: Rect, out: Rect, halo: i32) -> Vec { - if threaded_inside(kind) { - let win = out.inflate(halo).intersect(®ion); - if win.is_empty() { - return Vec::new(); - } - let f = photocraft_compose::effects::distance_field(kind, crop(shape, region, win), win.width() as usize, win.height() as usize, reach as f32); - return if win == out { f } else { crop(&f, win, out) }; - } - let parts = par_map(bands(out, BAND.max(halo)), |band| { - let win = band.inflate(halo).intersect(®ion); - let f = photocraft_compose::effects::distance_field(kind, crop(shape, region, win), win.width() as usize, win.height() as usize, reach as f32); - crop(&f, win, band) + let [f] = fields_in_windows(threaded_inside(kind), shape, region, out, halo, |s, w, h| { + [photocraft_compose::effects::distance_field(kind, s, w, h, reach as f32)] }); - parts.concat() + f +} + +/// [`field`] of both bevel fields over the same `out`, `[BevelInside, BevelOutside]`, with the +/// sharp edges of each window found once (the two kinds have the same radius and threading). +pub(crate) fn bevel_fields(reach: i32, shape: &[f32], region: Rect, out: Rect) -> [Vec; 2] { + let kind = FieldKind::BevelInside; + fields_in_windows(threaded_inside(kind), shape, region, out, field_radius(kind, reach) + 1, |s, w, h| { + photocraft_compose::effects::bevel_distance_fields(s, w, h, reach as f32) + }) +} + +/// The fields `compute` gives for a `w × h` window of the region-sized `shape`, over `out` (inside +/// `region`), each window grown by `halo`: `out` as one window when `whole` (the field spreads over +/// threads itself), else bands of it in parallel. +fn fields_in_windows( + whole: bool, + shape: &[f32], + region: Rect, + out: Rect, + halo: i32, + compute: impl Fn(Vec, usize, usize) -> [Vec; N] + Sync + Send, +) -> [Vec; N] { + let part = |r: Rect| -> [Vec; N] { + let win = r.inflate(halo).intersect(®ion); + if win.is_empty() { + return std::array::from_fn(|_| Vec::new()); + } + let f = compute(crop(shape, region, win), win.width() as usize, win.height() as usize); + if win == r { f } else { f.map(|f| crop(&f, win, r)) } + }; + if whole { + return part(out); + } + let parts = par_map(bands(out, BAND.max(halo)), part); + std::array::from_fn(|k| { + let mut f = Vec::with_capacity(parts.iter().map(|p| p[k].len()).sum()); + for p in &parts { + f.extend_from_slice(&p[k]); + } + f + }) } /// The layer's shape (`compose::layer_shape`) over `r`, in parallel bands. @@ -579,10 +614,12 @@ pub(crate) fn paste(shape: &mut [f32], region: Rect, r: Rect, v: &[f32]) { // --------------------------------------------------------------------------------------------- // Shape identity and damage -pub(crate) type Tiles = HashMap>; +/// A shape source's tiles by address: a [`std::sync::Weak`] reserves the address (so a new tile +/// can't reuse it and pass for unchanged) without keeping the pixels alive (#4530). +pub(crate) type Tiles = HashMap>; pub(crate) fn snapshot(s: &Surface) -> Tiles { - s.tiles().map(|(c, t)| (*c, t.clone())).collect() + s.tiles().map(|(c, t)| (*c, Arc::downgrade(t))).collect() } /// Union of the tiles that differ between `old` and `s`. @@ -590,7 +627,7 @@ pub(crate) fn damage(old: &Tiles, s: &Surface) -> Rect { let mut d = Rect::EMPTY; let mut add = |r: Rect| d = if d.is_empty() { r } else { d.union(&r) }; for (c, t) in s.tiles() { - if old.get(c).is_none_or(|o| !Arc::ptr_eq(o, t)) { + if old.get(c).is_none_or(|o| !std::ptr::eq(o.as_ptr(), Arc::as_ptr(t))) { add(c.rect()); } } @@ -642,41 +679,46 @@ pub(crate) fn shape_key(layer: &Layer, canvas: Rect) -> u64 { h.finish() } -/// Deep identity of a group (children, their pixels by tile pointer, settings). -pub(crate) fn group_key(layer: &Layer, light: &GlobalLight) -> u64 { - fn surface_fp(s: &Surface, h: &mut std::collections::hash_map::DefaultHasher) { +/// Deep identity of a group (children, their pixels by tile pointer, settings). `pin` sees every +/// tile hashed, so a cache entry can reserve their addresses (see [`Tiles`]). +pub(crate) fn group_key(layer: &Layer, light: &GlobalLight, pin: &mut dyn FnMut(&Arc)) -> u64 { + type Pin<'a> = &'a mut dyn FnMut(&Arc); + fn surface_fp(s: &Surface, h: &mut std::collections::hash_map::DefaultHasher, pin: Pin) { format!("{:?}{:?}", s.format(), s.default_pixel()).hash(h); for (c, t) in s.tiles() { + pin(t); (c.tx, c.ty, Arc::as_ptr(t) as usize).hash(h); } } - fn walk(l: &Layer, h: &mut std::collections::hash_map::DefaultHasher) { + fn walk(l: &Layer, h: &mut std::collections::hash_map::DefaultHasher, pin: Pin) { l.id.0.hash(h); (l.visible, l.clipped, l.opacity.to_bits(), l.fill_opacity.to_bits(), format!("{:?}", l.blend)).hash(h); match &l.content { LayerContent::Group(g) => { for c in &g.children { - walk(c, h); + walk(c, h, pin); } } LayerContent::Fill(f) => { format!("{f:?}").hash(h); if let Some(c) = &l.fill_cache { - surface_fp(&c.surface, h); + surface_fp(&c.surface, h, pin); } } LayerContent::Adjustment(a) => format!("{a:?}").hash(h), _ => { if let Some(s) = l.surface() { - surface_fp(s, h); + surface_fp(s, h, pin); } } } if let Some(m) = &l.mask { (m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(h); - surface_fp(&m.surface, h); + surface_fp(&m.surface, h, pin); } format!("{:?}{:?}", l.vector_mask, l.effects).hash(h); + l.blend_if.hash(h); + l.advanced.hash(h); h.write_u8(0xfe); } let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -684,14 +726,15 @@ pub(crate) fn group_key(layer: &Layer, light: &GlobalLight) -> u64 { match &layer.content { LayerContent::Group(g) => { for c in &g.children { - walk(c, &mut h); + walk(c, &mut h, pin); } if let Some(m) = &layer.mask { (m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(&mut h); - surface_fp(&m.surface, &mut h); + surface_fp(&m.surface, &mut h, pin); } + format!("{:?}", layer.vector_mask).hash(&mut h); } - _ => walk(layer, &mut h), + _ => walk(layer, &mut h, pin), } h.finish() } @@ -716,6 +759,36 @@ mod tests { assert_ne!(a.key, program(&Effect::DropShadow(s), &light, false).key); } + /// A styled group's effect shape follows its own vector mask and its children's Blend If and + /// Advanced Blending, so the cached shape must be rebuilt when any of them changes. + #[test] + fn group_key_follows_vector_mask_blend_if_and_advanced() { + use photocraft_doc::{BlendRange, Knockout, Path, PixelFormat, Subpath, VectorMask}; + let light = GlobalLight::default(); + let group = Layer::group("g", vec![Layer::raster("child", PixelFormat::RGBA8)]); + let base = group_key(&group, &light, &mut |_| {}); + + let mask = |x: f64| VectorMask::new(Path::new(vec![Subpath::polygon(&[(x, 0.0), (x + 10.0, 0.0), (x + 10.0, 10.0)])])); + let mut masked = group.clone(); + masked.vector_mask = Some(mask(0.0)); + let with_mask = group_key(&masked, &light, &mut |_| {}); + assert_ne!(base, with_mask, "adding the group's vector mask"); + masked.vector_mask = Some(mask(5.0)); + assert_ne!(with_mask, group_key(&masked, &light, &mut |_| {}), "moving the group's vector mask"); + + fn child_mut(g: &mut Layer) -> &mut Layer { + let LayerContent::Group(gr) = &mut g.content else { unreachable!() }; + &mut gr.children[0] + } + let mut blend_if = group.clone(); + child_mut(&mut blend_if).blend_if.ranges = vec![[BlendRange { black: [128, 128], white: [255, 255] }, BlendRange::FULL]]; + assert_ne!(base, group_key(&blend_if, &light, &mut |_| {}), "a child's Blend If"); + + let mut advanced = group.clone(); + child_mut(&mut advanced).advanced.knockout = Knockout::Deep; + assert_ne!(base, group_key(&advanced, &light, &mut |_| {}), "a child's Advanced Blending"); + } + #[test] fn windows_grow_by_reach_and_temps_are_reused() { let light = GlobalLight::default(); @@ -837,6 +910,40 @@ mod tests { assert!(too_small_noticed, "a 2 row halo went unnoticed, so this test guards nothing"); } + /// Both bevel fields from one window (sharp edges found once) are bit for bit the fields + /// computed one at a time, over the whole region and over a part of it. + #[test] + fn paired_bevel_fields_equal_the_fields_one_at_a_time() { + let region = Rect::new(-20, -10, 300, 290); + let (w, h) = (region.width() as usize, region.height() as usize); + let mut shape = vec![0.0f32; w * h]; + for y in 0..h { + for x in 0..w { + let (fx, fy) = (x as f32 - 150.0, y as f32 - 140.0); + let r = (fx * fx + fy * fy).sqrt() + 8.0 * (fy * 0.07).sin() * (fx * 0.05).cos(); + shape[y * w + x] = (90.0 - r).clamp(0.0, 1.0) * if (x / 13 + y / 17) % 5 == 0 { 0.6 } else { 1.0 }; + } + } + let bits = |v: &[f32]| v.iter().map(|x| x.to_bits()).collect::>(); + for reach in [3, 9, 40] { + for out in [region, Rect::new(40, 30, 170, 120), Rect::new(-20, 200, 300, 290)] { + let [inside, outside] = bevel_fields(reach, &shape, region, out); + assert_eq!(bits(&inside), bits(&field(FieldKind::BevelInside, reach, &shape, region, out)), "inside, reach {reach}, {out:?}"); + assert_eq!(bits(&outside), bits(&field(FieldKind::BevelOutside, reach, &shape, region, out)), "outside, reach {reach}, {out:?}"); + // The banded path (wasm) pairs them per band the same way. + let halo = field_radius(FieldKind::BevelInside, reach) + 1; + let banded = + fields_in_windows(false, &shape, region, out, halo, |s, w, h| photocraft_compose::effects::bevel_distance_fields(s, w, h, reach as f32)); + for (k, kind) in [FieldKind::BevelInside, FieldKind::BevelOutside].into_iter().enumerate() { + let [one] = fields_in_windows(false, &shape, region, out, halo, |s, w, h| { + [photocraft_compose::effects::distance_field(kind, s, w, h, reach as f32)] + }); + assert_eq!(bits(&banded[k]), bits(&one), "banded {kind:?}, reach {reach}, {out:?}"); + } + } + } + } + /// #3646: a stroke's outer edge is the fitted 5 × 5 chamfer while it is narrow and the exact /// Euclidean transform once it is wide — the chamfer's octagon facets a wide stroke. The GPU /// builds it from the field its width picks (`stroke_outside_exact`), while the spread of drop @@ -874,4 +981,39 @@ mod tests { let shadow = program(&Effect::DropShadow(s), &light, false); assert!(shadow.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutside), "the shadow spread keeps the chamfer: {:?}", shadow.fields); } + + /// Cached shapes and group keys reserve tile addresses without keeping pixels (#4530): + /// damage still tells a replaced tile from an unchanged one, and a dropped tile's pixels + /// are freed although the entry remembers it. + #[test] + fn shape_tiles_and_group_pins_hold_addresses_not_pixels() { + use photocraft_color::{ColorMode, PixelFormat, SampleType}; + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + let mut s = Surface::new(fmt); + // A ramp, so no two tiles share their pixels. + for x in 0..600 { + let v = x as f32 / 600.0; + s.fill_rect(Rect::new(x, 0, x + 1, 300), &[v, 0.2, 0.9, 1.0]); + } + let first = s.tiles().find(|(c, _)| c.tx == 0 && c.ty == 0).map(|(_, t)| t.clone()).unwrap(); + assert_eq!(Arc::strong_count(&first), 2); + let old = snapshot(&s); + assert_eq!(Arc::strong_count(&first), 2, "the snapshot adds no strong reference"); + assert!(damage(&old, &s).is_empty()); + s.fill_rect(Rect::new(0, 0, 4, 4), &[0.0, 0.0, 0.0, 1.0]); + let origin = s.tiles().map(|(c, _)| *c).find(|c| c.tx == 0 && c.ty == 0).unwrap(); + assert_eq!(damage(&old, &s), origin.rect(), "only the edited tile"); + drop(first); + assert!(old.values().any(|w| w.strong_count() == 0), "the replaced tile's pixels are freed"); + + let child = Layer::new("c", LayerContent::Raster(s)); + let n = child.surface().map_or(0, |s| s.tile_count()); + let group = Layer::group("g", vec![child]); + let mut pins = Vec::new(); + let key = group_key(&group, &GlobalLight::default(), &mut |t| pins.push(Arc::downgrade(t))); + assert_eq!(key, group_key(&group, &GlobalLight::default(), &mut |_| {}), "pinning doesn't change the key"); + assert_eq!(pins.len(), n); + drop(group); + assert!(pins.iter().all(|w| w.strong_count() == 0), "a group's pins keep none of its pixels"); + } } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 223d4ed983..7a4357becf 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -24,7 +24,9 @@ //! staged uploads and evicted pages stay bounded. //! - **Stack reuse.** A complete root stack unit can checkpoint its backdrop into bounded chunk //! textures. Exact COW snapshots invalidate the lower prefix; edits above it restore those -//! pixels and execute only the remaining passes. Groups and clipping units stay indivisible. +//! pixels and execute only the remaining passes, capturing on the way the highest checkpoint +//! below the edit, so repeating it (a duplicate or paste and its undo) skips that much more. +//! Groups and clipping units stay indivisible. //! //! Vector masks (rasterised once per mask state into a combined mask texture), layers clipped to //! pass-through groups, stroked shapes with clipped layers (fill and stroke split once per shape @@ -40,6 +42,7 @@ mod fx; pub mod health; pub mod plan; mod prefix; +pub mod timing; use std::collections::HashMap; use std::num::NonZeroU64; @@ -47,12 +50,13 @@ use std::sync::Arc; use photocraft_color::{PixelFormat, SampleType}; use photocraft_compose::effects::FieldKind; -use photocraft_doc::{DocId, Document, Layer, LayerContent, LayerId, Pattern}; +use photocraft_doc::{DocId, Document, LayerContent, LayerId, Pattern}; use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; use photocraft_raster::{Surface, Tile}; pub use health::{DeviceHealth, Fault}; pub use plan::{Kernel, Plan, Role, Unsupported, plan}; +pub use timing::{GpuTimer, GpuTiming}; /// Accumulator format for intermediate buffers. Full float: discontinuous operations /// (Posterize, Threshold, Hard Mix, Dissolve) must land on the same side of their thresholds as @@ -75,6 +79,11 @@ pub const FX_BUDGET: usize = 1536 << 20; pub const PAGE: u32 = 2048; /// GPU memory resident layer pages may hold before the least recently used are evicted. pub const RESIDENT_BUDGET: u64 = 4 << 30; +/// GPU memory pattern textures may hold before the least recently painted are dropped (those the +/// current frame paints with always stay). +pub const PATTERN_BUDGET: u64 = 256 << 20; +/// A pattern texture no frame has painted with for this many frames is dropped. +pub const PATTERN_IDLE_FRAMES: u64 = 240; /// Uploads (bytes) [`Compositor::render`] stages before submitting the work recorded so far, so /// a refresh of a huge document never holds all its uploads in staging memory at once. const FLUSH_BYTES: u64 = 512 << 20; @@ -100,6 +109,12 @@ pub struct ChunkOut<'a> { /// What a render did (for profiling and tests). #[derive(Clone, Copy, Debug, Default)] pub struct Stats { + /// How long the GPU spent executing a composite, once its timestamps came back. Because the + /// readback is asynchronous this belongs to an *earlier* render than the one returning it + /// (it carries that render's own pixel count, so the two are never confused), and it is + /// `None` unless [`Compositor::measure_gpu_time`] is on and a measurement has landed. + /// Always `None` from [`Compositor::encode`], which submits nothing of its own. + pub gpu_time: Option, pub passes: usize, pub chunks: usize, pub slots: u32, @@ -136,7 +151,9 @@ enum TexKind { Rgba16F, /// GRAY8 masks copied verbatim. R8Direct, - /// Other masks as R32F. + /// 16-bit masks as R16F (the precision 16-bit pixels get as RGBA16F). + R16F, + /// 32-bit masks as R32F. R32F, } @@ -147,7 +164,8 @@ impl TexKind { Role::Content | Role::Stroke if f.sample == SampleType::U8 => TexKind::Rgba8, Role::Content | Role::Stroke => TexKind::Rgba16F, Role::Mask if f == PixelFormat::GRAY8 => TexKind::R8Direct, - Role::Mask => TexKind::R32F, + Role::Mask if f.sample == SampleType::F32 => TexKind::R32F, + Role::Mask => TexKind::R16F, } } fn format(self) -> wgpu::TextureFormat { @@ -155,6 +173,7 @@ impl TexKind { TexKind::Rgba8Direct | TexKind::Rgba8 => wgpu::TextureFormat::Rgba8Unorm, TexKind::Rgba16F => wgpu::TextureFormat::Rgba16Float, TexKind::R8Direct => wgpu::TextureFormat::R8Unorm, + TexKind::R16F => wgpu::TextureFormat::R16Float, TexKind::R32F => wgpu::TextureFormat::R32Float, } } @@ -163,6 +182,7 @@ impl TexKind { TexKind::Rgba8Direct | TexKind::Rgba8 => 4, TexKind::Rgba16F => 8, TexKind::R8Direct => 1, + TexKind::R16F => 2, TexKind::R32F => 4, } } @@ -183,6 +203,12 @@ struct CachedPattern { format: PixelFormat, default_bits: Vec, texture: Tex, + /// The document that last painted with it: closing that document frees the texture (#4971). + doc: DocId, + /// [`Compositor::frame`] when it last painted. + last_used: u64, + /// GPU bytes of the texture (RGBA32F). + bytes: u64, } struct Resident { @@ -290,8 +316,8 @@ struct FxEntry { shape_key: u64, /// Tiles the shape was rendered from (content, mask): damage is their difference. tiles: [Option; 2], - /// Groups: a clone pinning the tile pointers `shape_key` hashes. - _pin: Option, + /// Groups: the tiles `shape_key` hashes, by address only (see [`fx::Tiles`]). + _pin: Vec>, /// The shape on the CPU (distance fields are computed from it) and on the GPU. shape_cpu: Vec, shape: Tex, @@ -423,6 +449,10 @@ pub struct Compositor { /// The colour mode of the document being encoded: patterns paint in it /// (`photocraft_compose::pattern::pattern_rgba`). pattern_mode: photocraft_color::ColorMode, + /// The document being encoded, which owns the patterns it paints with. + pattern_doc: DocId, + /// GPU memory pattern textures may hold ([`PATTERN_BUDGET`] by default). + pattern_budget: u64, /// Texture side limit the paging is planned for (the device's, or smaller in tests). max_dim: u32, /// The device's real texture side limit. @@ -452,6 +482,9 @@ pub struct Compositor { cmyk: u64, /// The device's health (see [`Compositor::set_health`]); `None` = assumed healthy. health: Option, + /// Timestamp queries measuring how long the GPU spends on a refresh (see + /// [`Compositor::measure_gpu_time`]); `None` = not measured. + timer: Option, } fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { @@ -670,6 +703,8 @@ impl Compositor { temps: Vec::new(), patterns: HashMap::new(), pattern_mode: photocraft_color::ColorMode::Rgb, + pattern_doc: DocId(0), + pattern_budget: PATTERN_BUDGET, max_dim: 0, device_max: device.limits().max_texture_dimension_2d, page: PAGE, @@ -685,6 +720,7 @@ impl Compositor { effect_maps: map_error.is_none(), cmyk: 0, health: None, + timer: None, } .with_texture_limit(device.limits().max_texture_dimension_2d) } @@ -775,6 +811,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 { @@ -791,6 +832,17 @@ impl Compositor { self.residents.values().map(Resident::bytes).sum() } + /// GPU memory held by cached pattern textures (bytes). + pub fn pattern_cache_bytes(&self) -> u64 { + self.patterns.values().map(|p| p.bytes).sum() + } + + /// Set the GPU memory pattern textures may hold (default [`PATTERN_BUDGET`]). It takes effect + /// at the end of the next refresh. + pub fn set_pattern_budget(&mut self, bytes: u64) { + self.pattern_budget = bytes; + } + /// Whether `doc` can be composited on the GPU. Documents and layers larger than the texture /// limit are fine: surfaces are stored in page-sized textures and effect maps of regions /// larger than the limit are built per page cell. @@ -865,14 +917,22 @@ impl Compositor { pub fn forget_doc(&mut self, doc: DocId) { self.residents.retain(|_, r| r.doc != doc); self.fx.retain(|_, e| e.doc != doc); + self.patterns.retain(|_, p| p.doc != doc); self.prefix.forget(doc); } /// [`Self::forget_doc`] for every document `keep` rejects: what the compositor holds for /// documents that were closed, however their canvas entry went (#4529). pub fn retain_docs(&mut self, keep: impl Fn(DocId) -> bool) { - let mut gone: Vec = - self.residents.values().map(|r| r.doc).chain(self.fx.values().map(|e| e.doc)).chain(self.prefix.doc()).filter(|d| !keep(*d)).collect(); + let mut gone: Vec = self + .residents + .values() + .map(|r| r.doc) + .chain(self.fx.values().map(|e| e.doc)) + .chain(self.patterns.values().map(|p| p.doc)) + .chain(self.prefix.doc()) + .filter(|d| !keep(*d)) + .collect(); gone.sort_by_key(|d| d.0); gone.dedup(); for d in gone { @@ -880,9 +940,17 @@ impl Compositor { } } - /// Documents the compositor holds pages, effect maps or a lower-stack composite for. + /// Documents the compositor holds pages, effect maps, pattern textures or a lower-stack + /// composite for. pub fn held_docs(&self) -> Vec { - let mut docs: Vec = self.residents.values().map(|r| r.doc).chain(self.fx.values().map(|e| e.doc)).chain(self.prefix.doc()).collect(); + let mut docs: Vec = self + .residents + .values() + .map(|r| r.doc) + .chain(self.fx.values().map(|e| e.doc)) + .chain(self.patterns.values().map(|p| p.doc)) + .chain(self.prefix.doc()) + .collect(); docs.sort_by_key(|d| d.0); docs.dedup(); docs @@ -905,6 +973,41 @@ impl Compositor { self.health.as_ref().and_then(DeviceHealth::fault) } + /// Measure how long the GPU spends executing each [`Compositor::render`], with timestamp + /// queries read back asynchronously (nothing waits for the GPU). Returns whether the device + /// supports it: without [`timing::FEATURES`] there is no measurement at all, and callers + /// report no GPU time rather than the encode-and-submit cost `composite_ms` already is. + /// + /// A measurement arrives a frame or two after the refresh it belongs to, so it carries that + /// refresh's own pixel count; take it with [`Compositor::take_gpu_timing`]. + pub fn measure_gpu_time(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) -> bool { + // `PHOTOCRAFT_NO_GPU_TIMING` turns it off: a kill switch for a driver whose timestamp + // queries misbehave, and the control arm for measuring what the queries themselves + // cost — the device is identical either way, so only the instrumentation varies. + if std::env::var_os("PHOTOCRAFT_NO_GPU_TIMING").is_some() { + return false; + } + if self.timer.is_none() { + self.timer = timing::GpuTimer::new(device, queue); + } + self.timer.is_some() + } + + /// Whether [`Compositor::measure_gpu_time`] is on for this compositor. + pub fn measures_gpu_time(&self) -> bool { + self.timer.is_some() + } + + /// The most recent finished GPU measurement, leaving none behind (a stale number is never + /// reported twice as new). `None` while unmeasured, unsupported, or still in flight. + /// + /// [`Compositor::render`] already drains this into [`Stats::gpu_time`], so the app never + /// calls it; it is how a bench or a test collects a measurement without issuing another + /// render to carry it. + pub fn take_gpu_timing(&mut self) -> Option { + self.timer.as_mut().and_then(timing::GpuTimer::take) + } + /// Composite `region` of `doc`. Each finished chunk is passed to `sink` while its encoder is /// still open; the chunk texture is reused afterwards, so the sink must record any copies /// or passes that read it into the given encoder. Submits the work before returning; a @@ -920,8 +1023,47 @@ impl Compositor { ) -> Result { let result = scoped_work(device, self.health.is_some(), || { let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") }); - let stats = self.encode_inner(device, queue, &mut encoder, doc, region, &mut sink, true)?; + if self.timer.as_ref().is_some_and(timing::GpuTimer::busy) { + // The readback map completes on a poll. The app polls every frame, but a + // non-blocking poll here keeps the measurements coming whatever the caller does. + match &self.health { + Some(h) => h.poll(device), + None => { + let _ = device.poll(wgpu::PollType::Poll); + } + } + } + // Whatever came back since the last render travels out with this one's stats, so + // nothing has to reach back into the compositor for it afterwards. + let landed = self.timer.as_mut().and_then(timing::GpuTimer::take); + if let Some(t) = &mut self.timer { + let r = region.intersect(&doc.bounds()); + t.start(&mut encoder, r.width() as u64 * r.height() as u64); + } + let mut stats = self.encode_inner(device, queue, &mut encoder, doc, region, &mut sink, true); + if let Ok(s) = &mut stats { + s.gpu_time = landed; + } + if let Some(t) = &mut self.timer { + // A refresh that failed part way measures nothing: its spans describe work the + // caller is about to redo on the CPU. + match stats { + Ok(_) => t.finish(&mut encoder), + Err(_) => { + t.cancel(); + // The refresh is lost, but a measurement that came back with it is not: + // hand it to the next render rather than drop it on the fallback path. + if let Some(landed) = landed { + t.restore(landed); + } + } + } + } + let stats = stats?; queue.submit([encoder.finish()]); + if let Some(t) = &mut self.timer { + t.submitted(); + } Ok(stats) }); if result.is_err() { @@ -994,6 +1136,7 @@ impl Compositor { let plan = plan(doc)?; self.check_fx(doc, &plan)?; self.pattern_mode = doc.pixel_format().mode; + self.pattern_doc = doc.id; let mut stats = Stats { passes: plan.passes.len(), slots: plan.slots, ..Default::default() }; if region.is_empty() { return Ok(stats); @@ -1026,7 +1169,7 @@ impl Compositor { let aprons: Vec = plan.fx.iter().zip(&paged).map(|(f, &pg)| if pg { Self::fx_apron(doc, f) } else { 0 }).collect(); for (f, &pg) in plan.fx.iter().zip(&paged) { if !pg { - self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, None), f.region, &mut stats); + self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, None), f.region, &mut stats)?; } } let fr = self.frame_res(device, queue, canvas, &plan); @@ -1057,7 +1200,7 @@ impl Compositor { continue; } let window = pf.inflate(aprons.get(i).copied().unwrap_or(0)).intersect(&f.region); - self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, Some(cell)), window, &mut stats); + self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, Some(cell)), window, &mut stats)?; } let chunks = grid_rects(part, self.chunk); stats.chunks += chunks.len(); @@ -1072,7 +1215,7 @@ impl Compositor { stats.evicted += evicted; freed += bytes; if flush && (self.staged >= flush_bytes || work >= FLUSH_WORK || freed >= flush_bytes || self.recorded >= FLUSH_OPS) { - submit_batch(device, queue, encoder, self.health.as_ref(), &mut last_submit); + submit_batch(device, queue, encoder, self.health.as_ref(), self.timer.as_mut(), &mut last_submit); self.staged = 0; self.recorded = 0; work = 0; @@ -1093,9 +1236,31 @@ impl Compositor { self.fx.retain(|(id, _), e| e.doc != doc.id || e.last_used == frame || live.contains(id)); stats.evicted += self.evict().0; self.temps.retain(|(_, used)| frame.saturating_sub(*used) < 240); + self.evict_patterns(); Ok(stats) } + /// Drop the pattern textures no frame has painted with for [`PATTERN_IDLE_FRAMES`] frames, + /// then the least recently painted ones over the pattern budget. Never one this frame painted + /// with: it would only be uploaded again on the next frame. + fn evict_patterns(&mut self) { + let frame = self.frame; + self.patterns.retain(|_, p| frame.saturating_sub(p.last_used) < PATTERN_IDLE_FRAMES); + let mut total = self.pattern_cache_bytes(); + if total > self.pattern_budget { + let mut lru: Vec<(u64, String, u64)> = + self.patterns.iter().filter(|(_, p)| p.last_used != frame).map(|(k, p)| (p.last_used, k.clone(), p.bytes)).collect(); + lru.sort_unstable(); + for (_, key, bytes) in lru { + if total <= self.pattern_budget { + break; + } + self.patterns.remove(&key); + total = total.saturating_sub(bytes); + } + } + } + /// Keep resident pages within the resident budget and the effect cache within /// its budget, least recently used first. Never drops what the current cell uses, nor /// whole-region effect maps drawn this frame. Returns how many textures were dropped and @@ -1235,7 +1400,10 @@ impl Compositor { if chunks.is_empty() { return; } - let first_pass = checkpoint.filter(|_| chunks.iter().all(|c| self.prefix.contains(*c))).map_or(0, |c| c.pass_end); + // Chunks may hold different checkpoints: bind from the lowest one any of them resumes at. + let first_pass = checkpoint + .and_then(|_| chunks.iter().map(|c| self.prefix.restore_point(*c).map(|r| r.pass_end)).try_fold(usize::MAX, |m, p| Some(m.min(p?)))) + .unwrap_or(0); let bound = self.bind_cell(device, queue, encoder, doc, plan, paged, cell, fr, first_pass, stats); // Chunk pool. @@ -1305,7 +1473,7 @@ impl Compositor { for (ci, c) in chunks.iter().enumerate() { let mut resume = 0; - if let Some(cp) = checkpoint + if let Some(cp) = checkpoint.and_then(|_| self.prefix.restore_point(*c)) && let Some((dst, _)) = self.pool.get(cp.slot as usize) && self.prefix.copy_out(encoder, *c, dst) { @@ -1322,7 +1490,7 @@ impl Compositor { if self.recorded >= FLUSH_OPS && let Some(last) = batch.as_deref_mut() { - submit_batch(device, queue, encoder, self.health.as_ref(), last); + submit_batch(device, queue, encoder, self.health.as_ref(), self.timer.as_mut(), last); self.staged = 0; self.recorded = 0; stats.flushes += 1; @@ -1330,11 +1498,12 @@ impl Compositor { return; } } - if resume == 0 + // Also after resuming from a lower checkpoint: the chunk moves up to this one. + if resume < i && let Some(cp) = checkpoint.filter(|cp| cp.pass_end == i) && let Some((src, _)) = self.pool.get(cp.slot as usize) { - self.prefix.capture(device, encoder, *c, src); + self.prefix.capture(device, encoder, *c, src, cp); } let scissor = match p.clip { Some(clip) => { @@ -1400,11 +1569,11 @@ impl Compositor { pass.set_bind_group(1, bg1[i].as_ref(), &[]); pass.draw(0..3, 0..1); } - if resume == 0 + if resume < plan.passes.len() && let Some(cp) = checkpoint.filter(|cp| cp.pass_end == plan.passes.len()) && let Some((src, _)) = self.pool.get(cp.slot as usize) { - self.prefix.capture(device, encoder, *c, src); + self.prefix.capture(device, encoder, *c, src, cp); } let (t, v) = &self.pool[plan.root as usize]; sink(encoder, ChunkOut { rect: *c, texture: t, view: v }); @@ -1579,11 +1748,14 @@ impl Compositor { let format = pat.surface.format(); // Unstored tiles read the default pixel, interpreted in the source format. let default_bits: Vec = pat.surface.default_pixel().iter().map(|v| v.to_bits()).collect(); - if let Some(cached) = self.patterns.get(&key) + let (doc, last_used) = (self.pattern_doc, self.frame); + if let Some(cached) = self.patterns.get_mut(&key) && cached.fingerprint == fp && cached.format == format && cached.default_bits == default_bits { + cached.doc = doc; + cached.last_used = last_used; return cached.texture.view.clone(); } let (w, h) = (pat.width, pat.height); @@ -1597,7 +1769,8 @@ impl Compositor { wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, ); let v = t.view.clone(); - self.patterns.insert(key, CachedPattern { fingerprint: fp, format, default_bits, texture: t }); + let bytes = u64::from(w.max(1)) * u64::from(h.max(1)) * 16; + self.patterns.insert(key, CachedPattern { fingerprint: fp, format, default_bits, texture: t, doc, last_used, bytes }); v } @@ -1638,11 +1811,11 @@ impl Compositor { key: FxKey, region: Rect, stats: &mut Stats, - ) { + ) -> Result<(), Unsupported> { let layer = f.layer; let canvas = doc.bounds(); let is_group = matches!(layer.content, LayerContent::Group(_)); - let shape_key = if is_group { fx::group_key(layer, &doc.global_light) } else { fx::shape_key(layer, canvas) }; + let shape_key = if is_group { fx::group_key(layer, &doc.global_light, &mut |_| {}) } else { fx::shape_key(layer, canvas) }; let (content_src, mask_src) = if is_group { (None, None) } else { fx::shape_sources(layer) }; let frame = self.frame; let (rw, rh) = (region.width(), region.height()); @@ -1690,7 +1863,13 @@ impl Compositor { region, shape_key, tiles: [None, None], - _pin: is_group.then(|| layer.clone()), + _pin: if is_group { + let mut pin = Vec::new(); + fx::group_key(layer, &doc.global_light, &mut |t| pin.push(Arc::downgrade(t))); + pin + } else { + Vec::new() + }, shape_cpu: vec![0.0; n], shape, fields: HashMap::new(), @@ -1703,13 +1882,13 @@ impl Compositor { } // Present: kept or (re)inserted above. let stamp = self.stamp; - let Some(e) = self.fx.get_mut(&key) else { return }; + let Some(e) = self.fx.get_mut(&key) else { return Ok(()) }; e.last_used = frame; e.stamp = stamp; e.doc = doc.id; e.tiles = [content_src.map(fx::snapshot), mask_src.map(fx::snapshot)]; if region.is_empty() { - return; + return Ok(()); } let t_trace = web_time_now(); @@ -1731,27 +1910,41 @@ impl Compositor { layer.effects.items.iter().filter(|e| e.enabled()).map(|e| fx::program_with(e, &doc.global_light, vector_shape, &doc.patterns, anchor)).collect(); let want = field_reaches(&progs); e.fields.retain(|k, _| want.contains_key(k)); - for (&kind, &reach) in &want { - let have = e.fields.get(&kind).map(|(r, _)| *r); - let out = match have { - Some(r) if r >= reach => { - if damage.is_empty() { - continue; - } - damage.inflate(fx::field_radius(kind, r)).intersect(®ion) + // Each field to recompute: over which rectangle, exact up to which reach. + let jobs: Vec<(FieldKind, Rect, i32)> = want + .iter() + .filter_map(|(&kind, &reach)| match e.fields.get(&kind).map(|(r, _)| *r) { + Some(r) if r >= reach => (!damage.is_empty()).then(|| (kind, damage.inflate(fx::field_radius(kind, r)).intersect(®ion), r)), + _ => Some((kind, region, reach)), + }) + .collect(); + // A chiselled bevel's two fields are asked for and kept together (`fx::bevel_fields`), so they + // are recomputed together, over one rectangle at one reach. + let job = |k: FieldKind| jobs.iter().find(|j| j.0 == k).map(|j| (j.1, j.2)); + let (bevel_in, bevel_out) = (job(FieldKind::BevelInside), job(FieldKind::BevelOutside)); + if bevel_in != bevel_out { + return Err(Unsupported(format!("bevel fields of `{}` out of step: inside {bevel_in:?}, outside {bevel_out:?}", layer.name))); + } + for (kind, out, reach) in jobs { + let fields = match kind { + FieldKind::BevelInside => { + let [inside, outside] = fx::bevel_fields(reach, &e.shape_cpu, region, out); + vec![(FieldKind::BevelInside, inside), (FieldKind::BevelOutside, outside)] } - _ => region, + // Computed with BevelInside, over the same rectangle at the same reach (checked above). + FieldKind::BevelOutside => continue, + _ => vec![(kind, fx::field(kind, reach, &e.shape_cpu, region, out))], }; - let reach = have.filter(|r| *r >= reach).unwrap_or(reach); - let v = fx::field(kind, reach, &e.shape_cpu, region, out); - if have.is_none_or(|r| r < reach) { - e.fields.insert(kind, (reach, Tex::map(device, "pc_fx_field", region, MAP32))); + for (kind, v) in fields { + if e.fields.get(&kind).is_none_or(|(r, _)| *r < reach) { + e.fields.insert(kind, (reach, Tex::map(device, "pc_fx_field", region, MAP32))); + } + if let Some((_, t)) = e.fields.get(&kind) { + t.write_r32(queue, region, out, &v); + } + staged += v.len() as u64 * 4; + stats.fx_pixels += out.width() as u64 * out.height() as u64; } - if let Some((_, t)) = e.fields.get(&kind) { - t.write_r32(queue, region, out, &v); - } - staged += v.len() as u64 * 4; - stats.fx_pixels += out.width() as u64 * out.height() as u64; } if let Some(t) = t_trace.filter(|_| !damage.is_empty()) { @@ -1766,7 +1959,7 @@ impl Compositor { if prog.maps == 0 { continue; } - let Some(e) = self.fx.get_mut(&key) else { return }; + let Some(e) = self.fx.get_mut(&key) else { return Ok(()) }; let same = e.progs[i].key == prog.key && e.progs[i].maps.len() == prog.maps; let d = if same { damage } else { region }; if d.is_empty() { @@ -1781,6 +1974,7 @@ impl Compositor { stats.fx_programs += 1; self.run_program(device, queue, encoder, key, i, prog, d, stats); } + Ok(()) } /// Record the passes of `prog` (maps of item `item` of `layer`) recomputing damage `d`. @@ -1946,9 +2140,14 @@ fn submit_batch( queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, health: Option<&DeviceHealth>, + timer: Option<&mut timing::GpuTimer>, last: &mut Option, ) { - let done = std::mem::replace(encoder, device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") })); + let mut done = std::mem::replace(encoder, device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") })); + if let Some(t) = timer { + // One refresh, several command buffers: the measurement spans all of them. + t.split(&mut done, encoder); + } let prev = last.replace(queue.submit([done.finish()])); #[cfg(not(target_arch = "wasm32"))] if let Some(prev) = prev { @@ -2115,6 +2314,7 @@ fn convert_tile(surface: &Surface, tile: Option<&Arc>, kind: TexKind, c: T } } TexKind::R8Direct => out.push((px[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8), + TexKind::R16F => out.extend(f32_to_f16(px[0]).to_le_bytes()), TexKind::R32F => out.extend(px[0].to_le_bytes()), } } @@ -2444,6 +2644,28 @@ mod tests { assert_eq!(op_words(&p, None, None, None).len() as u64, OP_UNIFORM); } + /// #6534: a 16-bit mask (16-bit documents, feathered or vector combined masks) costs two + /// bytes per pixel on the GPU, twice an 8-bit mask, not four; 32-bit masks stay R32Float. + #[test] + fn sixteen_bit_masks_upload_at_two_bytes_per_pixel() { + let gray = |sample| PixelFormat { sample, ..PixelFormat::GRAY8 }; + assert_eq!(TexKind::for_surface(Role::Mask, PixelFormat::GRAY8).bytes_per_pixel(), 1); + let k16 = TexKind::for_surface(Role::Mask, gray(SampleType::U16)); + assert_eq!(k16.bytes_per_pixel(), 2, "{k16:?}"); + assert_eq!(k16.format().block_copy_size(None), Some(2), "{k16:?}"); + let k32 = TexKind::for_surface(Role::Mask, gray(SampleType::F32)); + assert_eq!(k32.format(), wgpu::TextureFormat::R32Float); + // The texels hold the mask values (a half each), the default pixel included. + let mut s = Surface::with_default(gray(SampleType::U16), &[1.0]); + s.fill_rect(Rect::new(0, 0, 4, 1), &[0.25]); + let c = TileCoord::new(0, 0); + let bytes = convert_tile(&s, s.tile(c), k16, c); + assert_eq!(bytes.len(), (TILE_SIZE * TILE_SIZE) as usize * 2); + assert_eq!(bytes.get(..2), Some(f32_to_f16(0.25).to_le_bytes().as_slice())); + assert_eq!(bytes.get(8..10), Some(f32_to_f16(1.0).to_le_bytes().as_slice())); + assert_eq!(convert_tile(&s, None, k16, c).get(..2), Some(f32_to_f16(1.0).to_le_bytes().as_slice())); + } + /// #1774: a solid fill shares one tile across every coordinate; it is converted once, and /// every coordinate (shared or not) still gets exactly the bytes `convert_tile` gives it, at /// every depth and texture kind. @@ -2456,7 +2678,8 @@ mod tests { (PixelFormat::RGBA16, TexKind::Rgba16F), (PixelFormat::RGBA32F, TexKind::Rgba16F), (PixelFormat::GRAY8, TexKind::R8Direct), - (PixelFormat { sample: SampleType::U16, ..PixelFormat::GRAY8 }, TexKind::R32F), + (PixelFormat { sample: SampleType::U16, ..PixelFormat::GRAY8 }, TexKind::R16F), + (PixelFormat { sample: SampleType::F32, ..PixelFormat::GRAY8 }, TexKind::R32F), ] { let mut s = Surface::new(fmt); let px = [0.2, 0.4, 0.6, 1.0]; diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index b8c083c7af..58ce6e4299 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -652,11 +652,15 @@ impl<'a> Planner<'a> { let opacity = layer.opacity * layer.fill_opacity; let visible_clipped: Vec<&'a Layer> = clipped.iter().filter(|c| c.visible).collect(); - // Below 100% fill a pass-through group renders isolated (as the CPU compositor does). + // Below 100% fill a pass-through group renders isolated (as the CPU compositor does), and + // so does one that is the base of a clipping group and holds an adjustment layer. + let clip_base_adjusts = !visible_clipped.is_empty() + && matches!(&layer.content, LayerContent::Group(g) if g.children.iter().any(|c| c.visible && matches!(c.content, LayerContent::Adjustment(_)))); if let LayerContent::Group(g) = &layer.content && layer.blend == BlendMode::PassThrough && layer.fill_opacity >= 1.0 && !has_effects(layer) + && !clip_base_adjusts { let before = self.retain(backdrop); let mut after = self.stack(&g.children, backdrop)?; @@ -1097,9 +1101,10 @@ impl<'a> Planner<'a> { }; for &(i, e) in &rev { if let Effect::DropShadow(s) = e { - // The layer hides the shadow beneath it where its fill is see-through. + // The layer hides the shadow beneath it where its fill is see-through, and wholly + // in a mode that blends with it (as the CPU renderer: `shadow_see_through`). let flags = if s.knocks_out { F_KNOCKOUT } else { 0 }; - let see_through = 1.0 - layer.fill_opacity.clamp(0.0, 1.0); + let see_through = photocraft_compose::effects::shadow_see_through(layer); w = self.paint_k( w, content, @@ -1592,11 +1597,19 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf match adj { Adjustment::Invert => (1, p, None), Adjustment::Threshold { level } => { - p[0][0] = (level * 255.0).round(); + // The level, the depth's split (compose::adjust::threshold_white) and the level in 16 bits. + let t = (level * 255.0).round(); + p[0] = match depth { + photocraft_color::SampleType::U8 => [t, 1.0, 0.0, 0.0], + photocraft_color::SampleType::U16 => [t, 2.0, (t * 32768.0 / 255.0).round(), 0.0], + photocraft_color::SampleType::F32 => [t, 0.0, 0.0, 0.0], + }; (2, p, None) } Adjustment::Posterize { levels } => { + // The levels, and the 16-bit rule (compose::adjust::posterize_in). p[0][0] = (*levels).clamp(2, 255) as f32; + p[0][1] = if depth == photocraft_color::SampleType::U16 { 1.0 } else { 0.0 }; (3, p, None) } Adjustment::BrightnessContrast { brightness, contrast, legacy: true } => { @@ -1604,14 +1617,16 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 }; // Brightness before contrast when it is raised, after it when lowered (compose::adjust). let (pre, post) = if c >= 0.0 { (brightness / 255.0, 0.0) } else { (0.0, brightness / 255.0) }; - p[0] = [pre, k, photocraft_compose::adjust::LEGACY_PIVOT, post]; + // 32-bit documents pivot at 127 of 255 and don't clip (the shader checks F_HDR). + let pivot = if depth == photocraft_color::SampleType::F32 { 127.0 / 255.0 } else { photocraft_compose::adjust::LEGACY_PIVOT }; + p[0] = [pre, k, pivot, post]; (4, p, None) } Adjustment::BrightnessContrast { brightness, contrast, .. } => { - // Modern B/C: the shader rebuilds the curves from raw slider values (see compose.wgsl - // `mbright`/`mcontrast`, mirroring compose::adjust::modern_brightness/modern_contrast). - p[0] = [*brightness, *contrast, 0.0, 0.0]; - (5, p, None) + // Modern B/C: the CPU's lookup table (compose::adjust::modern_lut) on every channel, + // through the Levels / Curves kernel. + let row = to_row(&adjust::modern_lut(*brightness, *contrast)); + (7, p, Some(vec![row; 3])) } Adjustment::Exposure { exposure, offset, gamma } => { p[0] = [2f32.powf(*exposure), *offset, gamma.max(0.01), transfer_exponent(transfer.for_exposure())]; @@ -1654,9 +1669,14 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf (9, p, None) } Adjustment::ChannelMixer { matrix, monochrome } => { - p[0] = matrix[0]; - p[1] = matrix[1]; - p[2] = matrix[2]; + // compose::adjust: 32-bit documents cut each percentage to 1/1024, count 255/512 of + // the constant and don't clip (the shader checks F_HDR). + let hdr = depth == photocraft_color::SampleType::F32; + let cut = |v: f32| if hdr { (v * 1024.0).trunc() / 1024.0 } else { v }; + let rows = matrix.map(|r| [cut(r[0]), cut(r[1]), cut(r[2]), if hdr { r[3] * 255.0 / 512.0 } else { r[3] }]); + p[0] = rows[0]; + p[1] = rows[1]; + p[2] = rows[2]; p[3][0] = if *monochrome { 1.0 } else { 0.0 }; (10, p, None) } @@ -1812,10 +1832,15 @@ mod tests { for mode in [ColorMode::Rgb, ColorMode::Grayscale] { assert!(adjustment_on_gpu(adj, mode), "{adj:?} in {mode:?}"); } - // CMYK documents invert, posterize and threshold their inks, on the CPU. - let inks = matches!(adj, Adjustment::Invert | Adjustment::Posterize { .. } | Adjustment::Threshold { .. }); + // CMYK documents invert, posterize, threshold and brighten their inks, on the CPU. + let inks = !matches!(adj, Adjustment::GradientMap { .. }); assert_eq!(adjustment_on_gpu(adj, ColorMode::Cmyk), !inks, "{adj:?} in CMYK"); } + // CMYK Hue/Saturation works on the inks (on the CPU); Colorize stays on the screen colour. + let ranges = photocraft_doc::adjust::HueRange::defaults(); + let hue = |colorize| Adjustment::HueSaturation { hue: 30.0, saturation: 10.0, lightness: 0.0, colorize, ranges: ranges.clone() }; + assert!(!adjustment_on_gpu(&hue(false), ColorMode::Cmyk) && adjustment_on_gpu(&hue(true), ColorMode::Cmyk)); + assert!(adjustment_on_gpu(&hue(false), ColorMode::Rgb)); // Screen-colour adjustments stay on the GPU in Lab documents. assert!(adjustment_on_gpu(&Adjustment::Exposure { exposure: 1.0, offset: 0.0, gamma: 1.0 }, ColorMode::Lab)); } diff --git a/crates/gpu/src/prefix.rs b/crates/gpu/src/prefix.rs index 0312a25fa2..cf53a62850 100644 --- a/crates/gpu/src/prefix.rs +++ b/crates/gpu/src/prefix.rs @@ -6,26 +6,40 @@ //! every frame, and caching those would fill the budget with entries that never hit again. //! A partial rectangle is served as a sub-rect copy of the whole chunk it falls into, and a //! capture merges into the chunk it overlaps, so every hit lands on the same stable entry. +//! +//! Each chunk remembers the checkpoint it holds. A render captures at the highest checkpoint +//! below its edit, also where it resumed from a lower cached one, so the cache follows the edit +//! up the stack chunk by chunk: repeating an edit high in the stack (a duplicate or paste and its +//! undo) then skips everything below it, and chunks a refresh doesn't reach keep serving their +//! own, lower prefix meanwhile (#5286). A chunk moved by a partial capture holds only that +//! rectangle afterwards: the rest of it misses once, like any fresh entry. 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, + bytes: u64, } pub(crate) struct Prefix { context: Option, + /// The root layers of the last prepared render, all of them: a chunk stays valid while every + /// render since its capture left the layers below its checkpoint as they were. layers: Vec, + /// Where renders capture: the highest useful checkpoint below the latest edit. checkpoint: Option, chunks: HashMap, bytes: u64, @@ -90,6 +104,7 @@ impl Prefix { self.context.as_ref().map(|d| d.id) } + /// Drops the chunks `doc` invalidated and returns the checkpoint this render captures at. pub(crate) fn prepare(&mut self, doc: &Document, plan: &Plan<'_>) -> Option { if self.budget == 0 { return None; @@ -97,39 +112,51 @@ impl Prefix { // Keeping every non-layer field makes new document inputs invalidate automatically. // Tile data is Arc-shared; shared Tile equality does not scan its pixel bytes. let mut context = doc.clone(); - let mut layers = std::mem::take(&mut context.layers); + let layers = std::mem::take(&mut context.layers); let same_context = self.context.as_ref() == Some(&context); - let changed = self.layers.iter().zip(&layers).position(|(a, b)| a != b).or_else(|| (layers.len() < self.layers.len()).then_some(layers.len())); - let old = self.checkpoint.filter(|c| same_context && changed.is_none() && plan.checkpoints.contains(c)); - let checkpoint = old.or_else(|| { - if same_context && let Some(changed) = changed { - plan.checkpoints.iter().rev().find(|c| c.layer_end <= changed && c.pass_end > 1).copied() - } else { + // The lowest root layer this render changes since the last one (a layer added or removed + // at the top changes from there on). + let changed = self + .layers + .iter() + .zip(&layers) + .position(|(a, b)| a != b) + .or_else(|| (layers.len() != self.layers.len()).then_some(layers.len().min(self.layers.len()))); + if same_context { + let mut freed = 0u64; + self.chunks.retain(|_, c| { + let keep = changed.is_none_or(|ch| c.checkpoint.layer_end <= ch) && plan.checkpoints.contains(&c.checkpoint); + if !keep { + freed = freed.saturating_add(c.bytes); + } + keep + }); + self.bytes = self.bytes.saturating_sub(freed); + } else { + self.clear(); + } + let kept = self.checkpoint.filter(|c| same_context && changed.is_none() && plan.checkpoints.contains(c)); + let checkpoint = kept.or_else(|| match changed { + // Just below the edit; nothing when it is at the bottom. + Some(changed) if same_context => plan.checkpoints.iter().rev().find(|c| c.layer_end <= changed && c.pass_end > 1).copied(), + _ => { let mut useful = plan.checkpoints.iter().rev().filter(|c| c.pass_end > 1); let last = useful.next().copied(); useful.next().copied().or(last) } }); - if old.is_none() { - self.clear(); - } - let Some(checkpoint) = checkpoint else { - // Remember bottom-layer edits even when no useful prefix survives: otherwise - // every other edit would populate and immediately discard the default checkpoint. - self.context = Some(context); - self.layers = layers; - return None; - }; - layers.truncate(checkpoint.layer_end); + // Remember bottom-layer edits even when no useful prefix survives: otherwise every other + // edit would populate and immediately discard the default checkpoint. self.context = Some(context); self.layers = layers; - self.checkpoint = Some(checkpoint); - Some(checkpoint) + self.checkpoint = checkpoint; + checkpoint } - /// Whether every pixel of `rect` is already cached in its whole grid chunk. - pub(crate) fn contains(&self, rect: Rect) -> bool { - self.covered_by(rect).is_some() + /// The checkpoint the cached pixels of `rect` hold, when every one of them is cached in its + /// whole grid chunk. + pub(crate) fn restore_point(&self, rect: Rect) -> Option { + self.covered_by(rect).map(|(chunk, _)| chunk.checkpoint) } /// The whole chunk and the source offset that serve `rect`, when it is fully covered. @@ -146,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, @@ -160,7 +187,8 @@ impl Prefix { /// Merges the rendered `rect` (document coordinates) into its whole grid chunk, admitting /// the chunk while space remains. Replacing entries on every miss would make a full /// refresh larger than the cache evict its own next hits. - pub(crate) fn capture(&mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, rect: Rect, src: &wgpu::Texture) { + /// A chunk holding another (lower) checkpoint starts over with this capture. + pub(crate) fn capture(&mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, rect: Rect, src: &wgpu::Texture, checkpoint: Checkpoint) { if rect.is_empty() { return; } @@ -171,12 +199,26 @@ 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() }); + self.chunks.insert(cell, Chunk { tex, covered: Vec::new(), checkpoint, bytes: cell_bytes }); } let Some(chunk) = self.chunks.get_mut(&cell) else { return }; + if chunk.checkpoint != checkpoint { + // The render restored this chunk's old pixels earlier in the same encoder, so + // overwriting them in place is ordered after that copy. + chunk.covered.clear(); + chunk.checkpoint = checkpoint; + } // A whole-chunk render replaces the coverage; a partial rectangle lands at its offset // inside the cell, beside whatever earlier strokes put there. if rect == cell { @@ -188,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, diff --git a/crates/gpu/src/timing.rs b/crates/gpu/src/timing.rs new file mode 100644 index 0000000000..e79548d84c --- /dev/null +++ b/crates/gpu/src/timing.rs @@ -0,0 +1,308 @@ +//! GPU execution time of a composite (#7186), measured with wgpu timestamp queries. +//! +//! `GpuCanvas::refresh` reports `composite_ms`, the CPU cost of *encoding and submitting* the +//! composite. That is not the cost of running it: at 24 MP the encode costs ~7.7 ms and the GPU +//! then spends ~171 ms finishing the work, so every consumer of `composite_ms` under-reports a +//! GPU-bound canvas by more than an order of magnitude and cannot see a regression that moves +//! work onto the GPU. +//! +//! Waiting for the GPU to report the true number would serialise the two processors and make the +//! app slower, so this measures instead of waiting: a timestamp is written on the GPU timeline at +//! the start and end of every command encoder a refresh submits, and the values are read back +//! asynchronously. The sum of the spans is the time the GPU spent executing that refresh, with +//! the CPU encoding gaps between submissions excluded. Nothing blocks; the numbers simply arrive +//! a frame or two later, so each measurement carries the pixel count of the refresh it belongs to +//! and is never attributed to whichever refresh happens to be in flight when it lands. +//! +//! **What it does not count.** The spans run between bottom-of-pipe timestamps inside the +//! refresh's own command buffers. Two kinds of GPU work fall outside them: queue-level uploads +//! (`Queue::write_texture`), which wgpu executes in its own command buffer before ours, and +//! whatever the GPU still does after the final timestamp retires — the end-of-batch writeback of +//! the render targets. Measured against a waited-for wall clock on Intel UHD P630 / Mesa, with +//! no uploads outstanding, the uncounted remainder is **~5 % of the reported time**, and it is +//! proportional to the composite rather than a fixed submit latency (1.0 ms at 6 MP, 4.5 ms at +//! 24 MP, 7.6 ms at 42.6 MP, against 21 / 83 / 158 ms reported). So the number is a slight +//! under-report of the GPU's total contribution, by a few per cent rather than the ~22x of the +//! encode-and-submit time it replaces. +//! +//! **What it costs.** Nothing measurable. The same refresh on the same device with +//! `PHOTOCRAFT_NO_GPU_TIMING` set waits 88.1 ms against 87.9 ms measured — a difference of +//! −0.2 ms on an ~88 ms refresh, i.e. inside the noise of a shared host. Per refresh the +//! queries add two timestamp writes (two more per flush), one resolve and one copy of at most +//! 1 KiB, a `map_async` and an `Arc` allocation; the query set and both buffers are allocated +//! once per compositor and are size-independent, so a resize reallocates nothing. +//! +//! The measurement is *optional by construction*. Without `TIMESTAMP_QUERY` and +//! `TIMESTAMP_QUERY_INSIDE_ENCODERS` on the device (older drivers, most browsers, the Noop +//! backend), [`GpuTimer::new`] returns `None` and callers report no GPU time at all rather than a +//! plausible wrong number — reporting a plausible wrong number is the failure this fixes. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; + +/// The GPU execution time of one composite, with the refresh it measured. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GpuTiming { + /// Time the GPU spent executing the refresh's command buffers (ms). Excludes queue-level + /// uploads, which run in wgpu's own command buffer ahead of ours, and the end-of-batch work + /// after the last timestamp: see the module doc for the size of that (~5 % here, more on a + /// refresh that uploads). + pub ms: f64, + /// Pixels that refresh composited (so a late measurement stays interpretable). + pub px: u64, + /// Command buffers the refresh submitted (1 unless it flushed). + pub spans: u32, +} + +/// Device features [`GpuTimer`] needs. +pub const FEATURES: wgpu::Features = wgpu::Features::TIMESTAMP_QUERY.union(wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS); + +/// Spans one measurement may hold. A refresh submits once per flush; a 200 MP document flushes a +/// handful of times, so this is far above anything real. A measurement that would exceed it is +/// dropped rather than truncated (a truncated sum is exactly the under-report being fixed). +const SPANS: u32 = 64; +/// Any span total above this is a wrapped or garbage timestamp, not a composite. +const SANE_MS: f64 = 600_000.0; + +/// Readback state of the buffer holding the resolved timestamps. +const UNMAPPED: u8 = 0; +const MAPPED: u8 = 1; +const FAILED: u8 = 2; + +/// Timestamp queries for one compositor, reused for every refresh. +/// +/// At most one measurement is in flight: while the last one is still being read back a refresh +/// goes unmeasured, which costs a sample rather than correctness. +pub struct GpuTimer { + set: wgpu::QuerySet, + /// Resolved timestamps (`QUERY_RESOLVE | COPY_SRC`). + resolve: wgpu::Buffer, + /// Host-visible copy of `resolve` (`MAP_READ | COPY_DST`). + read: wgpu::Buffer, + /// Nanoseconds per timestamp tick, as the queue reports it. + period_ns: f32, + /// Timestamps written into the current recording. + written: u32, + /// Whether a span is open (a start timestamp waiting for its end). + open: bool, + /// Pixels of the recording in progress. + px: u64, + /// The recording overflowed [`SPANS`]: finish without resolving. + overflowed: bool, + /// The recording in progress has already had command buffers submitted (it flushed), so its + /// query slots may still be in use by the GPU. + flushed: bool, + /// A recording was abandoned after it had already submitted timestamps. Writing a query + /// again resets that slot (wgpu-core resets per write), and resetting one an in-flight + /// submission is still writing is undefined, with no way to learn when it has drained. + /// That only happens when a refresh fails after a flush, which on this path means the + /// device faulted and the canvas is about to drop to the CPU compositor anyway, so + /// measuring simply stops: no number, which is the contract everywhere else here. + stopped: bool, + /// Timestamps and pixels of the measurement being read back, and its readback flag. + in_flight: Option<(u32, u64, Arc)>, + /// [`Self::finish`] recorded a readback that [`Self::submitted`] has yet to map. Without + /// this, a refresh that starts before the last measurement lands would map the readback + /// buffer a second time while the first map is still pending, which wgpu rejects. + unmapped: bool, + /// The most recent completed measurement, until a caller takes it. + ready: Option, +} + +impl std::fmt::Debug for GpuTimer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GpuTimer").field("recording", &self.open).field("in_flight", &self.in_flight.is_some()).field("ready", &self.ready).finish() + } +} + +impl GpuTimer { + /// A timer for `device`, or `None` when it has no timestamp queries (the caller then reports + /// no GPU time, never an estimate). + pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Option { + if !device.features().contains(FEATURES) { + return None; + } + let period_ns = queue.get_timestamp_period(); + if !period_ns.is_finite() || period_ns <= 0.0 { + return None; + } + let bytes = u64::from(SPANS) * 2 * 8; + Some(Self { + set: device.create_query_set(&wgpu::QuerySetDescriptor { label: Some("pc_compose_time"), ty: wgpu::QueryType::Timestamp, count: SPANS * 2 }), + resolve: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_compose_time_resolve"), + size: bytes, + usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }), + read: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_compose_time_read"), + size: bytes, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + period_ns, + written: 0, + open: false, + px: 0, + overflowed: false, + flushed: false, + stopped: false, + in_flight: None, + unmapped: false, + ready: None, + }) + } + + /// Start measuring a refresh of `px` pixels, opening its first span in `encoder`. Does + /// nothing while a previous measurement is still being read back (that refresh goes + /// unmeasured) — call [`Self::collect`] first to give it the chance to land. + pub fn start(&mut self, encoder: &mut wgpu::CommandEncoder, px: u64) { + if self.stopped || self.in_flight.is_some() || self.open { + return; + } + self.written = 0; + self.overflowed = false; + self.flushed = false; + self.px = px; + self.open = true; + self.stamp(encoder); + } + + /// Close the span in `done` and open the next in `next`: one refresh that submits several + /// command buffers is one measurement, summed over them. + pub fn split(&mut self, done: &mut wgpu::CommandEncoder, next: &mut wgpu::CommandEncoder) { + if !self.open { + return; + } + self.stamp(done); + self.stamp(next); + self.flushed = true; + } + + /// Close the last span and record the resolve and readback copy into `encoder`, which the + /// caller is about to submit. Call [`Self::submitted`] once it has. + pub fn finish(&mut self, encoder: &mut wgpu::CommandEncoder) { + if !self.open { + return; + } + self.stamp(encoder); + self.open = false; + let count = self.written; + if self.overflowed || count < 2 || !count.is_multiple_of(2) { + self.abandon(); + return; + } + let bytes = u64::from(count) * 8; + encoder.resolve_query_set(&self.set, 0..count, &self.resolve, 0); + encoder.copy_buffer_to_buffer(&self.resolve, 0, &self.read, 0, bytes); + self.in_flight = Some((count, self.px, Arc::new(AtomicU8::new(UNMAPPED)))); + self.unmapped = true; + self.written = 0; + // This recording is over: its slots are protected by `in_flight` until the readback + // lands, not by `flushed`. Leaving `flushed` set would let a later refresh that + // recorded nothing (because `start` found the readback still in flight) stop measuring + // for good when it failed, on the strength of this recording's flush. + self.flushed = false; + } + + /// Map the readback buffer after the caller submitted the encoder [`Self::finish`] wrote + /// into. The map completes on a later device poll; nothing blocks here. + pub fn submitted(&mut self) { + if !self.unmapped { + return; + } + self.unmapped = false; + let Some((count, _, flag)) = &self.in_flight else { return }; + let (bytes, flag) = (u64::from(*count) * 8, flag.clone()); + self.read.slice(0..bytes).map_async(wgpu::MapMode::Read, move |r| { + flag.store(if r.is_ok() { MAPPED } else { FAILED }, Ordering::Release); + }); + } + + /// Abandon the recording in progress (the refresh failed part way, so its spans mean + /// nothing). + pub fn cancel(&mut self) { + self.open = false; + self.abandon(); + } + + /// Drop the recording in progress. Once its timestamps have reached the GPU its slots can't + /// be reused safely, so measuring stops for good rather than resetting a live query. + fn abandon(&mut self) { + self.stopped |= self.flushed; + self.written = 0; + self.overflowed = false; + self.flushed = false; + } + + /// Whether a measurement is being read back (so this refresh won't be measured). + pub fn busy(&self) -> bool { + self.in_flight.is_some() + } + + /// Read back a finished measurement if the map completed, and return whatever is ready. + /// Cheap and non-blocking; callers poll it per refresh or per frame. + pub fn collect(&mut self) -> Option { + if let Some((count, px, flag)) = &self.in_flight { + match flag.load(Ordering::Acquire) { + MAPPED => { + let (count, px) = (*count, *px); + self.ready = self.value(count, px); + self.read.unmap(); + self.in_flight = None; + } + FAILED => { + self.in_flight = None; + } + _ => {} + } + } + self.ready + } + + /// Put a measurement back after a caller took it but could not use it (its refresh failed), + /// so the sample is reported by the next one instead of being lost on the fallback path. + /// A measurement that has landed since wins: it is the newer of the two. + pub fn restore(&mut self, t: GpuTiming) { + self.ready.get_or_insert(t); + } + + /// Take the most recent completed measurement, leaving none behind, so a stale number is + /// never reported twice as if it were new. + pub fn take(&mut self) -> Option { + self.collect(); + self.ready.take() + } + + /// Sum the resolved spans of a mapped readback. + fn value(&self, count: u32, px: u64) -> Option { + let bytes = u64::from(count) * 8; + let view = self.read.slice(0..bytes).get_mapped_range().ok()?; + let mut ticks = 0u64; + for pair in view.as_chunks::<16>().0 { + let (a, b) = pair.split_at(8); + let start = u64::from_le_bytes(a.try_into().ok()?); + let end = u64::from_le_bytes(b.try_into().ok()?); + // A driver that reset or wrapped its timer gives an end before its start: the whole + // measurement is then unusable, and no number is better than a wrong one. + if end < start { + return None; + } + ticks += end - start; + } + drop(view); + let ms = ticks as f64 * f64::from(self.period_ns) / 1.0e6; + (ms.is_finite() && ms < SANE_MS).then_some(GpuTiming { ms, px, spans: count / 2 }) + } + + /// Write one timestamp, noting an overflow instead of wrapping round the query set. + fn stamp(&mut self, encoder: &mut wgpu::CommandEncoder) { + if self.written >= SPANS * 2 { + self.overflowed = true; + return; + } + encoder.write_timestamp(&self.set, self.written); + self.written += 1; + } +} diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index c61aee9b67..84d3d0e937 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -59,6 +59,14 @@ fn hdr_adjustments_preserve_range_and_cpu_parity() { black: Vec::new(), pencil: false, }, + // 32-bit Photoshop rules: unclipped, legacy pivot 127, the mixer's 1/1024 steps and halved constant. + Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy: true }, + Adjustment::BrightnessContrast { brightness: -20.0, contrast: -30.0, legacy: true }, + Adjustment::ChannelMixer { matrix: [[0.8, 0.3, -0.1, 0.0], [0.1, 0.9, 0.0, 0.05], [0.0, 0.2, 0.7, 0.0]], monochrome: false }, + Adjustment::ChannelMixer { matrix: [[0.4, 0.4, 0.2, 0.1], [0.0; 4], [0.0; 4]], monochrome: true }, + Adjustment::PhotoFilter { color: [0.97, 0.76, 0.0], density: 0.4, preserve_luminosity: true }, + Adjustment::PhotoFilter { color: [0.2, 0.6, 0.9], density: 0.3, preserve_luminosity: false }, + Adjustment::BlackWhite { weights: [70.0, 20.0, 50.0, 10.0, 90.0, 30.0], tint: Some([0.9, 0.7, 0.5]) }, ] { let mut d = Document::new("HDR adjustments", Size::new(3, 1), ColorMode::Rgb, SampleType::F32); let mut layer = Layer::raster("pixels", PixelFormat::RGBA32F); @@ -259,6 +267,93 @@ fn stack_prefix_reuse_matches_uncached_gpu_and_cpu_at_every_depth() { } } +/// Duplicating a layer high in the stack, undoing and redoing it (#5286): the render resumes +/// from the lower cached prefix and moves the cache up to just below the copy, so the undo and +/// the redo skip more passes. Every render matches an uncached compositor bit for bit, at every +/// depth. +#[test] +fn prefix_follows_an_edit_up_the_stack_and_stays_exact() { + let Some(mut g) = gpu() else { return }; + let mut uncached = Compositor::new(&g.device); + uncached.set_composite_cache_budget(0); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = Document::new("follow", Size::new(300, 70), ColorMode::Rgb, depth); + for seed in 1..=6 { + doc.layers.push(noise_layer("layer", doc.pixel_format(), doc.bounds(), seed, 0.2)); + } + let mut exact = |g: &mut Gpu, doc: &Document, what: &str| -> photocraft_gpu::Stats { + let stats = diff_rect(g, doc, doc.bounds(), what).unwrap_or_else(|e| panic!("{e}")); + let cached = render_to_vec(&mut g.comp, &g.device, &g.queue, doc, doc.bounds()).unwrap(); + let cold = render_to_vec(&mut uncached, &g.device, &g.queue, doc, doc.bounds()).unwrap(); + assert_eq!(cached, cold, "{depth:?} {what}: reuse preserves the GPU accumulator exactly"); + stats + }; + exact(&mut g, &doc, "populate"); + // An edit low in the stack puts the cache there. + doc.layers[1].opacity = 0.6; + assert_eq!(exact(&mut g, &doc, "low edit").prefix_hits, 0); + let low = doc.clone(); + let copy = doc.layers[4].duplicate(); + doc.layers.insert(5, copy); + let first = exact(&mut g, &doc, "duplicate"); + assert!(first.prefix_hits > 0, "{depth:?} the duplicate resumes from the low prefix"); + let undo = exact(&mut g, &low, "undo"); + assert!(undo.prefix_hits > 0, "{depth:?}"); + assert!(undo.prefix_passes_skipped > first.prefix_passes_skipped, "{depth:?} the cache moved up: {first:?} {undo:?}"); + let redo = exact(&mut g, &doc, "redo"); + assert_eq!(redo.prefix_passes_skipped, undo.prefix_passes_skipped, "{depth:?}"); + // An edit below the moved cache still invalidates it. + doc.layers[2].opacity = 0.4; + assert_eq!(exact(&mut g, &doc, "lower edit").prefix_hits, 0, "{depth:?}"); + } +} + +/// A small refresh above the cache moves only the chunk it renders, which then holds just that +/// rectangle; the other chunks keep serving their lower checkpoint, and a full render afterwards +/// resumes each chunk from its own (#5286). Every render matches an uncached compositor exactly. +#[test] +fn chunks_keep_their_own_checkpoint_after_a_partial_refresh() { + let Some(mut g) = gpu() else { return }; + let mut uncached = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap(); + uncached.set_texture_limit(PAGED_LIMIT); + uncached.set_composite_cache_budget(0); + let mut doc = base_doc(600, 300); + for seed in 2..=5 { + doc.layers.push(noise_layer("layer", PixelFormat::RGBA8, doc.bounds(), seed, 0.2)); + } + let mut render = |g: &mut Gpu, doc: &Document, rect: Rect, what: &str| -> photocraft_gpu::Stats { + let (cached, stats) = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, doc, rect).unwrap(); + let cold = render_to_vec(&mut uncached, &g.device, &g.queue, doc, rect).unwrap(); + assert_eq!(cached, cold, "{what}: reuse preserves the GPU accumulator exactly"); + let cpu = photocraft_compose::render(doc, rect); + assert!(worst_diff(&cpu.px, &cached).0 <= TOL, "{what}: GPU and CPU differ"); + stats + }; + render(&mut g, &doc, doc.bounds(), "populate"); + doc.layers[1].opacity = 0.5; + let low = render(&mut g, &doc, doc.bounds(), "low edit"); + assert_eq!(low.prefix_hits, 0); + // A copy near the top, refreshed in one chunk only. + let copy = doc.layers[3].duplicate(); + doc.layers.insert(4, copy); + let small = render(&mut g, &doc, Rect::new(10, 10, 60, 60), "partial duplicate refresh"); + assert_eq!(small.prefix_hits, 1, "{small:?}"); + let full = render(&mut g, &doc, doc.bounds(), "full refresh after the partial one"); + assert_eq!(full.prefix_hits, full.chunks - 1, "only the partly moved chunk misses: {full:?}"); + let again = render(&mut g, &doc, doc.bounds(), "full refresh, all moved"); + assert_eq!(again.prefix_hits, again.chunks); + assert!(again.prefix_passes_skipped > full.prefix_passes_skipped, "{full:?} {again:?}"); + // Mixed checkpoints: a bottom-half refresh moves the chunks it covers whole further up + // (an edit at the top), the top half keeps the previous checkpoint and still hits. + doc.layers.last_mut().unwrap().opacity = 0.7; + let bottom = render(&mut g, &doc, Rect::new(0, 256, 600, 300), "bottom strip"); + assert_eq!(bottom.prefix_hits, bottom.chunks); + let moved = render(&mut g, &doc, Rect::new(0, 0, 512, 256), "whole top chunks"); + assert_eq!(moved.prefix_hits, moved.chunks); + let mixed = render(&mut g, &doc, doc.bounds(), "full refresh, mixed checkpoints"); + assert_eq!(mixed.prefix_hits, mixed.chunks, "{mixed:?}"); +} + #[test] fn root_checkpoints_keep_clipping_groups_and_adjustments_whole() { let Some(mut g) = gpu() else { return }; @@ -576,6 +671,9 @@ fn adjustment_layers() { let pf = Adjustment::PhotoFilter { color: [0.93, 0.54, 0.0], density: 0.6, preserve_luminosity: true }; d.layers.push(Layer::new("pf", LayerContent::Adjustment(pf))); check(&mut g, &d, &format!("photo filter {depth:?}")); + // Threshold: whole 16-bit samples against the level in 16 bits, or the float luminosity. + d.layers.push(Layer::new("th", LayerContent::Adjustment(Adjustment::Threshold { level: 0.5 }))); + check(&mut g, &d, &format!("threshold {depth:?}")); } for adj in adjustments() { for (mode, opacity, masked) in [(BlendMode::Normal, 1.0, false), (BlendMode::Multiply, 0.7, true)] { @@ -2135,6 +2233,71 @@ fn pattern_cache_tracks_default_pixels() { } } +/// A document that paints one `side` x `side` pattern named `name`. +fn pattern_fill_doc(name: &str, side: u32) -> Document { + let mut d = Document::new(name, Size::new(8, 6), ColorMode::Rgb, SampleType::U8); + let pat = Pattern::new(name, photocraft_raster::Surface::with_default(PixelFormat::RGBA8, &[1.0, 0.0, 0.0, 1.0]), side, side); + d.layers.push(Layer::new( + "pattern", + LayerContent::Fill(Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }), + )); + d.patterns.push(pat); + d +} + +/// #4971: a pattern texture belongs to the document that last painted with it, and closing that +/// document frees it. Before, pattern textures were kept until the device was lost. +#[test] +fn closed_documents_release_their_pattern_textures() { + let Some(mut g) = gpu() else { return }; + let a = pattern_fill_doc("first", 4); + let mut b = pattern_fill_doc("second", 4); + b.id = photocraft_doc::DocId(a.id.0 + 1); + let one = 4 * 4 * 16; + assert_eq!(g.comp.pattern_cache_bytes(), 0); + render_to_vec(&mut g.comp, &g.device, &g.queue, &a, a.bounds()).expect("render a"); + assert_eq!(g.comp.pattern_cache_bytes(), one, "one 4 x 4 RGBA32F texture"); + render_to_vec(&mut g.comp, &g.device, &g.queue, &b, b.bounds()).expect("render b"); + assert_eq!(g.comp.pattern_cache_bytes(), 2 * one); + g.comp.forget_doc(a.id); + assert_eq!(g.comp.pattern_cache_bytes(), one, "closing a document frees its pattern"); + assert_eq!(g.comp.held_docs(), vec![b.id]); + g.comp.retain_docs(|_| false); + assert_eq!(g.comp.pattern_cache_bytes(), 0); + check(&mut g, &a, "the pattern is uploaded again after being forgotten"); +} + +/// #4971: pattern textures are bounded while their documents stay open, by a byte budget and +/// by age. What the current frame paints with is never dropped. +#[test] +fn pattern_textures_are_evicted_over_budget_and_when_idle() { + let Some(mut g) = gpu() else { return }; + let a = pattern_fill_doc("first", 4); + let mut b = pattern_fill_doc("second", 4); + b.id = photocraft_doc::DocId(a.id.0 + 1); + let mut plain = base_doc(8, 6); + plain.id = photocraft_doc::DocId(a.id.0 + 2); + let one = 4 * 4 * 16; + // Room for one pattern and a half: painting the second drops the first. + g.comp.set_pattern_budget(one + one / 2); + render_to_vec(&mut g.comp, &g.device, &g.queue, &a, a.bounds()).expect("render a"); + render_to_vec(&mut g.comp, &g.device, &g.queue, &b, b.bounds()).expect("render b"); + assert_eq!(g.comp.pattern_cache_bytes(), one, "the least recently painted pattern went"); + // A budget below a single pattern still keeps the one being painted. + g.comp.set_pattern_budget(0); + render_to_vec(&mut g.comp, &g.device, &g.queue, &b, b.bounds()).expect("render b again"); + assert_eq!(g.comp.pattern_cache_bytes(), one); + // Idle: frames that paint no pattern age it out. + g.comp.set_pattern_budget(photocraft_gpu::PATTERN_BUDGET); + for _ in 0..photocraft_gpu::PATTERN_IDLE_FRAMES - 1 { + render_to_vec(&mut g.comp, &g.device, &g.queue, &plain, plain.bounds()).expect("render plain"); + } + assert_eq!(g.comp.pattern_cache_bytes(), one, "still within the idle limit"); + render_to_vec(&mut g.comp, &g.device, &g.queue, &plain, plain.bounds()).expect("render plain"); + assert_eq!(g.comp.pattern_cache_bytes(), 0, "dropped after {} idle frames", photocraft_gpu::PATTERN_IDLE_FRAMES); + check(&mut g, &b, "the pattern is uploaded again after being evicted"); +} + #[test] fn pattern_cache_tracks_source_format_and_cow_tiles() { let Some(mut g) = gpu() else { return }; @@ -2436,3 +2599,22 @@ fn the_focused_view_stays_resident_after_a_full_refresh() { assert!(worst_diff(&cpu.px, &out).0 <= TOL); } } + +// A pass-through group holding an adjustment renders isolated when layers clip to it, on the GPU +// as on the CPU (psd-tools passthrough_clipping_mask_adjustment). +#[test] +fn pass_through_clipping_base_with_an_adjustment() { + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(32, 24); + d.layers.push(noise_layer("mid", PixelFormat::RGBA8, Rect::new(0, 0, 32, 24), 41, 1.0)); + let mut grp = Layer::group( + "g", + vec![noise_layer("in", PixelFormat::RGBA8, Rect::new(4, 4, 20, 20), 42, 0.5), Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))], + ); + grp.blend = BlendMode::PassThrough; + d.layers.push(grp); + let mut clip = noise_layer("clip", PixelFormat::RGBA8, Rect::new(8, 0, 28, 16), 43, 0.3); + clip.clipped = true; + d.layers.push(clip); + check(&mut g, &d, "pass-through clipping base with an adjustment"); +} diff --git a/crates/gpu/tests/timing.rs b/crates/gpu/tests/timing.rs new file mode 100644 index 0000000000..24c2057253 --- /dev/null +++ b/crates/gpu/tests/timing.rs @@ -0,0 +1,282 @@ +//! The canvas timing must measure GPU *execution*, not encode-and-submit (#7186). +//! +//! `composite_ms` reports the CPU cost of handing the composite to the driver, which on a +//! GPU-bound refresh is a small fraction of what the refresh costs — at 24 MP it read 7.7 ms for +//! work that took ~171 ms. `Compositor::measure_gpu_time` adds timestamp queries whose spans are +//! summed into a [`GpuTiming`]. This test pins that down from both sides: the measurement must +//! track the waited-for wall clock and must be several times the submit time, so an accidental +//! return to timing the submit fails it. +//! +//! **Hardware only.** A software rasteriser composites on the CPU, where submit and execution +//! are the same work, so the whole premise disappears and the test would pass on a bug. It +//! therefore refuses to run on a CPU adapter and says so, as every GPU timing measurement in +//! this project must (egui_kittest's `default_wgpu_setup` prefers CPU adapters on purpose). + +use std::time::Instant; + +use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, Size}; +use photocraft_geom::Rect; +use photocraft_gpu::{Compositor, DeviceHealth, GpuTiming, timing}; + +/// Pixels in `r`, as the compositor counts them. +fn area(r: Rect) -> u64 { + r.width() as u64 * r.height() as u64 +} + +fn block_on(f: F) -> F::Output { + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + let mut f = std::pin::pin!(f); + loop { + if let std::task::Poll::Ready(v) = f.as_mut().poll(&mut cx) { + return v; + } + std::thread::yield_now(); + } +} + +/// A real GPU with timestamp queries, or `None` with the reason printed. +fn gpu() -> Option<(wgpu::Device, wgpu::Queue, Compositor)> { + let options = wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }; + let Ok(adapter) = block_on(wgpu::Instance::default().request_adapter(&options)) else { + eprintln!("SKIP gpu timing: no adapter"); + return None; + }; + let info = adapter.get_info(); + if info.device_type == wgpu::DeviceType::Cpu || info.backend == wgpu::Backend::Noop { + // Not a skip we can be quiet about: a GPU timing test on llvmpipe measures nothing. + eprintln!("SKIP gpu timing: {} is a software adapter, which cannot show a GPU/CPU split", info.name); + return None; + } + if !adapter.features().contains(timing::FEATURES) { + eprintln!("SKIP gpu timing: {} has no timestamp queries", info.name); + return None; + } + let descriptor = wgpu::DeviceDescriptor { required_features: timing::FEATURES, ..Default::default() }; + let Ok((device, queue)) = block_on(adapter.request_device(&descriptor)) else { + eprintln!("SKIP gpu timing: no device with timestamp queries"); + return None; + }; + let mut comp = Compositor::new(&device); + assert!(comp.measure_gpu_time(&device, &queue), "the device has the features, so the timer must build"); + eprintln!("gpu timing on {} ({:?}, {:?})", info.name, info.device_type, info.backend); + Some((device, queue, comp)) +} + +fn rnd(seed: u32, i: u32) -> f32 { + let mut h = seed.wrapping_mul(0x9e37_79b9) ^ i.wrapping_mul(0x85eb_ca6b); + h ^= h >> 13; + h = h.wrapping_mul(0xc2b2_ae35); + h ^= h >> 16; + (h & 0xffff) as f32 / 65535.0 +} + +/// A document whose composite is clearly GPU-bound: several full-canvas blended photo layers. +fn document(size: Size) -> Document { + let mut doc = Document::with_background("timing", size, ColorMode::Rgb, SampleType::U8, Color::rgba(0.5, 0.5, 0.5, 1.0)); + let fmt = doc.pixel_format(); + let rect = Rect::from_xywh(0, 0, size.width, size.height); + for (i, blend) in [BlendMode::Normal, BlendMode::Multiply, BlendMode::Overlay, BlendMode::SoftLight].into_iter().enumerate() { + let mut layer = Layer::raster(format!("l{i}"), fmt); + let channels = fmt.channels(); + let px: Vec = (0..rect.width() * rect.height()).flat_map(|p| (0..channels).map(move |c| rnd(7 + i as u32 * 131 + c as u32 * 7919, p))).collect(); + if let Some(s) = layer.surface_mut() { + s.write_region(rect, &px); + } + layer.blend = blend; + layer.opacity = 0.8; + doc.layers.push(layer); + } + doc +} + +/// Render `region` and return (submit ms, waited-for wall ms, the measurement). +fn measure(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, doc: &Document, region: Rect) -> (f64, f64, Option) { + let t0 = Instant::now(); + comp.render(device, queue, doc, region, |_, _| {}).expect("the document composites on the GPU"); + let submit_ms = t0.elapsed().as_secs_f64() * 1000.0; + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + let wall_ms = t0.elapsed().as_secs_f64() * 1000.0; + // The readback map completes on a poll, and its callback runs on the next one. + let mut timing = None; + for _ in 0..4 { + timing = comp.take_gpu_timing(); + if timing.is_some() { + break; + } + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + } + (submit_ms, wall_ms, timing) +} + +#[test] +fn gpu_time_tracks_execution_not_submission() { + let Some((device, queue, mut comp)) = gpu() else { return }; + let size = Size::new(3000, 2000); + let doc = document(size); + let full = Rect::from_xywh(0, 0, size.width, size.height); + + // Warm the residency and pipelines: the first refresh uploads every layer. + let _ = measure(&device, &queue, &mut comp, &doc, full); + + // Best of three, by the smallest encode. Everything below is a ratio against `submit_ms`, + // and CPU contention on a shared machine only ever inflates it — one run that happened to + // encode behind a busy scheduler (8.2 ms against a typical 1.0 ms) failed the comparison + // while the measurement itself was right. The GPU side is unaffected by that contention, + // so taking the least-disturbed encode tests the same property without the false failure. + let mut best: Option<(f64, f64, Option)> = None; + for _ in 0..3 { + let run = measure(&device, &queue, &mut comp, &doc, full); + if run.2.is_some() && best.as_ref().is_none_or(|b| run.0 < b.0) { + best = Some(run); + } + } + let (submit_ms, wall_ms, timing) = best.expect("at least one of three refreshes reports a GPU time"); + let t = timing.expect("a hardware device with timestamp queries must report a GPU time"); + eprintln!("full {}x{}: submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms, gpu {:.1} ms", size.width, size.height, t.ms); + + assert_eq!(t.px, area(full), "the measurement carries the pixels of the refresh it measured"); + assert!(t.spans >= 1, "at least one command buffer was timed"); + + // The premise: on this adapter the refresh really is dominated by GPU execution. A machine + // fast enough that it isn't cannot tell the two timings apart, so say so instead of failing. + if wall_ms < 3.0 * submit_ms { + eprintln!("SKIP the comparison: this GPU finishes a 6 MP composite in {wall_ms:.1} ms against {submit_ms:.1} ms of encoding"); + return; + } + + // Timing the submit again (the bug) would report roughly `submit_ms` here. + assert!(t.ms > 3.0 * submit_ms, "gpu time {:.1} ms looks like the submit time {submit_ms:.1} ms, not the execution", t.ms); + // …and it must be the real execution, not merely a bigger number: the waited-for wall clock + // is encode + execute, so the GPU span sits below it and accounts for most of it. + assert!(t.ms <= wall_ms * 1.25, "gpu time {:.1} ms exceeds the whole waited-for refresh {wall_ms:.1} ms", t.ms); + assert!(t.ms >= (wall_ms - submit_ms) * 0.5, "gpu time {:.1} ms is far below the {:.1} ms the refresh waited", t.ms, wall_ms - submit_ms); + + // A quarter of the canvas is a quarter of the per-pixel work: a constant or a stale value + // would not follow it. + let quarter = Rect::from_xywh(0, 0, size.width / 2, size.height / 2); + let (_, _, small) = measure(&device, &queue, &mut comp, &doc, quarter); + let s = small.expect("the smaller refresh is measured too"); + eprintln!("quarter: gpu {:.1} ms for {} px against {:.1} ms for {} px", s.ms, s.px, t.ms, t.px); + assert_eq!(s.px, area(quarter)); + assert!(s.ms < t.ms * 0.6, "a quarter-canvas refresh took {:.1} ms against the full canvas's {:.1} ms", s.ms, t.ms); +} + +#[test] +fn an_unmeasured_compositor_reports_nothing() { + let Some((device, queue, _)) = gpu() else { return }; + let mut comp = Compositor::new(&device); + assert!(!comp.measures_gpu_time(), "timing is off until it is asked for"); + let doc = document(Size::new(256, 256)); + comp.render(&device, &queue, &doc, Rect::from_xywh(0, 0, 256, 256), |_, _| {}).expect("composites"); + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + assert_eq!(comp.take_gpu_timing(), None, "no measurement is reported rather than a wrong one"); +} + +#[test] +fn a_refresh_that_submits_several_times_is_one_measurement() { + let Some((device, queue, mut comp)) = gpu() else { return }; + // A budget far below the document's layer pages makes the refresh evict as it goes, which is + // what makes it submit in batches: the measurement must span all of them, not just the last. + comp.set_memory_budget(24 << 20); + let size = Size::new(3000, 2000); + let doc = document(size); + let full = Rect::from_xywh(0, 0, size.width, size.height); + + let _ = measure(&device, &queue, &mut comp, &doc, full); + let (submit_ms, wall_ms, timing) = measure(&device, &queue, &mut comp, &doc, full); + let t = timing.expect("a batched refresh is measured like any other"); + eprintln!("batched: {} command buffers, gpu {:.1} ms, submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms", t.spans, t.ms); + assert_eq!(t.px, area(full)); + if t.spans < 2 { + eprintln!("SKIP the sum: this refresh still fit one command buffer"); + return; + } + assert!(t.ms <= wall_ms * 1.25, "summed gpu time {:.1} ms exceeds the whole waited-for refresh {wall_ms:.1} ms", t.ms); + // What this bound excludes is summing only the last command buffer. A correct sum measures + // ~85 % of the waited-for GPU time here; the flush thresholds split on accumulated work, so + // no single buffer of the several carries much over half of it, and timing only the last + // would land near 40 %. 0.6 sits between the two with room on both sides for a machine + // whose split is less even than this one's. + assert!( + t.ms >= (wall_ms - submit_ms) * 0.6, + "summed gpu time {:.1} ms is far below the {:.1} ms the refresh waited: is only the last command buffer timed?", + t.ms, + wall_ms - submit_ms + ); +} + +#[test] +fn refreshes_faster_than_the_readback_stay_valid() { + let Some((device, queue, mut comp)) = gpu() else { return }; + // Refreshes arriving before the last measurement has been read back must simply go + // unmeasured. Mapping the readback buffer again while its first map is still pending is a + // validation error, and an unusable device drops the whole canvas to the CPU compositor. + let health = DeviceHealth::watch(&device); + comp.set_health(health.clone()); + let size = Size::new(600, 400); + let doc = document(size); + let full = Rect::from_xywh(0, 0, size.width, size.height); + for i in 0..16 { + comp.render(&device, &queue, &doc, full, |_, _| {}).unwrap_or_else(|e| panic!("refresh {i} failed: {e}")); + } + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + assert_eq!(comp.fault(), None, "hurried refreshes must not fault the device"); + assert_eq!(health.errors(), 0, "no uncaptured wgpu errors"); +} + +/// A refresh that recorded nothing must not stop the timer for good. `finish` leaves the +/// measurement's slots protected by the pending readback, not by `flushed`; if `flushed` +/// survived a successful finish, the next refresh to fail while that readback was still in +/// flight would set `stopped` on the strength of the *earlier* refresh's flush — even though it +/// wrote no timestamps itself — and the compositor would silently never measure again. +#[test] +fn a_failed_refresh_that_recorded_nothing_keeps_the_timer_alive() { + let Some((device, queue, _)) = gpu() else { return }; + let mut timer = timing::GpuTimer::new(&device, &queue).expect("the device has timestamp queries"); + + // A refresh that flushes once, then succeeds. + let mut first = device.create_command_encoder(&Default::default()); + let mut second = device.create_command_encoder(&Default::default()); + timer.start(&mut first, 1024); + timer.split(&mut first, &mut second); + queue.submit([first.finish()]); + timer.finish(&mut second); + queue.submit([second.finish()]); + timer.submitted(); + + // A later refresh fails before recording anything, because the readback is still in flight. + let mut third = device.create_command_encoder(&Default::default()); + timer.start(&mut third, 2048); + timer.cancel(); + drop(third); + + // The first measurement still lands... + let mut first_timing = None; + for _ in 0..8 { + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + first_timing = timer.take(); + if first_timing.is_some() { + break; + } + } + let landed = first_timing.expect("the flushed measurement completes"); + assert_eq!(landed.spans, 2, "both command buffers are one measurement"); + + // ...and the timer still measures afterwards, which is what the bug destroyed. + let mut next = device.create_command_encoder(&Default::default()); + timer.start(&mut next, 4096); + timer.finish(&mut next); + queue.submit([next.finish()]); + timer.submitted(); + let mut after = None; + for _ in 0..8 { + let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + after = timer.take(); + if after.is_some() { + break; + } + } + let after = after.expect("the timer still measures after a refresh that recorded nothing"); + assert_eq!(after.px, 4096, "the measurement belongs to the refresh that produced it"); +} diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index ae3aff5a74..7c88fc1b9c 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -5,6 +5,9 @@ //! EXIF and XMP. Its API is plain data ([`Info`], [`Decoded`], [`Error`]) so the crate knows //! nothing about the rest of PhotoCraft; `photocraft-codecs` adapts it behind its `heif` feature. //! +//! A grid's ICC profile is inherited from its tiles when the grid item itself carries no `colr`, +//! which is how phone cameras write it: see [`icc_for_item`]. +//! //! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF. //! [`Options::apply_transforms`] applies them; with it off, the pixels come back as coded. //! @@ -163,7 +166,7 @@ fn metadata(bytes: &[u8]) -> (Option>, Option>, Option) return (None, None, None); }; let id = ctx.meta.primary; - let icc = ctx.props(id).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec)); + let icc = icc_for_item(&ctx, id); let exif = ctx.exif(id).ok().flatten().map(<[u8]>::to_vec); let xmp = ctx.xmp_item(id).and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d { Cow::Borrowed(b) => String::from_utf8(b.to_vec()).ok(), @@ -172,6 +175,28 @@ fn metadata(bytes: &[u8]) -> (Option>, Option>, Option) (icc, exif, xmp.map(|x| x.trim_end_matches('\0').to_string())) } +/// The ICC profile describing `item`, inherited from the coded tiles when the item itself carries +/// none. +/// +/// `colr` is optional on a `grid` derivation, and phone cameras write it on the tiles instead: a +/// OnePlus 11 photograph carries one `prof` (Display P3) on each of its 48 `hvc1` tiles and +/// nothing on the grid. Asking the primary item alone therefore loses the profile and the photo +/// opens as if it were sRGB. The tiles are what the mosaic is composed from, so their profile is +/// the composed image's profile. +/// +/// heic-rs resolves `nclx` from the primary item in the same way and has the same gap +/// (tbraun96/heic-rs#6). The sample range cannot be recovered here as well: `DecodeOptions` has no +/// override for it, so that half has to be fixed upstream. +fn icc_for_item(ctx: &heic_rs::context::Context<'_>, item: u32) -> Option> { + let own = ctx.props(item).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec)); + if own.is_some() { + return own; + } + // `Ok(None)` is a coded picture rather than a derivation: no tiles to inherit from. + let (_, tiles) = ctx.grid(item).ok()??; + tiles.iter().find_map(|&t| ctx.props(t).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec))) +} + #[cfg(test)] mod tests { use super::*; @@ -215,4 +240,200 @@ mod tests { assert!(Error::Limit("x".into()).to_string().contains("limit")); assert!(Error::Malformed("x".into()).to_string().contains("malformed")); } + + // A grid whose colour description lives on its tiles, not on the grid item. + // + // The fixtures below are synthetic containers with no coded data at all: resolving a profile + // never decodes a pixel. Each test first asserts the fixture is the shape it claims to be, so + // a mistake in the builder cannot look like a passing fix. + + /// Stands in for a profile. `colr` hands its payload back without parsing it. + const TILE_ICC: &[u8] = b"tile-profile"; + const GRID_ICC: &[u8] = b"grid-profile"; + + const GRID_ID: u16 = 1; + const PICTURE_ID: u16 = 2; + + fn bx(kind: &[u8; 4], payload: &[u8]) -> Vec { + let mut v = u32::try_from(payload.len() + 8).unwrap().to_be_bytes().to_vec(); + v.extend_from_slice(kind); + v.extend_from_slice(payload); + v + } + + /// A box whose first four payload bytes are a version and 24 bits of flags. + fn full(kind: &[u8; 4], version: u8, payload: &[u8]) -> Vec { + let mut p = vec![version, 0, 0, 0]; + p.extend_from_slice(payload); + bx(kind, &p) + } + + fn hdlr() -> Vec { + let mut p = vec![0; 4]; + p.extend_from_slice(b"pict"); + p.extend_from_slice(&[0; 12]); + p.push(0); + full(b"hdlr", 0, &p) + } + + fn infe(id: u16, item_type: &[u8; 4]) -> Vec { + let mut p = id.to_be_bytes().to_vec(); + p.extend_from_slice(&0u16.to_be_bytes()); + p.extend_from_slice(item_type); + p.push(0); + full(b"infe", 2, &p) + } + + fn iref_dimg(from: u16, to: u16) -> Vec { + let mut p = from.to_be_bytes().to_vec(); + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&to.to_be_bytes()); + full(b"iref", 0, &bx(b"dimg", &p)) + } + + fn colr_prof(icc: &[u8]) -> Vec { + let mut p = b"prof".to_vec(); + p.extend_from_slice(icc); + bx(b"colr", &p) + } + + fn ispe(w: u32, h: u32) -> Vec { + let mut p = w.to_be_bytes().to_vec(); + p.extend_from_slice(&h.to_be_bytes()); + full(b"ispe", 0, &p) + } + + fn ipma(entries: &[(u16, &[u8])]) -> Vec { + let mut p = u32::try_from(entries.len()).unwrap().to_be_bytes().to_vec(); + for (item, assoc) in entries { + p.extend_from_slice(&item.to_be_bytes()); + p.push(u8::try_from(assoc.len()).unwrap()); + p.extend_from_slice(assoc); + } + full(b"ipma", 0, &p) + } + + /// `item`'s bytes live in `idat` (construction method 1) at offset 0. + fn iloc_in_idat(item: u16, length: u32) -> Vec { + let mut p = vec![0x44, 0x00]; + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&item.to_be_bytes()); + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&0u16.to_be_bytes()); + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&0u32.to_be_bytes()); + p.extend_from_slice(&length.to_be_bytes()); + full(b"iloc", 1, &p) + } + + fn file(meta: &[u8]) -> Vec { + let mut ftyp = b"heic".to_vec(); + ftyp.extend_from_slice(&0u32.to_be_bytes()); + ftyp.extend_from_slice(b"mif1heic"); + let mut out = bx(b"ftyp", &ftyp); + out.extend_from_slice(&full(b"meta", 0, meta)); + out + } + + /// A 1x1 64x64 grid, with `grid_icc` on the grid item and `tile_icc` on its single tile. + /// + /// `ipco` children are numbered from 1 in file order; an association byte's top bit marks the + /// property essential, as macOS writes it. + fn grid_file(grid_icc: Option<&[u8]>, tile_icc: Option<&[u8]>) -> Vec { + let mut ipco = ispe(64, 64); + let (mut grid_assoc, mut tile_assoc, mut index) = (vec![0x81], vec![0x81], 1u8); + if let Some(icc) = grid_icc { + index += 1; + ipco.extend_from_slice(&colr_prof(icc)); + grid_assoc.push(0x80 | index); + } + if let Some(icc) = tile_icc { + index += 1; + ipco.extend_from_slice(&colr_prof(icc)); + tile_assoc.push(0x80 | index); + } + let mut iprp = bx(b"ipco", &ipco); + iprp.extend_from_slice(&ipma(&[(GRID_ID, grid_assoc.as_slice()), (PICTURE_ID, tile_assoc.as_slice())])); + + let mut items = 2u16.to_be_bytes().to_vec(); + items.extend_from_slice(&infe(GRID_ID, b"grid")); + items.extend_from_slice(&infe(PICTURE_ID, b"hvc1")); + + // Version 0, flags 0, one row, one column, 64x64 output. + const DESCRIPTOR: &[u8] = &[0, 0, 0, 0, 0, 64, 0, 64]; + + let mut meta = hdlr(); + meta.extend_from_slice(&full(b"pitm", 0, &GRID_ID.to_be_bytes())); + meta.extend_from_slice(&full(b"iinf", 0, &items)); + meta.extend_from_slice(&iref_dimg(GRID_ID, PICTURE_ID)); + meta.extend_from_slice(&bx(b"iprp", &iprp)); + meta.extend_from_slice(&bx(b"idat", DESCRIPTOR)); + meta.extend_from_slice(&iloc_in_idat(GRID_ID, u32::try_from(DESCRIPTOR.len()).unwrap())); + file(&meta) + } + + /// One coded picture rather than a derivation, with `icc` on the item itself. + fn picture_file(icc: Option<&[u8]>) -> Vec { + let mut ipco = ispe(64, 64); + let mut assoc = vec![0x81]; + if let Some(icc) = icc { + ipco.extend_from_slice(&colr_prof(icc)); + assoc.push(0x82); + } + let mut iprp = bx(b"ipco", &ipco); + iprp.extend_from_slice(&ipma(&[(PICTURE_ID, assoc.as_slice())])); + + let mut items = 1u16.to_be_bytes().to_vec(); + items.extend_from_slice(&infe(PICTURE_ID, b"hvc1")); + + let mut meta = hdlr(); + meta.extend_from_slice(&full(b"pitm", 0, &PICTURE_ID.to_be_bytes())); + meta.extend_from_slice(&full(b"iinf", 0, &items)); + meta.extend_from_slice(&bx(b"iprp", &iprp)); + file(&meta) + } + + fn ctx_of(bytes: &[u8]) -> heic_rs::context::Context<'_> { + heic_rs::context::Context::open(bytes).expect("the synthetic container must parse") + } + + #[test] + fn a_grid_inherits_its_tiles_icc_profile() { + let bytes = grid_file(None, Some(TILE_ICC)); + let ctx = ctx_of(&bytes); + let primary = ctx.meta.primary; + assert_eq!(primary, u32::from(GRID_ID)); + let (grid, tiles) = ctx.grid(primary).unwrap().expect("a grid derivation"); + assert_eq!((grid.rows, grid.columns, tiles.len()), (1, 1, 1)); + assert!(ctx.icc(&ctx.props(primary).unwrap()).is_none(), "the grid item carries no colr of its own"); + assert_eq!(ctx.icc(&ctx.props(tiles[0]).unwrap()), Some(TILE_ICC), "the tile carries the profile"); + + assert_eq!(icc_for_item(&ctx, primary).as_deref(), Some(TILE_ICC)); + } + + #[test] + fn a_grids_own_profile_wins_over_its_tiles() { + let bytes = grid_file(Some(GRID_ICC), Some(TILE_ICC)); + let ctx = ctx_of(&bytes); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary).as_deref(), Some(GRID_ICC)); + } + + #[test] + fn an_unlabelled_grid_has_no_profile() { + let bytes = grid_file(None, None); + let ctx = ctx_of(&bytes); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary), None); + } + + #[test] + fn a_coded_picture_resolves_its_own_profile() { + let labelled = picture_file(Some(GRID_ICC)); + let ctx = ctx_of(&labelled); + assert!(ctx.grid(ctx.meta.primary).unwrap().is_none(), "not a derivation"); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary).as_deref(), Some(GRID_ICC)); + + let bare = picture_file(None); + let ctx = ctx_of(&bare); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary), None); + } } 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/abr_map.rs b/crates/io/src/abr_map.rs index c21800f13d..549ac40970 100644 --- a/crates/io/src/abr_map.rs +++ b/crates/io/src/abr_map.rs @@ -290,6 +290,7 @@ impl Mapper<'_> { control, fade_steps: num(d, "fStp").map_or(25, |v| v.clamp(1.0, 9999.0) as u32), minimum: pct(d, "Mnm ").unwrap_or(0.0).clamp(0.0, 1.0), + ..Dynamic::default() } } @@ -527,10 +528,17 @@ fn fit(w: u32, h: u32, v: Vec, max: u32) -> (u32, u32, Vec) { let k = (w.max(h) as f32 / max as f32).max(1.0); let (nw, nh) = (((w as f32 / k).round() as u32).clamp(1, max), ((h as f32 / k).round() as u32).clamp(1, max)); let mut out = vec![0.0f32; nw as usize * nh as usize]; + // Source rows/columns `[i * n / m, (i + 1) * n / m)` (at least one) for output index `i` of + // `m`; in u64, as `(i + 1) * n` passes u32 for a plane over 2^32 / `max` long. The parsers' + // edge caps keep today's inputs below that, but this function should not depend on them. + let span = |i: u32, n: u32, m: u32| { + let at = |k: u32| usize::try_from(u64::from(k) * u64::from(n) / u64::from(m)).unwrap_or(usize::MAX); + (at(i), at(i + 1).max(at(i).saturating_add(1)).min(n as usize)) + }; for y in 0..nh { - let (y0, y1) = ((y * h / nh) as usize, (((y + 1) * h / nh) as usize).max((y * h / nh) as usize + 1).min(h as usize)); + let (y0, y1) = span(y, h, nh); for x in 0..nw { - let (x0, x1) = ((x * w / nw) as usize, (((x + 1) * w / nw) as usize).max((x * w / nw) as usize + 1).min(w as usize)); + let (x0, x1) = span(x, w, nw); let (mut sum, mut n) = (0.0, 0usize); for yy in y0..y1 { for xx in x0..x1 { @@ -593,3 +601,18 @@ fn pattern_tile(p: &PsdPattern) -> Option { #[cfg(test)] mod tests; + +#[cfg(test)] +mod fit_tests { + use super::fit; + + /// A tall, narrow plane: `(y + 1) * h` passes `u32::MAX` once `h` is over 2^32 / `max` rows. + /// The ABR and pattern parsers cap edges well below that, but `fit` must not rely on it. + #[test] + fn fit_tall_planes_without_overflow() { + let h = 5_000_000u32; + let (w, nh, out) = fit(1, h, vec![1.0; h as usize], 1024); + assert_eq!((w, nh), (1, 1024)); + assert!(out.iter().all(|v| (*v - 1.0).abs() < 1e-6), "every output row averages real input rows"); + } +} diff --git a/crates/io/src/abr_map/tests.rs b/crates/io/src/abr_map/tests.rs index 8bea763861..501e331af9 100644 --- a/crates/io/src/abr_map/tests.rs +++ b/crates/io/src/abr_map/tests.rs @@ -177,6 +177,29 @@ fn v6_settings_map_onto_brush_sections() { } } +#[test] +fn dual_brush_tip_spacing_takes_precedence_over_wrapper_spacing() { + let preset = Descriptor::new("brushPreset") + .with("Nm ", t("Close-spaced dual brush")) + .with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 40.0)))) + .with( + "dualBrush", + Value::Descriptor( + Descriptor::new("dualBrush") + .with("useDualBrush", Value::Boolean(true)) + .with("Spcn", prc(100.0)) + .with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 12.0)).with("Spcn", prc(5.0)))), + ), + ); + let bytes = write_v6(2, &[], &[], &[preset], false).unwrap(); + let imported = read_abr(&bytes, "Dual").unwrap(); + assert_eq!(imported.presets.len(), 1); + let brush = &imported.presets[0].brush; + assert!(brush.dual_brush.enabled); + assert!((brush.dual_brush.spacing - 0.05).abs() < 1e-6, "the tip's 5% spacing must not be replaced with the wrapper's 100%"); + assert!((brush.spacing - 0.25).abs() < 1e-6, "main brush spacing is independent"); +} + #[test] fn missing_pattern_and_tip_are_reported() { let orphan = Descriptor::new("brushPreset").with("Nm ", t("Orphan")).with("Brsh", Value::Descriptor(sampled_tip("$nowhere"))); diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index 6e82478389..d4bc312690 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -280,7 +280,8 @@ fn parse_gradient_map(d: &[u8]) -> Option { 3 => (Some(d.get(4..8)?), 8), _ => return None, }; - at += 4 + be32(d, at)? as usize * 2; + // The gradient's name (UTF-16 units); checked, as `len * 2` overflows a 32-bit `usize`. + at = (be32(d, at)? as usize).checked_mul(2)?.checked_add(at + 4)?; let n = usize::from(be16(d, at)?); at += 2; let mut stops = Vec::with_capacity(n); 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/exr_parts.rs b/crates/io/src/exr_parts.rs index 5474d00310..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 { @@ -225,8 +226,8 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result> = vec![None; info.len()]; + // Each chosen part (deduplicated above) decodes once; groups come from their part's named + // channels. let mut layers: Vec = Vec::new(); let mut warnings: Vec = Vec::new(); let mut gray_only = true; @@ -234,14 +235,7 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result img.clone(), - None => { - let img = codecs::decode_exr_part(bytes, part.index, &opts)?; - part_pixels[part.index] = Some(img.clone()); - img - } - }; + let img = codecs::decode_exr_part(bytes, part.index, &opts)?; if img.sample_type() == SampleType::F16 { f16_seen = true; } @@ -277,10 +271,9 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result Result { - if codecs::detect(bytes) == Some(Format::Tiff) { + // Detected once; every step below branches on it. + let format = codecs::detect(bytes); + if format == Some(Format::Tiff) { return import_tiff_page(name, bytes, None); } - let img = codecs::decode(bytes)?; + let img = codecs::decode_as(format.ok_or(codecs::CodecError::UnknownFormat)?, bytes)?; // A palette BMP or PNG opens in Indexed Color mode with its colour table, as in Photoshop // (#3802, #4804). - let palette = match codecs::detect(bytes) { + let palette = match format { Some(Format::Bmp) => codecs::bmp_palette(bytes).ok().flatten().map(|c| (c, None)), Some(Format::Png) => codecs::png_palette(bytes).ok().flatten().map(|p| (p.colors, p.transparent)), _ => None, @@ -34,15 +36,15 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result { r.document.mode = ColorMode::Indexed; r.document.color_table = Some(photocraft_doc::ColorTable { colors, transparent }); } - r.document.file_encoding = file_encoding(bytes); - if codecs::detect(bytes) == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba { + r.document.file_encoding = file_encoding_as(format, bytes); + if format == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba { tga_alpha_to_channel(&mut r.document)?; } // OpenEXR and Radiance HDR hold linear, scene-referred values (Rec. 709 primaries unless // stated otherwise, and an OpenEXR `Y` is linear luminance): tag them linear sRGB or linear // gray so they display and convert correctly. let d = &mut r.document; - if d.icc_profile.is_none() && matches!(codecs::detect(bytes), Some(Format::OpenExr | Format::Hdr)) { + if d.icc_profile.is_none() && matches!(format, Some(Format::OpenExr | Format::Hdr)) { d.icc_profile = match d.mode { ColorMode::Rgb => Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes()), ColorMode::Grayscale => Some(photocraft_cms::builtin::linear_gray().to_bytes()), @@ -65,12 +67,19 @@ pub fn import_gif_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document } /// The lossy settings `bytes` were encoded with, for File › Save to keep: a JPEG's quality -/// (estimated from its quantisation tables) and a WebP's lossless or lossy encoding with its -/// quality. A lossy WebP whose quality can't be read gets [`DEFAULT_LOSSY_WEBP_QUALITY`]. +/// (estimated from its quantisation tables), a WebP's lossless or lossy encoding with its +/// quality, and a PNG's `sTER` stereo pairing. A lossy WebP whose quality can't be read gets +/// [`DEFAULT_LOSSY_WEBP_QUALITY`]. pub fn file_encoding(bytes: &[u8]) -> photocraft_doc::FileEncoding { + file_encoding_as(codecs::detect(bytes), bytes) +} + +/// [`file_encoding`] for `bytes` already detected as `format`. +fn file_encoding_as(format: Option, bytes: &[u8]) -> photocraft_doc::FileEncoding { let mut e = photocraft_doc::FileEncoding::default(); - match codecs::detect(bytes) { + match format { Some(Format::Jpeg) => e.jpeg_quality = codecs::jpeg_quality(bytes), + Some(Format::Png) => e.png_stereo = codecs::png_stereo(bytes).map(codecs::PngStereo::mode), Some(Format::WebP) => match codecs::webp_encoding(bytes) { Some(codecs::WebpEncoding::Lossless) => e.webp_lossless = Some(true), Some(codecs::WebpEncoding::Lossy { quality }) => { @@ -88,8 +97,8 @@ pub fn file_encoding(bytes: &[u8]) -> photocraft_doc::FileEncoding { pub const DEFAULT_LOSSY_WEBP_QUALITY: u8 = 75; /// Applies the settings `doc`'s file was encoded with (see [`file_encoding`]) to `opts`: what a -/// save that chose no quality writes, so a JPEG keeps its quality and a WebP stays lossy or -/// lossless. +/// save that chose no quality writes, so a JPEG keeps its quality, a WebP stays lossy or lossless, +/// and a stereo PNG keeps its `sTER` pairing (#6063). pub fn apply_file_encoding(doc: &Document, opts: &mut codecs::EncodeOptions) { let e = &doc.file_encoding; if let Some(q) = e.jpeg_quality { @@ -101,6 +110,7 @@ pub fn apply_file_encoding(doc: &Document, opts: &mut codecs::EncodeOptions) { if let Some(q) = e.webp_quality { opts.webp_quality = q.min(100); } + opts.png_stereo = e.png_stereo.and_then(codecs::PngStereo::from_mode); } /// Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" channel holding the @@ -199,6 +209,7 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result (ColorMode::Grayscale, ChannelLayout::GrayA), ChannelLayout::Rgb | ChannelLayout::Rgba => (ColorMode::Rgb, ChannelLayout::Rgba), ChannelLayout::Cmyk | ChannelLayout::CmykA => (ColorMode::Cmyk, ChannelLayout::CmykA), + ChannelLayout::Lab | ChannelLayout::LabA => (ColorMode::Lab, ChannelLayout::LabA), }; let (depth, csample) = match img.sample_type() { CSample::U8 => (SampleType::U8, CSample::U8), @@ -233,6 +244,11 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result ColorMode::Grayscale, ChannelLayout::Cmyk | ChannelLayout::CmykA => ColorMode::Cmyk, + ChannelLayout::Lab | ChannelLayout::LabA => ColorMode::Lab, _ => ColorMode::Rgb, }; let depth = match csample { @@ -294,6 +311,8 @@ pub(crate) fn layout_for(mode: ColorMode, alpha: bool) -> ChannelLayout { (ColorMode::Grayscale, true) => ChannelLayout::GrayA, (ColorMode::Cmyk, false) => ChannelLayout::Cmyk, (ColorMode::Cmyk, true) => ChannelLayout::CmykA, + (ColorMode::Lab, false) => ChannelLayout::Lab, + (ColorMode::Lab, true) => ChannelLayout::LabA, (_, false) => ChannelLayout::Rgb, (_, true) => ChannelLayout::Rgba, } @@ -309,18 +328,19 @@ pub(crate) fn csample(s: SampleType) -> CSample { /// Renders the document to a flat codec image (native pixels when possible). pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result { - document_to_image_for(doc, warnings, true) + document_to_image_for(doc, warnings, true, false) } -fn document_to_image_for(doc: &Document, warnings: &mut Vec, cmyk_target: bool) -> Result { +/// `cmyk_target` / `lab_target`: the format stores CMYK / Lab pixels. +fn document_to_image_for(doc: &Document, warnings: &mut Vec, cmyk_target: bool, lab_target: bool) -> Result { let (w, h) = (doc.size.width, doc.size.height); let canvas = doc.bounds(); let fmt = doc.pixel_format(); let n = (w as usize) * (h as usize); - // Lab has no flat-format layout here: Lab documents always go through the composite, which - // flattens nothing when there is a single plain layer. + // A Lab document keeps its pixels only for a format that stores Lab (TIFF); for the others it + // goes through the sRGB composite, which flattens nothing when there is a single plain layer. let single = single_layer(doc); - let native = single.filter(|_| fmt.mode != ColorMode::Lab); + let native = single.filter(|_| fmt.mode != ColorMode::Lab || lab_target); let mut icc = doc.icc_profile.as_ref().map(|i| i.to_vec()); let img = if let Some(s) = native { // Native path: keep model and depth. The surface's encoded samples are the codec's raw @@ -475,7 +495,7 @@ fn drop_alpha(data: &mut Vec, color: usize, alpha: usize) { } /// Whether every pixel of `s` over `r` is fully opaque (read from the tiles, no copy). -fn opaque_surface(s: &Surface, r: Rect) -> bool { +pub(crate) fn opaque_surface(s: &Surface, r: Rect) -> bool { let fmt = s.format(); let (ch, bps) = (fmt.channels(), fmt.sample.bytes()); let a = ch - 1; @@ -519,7 +539,8 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti } let mut warnings = Vec::new(); let cmyk_target = format.caps().layouts.iter().any(|layout| layout.is_cmyk()); - let mut img = document_to_image_for(doc, &mut warnings, cmyk_target)?; + let lab_target = format.caps().layouts.iter().any(|layout| layout.is_lab()); + let mut img = document_to_image_for(doc, &mut warnings, cmyk_target, lab_target)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(composite_share(format)); if opts.xmp == XmpEmbed::None { @@ -546,9 +567,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")); @@ -796,17 +818,27 @@ fn export_mode_specific(doc: &Document, format: Format, opts: &ExportOptions) -> match doc.mode { ColorMode::Indexed if format == Format::Png => { let Some(table) = doc.color_table.as_ref().filter(|t| !t.colors.is_empty() && t.colors.len() <= 256) else { return Ok(None) }; + // PNG transparency belongs to a palette index, not its RGB value. Keep the + // transparent entry out of opaque lookup, even when it duplicates an opaque colour. + let (opaque_indices, colors): (Vec, Vec<_>) = + table.colors.iter().copied().enumerate().filter(|(i, _)| Some(*i) != table.transparent.map(usize::from)).map(|(i, c)| (i as u8, c)).unzip(); + let opaque = photocraft_doc::ColorTable { colors, transparent: None }; let mut idx = try_buffer(doc.size.area() as usize, 1)?; - let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> { - idx.extend(band.px.iter().map(|p| match table.transparent { - Some(t) if p[3] < 0.5 => t, - _ => table.nearest([p[0], p[1], p[2]]) as u8, - })); + photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), IoError> { + for p in &band.px { + let i = match table.transparent { + Some(t) if p[3] < 0.5 => t, + _ => *opaque_indices + .get(opaque.nearest([p[0], p[1], p[2]])) + .ok_or_else(|| IoError::Unsupported("Indexed PNG palette has no opaque entry; add an opaque colour or convert to RGB".into()))?, + }; + idx.push(i); + } Ok(()) - }); + })?; // Same rule as the ordinary PNG encoder: the resolution goes into `pHYs` with the metadata. let dpi = opts.encode.embed_metadata.then_some((doc.resolution_dpi, doc.resolution_dpi)); - let bytes = codecs::encode_png_indexed(doc.size.width, doc.size.height, &idx, &table.colors, table.transparent, dpi)?; + let bytes = codecs::encode_png_indexed(doc.size.width, doc.size.height, &idx, &table.colors, table.transparent, dpi, opts.encode.png_stereo)?; let mut warnings = vec![format!("written as an 8-bit palette PNG ({} colours)", table.colors.len())]; if opts.encode.embed_metadata && opts.xmp == XmpEmbed::All && !doc.metadata.text.is_empty() { warnings.push("text metadata is not supported by the palette PNG exporter; it will be dropped".into()); diff --git a/crates/io/src/gradient_bake.rs b/crates/io/src/gradient_bake.rs index e452ae9e1f..0d34816a79 100644 --- a/crates/io/src/gradient_bake.rs +++ b/crates/io/src/gradient_bake.rs @@ -106,12 +106,15 @@ fn smooth_ease(u: f32) -> f32 { /// Colour at `t` for sorted stops (positions in 0..1) in `m`'s space. fn eval(stops: &[(f32, [f32; 3])], mids: &[f32], smooth: f32, t: f32, m: Method) -> [f32; 3] { let n = stops.len(); - if t <= stops[0].0 { - return stops[0].1; + let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return [0.0; 3] }; + // A lone stop (even at a NaN position, which neither test below catches) is the colour. + if t <= first.0 || n < 2 { + return first.1; } - if t >= stops[n - 1].0 { - return stops[n - 1].1; + if t >= last.0 { + return last.1; } + // From here `stops[i + 1]` and the tangents' `n - 2` are in range: `i < n - 1` and `n >= 2`. let i = stops.windows(2).position(|w| t >= w[0].0 && t <= w[1].0).unwrap_or(0); let (t0, t1) = (stops[i].0, stops[i + 1].0); let h = (t1 - t0).max(1e-6); @@ -281,4 +284,14 @@ mod tests { let s = bake(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], &[0.25], 0.0, Method::Classic); assert!((at(&s, 0.25)[0] - 0.5).abs() < 0.01); } + + /// `eval` guards its own stops instead of relying on `bake`'s two-stop check. + #[test] + fn eval_with_fewer_than_two_stops_does_not_index_out_of_range() { + assert_eq!(eval(&[], &[], 1.0, 0.5, Method::Smooth), [0.0; 3]); + let lone = [(f32::NAN, [0.25, 0.5, 0.75])]; + for m in [Method::Classic, Method::Smooth] { + assert_eq!(eval(&lone, &[], 1.0, 0.5, m), [0.25, 0.5, 0.75]); + } + } } diff --git a/crates/io/src/guides_map.rs b/crates/io/src/guides_map.rs new file mode 100644 index 0000000000..6137011e2c --- /dev/null +++ b/crates/io/src/guides_map.rs @@ -0,0 +1,157 @@ +//! Guides ⇄ PSD beyond image resource 1032. +//! +//! Photoshop writes two undocumented resources next to 1032 (Grid and Guides), layout +//! inferred from the psd-tools, ag-psd and Photoshop-authored corpora (#5965): +//! +//! * 1092, Grid and Guides version 2: `u32` version 2, `u32` horizontal and vertical grid +//! cycle (576 = 18 px in 1/32 px, as in 1032), `u32` count, then per guide 9 bytes: `i32` +//! position in 1/32 px, `u8` direction (0 vertical, 1 horizontal), one `u8` that is always 0 +//! in the corpora, and the guide's colour as `u8` R, G, B. In every corpus file its guides +//! equal 1032's, in the same order. +//! * 1097: `u32` count (1092's), then per guide a length-prefixed ASCII GUID, a `u32` 0 and a +//! second length-prefixed GUID, always all zeros. +//! +//! Import takes the guides' own colours from 1092 (a guide in Photoshop's default colour keeps +//! following the preference colour). Export keeps both resources only while 1092 still +//! describes the document's guides and colours; otherwise it leaves both out, so the file +//! carries 1032 alone, as Photoshop files before version 2 did (#5966). + +use photocraft_doc::{Document, Guides}; + +/// Grid and Guides, version 2 (positions, directions and colours). +pub const GUIDES_V2: u16 = 1092; +/// One GUID per guide, in 1092's order. +pub const GUIDE_IDS: u16 = 1097; +/// Photoshop's default guide colour (Cyan, `#4affff`), which 1092 also stores for guides +/// that use the preference colour. +pub const DEFAULT_GUIDE_RGB: [u8; 3] = [0x4a, 0xff, 0xff]; + +/// One guide of a 1092 resource. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct GuideV2 { + /// Position in 1/32 px. + pub location: i32, + pub vertical: bool, + pub rgb: [u8; 3], +} + +fn be_u32(data: &[u8], at: usize) -> Option { + let b = data.get(at..at.checked_add(4)?)?; + Some(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) +} + +/// The guides of a 1092 resource, or `None` when it is not version 2 or is cut short. +pub fn parse_v2(data: &[u8]) -> Option> { + if be_u32(data, 0)? != 2 { + return None; + } + let count = usize::try_from(be_u32(data, 12)?).ok()?; + // Each guide takes 9 bytes: a count the data can't hold is not a 1092 we understand. + if count > data.len().saturating_sub(16) / 9 { + return None; + } + (0..count) + .map(|i| { + let at = 16 + i * 9; + let g = data.get(at..at + 9)?; + Some(GuideV2 { location: i32::from_be_bytes([g[0], g[1], g[2], g[3]]), vertical: g[4] != 1, rgb: [g[6], g[7], g[8]] }) + }) + .collect() +} + +/// The number of entries a 1097 resource declares. +pub fn ids_count(data: &[u8]) -> Option { + be_u32(data, 0) +} + +/// The document's guides as 1092 would store them, sorted. +fn expected(guides: &Guides) -> Vec { + let mut v: Vec = [(&guides.vertical, true), (&guides.horizontal, false)] + .into_iter() + .flat_map(|(list, vertical)| { + list.iter().map(move |&p| GuideV2 { location: (p * 32.0).round() as i32, vertical, rgb: guides.color(vertical, p).unwrap_or(DEFAULT_GUIDE_RGB) }) + }) + .collect(); + v.sort(); + v +} + +/// Whether an imported 1092 still describes the document's guides: the same positions, +/// directions and colours, in any order. +pub fn describes(doc: &Document, data: &[u8]) -> bool { + let Some(mut stored) = parse_v2(data) else { return false }; + stored.sort(); + stored == expected(&doc.guides) +} + +/// Whether to write back an imported 1092 or 1097: only while 1092 describes the guides +/// (and, for 1097, holds as many entries as 1092). +pub fn keep_resource(doc: &Document, id: u16, data: &[u8]) -> bool { + match id { + GUIDES_V2 => describes(doc, data), + GUIDE_IDS => { + let v2 = doc.metadata.psd_resources.iter().find(|(rid, _, _)| *rid == GUIDES_V2); + v2.is_some_and(|(_, _, d)| describes(doc, d) && parse_v2(d).map(|g| g.len()).and_then(|n| u32::try_from(n).ok()) == ids_count(data)) + } + _ => true, + } +} + +/// Gives the document's guides the colours an imported 1092 stores. Only guides that 1032 +/// also has are coloured, and Photoshop's default colour is left to the preference. +pub fn apply_colors(doc: &mut Document) { + let Some(stored) = doc.metadata.psd_resources.iter().find(|(id, _, _)| *id == GUIDES_V2).and_then(|(_, _, d)| parse_v2(d)) else { + return; + }; + for g in stored { + if g.rgb == DEFAULT_GUIDE_RGB { + continue; + } + let pos = f64::from(g.location) / 32.0; + let list = if g.vertical { &doc.guides.vertical } else { &doc.guides.horizontal }; + if list.contains(&pos) { + doc.guides.set_color(g.vertical, pos, Some(g.rgb)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v2(guides: &[(i32, u8, [u8; 3])]) -> Vec { + let mut v = Vec::new(); + for n in [2u32, 576, 576, guides.len() as u32] { + v.extend_from_slice(&n.to_be_bytes()); + } + for (loc, dir, rgb) in guides { + v.extend_from_slice(&loc.to_be_bytes()); + v.extend_from_slice(&[*dir, 0]); + v.extend_from_slice(rgb); + } + v + } + + #[test] + fn parses_corpus_layout() { + // clipping-mask4.psd (psd-tools): one vertical and one horizontal guide at 16 px. + let d = v2(&[(512, 0, DEFAULT_GUIDE_RGB), (512, 1, DEFAULT_GUIDE_RGB)]); + let g = parse_v2(&d).unwrap(); + assert_eq!(g.len(), 2); + assert!(g[0].vertical && !g[1].vertical); + assert_eq!(g[1].location, 512); + } + + #[test] + fn rejects_other_versions_and_short_or_hostile_data() { + let mut d = v2(&[(512, 0, DEFAULT_GUIDE_RGB)]); + assert!(parse_v2(&d[..d.len() - 1]).is_none()); + d[3] = 1; + assert!(parse_v2(&d).is_none()); + let mut huge = v2(&[]); + huge[12..16].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(parse_v2(&huge).is_none()); + assert!(parse_v2(&[]).is_none()); + assert!(ids_count(&[0, 0]).is_none()); + } +} diff --git a/crates/io/src/kra/tests.rs b/crates/io/src/kra/tests.rs index 4316a74c10..92e951cdb8 100644 --- a/crates/io/src/kra/tests.rs +++ b/crates/io/src/kra/tests.rs @@ -28,7 +28,8 @@ fn tile_file(pixel_size: usize, tiles: &[(i32, i32, Vec)]) -> Vec { planar[k * n + p] = px[p * pixel_size + k]; } } - let body: Vec = if i % 2 == 1 { [vec![1], lzf_literal(&planar)].concat() } else { [vec![0], planar].concat() }; + // Krita splits only LZF tiles into byte planes; raw tiles stay interleaved. + let body: Vec = if i % 2 == 1 { [vec![1], lzf_literal(&planar)].concat() } else { [&[0][..], px].concat() }; out.extend(format!("{x},{y},LZF,{}\n", body.len()).into_bytes()); out.extend(body); } @@ -160,6 +161,22 @@ fn a_nonzero_default_pixel_fills_untouched_areas() { assert_eq!(s.to_interleaved(Rect::from_xywh(100, 100, 1, 1)), [0, 0, 255, 255]); } +#[test] +fn raw_tiles_hold_interleaved_pixels() { + // Krita writes a tile uncompressed (flag 0) when LZF can't shrink it, and then the pixels + // stay interleaved: only LZF tiles are split into byte planes. + let mut body = vec![0u8]; + body.extend((0..T * T).flat_map(|_| [10u8, 20, 30, 255])); + let mut file = format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,LZF,{}\n", body.len()).into_bytes(); + file.extend(body); + let doc = maindoc("RGBA", r#""#); + let r = crate::import("a.kra", &archive(&doc, &[("img/layers/l", file)])).unwrap(); + let s = layer(&r.document, "L").surface().unwrap(); + for (x, y) in [(0, 0), (1, 0), (3, 3)] { + assert_eq!(s.to_interleaved(Rect::from_xywh(x, y, 1, 1)), [30, 20, 10, 255], "pixel {x},{y}"); + } +} + fn merged_png() -> Vec { let img = Image::from_raw(12, 8, ChannelLayout::Rgba, CSample::U8, vec![90; 12 * 8 * 4]).unwrap(); photocraft_codecs::encode(&img, Format::Png, &Default::default()).unwrap() diff --git a/crates/io/src/kra/tiles.rs b/crates/io/src/kra/tiles.rs index b369341b99..67fd02fca5 100644 --- a/crates/io/src/kra/tiles.rs +++ b/crates/io/src/kra/tiles.rs @@ -231,19 +231,22 @@ impl<'a> TileFile<'a> { for &(x, y, data) in &self.tiles { ctl.check().map_err(|_| IoError::Cancelled)?; let (flag, payload) = data.split_first().ok_or_else(|| invalid("empty tile"))?; - let planar = match flag { - 1 => lzf_decompress(payload, expected)?, - 0 if payload.len() == expected => payload.to_vec(), - 0 => return Err(invalid("raw tile has the wrong size")), - f => return Err(invalid(format!("unknown tile compression flag {f}"))), - }; - // Byte planes: byte k of pixel i is at k * n + i. - for (k, plane) in planar.chunks_exact(n).enumerate() { - for (i, b) in plane.iter().enumerate() { - if let Some(slot) = interleaved.get_mut(i * self.pixel_size + k) { - *slot = *b; + match flag { + 1 => { + // LZF tiles hold byte planes: byte k of pixel i is at k * n + i. + let planar = lzf_decompress(payload, expected)?; + for (k, plane) in planar.chunks_exact(n).enumerate() { + for (i, b) in plane.iter().enumerate() { + if let Some(slot) = interleaved.get_mut(i * self.pixel_size + k) { + *slot = *b; + } + } } } + // Raw tiles (written when LZF doesn't shrink them) are already interleaved. + 0 if payload.len() == expected => interleaved.copy_from_slice(payload), + 0 => return Err(invalid("raw tile has the wrong size")), + f => return Err(invalid(format!("unknown tile compression flag {f}"))), } space.convert(&interleaved, fmt, &mut out); let (x0, y0) = diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 49e4bb60e2..f604b8c174 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -40,10 +40,12 @@ 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; mod gradient_bake; +pub mod guides_map; pub use gradient_bake::{Method as GradientMethod, bake as bake_gradient}; mod kra; pub mod linked; @@ -51,6 +53,7 @@ mod multichannel_map; mod ora; pub use ora::losses as ora_losses; pub mod pattern_map; +pub mod pdf; mod pdn; pub mod pixels; mod psd_export; @@ -65,9 +68,9 @@ pub mod text_styles_map; pub mod tiff_layers; pub mod vector_map; -pub use photocraft_codecs::DpxBits; use photocraft_codecs::{CodecError, EncodeOptions}; -use photocraft_doc::Document; +pub use photocraft_codecs::{DdsCompression, DdsOptions, DpxBits}; +use photocraft_doc::{Adjustment, Document, Layer, LayerContent}; use photocraft_psd::{PsdError, PsdFile}; pub use adjust_map::ADJUSTMENT_KEYS; @@ -138,6 +141,34 @@ pub(crate) fn unequal_resolution_warning(x: f64, y: f64) -> Option { .then(|| format!("the vertical resolution ({y:.2} ppi) differs from the horizontal ({x:.2} ppi); the document keeps {x:.2} ppi for both")) } +/// Warnings about a loaded document's layers that render without their stored effect, for +/// sources that carry no import warnings of their own (a crafted `.pcraft`): a Color Lookup +/// whose table can't be applied silently renders as the identity instead (#1763). +pub fn open_warnings(doc: &Document) -> Vec { + let mut out = Vec::new(); + // Iterative, so no document's nesting can make this recurse. + let mut stack: Vec<&Layer> = doc.layers.iter().rev().collect(); + while let Some(l) = stack.pop() { + if let LayerContent::Adjustment(Adjustment::ColorLookup { name: _, size, lut, .. }) = &l.content { + match lut.as_deref().zip(photocraft_doc::adjust::lut3d_len(*size)) { + Some((table, len)) if table.len() >= len => {} + Some((table, len)) => out.push(format!( + "layer \"{}\": its Color Lookup table has {} values, but a {size}³ lookup needs {len}; the layer renders without the lookup", + l.name, + table.len() + )), + None => { + out.push(format!("layer \"{}\": its Color Lookup has no usable table for a {size}³ lookup; the layer renders without the lookup", l.name)) + } + } + } + if let Some(children) = l.children() { + stack.extend(children.iter().rev()); + } + } + out +} + /// Result of [`export`]. #[derive(Debug, Clone)] pub struct ExportResult { @@ -186,6 +217,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 { @@ -198,6 +232,7 @@ impl Default for ExportOptions { tga_bits: None, image_previews: true, psd_maximize_compatibility: true, + document_path: None, } } } @@ -270,16 +305,22 @@ pub fn import_with_svg_group_depth_and_interrupt( fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, max_group_depth: usize) -> Result { // A declared native extension must reach its loader so malformed bundles retain format errors. if has_extension(name, photocraft_format::EXTENSION) || photocraft_format::is_pcraft(bytes) { - return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false, preview_only: false }); + let document = photocraft_format::load_from_bytes(bytes)?; + return Ok(ImportResult { warnings: open_warnings(&document), document, source_read_only: false, preview_only: false }); } if is_psd(bytes) { - let file = PsdFile::from_bytes(bytes)?; + // A merged image the import reads is decoded with the parse: a ZIP one is inflated once. + let (file, merged) = PsdFile::from_bytes_and_merged_recovering( + bytes, + |f| psd_import::check_group_depth(f).is_ok() && psd_import::merged_needed(f), + psd_import::can_recover_layers, + )?; // Nesting past the document model's cap could never be saved; reject the file with the // actionable limit. psd_import::check_group_depth(&file)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(0.05); - let (mut document, warnings) = psd_import::psd_to_document_with(&file, ctl).ok_or(IoError::Cancelled)?; + let (mut document, warnings) = psd_import::psd_to_document_decoded(&file, ctl, merged).ok_or(IoError::Cancelled)?; document.name = name.to_string(); text_import::prepare(&mut document); return Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }); @@ -390,6 +431,9 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & if ext == "ora" { return ora::export(doc); } + if ext == "pdf" { + return pdf::export(doc); + } if ext == photocraft_format::EXTENSION { let doc = named_for_file(doc, name_or_ext); let thumbnail = Some(photocraft_compose::thumbnail(doc.as_ref(), 256)); @@ -402,7 +446,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/linked.rs b/crates/io/src/linked.rs index 6634735ef0..e200b875ea 100644 --- a/crates/io/src/linked.rs +++ b/crates/io/src/linked.rs @@ -136,9 +136,11 @@ pub(crate) fn source_size_in_blocks<'a>( } /// Pixel size of an embedded source file from its header: PSD/PSB, TIFF (the default page's -/// directory), PNG (`IHDR`) and JPEG (the frame header); any other image is decoded flat by -/// `photocraft-codecs`. It never imports a document: a layered TIFF can carry PSD layer data -/// whose `lnkD` holds another file, so a full import here would nest without bound (#2465). +/// directory), PNG (`IHDR`) and JPEG (the frame header); WebP, GIF, BMP, EXR and the rest of +/// `photocraft_codecs::probe_size`'s formats from their decoder's header; anything else (or a +/// header that probe cannot read) is decoded flat by `photocraft-codecs`. It never imports a +/// document: a layered TIFF can carry PSD layer data whose `lnkD` holds another file, so a full +/// import here would nest without bound (#2465). pub(crate) fn source_size_probe(bytes: &[u8]) -> Option<(f64, f64)> { let be32 = |at: usize| bytes.get(at..at + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])); let size = |w: u32, h: u32| (f64::from(w), f64::from(h)); @@ -151,10 +153,7 @@ pub(crate) fn source_size_probe(bytes: &[u8]) -> Option<(f64, f64)> { } [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, ..] => be32(16).zip(be32(20)).map(|(w, h)| size(w, h)), [0xFF, 0xD8, ..] => jpeg_size(bytes), - _ => photocraft_codecs::decode(bytes).ok().map(|img| { - let (w, h) = img.dimensions(); - size(w, h) - }), + _ => photocraft_codecs::probe_size(bytes).or_else(|_| photocraft_codecs::decode(bytes).map(|img| img.dimensions())).ok().map(|(w, h)| size(w, h)), } } @@ -408,9 +407,11 @@ mod tests { fn source_size_probe_reads_headers() { use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType}; let img = Image::new(7, 5, ChannelLayout::Rgb, SampleType::U8).unwrap(); - for format in [Format::Png, Format::Jpeg, Format::Tiff, Format::Bmp] { + for format in [Format::Png, Format::Jpeg, Format::Tiff, Format::Bmp, Format::WebP, Format::Gif, Format::OpenExr, Format::Tga] { let bytes = photocraft_codecs::encode(&img, format, &EncodeOptions::default()).unwrap(); assert_eq!(source_size_probe(&bytes), Some((7.0, 5.0)), "{format:?}"); + // A header cut short neither panics nor invents a size (the decode fallback fails too). + assert_eq!(source_size_probe(&bytes[..10]), None, "{format:?} truncated"); } let mut psd = b"8BPS\0\x01".to_vec(); psd.extend([0; 6]); diff --git a/crates/io/src/ora.rs b/crates/io/src/ora.rs index 42b2ab7a33..6d1bcfd87f 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); @@ -539,6 +566,15 @@ impl Writer<'_> { if layer.locks.all || layer.locks.pixels { attrs.push_str(" edit-locked=\"true\""); } + // OpenRaster's one edit lock reopens as Lock All; it has no position lock (#7018). + if layer.locks.pixels && !layer.locks.all { + self.warnings.layer(&layer.name, "Lock Image Pixels was saved as OpenRaster's edit lock; it reopens as Lock All"); + } + // The Background's position lock is part of being the Background, not one the user set: + // every new document has it, and OpenRaster has no Background to keep it on. + if layer.locks.position && !layer.locks.all && !self.doc.is_background(layer.id) { + self.warnings.layer(&layer.name, "Lock Position is not saved to OpenRaster"); + } if layer.locks.transparency { attrs.push_str(" alpha-preserve=\"true\""); } diff --git a/crates/io/src/ora/tests.rs b/crates/io/src/ora/tests.rs index 319414a94d..c7c253ef29 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` @@ -416,6 +494,9 @@ fn lossy_documents() -> Vec<(&'static str, Document)> { }), ("clipped", with(&|d| d.layers[1].clipped = true)), ("fill opacity", with(&|d| d.layers[0].fill_opacity = 0.5)), + ("pixel lock", with(&|d| d.layers[0].locks.pixels = true)), + // Not the bottom layer: with its transparency lock, `Base` would become the Background. + ("position lock", with(&|d| d.layers[1].locks.position = true)), ("float", doc(SampleType::F32)), ("profile", with(&|d| d.icc_profile = Some(photocraft_cms::Builtin::DisplayP3.profile().to_bytes()))), ("metadata", with(&|d| d.metadata.xmp = Some("".into()))), @@ -442,6 +523,15 @@ fn plain_documents_lose_nothing() { d.icc_profile = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes()); assert_eq!(losses(&d), Vec::::new(), "{depth:?} tagged sRGB"); assert_eq!(export(&d).unwrap().warnings, Vec::::new()); + // OpenRaster's edit-locked and alpha-preserve hold Lock All and Lock Transparent Pixels. + d.layers[0].locks.all = true; + d.layers[1].locks.transparency = true; + assert_eq!(losses(&d), Vec::::new(), "{depth:?} locked"); + // A Background (every new document's bottom layer) loses nothing for its position lock. + d.layers[0].locks.all = false; + d.layers[0].locks.position = true; + assert!(d.background().is_some()); + assert_eq!(losses(&d), Vec::::new(), "{depth:?} Background"); } let mut cmyk = doc(SampleType::U8); cmyk.mode = ColorMode::Cmyk; diff --git a/crates/io/src/pattern_map.rs b/crates/io/src/pattern_map.rs index 4f570bf847..bd84d9aeb1 100644 --- a/crates/io/src/pattern_map.rs +++ b/crates/io/src/pattern_map.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Document, Pattern}; use photocraft_geom::Rect; -use photocraft_psd::patterns::{PsdPattern, block_key, mode_channels, parse_pattern_block, write_pattern_block}; +use photocraft_psd::patterns::{PatternBudget, PsdPattern, block_key, mode_channels, parse_pattern_block_with, write_pattern_block}; use photocraft_raster::Surface; use crate::pixels::{deinterleave, interleave, max_sample, psd_depth, sample_for_depth, zero_sample}; @@ -110,13 +110,15 @@ pub fn to_psd(p: &Pattern) -> PsdPattern { } } -/// Patterns decoded from the document's raw pattern blocks (malformed blocks are skipped). +/// Patterns decoded from the document's raw pattern blocks (malformed blocks are skipped). All +/// blocks share one memory budget, so repeating a block can't multiply it. pub fn from_global_blocks(doc: &Document) -> Vec { + let mut budget = PatternBudget::default(); doc.metadata .psd_global_blocks .iter() .filter(|(_, k, _)| PATTERN_KEYS.contains(k)) - .filter_map(|(_, _, d)| parse_pattern_block(d).ok()) + .filter_map(|(_, _, d)| parse_pattern_block_with(d, &mut budget).ok()) .flatten() .filter_map(|p| from_psd(&p)) .collect() diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs new file mode 100644 index 0000000000..4f992bc8fd --- /dev/null +++ b/crates/io/src/pdf.rs @@ -0,0 +1,219 @@ +//! The PDF writer (ISO 32000-1 basics): one raster page per file. +//! +//! A small, self-contained writer used by two paths: File › Save a Copy / Save As › PDF (#3401) +//! through [`export`], and File › Print through [`print_pdf`] (called by the engine, which adds +//! paper geometry, printer marks and labels around the image). One page holds a +//! Flate-compressed image XObject, optional printer marks as vector operators and optional +//! Helvetica text; an embedded ICC profile rides in an ICCBased colour space. Multi-page PDF, +//! layer-preserving PDF and PDF import stay open (FILE-215-10). + +use std::io::Write as _; + +use photocraft_color::{ColorMode, PixelFormat}; +use photocraft_doc::Document; + +use crate::{ExportResult, IoError}; + +/// The image placed on a page: 8-bit interleaved samples, 1 (gray), 3 (RGB) or 4 (CMYK, +/// 1 = full ink), with the profile it was authored in if any. +pub struct PrintImage { + pub width: u32, + pub height: u32, + pub channels: usize, + pub data: Vec, + pub icc: Option>, +} + +/// Printer marks around the image. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Marks { + pub corner_crop: bool, + pub center_crop: bool, + pub registration: bool, +} + +/// One page: paper (pt), image box (pt, origin bottom-left), marks and texts. +pub struct PrintPage { + pub paper: (f64, f64), + pub image: PrintImage, + pub rect: (f64, f64, f64, f64), + pub marks: Marks, + pub description: Option, + pub label: Option, +} + +fn pdf_string(s: &str) -> String { + let mut o = String::from("("); + for c in s.chars() { + match c { + '(' | ')' | '\\' => { + o.push('\\'); + o.push(c); + } + c if c.is_ascii() && !c.is_ascii_control() => o.push(c), + _ => o.push('?'), + } + } + o.push(')'); + o +} + +fn deflate(data: &[u8]) -> Vec { + // Fast: print PDFs are transient and large; level 1 is ~4× quicker than the default. + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + let _ = e.write_all(data); + e.finish().unwrap_or_default() +} + +/// Vector marks around `(x, y, w, h)`. +fn marks_ops(m: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> String { + let mut s = String::from("q 0 G 0.3 w\n"); + let (gap, len) = (6.0, 18.0); + let line = |s: &mut String, x0: f64, y0: f64, x1: f64, y1: f64| s.push_str(&format!("{x0:.2} {y0:.2} m {x1:.2} {y1:.2} l S\n")); + if m.corner_crop { + for (cx, cy, dx, dy) in [(x, y, -1.0, -1.0), (x + w, y, 1.0, -1.0), (x, y + h, -1.0, 1.0), (x + w, y + h, 1.0, 1.0)] { + line(&mut s, cx + dx * gap, cy, cx + dx * (gap + len), cy); + line(&mut s, cx, cy + dy * gap, cx, cy + dy * (gap + len)); + } + } + if m.center_crop { + let (mx, my) = (x + w / 2.0, y + h / 2.0); + line(&mut s, mx, y - gap, mx, y - gap - len); + line(&mut s, mx, y + h + gap, mx, y + h + gap + len); + line(&mut s, x - gap, my, x - gap - len, my); + line(&mut s, x + w + gap, my, x + w + gap + len, my); + } + if m.registration { + let r = 5.0; + let off = gap + len / 2.0; + for (cx, cy) in [(x + w / 2.0, y - off), (x + w / 2.0, y + h + off), (x - off, y + h / 2.0), (x + w + off, y + h / 2.0)] { + // Circle (four Béziers) plus crosshair. + let k = 0.5523 * r; + s.push_str(&format!( + "{:.2} {cy:.2} m {:.2} {:.2} {:.2} {:.2} {cx:.2} {:.2} c {:.2} {:.2} {:.2} {:.2} {:.2} {cy:.2} c {:.2} {:.2} {:.2} {:.2} {cx:.2} {:.2} c {:.2} {:.2} {:.2} {:.2} {:.2} {cy:.2} c S\n", + cx + r, cx + r, cy + k, cx + k, cy + r, cy + r, cx - k, cy + r, cx - r, cy + k, cx - r, cx - r, cy - k, cx - k, cy - r, cy - r, cx + k, cy - r, cx + r, cy - k, cx + r + )); + line(&mut s, cx - r * 1.6, cy, cx + r * 1.6, cy); + line(&mut s, cx, cy - r * 1.6, cx, cy + r * 1.6); + } + } + s.push_str("Q\n"); + s +} + +/// A one-page PDF. +pub fn print_pdf(page: &PrintPage) -> Vec { + let mut out: Vec = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(); + let mut offsets: Vec = Vec::new(); + let mut obj = |out: &mut Vec, body: &[u8]| { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", offsets.len()).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + }; + let img = &page.image; + let (pw, ph) = page.paper; + let (x, y, w, h) = page.rect; + // 1 catalog, 2 pages, 3 page, 4 contents, 5 image, 6 font, 7 ICC (optional). + obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>"); + obj(&mut out, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + obj( + &mut out, + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im0 5 0 R >> /Font << /F1 6 0 R >> >> /Contents 4 0 R >>" + ) + .as_bytes(), + ); + // An explicit white page, so every viewer (and rasteriser) shows paper, not transparency. + let mut content = format!("q 1 g 0 0 {pw:.2} {ph:.2} re f Q\nq {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Im0 Do Q\n"); + content.push_str(&marks_ops(page.marks, page.rect)); + let any_marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration; + let pad = if any_marks { 30.0 } else { 6.0 }; + if let Some(l) = page.label.as_deref().filter(|l| !l.is_empty()) { + content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y + h + pad, pdf_string(l))); + } + if let Some(d) = page.description.as_deref().filter(|d| !d.is_empty()) { + content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y - pad - 8.0, pdf_string(d))); + } + let c = deflate(content.as_bytes()); + let mut body = format!("<< /Length {} /Filter /FlateDecode >>\nstream\n", c.len()).into_bytes(); + body.extend_from_slice(&c); + body.extend_from_slice(b"\nendstream"); + obj(&mut out, &body); + let device = match img.channels { + 1 => "/DeviceGray", + 4 => "/DeviceCMYK", + _ => "/DeviceRGB", + }; + let cs = if img.icc.is_some() { "[/ICCBased 7 0 R]".to_string() } else { device.to_string() }; + let z = deflate(&img.data); + let mut body = format!( + "<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {cs} /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>\nstream\n", + img.width, + img.height, + z.len() + ) + .into_bytes(); + body.extend_from_slice(&z); + body.extend_from_slice(b"\nendstream"); + obj(&mut out, &body); + obj(&mut out, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"); + if let Some(icc) = &img.icc { + let z = deflate(icc); + let mut body = format!("<< /N {} /Alternate {device} /Filter /FlateDecode /Length {} >>\nstream\n", img.channels, z.len()).into_bytes(); + body.extend_from_slice(&z); + body.extend_from_slice(b"\nendstream"); + obj(&mut out, &body); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes()); + for o in &offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1).as_bytes()); + out +} + +/// File › Save As / Save a Copy › PDF (#3401): the flattened document, at its print size +/// (pixels ÷ resolution × 72 pt), as a one-page PDF. Layers and transparency are dropped — the +/// warning says so — and the embedded profile travels with the image. +pub fn export(doc: &Document) -> std::result::Result { + let (w, h) = (usize::try_from(doc.size.width).unwrap_or(0), usize::try_from(doc.size.height).unwrap_or(0)); + if w == 0 || h == 0 { + return Err(IoError::Unsupported("PDF needs a document with non-zero width and height; nothing was written".into())); + } + let device = match doc.mode { + ColorMode::Rgb => 3, + ColorMode::Grayscale => 1, + ColorMode::Cmyk => 4, + m => return Err(IoError::Unsupported(format!("PDF saves RGB, Grayscale and CMYK documents; {m:?} needs converting to RGB first (Image > Mode)"))), + }; + let fmt = doc.pixel_format(); + let n = fmt.channels().max(1); + let canvas = photocraft_geom::Rect::new(0, 0, w as i32, h as i32); + let surface = photocraft_compose::flatten_to_surface(doc, PixelFormat::new(fmt.mode, fmt.sample, true), None); + let values = surface.read_region(canvas); + let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + // Straight alpha, composited here over paper: white in RGB/Gray (1 = full light), zero ink + // in CMYK (0 = paper). + let paper = if doc.mode == ColorMode::Cmyk { 0.0 } else { 1.0 }; + let mut data = Vec::with_capacity((w * h).saturating_mul(device)); + for px in values.chunks(n) { + let a = px.last().copied().unwrap_or(1.0).clamp(0.0, 1.0); + for v in px.iter().take(device) { + data.push(to8(v * a + paper * (1.0 - a))); + } + } + let image = PrintImage { width: doc.size.width, height: doc.size.height, channels: device, data, icc: doc.icc_profile.as_ref().map(|p| p.to_vec()) }; + let dpi = f64::from(doc.resolution_dpi.max(1.0)); + let (pw, ph) = (f64::from(doc.size.width) * 72.0 / dpi, f64::from(doc.size.height) * 72.0 / dpi); + let page = PrintPage { paper: (pw, ph), image, rect: (0.0, 0.0, pw, ph), marks: Marks::default(), description: None, label: None }; + let mut warnings = vec!["PDF was saved flattened: layers, masks-as-editable and transparency are not preserved (#3401)".into()]; + if !doc.metadata.text.is_empty() { + warnings.push("free-form text metadata is not supported by PDF; it will be dropped".into()); + } + Ok(ExportResult { bytes: print_pdf(&page), warnings }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/io/src/pdf/tests.rs b/crates/io/src/pdf/tests.rs new file mode 100644 index 0000000000..1807b7adc0 --- /dev/null +++ b/crates/io/src/pdf/tests.rs @@ -0,0 +1,131 @@ +//! PDF export: structure, pixel oracle, colour modes and refusal paths. + +use super::*; +use photocraft_color::{PixelFormat, SampleType}; +use photocraft_doc::{Layer, LayerContent, Size}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +fn solid(fmt: PixelFormat, x0: i32, y0: i32, x1: i32, y1: i32, px: &[f32]) -> Surface { + let mut s = Surface::new(fmt); + let (w, h) = ((x1 - x0) as usize, (y1 - y0) as usize); + let data: Vec = px.iter().copied().cycle().take(w * h * px.len()).collect(); + s.write_region(Rect::from_xywh(x0, y0, w as u32, h as u32), &data); + s +} + +/// The opaque paper colour of `mode`, with alpha. +fn paper(mode: ColorMode) -> Vec { + match mode { + ColorMode::Grayscale => vec![1.0, 1.0], + ColorMode::Cmyk => vec![0.0, 0.0, 0.0, 0.0, 1.0], + _ => vec![1.0, 1.0, 1.0, 1.0], + } +} + +fn doc(mode: ColorMode) -> Document { + let fmt = PixelFormat::new(mode, SampleType::F32, true); + let bg = paper(mode); + let mut d = Document::new("t.pdf", Size { width: 4, height: 2 }, mode, SampleType::F32); + d.layers = vec![Layer::new("Paper", LayerContent::Raster(solid(fmt, 0, 0, 4, 2, &bg)))]; + d +} + +/// The bytes of the stream following `marker` (e.g. "/Subtype /Image"), inflated. +fn stream_after(pdf: &[u8], marker: &str) -> Vec { + let at = pdf.windows(marker.len()).position(|w| w == marker.as_bytes()).expect("marker"); + let body = &pdf[at..]; + let len_at = body.windows(8).position(|w| w == b"/Length ").expect("length") + 8; + let len_end = body[len_at..].iter().position(|b| !b.is_ascii_digit()).expect("length end"); + let len: usize = std::str::from_utf8(&body[len_at..len_at + len_end]).unwrap().parse().unwrap(); + let start = body.windows(7).position(|w| w == b"stream\n").expect("stream") + 7; + let mut out = Vec::new(); + std::io::Read::read_to_end(&mut flate2::read::ZlibDecoder::new(&body[start..start + len]), &mut out).unwrap(); + out +} + +fn has(pdf: &[u8], needle: &str) -> bool { + pdf.windows(needle.len()).any(|w| w == needle.as_bytes()) +} + +fn check_structure(pdf: &[u8]) { + assert!(pdf.starts_with(b"%PDF-1."), "PDF header"); + assert!(pdf.ends_with(b"%%EOF\n"), "EOF marker"); + let start = pdf.windows(9).rposition(|w| w == b"startxref").unwrap() + 9; + let off: usize = std::str::from_utf8(&pdf[start..]).unwrap().split_whitespace().next().unwrap().parse().unwrap(); + assert_eq!(&pdf[off..off + 4], b"xref", "startxref points at the xref table"); + // Every xref offset really precedes its `n 0 obj` header. The xref/trailer region is pure + // ASCII, so it is safe to decode just that slice. + let table = std::str::from_utf8(&pdf[off..]).unwrap().lines().collect::>(); + let count: usize = table[1].split_whitespace().nth(1).unwrap().parse().unwrap(); + for (i, line) in table.iter().skip(3).take(count - 1).enumerate() { + let o: usize = line[..10].parse().unwrap(); + let want = format!("{} 0 obj", i + 1); + assert_eq!(&pdf[o..o + want.len()], want.as_bytes(), "xref offset for object {}", i + 1); + } +} + +#[test] +fn a_flat_rgb_document_saves_as_one_page_pdf() { + let d = doc(ColorMode::Rgb); + let out = export(&d).unwrap(); + let pdf = &out.bytes; + check_structure(pdf); + assert!(has(pdf, "/MediaBox [0 0 4.00 2.00]"), "page = image size at 72 pt/in"); + assert!(has(pdf, "/Width 4 /Height 2 /ColorSpace /DeviceRGB"), "RGB is DeviceRGB"); + assert_eq!(stream_after(pdf, "/Subtype /Image"), vec![255u8; 4 * 2 * 3], "the white paper, 8-bit"); + assert!(out.warnings.iter().any(|w| w.contains("flattened")), "layers are not preserved"); + // Through the general dispatch as well. + let via = crate::export(&d, "out.pdf", &crate::ExportOptions::default()).unwrap(); + assert_eq!(via.bytes, *pdf); +} + +#[test] +fn layers_composite_over_white_before_the_page() { + let mut d = doc(ColorMode::Rgb); + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + d.layers.push(Layer::new("Half red", LayerContent::Raster(solid(fmt, 1, 0, 3, 2, &[1.0, 0.0, 0.0, 0.5])))); + let out = export(&d).unwrap(); + let img = stream_after(&out.bytes, "/Subtype /Image"); + // Pixels 1-2 of row 0: 0.5 red over white = (1.0, 0.5, 0.5); elsewhere plain white. + assert_eq!(&img[3..6], &[255, 128, 128], "the blend lands: {:?}", &img[3..6]); + assert_eq!(&img[0..3], &[255, 255, 255]); +} + +#[test] +fn greyscale_and_cmyk_pages_keep_their_channels() { + let g = export(&doc(ColorMode::Grayscale)).unwrap(); + assert!(has(&g.bytes, "/ColorSpace /DeviceGray"), "gray is DeviceGray"); + assert_eq!(stream_after(&g.bytes, "/Subtype /Image"), vec![255; 4 * 2], "one sample per pixel"); + let c = export(&doc(ColorMode::Cmyk)).unwrap(); + assert!(has(&c.bytes, "/ColorSpace /DeviceCMYK"), "cmyk is DeviceCMYK"); + assert_eq!(stream_after(&c.bytes, "/Subtype /Image"), vec![0; 4 * 2 * 4], "zero ink over the whole page"); +} + +#[test] +fn the_page_measures_at_the_document_resolution() { + let mut d = doc(ColorMode::Rgb); + d.size = Size { width: 300, height: 150 }; + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + d.layers = vec![Layer::new("Paper", LayerContent::Raster(solid(fmt, 0, 0, 300, 150, &[1.0, 1.0, 1.0, 1.0])))]; + d.resolution_dpi = 300.0; + let out = export(&d).unwrap(); + assert!(has(&out.bytes, "/MediaBox [0 0 72.00 36.00]"), "300 px at 300 dpi is one inch"); +} + +#[test] +fn an_embedded_profile_travels_as_iccbased() { + let mut d = doc(ColorMode::Rgb); + d.icc_profile = Some(std::sync::Arc::new(vec![1, 2, 3, 4, 5, 6, 7, 8])); + let out = export(&d).unwrap(); + assert!(has(&out.bytes, "[/ICCBased 7 0 R]"), "the image refers to the profile"); + assert_eq!(stream_after(&out.bytes, "/N 3 /Alternate"), vec![1, 2, 3, 4, 5, 6, 7, 8], "the profile bytes survive"); +} + +#[test] +fn other_modes_are_refused_with_an_actionable_error() { + let d = doc(ColorMode::Lab); + let e = export(&d).unwrap_err().to_string(); + assert!(e.contains("RGB") && e.contains("Mode"), "{e}"); + assert!(crate::export(&d, "x.pdf", &crate::ExportOptions::default()).is_err()); +} diff --git a/crates/io/src/pixels.rs b/crates/io/src/pixels.rs index 0167eb3b3f..0251f83755 100644 --- a/crates/io/src/pixels.rs +++ b/crates/io/src/pixels.rs @@ -43,40 +43,43 @@ pub fn interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: Sampl let ch = planes.len(); let row = ch * bps; let mut out = vec![0u8; n * row]; - // One band of rows per core, written straight into its slice of the shared output: the - // per-pixel channel loop stays, but a band holds ~1/cores of a 20 MP image's iterations - // instead of all of them. + if out.is_empty() { + return out; + } let bs = bands(n); - let parts: Vec> = par_map(bs.clone(), |band| { - let mut part = vec![0u8; band.len() * row]; - for (i, p) in band.clone().enumerate() { - for (c, plane) in planes.iter().enumerate() { - let dst = &mut part[(i * ch + c) * bps..(i * ch + c + 1) * bps]; - match plane { - Some(pl) if pl.len() >= (p + 1) * bps => { - let src = &pl[p * bps..(p + 1) * bps]; - for k in 0..bps { - dst[k] = src[bps - 1 - k]; - } - if cfg!(target_endian = "big") { - dst.copy_from_slice(src); - } - if invert[c] { - invert_sample(dst, s); - } - } - _ => dst.copy_from_slice(&fill[c]), - } + let step = bs.first().map_or(n, |b| b.len()) * row; + par_map(bs.into_iter().zip(out.chunks_mut(step)).collect(), |(band, part)| { + for (c, plane) in planes.iter().enumerate() { + let src = plane.and_then(|p| p.get(band.start * bps..)).unwrap_or_default(); + let inv = src.len() >= bps && invert[c]; + let fill = fill.get(c).map(Vec::as_slice).unwrap_or_default(); + match s { + SampleType::U8 => write_channel(part, ch, c, src, fill, |[v]| [if inv { 255 - v } else { v }]), + SampleType::U16 => write_channel(part, ch, c, src, fill, |v| { + let v = u16::from_be_bytes(v); + (if inv { 65535 - v } else { v }).to_ne_bytes() + }), + SampleType::F32 => write_channel(part, ch, c, src, fill, |v| { + // Preserve float bits when no inversion is requested. + if inv { (1.0 - f32::from_be_bytes(v)).to_ne_bytes() } else { u32::from_be_bytes(v).to_ne_bytes() } + }), } } - part }); - for (band, part) in bs.into_iter().zip(parts) { - out[band.start * row..band.end * row].copy_from_slice(&part); - } out } +fn write_channel(out: &mut [u8], ch: usize, c: usize, src: &[u8], fill: &[u8], decode: impl Fn([u8; N]) -> [u8; N]) { + let mut samples = src.as_chunks::().0.iter(); + for pixel in out.chunks_exact_mut(ch * N) { + let dst = &mut pixel[c * N..(c + 1) * N]; + match samples.next() { + Some(sample) => dst.copy_from_slice(&decode(*sample)), + None => dst.copy_from_slice(fill), + } + } +} + /// Splits interleaved native-endian bytes into big-endian planes. pub fn deinterleave(bytes: &[u8], ch: usize, s: SampleType, invert: &[bool]) -> Vec> { let bps = s.bytes(); @@ -217,21 +220,29 @@ pub fn bands(n: usize) -> Vec> { mod tests { use super::*; - #[test] - fn banded_shuffles_match_a_sequential_reference() { - fn seq_interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: SampleType, invert: &[bool]) -> Vec { - let bps = s.bytes(); - let ch = planes.len(); - let mut out = vec![0u8; n * ch * bps]; - for (c, plane) in planes.iter().enumerate() { - for i in 0..n { - let dst = &mut out[(i * ch + c) * bps..(i * ch + c + 1) * bps]; + pub(super) fn previous_interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: SampleType, invert: &[bool]) -> Vec { + let bps = s.bytes(); + let ch = planes.len(); + let row = ch * bps; + let mut out = vec![0u8; n * row]; + // One band of rows per core, written straight into its slice of the shared output: the + // per-pixel channel loop stays, but a band holds ~1/cores of a 20 MP image's iterations + // instead of all of them. + let bs = bands(n); + let parts: Vec> = par_map(bs.clone(), |band| { + let mut part = vec![0u8; band.len() * row]; + for (i, p) in band.clone().enumerate() { + for (c, plane) in planes.iter().enumerate() { + let dst = &mut part[(i * ch + c) * bps..(i * ch + c + 1) * bps]; match plane { - Some(p) if p.len() >= (i + 1) * bps => { - let src = &p[i * bps..(i + 1) * bps]; + Some(pl) if pl.len() >= (p + 1) * bps => { + let src = &pl[p * bps..(p + 1) * bps]; for k in 0..bps { dst[k] = src[bps - 1 - k]; } + if cfg!(target_endian = "big") { + dst.copy_from_slice(src); + } if invert[c] { invert_sample(dst, s); } @@ -240,8 +251,16 @@ mod tests { } } } - out + part + }); + for (band, part) in bs.into_iter().zip(parts) { + out[band.start * row..band.end * row].copy_from_slice(&part); } + out + } + + #[test] + fn banded_shuffles_match_a_sequential_reference() { fn seq_deinterleave(bytes: &[u8], ch: usize, s: SampleType, invert: &[bool]) -> Vec> { let bps = s.bytes(); let n = bytes.len() / (ch * bps).max(1); @@ -260,16 +279,17 @@ mod tests { planes } // Odd pixel counts cross band boundaries; a missing plane exercises the fill path. - for n in [1usize, 7, 64, 1000] { + for n in [0usize, 1, 7, 64, 1000] { for s in [SampleType::U8, SampleType::U16, SampleType::F32] { let bps = s.bytes(); let planes: Vec> = (0..4).map(|c| (0..n * bps).map(|i| (i * 13 + c * 5) as u8).collect()).collect(); let mut refs: Vec> = planes.iter().map(|p| Some(&p[..])).collect(); refs[2] = None; + refs[3] = Some(&planes[3][..(n * bps).saturating_sub(1)]); let fill: Vec> = (0..4).map(|c| vec![c as u8 * 40; bps]).collect(); let invert = [false, true, false, true]; let got = interleave(&refs, &fill, n, s, &invert); - let want = seq_interleave(&refs, &fill, n, s, &invert); + let want = previous_interleave(&refs, &fill, n, s, &invert); assert_eq!(got, want, "interleave n={n}"); let planes_back = deinterleave(&got, 4, s, &invert); let want_back = seq_deinterleave(&got, 4, s, &invert); @@ -287,6 +307,7 @@ mod tests { let fill = vec![zero_sample(s); 3]; let inv = [false, true, false]; let il = interleave(&refs, &fill, 4, s, &inv); + assert_eq!(interleave(&refs, &[], 4, s, &inv), il); let back = deinterleave(&il, 3, s, &inv); if s == SampleType::F32 { // 1 - (1 - v) may not be exact for floats; only check non-inverted planes. @@ -302,6 +323,12 @@ mod tests { fn missing_planes_use_fill() { let il = interleave(&[None, Some(&[5, 6])], &[vec![9], vec![0]], 2, SampleType::U8, &[false, false]); assert_eq!(il, vec![9, 5, 9, 6]); + for s in [SampleType::U8, SampleType::U16, SampleType::F32] { + for n in [1, 1000] { + let fill = vec![vec![9; s.bytes()]; 2]; + assert_eq!(interleave(&[None, Some(&[1, 2, 3][..s.bytes() - 1])], &fill, n, s, &[]), vec![9; n * 2 * s.bytes()]); + } + } } #[test] @@ -343,3 +370,35 @@ mod tests { assert_eq!(decode_be(&f, 0, SampleType::F32), 2.5); } } + +#[cfg(test)] +mod measurement { + use super::*; + #[test] + #[ignore = "manual 24 MP release benchmark"] + fn benchmark_interleave() { + use std::{hint::black_box, time::Instant}; + const N: usize = 24_000_000; + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let bps = sample.bytes(); + let planes: Vec> = (0..4).map(|c| (0..N * bps).map(|i| (i * 13 + c * 5) as u8).collect()).collect(); + let refs: Vec<_> = planes.iter().map(|p| Some(p.as_slice())).collect(); + let fill = vec![vec![0; bps]; 4]; + let invert = [false, true, false, true]; + assert_eq!(tests::previous_interleave(&refs, &fill, N, sample, &invert), interleave(&refs, &fill, N, sample, &invert)); + for trial in 0..6 { + let mut paths = [("main", tests::previous_interleave as fn(_, _, _, _, _) -> _), ("split", interleave)]; + if trial % 2 == 1 { + paths.reverse(); + } + for (name, path) in paths { + let t = Instant::now(); + let out = black_box(path)(&refs, &fill, N, sample, &invert); + let elapsed = t.elapsed().as_secs_f64() * 1000.0; + black_box(&out); + println!("interleave,{sample:?},{trial},{name},{elapsed:.6}"); + } + } + } + } +} diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 9db3d4799f..68842b2691 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -29,6 +29,12 @@ const PSD_ESTIMATE_LAYER_OVERHEAD: u64 = 64 * 1024; pub struct PsdExportOptions { /// Write PSB even when the document fits PSD limits. pub force_psb: bool, + /// Switch to PSB when the estimated encoded size exceeds PSD's 2 GB limit. The estimate + /// counts every layer plane uncompressed, so a document with many canvas-sized layers can + /// pass it while its real file is far smaller. A writer that can measure the real size + /// instead turns this off: the layered TIFF writes PSD-style layer data and falls back to + /// PSB only when a length really overflows 32 bits. + pub size_limit_psb: bool, /// Matte the merged image against white where it is translucent, as Photoshop does /// (`false` keeps straight colour under the alpha: for containers that store the composite /// with its own alpha, such as a layered TIFF). @@ -41,11 +47,21 @@ 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, + size_limit_psb: true, + merged_matte: true, + merged_compression: Compression::Rle, + maximize_compatibility: true, + document_path: None, + } } } @@ -53,6 +69,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, @@ -102,9 +120,7 @@ fn psd_background(doc: &Document) -> Option { if canvas.is_empty() || s.content_bounds() != canvas { return None; } - let n = s.channels(); - let opaque = s.read_region(canvas).chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)); - opaque.then_some(l.id) + crate::flat::opaque_surface(s, canvas).then_some(l.id) } /// How deep embedded documents are exported inside each other before a smart object is written @@ -164,6 +180,14 @@ struct SmartOut { keep_fx: std::collections::HashSet, /// Regenerated `FEid` items. new_fx: Vec, + /// The unfiltered pixels of each smart object whose filter mask shows: its layer record holds + /// the filtered pixels without the mask, as Photoshop's do (`compose::filter_mask`). + unfiltered: std::collections::HashMap, +} + +/// Whether a smart object's filter mask shows: enabled over at least one visible, enabled filter. +fn masked(sm: &photocraft_doc::SmartObject) -> bool { + sm.filter_mask.as_ref().is_some_and(|m| m.enabled) && sm.filters_enabled && sm.smart_filters.iter().any(|f| f.visible) } /// `FEid` data padded to 4 bytes inside the block length: Photoshop counts the padding in the @@ -760,6 +784,12 @@ impl Ex { } if !stack.filters.is_empty() { self.smart.keep_fx.insert(t.placed.clone()); + if masked(sm) { + let fmt = self.fmt; + if let Some(u) = self.smart.old_fx.iter().find(|i| i.id == t.placed).and_then(|i| crate::smart_map::unfiltered_from_item(i, fmt)) { + self.smart.unfiltered.insert(l.id, u); + } + } } return; } @@ -795,7 +825,14 @@ impl Ex { None => photocraft_algo::warp::place_source(&img, img_bounds, &sm.transform, sm.warp.as_ref()), }; match unfiltered { - Ok(unfiltered) => crate::smart_map::feid_item(&placed, &unfiltered, sm.filter_mask.as_ref(), bounds, self.fmt), + Ok(unfiltered) => { + let item = crate::smart_map::feid_item(&placed, &unfiltered, sm.filter_mask.as_ref(), bounds, self.fmt); + if item.is_some() && masked(sm) { + let u = if unfiltered.format() == self.fmt { unfiltered } else { unfiltered.convert(self.fmt) }; + self.smart.unfiltered.insert(l.id, u); + } + item + } Err(e) => { warnings.push(format!("smart filter mask was not re-rendered for PSD export ({e})")); None @@ -908,7 +945,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) @@ -977,8 +1015,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}"))); @@ -1072,7 +1114,12 @@ impl Ex { self.records.push(r); } LayerContent::Smart(sm) => { - let r = self.record(l, extra, sm.cache.as_ref()); + // Photoshop's record holds the filtered pixels without the filter mask. + let unmixed = match (self.smart.unfiltered.get(&l.id), sm.cache.as_ref(), sm.filter_mask.as_ref()) { + (Some(u), Some(c), Some(m)) if masked(sm) => Some(photocraft_compose::filter_mask::unmix(u, c, m)), + _ => None, + }; + let r = self.record(l, extra, unmixed.as_ref().or(sm.cache.as_ref())); self.records.push(r); } } @@ -1227,6 +1274,10 @@ pub(crate) fn keep_imported_resource(doc: &Document, id: u16, data: &[u8]) -> bo if !crate::annotations_map::keep_resource(doc, id) { return false; } + // Guides v2 and guide GUIDs: only while they still describe the document's guides. + if !crate::guides_map::keep_resource(doc, id, data) { + return false; + } // A preserved clipping-path resource is only valid while it names the current one. if id == crate::vector_map::CLIPPING_PATH { let n = usize::from(data.first().copied().unwrap_or(0)); @@ -1488,11 +1539,12 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c let sample = fmt.sample; let cc = fmt.mode.color_channels(); let big = doc.size.width > 30_000 || doc.size.height > 30_000; - let size_exceeds_limit = !opts.force_psb && !big && psd_size_exceeds_limit(doc); + let size_exceeds_limit = opts.size_limit_psb && !opts.force_psb && !big && psd_size_exceeds_limit(doc); let version = psd_version(doc, opts.force_psb, size_exceeds_limit); 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), @@ -1636,6 +1688,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c // Resources. let mut resources = vec![ + // hRes/vRes are always pixels per inch. The display unit written here is inches. ImageResource::new(ids::RESOLUTION_INFO, ResolutionInfo::from_dpi(f64::from(doc.resolution_dpi)).to_bytes()), ImageResource::new(ids::GLOBAL_ANGLE, (doc.global_light.angle.round() as i32).to_be_bytes().to_vec()), ImageResource::new(ids::GLOBAL_ALTITUDE, (doc.global_light.altitude.round() as i32).to_be_bytes().to_vec()), @@ -1745,9 +1798,13 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources.push(version_info_resource(real_merged)); let has_layers = !ex.records.is_empty(); + // Photoshop writes a merged-transparency marker (`Mtrn`, `Mt16`, `Mt32`) before the layer + // block, and won't open a file whose `Lr32` comes first (#5068): the layer block goes right + // after the last kept marker, else first. + let index = global_blocks.iter().rposition(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32")).map_or(0, |i| i + 1); let placement = match (has_layers, sample) { - (true, SampleType::U16) => LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: *b"Lr16", padding: None }, - (true, SampleType::F32) => LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: *b"Lr32", padding: None }, + (true, SampleType::U16) => LayerInfoPlacement::GlobalBlock { index, signature: *b"8BIM", key: *b"Lr16", padding: None }, + (true, SampleType::F32) => LayerInfoPlacement::GlobalBlock { index, signature: *b"8BIM", key: *b"Lr32", padding: None }, _ => LayerInfoPlacement::Section, }; let records = std::mem::take(&mut ex.records); @@ -1864,6 +1921,31 @@ mod tests { assert_eq!(unique, reloaded_ids); } + /// #5068: Photoshop won't open a file whose `Lr32` comes before the `Mt32` marker it keeps for + /// a 32-bit document's merged transparency (verified in Photoshop 2026 on psd-tools' + /// `transparentbg.psd` re-saved both ways). The layer block follows a kept marker. + #[test] + fn layer_block_follows_a_kept_merged_transparency_marker() { + for (depth, key, marker) in [(SampleType::F32, *b"Lr32", *b"Mt32"), (SampleType::U16, *b"Lr16", *b"Mt16"), (SampleType::F32, *b"Lr32", *b"Mtrn")] { + let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, depth); + let mut s = Surface::new(doc.pixel_format()); + s.fill_rect(photocraft_geom::Rect::new(1, 1, 5, 4), &[0.5, 0.2, 0.9, 1.0]); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(s))); + doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Patt", std::sync::Arc::new(Vec::new())), (*b"8BIM", marker, std::sync::Arc::new(Vec::new()))]; + let saved = PsdFile::from_bytes(&document_to_psd(&doc).to_bytes().unwrap()).unwrap(); + let LayerInfoPlacement::GlobalBlock { index, key: written, .. } = saved.layer_info_placement else { panic!("{depth:?}: a global layer block") }; + assert_eq!(written, key); + let at = saved.global_blocks.iter().position(|b| b.key == marker).expect("the marker is kept"); + assert!(at < index, "{depth:?}: {marker:?} at {at}, the layer block at {index}"); + } + // Without a marker the layer block stays first. + let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, SampleType::F32); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(Surface::new(doc.pixel_format())))); + doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Patt", std::sync::Arc::new(Vec::new()))]; + let saved = PsdFile::from_bytes(&document_to_psd(&doc).to_bytes().unwrap()).unwrap(); + assert!(matches!(saved.layer_info_placement, LayerInfoPlacement::GlobalBlock { index: 0, .. })); + } + #[test] fn unreadable_lr16_warns_on_import_and_is_not_saved_twice() { // #2700: a 16-bit file whose Lr16 block claims five layers but holds none. diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 444c2d6e5b..2c1a734b1c 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -403,12 +403,26 @@ impl Ctx<'_> { Some(p) if !stack.filters.is_empty() => self.filter_mask(&p.placed, &stack, &name), _ => None, }; + // Photoshop stores the filtered pixels without the filter mask and mixes the unfiltered + // ones (`FEid`) back in through it; our smart objects hold the mixed result. + let stored = self.record_surface(rec, &name); + let shown = stack.enabled && stack.filters.iter().any(|f| f.visible); + let cache = match (&placed, filter_mask.as_ref().filter(|m| m.enabled && shown)) { + (Some(p), Some(m)) => { + let unfiltered = self.filter_effects.iter().find(|i| i.id == p.placed).and_then(|i| crate::smart_map::unfiltered_from_item(i, self.fmt)); + match unfiltered { + Some(u) => photocraft_compose::filter_mask::mix(&u, &stored, m), + None => stored, + } + } + _ => stored, + }; LayerContent::Smart(SmartObject { contents_id, source: SmartSource::Linked { path: id }, transform, smart_filters: stack.filters, - cache: Some(self.record_surface(rec, &name)), + cache: Some(cache), psd_raw: principal(k), filters_enabled: stack.enabled, filter_mask, @@ -595,15 +609,84 @@ fn unicode_names(data: &[u8]) -> Vec { while at + 4 <= data.len() { let n = u32::from_be_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]]) as usize; at += 4; - let Some(b) = data.get(at..at + n * 2) else { break }; + // Checked: `n * 2` overflows a 32-bit `usize` for a hostile length. + let Some(end) = n.checked_mul(2).and_then(|len| at.checked_add(len)) else { break }; + let Some(b) = data.get(at..end) else { break }; let units: Vec = b.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); out.push(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string()); - at += n * 2; + at = end; } out } -fn parse_guides(data: &[u8], doc: &mut Document) { +/// Plane `i` of a decoded merged image whose planes are `plane` bytes each, or `None` when it is +/// short or the offsets overflow. +fn merged_plane(all: &[u8], i: usize, plane: usize) -> Option<&[u8]> { + all.get(i.checked_mul(plane)?..i.checked_add(1)?.checked_mul(plane)?) +} + +/// The merged image of a file the layered path does not open (Bitmap, Indexed, Duotone; 1-bit +/// files) as RGBA8. What `composite_rgba8` can't read (Multichannel) shows its first channels as +/// RGB. An output over `cap` bytes is refused, never allocated (#5200). +fn flat_composite( + file: &PsdFile, + merged: Option<&Result, photocraft_psd::PsdError>>, + cap: u64, +) -> Result { + use photocraft_psd::PsdError; + match file.composite_rgba8() { + Ok(img) => return Ok(img), + // Over the output budget: the fallback below would make the same allocation. + Err(e @ PsdError::LimitExceeded(_)) => return Err(e), + Err(_) => {} + } + let all = match merged { + Some(Ok(all)) => all, + Some(Err(e)) => return Err(e.clone()), + None => return Err(PsdError::Invalid("no merged image".into())), + }; + let h = &file.header; + let (w, hh) = (h.width as usize, h.height as usize); + let len = w + .checked_mul(hh) + .and_then(|n| n.checked_mul(4)) + .filter(|&len| len as u64 <= cap) + .ok_or(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES"))?; + let plane = h.row_bytes().checked_mul(hh).ok_or(PsdError::LimitExceeded("plane size overflow"))?; + let mut data = Vec::new(); + data.try_reserve_exact(len).map_err(|_| PsdError::LimitExceeded("not enough memory for RGBA output"))?; + data.resize(len, 255u8); + for c in 0..3.min(usize::from(h.channels)) { + let bytes = merged_plane(all, c, plane).ok_or_else(|| PsdError::Invalid("channel size mismatch".into()))?; + let p = photocraft_psd::pixels::plane_to_u8(bytes, h.depth, w, hh)?; + for (px, v) in data.as_chunks_mut::<4>().0.iter_mut().zip(&p) { + px[c] = *v; + } + } + Ok(photocraft_psd::RgbaImage { left: 0, top: 0, width: h.width, height: h.height, data }) +} + +/// Writes RGBA8 `data` covering `canvas` into `s`, a band of tile rows at a time (no full-size +/// float copy of the image). +fn write_rgba8(s: &mut Surface, canvas: Rect, data: &[u8]) { + let fmt = s.format(); + let row = (canvas.width() as usize).saturating_mul(4); + let mut vals = Vec::new(); + let mut y = canvas.y0; + while y < canvas.y1 { + let band = Rect::new(canvas.x0, y, canvas.x1, y.saturating_add(TILE_SIZE).min(canvas.y1)); + let start = ((y - canvas.y0) as usize).saturating_mul(row); + let Some(rows) = data.get(start..start.saturating_add((band.height() as usize).saturating_mul(row))) else { break }; + vals.clear(); + for p in rows.as_chunks::<4>().0 { + vals.extend(photocraft_raster::from_rgba(&fmt, p.map(|x| f32::from(x) / 255.0))); + } + s.write_region(band, &vals); + y = band.y1; + } +} + +pub(crate) fn parse_guides(data: &[u8], doc: &mut Document) { let rd = |at: usize| data.get(at..at + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])); let Some(count) = rd(12) else { return }; for i in 0..count as usize { @@ -623,10 +706,21 @@ fn parse_guides(data: &[u8], doc: &mut Document) { /// Background (Photoshop 27.11 opens psd-tools' flat Bitmap and Indexed files that way), so it is /// written back as one. fn flat_background(s: Surface) -> Layer { + // A band of tile rows at a time (no full-size float copy), stopping at the first + // transparent pixel. let opaque = { let r = s.content_bounds(); let n = s.channels(); - !r.is_empty() && s.read_region(r).chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)) + let mut vals = Vec::new(); + let mut y = r.y0; + let mut opaque = !r.is_empty(); + while opaque && y < r.y1 { + let band = Rect::new(r.x0, y, r.x1, y.saturating_add(TILE_SIZE).min(r.y1)); + s.read_region_into(band, &mut vals); + opaque = vals.chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)); + y = band.y1; + } + opaque }; let mut l = Layer::new("Background", LayerContent::Raster(s)); l.locks.transparency = opaque; @@ -657,6 +751,32 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { /// [`psd_to_document`] for a background open: checks `ctl` per layer record and reports /// progress. `None` when cancelled. pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) -> Option<(Document, Vec)> { + psd_to_document_decoded(file, ctl, None) +} + +/// Whether the import reads the merged composite: for a flattened or multichannel file (it *is* +/// the image), a file without layers, or extra alpha/spot channels behind the colour ones. +pub(crate) fn merged_needed(file: &PsdFile) -> bool { + let h = &file.header; + let mode = doc_mode(h.color_mode).unwrap_or(ColorMode::Rgb); + // A layered mode is its own pixel format, so these are the document's colour channels. + let layered = matches!(mode, ColorMode::Grayscale | ColorMode::Rgb | ColorMode::Cmyk | ColorMode::Lab) && h.depth != 1; + !layered || file.layers().is_empty() || usize::from(h.channels) > mode.color_channels() + usize::from(file.merged_has_alpha()) +} + +/// A damaged merged preview need not prevent opening independently stored layers (#6152). +/// Other modes are imported flattened, so their merged image cannot be discarded. +pub(crate) fn can_recover_layers(file: &PsdFile) -> bool { + matches!(file.header.color_mode, PsdMode::Grayscale | PsdMode::Rgb | PsdMode::Cmyk | PsdMode::Lab) && file.header.depth != 1 && !file.layers().is_empty() +} + +/// [`psd_to_document_with`] with the merged image already decoded (by +/// [`PsdFile::from_bytes_and_merged`] with [`merged_needed`]), so it is not decoded again. +pub(crate) fn psd_to_document_decoded( + file: &PsdFile, + ctl: &photocraft_raster::Interrupt, + decoded: Option>>, +) -> Option<(Document, Vec)> { let h = &file.header; let mut warnings = Vec::new(); let mode = doc_mode(h.color_mode).unwrap_or_else(|| { @@ -673,8 +793,9 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) match r.id { ids::RESOLUTION_INFO => { if let Ok(ri) = photocraft_psd::ResolutionInfo::from_bytes(&r.data) { - let ppi = |res: f64, unit: u16| if unit == 2 { res * 2.54 } else { res }; - let (x, y) = (ppi(ri.h_res(), ri.h_res_unit), ppi(ri.v_res(), ri.v_res_unit)); + // Fixed 16.16 hRes/vRes are pixels per inch. The unit shorts only + // say how to show that number (1 = px/in, 2 = px/cm). + let (x, y) = (ri.h_res(), ri.v_res()); doc.resolution_dpi = x as f32; warnings.extend(crate::unequal_resolution_warning(x, y)); } @@ -715,6 +836,7 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) id => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))), } } + crate::guides_map::apply_colors(&mut doc); for b in &file.global_blocks { doc.metadata.psd_global_blocks.push((b.signature, b.key, Arc::new(b.data.clone()))); } @@ -770,24 +892,31 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) } let (w, hh) = (h.width as usize, h.height as usize); - let n = w * hh; + // Pixels per plane and bytes per merged plane, checked: a 1-bit plane can decode within the + // budget while `w * hh` does not fit a 32-bit `usize` (wasm32). + let sizes = w.checked_mul(hh).zip(h.row_bytes().checked_mul(hh)); let canvas = Rect::new(0, 0, h.width as i32, h.height as i32); // The merged composite is decoded only when something consumes it: a flattened or // multichannel file (it *is* the image), or extra alpha/spot channels behind the colour // ones. A normal layered Photoshop save carries a merged composite nobody reads - roughly // half the file's bytes, decoded and thrown away on every open before this. - let extra = usize::from(h.channels) > cc + usize::from(file.merged_has_alpha()); - let merged = if !layered || file.layers().is_empty() || extra { - let m = file.decode_merged(); + let merged = if merged_needed(file) { + let m = decoded.unwrap_or_else(|| file.decode_merged()); if ctl.cancelled() { return None; } ctl.progress(0.1); if let Err(e) = &m { cx.warn(format!("merged image could not be decoded: {e}")); + if can_recover_layers(file) { + cx.warn("extra alpha/spot channels in the merged image could not be recovered"); + } } Some(m) } else { + if let Some(Err(e)) = decoded { + cx.warn(format!("merged image could not be decoded; opened the available layers instead: {e}")); + } None }; @@ -820,12 +949,13 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) } if doc.layers.is_empty() && let Some(Ok(all)) = &merged + // Always `Some` here: a decoded 8/16/32-bit plane holds at least `w * hh` bytes. + && let Some((n, plane)) = sizes { // Flattened file: the merged image becomes the background layer. - let plane = h.row_bytes() * hh; let alpha_idx = if file.merged_has_alpha() { Some(cc) } else { None }; - let mut planes: Vec> = (0..cc).map(|c| all.get(c * plane..(c + 1) * plane)).collect(); - planes.push(alpha_idx.and_then(|a| all.get(a * plane..(a + 1) * plane))); + let mut planes: Vec> = (0..cc).map(|c| merged_plane(all, c, plane)).collect(); + planes.push(alpha_idx.and_then(|a| merged_plane(all, a, plane))); let mut fill = vec![zero_sample(depth); cc]; fill.push(max_sample(depth)); let mut inv = vec![cx.cmyk; cc]; @@ -881,47 +1011,32 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) } else if !file.color_mode_data.is_empty() { cx.warn("color mode data is not preserved"); } - let rgba = file.composite_rgba8().or_else(|_| { - // Multichannel: show the first channels as RGB. - let all = merged.clone().ok_or_else(|| photocraft_psd::PsdError::Invalid("no merged image".into()))??; - let plane = h.row_bytes() * hh; - let mut data = vec![255u8; n * 4]; - for c in 0..3.min(usize::from(h.channels)) { - let p = photocraft_psd::pixels::plane_to_u8(&all[c * plane..(c + 1) * plane], h.depth, w, hh)?; - for i in 0..n { - data[i * 4 + c] = p[i]; - } + match flat_composite(file, merged.as_ref(), photocraft_psd::compression::MAX_DECODED_BYTES) { + Ok(img) => { + let mut s = Surface::new(fmt); + write_rgba8(&mut s, canvas, &img.data); + drop(img); + s.prune(); + doc.layers.push(flat_background(s)); } - Ok::<_, photocraft_psd::PsdError>(photocraft_psd::RgbaImage { left: 0, top: 0, width: h.width, height: h.height, data }) - }); - if let Ok(img) = rgba { - let mut s = Surface::new(fmt); - let vals: Vec = img - .data - .as_chunks::<4>() - .0 - .iter() - .flat_map(|p| { - let v = photocraft_raster::from_rgba(&fmt, [p[0], p[1], p[2], p[3]].map(|x| f32::from(x) / 255.0)); - v.into_iter() - }) - .collect(); - s.write_region(canvas, &vals); - s.prune(); - doc.layers.push(flat_background(s)); + // A merged image that failed to decode was reported above. + Err(_) if matches!(merged, Some(Err(_))) => {} + Err(e) => cx.warn(format!("the image could not be read: {e}")), } } // Extra (alpha / spot) channels of the merged image. - if layered && let Some(Ok(all)) = &merged { + if layered + && let Some(Ok(all)) = &merged + && let Some((n, plane)) = sizes + { let first = cc + usize::from(file.merged_has_alpha()); - let plane = h.row_bytes() * hh; let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default(); // Photoshop lists every extra channel, the merged transparency first when there is one. let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(h.channels).saturating_sub(cc)); let names = names.get(skip..).unwrap_or_default(); for (k, idx) in (first..usize::from(h.channels)).enumerate() { - let Some(p) = all.get(idx * plane..(idx + 1) * plane) else { break }; + let Some(p) = merged_plane(all, idx, plane) else { break }; let mut s = Surface::new(cx.mask_fmt); s.write_interleaved(canvas, &be_to_ne_plane(p, depth, n)); s.prune(); @@ -961,3 +1076,54 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) ctl.progress(1.0); Some((doc, cx.warnings)) } + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_psd::{Compression, PsdError, Version, testgen}; + + /// #5200: the Multichannel fallback checks its RGBA output against the budget before + /// allocating it (here a 2x2 image, 16 bytes, against caps of 15 and 16 bytes). + #[test] + fn flat_fallback_refuses_an_output_over_the_cap() { + let file = testgen::merged_only(Version::Psd, PsdMode::Multichannel, 8, Compression::Raw, 2, 2); + assert!(file.composite_rgba8().is_err(), "Multichannel goes through the fallback"); + let merged = file.decode_merged(); + assert!(matches!(flat_composite(&file, Some(&merged), 15), Err(PsdError::LimitExceeded(_)))); + let img = flat_composite(&file, Some(&merged), 16).unwrap(); + assert_eq!(img.data.len(), 16); + } + + /// #5200: a composite over the budget is not retried by the fallback (which allocated the + /// refused buffer: 14.4 GB for this 60000x60000 Bitmap header). + #[test] + fn flat_fallback_does_not_retry_an_over_budget_composite() { + let mut file = testgen::small(Version::Psb, Compression::Raw); + file.header.color_mode = PsdMode::Bitmap; + file.header.depth = 1; + file.header.channels = 1; + file.header.width = 60_000; + file.header.height = 60_000; + let merged = Ok(vec![0u8; 16]); + let cap = photocraft_psd::compression::MAX_DECODED_BYTES; + assert!(matches!(flat_composite(&file, Some(&merged), cap), Err(PsdError::LimitExceeded(_)))); + } + + /// #5202: a hostile name length ends the list instead of overflowing. + #[test] + fn unicode_names_stop_at_a_hostile_length() { + let mut data = 1u32.to_be_bytes().to_vec(); + data.extend([0, b'A']); + data.extend(u32::MAX.to_be_bytes()); + data.extend([0, b'B']); + assert_eq!(unicode_names(&data), ["A"]); + } + + #[test] + fn merged_plane_checks_offsets() { + let all = [1u8, 2, 3, 4]; + assert_eq!(merged_plane(&all, 1, 2), Some(&[3u8, 4][..])); + assert_eq!(merged_plane(&all, 2, 2), None); + assert_eq!(merged_plane(&all, usize::MAX, 2), None); + } +} diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index ab9349fbde..3de336e16c 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -289,6 +289,153 @@ fn known_params(class: &str, f: Option<&Descriptor>) -> Option<(&'static str, J) Some((k.command, params)) } +// ---------- Filter Gallery ---------- +// +// Filter › Filter Gallery stores its effect stack in a `GEfc` filter (filterID `GEfc`). Photoshop +// writes a single effect inline; Photopea writes a list (also for one effect): +// +// ```text +// Fltr = GEfc { GEfk = GEft enum (the effect), } (Photoshop) +// Fltr = GEfc { GEfs = [ GEfc { , GEfk = GEft enum, GELv = visible } ] } (Photopea) +// ``` +// +// The list is in application order, like `filter.filterGallery`'s `effects`. The item's +// `FrgC`/`BckC` are the colours the Sketch effects read. Only effects whose settings we know are +// mapped; a stack with any other effect (or setting) stays an [`UNSUPPORTED_FILTER`]. + +const GALLERY_CLASS: &str = "GEfc"; +const GALLERY_COMMAND: &str = "filter.filterGallery"; +const GALLERY_NAME: &str = "Filter Gallery..."; + +/// One gallery effect: our filter key, Photoshop's `GEft` code and its settings (PSD key, our key). +struct GalleryCode { + key: &'static str, + code: &'static str, + settings: &'static [(&'static str, &'static str)], +} + +const GALLERY_EFFECTS: [GalleryCode; 1] = [GalleryCode { key: "chrome", code: "Chrm", settings: &[("Dtl ", "detail"), ("Smth", "smoothness")] }]; + +/// An `RGBC` descriptor (0–255 channels) as straight `[r, g, b, a]`, 0–1. +fn rgbc_colour(d: &Descriptor) -> Option { + let c = |k: &str| px(d, k).map(|v| (v / 255.0).clamp(0.0, 1.0)); + Some(json!([c("Rd ")?, c("Grn ")?, c("Bl ")?, 1.0])) +} + +/// `filter.filterGallery` params for a `GEfc` filter item; `None` when an effect or setting is +/// not modelled. +fn gallery_params(item: &Descriptor, f: &Descriptor) -> Option { + let list: Vec<&Descriptor> = match f.get("GEfs") { + Some(Value::List(list)) => list + .iter() + .map(|v| match v { + Value::Descriptor(e) => Some(e), + _ => None, + }) + .collect::>()?, + Some(_) => return None, + None if f.get("GEfk").is_some() => vec![f], + None => return None, + }; + let mut effects = Vec::with_capacity(list.len()); + for e in list { + let code = enum_of(e, "GEfk")?; + let g = GALLERY_EFFECTS.iter().find(|g| g.code.as_bytes() == code)?; + let mut params = Map::new(); + for (k, v) in &e.items { + if k.is("GEfk") || k.is("GELv") { + continue; + } + let (_, ours) = g.settings.iter().find(|(psd, _)| k.is(psd))?; + params.insert((*ours).into(), json!(int_value(v)?)); + } + effects.push(json!({"filter": g.key, "params": params, "visible": bool_or(e, "GELv", true)})); + } + if effects.is_empty() { + return None; + } + let mut out = Map::new(); + out.insert("effects".into(), J::Array(effects)); + for (psd, ours) in [("FrgC", "foreground"), ("BckC", "background")] { + if let Some(c) = get_desc(item, psd).and_then(rgbc_colour) { + out.insert(ours.into(), c); + } + } + Some(J::Object(out)) +} + +fn colour_rgb(v: &J) -> Option<[f64; 3]> { + match v { + J::Array(a) => Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?]), + J::String(h) if h.len() == 7 && h.starts_with('#') => { + let ch = |j: usize| h.get(j..j + 2).and_then(|s| u8::from_str_radix(s, 16).ok()).map(|v| f64::from(v) / 255.0); + Some([ch(1)?, ch(3)?, ch(5)?]) + } + _ => None, + } +} + +/// A `[r, g, b(, a)]` (0–1) or `#rrggbb` colour param as an `RGBC` descriptor (0–255). +fn colour_rgbc(v: Option<&J>, default: f64) -> Result { + let Some(v) = v else { return Ok(rgbc(default)) }; + let rgb = colour_rgb(v).filter(|c| c.iter().all(|v| v.is_finite())).ok_or("a colour is not [r, g, b] or #rrggbb")?; + let c = |v: f64| Value::Double(v.clamp(0.0, 1.0) * 255.0); + Ok(Value::Descriptor(Descriptor::new("RGBC").with("Rd ", c(rgb[0])).with("Grn ", c(rgb[1])).with("Bl ", c(rgb[2])))) +} + +/// The `filterFXList` item for a `filter.filterGallery` smart filter. +fn gallery_item(f: &SmartFilter) -> Result { + let p = &f.params; + if let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !is_meta(k) && !["effects", "foreground", "background"].contains(&k.as_str()))) { + return Err(format!("setting `{k}` has no PSD equivalent")); + } + let list = p.get("effects").and_then(J::as_array).ok_or("it has no effects")?; + let mut out = Vec::with_capacity(list.len()); + for e in list { + let key = e.get("filter").and_then(J::as_str).ok_or("an effect has no `filter`")?; + let g = GALLERY_EFFECTS.iter().find(|g| g.key == key).ok_or_else(|| format!("gallery effect `{key}` has no PSD equivalent"))?; + let params = e.get("params").filter(|v| v.is_object()).unwrap_or(e); + if let Some(k) = params.as_object().and_then(|o| { + o.keys().find(|k| !is_meta(k) && !["filter", "params", "visible"].contains(&k.as_str()) && !g.settings.iter().any(|(_, ours)| ours == k)) + }) { + return Err(format!("gallery setting `{key}.{k}` has no PSD equivalent")); + } + let defaults = photocraft_algo::GalleryFilter::from_key(key).map(photocraft_algo::GalleryEffect::new); + let settings: Vec<(&str, Value)> = + g.settings.iter().map(|(psd, ours)| (*psd, pi(params, ours, defaults.as_ref().map_or(0.0, |x| f64::from(x.get(ours)))))).collect(); + out.push((g.code, settings, e.get("visible").and_then(J::as_bool).unwrap_or(true))); + } + let gallery = || { + let mut d = Descriptor::new(GALLERY_CLASS); + d.name = UnicodeString::new_nul("Filter Gallery"); + d + }; + // One visible effect: inline, as Photoshop writes it. Otherwise the effect list. + let fltr = match out.as_slice() { + [] => return Err("it has no effects".into()), + [(code, settings, true)] => settings.iter().fold(gallery().with("GEfk", enumv("GEft", code)), |d, (k, v)| d.with(k, v.clone())), + _ => { + let list = out + .into_iter() + .map(|(code, settings, visible)| { + let d = settings.into_iter().fold(gallery(), |d, (k, v)| d.with(k, v)); + Value::Descriptor(d.with("GEfk", enumv("GEft", code)).with("GELv", Value::Boolean(visible))) + }) + .collect(); + gallery().with("GEfs", Value::List(list)) + } + }; + Ok(Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul(GALLERY_NAME))) + .with("blendOptions", blend_options(f)) + .with("enab", Value::Boolean(f.visible)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", colour_rgbc(p.get("foreground"), 0.0)?) + .with("BckC", colour_rgbc(p.get("background"), 255.0)?) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(code(b"GEfc")))) +} + /// Lowercase hex of `bytes`. fn to_hex(bytes: &[u8]) -> String { const H: &[u8; 16] = b"0123456789abcdef"; @@ -322,6 +469,7 @@ pub fn filter_from_item(item: &Descriptor) -> SmartFilter { let known = match class.as_deref() { Some(camera_raw::CLASS) => camera_raw::import_params(item).map(|p| (camera_raw::COMMAND, p)), Some(displace::CLASS) => displace::import_params(item).map(|p| (displace::COMMAND, p)), + Some(GALLERY_CLASS) => fltr.and_then(|f| gallery_params(item, f)).map(|p| (GALLERY_COMMAND, p)), c => c.and_then(|c| known_params(c, fltr)), }; let (command, params) = match known { @@ -473,6 +621,9 @@ pub fn item_for_filter(f: &SmartFilter) -> Result { set(&mut d, "enab", Value::Boolean(f.visible)); return Ok(d); } + if f.command == GALLERY_COMMAND { + return gallery_item(f); + } if f.command == UNSUPPORTED_FILTER { let raw = f.params.get("psd").and_then(J::as_str).and_then(from_hex).ok_or("its filter data is missing")?; let mut d = Descriptor::from_bytes(&raw).map_err(|e| format!("its filter data is unreadable ({e})"))?; @@ -735,6 +886,45 @@ pub fn mask_from_item(item: &FilterEffectsItem, sample: SampleType, stack: &Filt Ok(Some(LayerMask { surface, enabled: stack.mask_enabled, linked: stack.mask_linked, density: 1.0, feather: 0.0 })) } +/// The unfiltered placed pixels of an `FEid` item (the inverse of [`feid_item`]), in `fmt`: colour +/// channels from slot 0, transparency from the last slot (opaque without it). `None` when the +/// item has no colour planes, its depth isn't `fmt`'s, or a plane doesn't decode. +pub fn unfiltered_from_item(item: &FilterEffectsItem, fmt: PixelFormat) -> Option { + let depth = u16::try_from(item.depth).ok()?; + if u32::from(crate::pixels::psd_depth(fmt.sample)) != item.depth || !fmt.alpha { + return None; + } + let (w, h) = item.rect.size().ok()?; + let n = w.checked_mul(h)?; + let cc = fmt.mode.color_channels(); + let mut planes = Vec::with_capacity(cc + 1); + for slot in (0..cc).chain(std::iter::once(item.slots.len().saturating_sub(1))) { + planes.push(match item.slots.get(slot).and_then(Option::as_ref) { + Some(p) => Some(p.decode(w, h, depth).ok()?), + None if slot < cc => return None, + None => None, + }); + } + let refs: Vec> = planes.iter().map(|p| p.as_deref()).collect(); + let mut fill = vec![crate::pixels::zero_sample(fmt.sample); cc]; + // Without a transparency plane the pixels are opaque (a native-endian full sample). + fill.push(match fmt.sample { + SampleType::U8 => vec![u8::MAX], + SampleType::U16 => u16::MAX.to_ne_bytes().to_vec(), + SampleType::F32 => 1.0f32.to_ne_bytes().to_vec(), + }); + let mut invert = vec![fmt.mode == ColorMode::Cmyk; cc]; + invert.push(false); + let mut bytes = crate::pixels::interleave(&refs, &fill, n, fmt.sample, &invert); + if fmt.mode == ColorMode::Lab && fmt.sample == SampleType::U16 { + crate::pixels::lab16_chroma(&mut bytes, cc + 1, true); + } + let mut surface = Surface::new(fmt); + surface.write_interleaved(GeomRect::new(item.rect.left, item.rect.top, item.rect.right, item.rect.bottom), &bytes); + surface.prune(); + Some(surface) +} + /// An `FEid` item for a smart object with filters: the unfiltered placed pixels Photoshop /// filters from (colour channels from slot 0, transparency in the last slot; in the document's /// pixel format, CMYK inverted as in layer channels) and the filter mask over `bounds` (white @@ -913,6 +1103,161 @@ mod tests { assert_eq!(filter_from_item(&odd).command, UNSUPPORTED_FILTER); } + /// A Filter Gallery item as Photopea writes it (Chrome, Detail 12, Smoothness 4). + fn gallery_item_with(effects: Vec) -> Descriptor { + let rgb = + |v: f64| Value::Descriptor(Descriptor::new("RGBC").with("Rd ", Value::Double(v)).with("Grn ", Value::Double(v)).with("Bl ", Value::Double(v))); + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Filtergalerie"))) + .with("blendOptions", Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 100.0)).with("Md ", enumv("BlnM", "normal")))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgb(0.0)) + .with("BckC", rgb(255.0)) + .with("Fltr", Value::Descriptor(Descriptor::new("GEfc").with("GEfs", Value::List(effects.into_iter().map(Value::Descriptor).collect())))) + .with("filterID", Value::Integer(code(b"GEfc"))) + } + + fn chrome(detail: i32, smoothness: i32, visible: bool) -> Descriptor { + Descriptor::new("GEfc") + .with("Dtl ", Value::Integer(detail)) + .with("Smth", Value::Integer(smoothness)) + .with("GEfk", enumv("GEft", "Chrm")) + .with("GELv", Value::Boolean(visible)) + } + + #[test] + fn filter_gallery_chrome_imports_as_editable_gallery() { + let f = filter_from_item(&gallery_item_with(vec![chrome(12, 4, true), chrome(3, 9, false)])); + assert_eq!(f.command, "filter.filterGallery"); + assert_eq!( + f.params, + json!({ + "effects": [ + {"filter": "chrome", "params": {"detail": 12, "smoothness": 4}, "visible": true}, + {"filter": "chrome", "params": {"detail": 3, "smoothness": 9}, "visible": false}, + ], + "foreground": [0.0, 0.0, 0.0, 1.0], + "background": [1.0, 1.0, 1.0, 1.0], + }) + ); + // Written back and read again: unchanged, also when re-blended and hidden. + let mut g = f.clone(); + g.blend = BlendMode::Multiply; + g.opacity = 0.5; + g.visible = false; + let d = item_for_filter(&g).unwrap(); + assert_eq!(filter_from_item(&Descriptor::from_bytes(&d.to_bytes()).unwrap()), g); + let fltr = get_desc(&d, "Fltr").unwrap(); + assert!(fltr.class_id.is("GEfc")); + assert_eq!(int(&d, "filterID"), Some(code(b"GEfc"))); + } + + /// A single effect as Photoshop 27 writes it: inline in `Fltr`, no `GEfs` list or `GELv`. + fn photoshop_chrome(detail: i32, smoothness: i32) -> Descriptor { + let rgb = + |v: f64| Value::Descriptor(Descriptor::new("RGBC").with("Rd ", Value::Double(v)).with("Grn ", Value::Double(v)).with("Bl ", Value::Double(v))); + let mut fltr = + Descriptor::new("GEfc").with("GEfk", enumv("GEft", "Chrm")).with("Dtl ", Value::Integer(detail)).with("Smth", Value::Integer(smoothness)); + fltr.name = UnicodeString::new_nul("Filter Gallery"); + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Filter Gallery..."))) + .with("blendOptions", Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 100.0)).with("Md ", enumv("BlnM", "Nrml")))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("FrgC", rgb(255.0)) + .with("BckC", rgb(0.0)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(code(b"GEfc"))) + } + + #[test] + fn filter_gallery_photoshop_inline_effect_imports_and_writes_back_inline() { + let item = photoshop_chrome(10, 4); + let f = filter_from_item(&item); + assert_eq!(f.command, "filter.filterGallery"); + assert_eq!( + f.params, + json!({ + "effects": [{"filter": "chrome", "params": {"detail": 10, "smoothness": 4}, "visible": true}], + "foreground": [1.0, 1.0, 1.0, 1.0], + "background": [0.0, 0.0, 0.0, 1.0], + }) + ); + // Written back in Photoshop's own layout. + let d = item_for_filter(&f).unwrap(); + assert_eq!(get_desc(&d, "Fltr"), get_desc(&item, "Fltr")); + assert_eq!((get_desc(&d, "FrgC"), get_desc(&d, "BckC")), (get_desc(&item, "FrgC"), get_desc(&item, "BckC"))); + assert_eq!(filter_from_item(&Descriptor::from_bytes(&d.to_bytes()).unwrap()), f); + // A hidden single effect needs the list (only it has a visibility flag). + let mut hidden = f.clone(); + hidden.params["effects"][0]["visible"] = json!(false); + let d = item_for_filter(&hidden).unwrap(); + assert!(get_desc(&d, "Fltr").unwrap().get("GEfs").is_some()); + assert_eq!(filter_from_item(&d), hidden); + // Inline effects we don't model stay verbatim. + let mut odd = photoshop_chrome(10, 4); + set(&mut odd, "Fltr", Value::Descriptor(Descriptor::new("GEfc").with("GEfk", enumv("GEft", "Ctot")).with("Dtl ", Value::Integer(1)))); + assert_eq!(filter_from_item(&odd).command, UNSUPPORTED_FILTER); + let mut extra = photoshop_chrome(10, 4); + set(&mut extra, "Fltr", Value::Descriptor(Descriptor::new("GEfc").with("GEfk", enumv("GEft", "Chrm")).with("Xtra", Value::Integer(1)))); + assert_eq!(filter_from_item(&extra).command, UNSUPPORTED_FILTER); + } + + #[test] + fn gallery_table_matches_the_gallery_filters() { + for g in &GALLERY_EFFECTS { + let f = photocraft_algo::GalleryFilter::from_key(g.key).unwrap(); + let keys: Vec<&str> = f.params().iter().map(|p| p.key).collect(); + for (_, ours) in g.settings { + assert!(keys.contains(ours), "{}.{ours}", g.key); + } + } + } + + #[test] + fn filter_gallery_written_with_defaults_and_colours() { + let f = sf("filter.filterGallery", json!({"effects": [{"filter": "chrome"}], "foreground": "#ff0000", "background": [0.0, 0.0, 1.0, 1.0]})); + let back = filter_from_item(&item_for_filter(&f).unwrap()); + assert_eq!(back.params["effects"][0]["params"], json!({"detail": 4, "smoothness": 7})); + assert_eq!(back.params["foreground"], json!([1.0, 0.0, 0.0, 1.0])); + assert_eq!(back.params["background"], json!([0.0, 0.0, 1.0, 1.0])); + } + + #[test] + fn filter_gallery_we_dont_model_is_kept_verbatim() { + // Another effect, an extra setting, a wrong-typed setting, no effects, a non-list. + let other = Descriptor::new("GEfc").with("GEfk", enumv("GEft", "Ctot")).with("GELv", Value::Boolean(true)); + let extra = chrome(4, 7, true).with("Xtra", Value::Integer(1)); + let typed = Descriptor::new("GEfc").with("Dtl ", Value::Double(4.0)).with("GEfk", enumv("GEft", "Chrm")); + for effects in [vec![chrome(4, 7, true), other], vec![extra], vec![typed], vec![]] { + let item = gallery_item_with(effects); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER); + assert_eq!(item_for_filter(&f).unwrap(), item); + } + let odd = Descriptor::new("filterFX").with("Fltr", Value::Descriptor(Descriptor::new("GEfc").with("GEfs", Value::Integer(3)))); + assert_eq!(filter_from_item(&odd).command, UNSUPPORTED_FILTER); + let bare = Descriptor::new("filterFX").with("filterID", Value::Integer(code(b"GEfc"))); + assert_eq!(filter_from_item(&bare).command, UNSUPPORTED_FILTER); + } + + #[test] + fn filter_gallery_without_psd_equivalent_is_reported() { + for p in [ + json!({"effects": [{"filter": "cutout"}]}), + json!({"effects": [{"filter": "chrome", "params": {"sparkle": 1}}]}), + json!({"effects": [{"params": {}}]}), + json!({"effects": []}), + json!({}), + json!({"effects": [{"filter": "chrome"}], "list": true}), + json!({"effects": [{"filter": "chrome"}], "foreground": "red"}), + json!(null), + ] { + assert!(item_for_filter(&sf("filter.filterGallery", p.clone())).is_err(), "{p}"); + } + } + #[test] fn unwritable_filters_are_reported_not_fatal() { let stack = FilterStack { diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 04fc4bee3a..8afefb733d 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -28,6 +28,10 @@ use crate::{ImportResult, IoError}; pub const MAX_SIDE: u32 = 16_384; /// A rasterised fallback layer or a smart-object render never exceeds this many pixels. const MAX_RASTER_PIXELS: u64 = 24_000_000; +/// Aggregate memory for retained fallback surfaces in the document's pixel format. +const MAX_PIXEL_BYTES: u64 = 1 << 30; +/// Estimated peak during raster conversion (retained surfaces + RGBA8 pixmap + f32 staging + tiles). +const MAX_PEAK_PIXEL_BYTES: u64 = 3 << 29; /// Past this many drawable elements the whole drawing opens as one pixel layer. const MAX_NODES: usize = 2_000; /// Nesting past which a subtree is rasterised instead of walked (bounded recursion). @@ -212,6 +216,10 @@ pub fn import_svg_with_group_depth(name: &str, bytes: &[u8], max_group_depth: us } fn import_parsed(name: &str, parsed: Parsed, max_group_depth: usize) -> Result { + import_parsed_with_budget(name, parsed, max_group_depth, MAX_PIXEL_BYTES) +} + +fn import_parsed_with_budget(name: &str, parsed: Parsed, max_group_depth: usize, pixel_budget: u64) -> Result { let Parsed { tree, fontless_text } = parsed; let mut warnings = Vec::new(); if fontless_text { @@ -222,7 +230,8 @@ fn import_parsed(name: &str, parsed: Parsed, max_group_depth: usize) -> Result MAX_NODES { conv.warnings.push(format!("SVG: more than {MAX_NODES} elements; opened as one pixel layer instead of shapes")); let (w, h) = (cv.size.width, cv.size.height); @@ -311,6 +320,10 @@ struct Conv { noted: Vec<&'static str>, /// Unnamed shapes, groups, images and texts so far, for "Shape 3"-style names. counts: [usize; 4], + /// Remaining budget for retained rasterised surfaces, in actual sample bytes. + pixel_budget: u64, + /// Successfully retained raster surface memory; rejected nodes are never counted. + retained_pixel_bytes: u64, } impl Conv { @@ -321,6 +334,23 @@ impl Conv { } } + /// Check both retained and transient memory before rendering; charge only retained output. + fn can_retain_pixels(&mut self, pixels: u64) -> bool { + let bytes = pixels.saturating_mul(self.fmt.bytes_per_pixel() as u64); + if bytes > self.pixel_budget { + self.note("pixel-budget", "SVG: some rasterised elements were omitted because they exceeded the remaining pixel memory budget".into()); + return false; + } + // surface() converts the RGBA8 pixmap into f32 scratch space before writing + // sample-format tiles. All three buffers briefly coexist with retained layers. + let temporary = pixels.saturating_mul(4 + self.fmt.channels() as u64 * 4 + self.fmt.bytes_per_pixel() as u64); + if self.retained_pixel_bytes.saturating_add(temporary) > MAX_PEAK_PIXEL_BYTES { + self.note("pixel-peak", "SVG: some rasterised elements were omitted to bound temporary conversion memory".into()); + return false; + } + true + } + /// The element's `id`, or a numbered name by kind (0 shape, 1 group, 2 image, 3 text). fn name(&mut self, id: &str, kind: usize) -> String { if !id.is_empty() { @@ -337,6 +367,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 +385,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 +405,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 +440,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 +459,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 +470,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 +493,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 +581,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); @@ -551,17 +597,31 @@ impl Conv { if x1 <= x0 || y1 <= y0 { return None; } - let Some(mut pixmap) = pixmap((x1 - x0) as u32, (y1 - y0) as u32) else { + let (w, h) = ((x1 - x0) as u32, (y1 - y0) as u32); + let pixels = u64::from(w) * u64::from(h); + // Preflight the pixmap limit first, without charging the shared allowance. + if pixels > MAX_RASTER_PIXELS { self.note("raster-size", "SVG: an element too large to rasterise was dropped".to_string()); return None; + } + if !self.can_retain_pixels(pixels) { + return None; + } + let Some(mut pixmap) = pixmap(w, h) else { + self.note("raster-alloc", "SVG: a rasterised element could not be allocated".to_string()); + return None; }; // resvg renders the node in its parent's space after shifting its absolute bounding box // 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)))) + let layer = Layer::new(name, LayerContent::Raster(surface(&pixmap, Rect::new(x0, y0, x1, y1), self.fmt))); + let bytes = pixels * self.fmt.bytes_per_pixel() as u64; + self.pixel_budget -= bytes; + self.retained_pixel_bytes += bytes; + Some(layer) } } @@ -768,6 +828,59 @@ mod tests { assert!(r.warnings.iter().any(|w| w.contains("clip-path")), "{:?}", r.warnings); } + #[test] + fn rasterized_groups_stop_at_the_aggregate_pixel_budget() { + let mut svg = String::from(""); + svg.push_str(""); + for i in 0..8 { + svg.push_str(&format!("")); + } + svg.push_str(""); + // Enough for one retained 256×256 RGBA8 surface, not eight of them. + let budget = 256u64 * 256 * 4; + let result = import_parsed_with_budget("budget", parse(svg.as_bytes()).unwrap(), MAX_GROUP_DEPTH, budget).unwrap(); + assert_eq!(result.document.layers.len(), 1, "one U8 surface fills the retained-byte allowance"); + assert_eq!(result.document.layers[0].name, "g0"); + assert!(result.warnings.iter().any(|warning| warning.contains("pixel memory")), "{:?}", result.warnings); + } + + #[test] + fn oversized_raster_does_not_consume_budget_for_later_layers() { + let svg = br##" + + + + + + + + + + + "##; + let result = import_parsed_with_budget("budget", parse(svg).unwrap(), MAX_GROUP_DEPTH, 64 * 64 * 4).unwrap(); + assert_eq!(result.document.layers.len(), 1, "the oversized group must not debit the budget"); + assert_eq!(result.document.layers[0].name, "small"); + assert!(result.warnings.iter().any(|w| w.contains("too large to rasterise")), "{:?}", result.warnings); + assert!(!result.warnings.iter().any(|w| w.contains("pixel memory budget")), "{:?}", result.warnings); + } + + #[test] + fn skipped_expensive_raster_does_not_hide_a_smaller_one() { + let svg = br##" + + + + + + + "##; + let result = import_parsed_with_budget("budget", parse(svg).unwrap(), MAX_GROUP_DEPTH, 32 * 32 * 4).unwrap(); + assert_eq!(result.document.layers.len(), 1); + assert_eq!(result.document.layers[0].name, "small"); + assert!(result.warnings.iter().any(|w| w.contains("some rasterised elements were omitted")), "{:?}", result.warnings); + } + #[test] fn huge_drawings_scale_to_the_side_limit() { let tree = parse(b"").unwrap().tree; diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 8a5810e922..7da61fa466 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -93,6 +93,7 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result (PsdMode::Grayscale, 1), ChannelLayout::Rgb | ChannelLayout::Rgba => (PsdMode::Rgb, 3), ChannelLayout::Cmyk | ChannelLayout::CmykA => (PsdMode::Cmyk, 4), + ChannelLayout::Lab | ChannelLayout::LabA => (PsdMode::Lab, 3), }; let depth = match img.sample_type() { CSample::U8 => 8, @@ -201,14 +202,38 @@ 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 (file, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psd_opts, ctl)); + // Layer data is written PSD-style ("Adobe Photoshop Document Data Block", 4-byte lengths), as + // Photoshop writes it: Photoshop does not read the PSB-style header ("... Data V0002") in a + // TIFF and opens only the composite, as one locked Background. The exporter's size estimate + // counts layer planes uncompressed, so a document with many canvas-sized layers passes 2 GB + // while its real layer data is a small fraction of that; it is not used here. PSB-style data + // is written only past 30000 px, when asked for, or when a length really overflows 32 bits. + let force_psb = opts.force_psb || big; + let psd_opts = PsdExportOptions { + force_psb, + size_limit_psb: false, + merged_matte: false, + merged_compression: Compression::Raw, + maximize_compatibility: true, + document_path: opts.document_path.clone(), + }; + let (mut 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)?; warnings.extend(w); let order = if codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big }; - let (layers, w) = ImageSourceData::from_psd(&file).to_bytes(order)?; + let (layers, w) = match ImageSourceData::from_psd(&file).to_bytes(order) { + Err(photocraft_psd::PsdError::LimitExceeded(_)) if !force_psb => { + let psb_opts = PsdExportOptions { force_psb: true, ..psd_opts }; + let (psb, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psb_opts, ctl)); + ctl.check().map_err(|_| IoError::Cancelled)?; + warnings.extend(w); + warnings.push("layer data exceeds 4 GB; written with PSB-sized lengths, which some readers do not open".into()); + file = psb; + ImageSourceData::from_psd(&file).to_bytes(order)? + } + r => r?, + }; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(TIFF_LAYERS_DONE); warnings.extend(w.into_iter().map(|w| format!("layer data: {w}"))); @@ -278,3 +303,54 @@ pub fn strip_foreign_order_blocks(file: &mut photocraft_psd::PsdFile) -> Vec", END), rec("b", None), rec("c", None)]); + assert_eq!(names(&f, &f.layer_tree()), "a,b,c"); + assert_eq!(names(&f, &f.layer_tree_max_depth(1)), "a,b,c"); + assert_eq!(names(&f, &f.layer_tree_max_depth(0)), "a,b,c"); +} + +/// #5758: a closed group inside unclosed dividers sits at the depth it ends up at after hoisting, +/// so the cap keeps its node and drops only its deeper children. +#[test] +fn layer_tree_max_depth_keeps_closed_group_inside_unclosed_dividers() { + let mut v = vec![rec("", END), rec("", END)]; + v.extend([rec("", END), rec("", END), rec("x", None), rec("g1", OPEN), rec("g0", OPEN)]); + let f = file_with(v); + assert_eq!(names(&f, &f.layer_tree()), "g0[g1[x]]"); + assert_eq!(names(&f, &f.layer_tree_max_depth(1)), "g0[g1[]]"); + assert_eq!(names(&f, &f.layer_tree_max_depth(0)), "g0[]"); +} + +/// #5758: thousands of unclosed dividers around a closed chain stay bounded by the cap and keep +/// every layer the full tree has. +#[test] +fn layer_tree_max_depth_bounds_deep_unclosed_chain() { + let mut v = Vec::new(); + for i in 0..16_000 { + v.push(rec("", END)); + v.push(rec(&format!("l{i}"), None)); + } + v.extend([rec("", END), rec("", END), rec("", END), rec("x", None)]); + v.extend([rec("g2", OPEN), rec("g1", OPEN), rec("g0", OPEN)]); + let f = file_with(v); + let full = f.layer_tree(); + assert_eq!(full.len(), 16_001); + assert_eq!(depth(&full), 4); + let capped = f.layer_tree_max_depth(1); + assert!(depth(&capped) <= 2); + assert_eq!(capped.len(), 16_001); + assert_eq!(names(&f, &capped[16_000..]), "g0[g1[]]"); + assert_eq!(names(&f, &capped[..2]), "l0,l1"); +} #[test] fn folder_without_divider_is_empty_group() { let f = file_with(vec![rec("a", None), rec("G", OPEN), rec("b", None)]); diff --git a/crates/raster/Cargo.toml b/crates/raster/Cargo.toml index 956d1c9545..41b188a94f 100644 --- a/crates/raster/Cargo.toml +++ b/crates/raster/Cargo.toml @@ -12,5 +12,9 @@ photocraft-geom = { workspace = true } photocraft-color = { workspace = true } serde = { workspace = true } +# Native only: converting a region's tiles is independent per tile, so it runs on all cores. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rayon = { workspace = true } + [dev-dependencies] proptest = { workspace = true } diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index ff45764980..958ab38b53 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -66,11 +66,17 @@ impl Tile { } fn filled(format: &PixelFormat, pixel: &[u8]) -> Self { - let n = (TILE_SIZE * TILE_SIZE) as usize; - let mut data = vec![0u8; n * format.bytes_per_pixel()]; + let row_len = TILE_SIZE as usize * format.bytes_per_pixel(); + let mut data = vec![0u8; row_len * TILE_SIZE as usize]; if pixel.iter().any(|&b| b != 0) { - for px in data.chunks_exact_mut(pixel.len()) { - px.copy_from_slice(pixel); + // One prebuilt row copied per tile row: 256 bulk copies instead of one + // copy_from_slice per pixel. + let mut row = Vec::with_capacity(row_len); + for _ in 0..TILE_SIZE { + row.extend_from_slice(pixel); + } + for out in data.chunks_mut(row_len) { + out.copy_from_slice(&row); } } Tile { data: data.into_boxed_slice() } @@ -111,6 +117,16 @@ pub struct Surface { tiles: BTreeMap>, } +/// Use stack storage when it fits, otherwise reuse a dynamically sized sample buffer. +fn sample_buffer<'a>(channels: usize, stack: &'a mut [f32], heap: &'a mut Vec) -> &'a mut [f32] { + if let Some(samples) = stack.get_mut(..channels) { + samples + } else { + heap.resize(channels, 0.0); + heap + } +} + /// Tiles of one pixel value met while moving a surface ([`Surface::translated`]): each scanned /// once, and one tile kept per value so the targets share it. #[derive(Default)] @@ -160,6 +176,55 @@ impl PartialEq for Surface { } } +/// Converts the `data` span covering one tile into that tile's bytes. +fn write_tile(t: &mut Tile, tc: TileCoord, r: Rect, data: &[f32], w: usize, n: usize, sample: SampleType) { + let tr = tc.rect().intersect(&r); + // Convert contiguous channel samples a row at a time. Dispatching on depth here removes the + // per-pixel channel loop and lets the compiler vectorise conversion. + let span = tr.width() as usize * n; + for y in tr.y0..tr.y1 { + let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n; + let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; + let src = &data[o..o + span]; + let bytes = sample.bytes(); + let dst = &mut t.data[base * bytes..(base + span) * bytes]; + match sample { + SampleType::U8 => { + for (d, v) in dst.iter_mut().zip(src) { + *d = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } + } + SampleType::U16 => { + for (d, v) in dst.as_chunks_mut::<2>().0.iter_mut().zip(src) { + let q = (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16; + d.copy_from_slice(&q.to_ne_bytes()); + } + } + SampleType::F32 => { + for (d, v) in dst.as_chunks_mut::<4>().0.iter_mut().zip(src) { + d.copy_from_slice(&v.to_ne_bytes()); + } + } + } + } +} + +/// Converts the tiles of a region write. A tile holds no state another tile reads, so the tiles +/// are converted on all cores; each writes only its own bytes, in the same arithmetic. +#[cfg(not(target_arch = "wasm32"))] +fn convert_tiles(tiles: &mut [(TileCoord, Arc)], r: Rect, data: &[f32], w: usize, n: usize, sample: SampleType) { + use rayon::prelude::*; + tiles.par_iter_mut().for_each(|(tc, t)| write_tile(Arc::make_mut(t), *tc, r, data, w, n, sample)); +} + +/// Serial counterpart, for the web build (there are no threads to spread tiles over). +#[cfg(target_arch = "wasm32")] +fn convert_tiles(tiles: &mut [(TileCoord, Arc)], r: Rect, data: &[f32], w: usize, n: usize, sample: SampleType) { + for (tc, t) in tiles.iter_mut() { + write_tile(Arc::make_mut(t), *tc, r, data, w, n, sample); + } +} + impl Surface { /// Reads a region into a newly allocated buffer, returning an error instead of aborting if /// the requested pixel buffer cannot be represented or reserved. @@ -233,11 +298,20 @@ impl Surface { decode_pixel(&self.format, &self.default_pixel) } + /// Decode the default pixel, using dynamic storage only when the stack buffer is too small. + fn default_samples<'a>(&self, stack: &'a mut [f32], heap: &'a mut Vec) -> &'a mut [f32] { + let px = sample_buffer(self.channels(), stack, heap); + for (i, v) in px.iter_mut().enumerate() { + *v = read_sample(&self.default_pixel, self.format.sample, i); + } + px + } + /// Mutable access to a tile, allocating it (filled with the default pixel) or un-sharing it. pub fn tile_mut(&mut self, c: TileCoord) -> &mut Tile { let fmt = self.format; - let dp = self.default_pixel.clone(); - let arc = self.tiles.entry(c).or_insert_with(|| Arc::new(Tile::filled(&fmt, &dp))); + let dp = &self.default_pixel; + let arc = self.tiles.entry(c).or_insert_with(|| Arc::new(Tile::filled(&fmt, dp))); Arc::make_mut(arc) } @@ -313,9 +387,9 @@ impl Surface { /// Drop tiles that are entirely the default pixel. pub fn prune(&mut self) { - let dp = self.default_pixel.clone(); + let dp = self.default_pixel.as_ref(); let bpp = dp.len(); - self.tiles.retain(|_, t| t.data.chunks_exact(bpp).any(|px| px != &*dp)); + self.tiles.retain(|_, t| t.data.chunks_exact(bpp).any(|px| px != dp)); } #[inline] @@ -405,14 +479,27 @@ impl Surface { let w = r.width() as usize; out.clear(); out.resize(w * r.height() as usize * n, 0.0); - let dp = self.default_pixel(); + self.read_samples_into(r, out); + } + + /// Copy samples into a zero-initialized buffer sized for `r`. + fn read_samples_into(&self, r: Rect, out: &mut [f32]) { + let n = self.channels(); + let w = r.width() as usize; + let mut stack = [0.0; 8]; + let mut heap = Vec::new(); + let dp = self.default_samples(&mut stack, &mut heap); + let zero = self.default_pixel.iter().all(|&b| b == 0); for tc in r.tiles() { let tr = tc.rect().intersect(&r); let tile = self.tiles.get(&tc); + if tile.is_none() && zero { + continue; + } for y in tr.y0..tr.y1 { let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n; let span = tr.width() as usize * n; - let dst = &mut out[o..o + span]; + let Some(dst) = out.get_mut(o..o + span) else { continue }; match tile { Some(t) => { let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; @@ -420,9 +507,10 @@ impl Surface { *d = read_sample(&t.data, self.format.sample, base + i); } } + None if n == 1 => dst.fill(dp[0]), None => { for px in dst.chunks_exact_mut(n) { - px.copy_from_slice(&dp); + px.copy_from_slice(dp); } } } @@ -439,7 +527,10 @@ impl Surface { debug_assert_eq!(out.len(), w * r.height() as usize); let fmt = self.format; let n = fmt.channels(); - let dp = to_rgba(&fmt, &self.default_pixel()); + let mut stack = [0.0; 8]; + let mut heap = Vec::new(); + let px = self.default_samples(&mut stack, &mut heap); + let dp = to_rgba(&fmt, px); for tc in r.tiles() { let tr = tc.rect().intersect(&r); let tile = self.tiles.get(&tc); @@ -483,12 +574,11 @@ impl Surface { } } _ => { - let mut px = [0.0f32; 8]; for (i, d) in dst.iter_mut().enumerate() { - for (c, v) in px.iter_mut().enumerate().take(n) { + for (c, v) in px.iter_mut().enumerate() { *v = read_sample(&t.data, fmt.sample, base + i * n + c); } - *d = to_rgba(&fmt, &px[..n]); + *d = to_rgba(&fmt, px); } } } @@ -504,7 +594,9 @@ impl Surface { let fmt = self.format; let rgba8 = matches!((fmt.mode, fmt.sample, fmt.alpha), (ColorMode::Rgb, SampleType::U8, true)); let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; - let dp = to_rgba(&fmt, &self.default_pixel()); + let mut stack = [0.0; 8]; + let mut heap = Vec::new(); + let dp = to_rgba(&fmt, self.default_samples(&mut stack, &mut heap)); let dp8 = [q(dp[0]), q(dp[1]), q(dp[2]), q(dp[3])]; let mut row = Vec::new(); for tc in r.tiles() { @@ -545,68 +637,43 @@ impl Surface { let n = self.channels(); let w = r.width() as usize; let mut out = vec![0.0f32; w * r.height() as usize * n]; - let dp = self.default_pixel(); - for tc in r.tiles() { - let tr = tc.rect().intersect(&r); - let tile = self.tiles.get(&tc); - for y in tr.y0..tr.y1 { - for x in tr.x0..tr.x1 { - let o = (((y - r.y0) as usize) * w + (x - r.x0) as usize) * n; - match tile { - Some(t) => { - let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize) * n; - for i in 0..n { - out[o + i] = read_sample(&t.data, self.format.sample, base + i); - } - } - None => out[o..o + n].copy_from_slice(&dp), - } - } - } - } + self.read_samples_into(r, &mut out); out } - /// Write interleaved normalised floats into a rectangle. + /// Write interleaved normalised floats into a rectangle, on the calling thread. + /// + /// This never enters Rayon, so it is safe inside a `OnceLock` initializer or any other + /// section that must not run another Rayon job on its stack. [`Surface::write_region_par`] + /// writes the same bytes on all cores. pub fn write_region(&mut self, r: Rect, data: &[f32]) { let n = self.channels(); let w = r.width() as usize; assert_eq!(data.len(), w * r.height() as usize * n, "region data length mismatch"); let sample = self.format.sample; for tc in r.tiles() { - let tr = tc.rect().intersect(&r); - let t = self.tile_mut(tc); - // Convert contiguous channel samples a row at a time. Dispatching on depth here - // removes the per-pixel channel loop and lets the compiler vectorise conversion. - let span = tr.width() as usize * n; - for y in tr.y0..tr.y1 { - let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n; - let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; - let src = &data[o..o + span]; - let bytes = sample.bytes(); - let dst = &mut t.data[base * bytes..(base + span) * bytes]; - match sample { - SampleType::U8 => { - for (d, v) in dst.iter_mut().zip(src) { - *d = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; - } - } - SampleType::U16 => { - for (d, v) in dst.as_chunks_mut::<2>().0.iter_mut().zip(src) { - let q = (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16; - d.copy_from_slice(&q.to_ne_bytes()); - } - } - SampleType::F32 => { - for (d, v) in dst.as_chunks_mut::<4>().0.iter_mut().zip(src) { - d.copy_from_slice(&v.to_ne_bytes()); - } - } - } - } + write_tile(self.tile_mut(tc), tc, r, data, w, n, sample); } } + /// [`Surface::write_region`] with the tiles converted on all cores (serially on the web). + /// + /// This enters Rayon: while it waits for its tiles, the calling worker may run any other + /// queued Rayon job. Callers inside a `OnceLock` initializer or another section that must + /// not do that use [`Surface::write_region`]. + pub fn write_region_par(&mut self, r: Rect, data: &[f32]) { + let n = self.channels(); + let w = r.width() as usize; + assert_eq!(data.len(), w * r.height() as usize * n, "region data length mismatch"); + let sample = self.format.sample; + // The tiles are disjoint, so converting them in any order (and on any thread) writes + // exactly the same bytes. `take_tiles` gives the missing tiles as one shared + // default-filled tile, copied per tile by `Arc::make_mut` in `convert_tiles`. + let mut tiles = self.take_tiles(r); + convert_tiles(&mut tiles, r, data, w, n, sample); + self.put_tiles(tiles); + } + /// Take the tiles covering `r` out of the surface (missing ones filled with the default /// pixel), so they can be edited elsewhere, e.g. in parallel: `Arc::unwrap_or_clone` copies a /// tile only if an undo snapshot still shares it. Give them back with [`Surface::put_tiles`]. @@ -763,7 +830,20 @@ impl Surface { let moved = content.translate(dx, dy); let targets: std::collections::BTreeSet = sources().flat_map(|(c, _)| c.rect().translate(dx, dy).intersect(&moved).tiles()).collect(); let mut uniform = UniformTiles::default(); - let zero = vec![0u8; bpp]; + // The fill row: one tile row of the default pixel, so the regions a shift vacates + // fill with bulk copies (empty when the default is zero — the tile starts zeroed). + + // One row of the default pixel, so the regions a shift vacates fill with bulk copies. + let fill_row: Vec = if self.default_pixel.iter().any(|&b| b != 0) { + let mut row = Vec::with_capacity(TILE_SIZE as usize * bpp); + for _ in 0..TILE_SIZE { + row.extend_from_slice(&self.default_pixel); + } + row + } else { + Vec::new() + }; + // One tile row of the default pixel, to compare a finished tile with a row at a time. let default_row = self.default_pixel.repeat(TILE_SIZE.unsigned_abs() as usize); for dc in targets { @@ -783,22 +863,82 @@ impl Surface { } continue; } - // A covered target is overwritten whole, so skip filling it with the default pixel. - let mut tile = Tile::filled(&self.format, if covered { &zero } else { &self.default_pixel }); - for sc in sr.intersect(&content).tiles() { - let Some(st) = self.tiles.get(&sc) else { continue }; - let part = sc.rect().intersect(&sr); - let span = part.width() as usize * bpp; - // Offsets inside the tiles (the target's computed wide: an offset near the i32 - // limits saturates `translate`, and such rows are skipped rather than misplaced). - let at = |x: i64, y: i64| usize::try_from(y * i64::from(TILE_SIZE) + x).ok().map(|i| i * bpp); - for y in part.y0..part.y1 { - let src = at(i64::from(part.x0 - sc.tx * TILE_SIZE), i64::from(y - sc.ty * TILE_SIZE)); - let dst = at(i64::from(part.x0) + i64::from(dx) - i64::from(dr.x0), i64::from(y) + i64::from(dy) - i64::from(dr.y0)); - let (Some(src), Some(dst)) = (src, dst) else { continue }; - if let (Some(d), Some(s)) = (tile.data.get_mut(dst..dst + span), st.data.get(src..src + span)) { - d.copy_from_slice(s); + + let mut tile = Tile { data: vec![0u8; (TILE_SIZE * TILE_SIZE) as usize * bpp].into_boxed_slice() }; + let (tx0, ty0) = (i64::from(dc.tx) * i64::from(TILE_SIZE), i64::from(dc.ty) * i64::from(TILE_SIZE)); + let row_len = TILE_SIZE as usize * bpp; + // Per stored row: copy the source span that falls inside the content in as few + // memcpys as the tile grid allows, and fill the vacated edges from the default + // row — no whole-tile pre-fill, which rewrote nearly every byte twice. + for local_y in 0..i64::from(TILE_SIZE) { + let y = ty0 + local_y; + let Some(row) = tile.data.get_mut(local_y as usize * row_len..(local_y as usize + 1) * row_len) else { continue }; + let in_content_y = y - i64::from(dy) >= i64::from(content.y0) && y - i64::from(dy) < i64::from(content.y1); + // The row's target columns whose SOURCE lies inside the content are copied + // (the copy loop crosses source tiles as needed); the rest stays default — + // exactly the pixels the shift vacated. + let cx0 = (i64::from(content.x0) + i64::from(dx)).max(tx0); + let cx1 = (i64::from(content.x1) + i64::from(dx)).min(tx0 + i64::from(TILE_SIZE)); + if !in_content_y || cx1 <= cx0 { + if !fill_row.is_empty() { + row.copy_from_slice(&fill_row); } + continue; + } + let left = (cx0.max(tx0) - tx0) as usize * bpp; + let copied_end = (cx1.min(tx0 + i64::from(TILE_SIZE)) - tx0) as usize * bpp; + if !fill_row.is_empty() { + if left > 0 { + row[..left].copy_from_slice(&fill_row[..left]); + } + if copied_end < row_len { + row[copied_end..].copy_from_slice(&fill_row[copied_end..]); + } + } + let mut x = cx0.max(tx0); + let x_end = cx1.min(tx0 + i64::from(TILE_SIZE)); + // Rows run over the same one or two source tiles: cache the last lookup. + let mut cached: Option<(i64, i64, &std::sync::Arc)> = None; + while x < x_end { + // The source document position this target column reads, and the source + // tile it lives in (the shift crosses source tiles as x runs). + let (sx, sy) = (x - i64::from(dx), y - i64::from(dy)); + let (tx, ty) = (sx.div_euclid(i64::from(TILE_SIZE)), sy.div_euclid(i64::from(TILE_SIZE))); + // A missing source tile inside the content holds the default pixel, not + // zeros: fill the span from the default row instead of skipping it + // (echelon's white-mask-with-sparse-tiles case). + let st = match cached.take() { + Some((ctx, cty, t)) if ctx == tx && cty == ty => { + cached = Some((ctx, cty, t)); + t + } + _ => match self.tiles.get(&TileCoord::new(tx as i32, ty as i32)) { + Some(t) => { + cached = Some((tx, ty, t)); + t + } + None => { + let span_end = ((tx + 1) * i64::from(TILE_SIZE) + i64::from(dx)).min(x_end).max(x + 1); + if !fill_row.is_empty() { + let at = (x - tx0) as usize * bpp; + let len = (span_end - x) as usize * bpp; + if let Some(d) = row.get_mut(at..at + len) { + d.copy_from_slice(&fill_row[at..at + len]); + } + } + x = span_end; + continue; + } + }, + }; + let span_end = (((tx + 1) * i64::from(TILE_SIZE) + i64::from(dx)).min(x_end)).max(x + 1); + let span = (span_end - x) as usize * bpp; + let src = ((sy - ty * i64::from(TILE_SIZE)) * row_len as i64 + (sx - tx * i64::from(TILE_SIZE)) * bpp as i64) as usize; + let dst = (x - tx0) as usize * bpp; + if let (Some(d), Some(sr)) = (row.get_mut(dst..dst + span), st.data.get(src..src + span)) { + d.copy_from_slice(sr); + } + x = span_end; } } // A target that got only default pixels isn't stored, as `prune` would drop it. Checked @@ -1023,6 +1163,60 @@ pub fn is_hdr(sample: SampleType) -> bool { #[cfg(test)] mod tests { + + /// Manually compare allocating reads with the previous implementation. + #[test] + #[ignore = "manual 24 MP release benchmark"] + fn benchmark_sparse_reads() { + use std::{hint::black_box, time::Instant}; + let rect = Rect::new(0, 0, 6000, 4000); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for default in [0.0, 0.25] { + let mut s = Surface::with_default(PixelFormat::GRAY8.with_sample(depth), &[default]); + s.fill_rect(Rect::new(257, 257, 266, 266), &[0.75]); + assert_eq!(s.read_region(rect), previous_read_region(&s, rect)); + for trial in 0..6 { + let mut paths = [("main", previous_read_region as fn(&Surface, Rect) -> Vec), ("split", Surface::read_region)]; + if trial % 2 == 1 { + paths.reverse(); + } + for (name, path) in paths { + let t = Instant::now(); + let out = black_box(path)(&s, rect); + let elapsed = t.elapsed().as_secs_f64() * 1000.0; + black_box(&out); + println!("sparse,{depth:?}-{default},{trial},{name},{elapsed:.6}"); + } + } + } + } + } + /// Previous allocating read path, retained as the bitwise-equivalence oracle. + fn previous_read_region(surface: &Surface, r: Rect) -> Vec { + let n = surface.channels(); + let w = r.width() as usize; + let mut out = vec![0.0f32; w * r.height() as usize * n]; + let dp = surface.default_pixel(); + for tc in r.tiles() { + let tr = tc.rect().intersect(&r); + let tile = surface.tiles.get(&tc); + for y in tr.y0..tr.y1 { + for x in tr.x0..tr.x1 { + let o = (((y - r.y0) as usize) * w + (x - r.x0) as usize) * n; + match tile { + Some(t) => { + let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize) * n; + for i in 0..n { + out[o + i] = read_sample(&t.data, surface.format.sample, base + i); + } + } + None => out[o..o + n].copy_from_slice(&dp), + } + } + } + } + out + } /// Distinct tile allocations in `s`. fn distinct_tiles(s: &Surface) -> usize { let mut seen: Vec<*const Tile> = s.tiles().map(|(_, t)| Arc::as_ptr(t)).collect(); @@ -1134,19 +1328,52 @@ mod tests { .collect(); let snapshot = actual.clone(); let snapshot_bytes = snapshot.to_interleaved(rect.inflate(1)); + let mut par = actual.clone(); actual.write_region(rect, &data); + par.write_region_par(rect, &data); write_region_reference(&mut expected, rect, &data); assert_eq!(actual, expected, "{fmt:?}, {rect:?}"); + assert_eq!(par, expected, "parallel {fmt:?}, {rect:?}"); assert_eq!(snapshot.to_interleaved(rect.inflate(1)), snapshot_bytes, "COW snapshot changed"); // Repeat against already materialised, unshared tiles too. actual.write_region(rect, &data); + par.write_region_par(rect, &data); assert_eq!(actual, expected); + assert_eq!(par, expected); } } } } } + /// Mask builds call `write_region` inside a `OnceLock` initializer, where entering Rayon can + /// run another tile's job that waits on the same lock. With every global worker held, a write + /// that queued work on Rayon could not finish. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn write_region_does_not_enter_rayon() { + use std::sync::{Barrier, mpsc}; + let workers = rayon::current_num_threads(); + let held = Arc::new(Barrier::new(workers + 1)); + let release = Arc::new(Barrier::new(workers + 1)); + let (h, rel) = (held.clone(), release.clone()); + rayon::spawn_broadcast(move |_| { + h.wait(); + rel.wait(); + }); + held.wait(); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let r = Rect::new(0, 0, 2 * TILE_SIZE, 2 * TILE_SIZE); + let mut s = Surface::new(PixelFormat::RGBA8); + s.write_region(r, &vec![0.5; (r.width() * r.height() * 4) as usize]); + tx.send(()).ok(); + }); + let done = rx.recv_timeout(std::time::Duration::from_secs(5)).is_ok(); + release.wait(); + assert!(done, "write_region waited on the Rayon pool"); + } + #[test] fn from_rgba_into_matches_from_rgba() { for fmt in [PixelFormat::RGBA8, PixelFormat::GRAY8, PixelFormat::GRAYA8, PixelFormat::CMYKA8, PixelFormat::new(ColorMode::Lab, SampleType::F32, true)] { @@ -1156,29 +1383,74 @@ mod tests { } } + /// Sample counts above the stack capacity use writable storage without truncating channels. + #[test] + fn sample_buffer_keeps_stack_fast_path_and_supports_dynamic_channels() { + for channels in [1, 8, 9, 32] { + let mut stack = [0.0; 8]; + let mut heap = Vec::new(); + let samples = sample_buffer(channels, &mut stack, &mut heap); + assert_eq!(samples.len(), channels); + samples.fill(0.25); + if channels <= stack.len() { + assert!(heap.is_empty()); + assert_eq!(&stack[..channels], vec![0.25; channels]); + } else { + assert_eq!(heap, vec![0.25; channels]); + } + } + } + + /// Compare sparse reads and converted accessors with the previous allocating path. #[test] fn zero_alloc_accessors_match_read_region() { - for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F, PixelFormat::GRAYA8, PixelFormat::CMYKA8, PixelFormat::GRAY8] { - let mut s = Surface::with_default(fmt, &vec![0.25; fmt.channels()]); + for (fmt, default) in [ + PixelFormat::RGBA8, + PixelFormat::RGBA16, + PixelFormat::RGBA32F, + PixelFormat::GRAYA8, + PixelFormat::CMYKA8, + PixelFormat::GRAY8, + PixelFormat::GRAY8.with_sample(SampleType::F32), + PixelFormat::new(ColorMode::Lab, SampleType::U16, true), + ] + .into_iter() + .flat_map(|fmt| [0.0, 0.25, -0.0].map(|default| (fmt, default))) + { + let mut s = Surface::with_default(fmt, &vec![default; fmt.channels()]); + let mut stack = [0.0; 8]; + let mut heap = Vec::new(); + for capacity in [0, stack.len()] { + let decoded = s.default_samples(&mut stack[..capacity], &mut heap); + assert!(decoded.iter().map(|v| v.to_bits()).eq(s.default_pixel().into_iter().map(f32::to_bits))); + } let r = Rect::new(-300, -20, 300, 40); for (i, (x, y)) in [(-299, -19), (0, 0), (255, 39), (256, 10), (299, 0)].into_iter().enumerate() { let px: Vec = (0..fmt.channels()).map(|c| ((i * 3 + c) % 5) as f32 / 4.0).collect(); s.write_pixel(x, y, &px); } let region = s.read_region(r); + assert!(region.iter().map(|v| v.to_bits()).eq(previous_read_region(&s, r).into_iter().map(f32::to_bits))); let mut into = vec![9.0; 3]; s.read_region_into(r, &mut into); assert_eq!(region, into); let mut rgba = vec![[0.0; 4]; (r.width() * r.height()) as usize]; s.read_rgba_into(r, &mut rgba); + let mut rgba8 = vec![[0; 4]; rgba.len()]; + s.read_rgba8_into(r, &mut rgba8); let n = fmt.channels(); for (i, p) in region.chunks_exact(n).enumerate() { let want = to_rgba(&fmt, p); + assert_eq!(rgba8[i], want.map(|v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)); for c in 0..4 { assert!((rgba[i][c] - want[c]).abs() < 1e-6, "{fmt:?} px {i}"); } let (x, y) = (r.x0 + (i % r.width() as usize) as i32, r.y0 + (i / r.width() as usize) as i32); - assert_eq!(s.sample_channel(x, y, 0), p[0]); + for c in 0..n { + let bits = s.sample_channel(x, y, c).to_bits(); + assert_eq!(bits, p[c].to_bits()); + assert_eq!(bits, into[i * n + c].to_bits()); + } } assert!(s.has_tiles_in(Rect::new(0, 0, 1, 1))); assert!(!s.has_tiles_in(Rect::new(1000, 1000, 1001, 1001))); @@ -1531,3 +1803,60 @@ mod tests { assert_eq!(s.content_bounds(), Rect::new(-700, 0, 2000, 4001)); } } + +#[cfg(test)] +mod echelon_case { + use super::*; + + #[test] + fn sparse_missing_tiles_read_the_default_after_a_shift() { + let mut s = Surface::with_default(PixelFormat::GRAY8, &[1.0]); + s.fill_rect(Rect::new(0, 0, 10, 10), &[0.0]); + s.fill_rect(Rect::new(600, 0, 610, 10), &[0.0]); + let t = s.translated(5, 0, Rect::new(0, 0, 768, 256)); + assert_eq!(t.pixel(305, 20), vec![1.0]); + } + + /// The rewrite must not change output: the misaligned path is compared against a + /// straightforward reference gather (read each pixel, write it shifted) over randomized + /// formats, default pixels, sparse tiles and shifts that cross tile boundaries. + #[test] + fn randomized_shifts_match_a_reference_gather() { + let mut rng: u64 = 0x1234_5678_9abc_def0; + let mut next = move || { + rng ^= rng << 13; + rng ^= rng >> 7; + rng ^= rng << 17; + rng + }; + for case in 0..200u32 { + let fmt = [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F, PixelFormat::GRAY8, PixelFormat::GRAYA8][(next() % 5) as usize]; + let def: Vec = (0..fmt.channels()).map(|i| if case % 3 == 0 { 0.0 } else { ((i + case as usize) % 7) as f32 / 6.0 }).collect(); + let mut s = Surface::with_default(fmt, &def); + // Sparse content: a few small patches at random positions. + for _ in 0..(1 + next() % 4) { + let x = (next() % 1600) as i32 - 800; + let y = (next() % 1200) as i32 - 600; + let w = 1 + (next() % 90) as i32; + let h = 1 + (next() % 70) as i32; + let v: Vec = (0..fmt.channels()).map(|i| ((i as u64 + next()) % 251) as f32 / 250.0).collect(); + s.fill_rect(Rect::new(x, y, x + w, y + h), &v); + } + let content = Rect::new(-900, -700, 1700, 1300); + let (dx, dy) = ((next() % 1200) as i32 - 600, (next() % 900) as i32 - 450); + let t = s.translated(dx, dy, content); + // Reference: every pixel of the moved extent reads the source at (x-dx, y-dy), + // inside the content, through the surface's default-aware reader. + for _ in 0..64 { + let x = (next() % 1500) as i32 - 750 + dx; + let y = (next() % 1100) as i32 - 550 + dy; + if !content.translate(dx, dy).contains(x, y) { + continue; + } + let want = if content.contains(x - dx, y - dy) { s.pixel(x - dx, y - dy) } else { s.default_pixel() }; + let got = t.pixel(x, y); + assert_eq!(got, want, "case {case} fmt {fmt:?} shift ({dx},{dy}) at ({x},{y})"); + } + } + } +} diff --git a/crates/raw/README.md b/crates/raw/README.md index b8c75ebff6..bb4815a4fc 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -67,7 +67,7 @@ camera colour tables were copied. | Format | Status | |---|---| -| DNG | Uncompressed (8–16 bit, packed or not), Deflate (compression 8, predictors 1 and 2), lossless JPEG and JPEG XL (DNG 1.7, e.g. Samsung Expert RAW; decoded by `jxl-oxide`; not in wasm32 builds, where it is `Unsupported`); strips and tiles; CFA (Bayer) and LinearRaw (three-sample colour, or one-sample monochrome such as Leica Monochrom and film-scanner DNGs, developed as neutral grey with no white balance or colour matrix); LinearizationTable, BlackLevel (+ repeat, DeltaH/V), WhiteLevel, ActiveArea, DefaultCrop, ColorMatrix1/2, CameraCalibration, ForwardMatrix, AnalogBalance, AsShotNeutral / AsShotWhiteXY, BaselineExposure, Orientation, OpcodeList2 GainMap (lens shading) | +| DNG | Uncompressed (8–16 bit, packed or not), Deflate (compression 8, predictors 1 and 2), lossless JPEG, lossy JPEG (DNG 1.4 compression 34892: 8-bit baseline JPEG tiles or strips, gray for CFA, YCbCr-coded RGB for LinearRaw; decoded by `zune-jpeg`, the stored 8-bit values then go through the same LinearizationTable / BlackLevel / WhiteLevel as every other depth) and JPEG XL (DNG 1.7, e.g. Samsung Expert RAW; decoded by `jxl-oxide`; not in wasm32 builds, where it is `Unsupported`); strips and tiles; CFA (Bayer) and LinearRaw (three-sample colour, or one-sample monochrome such as Leica Monochrom and film-scanner DNGs, developed as neutral grey with no white balance or colour matrix); LinearizationTable, BlackLevel (+ repeat, DeltaH/V), WhiteLevel, ActiveArea, DefaultCrop, ColorMatrix1/2, CameraCalibration, ForwardMatrix, AnalogBalance, AsShotNeutral / AsShotWhiteXY, BaselineExposure, Orientation, OpcodeList2 GainMap (lens shading) | | DNG (lossy JPEG, floating point; opcodes other than GainMap; ProfileGainTableMap) | Unsupported / not applied (reported) | | CR2 | Lossless JPEG with slices, borders and as-shot white balance from the maker note, black measured on the masked border. CR2 has no CFA tag and the row phase varies by model, so it is measured from the data (the green diagonal), with a Canon model-ID table as the fallback (see `src/cr2.rs`) | | CR2 sRAW / mRAW | Unsupported | @@ -83,6 +83,12 @@ camera colour tables were copied. | CR3 | Native CRX unsupported; bounded JPEG-track/PRVW preview fallback with CMT1 orientation. I/O labels the result as an 8-bit camera preview | | X-Trans and other periodic non-Bayer CFAs (up to 16×16) | Demosaiced by one pattern-agnostic edge-directed method, whatever `Demosaic` is chosen | +Nikon maker-note compression modes 6/10 select checked little-endian packed 12/14-bit +rows even when TIFF Compression is 34713. The modern lossy `0x44 0x40` curve uses +its smaller encoded domain while preserving sensor ADUs. Valid Nikon CropArea +coordinates take precedence over model fallbacks. HE/HE* and lossy-after-split +remain unsupported. Four pinned CC0 Z6 files cover both bit depths and both paths. + ## Development pipeline 1. Linearization table, per-position black level, scale to the white level. @@ -117,7 +123,8 @@ smallest preview at least 512 pixels long, fits and develops in one go. Camera Raw's tuned import fits at the default exposure and as-shot white balance first, then applies the user's Exposure, Temperature and Tint to that same rendering. The D5100's measured image area is 4928 × 3264 at (8, 8) in a 4992 × 3280 -sensor plane; it removes the border without changing levels, WB or calibration. +sensor plane; the D90's is 4288 × 2848 at (10, 10) in a 4352 × 2868 plane; each +removes the border without changing levels, WB or calibration. Measured image areas preserve an explicit file crop. For a slightly tighter camera JPEG, fitting can align the reference proxy using a bounded diff --git a/crates/raw/src/cameras.rs b/crates/raw/src/cameras.rs index 7439433c3e..84204c6bce 100644 --- a/crates/raw/src/cameras.rs +++ b/crates/raw/src/cameras.rs @@ -127,6 +127,10 @@ const PROFILES: &[Profile] = &[Profile { /// 4992 x 3280 sensor data, giving the camera JPEG's 4928 x 3264 frame. /// * **ILCE-7** (2 files): 12 x 12 pixels in from the left and top of the /// 6048 x 4024 cRAW data (36 and 12 pixels of margin on the right and bottom). +/// * **D90** (1 file): 10 x 10 pixels in from the left and top of the +/// 4352 x 2868 sensor data, giving the camera JPEG's 4288 x 2848 frame +/// (10 pixels of margin on the right and bottom; the right border reads the +/// camera's flat 255 from column 4310 on). struct ImageArea { make: &'static str, model: &'static str, @@ -137,6 +141,7 @@ struct ImageArea { } const IMAGE_AREAS: &[ImageArea] = &[ + ImageArea { make: "NIKON CORPORATION", model: "NIKON D90", size: (4352, 2868), crop: (10, 10, 4288, 2848) }, ImageArea { make: "NIKON CORPORATION", model: "NIKON D5100", size: (4992, 3280), crop: (8, 8, 4928, 3264) }, ImageArea { make: "NIKON CORPORATION", model: "NIKON D800E", size: (7424, 4924), crop: (8, 6, 7360, 4912) }, ImageArea { make: "SONY", model: "ILCE-7", size: (6048, 4024), crop: (12, 12, 6000, 4000) }, @@ -152,6 +157,11 @@ pub(crate) fn apply(s: &mut Sensor) { s.crop = Rect::new(x, y, w, h); } let Some(p) = PROFILES.iter().find(|p| make == p.make && model == p.model && (s.width, s.height) == p.size) else { return }; + // A raw with its own colour calibration (a DNG) keeps it: only the measured image area + // above applies. The measured profile stands in for files that carry no matrix themselves. + if !s.color.calibrations.is_empty() { + return; + } let (x, y, w, h) = p.crop; s.crop = Rect::new(x, y, w, h); s.white = [p.white; 3]; @@ -265,6 +275,36 @@ mod tests { assert_eq!(s.crop, Rect::new(0, 0, 7424, 4924)); } + #[test] + fn d90_image_area_matches_the_camera_jpeg() { + let mut s = nef("NIKON D90", 4352, 2868); + apply(&mut s); + assert_eq!(s.crop, Rect::new(10, 10, 4288, 2848)); + // Only the image area is measured: levels, colour and tone stay untouched. + assert!(s.color.calibrations.is_empty() && s.tone_curve.is_empty()); + assert_eq!(s.white, [16383.0; 3]); + // An explicit crop in the file wins over the measured area. + s.crop = Rect::new(0, 0, 4288, 2848); + apply(&mut s); + assert_eq!(s.crop, Rect::new(0, 0, 4288, 2848)); + } + + #[test] + fn a_file_with_its_own_calibration_keeps_it() { + // A file that carries its own colour calibration (a DNG, or any calibrated raw) keeps + // it: the measured profile must not override it (only the image area would apply). + let mut s = nef("NIKON D4", 4992, 3292); + s.color = ColorInfo { + calibrations: vec![Calibration { temperature: 6504.0, color_matrix: IDENTITY, forward_matrix: None, camera_calibration: IDENTITY }], + ..ColorInfo::default() + }; + apply(&mut s); + assert_eq!(s.color.calibrations.len(), 1); + assert_eq!(s.color.calibrations[0].temperature, 6504.0, "the file's own calibration stands"); + assert!(s.tone_curve.is_empty() && s.black_point == 0.0); + assert_eq!(s.crop, Rect::new(0, 0, 4992, 3292), "the D4 has no measured image area"); + } + #[test] fn other_cameras_and_sizes_are_left_alone() { for (model, w, h) in [("NIKON D4S", 4992, 3292), ("NIKON D4", 2496, 1646), ("NIKON D800", 7424, 4924)] { 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/raw/src/develop.rs b/crates/raw/src/develop.rs index e3ab70b0c7..5d28508750 100644 --- a/crates/raw/src/develop.rs +++ b/crates/raw/src/develop.rs @@ -535,7 +535,8 @@ pub(crate) fn tone(curve: &[[f32; 2]], v: f32) -> f32 { } let i = curve.partition_point(|p| p[0] <= v).clamp(1, curve.len() - 1); let ([x0, y0], [x1, y1]) = (curve[i - 1], curve[i]); - let f = (v.log2() - x0.log2()) / (x1.log2() - x0.log2()); + // Logarithmic between knots; linear from a knot at black, where log₂ has no value. + let f = if x0 > 0.0 { (v.log2() - x0.log2()) / (x1.log2() - x0.log2()) } else { (v - x0) / (x1 - x0) }; y0 + (y1 - y0) * f } diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 0bccf8ffac..0d1a99e796 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -28,7 +28,7 @@ //! Bodies whose full profile is not measured yet can still carry a measured //! image area (the D800E, the ILCE-7). //! -//! Decoded today: DNG (uncompressed, lossless-JPEG and JPEG XL, strips and +//! Decoded today: DNG (uncompressed, deflate, lossless-JPEG, lossy-JPEG and JPEG XL, strips and //! tiles, CFA and LinearRaw), CR2 (lossless JPEG with Canon slices), uncompressed or //! lossless-JPEG TIFF/EP raws (NEF, ARW, PEF… when not vendor-compressed), //! Nikon compressed NEF (lossless and lossy), Sony compressed ARW (cRAW), diff --git a/crates/raw/src/nefc.rs b/crates/raw/src/nefc.rs index 9ca5eff5f9..db1f8b1d74 100644 --- a/crates/raw/src/nefc.rs +++ b/crates/raw/src/nefc.rs @@ -33,7 +33,9 @@ //! * Lossless (`0x0096` version `0x46`): the decoded values are the samples. //! Lossy (`0x44`): they index a curve. Type 1 (`0x44 0x10`) stores the whole //! curve, type 2 (`0x44 0x20` / `0x44 0x40`) stores `n` points spaced -//! `2^bits / (n - 1)` codes apart, linearly interpolated. +//! `2^bits / (n - 1)` codes apart, linearly interpolated. Version `0x44 0x40` +//! uses a two-bit-smaller encoded domain (1025 / 4097 levels for 12 / 14-bit +//! samples), not the sensor bit depth; see Bill Claff's NEF Compression tables. //! * Values in the maker note are in the maker note's byte order. //! //! Not decoded (reported as unsupported; the embedded preview is used @@ -50,6 +52,59 @@ pub(crate) const NIKON_COMPRESSION: u32 = 34713; /// Maker note NEFLinearizationTable. pub(crate) const NIKON_LINEARIZATION_TABLE: u16 = 0x0096; +/// Packed uncompressed NEF modes 6 (12-bit) and 10 (14-bit). Measured on CC0 +/// Z6/Z7/D850 samples: little-endian bit packing, rows optionally padded to 16 +/// bytes. Selection comes from the maker note, never from the strip's length. +pub(crate) fn read_packed(t: &Tiff, ifd: &Ifd, bits: u32, limits: &Limits) -> Result { + let w = t.tag_uint(ifd, tag::IMAGE_WIDTH).ok_or_else(|| RawError::malformed("packed NEF has no width"))? as usize; + let h = t.tag_uint(ifd, tag::IMAGE_LENGTH).ok_or_else(|| RawError::malformed("packed NEF has no height"))? as usize; + if !t.le || t.tag_uint(ifd, tag::BITS_PER_SAMPLE) != Some(bits) || t.tag_uint(ifd, tag::SAMPLES_PER_PIXEL).unwrap_or(1) != 1 { + return Err(RawError::unsupported("packed NEF byte order, bit depth or sample layout")); + } + limits.check(w as u64, h as u64, 2)?; + let offsets = t.tag_uints(ifd, tag::STRIP_OFFSETS); + let counts = t.tag_uints(ifd, tag::STRIP_BYTE_COUNTS); + let ([off], [len]) = (offsets.as_slice(), counts.as_slice()) else { + return Err(RawError::unsupported("packed NEF with multiple strips")); + }; + let src = t.bytes(*off as usize, *len as usize).ok_or_else(|| RawError::malformed("packed NEF strip lies outside the file"))?; + let data = unpack_little_rows(src, w, h, bits)?; + Ok(Plane { width: w, height: h, samples: 1, bits, data }) +} + +fn unpack_little_rows(src: &[u8], w: usize, h: usize, bits: u32) -> Result> { + let bad = || RawError::malformed("packed NEF row layout is invalid or truncated"); + if w == 0 || h == 0 || !matches!(bits, 12 | 14) { + return Err(bad()); + } + let row = w.checked_mul(bits as usize).ok_or_else(bad)?.div_ceil(8); + let padded = row.checked_add(15).ok_or_else(bad)? / 16 * 16; + let stride = if row.checked_mul(h) == Some(src.len()) { + row + } else if padded.checked_mul(h) == Some(src.len()) { + padded + } else { + return Err(bad()); + }; + let count = w.checked_mul(h).ok_or_else(bad)?; + let mut out = Vec::with_capacity(count); + let mask = (1u32 << bits) - 1; + for row in src.chunks_exact(stride) { + let mut bytes = row.iter(); + let (mut acc, mut have) = (0u32, 0u32); + for _ in 0..w { + while have < bits { + acc |= u32::from(*bytes.next().ok_or_else(bad)?) << have; + have += 8; + } + out.push((acc & mask) as u16); + acc >>= bits; + have -= bits; + } + } + Ok(out) +} + /// A code word that never occurred in any analysed sample. const UNSEEN: u8 = u8::MAX; @@ -216,7 +271,10 @@ pub(crate) fn parse_table(t: &[u8], le: bool, bits: u32) -> Result if split != 0 { return Err(RawError::unsupported(format!("Nikon \"lossy after split\" compressed NEF (split at row {split})"))); } - let range = 1usize << bits; + // The modern lossy curve has a smaller *encoded* domain while + // its output remains in sensor ADUs. Using 2^bits stretches the + // curve fourfold and puts Z6 image values below its black level. + let range = 1usize << if v1 == 0x40 { bits - 2 } else { bits }; if n - 1 > range || !range.is_multiple_of(n - 1) { return Err(RawError::unsupported(format!("Nikon compressed NEF: {n}-point curve for {bits}-bit data"))); } @@ -503,6 +561,37 @@ mod tests { assert!(parse_table(&[0x44, 0x20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0], false, 12).is_err()); } + #[test] + fn modern_lossy_curve_preserves_sensor_adus_at_both_depths() { + for bits in [12u32, 14] { + for le in [false, true] { + let put = |v: u16| if le { v.to_le_bytes() } else { v.to_be_bytes() }; + let mut t = vec![0x44, 0x40]; + for _ in 0..4 { + t.extend(put(256)); + } + t.extend(put(257)); + let white = (1u16 << bits) - 1; + for i in 0..=256u32 { + t.extend(put(((i * 64).min(u32::from(white))) as u16)); + } + t.resize(624, 0); + let parsed = parse_table(&t, le, bits).unwrap(); + let Encoding::Lossy(curve) = &parsed.encoding else { panic!() }; + let range = 1usize << (bits - 2); + assert_eq!(curve.len(), range + 1); + assert_eq!(curve[range], white); + // Stored point 16 is 1024 ADU. It belongs to encoded code 256 + // at 14 bits, code 64 at 12 bits, independent of end-point clipping. + assert_eq!(curve[range / 16], 1024.min(white)); + let indices = vec![0, (range / 16) as i32, (range / 8) as i32, (range / 32) as i32]; + let huffman = if bits == 12 { &LOSSY_12 } else { &LOSSY_14 }; + let src = encode(&indices, 2, huffman, parsed.seeds); + assert_eq!(decode(&src, 2, 2, bits, &parsed).unwrap(), vec![0, 1024, 2048, 512]); + } + } + } + /// A 14-bit lossless table (version 0x46) with the given seeds. fn lossless_table(seed: u16, le: bool) -> Vec { let mut t = vec![0x46, 0x30]; diff --git a/crates/raw/src/sensor.rs b/crates/raw/src/sensor.rs index 585191b799..f2cffdb808 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -214,7 +214,20 @@ pub(crate) enum JpegLayout { /// DNG 1.7 JPEG XL compression. const JPEG_XL: u32 = 52546; -/// Reads the (uncompressed, lossless-JPEG or JPEG XL) image of a raw IFD. +/// Inflates one zlib tile into at most `need` bytes. The stream must run to its end: a truncated +/// stream, or one that holds more than `need` bytes, is an error rather than a silent cut-off. +fn inflate_tile(src: &[u8], need: usize) -> Result> { + let mut out = vec![0u8; need]; + let mut d = flate2::Decompress::new(true); + let status = d.decompress(src, &mut out, flate2::FlushDecompress::Finish).map_err(|_| RawError::malformed("deflate tile does not decompress"))?; + if status != flate2::Status::StreamEnd { + return Err(RawError::malformed("deflate tile is truncated or larger than its tile")); + } + out.truncate(usize::try_from(d.total_out()).unwrap_or(need).min(need)); + Ok(out) +} + +/// Reads the (uncompressed, deflate, lossless-JPEG, lossy-JPEG or JPEG XL) image of a raw IFD. pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayout) -> Result { let width = t.tag_uint(ifd, tag::IMAGE_WIDTH).ok_or_else(|| RawError::malformed("raw image has no width"))? as usize; let height = t.tag_uint(ifd, tag::IMAGE_LENGTH).ok_or_else(|| RawError::malformed("raw image has no height"))? as usize; @@ -243,6 +256,7 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou limits.check(width as u64, height as u64, samples as u64 * 2)?; let segments = segments(t, ifd, width, height)?; let le = t.le; + let tiled = t.tag_uint(ifd, tag::TILE_WIDTH).is_some(); let decoded: Vec>> = par::map(segments.len(), |i| { let s = &segments[i]; let rows = s.h.min(height - s.y); @@ -261,10 +275,7 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou let too_big = || RawError::malformed("deflate tile is too large"); let need = s.w.checked_mul(rows).and_then(|n| n.checked_mul(samples)).and_then(|n| n.checked_mul((bits as usize).div_ceil(8))).ok_or_else(too_big)?; - let mut out = vec![0u8; need]; - let mut d = flate2::Decompress::new(true); - d.decompress(src, &mut out, flate2::FlushDecompress::Finish).map_err(|_| RawError::malformed("deflate tile does not decompress"))?; - out.truncate(usize::try_from(d.total_out()).unwrap_or(need).min(need)); + let out = inflate_tile(src, need)?; let v = unpack(&out, s.w, rows, samples, bits, le)?; // The TIFF predictor runs after unpacking, per row, per sample plane. match t.tag_uint(ifd, tag::PREDICTOR).unwrap_or(1) { @@ -276,37 +287,12 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou } // A JPEG XL segment codes only the rows inside the image (strips) or the whole tile. JPEG_XL => { - let coded_rows = if t.tag_uint(ifd, tag::TILE_WIDTH).is_some() { s.h } else { rows }; + let coded_rows = if tiled { s.h } else { rows }; let mut v = jxl::decode(src, s.w, coded_rows, samples, bits, limits)?; v.resize(s.w * s.h * samples, 0); Ok(v) } - 34892 => { - // Adobe DNG compression 34892: baseline JPEG tiles (lossy, 8-bit). The tile is - // a complete JPEG stream: a CFA part decodes to gray samples, a 3-sample - // LinearRaw part holds YCbCr, which zune-jpeg converts to RGB. The header is - // decoded and size-checked before any pixel allocation, so a hostile tile - // cannot size the decoder's buffers (up to 16384x16384) with a fake SOF. - let out = match samples { - 1 => zune_core::colorspace::ColorSpace::Luma, - 3 => zune_core::colorspace::ColorSpace::RGB, - n => return Err(RawError::unsupported(format!("lossy DNG with {n} samples per pixel (1 or 3 are supported)"))), - }; - let options = zune_core::options::DecoderOptions::default().jpeg_set_out_colorspace(out); - let mut dec = zune_jpeg::JpegDecoder::new_with_options(zune_core::bytestream::ZCursor::new(src), options); - dec.decode_headers().map_err(|e| RawError::malformed(format!("lossy JPEG tile header: {e:?}")))?; - let f = dec.info().ok_or_else(|| RawError::malformed("lossy JPEG tile has no header"))?; - if (f.width as usize, f.height as usize) != (s.w, rows) { - return Err(RawError::malformed(format!("lossy JPEG tile is {}x{}, expected {}x{}", f.width, f.height, s.w, rows))); - } - let decoded = dec.decode().map_err(|e| RawError::malformed(format!("lossy JPEG tile: {e:?}")))?; - let need = s.w * rows * samples; - if decoded.len() != need { - return Err(RawError::malformed(format!("lossy JPEG tile holds {} samples, expected {need}", decoded.len()))); - } - // The 8-bit samples widen to the document's 16-bit range (0xFF -> 0xFFFF). - Ok(decoded.into_iter().map(|b| u16::from(b) * 257).collect()) - } + 34892 => lossy_jpeg(src, s, rows, samples, bits, tiled, limits), // Compression 7 (lossless JPEG): the frame the entropy stream declares is capped // by the budget, not only by the segment, and the `Quads` scatter buffer below // comes out of the same budget. `max_alloc` counts bytes, a sample is two. @@ -391,6 +377,54 @@ pub(crate) fn segments(t: &Tiff, ifd: &Ifd, width: usize, height: usize) -> Resu Ok(out) } +/// Decodes one segment of a lossy DNG (Adobe DNG compression 34892): a complete baseline-DCT +/// JPEG stream (ITU-T T.81), 8 bits per sample, as DNG 1.4 defines it. A CFA segment is a +/// one-component (gray) JPEG; a 3-sample LinearRaw segment is a three-component one in YCbCr, +/// which the JPEG decoder turns back into the stored R, G, B. The decoded 8-bit values are the +/// stored sample values: the LinearizationTable, BlackLevel and WhiteLevel apply to them later, +/// as for every other compression, and WhiteLevel defaults to 255 for 8-bit data. +/// +/// `rows` is the number of segment rows inside the image. A tile is always coded at the full +/// tile size (edge tiles are padded); a strip is coded at its row count (the last strip is +/// shorter). Either height is accepted, the width must be the segment's. The frame header is +/// checked before any pixel is decoded, and the decoder's own size cap is the segment, so a +/// hostile SOF cannot size a buffer larger than the tile the IFD declares (which `limits` has +/// already charged). +fn lossy_jpeg(src: &[u8], s: &Segment, rows: usize, samples: usize, bits: u32, tiled: bool, limits: &Limits) -> Result> { + use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + if bits != 8 { + return Err(RawError::unsupported(format!("lossy DNG with {bits}-bit samples (lossy JPEG tiles are 8-bit)"))); + } + let out = match samples { + 1 => ColorSpace::Luma, + 3 => ColorSpace::RGB, + n => return Err(RawError::unsupported(format!("lossy DNG with {n} samples per pixel (1 or 3 are supported)"))), + }; + let options = DecoderOptions::default().jpeg_set_out_colorspace(out).set_max_width(s.w).set_max_height(s.h); + let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(src), options); + dec.decode_headers().map_err(|e| RawError::malformed(format!("lossy JPEG tile header: {e:?}")))?; + let f = dec.info().ok_or_else(|| RawError::malformed("lossy JPEG tile has no header"))?; + let (fw, fh) = (usize::from(f.width), usize::from(f.height)); + let coded_rows = if tiled { s.h } else { rows }; + if fw != s.w || (fh != coded_rows && fh != rows) { + return Err(RawError::malformed(format!("lossy JPEG tile is {fw}x{fh}, expected {}x{coded_rows}", s.w))); + } + if usize::from(f.components) != samples { + return Err(RawError::malformed(format!("lossy JPEG tile has {} components, expected {samples}", f.components))); + } + let need = fw.checked_mul(fh).and_then(|n| n.checked_mul(samples)).ok_or_else(|| RawError::malformed("lossy JPEG tile is too large"))?; + limits.check(fw as u64, fh as u64, samples as u64 * 2)?; + let decoded = dec.decode().map_err(|e| RawError::malformed(format!("lossy JPEG tile: {e:?}")))?; + if decoded.len() != need { + return Err(RawError::malformed(format!("lossy JPEG tile holds {} samples, expected {need}", decoded.len()))); + } + let mut v: Vec = decoded.into_iter().map(u16::from).collect(); + // A strip coded at its own row count still fills the segment's declared height. + let full = s.w.checked_mul(s.h).and_then(|n| n.checked_mul(samples)).ok_or_else(|| RawError::malformed("lossy JPEG tile is too large"))?; + v.resize(full, 0); + Ok(v) +} + /// Reverses TIFF predictor 2 (horizontal differencing) on interleaved samples: each sample /// gains its channel's predecessor in the row, wrapping at the sample's container (8 or 16 bits). fn undelta(v: &[u16], plane: usize, row_samples: usize, rows: usize, bits: u32) -> Vec { @@ -459,6 +493,26 @@ mod tests { assert_eq!(c.phase(0, 0), [1, 0, 2, 1]); // GRBG } + fn zlib(data: &[u8]) -> Vec { + use std::io::Write; + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(data).unwrap(); + e.finish().unwrap() + } + + #[test] + fn inflate_tile_requires_stream_end() { + let data: Vec = (0..64u8).collect(); + let z = zlib(&data); + assert_eq!(inflate_tile(&z, 64).unwrap(), data); + // Shorter than the tile, but a complete stream: still accepted. + assert_eq!(inflate_tile(&z, 100).unwrap(), data); + // Truncated stream. + assert!(inflate_tile(&z[..z.len() - 6], 64).is_err()); + // More decoded output than the tile holds. + assert!(inflate_tile(&z, 32).is_err()); + } + #[test] fn unpack_12_bit() { // Two 12-bit samples 0xABC, 0x123 packed MSB first. diff --git a/crates/raw/src/testgen.rs b/crates/raw/src/testgen.rs index 2de64ae4bb..9d8b8fb94c 100644 --- a/crates/raw/src/testgen.rs +++ b/crates/raw/src/testgen.rs @@ -398,14 +398,30 @@ fn urat(v: f64) -> (u32, u32) { // ---------------------------------------------------------------- DNG +/// Encodes 8-bit samples (1 gray or 3 RGB per pixel, row-major) as a baseline JPEG at +/// quality 60, the way a lossy DNG segment is stored (an RGB segment is coded as YCbCr). +pub fn baseline_jpeg(samples: &[u8], width: usize, height: usize, per_pixel: usize) -> Vec { + use image::ImageEncoder as _; + let colour = if per_pixel == 3 { image::ExtendedColorType::Rgb8 } else { image::ExtendedColorType::L8 }; + let mut j = Vec::new(); + let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut j, 60); + enc.write_image(samples, width as u32, height as u32, colour).expect("jpeg segment"); + j +} + /// How the DNG stores its raw data. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DngStorage { /// One strip per `rows` rows, uncompressed at `bits` per sample (8, 12, 16…). Strips { rows: usize }, - /// Adobe DNG compression 34892: baseline JPEG tiles, 8-bit luma at tile size (the reader - /// decodes and widens to 16 bits). Quality 60 leaves visible-but-mild artifacts. + /// Adobe DNG compression 34892: baseline JPEG tiles at tile size (edge tiles padded), one + /// gray component for CFA and monochrome data, three (YCbCr-coded RGB) for LinearRaw. The + /// spec's `bits` should be 8 (the DNG spec's depth for lossy JPEG; the reader rejects others); + /// wider samples are written as their top 8 bits. Quality 60 leaves visible-but-mild artifacts. LossyJpegTiles { width: usize, height: usize }, + /// Adobe DNG compression 34892 in strips of this many rows: each strip is a baseline JPEG + /// of the image width by the strip's own row count (the last strip is shorter). + LossyJpegStrips { rows: usize }, /// Adobe DNG compression 8: zlib streams with the TIFF predictor (1 = none, 2 = horizontal /// differencing) applied before compressing. DeflateStrips { rows: usize, predictor: u16 }, @@ -438,6 +454,7 @@ pub struct DngSpec { pub big_endian: bool, pub black: Vec, pub black_repeat: (u16, u16), + /// WhiteLevel; 0 leaves the tag out (the reader then defaults to the bit depth's maximum). pub white: u32, /// top, left, bottom, right pub active_area: Option<[u32; 4]>, @@ -459,6 +476,10 @@ pub struct DngSpec { pub opcode_list2: Option>, /// Encoded segments for [`DngStorage::JxlStrips`] / [`DngStorage::JxlTiles`]. pub jxl_segments: Vec>, + /// Pre-encoded segments for [`DngStorage::LossyJpegTiles`] / [`DngStorage::LossyJpegStrips`] + /// (row-major), used instead of encoding `data` where present: for tiles that disagree with + /// the IFD (hostile-input tests). + pub lossy_segments: Vec>, /// Raw ProfileGainTableMap bytes, written to IFD0 (contents are not interpreted). pub profile_gain_table_map: Option>, pub orientation: u16, @@ -498,6 +519,7 @@ impl DngSpec { orientation: 1, opcode_list2: None, jxl_segments: Vec::new(), + lossy_segments: Vec::new(), profile_gain_table_map: None, make: "Photocraft".into(), model: "Synthetic".into(), @@ -515,6 +537,12 @@ impl DngSpec { self.samples == 1 && !self.monochrome } + /// The 8-bit sample a lossy JPEG segment stores for `v`: the value itself at 8 bits, else + /// its top 8 bits. + fn sample8(&self, v: u16) -> u8 { + (v >> self.bits.saturating_sub(8)) as u8 + } + fn pack(&self, rows: std::ops::Range) -> Vec { let n = self.width * self.samples; let vals = &self.data[rows.start * n..rows.end * n]; @@ -554,10 +582,12 @@ impl DngSpec { (262, Val::Short(vec![if self.is_cfa() { 32803 } else { 34892 }])), (277, Val::Short(vec![self.samples as u16])), (284, Val::Short(vec![1])), - (50717, Val::Long(vec![self.white; self.samples])), (50713, Val::Short(vec![self.black_repeat.0, self.black_repeat.1])), (50714, Val::Long(self.black.clone())), ]; + if self.white != 0 { + raw.push((50717, Val::Long(vec![self.white; self.samples]))); + } if self.is_cfa() { raw.push((33421, Val::Short(vec![2, 2]))); raw.push((33422, Val::Byte(self.cfa.to_vec()))); @@ -620,23 +650,18 @@ impl DngSpec { let s = self.samples; for ty in (0..self.height).step_by(th) { for tx in (0..self.width).step_by(tw) { - // Edge tiles are padded by repeating the last row / column. A 3-sample - // (LinearRaw) spec writes RGB tiles, a CFA spec gray ones. + // Edge tiles are padded by repeating the last row / column. let mut tile = vec![0u8; tw * th * s]; for y in 0..th { for x in 0..tw { let sy = (ty + y).min(self.height - 1); let sx = (tx + x).min(self.width - 1); for c in 0..s { - tile[(y * tw + x) * s + c] = (self.data[(sy * self.width + sx) * s + c] >> 8) as u8; + tile[(y * tw + x) * s + c] = self.sample8(self.data[(sy * self.width + sx) * s + c]); } } } - let colour = if s == 3 { image::ExtendedColorType::Rgb8 } else { image::ExtendedColorType::L8 }; - let mut j = Vec::new(); - let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut j, 60); - use image::ImageEncoder as _; - enc.write_image(&tile, tw as u32, th as u32, colour).expect("jpeg tile"); + let j = self.lossy_segments.get(offs.len()).cloned().unwrap_or_else(|| baseline_jpeg(&tile, tw, th, s)); lens.push(j.len() as u32); offs.push(t.blob(j)); } @@ -647,6 +672,22 @@ impl DngSpec { raw.push((324, Val::Blobs(offs))); raw.push((325, Val::Long(lens))); } + DngStorage::LossyJpegStrips { rows } => { + let mut offs = Vec::new(); + let mut lens = Vec::new(); + let n = self.width * self.samples; + for y in (0..self.height).step_by(rows) { + let r = (y + rows).min(self.height) - y; + let strip: Vec = self.data[y * n..(y + r) * n].iter().map(|&v| self.sample8(v)).collect(); + let j = self.lossy_segments.get(offs.len()).cloned().unwrap_or_else(|| baseline_jpeg(&strip, self.width, r, self.samples)); + lens.push(j.len() as u32); + offs.push(t.blob(j)); + } + raw.push((259, Val::Short(vec![34892]))); + raw.push((278, Val::Long(vec![rows as u32]))); + raw.push((273, Val::Blobs(offs))); + raw.push((279, Val::Long(lens))); + } DngStorage::Lj92Strips { rows } => { let mut offs = Vec::new(); let mut lens = Vec::new(); diff --git a/crates/raw/src/tiffep.rs b/crates/raw/src/tiffep.rs index 4a75491f5f..25befbddea 100644 --- a/crates/raw/src/tiffep.rs +++ b/crates/raw/src/tiffep.rs @@ -78,6 +78,18 @@ fn nikon_maker_note<'a>(t: &Tiff<'a>, ifds: &[Ifd]) -> Option<(Tiff<'a>, Ifd)> { Some((inner, ifd)) } +/// ExifTool's public Nikon tag documentation: legacy 0x93, or a u16 at offset +/// 10 of MakerNotes0x51. The latter is used by Z6/Z7, despite TIFF Compression +/// remaining 34713 even for uncompressed sensor data. +fn nikon_compression(nikon: Option<&(Tiff<'_>, Ifd)>) -> Option { + let (t, m) = nikon?; + t.tag_uint(m, 0x0093).or_else(|| { + let b = t.raw(m.get(0x0051)?)?; + let v: [u8; 2] = b.get(10..12)?.try_into().ok()?; + Some(u32::from(if t.le { u16::from_le_bytes(v) } else { u16::from_be_bytes(v) })) + }) +} + /// Four levels listed in R, G, G, B order, placed at their 2×2 CFA positions /// (data parity). pub(crate) fn rggb_by_position(cfa: &Cfa, v: &[f64]) -> Option<[f32; 4]> { @@ -130,7 +142,12 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result nefc::read_packed(t, &raw, 12, limits)?, + Some(10) => nefc::read_packed(t, &raw, 14, limits)?, + Some(13 | 14) => return Err(RawError::unsupported("Nikon High Efficiency / High Efficiency* NEF")), + _ => nefc::read_compressed(t, &raw, nikon.as_ref(), limits)?, + } } else { read_plane(t, &raw, limits, JpegLayout::Quads)? }; @@ -195,6 +212,17 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result= 0.0 { v.round() as usize } else { 0 }; let r = Rect::new(f(*ox), f(*oy), f(*cw), f(*ch)).intersect(&full); diff --git a/crates/raw/src/tone.rs b/crates/raw/src/tone.rs index 80a80bfdd6..1c2730bd2c 100644 --- a/crates/raw/src/tone.rs +++ b/crates/raw/src/tone.rs @@ -710,6 +710,19 @@ mod tests { assert!(Haze::new(w, h, |i| px[i], 0.0).is_none()); } + #[test] + fn retone_lifts_shadows_with_a_curve_from_black() { + // A curve whose first knot is black: its first segment must not divide by log₂ 0. + let (w, h) = (96, 64); + let curve = [[0.0, 0.0], [0.05, 0.12], [0.3, 0.6], [1.0, 1.0]]; + let px: Vec<[f32; 4]> = (0..w * h).map(|i| if i % w < 48 { [0.12, 0.11, 0.1, 1.0] } else { [0.7, 0.63, 0.56, 1.0] }).collect(); + let mut out = px.clone(); + retone(&mut out, w, h, &curve, SHADOWS); + assert!(out.iter().flatten().all(|v| v.is_finite())); + assert!(out[30 * w + 10][0] > px[30 * w + 10][0] + 0.05, "{:?}", out[30 * w + 10]); + assert!((out[30 * w + 80][0] - px[30 * w + 80][0]).abs() < 0.03, "{:?}", out[30 * w + 80]); + } + #[test] fn retone_changes_nothing_at_zero_and_matches_the_develop_stage() { let (w, h) = (300, 120); diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index e13fb920dd..7cc04d2a92 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -1,7 +1,7 @@ //! Synthetic DNG files: every storage layout decodes to the exact sensor //! samples, and development reproduces known colours. -use photocraft_raw::testgen::{DngSpec, DngStorage, Val, mosaic, scene}; +use photocraft_raw::testgen::{DngSpec, DngStorage, Val, baseline_jpeg, mosaic, scene}; use photocraft_raw::*; fn sensor(bytes: &[u8]) -> Sensor { @@ -99,8 +99,8 @@ fn monochrome_linear_raw_develops_to_neutral_grey() { ("LJ92 strips", 14, DngStorage::Lj92Strips { rows: 6 }), ("LJ92 tiles", 16, DngStorage::Lj92Tiles { width: 16, height: 8 }), ("deflate predictor 2", 16, DngStorage::DeflateStrips { rows: 7, predictor: 2 }), - // One whole tile: a partial edge tile is a separate decoder limitation. - ("lossy JPEG tile", 16, DngStorage::LossyJpegTiles { width: 37, height: 23 }), + ("lossy JPEG tiles", 8, DngStorage::LossyJpegTiles { width: 16, height: 8 }), + ("lossy JPEG strips", 8, DngStorage::LossyJpegStrips { rows: 5 }), ]; for (name, bits, storage) in cases { let white = ((1u32 << bits) - 1) as u16; @@ -109,7 +109,7 @@ fn monochrome_linear_raw_develops_to_neutral_grey() { spec.bits = bits; spec.white = u32::from(white); spec.storage = storage; - let lossy = matches!(storage, DngStorage::LossyJpegTiles { .. }); + let lossy = matches!(storage, DngStorage::LossyJpegTiles { .. } | DngStorage::LossyJpegStrips { .. }); let s = sensor(&spec.build()); assert_eq!((s.samples, s.cfa.is_none(), s.is_monochrome()), (1, true, true), "{name}"); if !lossy { @@ -712,87 +712,236 @@ fn profile_gain_table_map_is_reported() { assert!(gain_table(&sensor(&spec.build()))); } -/// Adobe DNG compression 34892 (lossy baseline JPEG, 8-bit luma tiles): decodes to the tile -/// samples widened to 16 bits, within the JPEG quality band (Q60 artifacts are mild but real). +// ---------------------------------------------------------------- lossy DNG (compression 34892) + +/// The largest difference between decoded and source samples, in stored (8-bit) levels. +fn worst_diff(got: &[u16], want: &[u16]) -> u16 { + assert_eq!(got.len(), want.len()); + got.iter().zip(want).map(|(g, w)| g.abs_diff(*w)).max().unwrap_or(0) +} + +/// An 8-bit lossy DNG around `data` (every value below 256): BitsPerSample 8, WhiteLevel 255. +fn lossy_spec(w: usize, h: usize, data: Vec, storage: DngStorage) -> DngSpec { + assert!(data.iter().all(|&v| v <= 255)); + let mut spec = DngSpec::cfa(w, h, data); + spec.bits = 8; + spec.white = 255; + spec.storage = storage; + spec +} + +/// Adobe DNG compression 34892 (DNG 1.4 lossy JPEG): a CFA image whose tiles or strips are +/// baseline JPEGs of 8-bit gray samples. They decode as the stored values (0..255, like any +/// other 8-bit raw: levels and the linearization table apply afterwards), within the JPEG +/// quality band (Q60 artifacts are mild but real), from whole and partial edge tiles and +/// from strips with a short last strip. #[test] -fn lossy_jpeg_tiles_decode() { - let (w, h) = (32, 32); +fn lossy_jpeg_cfa_tiles_and_strips_decode_stored_values() { + let (w, h) = (37, 23); // odd: partial edge tiles and a short last strip // A smooth ramp: baseline JPEG at Q60 keeps it within a few quantisation steps. - let data: Vec = (0..w * h).map(|i| 4096 + (i / w) as u16 * 16).collect(); - let mut spec = DngSpec::cfa(w, h, data.clone()); - spec.storage = DngStorage::LossyJpegTiles { width: 16, height: 16 }; - let s = sensor(&spec.build()); - assert_eq!((s.width, s.height, s.samples), (w, h, 1)); - let mut worst = 0u32; - for (got, want) in s.data.iter().zip(&data) { - // The tile was written 8-bit (>>8), so the quantised expectation is (want>>8)<<8. - let quant = u32::from(*want >> 8) << 8; - worst = worst.max(u32::from(got.abs_diff(quant as u16))); + let data: Vec = (0..w * h).map(|i| ((i % w) * 5 + (i / w) * 2) as u16).collect(); + let cases = [ + ("16x8 tiles", DngStorage::LossyJpegTiles { width: 16, height: 8 }), + ("one tile", DngStorage::LossyJpegTiles { width: 37, height: 23 }), + ("strips of 5", DngStorage::LossyJpegStrips { rows: 5 }), + ("one strip", DngStorage::LossyJpegStrips { rows: 23 }), + ]; + for (name, storage) in cases { + let bytes = lossy_spec(w, h, data.clone(), storage).build(); + assert_eq!(identify(&bytes), Some(RawFormat::Dng), "{name}"); + let s = sensor(&bytes); + assert_eq!((s.width, s.height, s.samples, s.cfa.is_some()), (w, h, 1, true), "{name}"); + assert_eq!(s.white, [255.0; 3], "{name}"); + assert!(s.data.iter().all(|&v| v <= 255), "{name}: a sample above the 8-bit range"); + let worst = worst_diff(&s.data, &data); + assert!(worst <= 4, "{name}: worst deviation from the source: {worst} levels"); + let d = develop_sensor(&s, &DevelopOptions::default()).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!((d.width as usize, d.height as usize, d.rgb.len()), (w, h, w * h * 3), "{name}"); + assert!(d.info.cfa.is_some(), "{name}"); } - assert!(worst <= 4 * 256, "worst deviation from the 8-bit quantisation: {worst}"); +} + +/// A 3-sample LinearRaw lossy DNG (as Adobe writes them: demosaiced, YCbCr-coded JPEG tiles) +/// decodes to the stored R, G, B, each channel at its own level, from tiles and strips. +#[test] +fn lossy_jpeg_linearraw_rgb_tiles_and_strips_decode_stored_values() { + let (w, h) = (37, 23); + // Channel offsets of 64 levels survive JPEG chroma quantisation at Q60; the ramp is smooth. + let data: Vec = (0..w * h * 3).map(|i| ((i / 3 % w) * 2 + i / 3 / w) as u16 + (i % 3) as u16 * 64).collect(); + let cases = [ + ("16x16 tiles", DngStorage::LossyJpegTiles { width: 16, height: 16 }), + ("one tile", DngStorage::LossyJpegTiles { width: 37, height: 23 }), + ("strips of 7", DngStorage::LossyJpegStrips { rows: 7 }), + ]; + for (name, storage) in cases { + let mut spec = lossy_spec(w, h, data.clone(), storage); + spec.samples = 3; + let s = sensor(&spec.build()); + assert_eq!((s.width, s.height, s.samples, s.cfa.is_none()), (w, h, 3, true), "{name}"); + assert!(s.data.iter().all(|&v| v <= 255), "{name}: a sample above the 8-bit range"); + let worst = worst_diff(&s.data, &data); + assert!(worst <= 8, "{name}: worst deviation from the source: {worst} levels"); + // Each channel carries its own level: R and B stay apart everywhere. + for p in 0..w * h { + let (r, b) = (s.data[p * 3], s.data[p * 3 + 2]); + assert!(b >= r + 64 - 8, "{name}: channels collapsed at pixel {p}: R {r}, B {b}"); + } + let d = develop_sensor(&s, &DevelopOptions::default()).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!((d.width as usize, d.height as usize, d.rgb.len()), (w, h, w * h * 3), "{name}"); + } +} + +/// The decoded 8-bit values are the stored samples, so WhiteLevel defaults to 255 when absent, +/// BlackLevel / WhiteLevel scale them and a LinearizationTable maps them, exactly as for an +/// uncompressed 8-bit DNG. Flat images keep the comparison inside the JPEG's DC rounding. +#[test] +fn lossy_jpeg_levels_and_linearization_apply_to_stored_values() { + let (w, h) = (24, 16); + let tiles = DngStorage::LossyJpegTiles { width: 16, height: 8 }; + let flat = |v: u16| vec![v; w * h]; + let grey = |d: &Developed| d.rgb.chunks(3).map(|p| p[0]).collect::>(); + + // No WhiteLevel: 255 is white. A flat 255 develops to full white, a flat 0 to black. + for (v, lo, hi) in [(255u16, 65000u16, 65535u16), (0, 0, 300)] { + let mut spec = DngSpec::monochrome(w, h, flat(v)); + spec.bits = 8; + spec.white = 0; + spec.storage = tiles; + let s = sensor(&spec.build()); + assert_eq!(s.white, [255.0; 3]); + let d = develop_sensor(&s, &DevelopOptions::default()).unwrap(); + assert!(grey(&d).iter().all(|&g| (lo..=hi).contains(&g)), "flat {v}: {:?}", &grey(&d)[..4]); + } + + // BlackLevel 20 / WhiteLevel 220 scale the stored values like the uncompressed path. + let levels = |storage: DngStorage| { + let mut spec = DngSpec::monochrome(w, h, flat(200)); + spec.bits = 8; + spec.black = vec![20]; + spec.white = 220; + spec.storage = storage; + let s = sensor(&spec.build()); + assert_eq!((s.white, s.black.at(0, 0, 0, 1)), ([220.0; 3], 20.0)); + grey(&develop_sensor(&s, &DevelopOptions::default()).unwrap()) + }; + let (lossy, plain) = (levels(tiles), levels(DngStorage::Strips { rows: h })); + assert!(plain[0] > 50_000 && plain[0] < 65_000, "{}", plain[0]); + let worst = worst_diff(&lossy, &plain); + assert!(worst <= 600, "levels: lossy and uncompressed differ by {worst}"); + + // A LinearizationTable indexed by the stored value. It is flat around 200, so the JPEG's + // DC rounding (199 or 200) lands on the same output: the two paths develop identically. + let table: Vec = (0..256u32).map(|v| if (190..=210).contains(&v) { 40_000 } else { (v * 257) as u16 }).collect(); + let linearized = |storage: DngStorage| { + let mut spec = DngSpec::monochrome(w, h, flat(200)); + spec.bits = 8; + spec.white = 0; + spec.linearization = Some(table.clone()); + spec.storage = storage; + let s = sensor(&spec.build()); + assert_eq!(s.linearization.as_deref(), Some(table.as_slice())); + assert_eq!(s.white, [65535.0; 3], "the table's maximum is white"); + grey(&develop_sensor(&s, &DevelopOptions::default()).unwrap()) + }; + let (lossy, plain) = (linearized(tiles), linearized(DngStorage::Strips { rows: h })); + assert!(plain[0] > 40_000 && plain[0] < 60_000, "{}", plain[0]); + assert_eq!(lossy, plain, "linearization: the lossy path differs from the uncompressed one"); } /// A lossy JPEG tile cut mid-stream fails cleanly. #[test] -fn lossy_jpeg_tiles_hostile() { +fn lossy_jpeg_truncated_tile_errors() { let (w, h) = (32, 32); - let data: Vec = (0..w * h).map(|_i| 8192u16).collect(); - let mut spec = DngSpec::cfa(w, h, data); - spec.storage = DngStorage::LossyJpegTiles { width: 32, height: 32 }; - let bytes = spec.build(); + let bytes = lossy_spec(w, h, vec![128; w * h], DngStorage::LossyJpegTiles { width: 32, height: 32 }).build(); let at = bytes.windows(2).position(|w| w == [0xFF, 0xD8]).expect("jpeg SOI") + 2; let cut = &bytes[..at + (bytes.len() - at) / 2]; - assert!(decode(cut, &Limits::default()).is_err(), "a cut JPEG tile must not decode"); + assert!(matches!(decode(cut, &Limits::default()), Err(RawError::Malformed(_))), "a cut JPEG tile must not decode"); } -/// A tile JPEG whose header lies about its size fails the header check, before any pixel -/// decode (a hostile SOF must not be able to size the decoder's buffers first). +/// A tile JPEG whose header disagrees with the tile size fails the header check, before any +/// pixel decode (a hostile SOF must not be able to size the decoder's buffers first). #[test] fn lossy_jpeg_tile_dimension_mismatch_errors() { - use image::ImageEncoder as _; let (w, h) = (32, 32); - let data: Vec = (0..w * h).map(|i| 4096 + (i / w) as u16 * 16).collect(); - let mut spec = DngSpec::cfa(w, h, data); - spec.storage = DngStorage::LossyJpegTiles { width: 32, height: 32 }; - let mut bytes = spec.build(); - // Swap the 32x32 tile JPEG for a 16x16 one, inside the same segment (the trailing bytes - // after its EOI stay; the JPEG decoder stops at EOI). - let small = { - let img = image::GrayImage::from_fn(16, 16, |x, _| image::Luma([(x as u16 * 16) as u8])); - let mut j = Vec::new(); - let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut j, 60); - enc.write_image(img.as_raw(), 16, 16, image::ExtendedColorType::L8).unwrap(); - j - }; - let soi = bytes.windows(3).position(|w| w == [0xFF, 0xD8, 0xFF]).expect("SOI"); - let eoi = soi + bytes[soi..].windows(2).position(|w| w == [0xFF, 0xD9]).expect("EOI") + 2; - bytes.splice(soi..eoi, small); - let err = decode(&bytes, &Limits::default()).unwrap_err().to_string(); + let mut spec = lossy_spec(w, h, vec![100; w * h], DngStorage::LossyJpegTiles { width: 32, height: 32 }); + let small: Vec = (0..16 * 16).map(|i| (i % 16 * 16) as u8).collect(); + spec.lossy_segments = vec![baseline_jpeg(&small, 16, 16, 1)]; + let err = decode(&spec.build(), &Limits::default()).unwrap_err().to_string(); assert!(err.contains("16x16") && err.contains("32x32"), "{err}"); + // A tile coded at the image's row count instead of the tile's is tolerated (a strip-like + // encoder); anything else is not. + let (w, h) = (16, 12); + let mut spec = lossy_spec(w, h, vec![100; w * h], DngStorage::LossyJpegTiles { width: 16, height: 16 }); + spec.lossy_segments = vec![baseline_jpeg(&[100u8; 16 * 12], 16, 12, 1)]; + let s = sensor(&spec.build()); + assert_eq!((s.width, s.height), (w, h)); + assert!(s.data.iter().all(|&v| v.abs_diff(100) <= 2)); + for (tw, th) in [(16, 14), (16, 18), (12, 16), (18, 16)] { + spec.lossy_segments = vec![baseline_jpeg(&vec![100u8; tw * th], tw, th, 1)]; + assert!(matches!(decode(&spec.build(), &Limits::default()), Err(RawError::Malformed(_))), "{tw}x{th}"); + } + // A strip must be coded at its own row count. + let mut spec = lossy_spec(w, h, vec![100; w * h], DngStorage::LossyJpegStrips { rows: 8 }); + spec.lossy_segments = vec![baseline_jpeg(&[100u8; 16 * 8], 16, 8, 1), baseline_jpeg(&[100u8; 16 * 8], 16, 8, 1)]; + assert!(matches!(decode(&spec.build(), &Limits::default()), Err(RawError::Malformed(_)))); } -/// A 3-sample LinearRaw lossy DNG (YCbCr on disk, as real camera files are) decodes to RGB -/// tiles widened to 16 bits, within the JPEG quality band; a LinearizationTable maps the -/// decoded samples in the develop stage, so the sensor sees the raw widened values. +/// A tile with the wrong number of JPEG components (a colour JPEG in a CFA DNG, a gray one in a +/// 3-sample LinearRaw DNG) is rejected rather than silently converted. #[test] -fn lossy_jpeg_rgb_linearraw_decodes() { - let (w, h) = (32, 32); - // Channel offsets of 64 8-bit steps survive JPEG chroma quantisation at Q60. - let data: Vec = (0..w * h * 3).map(|i| 4096 + ((i / (w * 3)) as u16 * 16) + (i % 3) as u16 * 64 * 256).collect(); - let mut spec = DngSpec::cfa(w, h, data.clone()); - spec.samples = 3; +fn lossy_jpeg_component_count_mismatch_errors() { + let (w, h) = (16, 16); + let mut cfa = lossy_spec(w, h, vec![90; w * h], DngStorage::LossyJpegTiles { width: 16, height: 16 }); + cfa.lossy_segments = vec![baseline_jpeg(&vec![90u8; w * h * 3], w, h, 3)]; + let err = decode(&cfa.build(), &Limits::default()).unwrap_err(); + assert!(matches!(&err, RawError::Malformed(m) if m.contains("3 components")), "{err}"); + + let mut rgb = lossy_spec(w, h, vec![90; w * h * 3], DngStorage::LossyJpegTiles { width: 16, height: 16 }); + rgb.samples = 3; + rgb.lossy_segments = vec![baseline_jpeg(&vec![90u8; w * h], w, h, 1)]; + let err = decode(&rgb.build(), &Limits::default()).unwrap_err(); + assert!(matches!(&err, RawError::Malformed(m) if m.contains("1 components")), "{err}"); +} + +/// A SOF claiming a 65535x65535 frame inside a 16x16 tile is refused at the header, under the +/// default limits as well as tight ones: the frame is never allocated. +#[test] +fn lossy_jpeg_oversized_frame_header_errors() { + let (w, h) = (16, 16); + let mut bytes = lossy_spec(w, h, vec![90; w * h], DngStorage::LossyJpegTiles { width: 16, height: 16 }).build(); + let sof = bytes.windows(2).position(|m| m == [0xFF, 0xC0]).expect("SOF0"); + // FF C0 | length (2) | precision (1) | height (2) | width (2) + bytes[sof + 5..sof + 9].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]); + for limits in [Limits::default(), Limits { max_width: 64, max_height: 64, max_pixels: 1 << 12, max_alloc: 1 << 16 }] { + assert!(matches!(decode(&bytes, &limits), Err(RawError::Malformed(_)))); + } + // The IFD cannot grow a tile past the limits either: a tile larger than the budget is + // refused before its JPEG is looked at. + let tight = Limits { max_width: 64, max_height: 64, max_pixels: 1 << 12, max_alloc: 1 << 10 }; + let big = lossy_spec(40, 40, vec![90; 1600], DngStorage::LossyJpegTiles { width: 40, height: 40 }).build(); + assert!(decode(&big, &tight).is_err()); +} + +/// DNG 1.4 stores lossy JPEG at 8 bits per sample; other depths and sample counts are reported +/// as unsupported, not decoded as something else. +#[test] +fn lossy_jpeg_rejects_other_depths_and_sample_counts() { + let (w, h) = (16, 16); + let mut spec = DngSpec::cfa(w, h, vec![4000; w * h]); spec.storage = DngStorage::LossyJpegTiles { width: 16, height: 16 }; - let s = sensor(&spec.build()); - assert_eq!((s.width, s.height, s.samples), (w, h, 3)); - let mut worst = 0u32; - for (got, want) in s.data.iter().zip(&data) { - let quant = u32::from(*want >> 8) << 8; - worst = worst.max(u32::from(got.abs_diff(quant as u16))); + for bits in [12u16, 16] { + spec.bits = bits; + spec.white = (1 << bits) - 1; + let err = decode(&spec.build(), &Limits::default()).unwrap_err(); + assert!(matches!(&err, RawError::Unsupported(m) if m.contains(&format!("{bits}-bit"))), "{err}"); } - assert!(worst <= 8 * 256, "worst deviation from the 8-bit quantisation: {worst}"); - // Each channel carries its own level: R and B stay apart everywhere. - for p in 0..w * h { - let (r, b) = (s.data[p * 3], s.data[p * 3 + 2]); - assert!(r.abs_diff(b) >= 64 * 256 - 8 * 256, "channels collapsed at pixel {p}"); - } + // Four samples per pixel (the IFD's claim; the JPEG itself is three-component). + let mut spec = lossy_spec(w, h, vec![90; w * h * 3], DngStorage::LossyJpegTiles { width: 16, height: 16 }); + spec.samples = 3; + let mut bytes = spec.build(); + let samples_tag = 277u16.to_le_bytes(); + let at = bytes.windows(4).position(|e| e[..2] == samples_tag && e[2..] == 3u16.to_le_bytes()).expect("SamplesPerPixel entry"); + bytes[at + 8] = 4; + let err = decode(&bytes, &Limits::default()).unwrap_err(); + assert!(matches!(&err, RawError::Unsupported(m) if m.contains("4 samples")), "{err}"); } diff --git a/crates/raw/tests/hostile.rs b/crates/raw/tests/hostile.rs index 4b91f2f103..3212d554e1 100644 --- a/crates/raw/tests/hostile.rs +++ b/crates/raw/tests/hostile.rs @@ -49,6 +49,21 @@ fn samples() -> Vec> { out.push(m.build()); m.storage = DngStorage::DeflateStrips { rows: 5, predictor: 2 }; out.push(m.build()); + // Lossy DNG (compression 34892): 8-bit baseline JPEG tiles of a CFA, and strips of LinearRaw RGB. + let mut l = DngSpec::cfa(w, h, cfa.iter().map(|&v| ((u32::from(v) - 64) * 255 / (4000 - 64)) as u16).collect()); + l.bits = 8; + l.white = 255; + l.storage = DngStorage::LossyJpegTiles { width: 16, height: 8 }; + l.active_area = Some([1, 2, 11, 22]); + out.push(l.build()); + let rgb8: Vec = scene(w, h).iter().flat_map(|p| p.map(|v| (v * 255.0) as u16)).collect(); + let mut l = DngSpec::cfa(w, h, rgb8); + l.samples = 3; + l.bits = 8; + l.white = 255; + l.linearization = Some((0..256u32).map(|v| (v * v / 4) as u16).collect()); + l.storage = DngStorage::LossyJpegStrips { rows: 5 }; + out.push(l.build()); let mut data = vec![600u16; 40 * 10]; for (i, v) in data.iter_mut().enumerate() { *v = 600 + (i as u16 * 7) % 3000; diff --git a/crates/raw/tests/nef_packed.rs b/crates/raw/tests/nef_packed.rs new file mode 100644 index 0000000000..f050681857 --- /dev/null +++ b/crates/raw/tests/nef_packed.rs @@ -0,0 +1,86 @@ +use photocraft_raw::testgen::{Val, nef_compressed}; +use photocraft_raw::{Limits, RawError}; + +fn packed(values: &[u16], w: usize, bits: u32, padded: bool) -> Vec { + let mut out = Vec::new(); + for row in values.chunks_exact(w) { + let start = out.len(); + let (mut acc, mut have) = (0u32, 0u32); + for v in row { + acc |= u32::from(*v) << have; + have += bits; + while have >= 8 { + out.push(acc as u8); + acc >>= 8; + have -= 8; + } + } + if have > 0 { + out.push(acc as u8); + } + if padded { + out.resize(start + (out.len() - start).div_ceil(16) * 16, 0xff); + } + } + out +} + +#[test] +fn packed_nef_uses_maker_mode_not_the_shared_tiff_compression() { + let (w, h) = (22, 5); + for (bits, mode) in [(12u32, 6u16), (14, 10)] { + let values: Vec = (0..w * h).map(|i| ((i * 83 + 11) % (1 << bits)) as u16).collect(); + for padded in [false, true] { + for big_note in [false, true] { + let strip = packed(&values, w, bits, padded); + for modern in [false, true] { + let tag = if modern { + let mut record = b"01000400\0\0".to_vec(); + record.extend(if big_note { mode.to_be_bytes() } else { mode.to_le_bytes() }); + (0x51, Val::Undefined(record)) + } else { + (0x93, Val::Short(vec![mode])) + }; + let bytes = nef_compressed(w, h, bits as u16, strip.clone(), [0, 1, 1, 2], big_note, vec![tag]); + let s = photocraft_raw::decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(s.data, values); + } + } + } + } +} + +#[test] +fn packed_nef_rejects_truncation_wrong_depth_and_unknown_modes() { + let strip = packed(&[1000; 18 * 5], 18, 14, true); + for size in 0..strip.len() { + let bytes = nef_compressed(18, 5, 14, strip[..size].to_vec(), [0, 1, 1, 2], false, vec![(0x93, Val::Short(vec![10]))]); + // The unpadded size is another valid layout; all other truncations fail. + if size != (18 * 14usize).div_ceil(8) * 5 { + assert!(photocraft_raw::decode(&bytes, &Limits::default()).is_err()); + } + } + let bytes = nef_compressed(18, 5, 12, strip.clone(), [0, 1, 1, 2], false, vec![(0x93, Val::Short(vec![10]))]); + assert!(matches!(photocraft_raw::decode(&bytes, &Limits::default()), Err(RawError::Unsupported(_)))); + for mode in [3, 4, 9, 13, 14, 65535] { + let bytes = nef_compressed(18, 5, 14, strip.clone(), [0, 1, 1, 2], false, vec![(0x93, Val::Short(vec![mode]))]); + assert!(matches!(photocraft_raw::decode(&bytes, &Limits::default()), Err(RawError::Unsupported(_)))); + } +} + +#[test] +fn nikon_crop_area_uses_sensor_coordinates_and_ignores_invalid_rectangles() { + let strip = packed(&[1000; 22 * 10], 22, 14, true); + for (rect, expected) in [ + ([2, 2, 18, 6], photocraft_raw::Rect::new(2, 2, 18, 6)), + ([2, 2, 22, 10], photocraft_raw::Rect::new(0, 0, 22, 10)), + ([2, 2, 0, 6], photocraft_raw::Rect::new(0, 0, 22, 10)), + ([u32::MAX, 2, 18, 6], photocraft_raw::Rect::new(0, 0, 22, 10)), + ] { + let bytes = nef_compressed(22, 10, 14, strip.clone(), [0, 1, 1, 2], false, vec![(0x93, Val::Short(vec![10])), (0x45, Val::Long(rect.to_vec()))]); + let sensor = photocraft_raw::decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(sensor.crop, expected); + let developed = photocraft_raw::develop_sensor(&sensor, &Default::default()).unwrap(); + assert_eq!((developed.width as usize, developed.height as usize), (expected.width, expected.height)); + } +} diff --git a/crates/raw/tests/raw_corpus.rs b/crates/raw/tests/raw_corpus.rs index d90e06a6c1..afd168a38a 100644 --- a/crates/raw/tests/raw_corpus.rs +++ b/crates/raw/tests/raw_corpus.rs @@ -144,6 +144,41 @@ fn developing_produces_the_crop_in_16bit_rgb() { } } +/// These paired exposures used to decode below their recorded black levels because the +/// version 0x44 0x40 linearization curve was interpolated across the sensor's full bit range. +#[test] +fn modern_lossy_nef_preserves_brightness_at_both_depths() { + for (file, black, white) in [("DSC_0751.NEF", 1008.0, 16383.0), ("DSC_0754.NEF", 252.0, 4095.0)] { + let bytes = std::fs::read(pixls(file)).unwrap(); + let s = photocraft_raw::decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(s.model.as_deref(), Some("NIKON Z 6")); + assert_eq!((s.width, s.height), (6064, 4040)); + assert_eq!(s.black.values, vec![black; 4]); + assert!(s.white.iter().all(|v| (*v - white).abs() < 100.0)); + // The same scene spans deep shadows and bright highlights at either sensor depth. + let max = s.data.iter().copied().max().unwrap(); + assert!(f32::from(max) > white * 0.9, "{file}: maximum {max}"); + let mean = s.data.iter().map(|v| f64::from(*v)).sum::() / s.data.len() as f64; + assert!(mean > f64::from(black) * 1.5, "{file}: mean {mean}"); + let d = photocraft_raw::develop_sensor(&s, &photocraft_raw::DevelopOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (6048, 4024)); + assert!(d.rgb.iter().copied().max().unwrap() > 32768, "{file}: black development"); + } +} + +#[test] +fn packed_nef_opens_full_sensor_and_metadata_crop_at_both_depths() { + for (file, first) in [("DSC_0750.NEF", [3539, 3761, 3640, 3904]), ("DSC_0753.NEF", [866, 932, 881, 948])] { + let bytes = std::fs::read(pixls(file)).unwrap(); + let s = photocraft_raw::decode(&bytes, &Limits::default()).unwrap(); + assert_eq!((s.width, s.height), (6064, 4040)); + assert_eq!(&s.data[..4], &first); + assert_eq!(s.crop, photocraft_raw::Rect::new(8, 8, 6048, 4024)); + let d = photocraft_raw::develop_sensor(&s, &Default::default()).unwrap(); + assert_eq!((d.width, d.height), (6048, 4024)); + } +} + /// The E-5 file is the documented known-unsupported case: later Olympus bodies store packed /// (compressed) sensor data. The error must say so, never panic, and the file still previews. #[test] 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| { 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"] } diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 985f627e90..0c251d1639 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -25,8 +25,10 @@ flate2 = { workspace = true } # line breaking. Native only: the CJK dictionary alone is 3.8 MB of wasm, and the web build must # stay under Cloudflare's 25 MiB per-file cap (packaging/web/package.sh). The web build falls back # to the generic Unicode rules for those scripts. +# Native only: the glyph rasteriser composites and converts its pixel buffers on all cores. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] parley = { version = "0.11", default-features = false, features = ["std", "complex-scripts"] } +rayon = { workspace = true } [build-dependencies] # Deflates the bundled fonts (build.rs); `flate2` above inflates them. diff --git a/crates/text/src/engine_data.rs b/crates/text/src/engine_data.rs index c8115c3e1b..a68d093669 100644 --- a/crates/text/src/engine_data.rs +++ b/crates/text/src/engine_data.rs @@ -46,7 +46,8 @@ impl Value { pub fn as_f64(&self) -> Option { match self { Value::Int(i) => Some(*i as f64), - Value::Real(r) => Some(*r), + // `inf`, `nan` and `1e999` parse as reals but are never a valid style number. + Value::Real(r) if r.is_finite() => Some(*r), _ => None, } } 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/layout.rs b/crates/text/src/layout.rs index 55f9e0a539..b79f4e3ceb 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). @@ -454,7 +456,7 @@ impl Layouter { } pub fn layout(&mut self, fonts: &mut FontDb, t: &TextLayer, dpi: f32) -> TextLayout { - let k = if dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; let runs = t.char_runs(); let paras = t.paragraph_runs(); let vertical = t.orientation == Orientation::Vertical; @@ -586,6 +588,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)); @@ -629,6 +637,11 @@ impl Layouter { for (range, size) in &synthetic_small_caps { b.push(StyleProperty::FontSize(*size), range.clone()); } + // Korean breaks lines at spaces, between words, as Photoshop sets it; Unicode's + // default rules break between any two Hangul syllables, mid-word (#5959). + for range in hangul_runs(&ptext) { + b.push(StyleProperty::WordBreak(parley::WordBreak::KeepAll), range); + } for (&at, &spacing) in tabs.iter().zip(tab_spacing) { b.push(StyleProperty::LetterSpacing(spacing), at..at + 1); } @@ -640,11 +653,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. @@ -773,6 +804,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(), }); } @@ -817,10 +849,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]; } @@ -858,15 +895,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 { @@ -1031,7 +1071,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, @@ -1043,6 +1083,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 } @@ -1375,6 +1420,23 @@ fn first_ascent(line: &parley::Line<'_, RunBrush>) -> Option { best } +/// Hangul (syllables and jamo). +fn is_hangul(c: char) -> bool { + matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7AF}' | '\u{D7B0}'..='\u{D7FF}') +} + +/// The byte ranges of `text` made of Hangul, each as long as it runs. +pub(crate) fn hangul_runs(text: &str) -> Vec> { + let mut runs: Vec> = Vec::new(); + for (i, c) in text.char_indices().filter(|&(_, c)| is_hangul(c)) { + match runs.last_mut() { + Some(r) if r.end == i => r.end = i + c.len_utf8(), + _ => runs.push(i..i + c.len_utf8()), + } + } + runs +} + #[cfg(test)] mod tests { use super::{SmallCapsMode, feature_list, small_caps_mode, variation_list}; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 7909199a6f..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, @@ -92,7 +92,7 @@ impl TextEngine { acc += r.len; at < acc })?; - let em = style_run.style.size_pt * if dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let em = style_run.style.size_pt * if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; if em.is_nan() || em <= 0.0 { return None; } diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 3f8ce72902..8c502c75af 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -105,8 +105,8 @@ fn enum_value(d: &Descriptor, key: &str) -> Option { fn dnum(v: Option<&D>) -> Option { match v { - Some(D::Double(x)) => Some(*x), - Some(D::UnitFloat { value, .. }) => Some(*value), + Some(D::Double(x)) if x.is_finite() => Some(*x), + Some(D::UnitFloat { value, .. }) if value.is_finite() => Some(*value), Some(D::Integer(i)) => Some(f64::from(*i)), _ => None, } @@ -351,16 +351,19 @@ 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"), }); } } - let k = 72.0 / if dpi > 0.0 { dpi } else { 72.0 }; + let k = 72.0 / if dpi.is_finite() && dpi > 0.0 { dpi } else { 72.0 }; if let Some(e) = engine_data(&t.text) { // Without an `Ornt` key, the engine data's writing direction says it (2 = vertical). if enum_value(&t.text, "Ornt").is_none() && e.path(&["EngineDict", "Rendered", "Shapes", "WritingDirection"]).and_then(E::as_f64) == Some(2.0) { @@ -582,6 +585,8 @@ fn lookup<'a>(run: &'a E, base: Option<&'a E>, key: &str) -> Option<&'a E> { pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> CharStyle { let g = |key: &str| lookup(d, base, key); let num = |key: &str| g(key).and_then(E::as_f64); + // An f32 that a huge finite f64 overflowed to infinity is dropped too. + let numk = |key: &str, k: f32| num(key).map(|v| v as f32 * k).filter(|v| v.is_finite()); let flag = |key: &str| g(key).and_then(E::as_bool); let mut s = CharStyle::default(); if let Some(ps) = g("Font").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).and_then(|i| fonts.get(i)) { @@ -591,18 +596,18 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C s.italic = r.italic; s.postscript_name = Some(ps.clone()); } - if let Some(v) = num("FontSize") { - s.size_pt = v as f32 * k; + if let Some(v) = numk("FontSize", k) { + s.size_pt = v; } if flag("AutoLeading") == Some(false) - && let Some(v) = num("Leading") + && let Some(v) = numk("Leading", k) { - s.leading_pt = Some(v as f32 * k); + s.leading_pt = Some(v); } - s.tracking = num("Tracking").unwrap_or(0.0) as f32; - s.baseline_shift_pt = num("BaselineShift").unwrap_or(0.0) as f32 * k; - s.horizontal_scale = num("HorizontalScale").unwrap_or(1.0) as f32; - s.vertical_scale = num("VerticalScale").unwrap_or(1.0) as f32; + s.tracking = numk("Tracking", 1.0).unwrap_or(0.0); + s.baseline_shift_pt = numk("BaselineShift", k).unwrap_or(0.0); + s.horizontal_scale = numk("HorizontalScale", 1.0).unwrap_or(1.0); + s.vertical_scale = numk("VerticalScale", 1.0).unwrap_or(1.0); s.faux_bold = flag("FauxBold").unwrap_or(false); s.faux_italic = flag("FauxItalic").unwrap_or(false); s.underline = flag("Underline").unwrap_or(false); @@ -615,14 +620,17 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C } } s.kerning = if flag("AutoKerning") == Some(false) { Kerning::Off } else { Kerning::Metrics }; - s.kern = num("Kerning").filter(|v| v.is_finite()).unwrap_or(0.0) as f32; + s.kern = numk("Kerning", 1.0).unwrap_or(0.0); s.caps = match g("FontCaps").and_then(E::as_i64) { Some(1) => Caps::SmallCaps, Some(2) => Caps::AllCaps, _ => Caps::Normal, }; if let Some(c) = g("FillColor") { - let v = arr_f(c.get("Values")); + // Positions matter (alpha, then the channels): a non-finite entry voids the colour. + let v: Vec = c.get("Values").and_then(E::as_array).map(|a| a.iter().filter_map(E::as_f64).collect()).unwrap_or_default(); + let n_raw = c.get("Values").and_then(E::as_array).map_or(0, <[E]>::len); + let v = if v.len() == n_raw && v.iter().all(|x| (*x as f32).is_finite()) { v } else { Vec::new() }; let a = v.first().copied().unwrap_or(1.0) as f32; s.color = match v.len() { 5 => Color { mode: ColorMode::Cmyk, c: [v[1] as f32, v[2] as f32, v[3] as f32, v[4] as f32], alpha: a }, @@ -636,6 +644,7 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C pub(crate) fn para_style(base: Option<&E>, d: &E, k: f32) -> ParagraphStyle { let num = |key: &str| lookup(d, base, key).and_then(E::as_f64); + let numk = |key: &str, k: f32| num(key).map(|v| v as f32 * k).filter(|v| v.is_finite()); ParagraphStyle { align: match num("Justification").map(|v| v as i64) { Some(1) => TextAlign::Right, @@ -646,12 +655,12 @@ pub(crate) fn para_style(base: Option<&E>, d: &E, k: f32) -> ParagraphStyle { Some(6) => TextAlign::JustifyAll, _ => TextAlign::Left, }, - first_line_indent_pt: num("FirstLineIndent").unwrap_or(0.0) as f32 * k, - start_indent_pt: num("StartIndent").unwrap_or(0.0) as f32 * k, - end_indent_pt: num("EndIndent").unwrap_or(0.0) as f32 * k, - space_before_pt: num("SpaceBefore").unwrap_or(0.0) as f32 * k, - space_after_pt: num("SpaceAfter").unwrap_or(0.0) as f32 * k, - auto_leading: num("AutoLeading").unwrap_or(1.2) as f32, + first_line_indent_pt: numk("FirstLineIndent", k).unwrap_or(0.0), + start_indent_pt: numk("StartIndent", k).unwrap_or(0.0), + end_indent_pt: numk("EndIndent", k).unwrap_or(0.0), + space_before_pt: numk("SpaceBefore", k).unwrap_or(0.0), + space_after_pt: numk("SpaceAfter", k).unwrap_or(0.0), + auto_leading: numk("AutoLeading", 1.0).unwrap_or(1.2), hyphenate: lookup(d, base, "AutoHyphenate").and_then(E::as_bool).unwrap_or(false), ..Default::default() } @@ -1089,7 +1098,7 @@ pub fn set_text_index(tysh: &[u8], index: i32) -> Option> { /// Updates (or creates) an EngineData tree for `layer`. pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E { - let k = 72.0 / if dpi > 0.0 { dpi } else { 72.0 }; + let k = 72.0 / if dpi.is_finite() && dpi > 0.0 { dpi } else { 72.0 }; let fresh = || E::Dict(vec![("EngineDict".into(), E::dict()), ("ResourceDict".into(), E::dict())]); // The template comes from the PSD: it may not be a dictionary or may lack `EngineDict`. let mut e = match template { @@ -1334,3 +1343,46 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec let bounds = if warped { [l, t, r, b] } else { [0.0; 4] }; write_tysh(&TySh { transform: layer.transform, text, warp, bounds }) } + +#[cfg(test)] +mod nonfinite_tests { + use super::*; + use crate::engine_data::parse; + + #[test] + fn non_finite_style_numbers_fall_back_to_defaults() { + let d = parse(b"<< /FontSize 1e999 /Tracking nan /HorizontalScale inf /Kerning -inf /FillColor << /Values [1.0 nan 0.0 0.0] >> >>").unwrap(); + let s = char_style(None, &d, &[], 1.0); + let dflt = CharStyle::default(); + assert_eq!(s.size_pt, dflt.size_pt); + assert_eq!((s.tracking, s.horizontal_scale, s.kern), (0.0, 1.0, 0.0)); + assert_eq!(s.color, Color::BLACK); + // A finite f64 too big for f32 is dropped as well. + let d = parse(b"<< /FontSize 1e300 /StartIndent inf >> ").unwrap(); + assert_eq!(char_style(None, &d, &[], 1.0).size_pt, dflt.size_pt); + assert_eq!(para_style(None, &d, 1.0).start_indent_pt, 0.0); + let d = parse(b"<< /FontSize 24.0 >>").unwrap(); + 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 = + TextLayer { text: "x".into(), runs: vec![TextRun { len: 1, style: CharStyle { size_pt: 12.0, ..Default::default() } }], ..Default::default() }; + let out = String::from_utf8_lossy(&crate::engine_data::write(&build_engine_data(&layer, None, f32::INFINITY))).into_owned(); + assert!(out.contains("/FontSize 12.0"), "{out}"); + } +} diff --git a/crates/text/src/raster.rs b/crates/text/src/raster.rs index 431d020b23..bca52e38cf 100644 --- a/crates/text/src/raster.rs +++ b/crates/text/src/raster.rs @@ -88,6 +88,10 @@ impl Coverage { } let (dir, (x0, y0), (x1, y1)) = if p0.1 < p1.1 { (1.0f32, p0, p1) } else { (-1.0f32, p1, p0) }; let (x0, y0, x1, y1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); + // A finite f64 beyond f32 range becomes infinite here. + if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) { + return; + } if y1 <= 0.0 || y0 >= h { return; } diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index af29dd10d6..39eef20757 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -12,6 +12,9 @@ use crate::layout::{GlyphOrient, PlacedGlyph, TextLayout}; use crate::raster::{Bounds, Coverage, LineSink, Pen, Xform, rect_to}; use crate::warp::{Placement, Warp}; +#[cfg(not(target_arch = "wasm32"))] +use rayon::prelude::*; + /// Faux-italic slant in degrees (Photoshop-like). pub const FAUX_ITALIC_DEG: f32 = 12.0; /// Faux-bold ring radius as a fraction of the font size (tuned so stem weight stays close to the old summed-coverage look). @@ -276,35 +279,57 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo } let comps = color_in(&format, c); let a = c.alpha.clamp(0.0, 1.0); - for (i, &cv) in cov.iter().enumerate() { - let cv = if antialias == AntiAlias::None { if cv >= 0.5 { 1.0 } else { 0.0 } } else { cv }; + composite(&mut acc, &cov, w, stride, &comps, a, antialias); + } + // Unpremultiply. + unpremultiply(&mut acc, n, stride, format.sample); + surface.write_region_par(rect, &acc); + surface.prune(); + Rendered { surface, rect } +} + +/// Composites one colour's coverage over the premultiplied accumulator, resolving `AntiAlias::None` +/// to 0/1 first. A pixel's maths reads and writes only its own slots, so the rows are done in any +/// order — one per thread — for exactly the same bytes. +fn composite(acc: &mut [f32], cov: &[f32], width: usize, stride: usize, comps: &[f32], a: f32, antialias: AntiAlias) { + let n = comps.len(); + let row = |acc: &mut [f32], cov: &[f32]| { + for (cv, px) in cov.iter().zip(acc.chunks_exact_mut(stride)) { + let cv = if antialias == AntiAlias::None { if *cv >= 0.5 { 1.0 } else { 0.0 } } else { *cv }; let s = cv * a; if s <= 0.0 { continue; } - let px = &mut acc[i * stride..(i + 1) * stride]; let keep = 1.0 - s; for j in 0..n { px[j] = comps[j] * s + px[j] * keep; } px[n] = s + px[n] * keep; } - } - // Unpremultiply. - for px in acc.chunks_exact_mut(stride) { + }; + #[cfg(not(target_arch = "wasm32"))] + acc.par_chunks_mut(width * stride).zip(cov.par_chunks(width)).for_each(|(a, c)| row(a, c)); + #[cfg(target_arch = "wasm32")] + acc.chunks_mut(width * stride).zip(cov.chunks(width)).for_each(|(a, c)| row(a, c)); +} + +/// Divides the premultiplied accumulator back to straight colour. Each pixel stands alone. +fn unpremultiply(acc: &mut [f32], n: usize, stride: usize, sample: SampleType) { + let one = |px: &mut [f32]| { let a = px[n]; if a > 0.0 { for v in &mut px[..n] { let straight = *v / a; // Float text can carry HDR and signed colours. Keep integer output bounded // and retain the old handling of non-finite intermediate values. - *v = if format.sample == SampleType::F32 && straight.is_finite() { straight } else { straight.clamp(0.0, 1.0) }; + *v = if sample == SampleType::F32 && straight.is_finite() { straight } else { straight.clamp(0.0, 1.0) }; } } - } - surface.write_region(rect, &acc); - surface.prune(); - Rendered { surface, rect } + }; + #[cfg(not(target_arch = "wasm32"))] + acc.par_chunks_exact_mut(stride).for_each(one); + #[cfg(target_arch = "wasm32")] + acc.chunks_exact_mut(stride).for_each(one); } /// One element of a glyph outline in document space (see [`outlines`]). diff --git a/crates/text/src/served.rs b/crates/text/src/served.rs index 73c5d7f30b..0198aea97a 100644 --- a/crates/text/src/served.rs +++ b/crates/text/src/served.rs @@ -127,7 +127,7 @@ pub fn language_script(tag: &str) -> Option<&'static str> { /// The Subresource Integrity value (`sha256-`) for a 64-digit hex SHA-256. fn sri(hex: &str) -> Option { - if hex.len() != 64 { + if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } let bytes: Option> = (0..32).map(|i| hex.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect(); @@ -444,4 +444,12 @@ mod tests { assert_eq!(language_script(tag), want, "{tag}"); } } + + #[test] + fn sri_rejects_non_hex_digits() { + let ok = "0".repeat(64); + assert!(sri(&ok).is_some()); + let plus = format!("+f{}", "0".repeat(62)); + assert!(sri(&plus).is_none()); + } } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 13de665ccd..a6c24e5da0 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1,6 +1,6 @@ use photocraft_color::{Color, PixelFormat, SampleType}; use photocraft_doc::TextLayer; -use photocraft_doc::text::{Caps, CharStyle, FontFeature, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape}; +use photocraft_doc::text::{AntiAlias, Caps, CharStyle, FontFeature, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape}; use photocraft_geom::Affine; use crate::{TextEngine, fonts}; @@ -13,6 +13,38 @@ fn styled(text: &str, style: CharStyle) -> TextLayer { TextLayer { text: text.into(), runs: vec![TextRun { len: text.len(), style }], ..Default::default() } } +/// The rasteriser's pixel stages — composing one colour's coverage, unpremultiplying it, and +/// converting the tiles — are per pixel: no pixel reads a slot another pixel wrote. Spreading them +/// over threads therefore has to leave every byte as it was, which a one-thread pool checks. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn parallel_raster_matches_a_single_thread() { + let one = rayon::ThreadPoolBuilder::new().num_threads(1).build().expect("one-thread pool"); + let mut e = TextEngine::new(); + let boxed = TextShape::Box { x: 0.0, y: 0.0, width: 220.0, height: 90.0 }; + let paragraph = TextLayer { + text: "A paragraph box that wraps over several lines and covers many tiles, so the whole raster is exercised.".into(), + font_family: "Inter".into(), + size_pt: 18.0, + shape: boxed, + ..Default::default() + }; + let no_aa = TextLayer { text: "No AA edge".into(), font_family: "Inter".into(), size_pt: 30.0, antialias: AntiAlias::None, ..Default::default() }; + let cases: Vec<(TextLayer, f32, PixelFormat)> = vec![ + (point("Parallel", 48.0), 72.0, PixelFormat::RGBA8), + (styled("Bold italic", CharStyle { size_pt: 40.0, faux_bold: true, faux_italic: true, ..Default::default() }), 300.0, PixelFormat::RGBA8), + (no_aa, 150.0, PixelFormat::RGBA16), + (styled("Wide gamut", CharStyle { size_pt: 36.0, color: Color::rgba(2.5, -0.25, 0.5, 0.4), ..Default::default() }), 96.0, PixelFormat::RGBA32F), + (with_para(paragraph, ParagraphStyle::default()), 144.0, PixelFormat::RGBA8), + ]; + for (layer, dpi, format) in cases { + let (_, spread) = e.render(&layer, dpi, format); + let (_, single) = one.install(|| e.render(&layer, dpi, format)); + assert_eq!(spread.rect, single.rect, "{}", layer.text); + assert_eq!(spread.surface, single.surface, "{}", layer.text); + } +} + #[test] fn raster_preserves_hdr_and_signed_float_text_colors() { let mut e = TextEngine::new(); @@ -272,6 +304,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(); @@ -1452,6 +1522,23 @@ fn japanese_box_text_preserves_wrap_boundaries() { assert_eq!(end, text.text.len()); } +/// #5959: Korean box text broke between Hangul syllables, mid-word ("설 / 정이나"). Lines break at +/// the spaces between words, as Photoshop sets Korean; a word longer than the box still breaks. +#[test] +fn korean_box_text_wraps_between_words() { + let mut engine = TextEngine::new(); + let mut text = point("여기서는 각종 설정이나 캐릭터 제작을 진행합니다.", 12.0); + text.shape = TextShape::Box { x: 0.0, y: 0.0, width: 60.0, height: 1000.0 }; + let layout = engine.layout(&text, 72.0); + assert!(layout.lines.len() > 2, "{}", layout.lines.len()); + let words: Vec<&str> = text.text.split(' ').collect(); + for line in &layout.lines { + let content = text.text.get(line.range.clone()).expect("UTF-8 line boundaries").trim(); + assert!(content.split(' ').all(|w| words.contains(&w)), "a line holds whole words only: {content:?}"); + } + assert_eq!(super::layout::hangul_runs("a 한국어 b"), vec![2..11]); +} + /// Which craft-fonts family drew the glyphs of `l` (matched by the embedded bytes). fn craft_family_of(l: &crate::TextLayout) -> Vec<&'static str> { let mut v = Vec::new(); @@ -2008,3 +2095,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); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index d6a49c0038..e5bf497c81 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true workspace = true [dependencies] +craft-print = { workspace = true } craft-ui = { workspace = true } photocraft-geom = { workspace = true } photocraft-color = { workspace = true } @@ -34,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 } @@ -46,7 +49,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..3ddbc5afab 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, 0); 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/examples/perf_scenarios.rs b/crates/ui-egui/examples/perf_scenarios.rs index 1b71258473..72808cfabc 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), @@ -538,6 +543,67 @@ fn layered_scenarios(b: &mut Bench, sz: &Sizes) { }); } +/// 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()) { + // 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, + 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)?) + }); +} + /// #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. diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 31e4c60301..c6ca4cd605 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 -- \ @@ -54,6 +54,7 @@ fn main() { opts.encode.webp_quality = q; } opts.tga_bits = settings.tga_bits; + opts.encode.dds = settings.dds; photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) })), write: Some(Box::new(|path: &str, bytes: &[u8]| photocraft_format::atomic_write(std::path::Path::new(path), bytes).map_err(|e| e.to_string()))), diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index e0f3713f2e..8b52ba88c5 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -23,6 +23,9 @@ pub struct ActionsUi { pub expanded: Vec, /// Set folders shown collapsed (Photoshop's set disclosure triangles). pub collapsed_sets: Vec, + /// Photoshop's Button Mode: each action is a coloured button, and a click plays it. + #[serde(default)] + pub button_mode: bool, #[serde(skip)] reveal_recording: Option<(usize, usize)>, /// The action whose name is being edited in place (double-click on its name). @@ -237,6 +240,24 @@ fn step_count(steps: usize, disabled: usize) -> String { } } +fn button_fill(color: &str, t: &Tokens) -> Color32 { + // Photoshop's Actions button colours. Empty uses the panel card so an uncoloured action stays readable. + match color { + "red" => Color32::from_rgb(196, 64, 64), + "orange" => Color32::from_rgb(204, 120, 40), + "yellow" => Color32::from_rgb(196, 176, 48), + "green" => Color32::from_rgb(56, 140, 72), + "blue" => Color32::from_rgb(48, 104, 176), + "violet" => Color32::from_rgb(120, 72, 168), + "gray" => t.text_dim, + _ => t.card, + } +} + +fn button_label_color(color: &str, t: &Tokens) -> Color32 { + if color.is_empty() { t.text } else { Color32::from_rgb(20, 20, 20) } +} + fn paint_check(ui: &egui::Ui, rect: Rect, on: bool, t: &Tokens) { let b = Rect::from_center_size(rect.center(), vec2(11.0, 11.0)); ui.painter().rect_stroke(b, 2.0, Stroke::new(1.0, t.text_faint), egui::StrokeKind::Inside); @@ -250,6 +271,14 @@ fn step_label(id: &str, params: &Value) -> String { if id == "actions.play" { let target = params.get("action").map(|v| v.as_str().map(str::to_owned).unwrap_or_else(|| v.to_string())).unwrap_or_default(); format!("{}: {target}", label_of(id)) + } else if id == "layer.select" { + let target = params + .get("name") + .and_then(Value::as_str) + .or_else(|| params.get("layer").and_then(Value::as_str)) + .map(str::to_owned) + .or_else(|| params.get("layer").and_then(Value::as_u64).map(|id| format!("ID {id}"))); + target.map_or_else(|| label_of(id), |target| format!("{} ({target})", label_of(id))) } else if id == "actions.conditional" { // "If hasLayers: FS8-Layers / FS8-No-Layers" let cond = params.get("if").and_then(Value::as_str).unwrap_or("?"); @@ -406,21 +435,29 @@ pub fn insert_stop_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map) { - let Some(r) = fields.get(RESUME) else { return }; +pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map) -> Result<(), String> { + let Some(r) = fields.get(RESUME) else { return Ok(()) }; // The dialog's command may run as a background job; the next step needs its result. if let Some(doc) = app.session.active().map(|d| d.doc.id) && let Some(job) = app.session.job_on(doc) + && let Err(e) = app.session.wait_job(job.id) { - let _ = app.session.wait_job(job.id); + let template = "Action playback failed: {error}"; + app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e.to_string())]); + app.ui.status_error = true; + return Err(e.to_string()); } let params = json!({"action": r.get("action").cloned().unwrap_or(Value::Null), "from": r.get("from").cloned().unwrap_or(Value::Null)}); match play(app, ¶ms) { - Ok(v) => report_play(app, &v), + Ok(v) => { + report_play(app, &v); + Ok(()) + } Err(e) => { let template = "Action playback failed: {error}"; app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e)]); app.ui.status_error = true; + Err(e) } } } @@ -577,6 +614,7 @@ struct Row { disabled: std::collections::BTreeSet, dialogs: std::collections::BTreeSet, set: Option, + color: String, } /// The panel's order: actions outside any set first, then each set folder (in order of first @@ -606,7 +644,14 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .list .iter() .enumerate() - .map(|(i, a)| Row { name: a.name.clone(), steps: visible_steps(app, i), disabled: a.disabled.clone(), dialogs: a.dialogs.clone(), set: a.set.clone() }) + .map(|(i, a)| Row { + name: a.name.clone(), + steps: visible_steps(app, i), + disabled: a.disabled.clone(), + dialogs: a.dialogs.clone(), + set: a.set.clone(), + color: a.color.clone(), + }) .collect(); if app .ui @@ -662,7 +707,7 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let mut shown_set: Option<&str> = None; for i in display_order(&rows) { - let Row { name, steps, disabled, dialogs, set } = &rows[i]; + let Row { name, steps, disabled, dialogs, set, color: swatch } = &rows[i]; // A set folder row before the first of its actions; collapsed folders hide them. if let Some(set) = set.as_deref() && shown_set != Some(set) @@ -711,9 +756,12 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if reveal == Some((i, 0)) { ui.scroll_to_rect(rect, Some(egui::Align::BOTTOM)); } + let button_mode = app.ui.actions.button_mode; let sel = app.ui.actions.selected == Some(i) && app.ui.actions.selected_step.is_none(); resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, name)); - if sel { + if button_mode { + ui.painter().rect_filled(rect, 3.0, button_fill(swatch, &t)); + } else if sel { ui.painter().rect_filled(rect, 0.0, t.row_selected); } else if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.5)); @@ -721,14 +769,21 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if let Some(change) = drag_row(ui, &resp, DragRow { action: i, step: None }, &t) { moved = Some(change); } - let expanded = app.ui.actions.expanded.get(i).copied().unwrap_or(false); - let tri = Rect::from_center_size(pos2(x0 + 12.0, rect.center().y), vec2(8.0, 8.0)); - let pts = - if expanded { vec![tri.left_top(), tri.right_top(), tri.center_bottom()] } else { vec![tri.left_top(), tri.right_center(), tri.left_bottom()] }; - ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE)); - crate::icons::paint(ui, Rect::from_center_size(pos2(x0 + 30.0, rect.center().y), vec2(16.0, 16.0)), "play", 11.0, t.icon); + let expanded = app.ui.actions.expanded.get(i).copied().unwrap_or(false) && !button_mode; + if !button_mode { + let tri = Rect::from_center_size(pos2(x0 + 12.0, rect.center().y), vec2(8.0, 8.0)); + let pts = if expanded { + vec![tri.left_top(), tri.right_top(), tri.center_bottom()] + } else { + vec![tri.left_top(), tri.right_center(), tri.left_bottom()] + }; + ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE)); + crate::icons::paint(ui, Rect::from_center_size(pos2(x0 + 30.0, rect.center().y), vec2(16.0, 16.0)), "play", 11.0, t.icon); + } let recording_this = recording.is_some_and(|(_, r)| r == i); let nsteps = steps.len(); + let name_x = if button_mode { x0 + 8.0 } else { x0 + 44.0 }; + let name_color = if button_mode { button_label_color(swatch, &t) } else { t.text }; let name_rect = if app.ui.actions.renaming.as_ref().is_some_and(|r| r.index == i) { let field = Rect::from_min_max(pos2(x0 + 40.0, rect.top() + 3.0), pos2((rect.right() - 80.0).max(x0 + 120.0), rect.bottom() - 3.0)); if let Some(text) = rename_field(app, ui, i, field) { @@ -736,45 +791,81 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } field } else { - ui.painter().text(pos2(x0 + 44.0, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text) + ui.painter().text(pos2(name_x, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), name_color) }; - ui.painter().text( - pos2(rect.right() - 8.0, rect.center().y), - Align2::RIGHT_CENTER, - app.session - .prefs() - .shortcuts - .get(&shortcut_id(name)) - .filter(|s| !s.is_empty()) - .map_or_else(|| step_count(nsteps, disabled.len()), |key| format!("{key} · {}", step_count(nsteps, disabled.len()))), - egui::FontId::proportional(11.0), - t.text_faint, - ); + if !button_mode { + ui.painter().text( + pos2(rect.right() - 8.0, rect.center().y), + Align2::RIGHT_CENTER, + app.session + .prefs() + .shortcuts + .get(&shortcut_id(name)) + .filter(|s| !s.is_empty()) + .map_or_else(|| step_count(nsteps, disabled.len()), |key| format!("{key} · {}", step_count(nsteps, disabled.len()))), + egui::FontId::proportional(11.0), + t.text_faint, + ); + } if recording_this { ui.painter().circle_filled(pos2(rect.right() - 64.0, rect.center().y), 4.0, Color32::from_rgb(230, 60, 60)); } if resp.clicked() { app.ui.actions.selected = Some(i); app.ui.actions.selected_step = None; - if resp.interact_pointer_pos().is_some_and(|p| p.x < x0 + 20.0) { + if button_mode { + if !resp.double_clicked() { + play_idx = Some(i); + } + } else if resp.interact_pointer_pos().is_some_and(|p| p.x < x0 + 20.0) { if app.ui.actions.expanded.len() <= i { app.ui.actions.expanded.resize(i + 1, false); } app.ui.actions.expanded[i] = !expanded; } } - resp.context_menu(|ui| { + // Clicks inside stay open, so Shift and Command can both be ticked; every item that + // finishes closes the menu itself. + egui::Popup::context_menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { ui.label(tl!("Keyboard Shortcuts")); let current = app.session.prefs().shortcuts.get(&shortcut_id(name)).cloned().unwrap_or_default(); - for key in std::iter::once(String::new()).chain((1..=12).map(|n| format!("F{n}"))) { - let label = if key.is_empty() { tl!("None").to_string() } else { key.clone() }; - if ui.selectable_label(current == key, label).clicked() { - let _ = assign_shortcut(app, name, &key); + // Photoshop's Action Options: a function key, with Shift and Command (#7646). + let (key, mut shift, mut command) = photocraft_engine::atn_cmds::parse_function_key(¤t).unwrap_or((0, false, false)); + for n in std::iter::once(0).chain(1..=12) { + let label = if n == 0 { tl!("None").to_string() } else { format!("F{n}") }; + if ui.selectable_label(key == n, label).clicked() { + let chosen = if n == 0 { String::new() } else { photocraft_engine::atn_cmds::function_key_name(n, shift, command) }; + let _ = assign_shortcut(app, name, &chosen); ui.close(); } } + ui.add_enabled_ui(key > 0, |ui| { + let command_label = if cfg!(target_os = "macos") { tl!("Command") } else { tl!("Ctrl") }; + let toggled = ui.checkbox(&mut shift, tl!("Shift")).changed() | ui.checkbox(&mut command, command_label).changed(); + if toggled { + let _ = assign_shortcut(app, name, &photocraft_engine::atn_cmds::function_key_name(key, shift, command)); + } + }); + ui.separator(); + ui.menu_button(tl!("Color"), |ui| { + for (id, label) in [ + ("", tl!("None")), + ("red", tl!("Red")), + ("orange", tl!("Orange")), + ("yellow", tl!("Yellow")), + ("green", tl!("Green")), + ("blue", tl!("Blue")), + ("violet", tl!("Violet")), + ("gray", tl!("Gray")), + ] { + if ui.selectable_label(swatch == id, label).clicked() { + let _ = app.run("actions.setColor", json!({"action": i, "color": id})); + ui.close(); + } + } + }); }); - if resp.double_clicked() { + if resp.double_clicked() && !button_mode { // Photoshop renames in place when the name itself is double-clicked. if resp.interact_pointer_pos().is_some_and(|p| name_rect.expand2(vec2(4.0, 13.0)).contains(p)) { app.ui.actions.renaming = Some(RenameAction { index: i, text: name.clone(), focused: false }); @@ -894,6 +985,12 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { delete_selection(app); } }); + let mode_label = if app.ui.actions.button_mode { tl!("Button Mode On") } else { tl!("Button Mode") }; + let mode_button = ui.add_enabled(!recording_now, egui::Button::new(mode_label).small()).on_hover_text(tl!("Button Mode")); + mode_button.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, !recording_now, mode_label)); + if mode_button.clicked() { + app.ui.actions.button_mode = !app.ui.actions.button_mode; + } }); if let Some(i) = play_idx { match app.run("actions.play", json!({"action": i})) { @@ -911,6 +1008,26 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + #[test] + fn selected_layer_name_is_visible_during_recording_and_after_stop() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":4,"height":4})).unwrap(); + app.run("layer.setProps", json!({"name":"Background"})).unwrap(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.run("actions.record", json!({"name":"Select photo"})).unwrap(); + app.ui.actions.expanded = vec![true]; + app.run("layer.select", json!({"layer":layer.0})).unwrap(); + let mut h = Harness::builder().with_size(vec2(400.0, 260.0)).build_ui_state(|ui, app| panel(app, ui), app); + h.run_steps(4); + h.get_by_label("Select Layer (Background)"); + h.state_mut().run("actions.stop", json!({})).unwrap(); + h.run_steps(4); + h.get_by_label("Select Layer (Background)"); + assert_eq!(step_label("layer.select", &json!({"layer":26})), "Select Layer (ID 26)"); + assert_eq!(step_label("layer.select", &json!({"layer":"Background"})), "Select Layer (Background)"); + } + #[test] fn actions_list_grows_with_panel_and_keeps_footer_visible() { use egui_kittest::{Harness, kittest::Queryable}; @@ -929,6 +1046,44 @@ mod tests { assert!(crate::dock::Group::History.scrolls_itself(1)); } + /// #7646: an action's function key can take Shift and Command, as in Photoshop's Action + /// Options, and only that combination plays it. + #[test] + fn a_function_key_with_shift_and_command_plays_its_action() { + use egui::{Event, Key, Modifiers}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.session.actions.list.push(Action { + name: "Action 1".into(), + steps: vec![("layer.new.layer".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); + let key = photocraft_engine::atn_cmds::function_key_name(2, true, true); + assert_eq!(key, "Cmd+Shift+F2"); + assign_shortcut(&mut app, "Action 1", &key).unwrap(); + let ctx = egui::Context::default(); + let press = |app: &mut PhotocraftApp, modifiers| { + ctx.begin_pass(egui::RawInput { + events: vec![ + Event::ModifiersChanged(modifiers), + Event::Key { key: Key::F2, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::F2, physical_key: None, pressed: false, repeat: false, modifiers }, + ], + ..Default::default() + }); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); + }; + let layers = |app: &PhotocraftApp| app.session.active().unwrap().doc.layers.len(); + let before = layers(&app); + press(&mut app, Modifiers::NONE); + press(&mut app, Modifiers::SHIFT); + assert_eq!(layers(&app), before, "F2 and Shift+F2 alone don't play it"); + press(&mut app, Modifiers::SHIFT | Modifiers::COMMAND); + assert_eq!(layers(&app), before + 1, "Cmd+Shift+F2 plays it"); + } + #[test] fn double_clicking_an_action_name_renames_it_in_place() { use egui_kittest::{Harness, kittest::Queryable}; @@ -1290,6 +1445,81 @@ mod tests { assert_eq!(app.session.active().unwrap().path.as_deref(), Some("out/result.psd")); } + /// An app with the real importer/exporter and writer whose Save dialogs answer `answers`. + fn disk_app(answers: Vec>) -> PhotocraftApp { + let services = crate::Services { + import: Some(Box::new(|name, bytes, depth| { + photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + })), + export: Some(Box::new(|doc, path, _| { + photocraft_io::export(doc, path, &Default::default()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) + })), + write: Some(Box::new(|path, bytes| std::fs::write(path, bytes).map_err(|e| e.to_string()))), + file_dialog: Some(crate::file_dialog::fake(answers).0), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + app.background_jobs = false; + app + } + + /// Records Invert + File › Save on the file `a`, then plays it on the file `b` (given a + /// new layer first when `layered`). Returns the played result and whether `b` / `a` + /// changed on disk. + fn record_save_on_a_play_on_b(ext: &str, layered: bool) -> (Value, bool, bool) { + use crate::file_dialog::FileDialogAnswer::SaveTo; + let dir = std::env::temp_dir().join(format!("photocraft-rec-save-{ext}-{layered}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = |n: &str| dir.join(format!("{n}.{ext}")).to_string_lossy().into_owned(); + let (a, b) = (path("a"), path("b")); + // The dialog confirms the suggested name, which is the document's own file. + let mut app = disk_app(vec![Some(SaveTo(a.clone())), Some(SaveTo(a.clone()))]); + let ctx = egui::Context::default(); + for p in [&a, &b] { + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + app.save_as(Some(p.clone())).unwrap(); + } + let (before_a, before_b) = (std::fs::read(&a).unwrap(), std::fs::read(&b).unwrap()); + app.open_path(&a).unwrap(); + app.run("actions.record", json!({})).unwrap(); + app.run("image.adjustments.invert", json!({})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + app.run("actions.stop", json!({})).unwrap(); + std::fs::write(&a, &before_a).unwrap(); + app.open_path(&b).unwrap(); + if layered { + app.run("layer.new.layer", json!({})).unwrap(); + } + let played = app.run("actions.play", json!({"action": 0})).unwrap(); + let r = (played, std::fs::read(&b).unwrap() != before_b, std::fs::read(&a).unwrap() != before_a); + let _ = std::fs::remove_dir_all(&dir); + r + } + + #[test] + fn recorded_save_overwrites_the_layered_file_the_action_is_played_on() { + let (played, b_changed, a_changed) = record_save_on_a_play_on_b("psd", false); + assert!(played.get("failed").is_none(), "{played}"); + assert!(b_changed && !a_changed); + } + + #[test] + fn recorded_save_overwrites_the_flat_file_the_action_is_played_on() { + let (played, b_changed, a_changed) = record_save_on_a_play_on_b("png", false); + assert!(played.get("failed").is_none(), "{played}"); + assert!(b_changed, "Save did not overwrite the file the action was played on"); + assert!(!a_changed, "Save wrote the file the action was recorded on"); + } + + #[test] + fn recorded_save_never_flattens_layers_over_a_flat_file() { + // The played-on document has layers its PNG can't keep: the step fails, nothing is written (#3806). + let (played, b_changed, a_changed) = record_save_on_a_play_on_b("png", true); + assert!(played.get("failed").is_some(), "{played}"); + assert!(!b_changed && !a_changed); + } + #[test] fn set_folders_group_actions_and_collapse() { use egui_kittest::{Harness, kittest::Queryable}; @@ -1371,6 +1601,43 @@ mod tests { assert_eq!(app.session.actions.list[0].steps.len(), 3); } + #[test] + fn dialog_worker_failure_stops_action_before_following_steps() { + for (background, locked) in [(true, true), (false, true), (true, false)] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 6, "background": "#203040"})).unwrap(); + let mut action = Action::new( + "Worker Result", + vec![ + ("layer.setProps".into(), json!({"name": "Prefix", "locks": {"pixels": locked}})), + ("filter.blur.gaussianBlur".into(), json!({"radius": 1.5})), + ("layer.new.layer".into(), json!({"name": "Suffix"})), + ], + ); + action.dialogs.insert(1); + app.session.actions.list.push(action); + app.background_jobs = background; + let paused = play(&mut app, &json!({"action": "Worker Result"})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + let result = crate::dialogs::confirm(&mut app, dialog); + let suffix = app.session.active().unwrap().doc.walk().iter().any(|(_, _, layer)| layer.name == "Suffix"); + + assert_eq!(suffix, !locked, "background={background}, locked={locked}: {result:?}"); + if locked && background { + assert!(result.as_ref().is_err_and(|e| e.contains("locked")), "failed worker must fail the dialog confirmation: {result:?}"); + assert!(app.ui.status_error, "the action must report the worker failure"); + assert!(app.ui.status.contains("locked"), "{}", app.ui.status); + assert_eq!(app.session.jobs_with_recent()[0].state, "failed"); + } else if locked { + assert!(result.is_err(), "the foreground command rejects the locked layer"); + } else { + assert!(result.is_ok(), "the successful worker lets the action continue: {result:?}"); + } + assert_eq!(app.session.actions.playing, 0); + assert!(app.session.actions.playback_stack.is_empty()); + } + } + #[test] fn a_dialog_toggled_step_pauses_playback_and_resumes_on_ok() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -1419,4 +1686,27 @@ mod tests { assert_eq!(v["ran"], 3, "{v}"); assert!(v.get("paused").is_none()); } + + #[test] + fn button_mode_plays_the_clicked_action_and_hides_its_steps() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + app.session.actions.list.push(Action::new("Print", vec![("layer.new.layer".into(), json!({}))])); + app.session.actions.list[0].color = "blue".into(); + app.ui.actions.expanded = vec![true]; + app.ui.actions.button_mode = true; + let mut h = Harness::builder().with_size(vec2(420.0, 280.0)).build_ui_state(|ui, app| panel(app, ui), app); + h.run_steps(3); + assert_eq!(h.query_all_by_role(egui::accesskit::Role::CheckBox).count(), 0, "button mode hides the step list"); + let before = h.state().session.active().unwrap().doc.layers.len(); + h.get_by_label("Print").click(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), before + 1, "a click plays the action"); + h.get_by_label("Button Mode On").click(); + h.run_steps(2); + assert!(!h.state().ui.actions.button_mode); + assert!(h.query_by_label("Button Mode").is_some()); + assert_eq!(h.query_all_by_role(egui::accesskit::Role::CheckBox).count(), 1, "the step list returns when Button Mode is turned off"); + } } diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index 5f43348507..fe483caef9 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -79,6 +79,101 @@ pub fn sample_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { true } +/// Whether the top modal is the Curves dialog. +pub fn curves_on_top(app: &PhotocraftApp) -> bool { + app.ui.dialogs.last().is_some_and(|d| d.fields.get("__adjust").and_then(Value::as_str) == Some("curves")) +} + +/// ⌘/Ctrl-click on the image with Curves on top (#7325): a point on the curve shown in the dialog +/// at the clicked pixel's tone, at the output the curve gives there rounded to a whole level, so the +/// image barely changes (where the curve is clipped at 0 or 255 the point re-shapes it, as a click on +/// the graph there does); with `every_channel` (Shift) one on each colour channel instead, at that +/// channel's value. +/// The pixel comes from the same merged composite as the eyedroppers. Returns true when Curves is +/// on top: the click is the dialog's even when nothing is added (pencil curve, Lab or CMYK, a mask +/// or saved channel target, outside the image, a full curve). +pub fn add_point_at(app: &mut PhotocraftApp, ctx: &egui::Context, x: f64, y: f64, every_channel: bool) -> bool { + if !curves_on_top(app) { + return false; + } + let Some((id, mut values)) = app.ui.dialogs.last().map(|d| (d.id, crate::filter_dialog::params_of(&d.fields))) else { return false }; + if values.get("pencil").and_then(Value::as_bool) == Some(true) { + return true; + } + let Some(mode) = app.session.active().map(|s| s.doc.mode) else { return true }; + if !matches!(mode, photocraft_doc::ColorMode::Rgb | photocraft_doc::ColorMode::Grayscale) { + app.ui.status = tl!("Unavailable in this color mode").into(); + return true; + } + // A mask, saved channel or Quick Mask holds its own gray levels, not the composite's. + let isolated = isolated(app); + if isolated.as_ref().is_some_and(|c| c.gray.is_some()) { + return true; + } + let Some(sample) = crate::canvas::composite_color(app, x, y) else { return true }; + let gray = adjust_editors::is_gray(mode); + let (shown, shown_key) = match &isolated { + Some(c) => (0, c.key), + None => { + let ch: usize = ctx.data(|d| d.get_temp(dialog_mem("curves").with("curves-ch"))).unwrap_or(0); + let chans = adjust_editors::tone_channels(adjust_editors::space_of(&values), gray); + (ch, chans.get(ch).map_or("", |c| c.key)) + } + }; + let keys: &[&str] = if every_channel && !gray && isolated.is_none() { &["red", "green", "blue"] } else { &[shown_key] }; + let mut selected = None; + let mut changed = false; + for &key in keys { + let level = match key { + "red" => sample[0], + "green" => sample[1], + "blue" => sample[2], + // The composite's tone: Gray's mean, as the Grayscale eyedroppers read it, and RGB's + // Rec. 709 luma, as the Neutral Gray eyedropper does (`adjust_params::rgb_luma`). + _ if gray => (sample[0] + sample[1] + sample[2]) / 3.0, + _ => sample[0] * 0.2126 + sample[1] * 0.7152 + sample[2] * 0.0722, + }; + let input = (level.clamp(0.0, 1.0) * 255.0).round(); + let field = if key.is_empty() { "points" } else { key }; + let mut pts = adjust_editors::read_curve(&values, field); + let curve: Vec<_> = pts.iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] / 255.0, output: q[1] / 255.0 }).collect(); + let lut = photocraft_compose::adjust::curve_lut(&curve); + let at = (input / 255.0 * lut.len().saturating_sub(1) as f32).round() as usize; + let Some(output) = lut.get(at).map(|o| (o * 255.0).round()) else { continue }; + let before = pts.len(); + let Some(i) = crate::point_curve::insert(&mut pts, [input, output]) else { continue }; + if pts.len() > before { + // Whole levels, as the editor writes a point clicked on the graph. + values[field] = json!(pts.iter().map(|q| [q[0].round(), q[1].round()]).collect::>()); + changed = true; + } + if key == shown_key { + selected = Some(i); + } + } + if let Some(i) = selected { + let state = dialog_mem("curves").with("curves"); + ctx.data_mut(|d| { + let mut st: crate::state::CurvesEditorState = d.get_temp(state).unwrap_or_default(); + st.channel = shown; + st.gesture = crate::state::PointCurveState { selected: Some(i), drag: None }; + d.insert_temp(state, st); + }); + } + if changed + && let Some(dialog) = app.ui.dialog_mut(id) + && let Value::Object(v) = values + { + dialog.fields.retain(|k, _| k.starts_with("__")); + dialog.fields.extend(v); + if picker(dialog.fields.get(CURVE_PICKER)).is_some() { + let base = crate::filter_dialog::params_of(&dialog.fields); + dialog.fields.insert(CURVE_PICKER_BASE.into(), base); + } + } + true +} + pub(crate) fn picker_button(ui: &mut egui::Ui, selected: bool, enabled: bool, tooltip: &str, color: egui::Color32) -> egui::Response { let response = ui.add_enabled_ui(enabled, |ui| crate::icons::button(ui, "pipette", 28.0, selected, tooltip)).inner; let tokens = crate::theme::Tokens::get(ui.ctx()); @@ -146,7 +241,7 @@ pub fn reset(app: &PhotocraftApp, ctx: &egui::Context, fields: &mut Map Option { let kind = command.strip_prefix(PREFIX).filter(|k| adjust_editors::has_editor(k))?; let spec = photocraft_engine::commands::find(command)?; - let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode); + let mode = curve_mode(app, kind); let defaults = photocraft_engine::adjust_params::default_for(kind, mode).ok()?; let mut fields = Map::new(); fields.insert("__command".into(), json!(command)); @@ -184,19 +279,45 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { Some(app.ui.open_dialog(crate::state::DialogKind::Command, fields)) } +/// The Curves dialog's single-channel target: the Channels panel's, plus the Layers panel's mask +/// selection — both ways of selecting a mask must give the same dropdown (#5335). +fn isolated(app: &PhotocraftApp) -> Option { + let mask = app.layer_mask_targeted(); + app.session.active().and_then(|st| photocraft_engine::channel_cmds::isolated_curve_channel_for(st, mask)) +} + +/// The colour mode the Levels/Curves dialog seeds and parses in: a mask, saved channel or +/// Quick Mask holds gray levels whatever the document's mode, so its curve is a neutral one +/// and the fields carry no Lab or CMYK records (#5335). +fn curve_mode(app: &PhotocraftApp, kind: &str) -> photocraft_doc::ColorMode { + let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode); + if kind == "curves" && isolated(app).is_some_and(|c| c.gray.is_some()) { photocraft_doc::ColorMode::Grayscale } else { mode } +} + /// Dialog body: the kind's editor over the fields, then the Preview checkbox. pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map) { let kind = fields.get("__adjust").and_then(Value::as_str).unwrap_or_default().to_string(); - let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode); + let mode = curve_mode(app, &kind); if matches!(kind.as_str(), "levels" | "curves") { picker_controls(ui, fields, mode); ui.add_space(4.0); } let mut values = crate::filter_dialog::params_of(fields); let active = app.session.active().and_then(|s| s.active_layer); - let gray = app.session.active().is_some_and(|s| adjust_editors::is_gray(s.doc.mode)); + let isolated_curve = isolated(app); + // Only Curves gets an isolated single-channel target here; Levels keeps its usual fields. + let gray_plane = (kind == "curves").then(|| isolated_curve.as_ref().and_then(|c| c.gray)).flatten(); + let gray = gray_plane.is_some() || app.session.active().is_some_and(|s| adjust_editors::is_gray(s.doc.mode)); let hist = match active { - Some(id) if adjust_editors::needs_histogram(&kind) => Some(tone::histograms(app, HistSource::Layer(id), adjust_editors::space_of(&values))), + Some(id) if adjust_editors::needs_histogram(&kind) => { + // A mask or saved channel's curve edits that plane's own levels: the histogram + // describes it, not the colour layers beneath (#5335). + let source = match gray_plane { + Some(surface) => HistSource::Channel { layer: id, surface }, + None => HistSource::Layer(id), + }; + Some(tone::histograms(app, source, adjust_editors::space_of(&values))) + } _ => None, }; let cx = EditorCx { @@ -207,6 +328,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map(dialog_mem("curves").with("curves-graph"))).unwrap(); + assert!(window.right() - graph.right() < 40.0, "blank right column: window {window:?}, graph {graph:?}"); + assert!(graph.width() > 270.0, "keep the graph usable: {graph:?}"); + assert!(window.contains_rect(graph)); + for label in ["Channel:", "Input:", "Output:", "Preview", "OK", "Cancel"] { + assert!(window.contains_rect(h.get_by_label(crate::i18n::t(label)).rect()), "{label} inside the window"); + } + } + } + } + } + #[test] fn cancel_leaves_the_document_untouched() { let mut harness = @@ -716,6 +862,139 @@ mod tests { } } + fn pointer_down(app: &mut PhotocraftApp, ctx: &egui::Context, x: f64, y: f64, modifiers: Value) { + let (request, _receiver) = + crate::control::ControlRequest::new("ui.pointer", json!({"events": [{"kind": "down", "x": x, "y": y}], "modifiers": modifiers})); + let _ = crate::control::handle(app, ctx, &request); + } + + /// The Curves curve `pts` (0-255 levels) as a 256-entry 0-255 map. + fn curve_levels(pts: &[[f64; 2]]) -> Vec { + let cp: Vec<_> = pts.iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] as f32 / 255.0, output: q[1] as f32 / 255.0 }).collect(); + let lut = photocraft_compose::adjust::curve_lut(&cp); + (0..256).map(|k| lut[(k as f32 / 255.0 * (lut.len() - 1) as f32).round() as usize] * 255.0).collect() + } + + /// #7325: ⌘/Ctrl-click on the image puts a point on the shown curve at the pixel's tone + /// (#b04020: Rec. 709 luma 85.5 → level 86) and at the curve's own output there, so the + /// image does not change; the new point is the selected one, and the document waits for OK. + #[test] + fn command_click_on_the_image_adds_a_point_on_the_composite_curve() { + let mut app = app_with_image(); + let original = app.session.active().unwrap().doc.clone(); + let steps = app.session.active().unwrap().history.past_len(); + let id = open(&mut app, "image.adjustments.curves").unwrap(); + let base = [[0.0, 0.0], [128.0, 200.0], [255.0, 255.0]]; + app.ui.dialog_mut(id).unwrap().fields.insert("points".into(), json!(base)); + let ctx = egui::Context::default(); + let sample = crate::canvas::composite_color(&mut app, 8.0, 8.0).unwrap(); + let level = ((sample[0] * 0.2126 + sample[1] * 0.7152 + sample[2] * 0.0722) * 255.0).round(); + assert_eq!(level, 86.0); + let output = f64::from(curve_levels(&base)[86].round()); + assert_ne!(output, 86.0, "the base curve is not the identity at the sampled level"); + + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true})); + let points = channel_points(&app, "points"); + assert_eq!(points, vec![base[0], [86.0, output], base[1], base[2]]); + let (before, after) = (curve_levels(&base), curve_levels(&points)); + assert!(before.iter().zip(&after).all(|(a, b)| (a - b).abs() <= 0.5), "the curve keeps its shape:\n{before:?}\n{after:?}"); + let st: crate::state::CurvesEditorState = ctx.data(|d| d.get_temp(dialog_mem("curves").with("curves"))).unwrap(); + assert_eq!((st.channel, st.gesture.selected), (0, Some(1)), "the new point is selected"); + for key in ["red", "green", "blue"] { + assert_eq!(channel_points(&app, key), vec![[0.0, 0.0], [255.0, 255.0]], "{key} untouched"); + } + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &original)); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + } + + /// #7325: Shift+⌘-click puts a point on each colour channel at that channel's own value + /// (176, 64, 32), and leaves the composite curve alone. + #[test] + fn shift_command_click_adds_a_point_on_each_colour_channel() { + let mut app = app_with_image(); + open(&mut app, "image.adjustments.curves").unwrap(); + let ctx = egui::Context::default(); + let composite = channel_points(&app, "points"); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true, "shift": true})); + for (key, level) in [("red", 176.0), ("green", 64.0), ("blue", 32.0)] { + assert_eq!(channel_points(&app, key), vec![[0.0, 0.0], [level, level], [255.0, 255.0]], "{key}"); + } + assert_eq!(channel_points(&app, "points"), composite); + } + + /// #7325: with Red shown in the Channel popup the point goes on the red curve, at red's value. + #[test] + fn command_click_adds_the_point_on_the_channel_shown_in_the_dialog() { + let mut app = app_with_image(); + open(&mut app, "image.adjustments.curves").unwrap(); + let ctx = egui::Context::default(); + ctx.data_mut(|d| d.insert_temp(dialog_mem("curves").with("curves-ch"), 1usize)); + let composite = channel_points(&app, "points"); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true})); + assert_eq!(channel_points(&app, "red"), vec![[0.0, 0.0], [176.0, 176.0], [255.0, 255.0]]); + for key in ["green", "blue"] { + assert_eq!(channel_points(&app, key), vec![[0.0, 0.0], [255.0, 255.0]], "{key} untouched"); + } + assert_eq!(channel_points(&app, "points"), composite); + let st: crate::state::CurvesEditorState = ctx.data(|d| d.get_temp(dialog_mem("curves").with("curves"))).unwrap(); + assert_eq!((st.channel, st.gesture.selected), (1, Some(1))); + } + + /// #7325: a plain click, a full curve and an armed eyedropper add nothing. + #[test] + fn command_click_adds_nothing_without_the_modifier_on_a_full_curve_or_with_an_eyedropper() { + let mut app = app_with_image(); + let id = open(&mut app, "image.adjustments.curves").unwrap(); + let ctx = egui::Context::default(); + let before = crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({})); + assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields), before, "no modifier, no point"); + // Space and the middle button pan, as with the mouse. + for extra in [json!({"modifiers": {"command": true, "space": true}}), json!({"command": true, "button": "middle"})] { + let mut params = json!({"events": [{"kind": "down", "x": 8, "y": 8}]}); + params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + let (request, _receiver) = crate::control::ControlRequest::new("ui.pointer", params); + let _ = crate::control::handle(&mut app, &ctx, &request); + assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields), before, "{extra}"); + } + + // Sixteen points (the most a curve holds), none within reach of level 86. + let full: Vec<[f64; 2]> = + [0, 10, 20, 30, 40, 50, 60, 70, 80, 100, 120, 140, 160, 180, 200, 255].iter().map(|&i| [f64::from(i), f64::from(i)]).collect(); + app.ui.dialog_mut(id).unwrap().fields.insert("points".into(), json!(full)); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true})); + assert_eq!(channel_points(&app, "points"), full, "a full curve takes no more points and moves none"); + + let mut app = app_with_image(); + let id = open(&mut app, "image.adjustments.curves").unwrap(); + arm(&mut app, id, "black"); + pointer_down(&mut app, &ctx, 8.0, 8.0, json!({"command": true})); + assert_eq!(channel_points(&app, "points").len(), 2, "the armed eyedropper samples instead"); + assert_eq!(channel_points(&app, "red")[0], [176.0, 0.0]); + } + + /// #7325: Lab and CMYK curves and a targeted mask are left to a later change: nothing is added. + #[test] + fn command_click_adds_nothing_in_lab_or_on_a_targeted_mask() { + let ctx = egui::Context::default(); + let mut lab = app_in_mode("lab"); + lab.run("edit.fill", json!({"color": "#b04020"})).unwrap(); + let id = open(&mut lab, "image.adjustments.curves").unwrap(); + let before = crate::filter_dialog::params_of(&lab.ui.dialog_mut(id).unwrap().fields); + assert!(add_point_at(&mut lab, &ctx, 4.0, 4.0, false)); + assert_eq!(crate::filter_dialog::params_of(&lab.ui.dialog_mut(id).unwrap().fields), before); + + let mut masked = app_with_image(); + masked.run("layer.layerMask.revealAll", json!({})).unwrap(); + masked.run("channel.target", json!({"channel": "mask"})).unwrap(); + let id = open(&mut masked, "image.adjustments.curves").unwrap(); + let before = crate::filter_dialog::params_of(&masked.ui.dialog_mut(id).unwrap().fields); + assert!(add_point_at(&mut masked, &ctx, 8.0, 8.0, false)); + assert_eq!(crate::filter_dialog::params_of(&masked.ui.dialog_mut(id).unwrap().fields), before); + } + #[test] fn ui_pointer_drives_an_armed_curves_picker() { let mut app = app_with_image(); @@ -738,4 +1017,87 @@ mod tests { assert_eq!(channel_points(&app, "red"), red, "pointer up does not sample again"); assert!(picker_armed(&app)); } + + /// #5335: a targeted mask, saved channel or colour component is the only Channel popup entry. + #[test] + fn curves_channel_popup_lists_only_the_targeted_channel() { + let mut app = app_with_image(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("channel.target", json!({"channel": "mask"})).unwrap(); + let mut h = curves_harness(app); + let names = popup_channels(&mut h); + assert!(names.iter().any(|n| n.contains("Mask")), "{names:?}"); + assert!(!names.iter().any(|n| n == "RGB" || n == "Red" || n == "Green" || n == "Blue"), "{names:?}"); + } + + /// #5335: selecting the mask in the Layers panel gives the same Curves dropdown as selecting + /// it in the Channels panel. + #[test] + fn curves_channel_popup_lists_only_the_mask_selected_in_the_layers_panel() { + let mut app = app_with_image(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.ui.mask_target = true; // clicking the mask thumbnail in the Layers panel + assert!(app.layer_mask_targeted(), "the Layers panel mask thumbnail is targeted"); + let mut h = curves_harness(app); + let names = popup_channels(&mut h); + assert_eq!(names.len(), 1, "only the mask: {names:?}"); + assert!(names[0].contains("Mask"), "{names:?}"); + } + + /// #5335: in a Lab document, Curves over a gray target keeps neutral fields: no Lab records + /// to overwrite the mask's own curve or pick the wrong tone space. + #[test] + fn curves_on_a_gray_target_seeds_neutral_fields_in_a_lab_document() { + for via_layers_panel in [false, true] { + let mut app = app_in_mode("lab"); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + if via_layers_panel { + app.ui.mask_target = true; + } else { + app.run("channel.target", json!({"channel": "mask"})).unwrap(); + } + let id = open(&mut app, "image.adjustments.curves").expect("opens"); + let values = crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields.clone()); + assert!(values.get("points").is_some(), "{values:?}"); + for key in ["lightness", "a", "b", "cyan", "magenta", "yellow", "black"] { + assert!(values.get(key).is_none(), "{key} in a gray target's curves: {values:?}"); + } + } + } + + /// #5335: in Italian the built-in labels translate, but a channel the user named "Green" + /// stays exactly "Green". + #[test] + fn curves_channel_popup_keeps_custom_names_verbatim_in_italian() { + let it = crate::i18n::Lang::from_code("it").expect("it registered"); + assert_eq!(crate::i18n::tr(it, "Green"), "Verde", "the test relies on the built-in label"); + let _lang = crate::i18n::language_scope(it); + let mut app = app_with_image(); + app.run("channel.new", json!({"name": "Green", "fill": "white"})).unwrap(); + app.run("channel.target", json!({"channel": 0})).unwrap(); + let mut h = curves_harness(app); + let names = popup_channels(&mut h); + assert!(names.iter().any(|n| n == "Green"), "custom names stay as entered: {names:?}"); + assert!(!names.iter().any(|n| n == "Verde"), "not translated: {names:?}"); + // A targeted colour component keeps its translated built-in label. + let mut colour = app_with_image(); + colour.run("channel.target", json!({"channel": {"color": 1}})).unwrap(); + let mut h = curves_harness(colour); + let names = popup_channels(&mut h); + assert!(names.iter().any(|n| n == "Verde"), "built-in labels follow the language: {names:?}"); + } + + /// Curves open in a harness over `app`. + fn curves_harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + open(h.state_mut(), "image.adjustments.curves").unwrap(); + h.run_steps(4); + h + } + + /// The Channel popup's selected value (its option labels are what it shows). + fn popup_channels(h: &mut Harness<'static, PhotocraftApp>) -> Vec { + h.query_all_by_role(egui::accesskit::Role::ComboBox).filter_map(|n| n.value().map(|s| s.to_string())).collect() + } } diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index 2678cc65f2..0c4d5ae268 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -92,6 +92,8 @@ pub struct EditorCx { /// Preferences ▸ Interface ▸ Dynamic Color Sliders: colour tracks on the colour sliders /// (off: the plain track). pub dynamic_color_sliders: bool, + /// Image › Curves with a single Channels-panel target: that channel only (#5335). + pub isolated_curve: Option, } /// The Levels/Curves channel space the values address. @@ -214,7 +216,7 @@ fn sliders(ui: &mut egui::Ui, v: &mut Value, rows: &[SliderRow]) -> Edit { for &(key, text, min, max, default, unit, decimals) in rows { let mut x = num(v, key, default).clamp(min, max); // A double-click on the slider puts it back to its default (Photoshop). - let gestures = widgets::RowGestures { reset: Some(default), wheel_step: None }; + let gestures = widgets::RowGestures { reset: Some(default), wheel_step: None, ..Default::default() }; let r = widgets::slider_row_prec(ui, text, &mut x, min..=max, unit, None, gestures, decimals); if r.changed() { v[key] = json!(row_value(x, min, max, decimals)); @@ -291,14 +293,26 @@ pub fn tone_channels(space: ToneSpace, gray: bool) -> &'static [ToneChannel] { } fn channel_picker(ui: &mut egui::Ui, id: egui::Id, chans: &[ToneChannel], text: &str) -> usize { + let opts: Vec<(usize, &str)> = chans.iter().enumerate().map(|(i, c)| (i, c.label)).collect(); + channel_picker_opts(ui, id, &opts, text, true) +} + +/// The popup, with `translate` for whether the built-in labels follow the interface language; +/// a user-typed channel name is shown exactly as entered (#5335). +fn channel_picker_opts(ui: &mut egui::Ui, id: egui::Id, opts: &[(usize, &str)], text: &str, translate: bool) -> usize { let mut ch: usize = ui.data(|d| d.get_temp(id)).unwrap_or(0); - if ch >= chans.len() { + if ch >= opts.len() { ch = 0; } ui.horizontal(|ui| { label(ui, text); - let opts: Vec<(usize, &str)> = chans.iter().enumerate().map(|(i, c)| (i, c.label)).collect(); - widgets::dropdown(ui, &format!("{id:?}-ch"), &mut ch, &opts, 110.0); + let width = if opts.len() == 1 { 160.0 } else { 110.0 }; + let key = format!("{id:?}-ch"); + if translate { + widgets::dropdown(ui, &key, &mut ch, opts, width); + } else { + widgets::dropdown_literal(ui, &key, &mut ch, opts, width); + } }); ui.data_mut(|d| d.insert_temp(id, ch)); ch @@ -348,7 +362,7 @@ fn curve_graph(full: Rect, side: f32) -> Rect { Rect::from_min_max(base.min + vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING), base.max - vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING)) } -fn read_curve(v: &Value, key: &str) -> Vec<[f32; 2]> { +pub(crate) fn read_curve(v: &Value, key: &str) -> Vec<[f32; 2]> { let pts: Vec<[f32; 2]> = v .get(key) .and_then(Value::as_array) @@ -428,11 +442,20 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let chans = tone_channels(space, cx.gray); let state_id = cx.mem.with("curves"); let mut st: CurveUi = ui.data(|d| d.get_temp(state_id)).unwrap_or_default(); - let ch = channel_picker(ui, cx.mem.with("curves-ch"), chans, tl!("Channel:")); + let isolated = cx.isolated_curve.as_ref(); + let ch = if let Some(iso) = isolated { + channel_picker_opts(ui, cx.mem.with("curves-ch"), &[(0, iso.name.as_str())], tl!("Channel:"), iso.translate) + } else { + channel_picker(ui, cx.mem.with("curves-ch"), chans, tl!("Channel:")) + }; if ch != st.channel { st = CurveUi { channel: ch, ..Default::default() }; } - let chan = chans.get(ch).copied().unwrap_or(ToneChannel { key: "", label: "" }); + let chan = + if let Some(iso) = isolated { ToneChannel { key: iso.key, label: "" } } else { chans.get(ch).copied().unwrap_or(ToneChannel { key: "", label: "" }) }; + let overlay_others = isolated.is_none(); + let tinted = isolated.is_some_and(|c| c.tint) || (isolated.is_none() && ch != 0); + let hist_i = isolated.map_or(ch, |c| c.hist); let key = curve_key(&chan); let mut e = curves_mode_row(ui, v, key, cx.gray); let pencil = v.get("pencil").and_then(Value::as_bool).unwrap_or(false); @@ -477,7 +500,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let p = ui.painter_at(full.expand(2.0)); p.rect_filled(graph, 0.0, t.field); if let Some(h) = &cx.hist { - tone::draw_histogram(&p, graph, &h[ch], Color32::from_gray(if t.pro { 88 } else { 190 }).gamma_multiply(0.75)); + tone::draw_histogram(&p, graph, &h[hist_i], Color32::from_gray(if t.pro { 88 } else { 190 }).gamma_multiply(0.75)); } for i in 1..4 { let f = i as f32 / 4.0; @@ -510,7 +533,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let line: Vec = map.iter().enumerate().map(|(k, o)| to_scr([k as f32 / last * 255.0, *o])).collect(); p.add(egui::Shape::line(line, Stroke::new(width, color))); }; - if ch == 0 { + if overlay_others && ch == 0 { // Photoshop overlays the edited channel curves on the composite. for c in chans.iter().skip(1) { if pencil { @@ -526,7 +549,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { } } } - let color = if ch == 0 { t.text } else { bar_color(&chan, &t) }; + let color = if tinted { bar_color(&chan, &t) } else { t.text }; if pencil { draw_map(&map, color, 1.5); } else { @@ -1072,7 +1095,7 @@ fn color_balance(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { ]; // Photoshop: a double-click on a slider zeroes it (this tone only); the dialog's sliders step // with the wheel; Up/Down step the fields; Shift makes those steps 10. - let gestures = widgets::RowGestures { reset: Some(0.0), wheel_step: cx.dialog.then_some(1.0) }; + let gestures = widgets::RowGestures { reset: Some(0.0), wheel_step: cx.dialog.then_some(1.0), ..Default::default() }; let mut changed = false; for (i, (text, a, b)) in rows.iter().enumerate() { let r = Edit::of(&widgets::slider_row_with(ui, text, &mut vals[i], -100.0..=100.0, "", Some(&[*a, *b]), gestures)); @@ -1623,6 +1646,7 @@ pub fn layer_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj gradients: gradient_presets(app, kind), dialog: false, dynamic_color_sliders: app.session.prefs().interface.dynamic_color_sliders, + isolated_curve: None, }; if matches!(kind, "levels" | "curves") { crate::layer_picker::controls(app, ui, id); @@ -1676,6 +1700,7 @@ mod tests { gradients: Vec::new(), dialog: false, dynamic_color_sliders: dynamic, + isolated_curve: None, }; let stops = [Color32::BLACK, Color32::RED]; assert_eq!(track_stops(&cx(true), &stops), Some(&stops[..])); diff --git a/crates/ui-egui/src/adjust_editors_tests.rs b/crates/ui-egui/src/adjust_editors_tests.rs index 9c39088bef..d8c6e7953a 100644 --- a/crates/ui-egui/src/adjust_editors_tests.rs +++ b/crates/ui-egui/src/adjust_editors_tests.rs @@ -566,6 +566,7 @@ fn gradient_map_stop_controls_fit_a_narrow_properties_panel_in_every_theme() { gradients: Vec::new(), dialog: false, dynamic_color_sliders: true, + isolated_curve: None, }; editor(ui, "gradientMap", values, &cx); }, @@ -774,6 +775,7 @@ fn editor_harness(kind: &'static str, params: serde_json::Value) -> Harness<'sta gradients: Vec::new(), dialog: true, dynamic_color_sliders: true, + isolated_curve: None, }; editor(ui, kind, values, &cx); }, diff --git a/crates/ui-egui/src/adjust_preview.rs b/crates/ui-egui/src/adjust_preview.rs index d94d58bff6..f74afc8595 100644 --- a/crates/ui-egui/src/adjust_preview.rs +++ b/crates/ui-egui/src/adjust_preview.rs @@ -436,6 +436,9 @@ pub(crate) fn retain_documents(app: &mut PhotocraftApp) { if app.adjust_preview.as_ref().is_some_and(|p| !app.session.documents().iter().any(|st| st.doc.id == p.doc)) { app.adjust_preview = None; } + if app.live_adjust_preview.as_ref().is_some_and(|p| !app.session.documents().iter().any(|st| st.doc.id == p.doc)) { + app.live_adjust_preview = None; + } } /// GPU texture keys the preview uses besides the documents' own (keep them alive). @@ -444,6 +447,108 @@ pub fn gpu_keys(app: &PhotocraftApp) -> Option { p.proxy.as_ref().map(|g| g.base.id.0) } +// --------------------------------------------------------------------------------------------- +// The Properties panel's live adjustment-layer preview (`canvas::display_doc`'s `live_adjust`). +// +// Dragging a control there shows the document with that layer's adjustment replaced. Unlike the +// dialog above it needs no temporary layer: the layer is already in the document. What it did +// lack was a damage region, so every parameter value recomposited the whole document — on the +// GPU canvas at 100 % zoom that is the full size, measured at about 7 ms per megapixel, which +// made #5778 a latency regression (#6839). These give that path the same treatment the dialog +// gets: a session-tagged key, the layer's area as the damage, and of that only what the view +// shows, catching up as the view moves. + +/// Tag of the Properties-panel live-adjust preview keys. Distinct from [`TAG`], so a key is +/// never mistaken for the dialog's. +pub const LIVE_TAG: u64 = 1 << 58; +/// Bits of a live-adjust key that identify the session (document, revision, layer); below them +/// the settings hash. The session occupies bits 40..58, under [`LIVE_TAG`]. +const LIVE_SESSION_SHIFT: u32 = 40; +/// Mask of the session part of a live-adjust key (18 bits). +const LIVE_SESSION_MASK: u64 = (1 << 18) - 1; + +/// One Properties-panel drag: which layer of which document revision, the area its adjustment +/// can change, and what the canvas has composited of it so far. +pub struct LiveAdjust { + doc: DocId, + revision: u64, + session: u64, + region: Rect, + /// The main canvas texture shows preview `key` only within this rect; elsewhere in the + /// region it is older (see [`live_uncovered`]). + covered: Option<(u64, Rect)>, +} + +/// The area an adjustment layer can change: the whole document. +/// +/// It is deliberately not narrowed by the layer's mask. A mask's coverage can live entirely in +/// its surface's *default pixel* with no tiles written — [`LayerMask::reveal_all`], which is what +/// `layer.newAdjustmentLayer` gives every adjustment layer — and such a mask reports an empty +/// `content_bounds()`. Intersecting with that emptied the region, so the damage was empty and the +/// compositor drew nothing at all for the whole drag. A mask only bounds the effect when it hides +/// outside its content (default pixel 0); that narrowing is worth little here, since the damage is +/// clipped to the view anyway ([`live_switch_damage`]), and is not worth the risk of showing stale +/// pixels. Too large only costs pixels; too small freezes the preview. +fn live_region(doc: &Document, layer: LayerId) -> Rect { + if doc.layer(layer).is_none() { + return Rect::EMPTY; + } + doc.bounds() +} + +/// The preview key for `params` on `layer` of `doc` at `revision`, starting a session (and +/// measuring its region) when the document, revision or layer changes. +pub fn live_key(app: &mut PhotocraftApp, doc: &Document, revision: u64, layer: LayerId, params: &Value) -> u64 { + let session = hash(&[&doc.id.0.to_le_bytes(), &revision.to_le_bytes(), &layer.0.to_le_bytes()]); + if app.live_adjust_preview.as_ref().is_none_or(|p| p.session != session) { + app.live_adjust_preview = Some(LiveAdjust { doc: doc.id, revision, session, region: live_region(doc, layer), covered: None }); + } + let h = hash(&[params.to_string().as_bytes()]); + LIVE_TAG | ((session & LIVE_SESSION_MASK) << LIVE_SESSION_SHIFT) | (h & ((1 << LIVE_SESSION_SHIFT) - 1)) +} + +/// The canvas area to recomposite when document `doc` (at `revision`) switches between two of +/// this drag's previews, or between one and the committed document (0). +pub fn live_switch_region(app: &PhotocraftApp, doc: DocId, revision: u64, from: u64, to: u64) -> Option { + let p = app.live_adjust_preview.as_ref()?; + if p.doc != doc || p.revision != revision { + return None; + } + let prefix = LIVE_TAG | ((p.session & LIVE_SESSION_MASK) << LIVE_SESSION_SHIFT); + let ours = |k: u64| k == 0 || k >> LIVE_SESSION_SHIFT == prefix >> LIVE_SESSION_SHIFT; + (ours(from) && ours(to) && (from != 0 || to != 0)).then_some(p.region) +} + +/// The part of a switch's region to composite now: to a preview, only what the view shows, +/// remembered so [`live_uncovered`] catches up as the view moves; back to the committed +/// document (key 0), the whole region. +pub fn live_switch_damage(app: &mut PhotocraftApp, to: u64, region: Rect, visible: Rect) -> Rect { + let Some(p) = app.live_adjust_preview.as_mut() else { return region }; + if to == 0 { + p.covered = None; + return region; + } + let shown = region.intersect(&visible); + p.covered = Some((to, shown)); + shown +} + +/// Where the main canvas texture (showing preview `key`) is out of date within `visible`: the +/// region's visible part when the view moved past what was composited. Marks it covered. +pub fn live_uncovered(app: &mut PhotocraftApp, doc: DocId, key: u64, visible: Rect) -> Option { + let p = app.live_adjust_preview.as_mut().filter(|p| p.doc == doc)?; + let (k, cov) = p.covered?; + if k != key { + return None; + } + let want = p.region.intersect(&visible); + if want.is_empty() || cov.contains_rect(&want) { + return None; + } + p.covered = Some((k, want)); + Some(want) +} + #[cfg(test)] #[path = "adjust_preview_tests.rs"] mod tests; diff --git a/crates/ui-egui/src/adjust_preview_tests.rs b/crates/ui-egui/src/adjust_preview_tests.rs index f3e33a723b..0b5337da7a 100644 --- a/crates/ui-egui/src/adjust_preview_tests.rs +++ b/crates/ui-egui/src/adjust_preview_tests.rs @@ -333,3 +333,60 @@ fn proxy_preview_has_its_own_ids() { } assert!(proxy_with_settings(&proxy, "levels", &json!({"gamma": "x"})).is_err()); } + +/// A Properties-panel drag damages the layer's area and only the part of it the view shows, +/// catching up as the view moves; a new document revision ends the session (#6839). +#[test] +fn a_live_adjust_drag_damages_only_the_visible_part_of_its_layer() { + let (doc, target) = document(ColorMode::Rgb, SampleType::U8, false); + let (id, bounds) = (doc.id, doc.bounds()); + let mut app = app_with(doc.clone(), target); + + let k1 = live_key(&mut app, &doc, 7, target, &json!({"gamma": 1.1})); + let k2 = live_key(&mut app, &doc, 7, target, &json!({"gamma": 1.4})); + assert_ne!(k1, k2, "a different setting is a different key"); + assert_eq!(k1 & LIVE_TAG, LIVE_TAG, "keys carry the live tag"); + assert_eq!(k1 >> LIVE_SESSION_SHIFT, k2 >> LIVE_SESSION_SHIFT, "the same drag is one session"); + assert_ne!(k1 >> LIVE_SESSION_SHIFT, TAG >> LIVE_SESSION_SHIFT, "never the dialog's tag"); + + // Between two of the drag's previews, and between one and the committed document. + let region = live_switch_region(&app, id, 7, k1, k2).expect("same session"); + assert_eq!(region, bounds, "the region is the whole document"); + assert!(live_switch_region(&app, id, 7, 0, k2).is_some(), "from the committed document"); + assert!(live_switch_region(&app, id, 7, k1, 0).is_some(), "back to the committed document"); + assert!(live_switch_region(&app, id, 7, 0, 0).is_none(), "the document to itself is no switch"); + assert!(live_switch_region(&app, id, 8, k1, k2).is_none(), "another revision is another session"); + + // Only the visible part is composited now; moving the view asks for the rest. + let mid = region.x0 + region.width() as i32 / 2; + let left = Rect::new(region.x0, region.y0, mid, region.y1); + assert_eq!(live_switch_damage(&mut app, k2, region, left), region.intersect(&left)); + assert!(live_uncovered(&mut app, id, k2, left).is_none(), "what was just composited is covered"); + let right = Rect::new(mid, region.y0, region.x1, region.y1); + assert_eq!(live_uncovered(&mut app, id, k2, right), Some(region.intersect(&right)), "the view moved"); + assert!(live_uncovered(&mut app, id, k2, right).is_none(), "and is covered once composited"); + assert!(live_uncovered(&mut app, id, k1, right).is_none(), "another preview's coverage is not this one's"); + + // Switching back to the document composites the whole region, not just the view. + assert_eq!(live_switch_damage(&mut app, 0, region, left), region); +} + +/// A reveal-all mask stores its coverage in the surface's default pixel and reports an empty +/// `content_bounds()`. Narrowing the region by it emptied the damage and the canvas drew nothing +/// for a whole drag — every adjustment layer `layer.newAdjustmentLayer` creates has one (#6839). +#[test] +fn a_reveal_all_mask_does_not_empty_the_damage_region() { + let (mut doc, target) = document(ColorMode::Rgb, SampleType::U8, false); + let bounds = doc.bounds(); + let mask = photocraft_doc::LayerMask::reveal_all(); + assert!(mask.surface.content_bounds().is_empty(), "a reveal-all mask has no written content"); + doc.layer_mut(target).expect("target").mask = Some(mask); + let id = doc.id; + let mut app = app_with(doc.clone(), target); + + let k1 = live_key(&mut app, &doc, 3, target, &json!({"gamma": 1.1})); + let k2 = live_key(&mut app, &doc, 3, target, &json!({"gamma": 1.4})); + let region = live_switch_region(&app, id, 3, k1, k2).expect("same session"); + assert_eq!(region, bounds, "the whole document, not the mask's empty content"); + assert!(!live_switch_damage(&mut app, k2, region, bounds).is_empty(), "something is composited"); +} 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/anchor_tools.rs b/crates/ui-egui/src/anchor_tools.rs index 9dc8b6cfe5..e6f9ac72d3 100644 --- a/crates/ui-egui/src/anchor_tools.rs +++ b/crates/ui-egui/src/anchor_tools.rs @@ -7,9 +7,10 @@ //! * Delete Anchor Point: hovering an anchor highlights it; a click removes it //! (`path.deleteAnchor`), joining its neighbours. //! * ⌥ swaps the two tools and ⌘/Ctrl is Direct Selection (`direct_select`) while held. -//! * The Pen does both with Auto Add/Delete on (its options bar, on by default, #4144), on the -//! active path only and while no path is being drawn: a click on a segment adds, a click on an -//! anchor deletes, except an open subpath's end points; ⇧ turns it off while held. +//! * The Pen does both with Auto Add/Delete on (its options bar, on by default, #4144): a click on +//! a segment adds, a click on an anchor deletes, except an open subpath's end points; ⇧ turns it +//! off while held. It edits the active path, or while a path is being drawn that path (#6140), +//! and drawing carries on from its last anchor. //! //! Each click is one command and one history step; a click on nothing it can edit does nothing. @@ -48,16 +49,26 @@ fn target(app: &PhotocraftApp, tool: Tool, p: [f64; 2]) -> Option<(PathRef, Path /// The Pen's Auto Add/Delete (#4144): what a Pen click at `p` does to the active path instead of /// starting a new one, as the anchor tool that does it (Add Anchor Point on a segment, Delete -/// Anchor Point on an anchor), the path and the hit. `None` when the option is off, ⇧, ⌥ or ⌘ is -/// held, a path is being drawn, or the click hits nothing it edits. The end points of an open -/// subpath are left to the Pen, as in Photoshop. +/// Anchor Point on an anchor), the path and the hit. While a path is being drawn it edits that +/// path only (#6140): a click anywhere else places the next anchor. `None` when the option is off, +/// ⇧, ⌥ or ⌘ is held, an anchor's handles are being pulled out, or the click hits nothing it edits. +/// The end points of an open subpath are left to the Pen, as in Photoshop: on the path being +/// drawn, the first closes it and the last breaks its handle. pub fn pen_auto(app: &PhotocraftApp, tool: Tool, mods: Modifiers, p: [f64; 2]) -> Option<(Tool, PathRef, Path, Hit)> { - if tool != Tool::Pen || !app.ui.tool_options.pen_auto_add_delete || app.ui.pen.is_some() || mods.shift || mods.alt || mods.command { + if tool != Tool::Pen || !app.ui.tool_options.pen_auto_add_delete || mods.shift || mods.alt || mods.command { return None; } let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); - let (r, path, hit) = - crate::direct_select::active_paths(app).into_iter().find_map(|(r, path)| edit::hit(&path, Point::new(p[0], p[1]), tol, &[]).map(|h| (r, path, h)))?; + let at = Point::new(p[0], p[1]); + let (r, path, hit) = match app.ui.pen.as_ref() { + Some(pen) if pen.dragging || pen.curvature => return None, + Some(pen) => { + let path = crate::vector_ui::pen_path(pen); + let hit = edit::hit(&path, at, tol, &[])?; + (PathRef::Pen, path, hit) + } + None => crate::direct_select::active_paths(app).into_iter().find_map(|(r, path)| edit::hit(&path, at, tol, &[]).map(|h| (r, path, h)))?, + }; let tool = match hit { Hit::Segment(..) => Tool::AddAnchorPoint, Hit::Anchor([s, k]) => { @@ -76,10 +87,14 @@ pub fn pen_auto(app: &PhotocraftApp, tool: Tool, mods: Modifiers, p: [f64; 2]) - /// when they aren't theirs. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { if let ToolEvent::Down { x, y, .. } = ev - && let Some((_, r, _, hit)) = pen_auto(app, app.ui.tool, mods, [x, y]) + && let Some((_, r, path, hit)) = pen_auto(app, app.ui.tool, mods, [x, y]) { - // The release finds no Pen path to finish, so the Pen draws nothing. - apply(app, r, hit); + // The release finds no Pen drag to finish, so the Pen draws nothing. + if r == PathRef::Pen { + edit_pen(app, path, hit); + } else { + apply(app, r, hit); + } return true; } let Some(tool) = effective(app.ui.tool, mods) else { return false }; @@ -90,23 +105,28 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool } fn click(app: &mut PhotocraftApp, tool: Tool, p: [f64; 2]) { - let Some((r, mut path, hit)) = target(app, tool, p) else { return }; + let Some((r, path, hit)) = target(app, tool, p) else { return }; if r == PathRef::Pen { - // The Pen path being drawn isn't in the document yet: it takes the edit directly. - let edited = match hit { - Hit::Segment(k, t) => edit::add_anchor(&mut path, k, t).is_ok(), - Hit::Anchor(k) => edit::delete_anchor(&mut path, k).is_ok(), - Hit::Handle(..) => false, - }; - if edited { - app.ui.direct_selection = DirectSelection { target: Some(r), ..Default::default() }; - crate::vector_ui::pen_replace(app, &path); - } + edit_pen(app, path, hit); return; } apply(app, r, hit); } +/// Add or delete an anchor of the Pen path being drawn (`path`, from `pen_path`). It isn't in the +/// document yet, so it takes the edit directly; drawing carries on from its last anchor. +fn edit_pen(app: &mut PhotocraftApp, mut path: Path, hit: Hit) { + let edited = match hit { + Hit::Segment(k, t) => edit::add_anchor(&mut path, k, t).is_ok(), + Hit::Anchor(k) => edit::delete_anchor(&mut path, k).is_ok(), + Hit::Handle(..) => false, + }; + if edited { + app.ui.direct_selection = DirectSelection { target: Some(PathRef::Pen), ..Default::default() }; + crate::vector_ui::pen_replace(app, &path); + } +} + /// Add an anchor where `hit` is on a segment of `r`, or delete the anchor it is on: one command. fn apply(app: &mut PhotocraftApp, r: PathRef, hit: Hit) { let (id, mut params) = match hit { diff --git a/crates/ui-egui/src/anchor_tools_tests.rs b/crates/ui-egui/src/anchor_tools_tests.rs index 87c045eba9..c21cc5aaf6 100644 --- a/crates/ui-egui/src/anchor_tools_tests.rs +++ b/crates/ui-egui/src/anchor_tools_tests.rs @@ -230,3 +230,67 @@ fn the_pen_cursor_shows_what_auto_add_delete_would_do() { app.ui.tool_options.pen_auto_add_delete = false; assert_eq!(badge(&app, Tool::Pen, Modifiers::NONE), None); } + +fn pen_anchors(app: &PhotocraftApp) -> Vec<[f64; 2]> { + app.ui.pen.as_ref().map(|p| p.knots.iter().map(|k| k[0]).collect()).unwrap_or_default() +} + +/// #6140: while drawing, a Pen click on the path being drawn adds an anchor on a segment or +/// deletes a middle anchor, and drawing carries on from the last anchor. Nothing reaches History. +#[test] +fn the_pen_adds_and_deletes_anchors_on_the_path_being_drawn() { + let mut app = app(Tool::Pen); + let before = steps(&app); + for at in [[40.0, 40.0], [120.0, 40.0], [120.0, 120.0]] { + click(&mut app, at, Modifiers::NONE); + } + click(&mut app, [80.0, 40.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app), vec![[40.0, 40.0], [80.0, 40.0], [120.0, 40.0], [120.0, 120.0]], "added on the first segment"); + click(&mut app, [40.0, 120.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app).last(), Some(&[40.0, 120.0]), "the next click carries on from the last anchor"); + assert_eq!(pen_anchors(&app).len(), 5); + + click(&mut app, [120.0, 40.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app), vec![[40.0, 40.0], [80.0, 40.0], [120.0, 120.0], [40.0, 120.0]], "a middle anchor is deleted"); + assert_eq!(steps(&app), before, "the path being drawn is not in the document yet"); +} + +/// The path being drawn keeps its end points (the first closes it, the last breaks its handle), ⇧ +/// or the option turned off places an anchor on its segment, and another path takes no edit. +#[test] +fn the_pen_draws_on_the_path_being_drawn_where_auto_add_delete_does_not_apply() { + let mut app = app(Tool::Pen); + with_path(&mut app); + let work_before = work(&app); + for at in [[20.0, 160.0], [180.0, 160.0], [180.0, 180.0]] { + click(&mut app, at, Modifiers::NONE); + } + click(&mut app, [180.0, 180.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app).len(), 3, "a click on the last anchor breaks its handle"); + click(&mut app, [100.0, 160.0], Modifiers::SHIFT); + assert_eq!(pen_anchors(&app).len(), 4, "⇧ places an anchor"); + app.ui.tool_options.pen_auto_add_delete = false; + click(&mut app, [100.0, 160.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app).len(), 5, "with the option off the Pen places an anchor"); + app.ui.tool_options.pen_auto_add_delete = true; + click(&mut app, [80.0, 120.0], Modifiers::NONE); + assert_eq!((pen_anchors(&app).len(), work(&app)), (6, work_before), "another path takes no edit while drawing"); + click(&mut app, [20.0, 160.0], Modifiers::NONE); + assert!(app.ui.pen.is_none(), "the first anchor closes the path"); +} + +#[test] +fn the_pen_cursor_shows_auto_add_delete_on_the_path_being_drawn() { + use crate::tool_feedback::{Badge, badge}; + let mut app = app(Tool::Pen); + for at in [[40.0, 40.0], [120.0, 40.0], [120.0, 120.0]] { + click(&mut app, at, Modifiers::NONE); + } + let cases = [([80.0, 40.0], Some(Badge::Add)), ([120.0, 40.0], Some(Badge::Subtract)), ([40.0, 40.0], None), ([120.0, 120.0], None), ([60.0, 100.0], None)]; + for (at, want) in cases { + app.hover_doc = Some(at); + assert_eq!(badge(&app, Tool::Pen, Modifiers::NONE), want, "{at:?}"); + } + app.hover_doc = Some([80.0, 40.0]); + assert_eq!(badge(&app, Tool::Pen, Modifiers::SHIFT), None); +} diff --git a/crates/ui-egui/src/artboard_tool.rs b/crates/ui-egui/src/artboard_tool.rs index 6b996c3f14..7f8ee838c6 100644 --- a/crates/ui-egui/src/artboard_tool.rs +++ b/crates/ui-egui/src/artboard_tool.rs @@ -182,7 +182,9 @@ fn follow(d: &mut ArtboardDrag, p: [f64; 2], mods: egui::Modifiers) { /// Pointer input for the Artboard tool. Returns true when the event was its own. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { - if app.ui.tool != Tool::Artboard { + // ⌘ is the Move tool (`hold_keys::cmd_moves`): a press with it held is not this tool's, + // so it drags a guide or the layer (#6288). A drag already under way keeps its events. + if app.ui.tool != Tool::Artboard || (app.ui.artboard_tool.drag.is_none() && crate::canvas::event_tool(app, mods) != Tool::Artboard) { return false; } let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return true }; @@ -238,20 +240,17 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> true } -/// The resize pointer for handle (`hx`, `hy`), or none away from the selected board's handles. -pub fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option { +/// The resize pointer for handle (`hx`, `hy`) as the view shows it, or none away from the +/// selected board's handles. +pub fn cursor(app: &PhotocraftApp, xf: &ViewXform, p: [f64; 2]) -> Option { let (hx, hy) = match app.ui.artboard_tool.drag { Some(ArtboardDrag::Resize { hx, hy, .. }) => (hx, hy), Some(_) => return None, None if plus_at(app, p).is_some() => return Some(CursorIcon::PointingHand), None => handle_at(app, p).map(|h| (h.2, h.3))?, }; - Some(match (hx, hy) { - (_, 0) => CursorIcon::ResizeHorizontal, - (0, _) => CursorIcon::ResizeVertical, - _ if hx == hy => CursorIcon::ResizeNwSe, - _ => CursorIcon::ResizeNeSw, - }) + // (hx, hy) is the handle's outward direction: -1, 0 or 1 on each axis. + Some(crate::tool_cursor::resize([f64::from(hx), f64::from(hy)], xf)) } /// The selected board's handles, its "+" buttons, and the board a gesture in progress would leave. @@ -388,6 +387,24 @@ mod tests { assert_eq!(boards(&app).len(), 1); } + /// The handles' resize arrows turn with a rotated view: on a view turned 90°, the right edge's + /// handle resizes up and down on screen. + #[test] + fn resize_cursors_turn_with_the_view() { + use CursorIcon::{ResizeHorizontal as H, ResizeNeSw as NeSw, ResizeNwSe as NwSe, ResizeVertical as V}; + let mut app = app(); + drag(&mut app, [20.0, 30.0], [120.0, 110.0]); + // The right edge, the top edge, the top-left and the top-right corner. + let handles = [[120.0, 70.0], [70.0, 30.0], [20.0, 30.0], [120.0, 30.0]]; + for (rotation, want) in [(0.0, [H, V, NwSe, NeSw]), (90.0, [V, H, NeSw, NwSe])] { + let view_index = app.session.active_index().unwrap(); + app.ui.views[view_index].rotation = rotation; + let xf = ViewXform::active(&app).unwrap(); + let got = handles.map(|p| cursor(&app, &xf, p)); + assert_eq!(got, want.map(Some), "view turned {rotation}°"); + } + } + /// #3033: a click on the selected board's right "+" adds a board of its size a gap to the right; /// ⇧ on a corner keeps the board's proportions. #[test] diff --git a/crates/ui-egui/src/background_redraw_tests.rs b/crates/ui-egui/src/background_redraw_tests.rs index e054ffe2a6..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"); @@ -163,3 +168,47 @@ fn closing_the_document_stops_its_redraw() { g.harness.step(); assert!(g.harness.state().redraws.is_empty()); } + +#[test] +fn hiding_a_large_layer_over_the_gpu_budget_redraws_in_the_background() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + // A layer covering most, but not all, of the document: hiding it damages a large rect that + // isn't the whole canvas. + let app = g.harness.state_mut(); + app.run("file.new", json!({"width": 5000, "height": 2000, "background": "#336699"})).expect("new"); + app.run("layer.new.layer", json!({})).expect("layer"); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 4800, "height": 1800})).expect("select"); + app.run("edit.fill", json!({"color": "#cc8844"})).expect("fill"); + app.run("select.deselect", json!({})).expect("deselect"); + let (doc, layer) = { + let st = app.session.active().expect("document"); + (st.doc.id, st.active_layer.expect("layer")) + }; + settle(&mut g); + assert!(g.harness.state().perf.last_refresh.starts_with("gpu"), "the document fits the GPU: {}", g.harness.state().perf.last_refresh); + // A budget the pages of one 2048 px page cell fit (about 33 MB here) but not those of the + // whole width (80 MB), as on a multi-gigabyte PSB. Damage refreshes keep it: `gpu_budget` + // recomputes it for full ones only. A small edit still draws on the GPU at once. + g.harness.state_mut().run("layer.new.layer", json!({})).expect("layer"); + g.harness.step(); + g.harness.state_mut().run("select.rect", json!({"x": 100, "y": 100, "width": 64, "height": 64})).expect("select"); + g.harness.step(); + g.observer.set_memory_budget(48 << 20); + g.harness.state_mut().run("edit.fill", json!({"color": "#22ee22"})).expect("fill"); + g.harness.step(); + assert_eq!(g.harness.state().perf.last_refresh, "gpu-rect", "{:?}", g.harness.state().perf.gpu_fallback); + g.harness.state_mut().run("select.deselect", json!({})).expect("deselect"); + g.harness.step(); + assert!(g.harness.state().redraws.is_empty()); + g.observer.set_memory_budget(48 << 20); + g.harness.state_mut().run("layer.setProps", json!({"layer": layer.0, "visible": false})).expect("hide"); + let damage = g.harness.state().session.active().expect("document").last_damage.expect("damage"); + assert!(damage != Rect::new(0, 0, 5000, 2000) && damage.width() as u64 * damage.height() as u64 >= crate::gpu_canvas::DEFER_PX, "{damage:?}"); + g.harness.step(); + assert!(g.harness.state().redraws.contains_key(&doc), "not composited inline: {}", g.harness.state().perf.last_refresh); + settle(&mut g); + assert_eq!(g.harness.state().perf.last_refresh, "background"); + assert_eq!(g.harness.state().perf.gpu_fallback.as_deref(), Some(crate::gpu_canvas::OVER_BUDGET)); + assert_matches_synchronous(&mut g, doc); +} 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/blend_preview.rs b/crates/ui-egui/src/blend_preview.rs index e9f010613a..06e7a208e1 100644 --- a/crates/ui-egui/src/blend_preview.rs +++ b/crates/ui-egui/src/blend_preview.rs @@ -1,5 +1,5 @@ -//! Layers panel: hovering a mode in the open blend-mode list previews it on the active layer -//! (Photoshop's live blend-mode preview, #970). Hovering records nothing: leaving the list, or +//! Layers panel: hovering a mode in the open blend-mode list previews it on the layers it would +//! change, the active one or every selected one (Photoshop's live blend-mode preview, #970). Hovering records nothing: leaving the list, or //! closing it without a choice, shows the document again, and choosing a mode commits it through //! `layer.setProps` (one history step). The canvas re-renders once per hovered mode, and only //! where the layer's blending can change pixels. @@ -15,29 +15,31 @@ use crate::PhotocraftApp; /// Preview keys (`canvas::display_doc`) are `BASE` plus a hash of the revision and the mode. const BASE: u64 = 1 << 37; -/// A blend mode hovered for a layer of a document at a revision. +/// A blend mode hovered for layers of a document at a revision. pub(crate) struct BlendPreview { doc: DocId, revision: u64, - layer: LayerId, + layers: Vec, /// The mode under the pointer; `None` once it left the list (the rest stays for the /// canvas's damage on its way back to the document). mode: Option, /// The frame the list was last drawn in: a list no longer drawn previews nothing. frame: u64, - /// Where the layer's blending can change pixels; `None` for anywhere. + /// Where the layers' blending can change pixels; `None` for anywhere. area: Option, /// The document shown for `mode`, with its key. shown: Option<(u64, Arc)>, + /// GPU preview key, affected region (including effects), and rectangles already refreshed. + covered: Option<(u64, Rect, Vec)>, } /// The Layers panel, each frame it draws `layer`'s blend-mode dropdown: the mode under the /// pointer in its open list, if any. -pub(crate) fn hover(app: &mut PhotocraftApp, layer: LayerId, mode: Option) { +pub(crate) fn hover(app: &mut PhotocraftApp, layers: &[LayerId], mode: Option) { let frame = app.frame; let Some(st) = app.session.active() else { return }; let (doc, revision) = (st.doc.id, st.revision); - if let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision && p.layer == layer) { + if let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision && p.layers == layers) { p.frame = frame; if mode.is_none() { p.shown = None; @@ -46,8 +48,15 @@ pub(crate) fn hover(app: &mut PhotocraftApp, layer: LayerId, mode: Option = Some(Rect::EMPTY); + for &id in layers { + area = match (area, photocraft_compose::change_bounds(st.doc.layer(id)?, st.doc.bounds())) { + (Some(a), Some(b)) => Some(if a.is_empty() { b } else { a.union(&b) }), + _ => None, + }; + } + Some(BlendPreview { doc, revision, layers: layers.to_vec(), mode: Some(mode), frame, area, shown: None, covered: None }) }); } @@ -57,15 +66,23 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc= frame)?; let mode = p.mode?; - let key = preview_key(p.revision, p.layer, mode); + let key = preview_key(p.revision, &p.layers, mode); if let Some((k, shown)) = &p.shown && *k == key { return Some((shown.clone(), key)); } let mut doc = (*st.doc).clone(); - let l = doc.layer_mut(p.layer).filter(|l| l.blend != mode)?; - l.blend = mode; + let mut changed = false; + for &id in &p.layers { + if let Some(l) = doc.layer_mut(id).filter(|l| l.blend != mode) { + l.blend = mode; + changed = true; + } + } + if !changed { + return None; + } // Duotone documents display through their inks. let doc = Arc::new(photocraft_engine::mode_cmds::display_document(&doc).unwrap_or(doc)); p.shown = Some((key, doc.clone())); @@ -73,7 +90,7 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc Option { let p = app.blend_preview.as_ref().filter(|p| p.doc == doc && p.revision == revision)?; @@ -81,12 +98,48 @@ pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, seen: u64, (seen != now && ours(seen) && ours(now) && (is_preview_key(seen) || is_preview_key(now))).then_some(p.area)? } +/// Limit an active preview to the view. Returning to the document restores the whole region. +pub(crate) fn clip_damage(app: &mut PhotocraftApp, doc: DocId, revision: u64, key: u64, region: Rect, visible: Rect) -> Rect { + let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision) else { return region }; + if !p.shown.as_ref().is_some_and(|(shown, _)| *shown == key) { + p.covered = None; + return region; + } + let shown = region.intersect(&visible); + p.covered = Some((key, region, vec![shown])); + shown +} + +/// A pan or zoom can expose pixels not yet rendered for the current preview key. +pub(crate) fn uncovered(app: &mut PhotocraftApp, doc: DocId, revision: u64, key: u64, visible: Rect) -> Option { + let p = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision)?; + let (covered_key, region, covered) = p.covered.as_mut()?; + if *covered_key != key { + return None; + } + let want = region.intersect(&visible); + if want.is_empty() || covered.iter().any(|r| r.contains_rect(&want)) { + return None; + } + // Keep disjoint views without marking the unrendered gaps between them as covered. + // Bound bookkeeping during long pans: after eight rectangles, render the whole affected + // region once. A different mode/revision resets coverage through `clip_damage`. + covered.retain(|r| !want.contains_rect(r)); + let refresh = if covered.len() < 8 { want } else { *region }; + if refresh == *region { + covered.clear(); + } + covered.push(refresh); + Some(refresh) +} + fn is_preview_key(key: u64) -> bool { (BASE..BASE << 1).contains(&key) } -fn preview_key(revision: u64, layer: LayerId, mode: BlendMode) -> u64 { - let h = mode.label().bytes().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ layer.0, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b))); +fn preview_key(revision: u64, layers: &[LayerId], mode: BlendMode) -> u64 { + let seed = layers.iter().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15), |h, id| h.wrapping_mul(0x100_0000_01b3) ^ id.0); + let h = mode.label().bytes().fold(seed, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b))); (h & (BASE - 1)) | BASE } diff --git a/crates/ui-egui/src/blend_preview/tests.rs b/crates/ui-egui/src/blend_preview/tests.rs index 4eb407aad0..72e809f786 100644 --- a/crates/ui-egui/src/blend_preview/tests.rs +++ b/crates/ui-egui/src/blend_preview/tests.rs @@ -36,7 +36,7 @@ fn a_hovered_mode_shows_on_the_canvas_until_the_pointer_leaves() { let (s, top) = session(); let mut app = PhotocraftApp::new(s, crate::Services::default()); let (undo, revision) = (steps(&app), app.session.active().unwrap().revision); - hover(&mut app, top, Some(BlendMode::Multiply)); + hover(&mut app, &[top], Some(BlendMode::Multiply)); let (shown, key) = display_doc(&mut app, 0).expect("previewed"); assert_eq!(shown.layer(top).unwrap().blend, BlendMode::Multiply); let (again, same) = display_doc(&mut app, 0).unwrap(); @@ -45,17 +45,17 @@ fn a_hovered_mode_shows_on_the_canvas_until_the_pointer_leaves() { // From the document to the preview and between previews: only the layer's area redraws. let doc = app.session.active().unwrap().doc.id; assert_eq!(damage(&app, doc, revision, 0, key).map(|r| r.width()), Some(16)); - hover(&mut app, top, Some(BlendMode::Screen)); + hover(&mut app, &[top], Some(BlendMode::Screen)); let (_, screen) = display_doc(&mut app, 0).unwrap(); assert_ne!(screen, key); assert!(damage(&app, doc, revision, key, screen).is_some()); // The current mode, or no mode, shows the document; and so does a list no longer drawn. - hover(&mut app, top, Some(BlendMode::Normal)); + hover(&mut app, &[top], Some(BlendMode::Normal)); assert!(display_doc(&mut app, 0).is_none()); - hover(&mut app, top, None); + hover(&mut app, &[top], None); assert!(display_doc(&mut app, 0).is_none()); assert!(damage(&app, doc, revision, screen, 0).is_some(), "back to the document: the layer's area"); - hover(&mut app, top, Some(BlendMode::Multiply)); + hover(&mut app, &[top], Some(BlendMode::Multiply)); app.frame += 2; assert!(display_doc(&mut app, 0).is_none(), "a stale hover previews nothing"); } @@ -124,3 +124,51 @@ fn moving_between_modes_keeps_a_preview_across_the_gaps_between_them() { } assert_eq!(previewed(&mut h), Some(BlendMode::Multiply)); } + +#[test] +fn a_hovered_mode_previews_on_every_selected_layer() { + let (mut s, top) = session(); + let other = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.execute("edit.fill", json!({"color": "#3366cc"})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + hover(&mut app, &[top, other], Some(BlendMode::Multiply)); + let (shown, key) = display_doc(&mut app, 0).expect("previewed"); + assert_eq!((shown.layer(top).unwrap().blend, shown.layer(other).unwrap().blend), (BlendMode::Multiply, BlendMode::Multiply)); + // A different set of layers is a different preview. + hover(&mut app, &[top], Some(BlendMode::Multiply)); + assert_ne!(display_doc(&mut app, 0).unwrap().1, key); +} + +#[test] +fn blend_hover_disjoint_coverage_retains_views_without_hiding_gaps() { + let (s, top) = session(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + hover(&mut app, &[top], Some(BlendMode::Multiply)); + let (_, key) = display_doc(&mut app, 0).unwrap(); + let st = app.session.active().unwrap(); + let (id, revision, region) = (st.doc.id, st.revision, st.doc.bounds()); + let a = photocraft_geom::Rect::new(0, 0, 2, 2); + let b = photocraft_geom::Rect::new(4, 0, 6, 2); + assert_eq!(super::clip_damage(&mut app, id, revision, key, region, a), a); + assert_eq!(super::uncovered(&mut app, id, revision, key, b), Some(b)); + assert_eq!(super::uncovered(&mut app, id, revision, key, a), None); + let gap = photocraft_geom::Rect::new(2, 0, 4, 2); + assert_eq!(super::uncovered(&mut app, id, revision, key, gap), Some(gap)); + // Repeated distinct views have bounded bookkeeping, then the whole region is current. + let mut fell_back = false; + for x in (8..40).step_by(4) { + let visible = photocraft_geom::Rect::new(x, 0, x + 2, 2); + if super::uncovered(&mut app, id, revision, key, visible) == Some(region) { + fell_back = true; + break; + } + } + assert!(fell_back); + assert_eq!(super::uncovered(&mut app, id, revision, key, region), None); + hover(&mut app, &[top], Some(BlendMode::Screen)); + let (_, next) = display_doc(&mut app, 0).unwrap(); + assert_eq!(super::clip_damage(&mut app, id, revision, next, region, a), a); + assert_eq!(super::uncovered(&mut app, id, revision, next, b), Some(b)); + hover(&mut app, &[top], None); + assert_eq!(super::clip_damage(&mut app, id, revision, 0, region, a), region); +} diff --git a/crates/ui-egui/src/blur_pins.rs b/crates/ui-egui/src/blur_pins.rs index 6a90d9910a..e368abf659 100644 --- a/crates/ui-egui/src/blur_pins.rs +++ b/crates/ui-egui/src/blur_pins.rs @@ -1,13 +1,26 @@ -//! Filter › Blur Gallery › Field Blur… as Photoshop's workspace (#3373): pins on the image, each -//! with its own blur, interpolated between them. While the dialog is open the image under it takes -//! the pointer: a click on the image adds a pin (and selects it), a drag moves a pin, and a drag on -//! the selected pin's ring turns its blur up or down (one turn = the full 0–500 px; the arc shows -//! the amount). Delete or Backspace removes the selected pin. The dialog shows the selected pin's -//! Blur and the Preview switch; OK runs `filter.blurGallery.fieldBlur` with the `pins`. +//! Filter › Blur Gallery as Photoshop's workspace (#3373): Field Blur, Iris Blur, Tilt-Shift and +//! Spin Blur are edited on the image. While the dialog is open the image under it takes the +//! pointer: a click on the image adds a pin (and selects it), a drag moves a pin, and a drag on the +//! selected pin's ring turns its amount up or down (one turn = the full range; the arc shows it). +//! Delete or Backspace removes the selected pin. The dialog shows the selected pin's amount and the +//! Preview switch; OK runs the filter. //! -//! Everything lives in the dialog's fields, so automation drives it with `ui.dialog.set`: `pins` -//! (`[{x, y, blur}]`, x and y as fractions of the filter's bounds) and `__selected`. The bounds are -//! the engine's: the selection's when there is one, else the canvas. +//! - **Field Blur** interpolates the pins' blur between them. +//! - **Iris Blur**: a sharp superellipse, blurred outside. The selected pin shows Photoshop's +//! handles: four on the outline (drag to resize that axis and turn the shape), a square at its +//! corner (drag out to round it towards a rectangle) and four dots inside (the sharp core, where +//! the blur starts). +//! - **Spin Blur**: an ellipse whose content turns about its centre; the outline handles resize and +//! turn it, and the ring sets the blur angle (one turn = 360°). +//! - **Tilt-Shift**: one sharp band (the engine has a single one, so a click adds nothing). The +//! solid lines bound the sharp band, the dashed lines the transition, and the dots on the solid +//! lines turn it. +//! +//! Everything lives in the dialog's fields, so automation drives it with `ui.dialog.set`: `pins` as +//! the command takes them (x and y as fractions of the filter's bounds, radii as fractions of their +//! shorter side) and `__selected`; Tilt-Shift keeps the command's own `centerX`, `centerY`, +//! `angle`, `focus`, `transition` and `blur`. The bounds are the engine's: the selection's when +//! there is one, else the canvas. use egui::{Color32, Pos2, Stroke, vec2}; use serde_json::{Map, Value, json}; @@ -16,8 +29,13 @@ use crate::PhotocraftApp; use crate::canvas::ViewXform; pub const COMMAND: &str = "filter.blurGallery.fieldBlur"; -/// Field Blur's range, in pixels. +pub const IRIS: &str = "filter.blurGallery.irisBlur"; +pub const SPIN: &str = "filter.blurGallery.spinBlur"; +pub const TILT: &str = "filter.blurGallery.tiltShift"; +/// The blur range, in pixels. const MAX_BLUR: f64 = 500.0; +/// Spin Blur's blur angle range, in degrees. +const MAX_SPIN: f64 = 360.0; /// Photoshop's first pin. const DEFAULT_BLUR: f64 = 15.0; /// More pins than anyone places by hand; keeps the params and the per-pixel loop bounded. @@ -27,13 +45,129 @@ const DOT: f32 = 5.0; const GRAB: f32 = 9.0; const RING: f32 = 24.0; const RING_BAND: f32 = 5.0; +/// Screen size (points) of a shape handle. +const HANDLE: f32 = 4.5; +/// How near (points) a press must be to a Tilt-Shift line to drag it. +const LINE_GRAB: f32 = 5.0; +/// Radii, as the commands bound them. +const MIN_RADIUS: f64 = 0.01; +const MAX_FEATHER: f64 = 0.99; -pub fn owns(fields: &Map) -> bool { - fields.get("__command").and_then(Value::as_str) == Some(COMMAND) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Field, + Iris, + Spin, + Tilt, } -/// Turn a freshly opened Field Blur dialog into the workspace: one pin in the middle of the -/// filter's bounds with the remembered blur, selected. +impl Kind { + /// The amount the ring and the dialog set: blur in pixels, or Spin's blur angle in degrees. + fn max(self) -> f64 { + if self == Kind::Spin { MAX_SPIN } else { MAX_BLUR } + } + + /// The dialog field the selected pin's amount is mirrored to (remembered for next time). + fn amount_key(self) -> &'static str { + if self == Kind::Spin { "blurAngle" } else { "blur" } + } +} + +fn kind(fields: &Map) -> Option { + match fields.get("__command").and_then(Value::as_str) { + Some(COMMAND) => Some(Kind::Field), + Some(IRIS) => Some(Kind::Iris), + Some(SPIN) => Some(Kind::Spin), + Some(TILT) => Some(Kind::Tilt), + _ => None, + } +} + +pub fn owns(fields: &Map) -> bool { + kind(fields).is_some() +} + +/// The Blur Gallery filters that open as this workspace. +pub fn handles(command: &str) -> bool { + matches!(command, COMMAND | IRIS | SPIN | TILT) +} + +/// An Iris or Spin pin's shape, as the command takes it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Iris { + /// Radii as fractions of the bounds' shorter side. + pub rx: f64, + pub ry: f64, + /// Degrees, clockwise on screen. + pub angle: f64, + /// 0 = ellipse … 100 = rounded rectangle (Iris only). + pub roundness: f64, + /// Where the blur starts, as a fraction of the shape (Iris only). + pub feather: f64, +} + +impl Default for Iris { + /// Iris Blur's defaults. + fn default() -> Self { + Iris { rx: 0.35, ry: 0.25, angle: 0.0, roundness: 0.0, feather: 0.5 } + } +} + +impl Iris { + /// Spin Blur's defaults: a circle. + pub fn spin() -> Self { + Iris { rx: 0.3, ry: 0.3, ..Iris::default() } + } + + fn clean(self) -> Self { + let f = |v: f64, d: f64| if v.is_finite() { v } else { d }; + Iris { + rx: f(self.rx, 0.35).clamp(MIN_RADIUS, 1.0), + ry: f(self.ry, 0.25).clamp(MIN_RADIUS, 1.0), + angle: (f(self.angle, 0.0) + 180.0).rem_euclid(360.0) - 180.0, + roundness: f(self.roundness, 0.0).clamp(0.0, 100.0), + feather: f(self.feather, 0.5).clamp(0.0, MAX_FEATHER), + } + } + + /// The superellipse exponent the engine uses (2 = ellipse … 8). + fn exponent(self) -> f64 { + 2.0 + self.roundness / 100.0 * 6.0 + } +} + +/// Tilt-Shift's band: its angle, and the sharp band's and the transition's widths beyond the +/// centre line, as fractions of the bounds' shorter side. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Tilt { + /// Degrees, −90…90; 0 is a horizontal band, positive turns it anticlockwise on screen. + pub angle: f64, + pub focus: f64, + pub transition: f64, +} + +impl Tilt { + fn clean(self) -> Self { + let f = |v: f64, d: f64| if v.is_finite() { v } else { d }; + // The band is the same turned half a turn: keep the command's −90…90. + let mut angle = (f(self.angle, 0.0) + 180.0).rem_euclid(360.0) - 180.0; + if angle > 90.0 { + angle -= 180.0; + } else if angle < -90.0 { + angle += 180.0; + } + Tilt { angle, focus: f(self.focus, 0.1).clamp(0.0, 1.0), transition: f(self.transition, 0.15).clamp(0.01, 1.0) } + } + + /// The unit normal of the band (the engine's), and its direction along the band. + fn axes(self) -> ([f64; 2], [f64; 2]) { + let (s, c) = self.angle.to_radians().sin_cos(); + ([s, c], [c, -s]) + } +} + +/// Turn a freshly opened Blur Gallery dialog into the workspace: one pin in the middle of the +/// filter's bounds with the remembered settings, selected. pub fn init(app: &mut PhotocraftApp, fields: &mut Map) { reset(fields); let bounds = app.session.active().map(|st| (st.doc.id, st.doc.bounds(), st.doc.selection.clone())); @@ -43,15 +177,33 @@ pub fn init(app: &mut PhotocraftApp, fields: &mut Map) { } } -/// One pin in the middle with the dialog's blur, selected. Alt+Cancel (Reset) runs this alone, so -/// the bounds captured on opening stay. +/// One pin in the middle with the dialog's settings, selected. Alt+Cancel (Reset) runs this +/// alone, so the bounds captured on opening stay. pub fn reset(fields: &mut Map) { - let blur = fields.get("blur").and_then(Value::as_f64).filter(|b| b.is_finite()).unwrap_or(DEFAULT_BLUR).clamp(0.0, MAX_BLUR); - fields.remove("centerX"); - fields.remove("centerY"); - fields.insert("blur".into(), json!(blur)); - fields.insert("pins".into(), json!([{"x": 0.5, "y": 0.5, "blur": blur}])); - fields.insert("__selected".into(), json!(0)); + let Some(k) = kind(fields) else { return }; + let num = |key: &str, d: f64| fields.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d); + let amount = num(k.amount_key(), DEFAULT_BLUR).clamp(0.0, k.max()); + let shape = |d: Iris| Iris { rx: num("radiusX", d.rx), ry: num("radiusY", d.ry), angle: num("angle", d.angle), ..d }; + let pin = match k { + Kind::Field => Pin { x: 0.5, y: 0.5, blur: amount, iris: None, tilt: None }, + Kind::Iris => { + let iris = Iris { roundness: num("roundness", 0.0), feather: num("feather", 0.5), ..shape(Iris::default()) }.clean(); + Pin { x: 0.5, y: 0.5, blur: amount, iris: Some(iris), tilt: None } + } + Kind::Spin => Pin { x: 0.5, y: 0.5, blur: amount, iris: Some(shape(Iris::spin()).clean()), tilt: None }, + Kind::Tilt => { + let tilt = Tilt { angle: num("angle", 0.0), focus: num("focus", 0.1), transition: num("transition", 0.15) }.clean(); + Pin { x: num("centerX", 0.5).clamp(0.0, 1.0), y: num("centerY", 0.5).clamp(0.0, 1.0), blur: amount, iris: None, tilt: Some(tilt) } + } + }; + // The pins replace a single shape's params (Tilt-Shift keeps its own). + if k != Kind::Tilt { + for key in ["centerX", "centerY", "radiusX", "radiusY", "angle", "roundness", "feather"] { + fields.remove(key); + } + } + set_pins(fields, &[pin]); + select(fields, Some(0)); } /// Where the dialog first opens (offset from the window's top-left): at the right edge, beside the @@ -61,26 +213,73 @@ pub fn first_place(fields: &Map, ctx: &egui::Context) -> Option, + pub tilt: Option, } /// The pins in `fields`, cleaned (finite, in range, at most [`MAX_PINS`]). pub fn pins(fields: &Map) -> Vec { + let Some(k) = kind(fields) else { return Vec::new() }; + let num = |v: &Value, key: &str, d: f64| v.get(key).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(d); + if k == Kind::Tilt { + // Tilt-Shift's one band is the command's own params. + let f = Value::Object(fields.clone()); + let tilt = Tilt { angle: num(&f, "angle", 0.0), focus: num(&f, "focus", 0.1), transition: num(&f, "transition", 0.15) }.clean(); + let (x, y) = (num(&f, "centerX", 0.5).clamp(0.0, 1.0), num(&f, "centerY", 0.5).clamp(0.0, 1.0)); + return vec![Pin { x, y, blur: num(&f, "blur", DEFAULT_BLUR).clamp(0.0, MAX_BLUR), iris: None, tilt: Some(tilt) }]; + } let Some(list) = fields.get("pins").and_then(Value::as_array) else { return Vec::new() }; - let num = |v: &Value, k: &str, d: f64| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(d); list.iter() .take(MAX_PINS) - .map(|v| Pin { x: num(v, "x", 0.5).clamp(0.0, 1.0), y: num(v, "y", 0.5).clamp(0.0, 1.0), blur: num(v, "blur", DEFAULT_BLUR).clamp(0.0, MAX_BLUR) }) + .map(|v| Pin { + x: num(v, "x", 0.5).clamp(0.0, 1.0), + y: num(v, "y", 0.5).clamp(0.0, 1.0), + blur: num(v, k.amount_key(), DEFAULT_BLUR).clamp(0.0, k.max()), + iris: match k { + Kind::Iris | Kind::Spin => { + let d = if k == Kind::Spin { Iris::spin() } else { Iris::default() }; + let iris = Iris { rx: num(v, "radiusX", d.rx), ry: num(v, "radiusY", d.ry), angle: num(v, "angle", d.angle), ..d }; + let iris = if k == Kind::Iris { + Iris { roundness: num(v, "roundness", d.roundness), feather: num(v, "feather", d.feather), ..iris } + } else { + iris + }; + Some(iris.clean()) + } + _ => None, + }, + tilt: None, + }) .collect() } fn set_pins(fields: &mut Map, pins: &[Pin]) { - let list: Vec = pins.iter().map(|p| json!({"x": p.x, "y": p.y, "blur": p.blur})).collect(); + let Some(k) = kind(fields) else { return }; + if k == Kind::Tilt { + let Some(p) = pins.first() else { return }; + let t = p.tilt.unwrap_or(Tilt { angle: 0.0, focus: 0.1, transition: 0.15 }); + for (key, v) in [("centerX", p.x), ("centerY", p.y), ("blur", p.blur), ("angle", t.angle), ("focus", t.focus), ("transition", t.transition)] { + fields.insert(key.into(), json!(v)); + } + return; + } + let list: Vec = pins + .iter() + .map(|p| match (k, p.iris) { + (Kind::Iris, Some(s)) => { + json!({"x": p.x, "y": p.y, "blur": p.blur, "radiusX": s.rx, "radiusY": s.ry, "angle": s.angle, "roundness": s.roundness, "feather": s.feather}) + } + (Kind::Spin, Some(s)) => json!({"x": p.x, "y": p.y, "blurAngle": p.blur, "radiusX": s.rx, "radiusY": s.ry, "angle": s.angle}), + _ => json!({"x": p.x, "y": p.y, "blur": p.blur}), + }) + .collect(); fields.insert("pins".into(), Value::Array(list)); } @@ -92,9 +291,9 @@ pub fn selected(fields: &Map) -> Option { fn select(fields: &mut Map, i: Option) { fields.insert("__selected".into(), i.map_or(Value::Null, |i| json!(i))); - // The dialog's Blur (remembered for the next Field Blur) follows the selected pin. - if let Some(p) = i.and_then(|i| pins(fields).get(i).copied()) { - fields.insert("blur".into(), json!(p.blur)); + // The dialog's amount (remembered for the next time) follows the selected pin. + if let (Some(k), Some(p)) = (kind(fields), i.and_then(|i| pins(fields).get(i).copied())) { + fields.insert(k.amount_key().into(), json!(p.blur)); } } @@ -106,6 +305,11 @@ fn bounds(fields: &Map) -> Option<[f64; 4]> { (r[2] > r[0] && r[3] > r[1]).then_some(r) } +/// The bounds' shorter side, which radii and Tilt-Shift widths are fractions of. +fn short_side(b: [f64; 4]) -> f64 { + (b[2] - b[0]).min(b[3] - b[1]).max(1.0) +} + fn to_doc(b: [f64; 4], p: Pin) -> [f64; 2] { [b[0] + p.x * (b[2] - b[0]), b[1] + p.y * (b[3] - b[1])] } @@ -114,8 +318,74 @@ fn from_doc(b: [f64; 4], d: [f64; 2]) -> (f64, f64) { (((d[0] - b[0]) / (b[2] - b[0])).clamp(0.0, 1.0), ((d[1] - b[1]) / (b[3] - b[1])).clamp(0.0, 1.0)) } -/// Remove the selected pin; the one before it (or the new first) is selected. +/// A shaped pin's local point `(u, v)` (1 = on the outline along that axis) in document pixels. +fn iris_point(b: [f64; 4], p: Pin, s: Iris, u: f64, v: f64) -> [f64; 2] { + let c = to_doc(b, p); + let ss = short_side(b); + let (x, y) = (u * s.rx * ss, v * s.ry * ss); + let (sin, cos) = s.angle.to_radians().sin_cos(); + [c[0] + x * cos - y * sin, c[1] + x * sin + y * cos] +} + +/// A document point in a shaped pin's local coordinates (the engine's `u`, `v`). +fn iris_local(b: [f64; 4], p: Pin, s: Iris, d: [f64; 2]) -> (f64, f64) { + let c = to_doc(b, p); + let ss = short_side(b); + let (dx, dy) = (d[0] - c[0], d[1] - c[1]); + let (sin, cos) = s.angle.to_radians().sin_cos(); + ((dx * cos + dy * sin) / (s.rx * ss), (-dx * sin + dy * cos) / (s.ry * ss)) +} + +/// The superellipse's diagonal point `t` (on the shape at (t, t)): 2^(−1/m). +fn diagonal(s: Iris) -> f64 { + 0.5f64.powf(1.0 / s.exponent()) +} + +/// A shaped pin's outline handles (along its axes), Iris's roundness square (upper right) and its +/// four feather dots (on the diagonals), in local coordinates. +fn axis_handles() -> [(f64, f64); 4] { + [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)] +} + +fn round_handle(s: Iris) -> (f64, f64) { + let t = diagonal(s); + (t, -t) +} + +fn feather_handles(s: Iris) -> [(f64, f64); 4] { + let k = s.feather * diagonal(s); + [(k, k), (-k, k), (-k, -k), (k, -k)] +} + +/// The point of the Tilt-Shift band at `offset` pixels across it (along its normal) and `along` +/// pixels along it from the centre, in document pixels. +fn tilt_point(b: [f64; 4], p: Pin, t: Tilt, offset: f64, along: f64) -> [f64; 2] { + let c = to_doc(b, p); + let (n, d) = t.axes(); + [c[0] + n[0] * offset + d[0] * along, c[1] + n[1] * offset + d[1] * along] +} + +/// How far a document point is across the band from its centre line, in pixels (signed). +fn tilt_offset(b: [f64; 4], p: Pin, t: Tilt, d: [f64; 2]) -> f64 { + let c = to_doc(b, p); + let (n, _) = t.axes(); + (d[0] - c[0]) * n[0] + (d[1] - c[1]) * n[1] +} + +/// A Tilt-Shift turn handle: one on each solid line, beside the pin's ring (so it never sits on +/// the ring, however narrow the band). `side` is +1 or −1. +pub fn tilt_turn_handle(b: [f64; 4], p: Pin, t: Tilt, xf: &ViewXform, side: f64) -> [f64; 2] { + let ss = short_side(b); + let scale = f64::from(xf.zoom_x()).max(1e-6); + tilt_point(b, p, t, side * t.focus * ss, f64::from(RING + 18.0) / scale) +} + +/// Remove the selected pin; the one before it (or the new first) is selected. Tilt-Shift's one +/// band stays. pub fn remove_selected(fields: &mut Map) -> bool { + if kind(fields) == Some(Kind::Tilt) { + return false; + } let Some(i) = selected(fields) else { return false }; let mut list = pins(fields); list.remove(i); @@ -124,16 +394,18 @@ pub fn remove_selected(fields: &mut Map) -> bool { true } -/// The dialog body: the selected pin's Blur, how to place pins, and Preview. +/// The dialog body: the selected pin's amount, how to edit the pins, and Preview. pub fn body(ui: &mut egui::Ui, f: &mut Map) { let t = crate::theme::Tokens::get(ui.ctx()); + let k = kind(f).unwrap_or(Kind::Field); match selected(f) { Some(i) => { let mut list = pins(f); if let Some(pin) = list.get_mut(i) { let mut v = pin.blur as f32; - if crate::widgets::slider_row(ui, tl!("Blur"), &mut v, 0.0..=MAX_BLUR as f32, "px", None).changed() { - pin.blur = f64::from(v.round()).clamp(0.0, MAX_BLUR); + let (label, unit) = if k == Kind::Spin { (tl!("Blur Angle"), "°") } else { (tl!("Blur"), "px") }; + if crate::widgets::slider_row(ui, label, &mut v, 0.0..=k.max() as f32, unit, None).changed() { + pin.blur = f64::from(v.round()).clamp(0.0, k.max()); set_pins(f, &list); select(f, Some(i)); } @@ -144,14 +416,26 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { } } ui.add_space(4.0); - ui.add( - egui::Label::new( - egui::RichText::new(tl!("Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin.")) - .color(t.text_dim) - .size(11.5), - ) - .wrap(), - ); + let hint = |ui: &mut egui::Ui, text: &str| ui.add(egui::Label::new(egui::RichText::new(text).color(t.text_dim).size(11.5)).wrap()); + if k == Kind::Tilt { + hint( + ui, + tl!( + "Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it." + ), + ); + } else { + hint(ui, tl!("Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin.")); + } + if k == Kind::Iris { + hint( + ui, + tl!("Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts."), + ); + } + if k == Kind::Spin { + hint(ui, tl!("Drag the handles on the outline to resize and turn it; the ring sets the blur angle.")); + } // Delete removes the selected pin, unless a field is being typed in. let typing = ui.ctx().memory(|m| m.focused().is_some()); if !typing && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace)) { @@ -165,30 +449,79 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { } } -/// What a press at screen point `at` grabs: the selected pin's ring, a pin, or nothing (a new pin). +/// What a press at screen point `at` grabs: one of the selected pin's handles (axis 0–3 = +u, +v, +/// −u, −v; Tilt-Shift's turn dots and lines), its ring, a pin, or nothing (a new pin). #[derive(Clone, Copy, Debug, PartialEq)] enum Grab { + Axis(usize, usize), + Round(usize), + Feather(usize), + Turn(usize, i8), + Focus(usize), + Transition(usize), Ring(usize), Pin(usize), New, } +/// Distance in screen points from `at` to the infinite line through screen points `a` and `b`. +fn line_distance(at: Pos2, a: Pos2, b: Pos2) -> f32 { + let d = b - a; + let len = d.length(); + if len <= f32::EPSILON { at.distance(a) } else { ((at - a).x * d.y - (at - a).y * d.x).abs() / len } +} + fn grab(fields: &Map, xf: &ViewXform, at: Pos2) -> Grab { let Some(b) = bounds(fields) else { return Grab::New }; + let k = kind(fields); let list = pins(fields); - let screen = |p: Pin| { - let d = to_doc(b, p); - xf.to_screen(d[0] as f32, d[1] as f32) - }; + let screen = |d: [f64; 2]| xf.to_screen(d[0] as f32, d[1] as f32); + let near = |d: [f64; 2]| screen(d).distance(at) <= GRAB; if let Some(i) = selected(fields) - && let Some(p) = list.get(i) - && (screen(*p).distance(at) - RING).abs() <= RING_BAND + && let Some(p) = list.get(i).copied() { - return Grab::Ring(i); + if let Some(s) = p.iris { + if let Some(a) = axis_handles().iter().position(|&(u, v)| near(iris_point(b, p, s, u, v))) { + return Grab::Axis(i, a); + } + if k == Some(Kind::Iris) { + let (u, v) = round_handle(s); + if near(iris_point(b, p, s, u, v)) { + return Grab::Round(i); + } + if feather_handles(s).iter().any(|&(u, v)| near(iris_point(b, p, s, u, v))) { + return Grab::Feather(i); + } + } + } + if let Some(t) = p.tilt { + for side in [1i8, -1] { + if near(tilt_turn_handle(b, p, t, xf, f64::from(side))) { + return Grab::Turn(i, side); + } + } + } + if (screen(to_doc(b, p)).distance(at) - RING).abs() <= RING_BAND { + return Grab::Ring(i); + } + if let Some(t) = p.tilt + && screen(to_doc(b, p)).distance(at) > GRAB + { + let ss = short_side(b); + let on = |offset: f64| line_distance(at, screen(tilt_point(b, p, t, offset, -ss)), screen(tilt_point(b, p, t, offset, ss))) <= LINE_GRAB; + let focus = t.focus * ss; + if on(focus) || on(-focus) { + return Grab::Focus(i); + } + let edge = (t.focus + t.transition) * ss; + if on(edge) || on(-edge) { + return Grab::Transition(i); + } + } } list.iter() .enumerate() - .map(|(i, p)| (i, screen(*p).distance(at))) + .map(|(i, p)| (i, screen(to_doc(b, *p)).distance(at))) .filter(|(_, d)| *d <= GRAB) .min_by(|a, b| a.1.total_cmp(&b.1)) .map_or(Grab::New, |(i, _)| Grab::Pin(i)) @@ -201,10 +534,14 @@ pub fn canvas(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, over let Some(d) = app.ui.dialogs.last_mut().filter(|d| owns(&d.fields)) else { return false }; let f = &mut d.fields; let Some(b) = bounds(f) else { return false }; + let Some(k) = kind(f) else { return false }; let (start, down) = ctx.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down())); ctx.set_cursor_icon(match grab(f, xf, over) { - Grab::Ring(_) => egui::CursorIcon::Alias, + Grab::Ring(_) | Grab::Round(_) | Grab::Turn(..) => egui::CursorIcon::Alias, + Grab::Axis(..) | Grab::Feather(_) => egui::CursorIcon::Move, + Grab::Focus(_) | Grab::Transition(_) => egui::CursorIcon::ResizeVertical, Grab::Pin(_) => egui::CursorIcon::Grab, + Grab::New if k == Kind::Tilt => egui::CursorIcon::Default, Grab::New => egui::CursorIcon::Crosshair, }); if !down && press.is_none() { @@ -216,6 +553,12 @@ pub fn canvas(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, over let mut list = pins(f); if start { let drag = match grab(f, xf, at) { + Grab::Axis(i, a) => json!({"axis": i, "k": a}), + Grab::Round(i) => json!({"round": i}), + Grab::Feather(i) => json!({"feather": i}), + Grab::Turn(i, side) => json!({"turn": i, "side": side}), + Grab::Focus(i) => json!({"focus": i}), + Grab::Transition(i) => json!({"transition": i}), Grab::Ring(i) => { let c = list.get(i).map(|p| to_doc(b, *p)).unwrap_or(doc); json!({"ring": i, "angle": (doc[1] - c[1]).atan2(doc[0] - c[0])}) @@ -225,11 +568,14 @@ pub fn canvas(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, over let c = list.get(i).map(|p| to_doc(b, *p)).unwrap_or(doc); json!({"pin": i, "dx": c[0] - doc[0], "dy": c[1] - doc[1]}) } - Grab::New if list.len() < MAX_PINS && (b[0]..=b[2]).contains(&doc[0]) && (b[1]..=b[3]).contains(&doc[1]) => { - // A new pin takes the selected pin's blur, as the dialog's Blur shows it. - let blur = f.get("blur").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(DEFAULT_BLUR).clamp(0.0, MAX_BLUR); + // Tilt-Shift has one band: a click elsewhere adds nothing. + Grab::New if k != Kind::Tilt && list.len() < MAX_PINS && (b[0]..=b[2]).contains(&doc[0]) && (b[1]..=b[3]).contains(&doc[1]) => { + // A new pin takes the selected pin's amount (as the dialog shows it) and shape. + let blur = f.get(k.amount_key()).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(DEFAULT_BLUR).clamp(0.0, k.max()); + let fallback = if k == Kind::Spin { Iris::spin() } else { Iris::default() }; + let iris = matches!(k, Kind::Iris | Kind::Spin).then(|| selected(f).and_then(|i| list.get(i)).and_then(|p| p.iris).unwrap_or(fallback)); let (x, y) = from_doc(b, doc); - list.push(Pin { x, y, blur }); + list.push(Pin { x, y, blur, iris, tilt: None }); set_pins(f, &list); select(f, Some(list.len() - 1)); json!({"pin": list.len() - 1, "dx": 0.0, "dy": 0.0}) @@ -241,63 +587,180 @@ pub fn canvas(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, over return true; } let Some(drag) = f.get("__drag").cloned() else { return true }; - if let Some(i) = drag.get("pin").and_then(Value::as_u64).map(|i| i as usize) + let index = |key: &str| drag.get(key).and_then(Value::as_u64).map(|i| i as usize); + let ss = short_side(b); + if let Some(i) = index("pin") && let Some(p) = list.get_mut(i) { - let num = |k: &str| drag.get(k).and_then(Value::as_f64).unwrap_or(0.0); + let num = |key: &str| drag.get(key).and_then(Value::as_f64).unwrap_or(0.0); (p.x, p.y) = from_doc(b, [doc[0] + num("dx"), doc[1] + num("dy")]); - set_pins(f, &list); - } else if let Some(i) = drag.get("ring").and_then(Value::as_u64).map(|i| i as usize) + } else if let Some(i) = index("ring") && let Some(p) = list.get_mut(i) { - // The angle turned since the last frame, the short way round: clockwise raises the blur. + // The angle turned since the last frame, the short way round: clockwise raises the amount. let c = to_doc(b, *p); let a = (doc[1] - c[1]).atan2(doc[0] - c[0]); let last = drag.get("angle").and_then(Value::as_f64).unwrap_or(a); let turn = (a - last + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; - p.blur = (p.blur + turn / std::f64::consts::TAU * MAX_BLUR).clamp(0.0, MAX_BLUR); - set_pins(f, &list); - select(f, Some(i)); + p.blur = (p.blur + turn / std::f64::consts::TAU * k.max()).clamp(0.0, k.max()); f.insert("__drag".into(), json!({"ring": i, "angle": a})); + } else if let Some(i) = index("axis") + && let Some(p) = list.get_mut(i) + && let Some(mut s) = p.iris + { + // The handle follows the pointer: its distance is that axis's radius, its direction turns + // the shape (the +v handle sits a quarter turn after +u). + let c = to_doc(b, *p); + let (dx, dy) = (doc[0] - c[0], doc[1] - c[1]); + let r = (dx.hypot(dy) / ss).clamp(MIN_RADIUS, 1.0); + let a = index("k").unwrap_or(0); + if a % 2 == 0 { + s.rx = r; + } else { + s.ry = r; + } + s.angle = dy.atan2(dx).to_degrees() - 90.0 * a as f64; + p.iris = Some(s.clean()); + } else if let Some(i) = index("round") + && let Some(p) = list.get_mut(i) + && let Some(s) = p.iris + { + // Out along the diagonal rounds towards a rectangle: the handle is the shape's diagonal + // point (t, t), t = 2^(−1/m), so t gives the exponent back. + let (u, v) = iris_local(b, *p, s, doc); + let t = ((u.abs() + v.abs()) / 2.0).clamp(0.5f64.sqrt(), 0.5f64.powf(1.0 / 8.0)); + let m = -std::f64::consts::LN_2 / t.ln(); + p.iris = Some(Iris { roundness: (m - 2.0) / 6.0 * 100.0, ..s }.clean()); + } else if let Some(i) = index("feather") + && let Some(p) = list.get_mut(i) + && let Some(s) = p.iris + { + // The dots sit where the blur starts: the pointer's distance in the shape's own measure. + let (u, v) = iris_local(b, *p, s, doc); + let m = s.exponent(); + let e = (u.abs().powf(m) + v.abs().powf(m)).powf(1.0 / m); + p.iris = Some(Iris { feather: if e.is_finite() { e } else { s.feather }, ..s }.clean()); + } else if let Some(i) = index("turn") + && let Some(p) = list.get_mut(i) + && let Some(t) = p.tilt + { + // The dot's side of the band points from the centre towards the pointer: the engine's + // normal is (sin a, cos a). + let c = to_doc(b, *p); + let side = drag.get("side").and_then(Value::as_f64).unwrap_or(1.0).signum(); + let (nx, ny) = ((doc[0] - c[0]) * side, (doc[1] - c[1]) * side); + if nx.hypot(ny) > 1e-9 { + p.tilt = Some(Tilt { angle: nx.atan2(ny).to_degrees(), ..t }.clean()); + } + } else if let Some(i) = index("focus") + && let Some(p) = list.get_mut(i) + && let Some(t) = p.tilt + { + p.tilt = Some(Tilt { focus: tilt_offset(b, *p, t, doc).abs() / ss, ..t }.clean()); + } else if let Some(i) = index("transition") + && let Some(p) = list.get_mut(i) + && let Some(t) = p.tilt + { + p.tilt = Some(Tilt { transition: tilt_offset(b, *p, t, doc).abs() / ss - t.focus, ..t }.clean()); + } + set_pins(f, &list); + if let Some(i) = ["pin", "ring", "axis", "round", "feather", "turn", "focus", "transition"].into_iter().find_map(index) { + select(f, Some(i)); } ctx.request_repaint(); true } -/// Draw the pins over the image while the workspace is open: a dot per pin (filled when -/// selected) and, round the selected one, its ring with the blur as an arc from the top. -pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { - let Some(d) = app.ui.dialogs.last().filter(|d| owns(&d.fields)) else { return }; - let Some(b) = bounds(&d.fields) else { return }; - let sel = selected(&d.fields); +/// Draw a dark-edged white line from `a` to `b`, dashed when `dash` is set. +fn draw_line(painter: &egui::Painter, a: Pos2, b: Pos2, dash: bool) { let (dark, light) = (Stroke::new(3.0, Color32::from_black_alpha(150)), Stroke::new(1.5, Color32::from_white_alpha(235))); - for (i, p) in pins(&d.fields).into_iter().enumerate() { - let c = to_doc(b, p); - let c = xf.to_screen(c[0] as f32, c[1] as f32); - if sel == Some(i) { - painter.circle_stroke(c, RING, dark); - painter.circle_stroke(c, RING, Stroke::new(1.0, Color32::from_white_alpha(140))); - let frac = (p.blur / MAX_BLUR) as f32; - let steps = (frac * 64.0).ceil().max(1.0) as usize; - let arc: Vec = (0..=steps) - .map(|k| { - let a = -std::f32::consts::FRAC_PI_2 + std::f32::consts::TAU * frac * k as f32 / steps as f32; - c + vec2(a.cos(), a.sin()) * RING - }) - .collect(); - if frac > 0.0 { - painter.add(egui::Shape::line(arc.clone(), Stroke::new(4.5, Color32::from_black_alpha(150)))); - painter.add(egui::Shape::line(arc, Stroke::new(2.5, Color32::WHITE))); - } - painter.circle(c, DOT, Color32::WHITE, Stroke::new(1.0, Color32::from_black_alpha(180))); - } else { - painter.circle_stroke(c, DOT, dark); - painter.circle_stroke(c, DOT, light); - } + if dash { + painter.extend(egui::Shape::dashed_line(&[a, b], dark, 6.0, 5.0)); + painter.extend(egui::Shape::dashed_line(&[a, b], light, 6.0, 5.0)); + } else { + painter.line_segment([a, b], dark); + painter.line_segment([a, b], light); } } -/// Preview proxies run at 1/`k` of the document: the pins' pixel blur scales with them. +/// Draw the pins over the image while the workspace is open: a dot per pin (filled when +/// selected) and, round the selected one, its ring with the amount as an arc from the top, and +/// its outline and handles or Tilt-Shift's lines. +pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { + let Some(d) = app.ui.dialogs.last().filter(|d| owns(&d.fields)) else { return }; + let Some(b) = bounds(&d.fields) else { return }; + let Some(k) = kind(&d.fields) else { return }; + let sel = selected(&d.fields); + let (dark, light) = (Stroke::new(3.0, Color32::from_black_alpha(150)), Stroke::new(1.5, Color32::from_white_alpha(235))); + let screen = |p: [f64; 2]| xf.to_screen(p[0] as f32, p[1] as f32); + let handle_stroke = Stroke::new(1.0, Color32::from_black_alpha(180)); + for (i, p) in pins(&d.fields).into_iter().enumerate() { + let c = screen(to_doc(b, p)); + if sel != Some(i) { + painter.circle_stroke(c, DOT, dark); + painter.circle_stroke(c, DOT, light); + continue; + } + if let Some(s) = p.iris { + // The outline: where the blur reaches its full amount (Iris) or where the spin ends. + let m = s.exponent(); + let outline: Vec = (0..=128) + .map(|n| { + let a = std::f64::consts::TAU * f64::from(n) / 128.0; + let (cos, sin) = (a.cos(), a.sin()); + let r = (cos.abs().powf(m) + sin.abs().powf(m)).powf(-1.0 / m); + screen(iris_point(b, p, s, r * cos, r * sin)) + }) + .collect(); + painter.add(egui::Shape::line(outline.clone(), dark)); + painter.add(egui::Shape::line(outline, light)); + for (u, v) in axis_handles() { + painter.circle(screen(iris_point(b, p, s, u, v)), HANDLE, Color32::WHITE, handle_stroke); + } + if k == Kind::Iris { + let (u, v) = round_handle(s); + let r = egui::Rect::from_center_size(screen(iris_point(b, p, s, u, v)), vec2(HANDLE * 2.0, HANDLE * 2.0)); + painter.rect(r, 1.0, Color32::WHITE, handle_stroke, egui::StrokeKind::Middle); + for (u, v) in feather_handles(s) { + painter.circle(screen(iris_point(b, p, s, u, v)), HANDLE + 1.0, Color32::from_white_alpha(230), handle_stroke); + } + } + } + if let Some(t) = p.tilt { + // The sharp band's edges (solid) and where the blur is full (dashed), across the image. + let ss = short_side(b); + let clip = painter.with_clip_rect(egui::Rect::from_two_pos(screen([b[0], b[1]]), screen([b[2], b[3]])).intersect(painter.clip_rect())); + let reach = (b[2] - b[0]).hypot(b[3] - b[1]); + for (offset, dash) in [(t.focus, false), (t.focus + t.transition, true)] { + for side in [1.0, -1.0] { + let o = side * offset * ss; + draw_line(&clip, screen(tilt_point(b, p, t, o, -reach)), screen(tilt_point(b, p, t, o, reach)), dash); + } + } + for side in [1.0, -1.0] { + painter.circle(screen(tilt_turn_handle(b, p, t, xf, side)), HANDLE + 0.5, Color32::WHITE, handle_stroke); + } + } + painter.circle_stroke(c, RING, dark); + painter.circle_stroke(c, RING, Stroke::new(1.0, Color32::from_white_alpha(140))); + let frac = (p.blur / k.max()) as f32; + let steps = (frac * 64.0).ceil().max(1.0) as usize; + let arc: Vec = (0..=steps) + .map(|n| { + let a = -std::f32::consts::FRAC_PI_2 + std::f32::consts::TAU * frac * n as f32 / steps as f32; + c + vec2(a.cos(), a.sin()) * RING + }) + .collect(); + if frac > 0.0 { + painter.add(egui::Shape::line(arc.clone(), Stroke::new(4.5, Color32::from_black_alpha(150)))); + painter.add(egui::Shape::line(arc, Stroke::new(2.5, Color32::WHITE))); + } + painter.circle(c, DOT, Color32::WHITE, handle_stroke); + } +} + +/// Preview proxies run at 1/`k` of the document: the pins' pixel blur scales with them, as the +/// top-level pixel params (Tilt-Shift's `blur`) already do. Spin's blur angle doesn't. pub fn scale_for_proxy(params: &mut Value, k: u32) { if k <= 1 { return; diff --git a/crates/ui-egui/src/blur_pins_tests.rs b/crates/ui-egui/src/blur_pins_tests.rs index 33a691d3d8..0b84a6dbff 100644 --- a/crates/ui-egui/src/blur_pins_tests.rs +++ b/crates/ui-egui/src/blur_pins_tests.rs @@ -6,11 +6,16 @@ use egui_kittest::Harness; use serde_json::json; use crate::PhotocraftApp; -use crate::blur_pins::{COMMAND, Pin, pins, selected}; +use crate::blur_pins::{COMMAND, IRIS, Iris, Pin, SPIN, TILT, pins, selected}; use crate::canvas::ViewXform; /// A 400×300 document at 400 %, so it fills the window round the dialog, with Field Blur open. fn harness(selection: bool) -> Harness<'static, PhotocraftApp> { + harness_for(COMMAND, selection) +} + +/// [`harness`] with the Blur Gallery `command` open. +fn harness_for(command: &str, selection: bool) -> Harness<'static, PhotocraftApp> { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); if selection { @@ -34,7 +39,7 @@ fn harness(selection: bool) -> Harness<'static, PhotocraftApp> { h.run_steps(4); let v = &mut h.state_mut().ui.views[0]; (v.zoom, v.center, v.fit_pending) = (4.0, [200.0, 150.0], false); - crate::filter_dialog::open(h.state_mut(), COMMAND).unwrap(); + crate::filter_dialog::open(h.state_mut(), command).unwrap(); h.run_steps(4); h } @@ -86,7 +91,7 @@ fn on_screen(h: &Harness<'static, PhotocraftApp>, p: Pin, bounds: [f64; 4]) -> P fn it_opens_with_one_selected_pin_in_the_middle_of_the_bounds() { let h = harness(false); let f = fields(&h); - assert_eq!(pins(&f), vec![Pin { x: 0.5, y: 0.5, blur: 15.0 }]); + assert_eq!(pins(&f), vec![Pin { x: 0.5, y: 0.5, blur: 15.0, iris: None, tilt: None }]); assert_eq!(selected(&f), Some(0)); assert_eq!(f["__bounds"], json!([0, 0, 400, 300])); assert!(!f.contains_key("centerX") && !f.contains_key("centerY"), "the pins replace the single centre"); @@ -173,8 +178,210 @@ fn proxy_previews_scale_the_pins_blur() { assert_eq!(p["pins"][0]["blur"], json!(10.0)); } +/// Screen point of an Iris pin's local point `(u, v)` (1 = on the outline along that axis), on the +/// 400×300 canvas (shorter side 300), as the engine places it. +fn iris_at(h: &Harness<'static, PhotocraftApp>, p: Pin, u: f64, v: f64) -> Pos2 { + let s = p.iris.unwrap(); + let (cx, cy) = (p.x * 400.0, p.y * 300.0); + let (x, y) = (u * s.rx * 300.0, v * s.ry * 300.0); + let (sin, cos) = s.angle.to_radians().sin_cos(); + xf(h).to_screen((cx + x * cos - y * sin) as f32, (cy + x * sin + y * cos) as f32) +} + +fn only_pin(h: &Harness<'static, PhotocraftApp>) -> Pin { + pins(&fields(h))[0] +} + +#[test] +fn iris_opens_with_its_shape_as_a_pin() { + let h = harness_for(IRIS, false); + let f = fields(&h); + let list = pins(&f); + assert_eq!(list.len(), 1); + let (p, s, d) = (list[0], list[0].iris.unwrap(), Iris::default()); + assert_eq!((p.x, p.y, p.blur), (0.5, 0.5, 15.0)); + // The dialog's defaults come from the command spec (f32), so compare loosely. + for (got, want) in [(s.rx, d.rx), (s.ry, d.ry), (s.angle, d.angle), (s.roundness, d.roundness), (s.feather, d.feather)] { + assert!((got - want).abs() < 1e-6, "{s:?}"); + } + for k in ["centerX", "radiusX", "radiusY", "angle", "roundness", "feather"] { + assert!(!f.contains_key(k), "{k} is in the pin now"); + } + assert!(f["pins"][0]["radiusX"].as_f64().is_some_and(|r| (r - 0.35).abs() < 1e-6), "the command's own keys: {}", f["pins"]); +} + +/// The selected Iris pin's handles: an outline handle resizes its axis and turns the shape, the +/// square rounds it, the inner dots set where the blur starts; a new pin copies the shape; OK +/// applies them all. +#[test] +fn iris_handles_resize_turn_round_and_feather() { + let mut h = harness_for(IRIS, false); + // A small shape, so its handles sit in the free middle of the window, left of the dialog. + let small = json!([{"x": 0.5, "y": 0.5, "blur": 15.0, "radiusX": 0.1, "radiusY": 0.08, "angle": 0.0, "roundness": 0.0, "feather": 0.5}]); + h.state_mut().ui.dialogs.last_mut().unwrap().fields.insert("pins".into(), small); + h.run_steps(2); + // Drag the +u handle (30 px right of the centre) to 40 px below it: radius 40/300, a quarter turn. + let p = only_pin(&h); + let below = xf(&h).to_screen(200.0, 190.0); + let from = iris_at(&h, p, 1.0, 0.0); + drag(&mut h, &[from, from.lerp(below, 0.5), below]); + let s = only_pin(&h).iris.unwrap(); + assert!((s.rx - 40.0 / 300.0).abs() < 2e-3 && (s.angle - 90.0).abs() < 0.5, "{s:?}"); + assert_eq!(s.ry, 0.08, "the other axis keeps its radius"); + // Drag the corner square out along the diagonal to 0.9: the exponent 6.58, roundness 76. + let p = only_pin(&h); + let t = 0.5f64.sqrt(); + let path = [iris_at(&h, p, t, -t), iris_at(&h, p, 0.8, -0.8), iris_at(&h, p, 0.9, -0.9)]; + drag(&mut h, &path); + let s = only_pin(&h).iris.unwrap(); + let m = -std::f64::consts::LN_2 / 0.9f64.ln(); + assert!((s.roundness - (m - 2.0) / 6.0 * 100.0).abs() < 1.0, "{s:?}"); + // Drag a feather dot in to a fifth of the way along the diagonal. + let p = only_pin(&h); + let k = 0.5 * 0.9; + let path = [iris_at(&h, p, k, k), iris_at(&h, p, 0.3, 0.3), iris_at(&h, p, 0.2, 0.2)]; + drag(&mut h, &path); + let s = only_pin(&h).iris.unwrap(); + let want = 0.2 * 2f64.powf(1.0 / m); + assert!((s.feather - want).abs() < 0.01, "{} vs {want}", s.feather); + // A click away from the shape adds a pin with the same shape and blur. + let r = h.state().last_canvas_rect; + let at = pos2(r.left() + 80.0, r.bottom() - 80.0); + move_to(&mut h, at); + button(&mut h, at, true); + button(&mut h, at, false); + h.run_steps(2); + let list = pins(&fields(&h)); + assert_eq!(list.len(), 2); + assert_eq!(list[1].iris, list[0].iris); + assert_eq!(selected(&fields(&h)), Some(1)); + let id = h.state().ui.dialogs.last().unwrap().id; + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let (cmd, params) = h.state().session.journal.last().cloned().unwrap(); + assert_eq!(cmd, IRIS); + assert_eq!(params["pins"].as_array().map(Vec::len), Some(2)); + assert_eq!(params["pins"][0]["angle"], json!(s.angle), "{params}"); +} + +/// Hostile pin values from automation are cleaned before anything uses them. +#[test] +fn iris_pins_are_cleaned() { + let mut h = harness_for(IRIS, false); + let bad = json!([{"x": 7, "y": -3, "blur": -1, "radiusX": 0, "radiusY": 1e9, "angle": 540.0, "roundness": 300, "feather": 2}]); + h.state_mut().ui.dialogs.last_mut().unwrap().fields.insert("pins".into(), bad); + let p = only_pin(&h); + assert_eq!((p.x, p.y, p.blur), (1.0, 0.0, 0.0)); + let s = p.iris.unwrap(); + assert_eq!((s.rx, s.ry, s.angle, s.roundness, s.feather), (0.01, 1.0, -180.0, 100.0, 0.99)); + h.run_steps(2); +} + +fn at_doc(h: &Harness<'static, PhotocraftApp>, x: f64, y: f64) -> Pos2 { + xf(h).to_screen(x as f32, y as f32) +} + +/// Tilt-Shift's one band on the 400×300 canvas (shorter side 300): the solid lines set the sharp +/// band, the dashed ones the transition, the dots turn it, the pin moves it; a click adds nothing; +/// OK sends the command's own params. +#[test] +fn tilt_shift_lines_and_dots_shape_the_band() { + let mut h = harness_for(TILT, false); + let f = fields(&h); + assert!(!f.contains_key("pins"), "Tilt-Shift keeps its own params"); + let t = only_pin(&h).tilt.unwrap(); + assert!((t.focus - 0.1).abs() < 1e-6 && (t.transition - 0.15).abs() < 1e-6 && t.angle == 0.0, "{t:?}"); + // The band is horizontal: the solid lines at y = 150 ± 30, the dashed ones 45 px further. + // Drag the upper dashed line, far left of the pin, out to 80 px from the centre: the transition + // is 50 px. + let path = [at_doc(&h, 60.0, 75.0), at_doc(&h, 60.0, 72.0), at_doc(&h, 60.0, 70.0)]; + drag(&mut h, &path); + let t = only_pin(&h).tilt.unwrap(); + assert!((t.transition - 50.0 / 300.0).abs() < 1e-3, "{t:?}"); + // Drag the lower solid line 20 px down: the sharp band is 50 px each side. + let path = [at_doc(&h, 60.0, 180.0), at_doc(&h, 60.0, 190.0), at_doc(&h, 60.0, 200.0)]; + drag(&mut h, &path); + let t = only_pin(&h).tilt.unwrap(); + assert!((t.focus - 50.0 / 300.0).abs() < 1e-3 && (t.transition - 50.0 / 300.0).abs() < 1e-3, "{t:?}"); + // Turn: pull the lower line's dot round to 30° (the band's normal is (sin a, cos a)). + let p = only_pin(&h); + let dot = crate::blur_pins::tilt_turn_handle([0.0, 0.0, 400.0, 300.0], p, t, &xf(&h), 1.0); + let (s30, c30) = 30f64.to_radians().sin_cos(); + let path = [at_doc(&h, dot[0], dot[1]), at_doc(&h, 200.0 + 40.0 * s30, 150.0 + 40.0), at_doc(&h, 200.0 + 60.0 * s30, 150.0 + 60.0 * c30)]; + drag(&mut h, &path); + let t = only_pin(&h).tilt.unwrap(); + assert!((t.angle - 30.0).abs() < 0.5, "{t:?}"); + assert!((t.focus - 50.0 / 300.0).abs() < 1e-3, "turning keeps the widths"); + // The pin moves the band. + let path = [at_doc(&h, 200.0, 150.0), at_doc(&h, 210.0, 145.0), at_doc(&h, 220.0, 140.0)]; + drag(&mut h, &path); + let p = only_pin(&h); + assert!((p.x - 0.55).abs() < 1e-3 && (p.y - 140.0 / 300.0).abs() < 1e-3, "{p:?}"); + // A click away from the band's lines adds nothing: there is one band. + let r = h.state().last_canvas_rect; + let away = pos2(r.left() + 30.0, r.top() + 30.0); + move_to(&mut h, away); + button(&mut h, away, true); + button(&mut h, away, false); + h.run_steps(2); + assert_eq!(pins(&fields(&h)).len(), 1); + h.key_press(egui::Key::Delete); + h.run_steps(2); + assert_eq!(pins(&fields(&h)).len(), 1, "Delete keeps the one band"); + let id = h.state().ui.dialogs.last().unwrap().id; + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let (cmd, params) = h.state().session.journal.last().cloned().unwrap(); + assert_eq!(cmd, TILT); + for (k, want) in [("centerX", 0.55), ("angle", t.angle), ("focus", t.focus), ("transition", t.transition), ("blur", 15.0)] { + assert!(params[k].as_f64().is_some_and(|v| (v - want).abs() < 1e-3), "{k}: {params}"); + } + assert!(params.get("pins").is_none(), "{params}"); +} + +/// Spin Blur: outline handles resize and turn the ellipse, the ring is the blur angle (a quarter +/// turn = 90°), a click adds a pin with the same shape, OK sends `blurAngle` per pin. +#[test] +fn spin_blur_handles_and_its_angle_ring() { + let mut h = harness_for(SPIN, false); + let p = only_pin(&h); + assert_eq!(p.blur, 15.0, "the blur angle"); + assert!(p.iris.is_some_and(|s| (s.rx - 0.3).abs() < 1e-6 && (s.ry - 0.3).abs() < 1e-6)); + let small = json!([{"x": 0.5, "y": 0.5, "blurAngle": 15.0, "radiusX": 0.1, "radiusY": 0.1, "angle": 0.0}]); + h.state_mut().ui.dialogs.last_mut().unwrap().fields.insert("pins".into(), small); + h.run_steps(2); + // The +v handle (30 px below the centre) dragged 45 px out to the right: radius 45/300, and + // the shape turned so +v points right (−90°). + let p = only_pin(&h); + let path = [iris_at(&h, p, 0.0, 1.0), at_doc(&h, 230.0, 175.0), at_doc(&h, 245.0, 150.0)]; + drag(&mut h, &path); + let s = only_pin(&h).iris.unwrap(); + assert!((s.ry - 45.0 / 300.0).abs() < 2e-3 && (s.angle + 90.0).abs() < 0.5 && s.rx == 0.1, "{s:?}"); + // A quarter turn clockwise on the ring adds 90° of blur angle. + let c = at_doc(&h, 200.0, 150.0); + let ring: Vec = (0..=6).map(|k| c + vec2(24.0 * (k as f32 * 0.2618).cos(), 24.0 * (k as f32 * 0.2618).sin())).collect(); + drag(&mut h, &ring); + let p = only_pin(&h); + assert!((p.blur - 105.0).abs() < 1.0, "{}", p.blur); + assert_eq!(fields(&h)["blurAngle"], json!(p.blur), "the dialog's Blur Angle follows the pin"); + let r = h.state().last_canvas_rect; + let at = pos2(r.left() + 80.0, r.bottom() - 80.0); + move_to(&mut h, at); + button(&mut h, at, true); + button(&mut h, at, false); + h.run_steps(2); + let list = pins(&fields(&h)); + assert_eq!(list.len(), 2); + assert_eq!((list[1].iris, list[1].blur), (list[0].iris, list[0].blur)); + let id = h.state().ui.dialogs.last().unwrap().id; + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let (cmd, params) = h.state().session.journal.last().cloned().unwrap(); + assert_eq!(cmd, SPIN); + assert!(params["pins"][0]["blurAngle"].as_f64().is_some_and(|a| (a - 105.0).abs() < 1.0), "{params}"); + assert!(params["pins"][0].get("blur").is_none() && params["pins"][0].get("roundness").is_none(), "{params}"); +} + /// Alt+Cancel → Reset (#3871) starts the workspace again from one pin in the middle, as when it -/// opened, instead of leaving it with no pins; the bounds captured on opening stay. +/// opened, instead of leaving it with no pins; the bounds captured on opening stay. An Iris pin +/// gets the command's default shape back, and Spin Blur and Tilt-Shift their defaults too. #[test] fn alt_reset_starts_again_from_one_pin() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -186,8 +393,35 @@ fn alt_reset_starts_again_from_one_pin() { fields.insert("__selected".into(), json!(1)); assert!(crate::dialog_alt_reset::supported(&fields)); crate::dialog_alt_reset::apply(&mut app, &egui::Context::default(), &mut fields); - assert_eq!(pins(&fields), [Pin { x: 0.5, y: 0.5, blur: 15.0 }]); + assert_eq!(pins(&fields), [Pin { x: 0.5, y: 0.5, blur: 15.0, iris: None, tilt: None }]); assert_eq!(selected(&fields), Some(0)); assert_eq!(fields["__bounds"], bounds); assert!(!fields.contains_key("centerX") && !fields.contains_key("centerY"), "{fields:?}"); + let id = crate::filter_dialog::open(&mut app, IRIS).unwrap(); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + let bent = json!([{"x": 0.2, "y": 0.2, "blur": 40.0, "radiusX": 0.1, "radiusY": 0.1, "angle": 30.0, "roundness": 50.0, "feather": 0.2}]); + fields.insert("pins".into(), bent); + crate::dialog_alt_reset::apply(&mut app, &egui::Context::default(), &mut fields); + let list = pins(&fields); + assert_eq!(list.len(), 1); + assert_eq!((list[0].x, list[0].y, list[0].blur), (0.5, 0.5, 15.0)); + // The dialog's defaults come from the command spec (f32), so compare loosely. + let (s, d) = (list[0].iris.unwrap(), Iris::default()); + for (got, want) in [(s.rx, d.rx), (s.ry, d.ry), (s.angle, d.angle), (s.roundness, d.roundness), (s.feather, d.feather)] { + assert!((got - want).abs() < 1e-6, "{s:?}"); + } + let id = crate::filter_dialog::open(&mut app, SPIN).unwrap(); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + fields.insert("pins".into(), json!([{"x": 0.2, "y": 0.2, "blurAngle": 90.0}, {"x": 0.7, "y": 0.6, "blurAngle": 30.0}])); + crate::dialog_alt_reset::apply(&mut app, &egui::Context::default(), &mut fields); + let list = pins(&fields); + assert_eq!(list.len(), 1); + assert_eq!((list[0].x, list[0].y, list[0].blur), (0.5, 0.5, 15.0)); + let id = crate::filter_dialog::open(&mut app, TILT).unwrap(); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + fields.insert("centerX".into(), json!(0.2)); + fields.insert("angle".into(), json!(30.0)); + crate::dialog_alt_reset::apply(&mut app, &egui::Context::default(), &mut fields); + let p = pins(&fields)[0]; + assert_eq!((p.x, p.y, p.tilt.map(|t| t.angle)), (0.5, 0.5, Some(0.0))); } diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index 515e6e5a21..332c80c4b7 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -249,7 +249,6 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting ui.set_width(150.0); ui.spacing_mut().item_spacing.y = 1.0; let (br_rect, br_resp) = ui.allocate_exact_size(vec2(150.0, 23.0), Sense::click()); - br_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Brushes"))); if br_resp.hovered() { ui.painter().rect_filled(br_rect, t.radius_sm, t.hover); } diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 750a9d5506..670f442be2 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -24,7 +24,7 @@ fn sections_with_boxes_have_flags() { /// What each section's controls edit, set to non-default values. fn edit_section(i: usize, b: &mut BrushSettings) { - let dynamic = |jitter: f32, control: Control, minimum: f32| paint::Dynamic { jitter, control, fade_steps: 40, minimum }; + let dynamic = |jitter: f32, control: Control, minimum: f32| paint::Dynamic { jitter, control, fade_steps: 40, minimum, ..Default::default() }; match i { 0 => { b.size = 87.0; @@ -399,6 +399,21 @@ fn brush_mode_dropdown_shows_outside_the_pro_themes() { assert_eq!(j[n].0, "tools.setBrush"); } +#[test] +fn brush_and_eraser_size_fields_keep_sizes_above_2500_px() { + // #5708: outside the Pro themes the Size field capped at 2500 px and clamped a bigger brush + // (set from the picker or `]`) on the next frame; Photoshop and the engine allow 5000 px. + for tool in [crate::state::Tool::Brush, crate::state::Tool::Eraser] { + let mut h = options_bar_harness(tool); + let n = h.state().session.journal.len(); + h.state_mut().session.tools.brush.size = 4000.0; + h.run_steps(4); + assert_eq!(h.state().session.tools.brush.size, 4000.0, "{tool:?}"); + let j = &h.state().session.journal; + assert_eq!(j.len(), n, "showing the bar issues nothing: {:?}", &j[n..]); + } +} + #[test] fn airbrush_smoothing_options_and_symmetry_buttons_work() { // #1177: in the default (Photoshop) theme these options-bar buttons did nothing. @@ -1268,6 +1283,27 @@ fn hand_tool_offers_scale_buttons_and_requests_the_right_view() { assert!(!h.state().ui.views[0].fit_pending); } +#[test] +fn a_dynamic_gets_its_own_response_curve() { + use egui_kittest::kittest::Queryable; + let mut h = harness(0, 1); + h.state_mut().run("tools.setBrush", json!({"shapeDynamics": {"enabled": true, "size": {"control": "penPressure"}}})).unwrap(); + h.run_steps(3); + // Only Size has a control with an input to shape; its curve button shows the editor. + assert_eq!(h.query_all_by_label("Response curve").count(), 1); + h.get_by_label("Response curve").click(); + h.run_steps(3); + h.get_by_label("Soft").click(); + h.run_steps(3); + let (id, p) = last_journal(h.state()).unwrap(); + assert_eq!(id, "tools.setBrush"); + let curve: Vec<[f32; 2]> = serde_json::from_value(p["brush"]["shapeDynamics"]["size"]["curve"].clone()).unwrap(); + assert_eq!(curve, vec![[0.0, 0.0], [0.3, 0.6], [1.0, 1.0]]); + let b = &h.state().session.tools.brush; + assert_eq!(b.shape_dynamics.size.curve.points(), curve); + assert!(b.shape_dynamics.roundness.curve.is_linear() && b.transfer.opacity.curve.is_linear()); +} + #[test] fn brush_settings_hamburger_menu_actions() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/brush_picker.rs b/crates/ui-egui/src/brush_picker.rs index 8ac5bb8c2c..9bef83a647 100644 --- a/crates/ui-egui/src/brush_picker.rs +++ b/crates/ui-egui/src/brush_picker.rs @@ -166,7 +166,7 @@ pub fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, picks: Vec) { crate::brush_preset_dialog::open(app, None); } Pick::Import => { - let _ = app.open_dialog_file(); + let _ = crate::preset_files_ui::import_brushes(app); } } } diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index ff7ca5d292..e7d3a16f56 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -67,9 +67,23 @@ fn dim(ui: &mut egui::Ui, s: &str) { ui.label(RichText::new(s).color(t.text_dim)); } -/// `Control: [Off ▾] [25]`: the controller pop-up plus Fade's step count. `angle` offers the +/// Whether a dynamic's control has a 0..1 input its own response curve shapes (not Off, not the +/// direction controls, not Angle Jitter, whose controls set an angle). +pub(crate) fn takes_curve(control: Control, angle: bool) -> bool { + !angle && matches!(control, Control::Fade | Control::PenPressure | Control::PenTilt | Control::StylusWheel | Control::Rotation) +} + +/// egui memory key of the open/closed state of a dynamic's curve editor. +pub(crate) fn curve_open_id(id: &str) -> egui::Id { + egui::Id::new(("brush-curve-open", id)) +} + +/// `Control: [Off ▾] [25] [~]`: the controller pop-up, Fade's step count and the button that +/// shows this dynamic's own response curve (lit while the curve isn't linear). `angle` offers the /// direction controls (Photoshop only has them for Angle Jitter). fn control_row(ui: &mut egui::Ui, id: &str, d: &mut Dynamic, angle: bool) { + let open_id = curve_open_id(id); + let mut open: bool = ui.data(|m| m.get_temp(open_id)).unwrap_or(false); ui.horizontal(|ui| { dim(ui, tl!("Control:")); let n = if angle || matches!(d.control, Control::InitialDirection | Control::Direction) { 8 } else { 6 }; @@ -80,7 +94,17 @@ fn control_row(ui: &mut egui::Ui, id: &str, d: &mut Dynamic, angle: bool) { d.fade_steps = steps.round().clamp(1.0, 9999.0) as u32; } } + if takes_curve(d.control, angle) && crate::icons::button(ui, "spline", 22.0, open || !d.curve.is_linear(), tl!("Response curve")).clicked() { + open = !open; + } }); + if open && takes_curve(d.control, angle) { + let mut points = d.curve.points(); + if crate::pressure_curve_ui::edit_points(ui, egui::Id::new(("brush-curve-rect", id)), &mut points) { + d.curve = paint::ResponseCurve::new(&points); + } + } + ui.data_mut(|m| m.insert_temp(open_id, open)); ui.add_space(2.0); } diff --git a/crates/ui-egui/src/brushes_tab.rs b/crates/ui-egui/src/brushes_tab.rs index 703b6e0290..3f45526e52 100644 --- a/crates/ui-egui/src/brushes_tab.rs +++ b/crates/ui-egui/src/brushes_tab.rs @@ -17,7 +17,7 @@ use egui::{Color32, RichText, Sense, Stroke, pos2, vec2}; use photocraft_engine::paint::{BrushPreset, MAX_BRUSH_SIZE}; use serde_json::json; -use crate::brush_panel::{BrushesPanelState, BrushesView, Renaming, UNGROUPED, WIDTH, commit_gesture, full_uv, grouped_presets, run_or_status}; +use crate::brush_panel::{BrushesPanelState, BrushesView, Renaming, UNGROUPED, commit_gesture, full_uv, grouped_presets, run_or_status}; use crate::brush_preview; use crate::theme::{self, Tokens}; use crate::{PhotocraftApp, icons, widgets}; @@ -98,6 +98,11 @@ pub struct ListLayout { /// The Brushes tab's list. pub const PANEL_LIST: ListLayout = ListLayout { id: "brush-presets", max_height: 400.0, cell: 52.0, indent: 20.0, cards: false }; +fn responsive_list_layout(ui: &egui::Ui) -> ListLayout { + let height = ui.available_height().clamp(180.0, 640.0); + ListLayout { max_height: height, ..PANEL_LIST } +} + /// The group key (`BrushPreset::group`) behind a panel label: ungrouped presets show as /// [`UNGROUPED`]. pub fn group_key(presets: &[BrushPreset], label: &str) -> String { @@ -880,6 +885,15 @@ pub fn preset_list(ui: &mut egui::Ui, presets: &[BrushPreset], current: Option<& let show = (st.show_name, st.show_stroke, st.show_tip); let collapsed = &st.collapsed; egui::ScrollArea::vertical().id_salt(layout.id).max_height(layout.max_height).auto_shrink([false, true]).show(ui, |ui| { + // A preset, group or folder dragged to the list's top or bottom edge (or past it) scrolls + // the list, as the Layers panel's rows do, so it can be dropped on rows out of view (#5741). + let dragging = egui::DragAndDrop::has_payload_of_type::(ui.ctx()) && ui.input(|i| i.pointer.primary_down()); + let pointer = ui.ctx().pointer_interact_pos(); + let delta = crate::panels::layer_drag_edge_scroll(pointer, ui.clip_rect(), dragging, ui.input(|i| i.stable_dt)); + if delta != 0.0 { + ui.scroll_with_delta(vec2(0.0, delta)); + ui.ctx().request_repaint(); + } ui.spacing_mut().item_spacing.y = 1.0; for (label, items) in grouped_presets(presets) { let items: Vec<&BrushPreset> = @@ -920,7 +934,28 @@ pub fn preset_list(ui: &mut egui::Ui, presets: &[BrushPreset], current: Option<& egui::Area::new(egui::Id::new(("brush-drag-label", layout.id))).order(egui::Order::Tooltip).fixed_pos(p + vec2(12.0, 8.0)).interactable(false).show( ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| ui.label(name)); + if matches!(&*d, BrushDrag::Preset(_)) { + ui.set_opacity(0.8); + } + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + if let BrushDrag::Preset(n) = &*d + && let Some(preset) = presets.iter().find(|preset| preset.name == *n) + { + let brush = &preset.brush; + let tip = brush_preview::tip_texture( + ui.ctx(), + &format!("brushes-drag-tip:{}", preset.name), + &brush.tip, + (brush.hardness, brush.angle, brush.roundness), + 36, + Tokens::get(ui.ctx()).text, + ); + ui.add(egui::Image::new(&tip).fit_to_exact_size(vec2(36.0, 36.0)).alt_text(&preset.name)); + } + ui.label(name); + }); + }); }, ); } @@ -930,13 +965,15 @@ pub fn preset_list(ui: &mut egui::Ui, presets: &[BrushPreset], current: Option<& /// The Brushes tab. pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); + let inner_w = ui.available_width().max(180.0); // Size of the current brush (Photoshop's Brushes panel slider). let before = app.session.tools.brush.clone(); let mut b = before.clone(); ui.horizontal(|ui| { ui.label(RichText::new(tl!("Size")).color(t.text_dim)); + let slider_w = (ui.available_width() - 90.0).clamp(100.0, 260.0); let mut lv = b.size.max(1.0).ln(); - ui.add_sized(vec2(WIDTH - 140.0, 18.0), |ui: &mut egui::Ui| { + ui.add_sized(vec2(slider_w, 18.0), |ui: &mut egui::Ui| { let r = widgets::slider(ui, &mut lv, 0.0..=MAX_BRUSH_SIZE.ln(), None); if r.changed() { b.size = lv.exp().round().clamp(1.0, MAX_BRUSH_SIZE); @@ -953,7 +990,8 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint); ui.add_space(20.0); - widgets::text_field(ui, ui.id().with("brush-search"), &mut app.ui.brushes_panel.filter, tl!("Search Brushes"), WIDTH - 120.0, false); + let search_w = (inner_w - 112.0).clamp(90.0, inner_w - 40.0); + widgets::text_field(ui, ui.id().with("brush-search"), &mut app.ui.brushes_panel.filter, tl!("Search Brushes"), search_w, false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let view = &mut app.ui.brushes_panel.view; let grid_tip = "Grid view"; @@ -967,7 +1005,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); }); ui.add_space(6.0); - let acts = preset_list(ui, &app.session.tools.presets, app.session.tools.current_preset.as_deref(), &mut app.ui.brushes_panel, PANEL_LIST); + let acts = preset_list(ui, &app.session.tools.presets, app.session.tools.current_preset.as_deref(), &mut app.ui.brushes_panel, responsive_list_layout(ui)); apply(app, acts); ui.add_space(4.0); widgets::hairline(ui); @@ -988,8 +1026,8 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if crate::brush_picker::named(new, "Create new brush from the current settings").clicked() { crate::brush_preset_dialog::open(app, None); } - if icons::button(ui, "folder-open", 24.0, false, "Import Brushes… (.abr)").clicked() { - let _ = app.open_dialog_file(); + if icons::button(ui, "folder-open", 24.0, false, "Import Brushes… (.abr or image)").clicked() { + let _ = crate::preset_files_ui::import_brushes(app); } }); }); diff --git a/crates/ui-egui/src/brushes_tab_tests.rs b/crates/ui-egui/src/brushes_tab_tests.rs index 4c61ecc54d..0d0a8b790f 100644 --- a/crates/ui-egui/src/brushes_tab_tests.rs +++ b/crates/ui-egui/src/brushes_tab_tests.rs @@ -7,6 +7,87 @@ use egui_kittest::kittest::{NodeT, Queryable}; use super::*; +#[test] +fn dragging_a_brush_shows_its_tip_preview() { + for theme in crate::theme::ThemeKind::ALL { + for mode in 0..3 { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("prefs.set", json!({"path": "interface.theme", "value": theme.id()})).unwrap(); + app.ui.panels.brush_settings = mode != 2; + app.ui.brush_tab = 1; + app.ui.brushes_panel.view = if mode == 1 { BrushesView::Grid } else { BrushesView::List }; + if mode == 2 { + app.ui.brush_picker = Some([300.0, 120.0]); + } + let name = app.session.tools.presets[0].name.clone(); + let brush = app.session.tools.brush.clone(); + let presets = app.session.tools.presets.clone(); + let mut h = Harness::builder().with_size(vec2(1200.0, 900.0)).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, theme); + app + }); + h.run_steps(4); + let start = h.get_by_role_and_label(Role::Button, &name).rect().center(); + h.hover_at(start); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + let end = start + vec2(50.0, 10.0); + h.event(egui::Event::PointerMoved(end)); + h.run_steps(4); + assert!(matches!(egui::DragAndDrop::payload::(&h.ctx).as_deref(), Some(BrushDrag::Preset(n)) if n == &name)); + assert!(h.query_by_role_and_label(Role::Image, &name).is_some(), "dragged brush needs a real tip thumbnail, not only its name"); + let renders = brush_preview::render_count(&h.ctx); + h.run_steps(2); + assert_eq!(brush_preview::render_count(&h.ctx), renders, "unchanged drag preview stays cached"); + h.event(egui::Event::PointerMoved(start)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(4); + assert!(h.query_by_role_and_label(Role::Image, &name).is_none(), "preview disappears on release"); + assert_eq!(h.state().session.tools.brush, brush, "preview does not change brush settings"); + assert_eq!(h.state().session.tools.presets, presets, "dropping on the source preserves the library"); + } + } +} + +/// #5741: a brush dragged to the list's bottom edge (or below it) scrolls the list, so it can be +/// dropped on rows out of view; the list used to stay put, and the drag stopped at the last +/// visible row. +#[test] +fn dragging_a_brush_to_the_list_edge_scrolls_the_list() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.ui.panels.brush_settings = true; + app.ui.brush_tab = 1; + app.ui.brushes_panel.view = BrushesView::List; + app.ui.brushes_panel.collapsed.clear(); + let name = app.session.tools.presets[0].name.clone(); + let (w, h_px) = (1200.0, 520.0); + let mut h = Harness::builder().with_size(vec2(w, h_px)).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::ALL[0]); + app + }); + h.run_steps(4); + let start = h.get_by_role_and_label(Role::Button, &name).rect(); + h.hover_at(start.center()); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start.center(), button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(start.center() + vec2(0.0, 12.0))); + h.run_steps(2); + assert!(egui::DragAndDrop::has_payload_of_type::(&h.ctx), "the brush is being dragged"); + // Held at the bottom of the window, below the list's last visible row. + let bottom = egui::pos2(start.center().x, h_px - 2.0); + for _ in 0..12 { + h.event(egui::Event::PointerMoved(bottom)); + h.run_steps(1); + } + let moved = h.query_by_role_and_label(Role::Button, &name).map(|n| n.rect()); + assert!(moved.is_none_or(|r| r.top() < start.top() - 1.0), "the list scrolled down: {start:?} → {moved:?}"); + h.event(egui::Event::PointerButton { pos: bottom, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); +} + #[test] fn grid_columns_centre_whole_columns_past_the_indent() { // 6 × 44 + 5 × 3 = 279 of the 290 past a 4 pt indent: 5 spare on each side (rounded down). diff --git a/crates/ui-egui/src/caf_ui.rs b/crates/ui-egui/src/caf_ui.rs new file mode 100644 index 0000000000..747e9d4bcf --- /dev/null +++ b/crates/ui-egui/src/caf_ui.rs @@ -0,0 +1,1972 @@ +//! Edit › Content-Aware Fill…: Photoshop's Content-Aware Fill workspace, a full-window dialog +//! like Liquify's. +//! +//! Measured on Photoshop 25.4 (black box) and laid out the same way: the tools on the left +//! (Sampling Brush, Lasso, Hand, Zoom) with their options bar above, the document in the middle +//! with the sampling area tinted (green, 50 %) and the fill area outlined, a Preview panel, and +//! the Content-Aware Fill panel (Sampling Area Overlay, Sampling Area Options, Fill Settings, +//! Output Settings) with OK, Apply and Cancel. +//! +//! * **Sampling Brush**: a hard round brush. In Auto and Rectangular it subtracts from the +//! sampling area, in Custom (which starts empty) it adds; Alt reverses. Painting keeps the +//! sampling option, and added strokes may reach past the sampling window. Its starting size is +//! a tenth of the window's side (20 px for a 50 px selection). +//! * **Lasso**: edits the fill area itself (New, Add, Subtract, Intersect; Expand and Contract by +//! a few pixels). The window and the Auto area follow the new fill area; brush strokes stay. +//! * **Preview**: the fill computed in the background after each change, opening framed on the +//! sampling window. +//! * **Apply** fills (one history step) and keeps the workspace open; **OK** fills and closes; +//! **Cancel** closes, keeping what was applied. Ctrl+Z undoes the last stroke or lasso edit. +//! +//! What the workspace holds is plain data (the sampling option, brush strokes, lasso edits and +//! fill settings); OK and Apply run `edit.contentAwareFill` with it, and the preview runs the same +//! plan ([`photocraft_engine::edit_menu_cmds::content_aware_plan`]), so the preview is exactly +//! what gets written. The settings are remembered for the next time (`prefs.dialogs`). + +use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2}; +use photocraft_algo::content_aware::{SamplingStroke, paint_strokes}; +use photocraft_doc::LayerId; +use photocraft_engine::edit_menu_cmds::{MAX_CAF_EDITS, MAX_CAF_POINTS}; +use photocraft_geom::Rect; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::canvas::ToolEvent; +use crate::theme::Tokens; +use crate::widgets; + +/// Longest side of the document proxy shown in the view (px). +const PROXY_SIDE: usize = 1600; +const LEFT_W: f32 = 48.0; +const BAR_H: f32 = 36.0; +const PANEL_W: f32 = 300.0; +const PREVIEW_W: f32 = 380.0; +const UNDO_LIMIT: usize = 100; +/// Outlines with more edges than this are drawn at the proxy's resolution. +const MAX_OUTLINE_EDGES: usize = 200_000; +/// Photoshop's overlay colour as it shows (a 50 % tint of grey 200 reads 171, 204, 153). +const OVERLAY_COLOR: [u8; 3] = [142, 208, 106]; +/// The `prefs.dialogs` key under which the settings are remembered. +const REMEMBERED: &str = "edit.contentAwareFill.workspace"; +const CMD: &str = "edit.contentAwareFill"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CafTool { + Brush, + Lasso, + Hand, + Zoom, +} + +impl CafTool { + fn name(self) -> &'static str { + match self { + CafTool::Brush => "brush", + CafTool::Lasso => "lasso", + CafTool::Hand => "hand", + CafTool::Zoom => "zoom", + } + } + fn from_name(s: &str) -> Option { + Some(match s { + "brush" => CafTool::Brush, + "lasso" => CafTool::Lasso, + "hand" => CafTool::Hand, + "zoom" => CafTool::Zoom, + _ => return None, + }) + } +} + +/// The lasso's selection modes, in options-bar order. +const LASSO_OPS: [&str; 4] = ["new", "add", "subtract", "intersect"]; + +/// The panel's settings (remembered between uses). +#[derive(Clone, Debug, PartialEq)] +pub struct CafOpts { + pub show_overlay: bool, + /// Overlay opacity, 0..100. + pub opacity: f32, + pub color: [u8; 3], + /// The overlay marks what is left out (Indicates: Excluded Area) rather than the sampling area. + pub excluded: bool, + /// auto | rectangular | custom + pub sampling: String, + pub sample_all: bool, + /// default | none | high | veryHigh + pub color_adaptation: String, + /// none | low | medium | high | full + pub rotation: String, + pub scale: bool, + pub mirror: bool, + /// current | new | duplicate (Photoshop opens on New Layer) + pub output: String, +} + +impl Default for CafOpts { + fn default() -> Self { + CafOpts { + show_overlay: true, + opacity: 50.0, + color: OVERLAY_COLOR, + excluded: false, + sampling: "auto".into(), + sample_all: false, + color_adaptation: "default".into(), + rotation: "none".into(), + scale: false, + mirror: false, + output: "new".into(), + } + } +} + +impl CafOpts { + /// Applies the settings named in `v` (control channel and remembered keys); others stay. + pub fn apply(&mut self, v: &Value) -> Result<(), String> { + let flag = |k: &str| v.get(k).and_then(Value::as_bool); + let text = |k: &str| v.get(k).and_then(Value::as_str); + for (k, slot) in [ + ("showOverlay", &mut self.show_overlay), + ("excluded", &mut self.excluded), + ("sampleAllLayers", &mut self.sample_all), + ("scale", &mut self.scale), + ("mirror", &mut self.mirror), + ] { + if let Some(b) = flag(k) { + *slot = b; + } + } + if let Some(n) = v.get("opacity").and_then(Value::as_f64).filter(|n| n.is_finite()) { + self.opacity = (n as f32).clamp(0.0, 100.0); + } + if let Some(c) = v.get("color").and_then(Value::as_array).filter(|c| c.len() == 3) { + let ch = |i: usize| c[i].as_u64().map(|v| v.min(255) as u8); + if let (Some(r), Some(g), Some(b)) = (ch(0), ch(1), ch(2)) { + self.color = [r, g, b]; + } + } + let pick = |k: &str, allowed: &[&str]| -> Result, String> { + match text(k) { + None => Ok(None), + Some(s) if allowed.contains(&s) => Ok(Some(s.to_string())), + Some(s) => Err(format!("bad {k} `{s}` ({})", allowed.join("|"))), + } + }; + if let Some(s) = pick("sampling", &["auto", "rectangular", "custom"])? { + self.sampling = s; + } + if let Some(s) = pick("colorAdaptation", &["none", "default", "high", "veryHigh"])? { + self.color_adaptation = s; + } + if let Some(s) = pick("rotationAdaptation", &["none", "low", "medium", "high", "full"])? { + self.rotation = s; + } + if let Some(s) = pick("output", &["current", "new", "duplicate"])? { + self.output = s; + } + Ok(()) + } + + /// The settings as [`CafOpts::apply`] reads them. + pub fn to_json(&self) -> Value { + json!({ + "showOverlay": self.show_overlay, + "opacity": self.opacity, + "color": self.color, + "excluded": self.excluded, + "sampling": self.sampling, + "sampleAllLayers": self.sample_all, + "colorAdaptation": self.color_adaptation, + "rotationAdaptation": self.rotation, + "scale": self.scale, + "mirror": self.mirror, + "output": self.output, + }) + } +} + +/// One undoable step. +#[derive(Clone, Debug)] +enum Step { + Stroke(SamplingStroke), + Fill(Value), + /// A change of the sampling option (or a reset), with what it replaced. + State { + sampling: String, + strokes: Vec, + fill_edits: Vec, + }, +} + +fn stroke_json(s: &SamplingStroke) -> Value { + json!({"mode": if s.add { "add" } else { "subtract" }, "size": s.size, "points": s.points}) +} + +/// What the plan (without the brush strokes) says: the window, the fill area and the sampling +/// option's area, over `rect`. +struct Base { + window: Rect, + rect: Rect, + hole: Vec, + base: Vec, +} + +impl Base { + /// The index into `hole` and `base` of document point `(x, y)`, when it is inside `rect`. + fn index(&self, x: f64, y: f64) -> Option { + let (xi, yi) = (x.floor() as i32, y.floor() as i32); + self.rect.contains(xi, yi).then(|| (yi - self.rect.y0) as usize * self.rect.width() as usize + (xi - self.rect.x0) as usize) + } +} + +/// A computed preview: the filled rectangle as an image, or why it failed. +#[cfg(not(target_arch = "wasm32"))] +type PreviewResult = Result<(Rect, egui::ColorImage), String>; + +/// The preview being computed or shown. +#[derive(Default)] +struct Preview { + /// Bumped whenever the state changes; a result for an older generation is dropped. + want: u64, + #[cfg(not(target_arch = "wasm32"))] + running: Option<(u64, std::sync::mpsc::Receiver, photocraft_engine::jobs::JobCtx)>, + shown: u64, + /// A finished preview waiting to be uploaded (on the UI thread, which has the context). + pending_image: Option<(Rect, egui::ColorImage)>, + tex: Option<(TextureHandle, Rect)>, + /// Screen px per document px (0 = frame the window on the next frame) and the view centre. + zoom: f32, + center: [f64; 2], + error: Option, +} + +impl Preview { + /// Takes the result of the preview of state `generation`: an image to upload, or the error, + /// which stands for that state until it changes (the same preview is not started again). + fn finish(&mut self, generation: u64, r: Result<(Rect, egui::ColorImage), String>) { + self.shown = generation; + self.tex = None; + match r { + Ok(img) => { + self.pending_image = Some(img); + self.error = None; + } + Err(e) => { + self.pending_image = None; + self.error = Some(e); + } + } + } +} + +pub struct CafWorkspace { + pub layer: LayerId, + layer_name: String, + canvas: Rect, + /// The document revision the textures and the base were made from. + revision: u64, + pub opts: CafOpts, + pub tool: CafTool, + pub brush_add: bool, + pub brush_size: f64, + pub lasso_op: usize, + pub expand_by: f32, + pub strokes: Vec, + pub fill_edits: Vec, + undo: Vec, + redo: Vec, + cur: Option, + /// The lasso polygon being drawn and its selection mode. + lasso: Option<(usize, Vec<[f64; 2]>)>, + base: Base, + /// Fill-area outline edges (document px), or `None` when there are too many. + outline: Option>, + /// Document px per proxy px, and the proxy size. + k: usize, + pw: usize, + ph: usize, + /// Per proxy pixel: the sampling option's area (before the strokes), the fill area, and the + /// sampling area after the strokes so far (the first painted with them in order, as the + /// engine's `paint_strokes` paints the full-resolution one). + base_px: Vec, + hole_px: Vec, + mask: Vec, + /// The pointer is down on the view with a tool: one press is one `Down`, egui's drag (which + /// starts a few pixels later) continues it. + pressed: bool, + /// The device's texture side limit, as the context reports it each frame. + texture_limit: usize, + image_tex: Option, + overlay_tex: Option, + overlay_dirty: bool, + zoom: f32, + center: [f64; 2], + hover: Option<[f64; 2]>, + preview: Preview, + pub error: Option, +} + +/// The plan of the current state (without strokes when `strokes` is false). +fn plan_params(w: &CafWorkspace, strokes: bool) -> Value { + let o = &w.opts; + let mut p = json!({ + "sampling": o.sampling, + "sampleAllLayers": o.sample_all, + "colorAdaptation": o.color_adaptation, + "rotationAdaptation": o.rotation, + "scale": o.scale, + "mirror": o.mirror, + "output": o.output, + "layer": w.layer.0, + "fillEdits": w.fill_edits, + }); + if strokes && !w.strokes.is_empty() { + p["samplingStrokes"] = Value::Array(w.strokes.iter().map(stroke_json).collect()); + } + p +} + +impl CafWorkspace { + pub fn describe(&self) -> Value { + let sampled = self.mask.iter().zip(&self.hole_px).filter(|(m, h)| **m && !**h).count(); + json!({ + "layer": self.layer.0, + "tool": self.tool.name(), + "brushSize": self.brush_size, + "brushMode": if self.brush_add { "add" } else { "subtract" }, + "lassoMode": LASSO_OPS[self.lasso_op], + "expandBy": self.expand_by, + "strokes": self.strokes.len() + usize::from(self.cur.is_some()), + "fillEdits": self.fill_edits.len(), + "undo": self.undo.len(), + "redo": self.redo.len(), + "window": [self.base.window.x0, self.base.window.y0, self.base.window.width(), self.base.window.height()], + "fillArea": self.base.hole.iter().filter(|h| **h).count(), + "sampledProxyPixels": sampled, + "proxyScale": self.k, + "settings": self.opts.to_json(), + "previewReady": self.preview.tex.is_some() && self.preview.shown == self.preview.want, + "previewError": self.preview.error, + "error": self.error, + }) + } + + /// The proxy, in proxy px. + fn proxy_area(&self) -> Rect { + Rect::new(0, 0, self.pw as i32, self.ph as i32) + } + + /// `s` in proxy coordinates: proxy pixel `(px, py)` is centred on document point + /// `canvas.x0 + (px + 0.5) k`, so the stroke's points and size scale by `1 / k`. + fn proxy_stroke(&self, s: &SamplingStroke) -> SamplingStroke { + let k = self.k as f64; + let (ox, oy) = (f64::from(self.canvas.x0), f64::from(self.canvas.y0)); + SamplingStroke { add: s.add, size: s.size / k, points: s.points.iter().map(|p| [(p[0] - ox) / k, (p[1] - oy) / k]).collect() } + } + + /// Paints `s` (a stroke, or one segment of the one being drawn) into the proxy's sampling + /// area: only the pixels within the brush of its path are visited, whatever came before. + fn paint(&mut self, s: &SamplingStroke) { + let (s, area) = (self.proxy_stroke(s), self.proxy_area()); + paint_strokes(&mut self.mask, area, &[s]); + self.overlay_dirty = true; + } + + /// Takes a new base: its option's area and fill area at the proxy's pixel centres, then the + /// strokes over it. + fn set_base(&mut self, b: Base) { + self.outline = outline(&b); + let k = self.k as f64; + for py in 0..self.ph { + for px in 0..self.pw { + let (x, y) = (f64::from(self.canvas.x0) + (px as f64 + 0.5) * k, f64::from(self.canvas.y0) + (py as f64 + 0.5) * k); + let i = b.index(x, y); + let at = |v: &[bool]| i.is_some_and(|i| v.get(i).copied().unwrap_or(false)); + self.base_px[py * self.pw + px] = at(&b.base); + self.hole_px[py * self.pw + px] = at(&b.hole); + } + } + self.base = b; + self.restate_all(); + } + + /// Rebuilds the proxy's sampling area: the base painted with every stroke in order. Each + /// stroke costs its own pixels, so this is linear in the strokes, not strokes × pixels. + fn restate_all(&mut self) { + self.mask.clone_from(&self.base_px); + let strokes: Vec = self.strokes.iter().chain(self.cur.iter()).map(|s| self.proxy_stroke(s)).collect(); + let area = self.proxy_area(); + paint_strokes(&mut self.mask, area, &strokes); + self.overlay_dirty = true; + } + + /// Points in the strokes and lasso edits so far, against the engine's budget. + fn points_used(&self) -> usize { + let strokes: usize = self.strokes.iter().chain(self.cur.iter()).map(|s| s.points.len()).sum(); + let lassos: usize = self.fill_edits.iter().map(|e| e.get("points").and_then(Value::as_array).map_or(0, Vec::len)).sum(); + strokes + lassos + } + + /// The preview is out of date. + fn stale(&mut self) { + self.preview.want += 1; + } + + fn begin_stroke(&mut self, p: [f64; 2], alt: bool) { + // The engine takes at most MAX_CAF_EDITS strokes and MAX_CAF_POINTS points in all: the + // brush stops there, so Apply and the preview never fail on what was painted. + if self.strokes.len() >= MAX_CAF_EDITS { + self.error = Some(format!("at most {MAX_CAF_EDITS} sampling strokes: undo some, or reset the sampling area")); + return; + } + if self.points_used() >= MAX_CAF_POINTS { + self.error = Some(points_limit()); + return; + } + self.error = None; + let add = self.brush_add != alt; + let s = SamplingStroke { add, size: self.brush_size, points: vec![p] }; + self.paint(&s); + self.cur = Some(s); + } + + fn extend_stroke(&mut self, p: [f64; 2]) { + if self.cur.is_none() { + return; + } + if self.points_used() >= MAX_CAF_POINTS { + self.error = Some(points_limit()); + return; + } + let Some(s) = self.cur.as_mut() else { return }; + if s.points.last().is_some_and(|l| l[0] == p[0] && l[1] == p[1]) { + return; + } + let last = *s.points.last().unwrap_or(&p); + s.points.push(p); + let seg = SamplingStroke { add: s.add, size: s.size, points: vec![last, p] }; + self.paint(&seg); + } + + fn end_stroke(&mut self) { + if let Some(s) = self.cur.take() { + self.strokes.push(s.clone()); + self.push(Step::Stroke(s)); + self.stale(); + } + } + + fn push(&mut self, s: Step) { + self.redo.clear(); + if self.undo.len() == UNDO_LIMIT { + self.undo.remove(0); + } + self.undo.push(s); + } +} + +fn points_limit() -> String { + format!("at most {MAX_CAF_POINTS} points in all sampling strokes and lasso edits: undo some, or reset") +} + +/// The plan without strokes: the window, fill area and the option's area. Custom starts empty +/// (the window and fill area are those of Rectangular). +fn compute_base(app: &PhotocraftApp, w: &CafWorkspace) -> Result { + let st = app.session.active().ok_or("no document")?; + let mut p = plan_params(w, false); + let custom = w.opts.sampling == "custom"; + if custom { + p["sampling"] = json!("rectangular"); + } + let plan = photocraft_engine::edit_menu_cmds::content_aware_plan(&st.doc, w.layer, &p, CMD).map_err(|e| e.to_string())?; + let base = if custom { vec![false; plan.base.len()] } else { plan.base }; + Ok(Base { window: plan.window, rect: plan.rect, hole: plan.hole, base }) +} + +/// Unit edges between the fill area and the rest, in document px. +fn outline(b: &Base) -> Option> { + let (w, h) = (b.rect.width() as usize, b.rect.height() as usize); + let (ox, oy) = (b.rect.x0 as f32, b.rect.y0 as f32); + let at = |x: i64, y: i64| x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && b.hole.get(y as usize * w + x as usize).copied().unwrap_or(false); + let mut out = Vec::new(); + for y in 0..h as i64 { + for x in 0..w as i64 { + if !at(x, y) { + continue; + } + let (fx, fy) = (ox + x as f32, oy + y as f32); + if !at(x - 1, y) { + out.push([[fx, fy], [fx, fy + 1.0]]); + } + if !at(x + 1, y) { + out.push([[fx + 1.0, fy], [fx + 1.0, fy + 1.0]]); + } + if !at(x, y - 1) { + out.push([[fx, fy], [fx + 1.0, fy]]); + } + if !at(x, y + 1) { + out.push([[fx, fy + 1.0], [fx + 1.0, fy + 1.0]]); + } + if out.len() > MAX_OUTLINE_EDGES { + return None; + } + } + } + Some(out) +} + +/// Recompute the base after a change of the fill area, the sampling option or the document. +fn rebase(app: &mut PhotocraftApp) { + let Some(w) = app.distort.caf.as_ref() else { return }; + let r = compute_base(app, w); + let Some(w) = app.distort.caf.as_mut() else { return }; + match r { + Ok(b) => { + w.set_base(b); + w.error = None; + } + Err(e) => { + w.error = Some(e); + w.restate_all(); + } + } + w.stale(); +} + +/// Opens the workspace on the active layer and its selection. +pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { + if !app.session.is_enabled(CMD) { + return Err("Content-Aware Fill needs a selection on a pixel layer".into()); + } + let st = app.session.active().ok_or("no document")?; + let layer = st.active_layer.ok_or("no active layer")?; + let canvas = st.doc.bounds(); + let revision = st.revision; + let name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default(); + let k = (canvas.width().max(canvas.height()) as usize).div_ceil(PROXY_SIDE).max(1); + let (pw, ph) = ((canvas.width() as usize).div_ceil(k), (canvas.height() as usize).div_ceil(k)); + let mut opts = CafOpts::default(); + if let Some(saved) = app.session.prefs().dialogs.get(REMEMBERED) { + let _ = opts.apply(saved); + } + let mut w = CafWorkspace { + layer, + layer_name: name, + canvas, + revision, + opts, + tool: CafTool::Brush, + brush_add: false, + brush_size: 20.0, + lasso_op: 0, + expand_by: 3.0, + strokes: Vec::new(), + fill_edits: Vec::new(), + undo: Vec::new(), + redo: Vec::new(), + cur: None, + lasso: None, + base: Base { window: Rect::EMPTY, rect: Rect::EMPTY, hole: Vec::new(), base: Vec::new() }, + outline: None, + k, + pw, + ph, + base_px: vec![false; pw * ph], + hole_px: vec![false; pw * ph], + mask: vec![false; pw * ph], + pressed: false, + texture_limit: 2048, + image_tex: None, + overlay_tex: None, + overlay_dirty: true, + zoom: 0.0, + center: [f64::from(canvas.x0 + canvas.x1) / 2.0, f64::from(canvas.y0 + canvas.y1) / 2.0], + hover: None, + preview: Preview::default(), + error: None, + }; + w.brush_add = w.opts.sampling == "custom"; + let base = compute_base(app, &w)?; + w.brush_size = photocraft_algo::content_aware::default_brush_size(bounds(&base)); + w.set_base(base); + w.stale(); + app.distort.caf = Some(w); + Ok(()) +} + +/// Bounds of the fill area. +fn bounds(b: &Base) -> Rect { + let w = b.rect.width() as usize; + b.hole.iter().enumerate().filter(|(_, h)| **h).fold(Rect::EMPTY, |r, (i, _)| { + let (x, y) = (b.rect.x0 + (i % w) as i32, b.rect.y0 + (i / w) as i32); + r.union(&Rect::new(x, y, x + 1, y + 1)) + }) +} + +fn remember(app: &mut PhotocraftApp, opts: &CafOpts) { + let v = opts.to_json(); + app.session.prefs.edit(|p| p.dialogs.insert(REMEMBERED.into(), v)); +} + +/// Apply: fill with the current state (one history step); the workspace stays open. +pub fn apply(app: &mut PhotocraftApp) -> Result { + let Some(w) = app.distort.caf.as_mut() else { return Err("Content-Aware Fill is not open".into()) }; + w.end_stroke(); + let p = plan_params(w, true); + let opts = w.opts.clone(); + remember(app, &opts); + let r = app.run(CMD, p); + if r.is_ok() { + refresh(app); + } + r +} + +/// OK: fill and close. +pub fn commit(app: &mut PhotocraftApp) { + let ok = apply(app); + if let Err(e) = ok { + app.ui.status = e; + app.ui.status_error = true; + } + close(app); +} + +/// Cancel (button, Esc): closes; what was applied stays. The settings are kept. +pub fn cancel(app: &mut PhotocraftApp) { + if let Some(w) = app.distort.caf.as_ref() { + let opts = w.opts.clone(); + remember(app, &opts); + } + close(app); +} + +fn close(app: &mut PhotocraftApp) { + if let Some(w) = app.distort.caf.take() { + cancel_preview(w); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn cancel_preview(w: CafWorkspace) { + if let Some((_, _, job)) = w.preview.running { + job.cancel(); + } +} + +#[cfg(target_arch = "wasm32")] +fn cancel_preview(_: CafWorkspace) {} + +/// The document changed under the workspace (an Apply): new textures, a new base. +fn refresh(app: &mut PhotocraftApp) { + let rev = app.session.active().map(|s| s.revision); + if let Some(w) = app.distort.caf.as_mut() { + w.image_tex = None; + w.revision = rev.unwrap_or(w.revision); + } + rebase(app); +} + +fn undo(app: &mut PhotocraftApp) { + let Some(w) = app.distort.caf.as_mut() else { return }; + w.end_stroke(); + let Some(step) = w.undo.pop() else { return }; + let rebuild = match &step { + Step::Stroke(_) => { + w.strokes.pop(); + w.redo.push(step); + w.restate_all(); + w.stale(); + false + } + Step::Fill(_) => { + w.fill_edits.pop(); + w.redo.push(step); + true + } + Step::State { sampling, strokes, fill_edits } => { + let now = Step::State { sampling: w.opts.sampling.clone(), strokes: w.strokes.clone(), fill_edits: w.fill_edits.clone() }; + w.opts.sampling = sampling.clone(); + w.strokes = strokes.clone(); + w.fill_edits = fill_edits.clone(); + w.brush_add = w.opts.sampling == "custom"; + w.redo.push(now); + true + } + }; + if rebuild { + rebase(app); + } +} + +fn redo(app: &mut PhotocraftApp) { + let Some(w) = app.distort.caf.as_mut() else { return }; + w.end_stroke(); + let Some(step) = w.redo.pop() else { return }; + let rebuild = match &step { + Step::Stroke(s) => { + w.strokes.push(s.clone()); + w.undo.push(step); + w.restate_all(); + w.stale(); + false + } + Step::Fill(e) => { + w.fill_edits.push(e.clone()); + w.undo.push(step); + true + } + Step::State { sampling, strokes, fill_edits } => { + let now = Step::State { sampling: w.opts.sampling.clone(), strokes: w.strokes.clone(), fill_edits: w.fill_edits.clone() }; + w.opts.sampling = sampling.clone(); + w.strokes = strokes.clone(); + w.fill_edits = fill_edits.clone(); + w.brush_add = w.opts.sampling == "custom"; + w.undo.push(now); + true + } + }; + if rebuild { + rebase(app); + } +} + +/// A lasso edit of the fill area (or Expand / Contract), undoable. +fn fill_edit(app: &mut PhotocraftApp, edit: Value) { + let Some(w) = app.distort.caf.as_mut() else { return }; + w.end_stroke(); + // The engine's limits, kept here (see `begin_stroke`). + if w.fill_edits.len() >= MAX_CAF_EDITS { + w.error = Some(format!("at most {MAX_CAF_EDITS} lasso edits: undo some, or reset the fill area")); + return; + } + if w.points_used() + edit.get("points").and_then(Value::as_array).map_or(0, Vec::len) > MAX_CAF_POINTS { + w.error = Some(points_limit()); + return; + } + w.fill_edits.push(edit.clone()); + w.push(Step::Fill(edit)); + rebase(app); + // An edit that empties the fill area is refused (the plan says so): undo it. + if app.distort.caf.as_ref().is_some_and(|w| w.error.is_some()) { + let msg = app.distort.caf.as_ref().and_then(|w| w.error.clone()); + undo(app); + if let Some(w) = app.distort.caf.as_mut() { + w.redo.clear(); + w.error = msg; + } + } +} + +/// A new sampling option (Auto, Rectangular, Custom): the brush strokes start over and the brush +/// takes the option's mode (Custom adds, the others subtract). +fn set_sampling(app: &mut PhotocraftApp, sampling: &str) { + let Some(w) = app.distort.caf.as_mut() else { return }; + if w.opts.sampling == sampling && w.strokes.is_empty() { + return; + } + w.end_stroke(); + let prev = Step::State { sampling: w.opts.sampling.clone(), strokes: std::mem::take(&mut w.strokes), fill_edits: w.fill_edits.clone() }; + w.push(prev); + w.opts.sampling = sampling.to_string(); + w.brush_add = sampling == "custom"; + rebase(app); +} + +/// Reset buttons: the sampling options, the fill settings, or everything. +fn reset(app: &mut PhotocraftApp, what: &str) { + let Some(w) = app.distort.caf.as_mut() else { return }; + let d = CafOpts::default(); + match what { + "fill" => { + w.opts.color_adaptation = d.color_adaptation; + w.opts.rotation = d.rotation; + w.opts.scale = d.scale; + w.opts.mirror = d.mirror; + w.stale(); + } + "sampling" => { + w.opts.sample_all = false; + set_sampling(app, "auto"); + } + "lasso" => { + if !w.fill_edits.is_empty() { + let prev = Step::State { sampling: w.opts.sampling.clone(), strokes: w.strokes.clone(), fill_edits: std::mem::take(&mut w.fill_edits) }; + w.push(prev); + rebase(app); + } + } + _ => { + let prev = + Step::State { sampling: w.opts.sampling.clone(), strokes: std::mem::take(&mut w.strokes), fill_edits: std::mem::take(&mut w.fill_edits) }; + w.push(prev); + w.opts = CafOpts::default(); + w.brush_add = false; + rebase(app); + } + } +} + +/// Control channel: `edit.contentAwareFill {"ui": {...}}` while the workspace is open. +pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { + let flag = |k: &str| ui.get(k).and_then(Value::as_bool) == Some(true); + if flag("cancel") { + cancel(app); + return Ok(json!({"cancelled": true})); + } + let before = app.distort.caf.as_ref().ok_or("Content-Aware Fill is not open")?.opts.clone(); + { + let w = app.distort.caf.as_mut().ok_or("Content-Aware Fill is not open")?; + let mut o = w.opts.clone(); + o.apply(ui)?; + // The sampling option goes through `set_sampling` (it resets the strokes). + let sampling = std::mem::replace(&mut o.sampling, w.opts.sampling.clone()); + w.opts = o; + if let Some(t) = ui.get("tool").and_then(Value::as_str) { + w.tool = CafTool::from_name(t).ok_or(format!("bad tool `{t}` (brush|lasso|hand|zoom)"))?; + } + if let Some(n) = ui.get("brushSize").and_then(Value::as_f64).filter(|n| n.is_finite()) { + w.brush_size = n.clamp(1.0, 5000.0); + } + if let Some(m) = ui.get("brushMode").and_then(Value::as_str) { + w.brush_add = m == "add"; + } + if let Some(m) = ui.get("lassoMode").and_then(Value::as_str) { + w.lasso_op = LASSO_OPS.iter().position(|o| *o == m).ok_or(format!("bad lassoMode `{m}`"))?; + } + if let Some(n) = ui.get("expandBy").and_then(Value::as_f64).filter(|n| n.is_finite()) { + w.expand_by = (n as f32).clamp(1.0, 100.0); + } + if sampling != w.opts.sampling { + set_sampling(app, &sampling); + } + } + let after = app.distort.caf.as_ref().map(|w| w.opts.clone()); + if after.as_ref().is_some_and(|a| a.sample_all != before.sample_all) { + rebase(app); + } else if after.as_ref().is_some_and(|a| *a != before) + && let Some(w) = app.distort.caf.as_mut() + { + w.stale(); + w.restate_all(); + } + if let Some(s) = ui.get("stroke") { + let pts: Vec<[f64; 2]> = serde_json::from_value(s.get("points").cloned().unwrap_or(Value::Null)).map_err(|e| format!("bad stroke points: {e}"))?; + let w = app.distort.caf.as_mut().ok_or("Content-Aware Fill is not open")?; + if let Some(n) = s.get("size").and_then(Value::as_f64) { + w.brush_size = n.clamp(1.0, 5000.0); + } + let alt = s.get("alt").and_then(Value::as_bool).unwrap_or(false); + if let Some(first) = pts.first() { + w.begin_stroke(*first, alt); + for p in &pts[1..] { + w.extend_stroke(*p); + } + w.end_stroke(); + } + } + if let Some(l) = ui.get("lasso") { + let pts: Vec<[f64; 2]> = serde_json::from_value(l.get("points").cloned().unwrap_or(Value::Null)).map_err(|e| format!("bad lasso points: {e}"))?; + let op = match l.get("op").and_then(Value::as_str) { + Some(o) if LASSO_OPS.contains(&o) => o.to_string(), + Some(o) => return Err(format!("bad lasso op `{o}`")), + None => LASSO_OPS[app.distort.caf.as_ref().map_or(0, |w| w.lasso_op)].to_string(), + }; + fill_edit(app, json!({"op": op, "points": pts})); + } + for k in ["expand", "contract"] { + if let Some(n) = ui.get(k).and_then(Value::as_f64) { + fill_edit(app, json!({"op": k, "by": n.clamp(1.0, 100.0)})); + } + } + if let Some(r) = ui.get("reset").and_then(Value::as_str) { + reset(app, r); + } + if flag("undo") { + undo(app); + } + if flag("redo") { + redo(app); + } + if flag("apply") { + apply(app)?; + } + if flag("waitPreview") { + wait_preview(app)?; + } + // OK last, with the settings given alongside. + if flag("commit") { + let r = apply(app); + close(app); + return r.map(|v| json!({"committed": true, "result": v})); + } + Ok(app.distort.caf.as_ref().map(CafWorkspace::describe).unwrap_or(Value::Null)) +} + +/// Computes the preview now, on this thread (tests and the control channel). +fn wait_preview(app: &mut PhotocraftApp) -> Result<(), String> { + let (doc, layer, p, want, limit) = { + let w = app.distort.caf.as_ref().ok_or("Content-Aware Fill is not open")?; + let st = app.session.active().ok_or("no document")?; + (st.doc.clone(), w.layer, plan_params(w, true), w.preview.want, w.texture_limit) + }; + let r = compute_preview(&doc, layer, &p, &photocraft_raster::Interrupt::NONE, limit); + let w = app.distort.caf.as_mut().ok_or("Content-Aware Fill is not open")?; + let err = r.as_ref().err().cloned(); + w.preview.finish(want, r); + err.map_or(Ok(()), Err) +} + +/// The filled rectangle as an image (what Apply would write there), as a texture no wider than +/// `max_side`: past the device's limit it is box-averaged to fit (the rectangle it is drawn over +/// stays the plan's). +fn compute_preview( + doc: &photocraft_doc::Document, + layer: LayerId, + p: &Value, + ctl: &photocraft_raster::Interrupt, + max_side: usize, +) -> Result<(Rect, egui::ColorImage), String> { + let plan = photocraft_engine::edit_menu_cmds::content_aware_plan_with(doc, layer, p, CMD, ctl).map_err(|e| match e { + photocraft_engine::EngineError::Cancelled => "cancelled".to_string(), + e => e.to_string(), + })?; + let (w, h) = (plan.rect.width() as usize, plan.rect.height() as usize); + let n = plan.fmt.channels(); + let filled = + photocraft_algo::content_aware::fill_with(w, h, n, &plan.img, &plan.hole, &plan.source, &plan.opts, ctl).map_err(|_| "cancelled".to_string())?; + let own = plan.layer_pixels(); + // Pixel `i`: the fill over the fill area (a partly selected pixel by its coverage), the + // layer's own pixels elsewhere. + let pixel = |i: usize| -> [f32; 4] { + let src = if plan.hole[i] { &filled[i * n..(i + 1) * n] } else { &own[i * n..(i + 1) * n] }; + let mut c = photocraft_raster::to_rgba(&plan.fmt, src); + if plan.hole[i] { + let k = plan.cover[i]; + let under = photocraft_raster::to_rgba(&plan.fmt, &own[i * n..(i + 1) * n]); + for j in 0..4 { + c[j] = under[j] + (c[j] - under[j]) * k; + } + } + c + }; + let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let color = |c: [f32; 4]| Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3])); + let f = crate::filter_preview_ui::texture_factor(w as u32, h as u32, max_side) as usize; + if f == 1 { + let px = (0..w * h).map(|i| color(pixel(i))).collect(); + return Ok((plan.rect, egui::ColorImage::new([w, h], px))); + } + let (tw, th) = (w.div_ceil(f), h.div_ceil(f)); + let mut px = Vec::with_capacity(tw * th); + for ty in 0..th { + for tx in 0..tw { + // The block's premultiplied average. + let (mut acc, mut count) = ([0.0f32; 4], 0.0f32); + for y in ty * f..((ty + 1) * f).min(h) { + for x in tx * f..((tx + 1) * f).min(w) { + let c = pixel(y * w + x); + for j in 0..3 { + acc[j] += c[j] * c[3]; + } + acc[3] += c[3]; + count += 1.0; + } + } + let c = if acc[3] > 0.0 { [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3] / count] } else { [0.0; 4] }; + px.push(color(c)); + } + } + Ok((plan.rect, egui::ColorImage::new([tw, th], px))) +} + +/// Starts a background preview when the shown one is out of date (and the pointer is up), and +/// collects a finished one. +#[cfg(not(target_arch = "wasm32"))] +fn drive_preview(app: &mut PhotocraftApp, ctx: &egui::Context, dragging: bool) { + let doc = app.session.active().map(|s| s.doc.clone()); + let Some(w) = app.distort.caf.as_mut() else { return }; + let pv = &mut w.preview; + if let Some((generation, rx, _)) = &pv.running { + match rx.try_recv() { + Ok(r) => { + let generation = *generation; + pv.running = None; + if generation == pv.want { + match r { + Err(e) if e == "cancelled" => {} + r => pv.finish(generation, r), + } + } + } + Err(std::sync::mpsc::TryRecvError::Empty) => { + // Superseded: stop it, a new one starts when it has gone. + if *generation != pv.want + && let Some((_, _, job)) = &pv.running + { + job.cancel(); + } + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + return; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => pv.running = None, + } + } + if pv.running.is_some() || pv.shown == pv.want || dragging { + return; + } + let Some(doc) = doc else { return }; + let (layer, p, generation, limit) = (w.layer, plan_params(w, true), w.preview.want, w.texture_limit); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + let job = photocraft_engine::jobs::JobCtx::new(); + let work = job.clone(); + let repaint = ctx.clone(); + let spawned = std::thread::Builder::new().name("caf-preview".into()).spawn(move || { + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work.stage(0.0, 1.0, "", |ctl| compute_preview(&doc, layer, &p, ctl, limit)))) + .unwrap_or_else(|_| Err("the preview failed".into())); + let _ = tx.send(r); + repaint.request_repaint(); + }); + match spawned { + Ok(_) => w.preview.running = Some((generation, rx, job)), + Err(e) => w.preview.error = Some(format!("could not start the preview: {e}")), + } +} + +/// The web build has no threads: the preview is computed when the pointer comes up. +#[cfg(target_arch = "wasm32")] +fn drive_preview(app: &mut PhotocraftApp, _ctx: &egui::Context, dragging: bool) { + let stale = app.distort.caf.as_ref().is_some_and(|w| w.preview.shown != w.preview.want); + if stale && !dragging { + let _ = wait_preview(app); + } +} + +/// Pointer in document coordinates (from the view or the control channel). +pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { + let Some(w) = app.distort.caf.as_mut() else { return }; + match (w.tool, ev) { + (CafTool::Brush, ToolEvent::Down { x, y, .. }) => w.begin_stroke([x, y], mods.alt), + (CafTool::Brush, ToolEvent::Move { x, y, .. }) => { + w.hover = Some([x, y]); + w.extend_stroke([x, y]); + } + (CafTool::Brush, ToolEvent::Up { x, y }) => { + w.extend_stroke([x, y]); + w.end_stroke(); + } + (CafTool::Lasso, ToolEvent::Down { x, y, .. }) => { + // Shift adds, Alt subtracts, both intersect (as Photoshop's selection tools). + let op = match (mods.shift, mods.alt) { + (true, true) => 3, + (true, false) => 1, + (false, true) => 2, + _ => w.lasso_op, + }; + w.lasso = Some((op, vec![[x, y]])); + } + (CafTool::Lasso, ToolEvent::Move { x, y, .. }) => { + if let Some((_, pts)) = &mut w.lasso + && pts.last().is_none_or(|l| l[0] != x || l[1] != y) + { + pts.push([x, y]); + } + } + (CafTool::Lasso, ToolEvent::Up { .. }) => { + if let Some((op, pts)) = w.lasso.take() + && pts.len() >= 3 + { + fill_edit(app, json!({"op": LASSO_OPS[op], "points": pts})); + } + } + (CafTool::Zoom, ToolEvent::Down { x, y, .. }) => { + let f = if mods.alt { 0.5 } else { 2.0 }; + w.zoom = (w.zoom * f).clamp(0.01, 32.0); + w.center = [x, y]; + } + _ => {} + } +} + +pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { + let cmd_shift = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; + if ctx.input_mut(|i| i.consume_key(cmd_shift, egui::Key::Z)) { + redo(app); + return; + } + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Z)) { + undo(app); + return; + } + let Some(w) = app.distort.caf.as_mut() else { return }; + // Ctrl+H hides the overlay (Hide Extras). + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::H)) { + w.opts.show_overlay = !w.opts.show_overlay; + w.overlay_dirty = true; + } + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::OpenBracket)) { + w.brush_size = (w.brush_size * 0.9).max(1.0).round(); + } + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::CloseBracket)) { + w.brush_size = (w.brush_size * 1.1).min(5000.0).round().max(w.brush_size + 1.0); + } + for (k, t) in [(egui::Key::B, CafTool::Brush), (egui::Key::L, CafTool::Lasso), (egui::Key::H, CafTool::Hand), (egui::Key::Z, CafTool::Zoom)] { + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, k)) { + w.tool = t; + } + } +} + +fn tool_icon(t: CafTool) -> &'static str { + match t { + CafTool::Brush => "brush", + CafTool::Lasso => "lasso", + CafTool::Hand => "hand", + CafTool::Zoom => "zoom-in", + } +} + +fn tool_tip(t: CafTool) -> String { + match t { + CafTool::Brush => format!("{} (B)", tl!("Sampling Brush Tool")), + CafTool::Lasso => format!("{} (L)", tl!("Lasso Tool")), + CafTool::Hand => format!("{} (H)", tl!("Hand Tool")), + CafTool::Zoom => format!("{} (Z)", tl!("Zoom Tool")), + } +} + +/// A view: screen px per document px and the document point at the centre of `area`. +#[derive(Clone, Copy)] +struct View { + area: ERect, + zoom: f32, + center: [f64; 2], +} + +impl View { + fn to_screen(self, p: [f64; 2]) -> Pos2 { + let c = self.area.center(); + pos2(c.x + ((p[0] - self.center[0]) as f32) * self.zoom, c.y + ((p[1] - self.center[1]) as f32) * self.zoom) + } + fn to_doc(self, s: Pos2) -> [f64; 2] { + let c = self.area.center(); + [self.center[0] + f64::from((s.x - c.x) / self.zoom), self.center[1] + f64::from((s.y - c.y) / self.zoom)] + } + fn rect(self, r: Rect) -> ERect { + ERect::from_min_max(self.to_screen([f64::from(r.x0), f64::from(r.y0)]), self.to_screen([f64::from(r.x1), f64::from(r.y1)])) + } +} + +/// Upload what changed: the document proxy, the overlay, a finished preview. +fn upload(app: &mut PhotocraftApp, ctx: &egui::Context) { + let need_image = app.distort.caf.as_ref().is_some_and(|w| w.image_tex.is_none()); + if need_image { + let img = view_image(app); + if let (Some(w), Some(img)) = (app.distort.caf.as_mut(), img) { + w.image_tex = Some(ctx.load_texture("caf-image", img, egui::TextureOptions::LINEAR)); + } + } + let Some(w) = app.distort.caf.as_mut() else { return }; + if w.overlay_dirty || w.overlay_tex.is_none() { + w.overlay_dirty = false; + let [r, g, b] = w.opts.color; + let a = (w.opts.opacity / 100.0 * 255.0).round() as u8; + let tint = Color32::from_rgba_unmultiplied(r, g, b, a); + // The tint marks the sampling area (with Indicates: Excluded Area, what is left out); + // the fill area is never tinted. + let px: Vec = + w.mask.iter().zip(&w.hole_px).map(|(m, h)| if w.opts.show_overlay && !*h && *m != w.opts.excluded { tint } else { Color32::TRANSPARENT }).collect(); + let img = egui::ColorImage::new([w.pw, w.ph], px); + match &mut w.overlay_tex { + Some(t) => t.set(img, egui::TextureOptions::NEAREST), + None => w.overlay_tex = Some(ctx.load_texture("caf-overlay", img, egui::TextureOptions::NEAREST)), + } + } + if let Some((rect, img)) = w.preview.pending_image.take() { + w.preview.tex = Some((ctx.load_texture("caf-preview", img, egui::TextureOptions::LINEAR), rect)); + } +} + +/// The document as the view shows it: the visible layers composited, as on the canvas (so an +/// Apply to a new layer shows), reduced to the proxy. +fn view_image(app: &PhotocraftApp) -> Option { + let w = app.distort.caf.as_ref()?; + let st = app.session.active()?; + let buf = photocraft_compose::render_reduced(&st.doc, w.pw as u32, w.ph as u32); + let (display, _) = crate::canvas::canvas_display(app, &st.doc, None); + Some(crate::canvas::display_image(display.as_deref(), &buf)) +} + +/// Draws the workspace (a full-window layer over the app). +pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + // An Apply (or anything else) changed the document: refresh. + let rev = app.session.active().map(|s| s.revision); + if app.distort.caf.as_ref().is_some_and(|w| Some(w.revision) != rev) { + refresh(app); + } + upload(app, ctx); + let t = Tokens::get(ctx); + let screen = ctx.content_rect(); + let mut action: Option<&str> = None; + let mut events: Vec = Vec::new(); + let mut mods = egui::Modifiers::NONE; + let mut new_sampling: Option<&str> = None; + let mut fill_btn: Option = None; + let mut reset_what: Option<&str> = None; + let mut rebase_needed = false; + let mut dragging = false; + egui::Area::new(egui::Id::new("caf-workspace")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { + let Some(w) = app.distort.caf.as_mut() else { return }; + let before = w.opts.clone(); + w.texture_limit = ctx.input(|i| i.max_texture_side); + let (full, _) = ui.allocate_exact_size(screen.size(), Sense::hover()); + let painter = ui.painter().clone(); + painter.rect_filled(full, 0.0, t.chrome); + // Title strip. + let title = ERect::from_min_size(full.min, vec2(full.width(), 30.0)); + painter.rect_filled(title, 0.0, t.dock); + painter.line_segment([title.left_bottom(), title.right_bottom()], Stroke::new(1.0, t.separator)); + let pct = if w.zoom > 0.0 { w.zoom * 100.0 } else { 100.0 }; + painter.text( + title.center(), + Align2::CENTER_CENTER, + format!("{} ({}, {:.0}%)", tl!("Content-Aware Fill…").trim_end_matches('…'), w.layer_name, pct), + FontId::proportional(13.0), + t.text, + ); + // Options bar. + let bar = ERect::from_min_size(pos2(full.left(), title.bottom()), vec2(full.width(), BAR_H)); + painter.rect_filled(bar, 0.0, t.dock); + painter.line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.separator)); + let mut bui = + ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink2(vec2(LEFT_W + 8.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + match w.tool { + CafTool::Brush => { + if crate::icons::button(&mut bui, "plus", 26.0, w.brush_add, tl!("Add")).clicked() { + w.brush_add = true; + } + if crate::icons::button(&mut bui, "minus", 26.0, !w.brush_add, tl!("Subtract")).clicked() { + w.brush_add = false; + } + bui.add_space(8.0); + bui.label(tl!("Size:")); + let mut size = w.brush_size as f32; + if widgets::value_field(&mut bui, &mut size, 1.0..=5000.0, " px", 64.0).changed() { + w.brush_size = f64::from(size); + } + } + CafTool::Lasso => { + // The selection tools' mode buttons (the lasso here edits the fill area). + let tips = [ + tl!("New selection").to_string(), + crate::i18n::fmt(tl!("Add to selection ({key})"), &[("key", &crate::shortcuts::pretty("Shift"))]), + crate::i18n::fmt(tl!("Subtract from selection ({key})"), &[("key", &crate::shortcuts::pretty("Alt"))]), + crate::i18n::fmt(tl!("Intersect with selection ({key})"), &[("key", &crate::shortcuts::pretty("Shift+Alt"))]), + ]; + for (i, (icon, tip)) in ["square", "plus", "minus", "squares-subtract"].into_iter().zip(tips.iter()).enumerate() { + if crate::icons::button(&mut bui, icon, 26.0, w.lasso_op == i, tip).clicked() { + w.lasso_op = i; + } + } + bui.add_space(8.0); + if widgets::secondary_button(&mut bui, tl!("Expand"), 70.0).clicked() { + fill_btn = Some(json!({"op": "expand", "by": w.expand_by})); + } + if widgets::secondary_button(&mut bui, tl!("Contract"), 70.0).clicked() { + fill_btn = Some(json!({"op": "contract", "by": w.expand_by})); + } + widgets::value_field(&mut bui, &mut w.expand_by, 1.0..=100.0, " px", 56.0); + if crate::icons::button(&mut bui, "undo-2", 26.0, false, tl!("Reset")).clicked() { + reset_what = Some("lasso"); + } + } + _ => {} + } + let body = ERect::from_min_max(pos2(full.left(), bar.bottom()), full.max); + // Left tool strip. + let left = ERect::from_min_size(pos2(full.left(), title.bottom()), vec2(LEFT_W, full.height() - title.height())); + painter.rect_filled(left, 0.0, t.dock); + painter.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.separator)); + let mut strip = ui.new_child(egui::UiBuilder::new().max_rect(left.shrink2(vec2(6.0, 8.0)))); + strip.spacing_mut().item_spacing.y = 4.0; + for tool in [CafTool::Brush, CafTool::Lasso, CafTool::Hand, CafTool::Zoom] { + if crate::icons::button(&mut strip, tool_icon(tool), 34.0, w.tool == tool, &tool_tip(tool)).clicked() { + w.tool = tool; + } + } + // Right: the Content-Aware Fill panel. + let right = ERect::from_min_size(pos2(body.right() - PANEL_W, body.top()), vec2(PANEL_W, body.height())); + painter.rect_filled(right, 0.0, t.dock); + painter.line_segment([right.left_top(), right.left_bottom()], Stroke::new(1.0, t.separator)); + let mut props = + ui.new_child(egui::UiBuilder::new().max_rect(ERect::from_min_max(right.min + vec2(14.0, 12.0), pos2(right.right() - 14.0, right.bottom() - 60.0)))); + egui::ScrollArea::vertical().id_salt("caf-props").show(&mut props, |ui| { + ui.spacing_mut().item_spacing.y = 6.0; + egui::CollapsingHeader::new(tl!("Sampling Area Overlay")).id_salt("caf-overlay").default_open(false).show(ui, |ui| { + widgets::checkbox(ui, &mut w.opts.show_overlay, tl!("Show Sampling Area")); + widgets::slider_row(ui, tl!("Opacity"), &mut w.opts.opacity, 0.0..=100.0, "%", None); + ui.horizontal(|ui| { + ui.label(tl!("Color")); + widgets::color_edit_button_srgb(ui, &mut w.opts.color); + ui.add_space(8.0); + ui.label(tl!("Indicates")); + widgets::dropdown(ui, "caf-indicates", &mut w.opts.excluded, &[(false, tl!("Sampling Area")), (true, tl!("Excluded Area"))], 120.0); + }); + }); + widgets::hairline(ui); + ui.horizontal(|ui| { + widgets::section_label(ui, tl!("Sampling Area Options")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::icons::button(ui, "undo-2", 22.0, false, tl!("Reset")).clicked() { + reset_what = Some("sampling"); + } + }); + }); + ui.horizontal(|ui| { + for (k, l) in [("auto", tl!("Auto")), ("rectangular", tl!("Rectangular")), ("custom", tl!("Custom"))] { + if ui.selectable_label(w.opts.sampling == k, l).clicked() && w.opts.sampling != k { + new_sampling = Some(k); + } + } + }); + if widgets::checkbox(ui, &mut w.opts.sample_all, tl!("Sample All Layers")).changed() { + rebase_needed = true; + } + widgets::hairline(ui); + ui.horizontal(|ui| { + widgets::section_label(ui, tl!("Fill Settings")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::icons::button(ui, "undo-2", 22.0, false, tl!("Reset")).clicked() { + reset_what = Some("fill"); + } + }); + }); + ui.horizontal(|ui| { + ui.label(tl!("Color Adaptation")); + let o = [ + ("none".to_string(), tl!("None")), + ("default".to_string(), tl!("Default")), + ("high".to_string(), tl!("High")), + ("veryHigh".to_string(), tl!("Very High")), + ]; + widgets::dropdown(ui, "caf-color", &mut w.opts.color_adaptation, &o, 110.0); + }); + ui.horizontal(|ui| { + ui.label(tl!("Rotation Adaptation")); + let o = [ + ("none".to_string(), tl!("None")), + ("low".to_string(), tl!("Low")), + ("medium".to_string(), tl!("Medium")), + ("high".to_string(), tl!("High")), + ("full".to_string(), tl!("Full")), + ]; + widgets::dropdown(ui, "caf-rotation", &mut w.opts.rotation, &o, 110.0); + }); + ui.horizontal(|ui| { + widgets::checkbox(ui, &mut w.opts.scale, tl!("Scale")); + widgets::checkbox(ui, &mut w.opts.mirror, tl!("Mirror")); + }); + widgets::hairline(ui); + widgets::section_label(ui, tl!("Output Settings")); + ui.horizontal(|ui| { + ui.label(tl!("Output To")); + let o = + [("current".to_string(), tl!("Current Layer")), ("new".to_string(), tl!("New Layer")), ("duplicate".to_string(), tl!("Duplicate Layer"))]; + widgets::dropdown(ui, "caf-output", &mut w.opts.output, &o, 140.0); + }); + if let Some(e) = w.error.as_ref().or(w.preview.error.as_ref()) { + ui.label(egui::RichText::new(e.as_str()).color(t.danger).size(11.0)); + } + }); + // Footer, in Photoshop's order on every platform: reset all at the left, then OK, Apply, + // Cancel against the right edge. + let foot = ERect::from_min_size(pos2(right.left() + 10.0, right.bottom() - 48.0), vec2(PANEL_W - 20.0, 32.0)); + let mut fb = ui.new_child(egui::UiBuilder::new().max_rect(foot).layout(egui::Layout::right_to_left(egui::Align::Center))); + if widgets::secondary_button(&mut fb, tl!("Cancel"), 70.0).clicked() { + action = Some("cancel"); + } + if widgets::secondary_button(&mut fb, tl!("Apply"), 70.0).clicked() { + action = Some("apply"); + } + if widgets::primary_button(&mut fb, tl!("OK"), 64.0).clicked() { + action = Some("ok"); + } + if crate::icons::button(&mut fb, "undo-2", 24.0, false, tl!("Reset")).clicked() { + reset_what = Some("all"); + } + // The Preview panel. + let pv_rect = ERect::from_min_size(pos2(right.left() - PREVIEW_W, body.top()), vec2(PREVIEW_W, body.height())); + painter.rect_filled(pv_rect, 0.0, t.dock); + painter.line_segment([pv_rect.left_top(), pv_rect.left_bottom()], Stroke::new(1.0, t.separator)); + painter.text(pv_rect.left_top() + vec2(12.0, 10.0), Align2::LEFT_TOP, tl!("Preview"), FontId::proportional(12.0), t.text); + let pv_area = ERect::from_min_max(pv_rect.min + vec2(0.0, 30.0), pv_rect.max - vec2(0.0, 44.0)); + painter.rect_filled(pv_area, 0.0, t.canvas); + if w.preview.zoom <= 0.0 { + // Opens framed on the sampling window, as Photoshop's. + let win = w.base.window; + if !win.is_empty() { + w.preview.zoom = (pv_area.width() / win.width().max(win.height()) as f32).clamp(0.01, 32.0); + w.preview.center = [f64::from(win.x0 + win.x1) / 2.0, f64::from(win.y0 + win.y1) / 2.0]; + } + } + let pv = View { area: pv_area, zoom: w.preview.zoom.max(0.01), center: w.preview.center }; + let pclip = painter.with_clip_rect(pv_area); + let uv = ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + let img_rect = pv.rect(w.canvas); + widgets::checker(&pclip, img_rect.intersect(pv_area), 8.0); + if let Some(tex) = &w.image_tex { + pclip.image(tex.id(), img_rect, uv, Color32::WHITE); + } + if let Some((tex, r)) = &w.preview.tex { + pclip.image(tex.id(), pv.rect(*r), uv, Color32::WHITE); + } + if w.preview.shown != w.preview.want { + // Being recomputed: a spinner in the corner. + let sp = ERect::from_min_size(pv_area.right_bottom() - vec2(26.0, 26.0), vec2(18.0, 18.0)); + ui.put(sp, egui::Spinner::new().size(16.0)); + } + let presp = ui.interact(pv_area, egui::Id::new("caf-preview-view"), Sense::click_and_drag()); + if presp.dragged() { + let d = presp.drag_delta(); + w.preview.center = [w.preview.center[0] - f64::from(d.x / pv.zoom), w.preview.center[1] - f64::from(d.y / pv.zoom)]; + } + if presp.hovered() { + let scroll = ui.input(|i| i.smooth_scroll_delta.y); + if scroll != 0.0 { + w.preview.zoom = (w.preview.zoom * (scroll / 200.0).exp()).clamp(0.01, 32.0); + } + } + let zrow = ERect::from_min_size(pos2(pv_rect.left() + 10.0, pv_rect.bottom() - 38.0), vec2(PREVIEW_W - 20.0, 30.0)); + let mut zr = ui.new_child(egui::UiBuilder::new().max_rect(zrow).layout(egui::Layout::left_to_right(egui::Align::Center))); + let mut pct = (w.preview.zoom * 100.0).round(); + if widgets::value_field(&mut zr, &mut pct, 1.0..=3200.0, "%", 64.0).changed() { + w.preview.zoom = pct / 100.0; + } + let mut lz = pct.max(1.0).ln(); + if zr.add(egui::Slider::new(&mut lz, 0.0..=3200f32.ln()).show_value(false)).changed() { + w.preview.zoom = lz.exp() / 100.0; + } + // The document view. + let area = ERect::from_min_max(pos2(left.right(), body.top()), pos2(pv_rect.left(), body.bottom())); + painter.rect_filled(area, 0.0, t.canvas); + let (cw, chh) = (w.canvas.width() as f32, w.canvas.height() as f32); + if w.zoom <= 0.0 { + w.zoom = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / chh).clamp(0.01, 4.0); + } + let resp = ui.interact(area, egui::Id::new("caf-view"), Sense::click_and_drag()); + if resp.hovered() { + let scroll = ui.input(|i| i.smooth_scroll_delta.y); + if scroll != 0.0 + && let Some(hp) = resp.hover_pos() + { + let v = View { area, zoom: w.zoom, center: w.center }; + let before = v.to_doc(hp); + w.zoom = (w.zoom * (scroll / 200.0).exp()).clamp(0.01, 32.0); + let after = View { area, zoom: w.zoom, center: w.center }.to_doc(hp); + w.center = [w.center[0] + before[0] - after[0], w.center[1] + before[1] - after[1]]; + } + } + let v = View { area, zoom: w.zoom, center: w.center }; + let clip = painter.with_clip_rect(area); + let img = v.rect(w.canvas); + widgets::checker(&clip, img.intersect(area), 8.0); + if let Some(tex) = &w.image_tex { + clip.image(tex.id(), img, uv, Color32::WHITE); + } + if let Some(tex) = &w.overlay_tex { + // The proxy's blocks are `k` document px; its last row and column may reach past the + // canvas, so the overlay is clipped to it. + let pr = ERect::from_min_size(img.min, vec2((w.pw * w.k) as f32 * v.zoom, (w.ph * w.k) as f32 * v.zoom)); + clip.with_clip_rect(img.intersect(area)).image(tex.id(), pr, uv, Color32::WHITE); + } + // The fill area's outline (marching ants: black dashes on white). + let phase = (ui.input(|i| i.time) * 8.0) as usize; + match &w.outline { + Some(edges) => { + for e in edges { + let (a, b) = (v.to_screen([f64::from(e[0][0]), f64::from(e[0][1])]), v.to_screen([f64::from(e[1][0]), f64::from(e[1][1])])); + let dark = ((e[0][0] + e[0][1]) as usize / 4 + phase).is_multiple_of(2); + clip.line_segment([a, b], Stroke::new(1.0, if dark { Color32::BLACK } else { Color32::WHITE })); + } + } + None => { + let b = v.rect(bounds(&w.base)); + clip.rect_stroke(b, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); + } + } + ctx.request_repaint_after(std::time::Duration::from_millis(120)); + if let Some((_, pts)) = &w.lasso + && pts.len() > 1 + { + let line: Vec = pts.iter().map(|p| v.to_screen(*p)).collect(); + clip.add(egui::Shape::line(line, Stroke::new(1.0, t.accent))); + } + if let Some(hp) = resp.hover_pos() { + if w.tool == CafTool::Brush { + let r = w.brush_size as f32 * 0.5 * v.zoom; + clip.circle_stroke(hp, r, Stroke::new(1.5, Color32::BLACK)); + clip.circle_stroke(hp, r, Stroke::new(0.75, Color32::WHITE)); + } + w.hover = Some(v.to_doc(hp)); + } + clip.rect_stroke(img, 0.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); + // Mouse → tools (Space, the middle button or the Hand pan). + let pan = ui.input(|i| i.key_down(egui::Key::Space) || i.pointer.middle_down()) || w.tool == CafTool::Hand; + if pan && resp.dragged() { + let dl = resp.drag_delta(); + w.center = [w.center[0] - f64::from(dl.x / w.zoom), w.center[1] - f64::from(dl.y / w.zoom)]; + } else if !pan + && resp.is_pointer_button_down_on() + && let Some(pp) = resp.interact_pointer_pos() + { + // The press is the first point; the frames before egui's drag threshold and the drag + // itself continue it (a second `Down` would start the stroke over, dropping them). + let q = v.to_doc(pp); + mods = ui.input(|i| i.modifiers); + if w.pressed { + events.push(ToolEvent::Move { x: q[0], y: q[1], pressure: 1.0 }); + ctx.request_repaint(); + } else { + w.pressed = true; + events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: 1.0 }); + } + dragging = true; + } + if w.pressed && !resp.is_pointer_button_down_on() { + w.pressed = false; + let q = w.hover.unwrap_or(w.center); + events.push(ToolEvent::Up { x: q[0], y: q[1] }); + dragging = false; + } + // Settings changed in the panel: the overlay and the preview follow. + if w.opts != before { + w.overlay_dirty = true; + w.stale(); + } + }); + if let Some(s) = new_sampling { + set_sampling(app, s); + } + if let Some(r) = reset_what { + reset(app, r); + } + if let Some(e) = fill_btn { + fill_edit(app, e); + } + if rebase_needed { + rebase(app); + } + for ev in events { + pointer(app, ev, mods); + } + drive_preview(app, ctx, dragging); + match action { + Some("ok") => commit(app), + Some("cancel") => cancel(app), + Some("apply") => { + if let Err(e) = apply(app) { + app.ui.status = e; + app.ui.status_error = true; + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A textured layer (a checker of two colours over a gradient) with a 20 px square selected. + fn app_with_selection() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": 200, "height": 160, "depth": 8})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, active| { + let s = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + for y in 0..160 { + for x in 0..200 { + let v = ((x / 6 + y / 6) % 2) as f32 * 0.5 + x as f32 / 400.0; + s.write_pixel(x, y, &[v, 0.3, 1.0 - v, 1.0]); + } + } + // A red blemish to remove. + for y in 72..88 { + for x in 92..108 { + s.write_pixel(x, y, &[1.0, 0.0, 0.0, 1.0]); + } + } + Ok(()) + }) + .unwrap(); + app.session.execute("select.rect", json!({"x": 90, "y": 70, "width": 20, "height": 20})).unwrap(); + app.sync_views(); + app + } + + fn px(app: &PhotocraftApp, layer: LayerId, x: i32, y: i32) -> Vec { + app.session.active().unwrap().doc.layer(layer).unwrap().surface().unwrap().pixel(x, y) + } + + fn ws(app: &PhotocraftApp) -> &CafWorkspace { + app.distort.caf.as_ref().unwrap() + } + + /// Photoshop opens the workspace with the Sampling Brush subtracting at a tenth of the + /// window's side, Auto sampling, output to a new layer. + #[test] + fn opens_like_photoshop() { + let mut app = app_with_selection(); + app.session.prefs.edit(|p| { + p.dialogs.remove(REMEMBERED); + }); + open(&mut app).unwrap(); + let d = ws(&app).describe(); + assert_eq!(d["window"], json!([0, 0, 200, 160]), "a 200 px window, clipped to the 200 × 160 canvas"); + assert_eq!((d["tool"].as_str(), d["brushMode"].as_str(), d["brushSize"].as_f64()), (Some("brush"), Some("subtract"), Some(20.0))); + assert_eq!((d["settings"]["sampling"].as_str(), d["settings"]["output"].as_str()), (Some("auto"), Some("new"))); + assert_eq!(d["fillArea"], json!(400)); + assert!(d["sampledProxyPixels"].as_u64().unwrap() > 1000); + } + + #[test] + fn brush_strokes_change_the_sampling_area_and_undo() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + control(&mut app, &json!({"sampling": "rectangular"})).unwrap(); + let all = ws(&app).describe()["sampledProxyPixels"].as_u64().unwrap(); + assert_eq!(all, 200 * 160 - 400, "Rectangular: the whole window but the fill area"); + // Subtracting (the default): a 10 px stroke takes about 10 × 60 px away. + let d = control(&mut app, &json!({"stroke": {"points": [[20.0, 20.0], [80.0, 20.0]], "size": 10}})).unwrap(); + let less = d["sampledProxyPixels"].as_u64().unwrap(); + assert!((all - less).abs_diff(10 * 60 + 79) < 120, "{all} → {less}"); + // Undo brings it back; redo takes it again. + assert_eq!(control(&mut app, &json!({"undo": true})).unwrap()["sampledProxyPixels"].as_u64(), Some(all)); + assert_eq!(control(&mut app, &json!({"redo": true})).unwrap()["sampledProxyPixels"].as_u64(), Some(less)); + // Custom starts empty and the brush adds; Alt reverses it. + let d = control(&mut app, &json!({"sampling": "custom"})).unwrap(); + assert_eq!((d["sampledProxyPixels"].as_u64(), d["brushMode"].as_str(), d["strokes"].as_u64()), (Some(0), Some("add"), Some(0))); + let d = control(&mut app, &json!({"stroke": {"points": [[20.0, 120.0], [60.0, 120.0]], "size": 8}})).unwrap(); + assert!(d["sampledProxyPixels"].as_u64().unwrap() > 300); + let d = control(&mut app, &json!({"stroke": {"points": [[20.0, 120.0], [60.0, 120.0]], "size": 8, "alt": true}})).unwrap(); + assert_eq!(d["sampledProxyPixels"].as_u64(), Some(0)); + // Undoing the change of option restores Rectangular with its stroke. + control(&mut app, &json!({"undo": true})).unwrap(); + control(&mut app, &json!({"undo": true})).unwrap(); + let d = control(&mut app, &json!({"undo": true})).unwrap(); + assert_eq!((d["settings"]["sampling"].as_str(), d["strokes"].as_u64(), d["brushMode"].as_str()), (Some("rectangular"), Some(1), Some("subtract"))); + } + + #[test] + fn the_lasso_edits_the_fill_area_and_the_window_follows() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let d = control(&mut app, &json!({"lasso": {"op": "add", "points": [[130.0, 70.0], [150.0, 70.0], [150.0, 90.0], [130.0, 90.0]]}})).unwrap(); + assert_eq!(d["fillArea"], json!(800)); + let d = control(&mut app, &json!({"expand": 2})).unwrap(); + assert!(d["fillArea"].as_u64().unwrap() > 900); + let d = control(&mut app, &json!({"undo": true, "lassoMode": "subtract"})).unwrap(); + assert_eq!((d["fillArea"].as_u64(), d["lassoMode"].as_str()), (Some(800), Some("subtract"))); + // Subtracting everything is refused: the fill area can't be empty. + let d = control(&mut app, &json!({"lasso": {"points": [[0.0, 0.0], [200.0, 0.0], [200.0, 160.0], [0.0, 160.0]]}})).unwrap(); + assert_eq!(d["fillArea"], json!(800)); + assert!(d["error"].as_str().is_some_and(|e| e.contains("empty")), "{d}"); + // The reset button goes back to the selection. + let d = control(&mut app, &json!({"reset": "lasso"})).unwrap(); + assert_eq!(d["fillArea"], json!(400)); + } + + #[test] + fn apply_fills_and_stays_open_ok_closes_cancel_keeps_what_was_applied() { + let mut app = app_with_selection(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + open(&mut app).unwrap(); + // The preview is what Apply writes. + control(&mut app, &json!({"output": "current", "colorAdaptation": "none", "waitPreview": true})).unwrap(); + let (rect, img) = ws(&app).preview.pending_image.clone().unwrap(); + control(&mut app, &json!({"apply": true})).unwrap(); + assert!(app.distort.caf.is_some(), "Apply keeps the workspace open"); + let after = px(&app, layer, 100, 80); + assert!(after[0] < 0.9 || after[1] > 0.1, "the blemish is filled: {after:?}"); + let i = ((80 - rect.y0) * rect.width() as i32 + (100 - rect.x0)) as usize; + let shown = img.pixels[i]; + assert!( + (f32::from(shown.r()) / 255.0 - after[0]).abs() < 0.01 && (f32::from(shown.b()) / 255.0 - after[2]).abs() < 0.01, + "preview {shown:?} vs {after:?}" + ); + let steps = app.session.active().unwrap().history.past_len(); + cancel(&mut app); + assert!(app.distort.caf.is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), steps, "Cancel keeps the applied fill"); + // OK with New Layer: a new layer holds the fill; the original stays. + app.session.execute("edit.undo", json!({})).unwrap(); + open(&mut app).unwrap(); + control(&mut app, &json!({"output": "new", "commit": true})).unwrap(); + assert!(app.distort.caf.is_none()); + assert_eq!(px(&app, layer, 100, 80)[0], 1.0, "the original layer keeps its blemish"); + let top = app.session.active().unwrap().active_layer.unwrap(); + assert_ne!(top, layer); + assert!(px(&app, top, 100, 80)[3] > 0.99, "the new layer holds the fill"); + } + + /// #5092: the workspace left open does not follow the user to another document, and closing + /// the document it was opened on closes it. + #[test] + fn the_workspace_is_dropped_when_another_document_becomes_active() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + crate::distort_ui::drop_foreign(&mut app); + assert!(app.distort.caf.is_some()); + app.session.execute("file.new", json!({"width": 32, "height": 32, "depth": 8})).unwrap(); + crate::distort_ui::drop_foreign(&mut app); + assert!(app.distort.caf.is_none()); + assert!(app.session.set_active(0)); + open(&mut app).unwrap(); + crate::distort_ui::drop_foreign(&mut app); + assert!(app.distort.caf.is_some()); + assert!(app.session.close(0).is_some()); + crate::distort_ui::drop_foreign(&mut app); + assert!(app.distort.caf.is_none()); + } + + #[test] + fn settings_are_remembered_and_the_menu_opens_the_workspace() { + let ctx = egui::Context::default(); + let mut app = app_with_selection(); + let r = crate::menus::invoke(&mut app, &ctx, "edit.contentAwareFill", json!({})).unwrap(); + assert!(r["contentAwareFill"]["window"].is_array(), "{r}"); + let set = json!({"opacity": 30, "color": [200, 40, 40], "excluded": true, "colorAdaptation": "high", "rotationAdaptation": "low", "mirror": true, "output": "duplicate"}); + crate::menus::invoke(&mut app, &ctx, "edit.contentAwareFill", json!({"ui": set})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "edit.contentAwareFill", json!({"ui": {"cancel": true}})).unwrap(); + open(&mut app).unwrap(); + let mut want = CafOpts::default(); + want.apply(&set).unwrap(); + assert_eq!(ws(&app).opts, want); + // Bad values are errors; the engine command with params still runs directly. + assert!(control(&mut app, &json!({"sampling": "everywhere"})).is_err()); + assert!(control(&mut app, &json!({"tool": "pencil"})).is_err()); + cancel(&mut app); + let r = crate::menus::invoke(&mut app, &ctx, "edit.contentAwareFill", json!({"colorAdaptation": "none"})).unwrap(); + assert_eq!(r["filled"], json!(400)); + assert!(app.distort.caf.is_none()); + } + + /// The engine's sampling area over the plan's rectangle (what the fill samples) and its fill + /// area. + fn engine_source(app: &PhotocraftApp) -> (Rect, Vec, Vec) { + let w = ws(app); + let st = app.session.active().unwrap(); + let plan = photocraft_engine::edit_menu_cmds::content_aware_plan(&st.doc, w.layer, &plan_params(w, true), CMD).unwrap(); + (plan.rect, plan.source, plan.hole) + } + + /// Each stroke is painted into the proxy once, over its own pixels: 400 strokes of 50 points + /// and the undo and redo that rebuild the proxy stay cheap (they used to test every proxy + /// pixel against every segment), and the overlay is exactly what the engine samples (at one + /// proxy px per document px). + #[test] + fn strokes_are_painted_once_and_the_overlay_is_the_engines_sampling_area() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + control(&mut app, &json!({"sampling": "rectangular"})).unwrap(); + let start = std::time::Instant::now(); + for i in 0..400u32 { + // Lines across the document, most subtracting, every fifth adding (Alt). + let y = 2.0 + f64::from(i % 40) * 4.0; + let pts: Vec<[f64; 2]> = (0..50).map(|j| [f64::from(j) * 4.0 + f64::from(i % 3), y + f64::from(j % 2)]).collect(); + control(&mut app, &json!({"stroke": {"points": pts, "size": 6 + (i % 4) * 3, "alt": i % 5 == 0}})).unwrap(); + } + let painted = start.elapsed(); + let start = std::time::Instant::now(); + control(&mut app, &json!({"undo": true})).unwrap(); + control(&mut app, &json!({"redo": true})).unwrap(); + let rebuilt = start.elapsed(); + assert!(painted.as_secs_f64() < 10.0 && rebuilt.as_secs_f64() < 10.0, "400 strokes {painted:?}, undo + redo {rebuilt:?}"); + let w = ws(&app); + assert_eq!((w.k, w.strokes.len()), (1, 400)); + let (rect, source, hole) = engine_source(&app); + assert_eq!(rect, w.canvas, "Rectangular's window is the whole 200 × 160 canvas"); + let differ = (0..source.len()).filter(|&i| (w.mask[i] && !w.hole_px[i]) != (source[i] && !hole[i])).count(); + assert_eq!(differ, 0, "proxy pixels that differ from the engine's sampling area"); + assert!(w.describe()["sampledProxyPixels"].as_u64().unwrap() > 1000); + } + + /// `edit.contentAwareFill {"ui": …}` addresses the workspace: with it closed that is an + /// error, not a fill with the default settings (an agent polling the state after an Esc + /// used to add a fill per poll). + #[test] + fn ui_params_with_the_workspace_closed_are_an_error_not_a_fill() { + let ctx = egui::Context::default(); + let mut app = app_with_selection(); + let steps = app.session.active().unwrap().history.past_len(); + for ui in [json!({}), json!({"cancel": true}), json!({"sampling": "rectangular"})] { + let r = crate::menus::invoke(&mut app, &ctx, "edit.contentAwareFill", json!({"ui": ui})); + assert!(r.as_ref().is_err_and(|e| e.contains("not open")), "{r:?}"); + } + assert_eq!(app.session.active().unwrap().history.past_len(), steps, "nothing was filled"); + assert!(app.distort.caf.is_none()); + } + + /// The press is the first point of a stroke (and of a lasso), and the pointer's moves before + /// egui's drag threshold are its next ones: the drag, which starts a few pixels later, goes + /// on with the same stroke rather than beginning another over it. + #[test] + fn a_stroke_keeps_its_points_from_before_the_drag_threshold() { + use egui::{Modifiers, PointerButton}; + for tool in ["brush", "lasso"] { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + control(&mut app, &json!({"tool": tool, "sampling": "rectangular"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + // The document view sits between the tool strip and the Preview panel, under the + // title and the options bar; its centre shows the document's centre. + let view = ERect::from_min_max(pos2(LEFT_W, 30.0 + BAR_H), pos2(1600.0 - PANEL_W - PREVIEW_W, 1000.0)); + let c = view.center(); + let at = |dx: f32, dy: f32| pos2(c.x + dx, c.y + dy); + let press = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, down: bool, p: Pos2| { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: Modifiers::NONE }); + h.run_steps(1); + }; + h.event(egui::Event::PointerMoved(at(0.0, 0.0))); + h.run_steps(1); + press(&mut h, true, at(0.0, 0.0)); + // Within egui's click distance (6 px): no drag yet, but these are the stroke's points. + // Then past it: the drag starts. (Not collinear: the lasso's polygon needs an area.) + for (dx, dy) in [(2.0, 1.0), (4.0, 2.0), (30.0, 20.0), (60.0, -10.0), (90.0, 40.0)] { + h.event(egui::Event::PointerMoved(at(dx, dy))); + h.run_steps(1); + } + press(&mut h, false, at(90.0, 40.0)); + h.run_steps(2); + let w = h.state().distort.caf.as_ref().unwrap(); + let points: Vec<[f64; 2]> = match tool { + "brush" => { + assert_eq!(w.strokes.len(), 1, "one stroke"); + w.strokes[0].points.clone() + } + _ => { + assert_eq!(w.fill_edits.len(), 1, "one lasso edit"); + serde_json::from_value(w.fill_edits[0]["points"].clone()).unwrap() + } + }; + let centre = [f64::from(w.canvas.x0 + w.canvas.x1) / 2.0, f64::from(w.canvas.y0 + w.canvas.y1) / 2.0]; + assert_eq!(points.len(), 6, "{tool}: the press and every move are points: {points:?}"); + let d = ((points[0][0] - centre[0]).powi(2) + (points[0][1] - centre[1]).powi(2)).sqrt(); + assert!(d < 1.0, "{tool}: the first point is the press ({:?} vs {centre:?})", points[0]); + assert!(w.cur.is_none() && w.lasso.is_none() && !w.pressed, "{tool}: the release ended it"); + } + } + + /// The preview texture fits the device: a plan rectangle wider than the texture limit is + /// box-averaged to fit, and is still drawn over the plan's rectangle. + #[test] + fn the_preview_texture_respects_the_device_limit() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": 2200, "height": 40, "depth": 8})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, active| { + let s = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + s.fill_rect(Rect::new(0, 0, 2200, 40), &[0.2, 0.6, 0.4, 1.0]); + s.fill_rect(Rect::new(1090, 10, 1110, 30), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.session.execute("select.rect", json!({"x": 1090, "y": 10, "width": 20, "height": 20})).unwrap(); + app.sync_views(); + open(&mut app).unwrap(); + // Custom sampling with a stroke across the whole width: the plan's rectangle is 2200 px wide. + control(&mut app, &json!({"sampling": "custom", "stroke": {"points": [[0.0, 5.0], [2200.0, 5.0]], "size": 8}})).unwrap(); + for (limit, want) in [(2048usize, 1100usize), (4096, 2200)] { + let w = app.distort.caf.as_mut().unwrap(); + w.texture_limit = limit; + w.stale(); + control(&mut app, &json!({"waitPreview": true})).unwrap(); + let (rect, img) = ws(&app).preview.pending_image.clone().unwrap(); + assert_eq!((rect.width(), img.size[0]), (2200, want), "limit {limit}"); + assert!(img.size.iter().all(|s| *s <= limit), "limit {limit}: {:?}", img.size); + let (sum, n) = img.pixels.iter().fold((0u32, 0u32), |(s, n), p| (s + u32::from(p.g()), n + 1)); + assert!(sum / n > 100, "limit {limit}: the reduced preview keeps the image ({})", sum / n); + } + } + + /// A preview that fails stands for that state: nothing is shown, the error is reported, and + /// the same preview is not started again on every frame. + #[test] + fn a_failing_preview_is_reported_once_not_restarted() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + control(&mut app, &json!({"waitPreview": true})).unwrap(); + assert!(ws(&app).preview.pending_image.is_some()); + // The selection goes away under the workspace: there is nothing to fill. + app.session.execute("select.deselect", json!({})).unwrap(); + app.distort.caf.as_mut().unwrap().stale(); + assert!(control(&mut app, &json!({"waitPreview": true})).is_err()); + let pv = &ws(&app).preview; + assert_eq!(pv.shown, pv.want, "the failed state counts as shown: no retry"); + assert!(pv.pending_image.is_none() && pv.tex.is_none()); + let d = ws(&app).describe(); + assert_eq!(d["previewReady"], json!(false)); + assert!(d["previewError"].as_str().is_some_and(|e| e.contains("selection")), "{d}"); + } + + /// The view shows the document, not the layer alone: after Apply to a new layer the fill + /// (on that layer) covers the blemish. + #[test] + fn the_view_shows_the_document_after_an_apply_to_a_new_layer() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let red = |img: &egui::ColorImage| { + let p = img.pixels[80 * img.size[0] + 100]; + p.r() > 200 && p.g() < 60 + }; + let before = view_image(&app).unwrap(); + assert_eq!(before.size, [200, 160]); + assert!(red(&before), "the blemish shows before the fill"); + control(&mut app, &json!({"output": "new", "colorAdaptation": "none", "apply": true})).unwrap(); + assert!(!red(&view_image(&app).unwrap()), "the fill on the new layer shows"); + } + + /// The engine's limits (MAX_CAF_EDITS strokes or lasso edits, MAX_CAF_POINTS points in all) + /// hold at the brush: a stroke past them is refused with a message, so Apply and the + /// preview never fail on what was painted. + #[test] + fn strokes_past_the_engines_limits_are_refused_at_the_brush() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + control(&mut app, &json!({"sampling": "rectangular"})).unwrap(); + for i in 0..MAX_CAF_EDITS { + let x = (i % 190) as f64; + control(&mut app, &json!({"stroke": {"points": [[x, 5.0], [x + 3.0, 5.0]], "size": 2}})).unwrap(); + } + let d = control(&mut app, &json!({"stroke": {"points": [[10.0, 50.0], [20.0, 50.0]], "size": 2}})).unwrap(); + assert_eq!(d["strokes"], json!(MAX_CAF_EDITS)); + assert!(d["error"].as_str().is_some_and(|e| e.contains("at most")), "{d}"); + control(&mut app, &json!({"waitPreview": true})).unwrap(); + // The points budget: a long stroke stops at it. + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let pts: Vec<[f64; 2]> = (0..MAX_CAF_POINTS + 100).map(|i| [10.0 + i as f64 * 0.0005, 5.0]).collect(); + let d = control(&mut app, &json!({"stroke": {"points": pts, "size": 2}})).unwrap(); + assert_eq!(ws(&app).points_used(), MAX_CAF_POINTS); + assert!(d["error"].as_str().is_some_and(|e| e.contains("at most")), "{d}"); + control(&mut app, &json!({"waitPreview": true})).unwrap(); + // A lasso edit past the points budget is refused too. + let d = control(&mut app, &json!({"lasso": {"op": "add", "points": [[130.0, 70.0], [150.0, 70.0], [150.0, 90.0]]}})).unwrap(); + assert_eq!(d["fillEdits"], json!(0)); + assert!(d["error"].as_str().is_some_and(|e| e.contains("at most")), "{d}"); + } +} diff --git a/crates/ui-egui/src/cam_transform.rs b/crates/ui-egui/src/cam_transform.rs index a2afa8d812..995645940e 100644 --- a/crates/ui-egui/src/cam_transform.rs +++ b/crates/ui-egui/src/cam_transform.rs @@ -212,11 +212,11 @@ pub(crate) fn cursor(app: &PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p Some(match h { Hit::Corner(i) => { let (a, b) = (q.get(i)?, q.get((i + 2) % 4)?); - crate::type_transform::resize_cursor([a[0] - b[0], a[1] - b[1]], xf.flip) + crate::tool_cursor::resize([a[0] - b[0], a[1] - b[1]], xf) } Hit::Edge(i) => { let (a, b) = (mid(i)?, mid(i + 2)?); - crate::type_transform::resize_cursor([a[0] - b[0], a[1] - b[1]], xf.flip) + crate::tool_cursor::resize([a[0] - b[0], a[1] - b[1]], xf) } Hit::Outside => { crate::type_transform::rotation_cursor(ctx, p); diff --git a/crates/ui-egui/src/camera_raw_preview_ui.rs b/crates/ui-egui/src/camera_raw_preview_ui.rs index f71959d5d5..e2d15f9e0c 100644 --- a/crates/ui-egui/src/camera_raw_preview_ui.rs +++ b/crates/ui-egui/src/camera_raw_preview_ui.rs @@ -110,11 +110,22 @@ pub(crate) fn interact(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport: let rect = state.image_rect(viewport, size); // Reuse the canvas wheel policy: retain the modifiers of the wheel event while egui // smooths it, even if Alt is released before the final part of the notch arrives. - let wheel = crate::wheel_nav::read(ui.ctx(), false, true); - if response.hovered() { + let cr_hovered = response.hovered(); + let wheel = crate::wheel_nav::read(ui.ctx(), false, true, ui.id().with("cr-wheel"), cr_hovered); + if cr_hovered { ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO); match wheel { Some(crate::wheel_nav::Wheel::Zoom(factor)) => state.zoom_at(state.scale(viewport, size) * factor, at, viewport, size), + Some(crate::wheel_nav::Wheel::SnapZoom(n)) => { + let dir = if n > 0 { 1 } else { -1 }; + let mut nz = state.scale(viewport, size); + for _ in 0..n.unsigned_abs() { + nz = crate::zoom_levels::step_in_range(nz, dir, MIN_ZOOM, MAX_ZOOM); + } + if nz != state.scale(viewport, size) { + state.zoom_at(nz, at, viewport, size); + } + } Some(crate::wheel_nav::Wheel::Pan(delta)) => state.pan(delta, viewport, size), None => {} } @@ -287,6 +298,74 @@ mod tests { view.fit(); assert_eq!(view.image_rect(viewport, size), initial); } + #[test] + fn shift_alt_wheel_snaps_zoom_in_camera_raw() { + let viewport = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let size = vec2(1600.0, 1200.0); + let at = pos2(400.0, 300.0); + let mut state = CameraRawPreviewState { zoom: Some(1.0), center: [0.5, 0.5], hand: false }; + let scale_before = state.scale(viewport, size); + let n = 1i32; + let dir = if n > 0 { 1 } else { -1 }; + let mut nz = state.scale(viewport, size); + for _ in 0..n.unsigned_abs() { + nz = crate::zoom_levels::step_in_range(nz, dir, MIN_ZOOM, MAX_ZOOM); + } + assert_ne!(nz, scale_before, "zoom-in steps to a new level"); + state.zoom_at(nz, at, viewport, size); + assert_eq!(state.scale(viewport, size), nz, "zoom applied"); + assert!(nz > scale_before, "zoomed in"); + let scale_after_in = state.scale(viewport, size); + let mut nz = state.scale(viewport, size); + nz = crate::zoom_levels::step_in_range(nz, -1, MIN_ZOOM, MAX_ZOOM); + state.zoom_at(nz, at, viewport, size); + assert!(state.scale(viewport, size) < scale_after_in, "zoomed out"); + } + + #[test] + fn shift_alt_snap_at_limits_does_not_move() { + let viewport = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let size = vec2(1600.0, 1200.0); + let at = viewport.center(); + let state = CameraRawPreviewState { zoom: Some(MAX_ZOOM), center: [0.5, 0.5], hand: false }; + let nz = crate::zoom_levels::step_in_range(state.scale(viewport, size), 1, MIN_ZOOM, MAX_ZOOM); + assert_eq!(nz, state.scale(viewport, size), "at max, step up stays put"); + let state_lo = CameraRawPreviewState { zoom: Some(MIN_ZOOM), center: [0.5, 0.5], hand: false }; + let nz = crate::zoom_levels::step_in_range(state_lo.scale(viewport, size), -1, MIN_ZOOM, MAX_ZOOM); + assert_eq!(nz, state_lo.scale(viewport, size), "at min, step down stays put"); + let _ = at; + } + + #[test] + fn snap_zoom_from_fit_enters_explicit_zoom() { + let viewport = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let size = vec2(1600.0, 1200.0); + let at = viewport.center(); + let mut state = CameraRawPreviewState::default(); + assert!(state.zoom.is_none(), "starts in fit mode"); + let fit_scale = state.scale(viewport, size); + let nz = crate::zoom_levels::step_in_range(fit_scale, 1, MIN_ZOOM, MAX_ZOOM); + if nz != fit_scale { + state.zoom_at(nz, at, viewport, size); + } + assert!(state.zoom.is_some(), "snap zoom leaves fit mode"); + } + + #[test] + fn snap_zoom_preserves_point_under_pointer() { + let viewport = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let size = vec2(1600.0, 1200.0); + let at = pos2(300.0, 200.0); + let mut state = CameraRawPreviewState { zoom: Some(1.0), center: [0.5, 0.5], hand: false }; + let rect_before = state.image_rect(viewport, size); + let point_before = (at - rect_before.min) / rect_before.size(); + let nz = crate::zoom_levels::step_in_range(1.0, 1, MIN_ZOOM, MAX_ZOOM); + state.zoom_at(nz, at, viewport, size); + let rect_after = state.image_rect(viewport, size); + let point_after = (at - rect_after.min) / rect_after.size(); + assert!((point_before - point_after).length() < 0.01, "point preserved: {point_before:?} vs {point_after:?}"); + } + #[test] fn tiny_images_nonfinite_state_and_resize_stay_centered_and_finite() { let viewport = Rect::from_min_size(Pos2::ZERO, vec2(600.0, 400.0)); diff --git a/crates/ui-egui/src/camera_raw_scope_ui.rs b/crates/ui-egui/src/camera_raw_scope_ui.rs index c9f98080c9..9cb5233d00 100644 --- a/crates/ui-egui/src/camera_raw_scope_ui.rs +++ b/crates/ui-egui/src/camera_raw_scope_ui.rs @@ -384,7 +384,8 @@ fn graph(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut CameraRawScopeSt response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, ui.is_enabled(), tl!("Tone Histogram"))); ui.painter().rect_filled(rect, t.radius_sm, t.histogram_background()); let plot = rect.shrink(4.0); - crate::rgb_histogram::paint(ui.painter(), plot, d.histogram(), &t); + // Camera Raw's scale: the typical columns set it and a shadow or highlight spike runs off the top. + crate::rgb_histogram::paint_with(ui.painter(), plot, d.histogram(), &t, crate::rgb_histogram::Spikes::Clip); let corner = 18.0; if let Some(sample) = d.hover_sample(false) { crate::rgb_histogram::paint_sample(ui.painter(), plot, sample.rgb, &t); diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index f3e2aa8fbb..d2f78a35c2 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -69,6 +69,11 @@ pub struct CameraRawDialog { /// The open-time dialog's camera tone curve: Shadows / Highlights act on the raw data there, /// in linear light before this curve, and the preview undoes it to show the same. raw_curve: Option>, + /// Settings before each finished edit, so Ctrl+Z works inside the dialog (#4100). + undo: Vec, + redo: Vec, + /// Params at pointer-down. One drag is one undo step. + edit_origin: Option, } impl CameraRawDialog { @@ -638,6 +643,9 @@ fn open_pixels( render_ms: 0.0, raw_open: None, raw_curve: None, + undo: Vec::new(), + redo: Vec::new(), + edit_origin: None, }; d.render(ctx); app.camera_raw = Some(d); @@ -676,8 +684,10 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val for (key, value) in fields { match key.as_str() { "set" | "scope" | "view" => {} - "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" if value.is_boolean() => {} - "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" => return Some(Err(format!("Camera Raw {key} must be a boolean"))), + "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" | "undo" | "redo" if value.is_boolean() => {} + "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" | "undo" | "redo" => { + return Some(Err(format!("Camera Raw {key} must be a boolean"))); + } _ => return Some(Err(format!("unknown Camera Raw ui property {key}"))), } } @@ -754,14 +764,23 @@ fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Opti if d.params.point_curve != next_params.point_curve { d.curve_state = Default::default(); } - d.dirty |= d.params != next_params; - d.params = next_params; + if d.params != next_params { + remember_edit(d); + d.dirty = true; + d.params = next_params; + } if let Some(b) = ui.get("before").and_then(Value::as_bool) { d.show_before = b; } if let Some(update) = scope_update { update.apply(d, &mut app.ui.camera_raw_scope); } + if ui.get("undo").and_then(Value::as_bool) == Some(true) { + undo_edit(d); + } + if ui.get("redo").and_then(Value::as_bool) == Some(true) { + redo_edit(d); + } if d.dirty { d.render(ctx); } @@ -805,8 +824,11 @@ fn load_settings(app: &mut PhotocraftApp) -> Result { if d.params.point_curve != next.point_curve { d.curve_state = Default::default(); } - d.dirty |= d.params != next; - d.params = next; + if d.params != next { + remember_edit(d); + d.dirty = true; + d.params = next; + } app.ui.status_error = false; app.ui.status = if report.ignored.is_empty() { crate::i18n::fmt(tl!("Loaded Camera Raw settings from {file}"), &[("file", &file)]) @@ -827,6 +849,49 @@ fn report_file_error(app: &mut PhotocraftApp, r: Result) { } } +const RAW_UNDO_LIMIT: usize = 40; + +fn remember_edit(d: &mut CameraRawDialog) { + if d.undo.len() == RAW_UNDO_LIMIT { + d.undo.remove(0); + } + d.undo.push(d.params.clone()); + d.redo.clear(); +} + +fn undo_edit(d: &mut CameraRawDialog) -> bool { + let Some(prev) = d.undo.pop() else { return false }; + d.redo.push(d.params.clone()); + if d.params.point_curve != prev.point_curve { + d.curve_state = Default::default(); + } + d.params = prev; + d.dirty = true; + d.edit_origin = None; + true +} + +fn redo_edit(d: &mut CameraRawDialog) -> bool { + let Some(next) = d.redo.pop() else { return false }; + d.undo.push(d.params.clone()); + if d.params.point_curve != next.point_curve { + d.curve_state = Default::default(); + } + d.params = next; + d.dirty = true; + d.edit_origin = None; + true +} + +fn history_keys(d: &mut CameraRawDialog, ctx: &egui::Context) { + let cmd_shift = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; + if ctx.input_mut(|i| i.consume_key(cmd_shift, egui::Key::Z)) { + redo_edit(d); + } else if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Z)) { + undo_edit(d); + } +} + fn commit(app: &mut PhotocraftApp) -> Result { let d = app.camera_raw.as_ref().ok_or(tl!("Camera Raw isn't open"))?; let result = match d.target { @@ -995,6 +1060,11 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { let mut action: Option<&str> = None; egui::Area::new(egui::Id::new("camera-raw-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { let Some(d) = app.camera_raw.as_mut() else { return }; + history_keys(d, ctx); + let pointer = ctx.input(|i| i.pointer.primary_down()); + if pointer && d.edit_origin.is_none() { + d.edit_origin = Some(d.params.clone()); + } if let Some(display) = ¤t_display && display.key != d.display.key { @@ -1201,6 +1271,14 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { if fu.button(tl!("Load Settings…")).on_hover_text(tl!("Load settings from a Camera Raw .xmp file")).clicked() { action = Some("load"); } + if !ctx.input(|i| i.pointer.primary_down()) + && let Some(origin) = d.edit_origin.take() + && origin != d.params + { + let edited = std::mem::replace(&mut d.params, origin); + remember_edit(d); + d.params = edited; + } }); super::camera_raw_scope_ui::persist(app, ctx); if ctx.input(|i| i.key_pressed(egui::Key::Y)) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index db0efe31fa..184e9ccce3 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, @@ -244,7 +251,10 @@ pub(crate) fn marquee_preview_px(o: &crate::state::ToolOptions, d: &Drag, zoom: } let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); let (a, b) = marquee_corners(o, d, last); - let r = marquee_px(a, b); + let mut r = marquee_px(a, b); + if marquee_is_square(o, d) { + r = square_px(r, d.start, last); + } (r[2] > r[0] && r[3] > r[1]).then_some(r) } @@ -253,6 +263,37 @@ pub fn marquee_px(a: [f64; 2], b: [f64; 2]) -> [f64; 4] { [a[0].min(b[0]).floor(), a[1].min(b[1]).floor(), a[0].max(b[0]).ceil(), a[1].max(b[1]).ceil()] } +/// Whether the marquee drag `d` is constrained to a square (or circle): ⇧ during the drag with +/// the Normal style, or a Fixed Ratio of 1:1. Mirrors the branches of `chrome_ui::marquee_end`. +fn marquee_is_square(o: &crate::state::ToolOptions, d: &Drag) -> bool { + let (w, h) = (f64::from(o.marquee_width), f64::from(o.marquee_height)); + match o.marquee_style.as_str() { + "fixedSize" => false, + "fixedRatio" if w > 0.0 && h > 0.0 => w == h, + _ => d.marquee_mods().0, + } +} + +/// A constrained square `r` (`marquee_px` of its corners) with equal sides on whole pixels. +/// +/// The square is built in continuous coordinates, and snapping each axis to the pixel grid on +/// its own (floor the near edge, ceil the far one) made one side a pixel longer than the other +/// whenever the press point sat between pixels: a ⇧ drag read 101 × 102 (#5472). The shorter +/// side grows to the longer one, away from the press point (`start`, towards `end`), so the +/// square still covers every pixel the drag touched. +pub fn square_px(r: [f64; 4], start: [f64; 2], end: [f64; 2]) -> [f64; 4] { + let [x0, y0, x1, y1] = r; + let (w, h) = (x1 - x0, y1 - y0); + let n = w.max(h); + let grow = |lo: f64, hi: f64, extent: f64, from: f64, to: f64| { + let d = (n - extent).max(0.0); + if to < from { (lo - d, hi) } else { (lo, hi + d) } + }; + let (x0, x1) = grow(x0, x1, w, start[0], end[0]); + let (y0, y1) = grow(y0, y1, h, start[1], end[1]); + [x0, y0, x1, y1] +} + /// The one-pixel line `[x0, y0, x1, y1]` a Single Row (Single Column) Marquee at `p` selects: the /// pixel row (column) under `p`, kept on the canvas, across the whole `size`. `None` for other /// tools, an empty canvas or a point that is not a number. @@ -475,12 +516,22 @@ pub(crate) struct CommittedStroke { pub(crate) revision: u64, key: u64, damage: Vec, + /// What the commit repaints beyond the stroke whatever a cache showed: the layer's whole + /// composite when effects laid out over its frame moved with it (a Gradient Overlay spans the + /// grown layer, #4053; `photocraft_compose::relayout_damage`). + relayout: DRect, } impl CommittedStroke { #[cfg(test)] pub(crate) fn for_test(key: u64, damage: Vec) -> Self { - Self { doc: photocraft_doc::DocId(0), revision: 0, key, damage } + Self { doc: photocraft_doc::DocId(0), revision: 0, key, damage, relayout: DRect::EMPTY } + } + + /// The raw preview key of the live stroke's last step: what a cache showed at release. + #[cfg(test)] + pub(crate) fn last_key(&self) -> u64 { + self.key + self.damage.len() as u64 } /// What a cache that showed raw preview `key` of this stroke at the revision before misses. @@ -697,10 +748,12 @@ fn feed_live_stroke(app: &mut PhotocraftApp) { return; } l.fed = d.points.len(); + let started = crate::gpu_canvas::now_ms(); match l.stroke.push(&pts) { Ok(r) => l.damage.push(r), Err(_) => app.live_stroke = None, } + app.stroke_trace.stroke(crate::gpu_canvas::now_ms() - started); } /// The points the live retouching stroke has rendered. @@ -1160,12 +1213,13 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar return (doc.clone(), key); } } + let revision = st.revision; let snapshot = st.doc.clone(); if let Some((layer, params)) = app.live_adjust.clone() && let Some(l) = snapshot.layer(layer) && let LayerContent::Adjustment(a) = &l.content { - let key = 1 + params.to_string().bytes().fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64)); + let key = crate::adjust_preview::live_key(app, &snapshot, revision, layer, ¶ms); let shown = memo_display(app, &snapshot, key, || { let kind = photocraft_engine::commands::adjustment_kind(a); let preview = photocraft_engine::adjust_params::from_params(kind, ¶ms, Some(a), snapshot.mode).unwrap_or_else(|_| a.clone()); @@ -1385,7 +1439,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) { @@ -1428,6 +1482,13 @@ pub(crate) fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), no && let Some(c) = app.committed_stroke.as_ref().filter(|c| c.doc == st.doc.id && c.revision == now.0) && let Some(r) = c.since(seen.1 ^ display_key) { + let r = if c.relayout.is_empty() { + r + } else if r.is_empty() { + c.relayout + } else { + r.union(&c.relayout) + }; return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }); } if seen.1 == now.1 ^ display_key && seen.0 + 1 == now.0 { @@ -1446,6 +1507,14 @@ pub(crate) fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), no { return Some(r); } + // Between a Properties-panel adjustment-layer drag's previews (and the document): the + // layer's area. Without this every parameter value recomposited the whole document (#6839). + if seen.0 == now.0 + && let Some(st) = app.session.documents().get(idx) + && let Some(r) = crate::adjust_preview::live_switch_region(app, st.doc.id, now.0, seen.1 ^ display_key, now.1) + { + return Some(r); + } // Between a Move drag's offsets: where the moving layers were and are (Auto-Select's pick // at the press changes no pixels). if (seen.0 == now.0 || (seen.0 + 1 == now.0 && last_damage.is_some_and(|r| r.is_empty()))) @@ -1539,16 +1608,28 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: }; let (doc, raw_key) = display_doc(app, idx); let (display, _) = canvas_display(app, &doc, None); + // Repeated pattern tiles sample the same texture, including pixels outside the unwrapped + // viewport. Refresh the affected document region while Pattern Preview is enabled. + let blend_visible = if app.ui.view.pattern_preview { doc.bounds() } else { visible }; // The texture is shared by every display: only what it stores counts, not the monitor. let display_key = texture_key(display.as_deref()); let seen = app.canvases.get(&(id, GPU_OUTPUT)).map(|c| (c.revision, c.preview_key)); let mut damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, raw_key), display_key, last_damage)); + if let Some(region) = damage { + damage = Some(crate::blend_preview::clip_damage(app, id, revision, raw_key, region, blend_visible)); + } // To an adjustment dialog's preview: only what the view shows now (the rest as it moves there). if damage.is_some() && let Some(r) = seen.filter(|s| s.0 == revision).and_then(|s| crate::adjust_preview::switch_region(app, id, revision, s.1 ^ display_key, raw_key)) { damage = Some(crate::adjust_preview::switch_damage(app, raw_key, r, visible)); } + // The same for a Properties-panel drag's previews. + if damage.is_some() + && let Some(r) = seen.filter(|s| s.0 == revision).and_then(|s| crate::adjust_preview::live_switch_region(app, id, revision, s.1 ^ display_key, raw_key)) + { + damage = Some(crate::adjust_preview::live_switch_damage(app, raw_key, r, visible)); + } let preview_key = raw_key ^ display_key; // The Navigator may draw this texture only while it holds the plain document (no live // preview, which can be composited only where the view is). @@ -1572,24 +1653,52 @@ 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); + } + // 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) { let r = gpu.refresh(id.0, &doc, Some(r), display.as_deref()); - app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms); + app.perf.record_refresh(&r); + } + if let Some(r) = crate::adjust_preview::live_uncovered(app, id, raw_key, visible) { + let r = gpu.refresh(id.0, &doc, Some(r), display.as_deref()); + app.perf.record_refresh(&r); + } + // Blend previews keep offscreen pixels untouched until the view reaches them. + // Use the normal refresh path so CPU fallback and pending redraws stay synchronized. + damage = crate::blend_preview::uncovered(app, id, revision, raw_key, blend_visible); + if damage.is_none() { + return true; } - return true; } // Display textures for a canvas this large would not fit the GPU: the reduced CPU canvas // 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(); 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. @@ -1612,7 +1721,7 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: log::info!("{e}; using the CPU compositor"); } if r.kind != crate::gpu_canvas::REFRESH_DEFERRED { - app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms); + app.perf.record_refresh(&r); } if r.kind == "full" { crate::notices::slow_cpu_refresh(app, id.0, r.composite_ms + r.upload_ms, r.fallback.as_deref()); @@ -1630,21 +1739,53 @@ 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, and physical memory: 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.history.pixel_bytes(&st.doc) as u64; - let budget = crate::gpu_canvas::memory_budget(allowance, pixels, crate::gpu_canvas::physical_memory()); + let pixels = st.memory_bytes() as u64; + 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)) +} + +/// 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 @@ -1668,8 +1809,14 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e return None; } let cmd = d.fields.get("__command")?.as_str()?.to_string(); - // Previews edit what the command will: a targeted layer mask included (#780). - let mut params = app.with_mask_target(&cmd, crate::filter_dialog::params_of(&d.fields)); + // Previews edit what the command will: a targeted layer mask included (#780), and a smart + // filter being edited is replaced in its stack rather than stacked again (#6324). + let (cmd, mut params) = if d.fields.contains_key("__smartFilter") { + crate::smart_ui::confirm_command(&d.fields, cmd, crate::filter_dialog::params_of(&d.fields)) + } else { + let params = app.with_mask_target(&cmd, crate::filter_dialog::params_of(&d.fields)); + (cmd, params) + }; // Select and Mask previews in its view mode; holding X shows the image without one. if crate::select_mask_view::owns(&d.fields) { if crate::select_mask_view::hides_view(&d.fields) { @@ -1678,8 +1825,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, @@ -1697,21 +1843,36 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e #[cfg(not(target_arch = "wasm32"))] if app.background_jobs { app.filter_preview_worker.supersede(&request, crate::gpu_canvas::now_ms()); - if let Some((finished, computed)) = app.filter_preview_worker.poll() - && finished == request - { + if let Some((finished, computed)) = app.filter_preview_worker.poll() { match computed { - Ok(computed) => publish_filter_preview(app, idx, finished, computed.result, computed.ms), - Err(error) => { + // The dialog still holds these exact parameters. Publish as before, including an + // empty result: that is how a filter which no longer applies clears the canvas. + Ok(computed) if finished == request => publish_filter_preview(app, idx, finished, computed.result, computed.ms), + // Mid-drag, a frame for the value the pointer has just left is what makes the + // canvas follow the drag. Three things have to hold for it: it belongs to this + // preview, it has a frame to show (an empty one would blank a good preview), and + // it must not displace a frame that is already exact for the dialog, which is + // what a slider dragged out and back would otherwise make the canvas do. + Ok(computed) + if finished.same_target(&request) && computed.result.is_some() && !app.filter_preview.as_ref().is_some_and(|p| p.key == request) => + { + publish_filter_preview(app, idx, finished, computed.result, computed.ms); + } + Ok(_) => {} + // Errors stay on the exact request: a value the pointer has already dragged past + // must not raise a notice, and a still one will raise it when it is recomputed. + Err(error) if finished == request => { app.filter_preview = Some(crate::filter_dialog::FilterPreview { key: finished, result: None, committing: false }); crate::notices::error(app, error); } + Err(_) => {} } } let fresh = app.filter_preview.as_ref().is_some_and(|p| p.key == request); if !fresh && !app.filter_preview_worker.busy() { let doc = app.session.documents().get(idx)?.doc.clone(); let documents: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); + let color = app.session.color.settings.clone(); let task = request.clone(); if let Err(error) = app.filter_preview_worker.start(request.clone(), ctx.clone(), move |cancel| { let t0 = crate::gpu_canvas::now_ms(); @@ -1724,6 +1885,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e Some(cancel), task.target, &documents, + &color, ) .map(|doc| { let buffer = photocraft_compose::flatten(&doc); @@ -1740,15 +1902,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e } let preview = app.filter_preview.as_ref()?; // Keep the last preview during a drag, but never one from another source or dialog. - // Completed worker results above must match every parameter before being uploaded. - if preview.key.doc != request.doc - || preview.key.revision != request.revision - || preview.key.dialog != request.dialog - || preview.key.active != request.active - || preview.key.target != request.target - || preview.key.k != request.k - || preview.key.command != request.command - { + if !preview.key.same_target(&request) { return None; } let result = preview.result.as_ref()?; @@ -1756,12 +1910,13 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e } // Web and explicitly inline sessions keep the deterministic synchronous path (#1676): // computed inline, uploaded when a GPU texture exists, still inspectable without one. + // Flattened once, as the background worker does; `publish_filter_preview` uploads it (#7490). let _ = ctx; if app.filter_preview.as_ref().is_none_or(|p| p.key != request) { let doc = app.session.documents().get(idx)?.doc.clone(); let documents: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); let t0 = crate::gpu_canvas::now_ms(); - let result = crate::filter_dialog::preview_document_for_target( + let computed = crate::filter_dialog::preview_document_for_target( &doc, request.active, &request.command, @@ -1770,19 +1925,13 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e None, request.target, &documents, + &app.session.color.settings, ) - .map(std::sync::Arc::new); - if let Some(r) = &result { - let buf = photocraft_compose::flatten(r); - let t1 = crate::gpu_canvas::now_ms(); - let (display, _) = canvas_display(app, &doc, None); - if let Some(gpu) = app.gpu.as_ref() { - gpu.upload_buffer_full(key, &texture_buffer(display.as_deref(), &buf), doc.depth); - } - app.perf.record("filter-preview", r.size.area(), t1 - t0, crate::gpu_canvas::now_ms() - t1); - } - let buffer = result.as_ref().map(|r| photocraft_compose::flatten(r)); - publish_filter_preview(app, idx, request, result.zip(buffer), crate::gpu_canvas::now_ms() - t0); + .map(|doc| { + let buffer = photocraft_compose::flatten(&doc); + (std::sync::Arc::new(doc), buffer) + }); + publish_filter_preview(app, idx, request, computed, crate::gpu_canvas::now_ms() - t0); } let preview = app.filter_preview.as_ref()?; let result = preview.result.as_ref()?; @@ -1811,7 +1960,9 @@ fn publish_filter_preview( let result = computed.and_then(|(result, buffer)| { let t0 = crate::gpu_canvas::now_ms(); let doc = app.session.documents().get(idx)?.doc.clone(); - let (display, _) = canvas_display(app, &doc, None); + // Shown through the preview's own colour management, as the result will be: Assign and + // Convert to Profile change the profile (and Convert the mode), not just the pixels. + let (display, _) = canvas_display(app, &result, None); // A GPU texture is an optimisation: headless/CPU tests must still see the preview. if let Some(gpu) = app.gpu.as_ref() { gpu.upload_buffer_full(request.doc.0 ^ (1u64 << 61), &texture_buffer(display.as_deref(), &buffer), doc.depth); @@ -1822,6 +1973,16 @@ fn publish_filter_preview( app.filter_preview = Some(crate::filter_dialog::FilterPreview { key: request, result, committing: false }); } +/// The document whose colour management the canvas texture under `key` is shown through: a filter +/// preview's own result (it keeps the document's id, so Proof Colors still apply), else `doc`. +/// Assign Profile changes only the profile, so through `doc`'s its preview looked unchanged. +fn filter_preview_display_doc(app: &PhotocraftApp, doc: &std::sync::Arc, key: u64) -> std::sync::Arc { + if key != doc.id.0 ^ (1u64 << 61) { + return doc.clone(); + } + app.filter_preview.as_ref().and_then(|p| p.result.clone()).filter(|r| r.id == doc.id).unwrap_or_else(|| doc.clone()) +} + /// After a filter dialog's OK: `Some(shown)` while its preview stands in for the background job /// that commits it (the document unchanged and the job still running, for this document's views), /// `None` otherwise (other documents' views included). A job that lands, fails or is cancelled @@ -1875,6 +2036,9 @@ fn ensure_adjust_proxy(app: &mut PhotocraftApp, idx: usize, zoom: f32) -> Option let (display, _) = canvas_display(app, &doc, None); let r = gpu.refresh(key, &frame.doc, frame.damage, display.as_deref()); app.perf.record(if r.kind.starts_with("gpu") { "gpu-adjust-proxy" } else { "adjust-proxy" }, r.px, r.composite_ms, r.upload_ms); + if let Some(t) = r.gpu { + app.perf.record_gpu(t); + } crate::adjust_preview::proxy_drawn(app, &frame); } Some((frame.k, key, size)) @@ -1882,14 +2046,22 @@ fn ensure_adjust_proxy(app: &mut PhotocraftApp, idx: usize, zoom: f32) -> Option /// If a live adjustment preview is active on a large document, composite it on the proxy and upload /// it under its own GPU key. Returns (factor, gpu key, dimensions) when the proxy should be drawn. -fn ensure_proxy_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64, [u32; 2])> { +/// `zoom` is the view's (physical screen pixels per document pixel). +fn ensure_proxy_preview(app: &mut PhotocraftApp, idx: usize, zoom: f32) -> Option<(u32, u64, [u32; 2])> { let (layer, params) = app.live_adjust.clone()?; let (doc_id, revision, doc) = { let st = app.session.documents().get(idx)?; (st.doc.id, st.revision, st.doc.clone()) }; // Low-resolution previews off: k is 1 and the canvas shows `display_doc` (full size). - let k = crate::proxy::preview_factor(&doc, crate::proxy::reduced_previews(app)); + let mut k = crate::proxy::preview_factor(&doc, crate::proxy::reduced_previews(app)); + // Zoomed in, a proxy coarser than the screen showed the drag in k-pixel blocks (#5764): when + // the wgpu compositor draws the full-size document, use a proxy only as coarse as the screen + // (none at 50 % and closer). Otherwise the full size would be a CPU composite per change. + let fine = crate::proxy::view_factor(k, zoom); + if fine < k && app.gpu.as_ref().is_some_and(|g| g.supports(&doc)) { + k = fine; + } if k <= 1 { return None; } @@ -1957,6 +2129,7 @@ pub(crate) fn retain_gpu_documents(app: &mut PhotocraftApp) { /// Tabs + canvas for the active document, or the start screen; then drops layers dragged onto /// another document (`layer_transfer`). pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + crate::distort_ui::drop_foreign(app); documents(app, ui); crate::layer_transfer::finish(app, ui.ctx()); // Once a frame, not per document view: the right-click or options-bar Brush Preset picker. @@ -1969,9 +2142,12 @@ pub(crate) fn arranged_cells(app: &PhotocraftApp, rect: Rect) -> Option = (0..n).map(|k| (idx + k) % n).zip(cells).collect(); - shown.sort_by_key(|(d, _)| *d); - Some(shown) + // Each document keeps its tile: a click into another one activates it where it is (#6017; it + // used to move to the first tile). With more documents than tiles, the shown run stays put + // while the active document is in it, and otherwise slides only as far as it must. + let start = app.arrange_start.min(n.saturating_sub(cells.len())); + let start = if idx < start { idx } else { start.max((idx + 1).saturating_sub(cells.len())) }; + Some((start..n).zip(cells).collect()) } fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { @@ -1990,7 +2166,9 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } if n == 0 && !opening { // Auto show the Home Screen is off: an empty workspace, like Photoshop. - paint_dots(ui, ui.available_rect_before_wrap(), app.session.prefs().interface.show_canvas_dots); + let area = ui.available_rect_before_wrap(); + paint_dots(ui, area, app.session.prefs().interface.show_canvas_dots); + open_on_double_click(app, ui, area); return; } if !app.ui.view.hides_tabs() || opening { @@ -2015,6 +2193,7 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Window › Arrange: tiled / n-up layouts show several documents side by side; the active one // takes input, a click elsewhere activates that document. if let Some(shown) = arranged_cells(app, rect) { + app.arrange_start = shown.first().map_or(0, |(d, _)| *d); let t = crate::theme::Tokens::get(ui.ctx()); for (d, cell) in shown { let cell = cell.shrink(1.0); @@ -2085,7 +2264,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { .map_or_else(|| app.session.active_index().unwrap_or(0), |p| tab_count + p); let frame = egui::Frame::NONE.fill(t.canvas).inner_margin(egui::Margin { left: 8, right: 8, top: 6, bottom: 4 }).show(ui, |ui| { let (row, _) = ui.allocate_exact_size(egui::vec2(ui.available_width(), tab_h), Sense::hover()); - let f = crate::tab_strip::fit(&natural, selected, row.width(), DOC_TAB_MIN_W, crate::tab_strip::CHEVRON_W); + let f = fit_doc_tabs(&natural, selected, row.width()); let mut x = row.left(); let mut placed: Vec<(usize, Rect)> = Vec::with_capacity(f.shown.len()); for &(i, w) in &f.shown { @@ -2183,7 +2362,10 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { let labels: Vec<&str> = app.session.documents().iter().map(|st| st.doc.name.as_str()).chain(opening.iter().map(|(_, name, _)| name.as_str())).collect(); let mut picked = None; - crate::tab_strip::overflow_button(ui, ui.id().with("dtab-overflow"), r, tl!("More documents"), &labels, &f.overflow, &mut picked); + // Like Photoshop's, the menu lists every open document, not only the hidden ones, and checks + // the active one (#4487). + let every: Vec = (0..labels.len()).collect(); + crate::tab_strip::overflow_button(ui, ui.id().with("dtab-overflow"), r, tl!("More documents"), &labels, &every, Some(selected), &mut picked); match picked { Some(i) if i < tab_count => activate = Some(i), Some(i) => focus_open = opening.get(i - tab_count).map(|(job, _, _)| *job).or(focus_open), @@ -2357,23 +2539,39 @@ fn open_tab_clicks( } } -/// Document tabs: the chrome around a title (the × and its gaps), and the width a tab shrinks to -/// before it moves into the » overflow menu. +/// Document tabs: the chrome around a title (the × and its gaps). const DOC_TAB_PAD: f32 = 42.0; -const DOC_TAB_MIN_W: f32 = 72.0; /// The Studio strip's tabs carried 4 pt of spacing between them and 44 pt of chrome; `fit` sees /// one number per tab, so the gap rides in the natural width and comes back out when painted. const STUDIO_TAB_PAD: f32 = 48.0; const STUDIO_TAB_GAP: f32 = 4.0; +/// Fit document tabs as Photoshop does (#4487): a tab never shrinks below its whole title, so the +/// tabs that don't fit move into the » menu instead. Only the active tab, when it alone is wider +/// than the strip, is cut. +fn fit_doc_tabs(natural: &[f32], selected: usize, avail: f32) -> crate::tab_strip::TabFit { + let widest = natural.iter().copied().filter(|w| w.is_finite()).fold(0.0, f32::max); + crate::tab_strip::fit(natural, selected, avail, widest, crate::tab_strip::CHEVRON_W) +} + /// Photoshop's tab title: "name @ 50% (Layer 1, RGB/8)", "(Layer 1, Layer Mask/8)" when the mask -/// is targeted; the Background layer's name is omitted. +/// is targeted, "(Quick Mask/8)" in Quick Mask mode; the Background layer's name is omitted. fn pro_tab_title(app: &PhotocraftApp, i: usize) -> String { + use photocraft_engine::channel_cmds::ChannelTarget; let Some(st) = app.session.documents().get(i) else { return String::new() }; let zoom = app.ui.views.get(i).map_or(100.0, |v| v.zoom * 100.0); - let active_layer = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| !st.doc.is_background(l.id)); + // Quick Mask mode targets the mask, not the layer (see `paint_target`): painting and Free + // Transform change the red overlay, so the tab says so, as Photoshop's does. + let quick_mask = st.doc.quick_mask.is_some() && st.channel_view.target == ChannelTarget::Composite; + let active_layer = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| !quick_mask && !st.doc.is_background(l.id)); let mask = app.session.active_index() == Some(i) && app.ui.mask_target && active_layer.is_some_and(|l| l.mask.is_some()); - let model = if mask { tl!("Layer Mask").to_string() } else { mode_label(&st.doc).to_string() }; + let model = if quick_mask { + tl!("Quick Mask").to_string() + } else if mask { + tl!("Layer Mask").to_string() + } else { + mode_label(&st.doc).to_string() + }; let layer = active_layer.map(|l| format!("{}, ", l.name)).unwrap_or_default(); // A save in progress: "name @ 100% (Layer 1, RGB/16) - Saving 45%", as Photoshop does (#3389). let saving = crate::jobs_ui::save_progress(app, st.doc.id).map(|s| format!(" - {s}")).unwrap_or_default(); @@ -2387,8 +2585,8 @@ fn studio_tab_name(app: &PhotocraftApp, st: &photocraft_engine::DocState) -> Str } /// Photoshop document tabs: "name @ 33.3% (RGB/8)" on a dark strip; active tab matches panels. -/// Tabs that don't fit shrink and elide, and the ones that still don't fit move into a » menu at -/// the end of the strip; the active document's tab always stays on the strip (#1276). +/// Tabs keep their whole titles, and the ones that don't fit move into a » menu at the end of the +/// strip; the active document's tab always stays on the strip (#1276, #4487). fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { let t = crate::theme::Tokens::get(ui.ctx()); let active = app.session.active_index().filter(|_| app.jobs.focus.is_none()); @@ -2417,7 +2615,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { .focus .and_then(|job| opening.iter().position(|(open, _, _)| *open == job)) .map_or_else(|| app.session.active_index().unwrap_or(0), |p| tab_count + p); - let f = crate::tab_strip::fit(&natural, selected, strip.width(), DOC_TAB_MIN_W, crate::tab_strip::CHEVRON_W); + let f = fit_doc_tabs(&natural, selected, strip.width()); let mut x = strip.left(); let mut placed: Vec<(usize, Rect)> = Vec::with_capacity(f.shown.len()); for &(i, w) in &f.shown { @@ -2496,7 +2694,10 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { let r = Rect::from_min_size(egui::pos2(left, strip.top()), egui::vec2(crate::tab_strip::CHEVRON_W, strip.height())); let labels: Vec<&str> = titles.iter().map(String::as_str).collect(); let mut picked = None; - crate::tab_strip::overflow_button(ui, ui.id().with("ptab-overflow"), r, tl!("More documents"), &labels, &f.overflow, &mut picked); + // Like Photoshop's, the menu lists every open document, not only the hidden ones, and checks + // the active one (#4487). + let every: Vec = (0..labels.len()).collect(); + crate::tab_strip::overflow_button(ui, ui.id().with("ptab-overflow"), r, tl!("More documents"), &labels, &every, Some(selected), &mut picked); match picked { Some(i) if i < tab_count => activate = Some(i), Some(i) => focus_open = opening.get(i - tab_count).map(|(job, _, _)| *job).or(focus_open), @@ -2573,9 +2774,10 @@ pub fn fmt_zoom(pct: f32) -> String { if (pct - pct.round()).abs() < 0.05 { format!("{}", pct.round() as i64) } else { format!("{pct:.1}") } } -/// Repeating dot-grid texture for the canvas surround. +/// Repeating dot-grid texture for the canvas surround. A theme whose dots are its canvas colour +/// has no pattern to show, so it gets no texture at all rather than an invisible one (#4542). fn dots(ctx: &egui::Context, t: &crate::theme::Tokens) -> Option { - if t.bevel { + if t.bevel || t.canvas_dot == t.canvas { return None; } let key = egui::Id::new(("canvas-dots", format!("{:?}", t.kind))); @@ -2624,16 +2826,42 @@ pub fn mode_label(doc: &Document) -> &'static str { } } +/// With no document open, double-clicking the empty workspace shows File › Open, as in Photoshop +/// (#5367), and so does the Home screen's blank background. Registered before anything drawn over +/// `area`, so the buttons and links there keep their own clicks; not a keyboard focus stop. +fn open_on_double_click(app: &mut PhotocraftApp, ui: &egui::Ui, area: Rect) { + if app.session.documents().is_empty() && ui.interact(area, ui.id().with("empty-workspace"), Sense::CLICK).double_clicked() { + let _ = app.open_dialog_file(); + } +} + +/// The Home screen's card is this wide when the canvas has room, and as wide as the canvas when it doesn't. +const HOME_CARD_WIDTH: f32 = 460.0; +/// Least width of each of the Home screen's New document and Open buttons. +const HOME_BUTTON_WIDTH: f32 = 190.0; +/// Space between the Home screen's two buttons. +const HOME_BUTTON_GAP: f32 = 12.0; + fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); let area = ui.available_rect_before_wrap(); paint_dots(ui, area, app.session.prefs().interface.show_canvas_dots); + open_on_double_click(app, ui, area); // File › Open Recent, newest first (on the web there are no paths to reopen). - let recent: Vec = if cfg!(target_arch = "wasm32") { Vec::new() } else { app.ui.recent_files.iter().take(HOME_RECENT).cloned().collect() }; + let show_recent = app.session.prefs().interface.show_home_recent_files; + let recent: Vec = + if cfg!(target_arch = "wasm32") || !show_recent { Vec::new() } else { app.ui.recent_files.iter().take(HOME_RECENT).cloned().collect() }; let recent_h = if recent.is_empty() { 0.0 } else { 34.0 + recent.len() as f32 * HOME_RECENT_ROW }; - let card = Rect::from_center_size(area.center(), egui::vec2(460.0, 330.0 + recent_h)); + // A tiling window manager can give the canvas less room than the card wants: shrink the card + // to fit, and stack the buttons when they don't fit side by side. let new_label = crate::shortcuts::command_label(app, "New document…", "file.new"); let open_label = crate::shortcuts::command_label(app, "Open…", "file.open"); + let card_width = HOME_CARD_WIDTH.min(area.width()); + let button_width = HOME_BUTTON_WIDTH.max(crate::widgets::button_width(ui, &[&new_label, &open_label])); + let side_by_side = 2.0 * button_width + HOME_BUTTON_GAP <= card_width; + // Stacked buttons take one more row. + let stacked_h = if side_by_side { 0.0 } else { 44.0 }; + let card = Rect::from_center_size(area.center(), egui::vec2(card_width, 330.0 + recent_h + stacked_h)); ui.scope_builder(egui::UiBuilder::new().max_rect(card), |ui| { ui.vertical_centered(|ui| { ui.horizontal(|ui| { @@ -2649,17 +2877,26 @@ fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.add_space(6.0); ui.label(egui::RichText::new(tl!("Create a new document or open an existing file.")).color(t.text_dim).size(14.0)); ui.add_space(22.0); - ui.horizontal(|ui| { - ui.add_space(((card.width() - 2.0 * 190.0 - 12.0) / 2.0).max(0.0)); - ui.spacing_mut().item_spacing.x = 12.0; - if crate::widgets::primary_button(ui, &new_label, 190.0).clicked() { - let fields = app.new_document_fields(); - app.ui.open_dialog(crate::state::DialogKind::NewDocument, fields); - } - if crate::widgets::secondary_button(ui, &open_label, 190.0).clicked() { - let _ = app.open_dialog_file(); - } - }); + let (mut new_clicked, mut open_clicked) = (false, false); + if side_by_side { + ui.horizontal(|ui| { + ui.add_space(((card.width() - 2.0 * button_width - HOME_BUTTON_GAP) / 2.0).max(0.0)); + ui.spacing_mut().item_spacing.x = HOME_BUTTON_GAP; + new_clicked = crate::widgets::primary_button(ui, &new_label, button_width).clicked(); + open_clicked = crate::widgets::secondary_button(ui, &open_label, button_width).clicked(); + }); + } else { + new_clicked = crate::widgets::primary_button(ui, &new_label, button_width).clicked(); + ui.add_space(HOME_BUTTON_GAP); + open_clicked = crate::widgets::secondary_button(ui, &open_label, button_width).clicked(); + } + if new_clicked { + let fields = app.new_document_fields(); + app.ui.open_dialog(crate::state::DialogKind::NewDocument, fields); + } + if open_clicked { + let _ = app.open_dialog_file(); + } ui.add_space(22.0); ui.horizontal(|ui| { let msg = start_screen_drop_hint(app); @@ -2838,7 +3075,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::monitor_status::check_window(app, &ctx); } let full = rect; - let rect = if primary { crate::rulers::content_rect(app, rect) } else { rect }; + // Every document view has its rulers, the tiles of Window › Arrange and floating windows + // too, as in Photoshop (#6017). + let rect = crate::rulers::content_rect(app, rect); let doc = app.session.documents()[idx].doc.clone(); let size = [doc.size.width, doc.size.height]; if view.doc_size != size { @@ -2904,11 +3143,12 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if app.gpu.is_some() && gpu_ok && let Some((k, key, preview_size)) = - ensure_adjust_proxy(app, idx, view.zoom).or_else(|| ensure_filter_preview(app, idx, &ctx)).or_else(|| ensure_proxy_preview(app, idx)) + ensure_adjust_proxy(app, idx, view.zoom).or_else(|| ensure_filter_preview(app, idx, &ctx)).or_else(|| ensure_proxy_preview(app, idx, view.zoom)) { on_gpu = true; app.navigator_gpu.remove(&doc.id); let original_size = [doc.size.width.div_ceil(k), doc.size.height.div_ceil(k)]; + let shown = filter_preview_display_doc(app, &doc, key); let params = crate::gpu_canvas::ViewParams { doc: key, doc_size: preview_size, @@ -2925,7 +3165,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: }, pixel_grid: false, view_key: egui::Id::new(("pc-canvas-proxy", ctx.viewport_id(), idx)).value(), - display: sync_display_lut(app, &doc, key, output), + display: sync_display_lut(app, &shown, key, output), output: output.unwrap_or(0), hdr: hdr_preview(app, &doc), offset: [0.0, 0.0], @@ -3064,14 +3304,24 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // The view centre can be any finite number (`ui.set`, #980). Past ±2³⁰ there is no document // to outline, and the padding below (at most 2 · 256 · 64) can't overflow i32 from there. let px = |v: f64| v.clamp(-f64::from(1 << 30), f64::from(1 << 30)) as i32; - let vis = photocraft_geom::Rect::new( - px(x0.floor()).div_euclid(q) * q - q, - px(y0.floor()).div_euclid(q) * q - q, - (px(x1.ceil()).div_euclid(q) + 2) * q, - (px(y1.ceil()).div_euclid(q) + 2) * q, - ); + // A Free Transform carries the selection to wherever its box goes (#5297), so all of + // it is outlined: the part now in view may have started out of it. + let carried = app.session.active_index() == Some(idx) && crate::transform_tool::carries_selection_of(app, doc.id); + let vis = if carried { + photocraft_geom::Rect::new(-(1 << 30), -(1 << 30), 1 << 30, 1 << 30) + } else { + photocraft_geom::Rect::new( + px(x0.floor()).div_euclid(q) * q - q, + px(y0.floor()).div_euclid(q) * q - q, + (px(x1.ceil()).div_euclid(q) + 2) * q, + (px(y1.ceil()).div_euclid(q) + 2) * q, + ) + }; + let bounds_key = if k > 1 { u64::MAX / 2 - doc.id.0 } else { u64::MAX - doc.id.0 }; + let carried_bounds = carried.then(|| app.cached_bounds(bounds_key, sel).intersect(&vis)); + let step = carried_bounds.map_or(step, |b| crate::outline::bounded_step(b, step)); let key = crate::surface_fingerprint(sel) - ^ (step as u64) << 56 + ^ u64::from(step).rotate_left(56) ^ doc.id.0.rotate_left(17) ^ (vis.x0 as u64) << 8 ^ (vis.y0 as u64) << 24 @@ -3081,8 +3331,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let fresh = matches!(&app.outline_cache, Some((d, k, _)) if *d == doc.id && *k == key); if !fresh { let t0 = crate::gpu_canvas::now_ms(); - let bounds_key = if k > 1 { u64::MAX / 2 - doc.id.0 } else { u64::MAX - doc.id.0 }; - let b = app.cached_bounds(bounds_key, sel).intersect(&vis); + let b = carried_bounds.unwrap_or_else(|| app.cached_bounds(bounds_key, sel).intersect(&vis)); // Filters can occupy every worker in Rayon's global pool. A parallel scan here would // block the UI thread waiting for those workers, preventing job cancellation (#1554). let mut segs = @@ -3103,7 +3352,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if let Some((dx, dy)) = selection_shown_offset(app) { shown.center = [xf.center[0] - dx as f32, xf.center[1] - dy as f32]; } - marching_ants_segments(&painter, &shown, segs, time); + // Under a Free Transform box they are where the box puts them (#5297). + if !(carried && crate::transform_tool::draw_selection(app, &painter, &xf, doc.id, segs, time)) { + marching_ants_segments(&painter, &shown, segs, time); + } ctx.request_repaint_after(std::time::Duration::from_millis(100)); } } @@ -3121,12 +3373,24 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::spring_tools::note_canvas_press(app); } // Navigation (wheel_nav.rs): scroll pans (⇧ or ⌘/Ctrl sideways); pinch and ⌥-scroll zoom around the pointer. - let wheel = - crate::wheel_nav::read(&ctx, app.session.prefs().general.zoom_with_scroll_wheel, app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch); // The wheel also scrolls over the scrollbars drawn on top of the canvas (last frame's hover). let bars_id = egui::Id::new(("pc-canvas-bars-hover", idx)); let over_bars = ctx.data(|d| d.get_temp::(bars_id)).unwrap_or(false); - if response.hovered() || free_hover || over_bars { + let nav_hovered = response.hovered() || free_hover || over_bars; + // Interface › Enable Animations off: the wheel moves the canvas without a glide (#5721). + // Enhanced Controls › Enable Gestures off: two fingers on a touch screen neither zoom nor + // turn the view (#204); the mouse and trackpad are unaffected. + let touch_gestures = app.session.prefs().enhanced_controls.touch_gestures; + let wheel = crate::wheel_nav::read_with( + &ctx, + app.session.prefs().general.zoom_with_scroll_wheel, + app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch, + response.id.with("wheel"), + nav_hovered, + app.session.prefs().interface.enable_animations, + touch_gestures, + ); + if nav_hovered { let pointer = ui.input(|i| i.pointer.hover_pos()); match (wheel, pointer) { (Some(crate::wheel_nav::Wheel::Zoom(f)), Some(p)) => { @@ -3139,6 +3403,16 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: zoom_about(&mut view, &xf, at, nz, false, ppp); } } + (Some(crate::wheel_nav::Wheel::SnapZoom(n)), Some(p)) => { + let dir = if n > 0 { 1 } else { -1 }; + let mut nz = view.zoom; + for _ in 0..n.unsigned_abs() { + nz = crate::zoom_levels::step(nz, dir, view.doc_size); + } + if nz != view.zoom { + zoom_about(&mut view, &xf, p, nz, false, ppp); + } + } (Some(crate::wheel_nav::Wheel::Pan(scroll)), _) => { let d = xf.unmap_vec(scroll) / (view.zoom / ppp); view.center[0] -= d.x; @@ -3147,7 +3421,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: _ => {} } let rotate_trackpad = app.session.prefs().enhanced_controls.rotate_view_with_trackpad; - if let Some(delta) = crate::rotate_view::trackpad_delta(&ctx, rotate_trackpad) { + if let Some(delta) = crate::rotate_view::trackpad_delta(&ctx, rotate_trackpad, touch_gestures) { let next = view.rotation + crate::rotate_view::screen_delta(delta, xf.flip); if next.is_finite() { view.rotation = crate::rotate_view::wrap_deg(next); @@ -3216,7 +3490,24 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: primary && app.ui.dialogs.last().is_some_and(|d| crate::select_mask_view::owns(&d.fields) && crate::select_mask_view::brush_on(&d.fields)); // Field Blur's pins are placed and dragged on the image (`blur_pins`). let pinning = primary && app.ui.dialogs.last().is_some_and(|d| crate::blur_pins::owns(&d.fields)); - let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning; + // Layer Style on a Drop or Inner Shadow page: a drag on the image moves the shadow (#5701). + let shadow_drag = + !picking && app.ui.dialogs.last().is_some_and(|d| d.kind == crate::state::DialogKind::LayerStyle && crate::layer_style::drags_shadow(&d.fields)); + // ⌘/Ctrl-click with Curves open: a point at the pixel's tone (#7325), whatever the tool. The + // press's own modifiers decide, and only the press adds: a drag would add one per frame. + let curve_point_press = (primary && !middle && crate::adjust_dialog::curves_on_top(app)) + .then(|| { + ctx.input(|i| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::PointerButton { button: PointerButton::Primary, pressed: true, modifiers, .. } => Some(*modifiers), + _ => None, + }) + }) + }) + .flatten() + .map(|m| crate::workspace_ui::sticky_mods(app, m)) + .filter(|m| m.command); + let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning && !shadow_drag && curve_point_press.is_none(); if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) { let d = xf.unmap_vec(d) / (view.zoom / ppp); view.center[0] -= d.x; @@ -3224,6 +3515,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: ctx.set_cursor_icon(crate::tool_cursor::hand(&ctx, true)); } else if free_hover && (space_pan || hand) { ctx.set_cursor_icon(crate::tool_cursor::hand(&ctx, false)); + } else if shadow_drag && !middle && crate::dialogs::free_pointer_over(&ctx, rect).is_some() { + ctx.set_cursor_icon(egui::CursorIcon::Move); + if let Some(p) = crate::dialogs::free_press(&ctx, rect) { + let (start, origin) = ctx.input(|i| (i.pointer.primary_pressed(), i.pointer.press_origin().unwrap_or(p))); + let total = xf.unmap_vec(p - origin) / (view.zoom / ppp); + if let Some(d) = app.ui.dialogs.last_mut() { + crate::layer_style::drag_shadow(&mut d.fields, start, [f64::from(total.x), f64::from(total.y)]); + } + } } else if picking && let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect) { if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise { ctx.set_cursor_icon(egui::CursorIcon::Crosshair); @@ -3252,6 +3552,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: { let press = crate::dialogs::free_press(&ctx, rect).map(|q| xf.to_doc(q)); crate::select_mask_view::canvas_brush(app, &painter, p, press, xf.zoom_x()); + } else if let Some(mods) = curve_point_press + && let Some(p) = crate::dialogs::free_press(&ctx, rect) + { + let d = xf.to_doc(p); + crate::adjust_dialog::add_point_at(app, &ctx, d[0], d[1], mods.shift); } else if primary && !middle && let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect).filter(|p| img_rect.contains(*p)) @@ -3364,10 +3669,17 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::lasso_ui::canvas_input(app, &ctx, &xf, &response); (buttons.started, buttons.dragged, buttons.stopped, buttons.clicked) = (false, false, false, false); } + // Right-click on a guide where it could be dragged (the Move tool, or ⌘ with a selection + // tool): select it and open the guide menu, as Photoshop does. + let guide_menu = response.secondary_clicked() + && !lasso_retracted + && !transforming + && response.interact_pointer_pos().is_some_and(|p| crate::canvas_tool_menu::open_guide(app, &ctx, tool, mods, [p.x, p.y], xf.to_doc(p))); // Right-click with the Move tool, or ⌘/Ctrl+right-click: the layers under the pointer. if response.secondary_clicked() && !lasso_retracted && !transforming + && !guide_menu && crate::layer_pick_ui::is_gesture(tool, mods) && let Some(p) = response.interact_pointer_pos() { @@ -3381,6 +3693,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if response.secondary_clicked() && !lasso_retracted && !transforming + && !guide_menu && !crate::layer_pick_ui::is_gesture(tool, mods) && let Some(p) = response.interact_pointer_pos() { @@ -3558,6 +3871,12 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Tool::MagneticLasso if response.double_clicked() || response.triple_clicked() => crate::magnetic_lasso_ui::close(app, mods.alt), // The first click placed (or grabbed) the point; the second toggles it. Tool::CurvaturePen if response.double_clicked() => crate::vector_ui::curvature_toggle(app, d[0], d[1]), + // Double-clicking a guide opens Preferences › Guides, Grid & Slices, as in Photoshop. + Tool::Move if response.double_clicked() && crate::rulers::guide_at(app, d[0], d[1]).is_some() => { + if let Err(e) = crate::menus::invoke(app, &ctx, "edit.preferences.guidesGridAndSlices", json!({})) { + app.ui.status = e; + } + } _ => { if tool == Tool::Move && app.ui.transform.is_none() { begin_transform_controls_at(app, &ctx, &xf, p); @@ -3636,6 +3955,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let xf = if crop_camera { ViewXform { center: view.center, rotation: view.rotation, ..xf } } else { xf }; let (img_rect, beyond, layer_edges) = painted.unwrap_or_else(|| paint_canvas(app, ui, &view, xf)); if primary && !(hand_pan && response.dragged()) { + // The transform preview is image content: it goes under the grid and guides, which + // stay visible across a layer being transformed (#5086); the box is drawn later. + crate::transform_tool::draw_content(app, &painter, &xf); if app.ui.extras.grid && app.ui.view.extras { crate::rulers::draw_grid(app, &painter, &xf, &doc); } @@ -3696,9 +4018,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: command_guide_at(app, tool, d, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers))) }); if let Some((vertical, _)) = guide_hover { - ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); + ui.ctx().set_cursor_icon(crate::rulers::guide_cursor(&xf, vertical)); } else if let Some(p) = response.hover_pos() - && let Some(c) = crate::transform_tool::cursor(app, xf.to_doc(p), ui.input(|i| i.modifiers.alt || i.modifiers.command)) + && let Some(c) = crate::transform_tool::cursor(app, &xf, xf.to_doc(p), ui.input(|i| i.modifiers.alt || i.modifiers.command)) { ui.ctx().set_cursor_icon(c); // CursorIcon::None hides the pointer; the curved arrow has to be drawn or the @@ -3712,8 +4034,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: ui.ctx().set_cursor_icon(c); } else if let Some(c) = response.hover_pos().and_then(|p| crate::cam_transform::cursor(app, &ctx, &xf, p)) { ui.ctx().set_cursor_icon(c); - } else if let Some((r, p, c)) = - response.hover_pos().and_then(|p| transform_controls_rect(app, &xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c)))) + } else if let Some(p) = response.hover_pos().filter(|p| crate::corner_radius::hovers(app, xf.to_doc(*p))) { + // Over a live rectangle's corner widget (or dragging one): the arrow with a rounded + // corner badge, as Photoshop's corner radius cursor. + ui.ctx().set_cursor_icon(egui::CursorIcon::Default); + crate::icons::cursor_badge(&ctx, "scan", p); + } else if let Some((r, p, c)) = response + .hover_pos() + .filter(|_| !transform_controls_several(app)) + .and_then(|p| transform_controls_rect(app, &xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c)))) { // The Move tool draws handles without opening a Free Transform session. // Give them their resize/rotate cursors before the first pointer press (#2871). @@ -3726,7 +4055,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::type_transform::cursor(app, &ctx, &xf, p, mods) }) { ui.ctx().set_cursor_icon(c); - } else if let Some(c) = response.hover_pos().filter(|_| tool == Tool::Artboard).and_then(|p| crate::artboard_tool::cursor(app, xf.to_doc(p))) { + } else if let Some(c) = response.hover_pos().filter(|_| tool == Tool::Artboard).and_then(|p| crate::artboard_tool::cursor(app, &xf, xf.to_doc(p))) { ui.ctx().set_cursor_icon(c); } else if let Some((p, c)) = response.hover_pos().filter(|_| tool == Tool::Crop).and_then(|p| Some((p, crate::crop_ui::cursor(app, xf.to_doc(p))?))) { ui.ctx().set_cursor_icon(c); @@ -3883,7 +4212,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: { egui::CursorIcon::Crosshair } - Tool::Move => egui::CursorIcon::Move, + // Photoshop's Move tool keeps the standard arrow pointer (#5646): the movable + // layer under it is marked by the hover outline, not by a different cursor. + Tool::Move => egui::CursorIcon::Default, // Dragging is handled with the pan itself, above. Tool::Hand => crate::tool_cursor::hand(ui.ctx(), false), Tool::RotateView => crate::rotate_view::cursor(response.dragged()), @@ -3918,7 +4249,10 @@ 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) { @@ -3952,6 +4286,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } if primary && app.ui.extras.rulers { crate::rulers::draw_rulers(app, ui, full, &xf); + } else if app.ui.extras.rulers { + crate::rulers::paint_rulers(app, ui, full, &xf, &doc); } crate::rotate_view::overlay(app, &ctx, &painter, &xf, &mut view, &response); if primary { @@ -4093,6 +4429,54 @@ fn marching_ants_segments(painter: &egui::Painter, xf: &ViewXform, segs: &[crate /// [`marching_ants_segments`] with each outline point placed on screen by `to_screen` (a /// transformed outline: Content-Aware Move's Transform On Drop). pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outline::Segment], time: f64, to_screen: impl Fn([i32; 2]) -> Pos2) { + marching_ants_lines(painter, segs.iter().map(|(a, b)| (to_screen(*a), to_screen(*b))), time); +} + +/// The part of the line `a`–`b` inside `r` (Liang–Barsky), if any. +fn clip_line(r: Rect, a: Pos2, b: Pos2) -> Option<(Pos2, Pos2)> { + // Perspective can put endpoints billions of pixels away. f32 subtraction/interpolation + // loses viewport-sized differences there, clipping visible ants to the wrong endpoints. + let (ax, ay) = (f64::from(a.x), f64::from(a.y)); + let (dx, dy) = (f64::from(b.x) - ax, f64::from(b.y) - ay); + let (mut t0, mut t1) = (0.0f64, 1.0f64); + for (p, q) in [(-dx, ax - f64::from(r.min.x)), (dx, f64::from(r.max.x) - ax), (-dy, ay - f64::from(r.min.y)), (dy, f64::from(r.max.y) - ay)] { + if p == 0.0 { + if q < 0.0 { + return None; + } + } else if p < 0.0 { + t0 = t0.max(q / p); + } else { + t1 = t1.min(q / p); + } + } + let at = |t: f64| pos2((ax + dx * t) as f32, (ay + dy * t) as f32); + (t0 <= t1).then(|| (at(t0), at(t1))) +} + +#[cfg(test)] +mod transform_outline_clip_tests { + use super::*; + + #[test] + fn near_horizon_lines_keep_viewport_precision() { + let r = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); + for (a, b, start, end) in [ + (pos2(-1e9, 100.0), pos2(1e9, 100.0), pos2(0.0, 100.0), pos2(800.0, 100.0)), + (pos2(100.0, -1e9), pos2(100.0, 1e9), pos2(100.0, 0.0), pos2(100.0, 600.0)), + (pos2(-1e9, -1e9), pos2(1e9, 1e9), pos2(0.0, 0.0), pos2(600.0, 600.0)), + ] { + let (x, y) = clip_line(r, a, b).unwrap(); + assert!(x.distance(start) < 0.01 && y.distance(end) < 0.01, "{x:?}–{y:?}, expected {start:?}–{end:?}"); + } + assert!(clip_line(r, pos2(-1e9, -100.0), pos2(1e9, -100.0)).is_none()); + } +} + +/// Marching ants along lines already placed on screen (a warped outline is a run of short ones). +pub(crate) fn marching_ants_lines(painter: &egui::Painter, lines: impl Iterator, time: f64) { + // Longer than any line that fits the view: only its part in view is dashed. + const LONG: f32 = 8192.0; let dash = 4.0f32; let phase = ((time * 10.0) % (dash as f64 * 2.0)) as f32; let clip = painter.clip_rect(); @@ -4110,12 +4494,17 @@ pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outli mesh.add_triangle(i, i + 1, i + 2); mesh.add_triangle(i, i + 2, i + 3); }; - for (a, b) in segs { - let pa = to_screen(*a); - let pb = to_screen(*b); - if !clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0)) { + for (pa, pb) in lines { + // A transformed outline can land anywhere, or nowhere (a point on a Distort's horizon). + if !(pa.is_finite() && pb.is_finite() && clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0))) { continue; } + let (pa, pb) = if pa.distance(pb) > LONG { + let Some(inside) = clip_line(clip.expand(4.0 * dash), pa, pb) else { continue }; + inside + } else { + (pa, pb) + }; let pa = pos2(pa.x.round() + 0.5, pa.y.round() + 0.5); let pb = pos2(pb.x.round() + 0.5, pb.y.round() + 0.5); let len = pa.distance(pb); @@ -4332,8 +4721,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; @@ -4491,17 +4880,36 @@ pub(crate) fn layer_edges_rect(app: &PhotocraftApp, idx: usize) -> Option Vec<&photocraft_doc::Layer> { + if app.ui.tool != Tool::Move + || !app.ui.tool_options.move_show_transform + || app.ui.transform.is_some() + || app.drag.is_some() + || app.ui.corner_radius.is_some() + { + return Vec::new(); + } + let Some(st) = app.session.active() else { return Vec::new() }; + st.selected_layers().into_iter().filter_map(|id| st.doc.layer(id)).filter(|l| !crate::doc_props_ui::is_background(&st.doc, l)).collect() +} + +/// Several layers are selected: Show Transform Controls frames them all, as Photoshop does, but +/// Free Transform works on one layer, so the frame has no handles to scale or rotate them. +fn transform_controls_several(app: &PhotocraftApp) -> bool { + transform_controls_layers(app).len() > 1 +} + +/// Move tool › Show Transform Controls: the boxes Free Transform would open on the selected +/// layers, joined into one. fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Option { - if app.ui.tool != Tool::Move || !app.ui.tool_options.move_show_transform || app.ui.transform.is_some() || app.drag.is_some() { - return None; - } let st = app.session.active()?; - let l = st.active_layer.and_then(|id| st.doc.layer(id))?; - if crate::doc_props_ui::is_background(&st.doc, l) { - return None; - } - let b = photocraft_engine::transform_cmds::transform_bounds(&st.doc, l); + let b = transform_controls_layers(app) + .into_iter() + .map(|l| photocraft_engine::transform_cmds::transform_frame(&st.doc, l)) + .filter(|b| !b.is_empty()) + .reduce(|a, b| a.union(&b))?; let (dx, dy) = crate::move_ui::floating_offset(app).unwrap_or((0, 0)); let b = b.translate(dx, dy); if b.is_empty() { @@ -4558,6 +4966,9 @@ fn transform_controls_hit(r: Rect, p: Pos2) -> bool { /// Enter the existing Free Transform session when a Move-tool transform control is pressed. fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p: Pos2) -> bool { + if transform_controls_several(app) { + return false; + } let Some(r) = transform_controls_rect(app, xf) else { return false }; if !transform_controls_hit(r, p) { return false; @@ -4571,11 +4982,21 @@ fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: } } -/// Move tool › Show Transform Controls: the active layer's bounding box with its eight handles. +/// Move tool › Show Transform Controls: the selected layers' bounding box, with its eight handles +/// for one layer, and the outline of every selected shape layer, as in Photoshop. fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let Some(r) = transform_controls_rect(app, xf) else { return }; let accent = crate::theme::Tokens::get(painter.ctx()).accent; + let to_scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); + for l in transform_controls_layers(app) { + if let LayerContent::Shape(sh) = &l.content { + crate::vector_ui::draw_outline(painter, &sh.path, &to_scr, accent); + } + } painter.rect_stroke(r, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Middle); + if transform_controls_several(app) { + return; + } for p in [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()] { let h = Rect::from_center_size(p, vec2(7.0, 7.0)); painter.rect_filled(h, 0.0, Color32::WHITE); @@ -4631,6 +5052,9 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View Tool::QuickSelection => Color32::from_white_alpha(40), // The Remove Tool tints what it will remove, like Photoshop's. Tool::Remove => crate::theme::Tokens::get(painter.ctx()).danger.gamma_multiply(0.45), + // Photoshop paints the area to heal in half-opaque dark grey; a faint white trail + // barely showed on light pixels (#7342). + Tool::SpotHealing => Color32::from_black_alpha(128), _ => Color32::from_white_alpha(60), }; let Some(st) = app.session.active() else { return }; @@ -4820,7 +5244,7 @@ fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui: crate::vector_ui::pen_break_handle(app); } if tool == Tool::Pen { - crate::vector_ui::pen_move(app, x, y); + crate::vector_ui::pen_move(app, x, y, mods.shift); } else if tool == Tool::CurvaturePen { crate::vector_ui::curvature_move(app, x, y); } @@ -4846,6 +5270,13 @@ fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui: /// Tool state machine. Shared by mouse input and automation. pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { + // A press anywhere but on a guide deselects the selected guide (`guide_select`). + if let ToolEvent::Down { x, y, .. } = ev + && app.ui.selected_guide.is_some() + && crate::rulers::guide_at(app, x, y).is_none() + { + crate::guide_select::clear(app); + } if crate::symmetry_ui::pointer(app, ev, crate::workspace_ui::sticky_mods(app, mods)) { return; } @@ -4907,6 +5338,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } // Move tool: ⇧ locks the axis, ⌥ duplicates (move_mods.rs). let ev = crate::move_mods::filter_event(app, ev, mods); + // A live rectangle's corner radius widgets (Path Selection, Rectangle tool). + if crate::corner_radius::pointer(app, ev, mods) { + return; + } // Direct Selection, and the Pen's ⌘ (Direct Selection) and ⌥ (Convert Point) modes (#790). if crate::direct_select::pointer(app, ev, mods) { return; @@ -4939,11 +5374,11 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) if crate::gradient_ui::pointer(app, ev, mods) { return; } - // ⌘ with a selection tool over a guide takes the guide (#2690); the guide drag below moves it. + // ⌘ over a guide takes the guide (#2690, #6288); the guide drag below moves it. if let ToolEvent::Down { x, y, .. } = raw && let Some((vertical, i)) = command_guide_at(app, app.active_tool(), [x, y], mods) { - app.guide_drag = Some(crate::rulers::GuideDrag { vertical, index: Some(i), pos: if vertical { x } else { y } }); + app.guide_drag = Some(crate::rulers::GuideDrag::existing(vertical, i, [x, y])); crate::snap_ui::begin_guide(app, [x, y]); return; } @@ -4987,7 +5422,12 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) match ev { ToolEvent::Down { x, y, pressure } if tool == Tool::Move => { if let Some((vertical, i)) = crate::rulers::guide_at(app, x, y) { - app.guide_drag = Some(crate::rulers::GuideDrag { vertical, index: Some(i), pos: if vertical { x } else { y } }); + // ⌥-click turns the guide, through the point clicked, as in Photoshop. + if mods.alt && !mods.shift && !mods.command { + crate::guide_select::flip(app, vertical, i, [x, y]); + return; + } + app.guide_drag = Some(crate::rulers::GuideDrag::existing(vertical, i, [x, y])); return; } // Auto-Select (or ⌘-click while it is off) picks the layer under the pointer first @@ -5126,7 +5566,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } Tool::Pen => { if !(mods.alt && crate::vector_ui::pen_grab_handle(app, x, y)) { - crate::vector_ui::pen_down(app, x, y); + crate::vector_ui::pen_down(app, x, y, mods.shift); } return; } @@ -5141,7 +5581,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) Tool::MagicWand => { let o = app.ui.tool_options.clone(); let mode = selection_mode(app, mods); - let _ = app.run("select.magicWand", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "sampleAllLayers": o.sample_all_layers, "mode": mode})); + let _ = app.run("select.magicWand", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "sampleAllLayers": o.sample_all.magic_wand, "mode": mode})); return; } Tool::MagicEraser => { @@ -5151,7 +5591,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) Tool::PaintBucket => { let o = app.ui.tool_options.clone(); let contents = if o.bucket_fill_pattern { "pattern" } else { "foreground" }; - let _ = app.run("paint.bucket", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "sampleAllLayers": o.sample_all_layers, "opacity": o.fill_opacity, "contents": contents, "target": paint_target(app)})); + let _ = app.run("paint.bucket", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "sampleAllLayers": o.sample_all.paint_bucket, "opacity": o.fill_opacity, "contents": contents, "target": paint_target(app)})); return; } Tool::RedEye => { @@ -5223,6 +5663,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); @@ -5328,13 +5769,16 @@ pub(crate) fn command_moves_layer(app: &PhotocraftApp, tool: Tool, p: [f64; 2], selection_tool && mods.command && !mods.shift && !floating && app.ui.polygon.is_empty() && !inside_selection(app, p) } -/// The guide a ⌘ press with selection tool `tool` at `p` drags (vertical?, index), as the Move -/// tool's press would (#2690): ⌘ is the Move tool, and a guide under the pointer comes before the -/// layer or the selected pixels. Not while a polygon or lasso outline is being drawn. +/// The guide a ⌘ press with `tool` at `p` drags (vertical?, index), as the Move tool's press +/// would (#2690): ⌘ is the Move tool, and a guide under the pointer comes before the layer or the +/// selected pixels. The selection tools, and every tool on which ⌘ is the Move tool +/// (`hold_keys::cmd_moves`, #6288); the canvas shows the guide cursor where this finds a guide. +/// Not while a polygon or lasso outline, a stroke, Free Transform or type editing is under way. pub(crate) fn command_guide_at(app: &PhotocraftApp, tool: Tool, p: [f64; 2], mods: egui::Modifiers) -> Option<(bool, usize)> { let selection_tool = matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::Lasso | Tool::PolygonLasso | Tool::MagicWand); - let idle = app.ui.polygon.is_empty() && !crate::lasso_ui::active(app) && app.ui.transform.is_none(); - (selection_tool && mods.command && idle).then(|| crate::rulers::guide_at(app, p[0], p[1])).flatten() + let moves = selection_tool || crate::hold_keys::cmd_moves(tool); + let idle = app.ui.polygon.is_empty() && !crate::lasso_ui::active(app) && app.ui.transform.is_none() && app.ui.text_edit.is_none() && app.drag.is_none(); + (moves && mods.command && idle).then(|| crate::rulers::guide_at(app, p[0], p[1])).flatten() } /// Photoshop's cursor over a selection: what a press (or the drag under way) would do. @@ -5455,22 +5899,26 @@ fn commit_brush_stroke(app: &mut PhotocraftApp, d: &Drag) { } } let LiveStroke { stroke, doc, key, damage, .. } = l; + let before = app.session.documents().iter().find(|st| st.doc.id == doc).map(|st| st.doc.clone()); if let EngineStroke::Brush(stroke) = stroke && app.session.offer_live_stroke(*stroke).is_err() { app.session.clear_live_stroke(); } + let started = crate::gpu_canvas::now_ms(); let result = app.run(stroke_command(app, d.tool), p); + app.stroke_trace.stroke(crate::gpu_canvas::now_ms() - started); app.session.clear_live_stroke(); let Ok(r) = result else { return }; - let revision = app.session.documents().iter().find(|st| st.doc.id == doc).map(|st| st.revision); + let after = app.session.documents().iter().find(|st| st.doc.id == doc).map(|st| (st.revision, st.doc.clone())); if r.get("fromLive").and_then(serde_json::Value::as_bool) == Some(true) - && let Some(revision) = revision + && let Some((revision, after)) = after { - app.committed_stroke = Some(CommittedStroke { doc, revision, key, damage }); + let relayout = before.map_or(DRect::EMPTY, |b| photocraft_compose::relayout_damage(&b, &after)); + app.committed_stroke = Some(CommittedStroke { doc, revision, key, damage, relayout }); } else { // Rendered again from scratch: the commit's damage rect refreshes the stroke. - let shown = CommittedStroke { doc, revision: 0, key, damage }; + let shown = CommittedStroke { doc, revision: 0, key, damage, relayout: DRect::EMPTY }; // Raw preview key 0 = the document itself (its colour display folded in). shown_as_document(app, doc, |k| shown.since(k).is_some()); } @@ -5568,14 +6016,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()); @@ -5716,6 +6162,8 @@ pub fn commit_crop(app: &mut PhotocraftApp) { crate::crop_ui::cancel_stale(app); // Committing ends the box's own Undo/Redo: from here Edit › Undo reverts the document. crate::crop_ui::clear_history(app); + // ↵ during a drag ends it: the release must not bring back the old document's frame. + app.crop.drag = None; let Some(r) = app.ui.crop_rect.take() else { return }; // The crop's default mode leaves the view upright (`crop_mode`). crate::crop_mode::upright(app); @@ -5731,6 +6179,11 @@ pub fn commit_crop(app: &mut PhotocraftApp) { let (w, h) = ((r[2] - r[0]).round().max(1.0), (r[3] - r[1]).round().max(1.0)); let delete = app.ui.tool_options.crop_delete; let mut p = json!({"x": x, "y": y, "width": w, "height": h, "deleteCroppedPixels": delete}); + // Fill › Content-Aware Fill (#3823): what a turned or extending frame uncovers is filled from + // its surroundings instead of the background colour. + if app.ui.tool_options.crop_content_aware { + p["contentAware"] = json!(true); + } // A turned frame (#1792): the document turns with it, then is cropped to it. (The untouched // default frame above is never turned: turning it makes it a real frame.) let angle = crate::crop_ui::angle(app); @@ -5784,6 +6237,22 @@ mod tabs_tests; #[cfg(test)] mod tests { + /// #5472: a ⇧ square pressed between pixels snapped to 101 × 102; the short side grows to + /// match, on the side the drag points to. + #[test] + fn square_px_equalises_the_sides_away_from_the_press_point() { + use super::{marquee_px, square_px}; + // Down-right drag: the continuous square (50.3, 50.7)..(150.8, 151.2) snaps unevenly. + let r = marquee_px([50.3, 50.7], [150.8, 151.2]); + assert_eq!(r, [50.0, 50.0, 151.0, 152.0], "uneven before the fix"); + assert_eq!(square_px(r, [50.3, 50.7], [150.8, 90.0]), [50.0, 50.0, 152.0, 152.0]); + // Up-left drag: the short side grows towards lower coordinates. + let r = marquee_px([150.8, 151.2], [50.3, 50.7]); + assert_eq!(square_px(r, [150.8, 151.2], [60.0, 50.7]), [49.0, 50.0, 151.0, 152.0]); + // Already square: unchanged. + assert_eq!(square_px([10.0, 20.0, 30.0, 40.0], [10.0, 20.0], [30.0, 40.0]), [10.0, 20.0, 30.0, 40.0]); + } + #[test] fn display_image_matches_egui_unmultiplied_conversion() { // Several parallel pieces and a ragged one; translucent, out-of-range and NaN values. @@ -5811,6 +6280,79 @@ mod tests { assert_eq!(super::display_image(Some(&d), &buf).pixels, reference(d.to_rgba8(&buf)).pixels); } + /// #7490: the inline (web) preview path composites and uploads each changed preview once, as + /// the background worker does. It used to flatten and upload it twice and record it twice, + /// so the frame's refresh time counted the preview twice. + #[test] + fn inline_filter_preview_is_composited_and_recorded_once() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width": 320, "height": 240})).unwrap(); + crate::filter_dialog::open(&mut app, "filter.stylize.solarize").unwrap(); + app.perf.frame_refresh_ms = 0.0; + let (_, _, size) = ensure_filter_preview(&mut app, 0, &egui::Context::default()).expect("a preview"); + assert_eq!(size, [320, 240]); + assert_eq!(app.perf.last_refresh, "filter-preview"); + assert_eq!(app.perf.last_refresh_px, 320 * 240); + assert_eq!(app.perf.frame_refresh_ms, app.perf.composite_ms + app.perf.upload_ms, "one record per preview"); + // Unchanged request: nothing is recomputed or recorded again. + ensure_filter_preview(&mut app, 0, &egui::Context::default()).expect("the same preview"); + assert_eq!(app.perf.frame_refresh_ms, app.perf.composite_ms + app.perf.upload_ms); + } + + /// #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() { @@ -5877,57 +6419,233 @@ mod tests { assert!(app.filter_preview.as_ref().unwrap().result.is_some()); } + // Starts a worker job that computes a real preview for the dialog as it stands, but only + // finishes when the returned sender is signalled. Gating it here keeps these tests off the + // clock: `supersede` cancels a running job once the values have been still for SETTLE_MS, so a + // test that relies on a real blur beating a 150 ms timer is a test that fails on a loaded box. + #[cfg(not(target_arch = "wasm32"))] + fn gated_blur_preview(app: &mut PhotocraftApp, ctx: &egui::Context) -> (std::sync::mpsc::Sender<()>, f64) { + let d = app.ui.dialogs.first().expect("a dialog is open"); + let st = app.session.active().expect("a document is active"); + let command = "filter.blur.gaussianBlur"; + let params = app.with_mask_target(command, crate::filter_dialog::params_of(&d.fields)); + assert_eq!( + crate::filter_dialog::preview_factor(command, ¶ms, 1), + 1, + "a 64x48 document previews at full resolution, and these tests rely on k staying 1" + ); + let key = crate::filter_dialog::FilterPreviewKey { + doc: st.doc.id, + revision: st.revision, + dialog: d.id, + active: st.active_layer, + target: st.channel_view.target, + command: command.into(), + params, + k: 1, + }; + let doc = app.session.documents()[0].doc.clone(); + let documents: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); + let radius = key.params["radius"].as_f64().expect("the dialog seeds a radius"); + let (release, wait) = std::sync::mpsc::channel::<()>(); + app.filter_preview_worker + .start(key.clone(), ctx.clone(), move |_| { + wait.recv_timeout(std::time::Duration::from_secs(60)).expect("the test releases the job"); + let result = crate::filter_dialog::preview_document_for_target( + &doc, + key.active, + &key.command, + &key.params, + key.k, + None, + key.target, + &documents, + &Default::default(), + ) + .map(|d| { + let buffer = photocraft_compose::flatten(&d); + (std::sync::Arc::new(d), buffer) + }); + crate::filter_preview_worker::Computed { result, ms: 0.0 } + }) + .expect("the worker starts"); + (release, radius) + } + + // The worker hands its result to the channel only after `compute` returns, so a signal sent + // from inside `compute` would still race that send. These tests drive the canvas until the + // state they need appears instead of assuming a timing. + #[cfg(not(target_arch = "wasm32"))] + fn drive_until(app: &mut PhotocraftApp, ctx: &egui::Context, what: &str, done: impl Fn(&PhotocraftApp) -> bool) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while !done(app) { + assert!(std::time::Instant::now() < deadline, "timed out waiting for {what}"); + ensure_filter_preview(app, 0, ctx); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } + #[cfg(not(target_arch = "wasm32"))] #[test] - fn indexed_preview_discards_a_finished_job_after_slider_change_or_reopening() { - for reopen in [false, true] { - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); - app.background_jobs = true; - crate::filter_dialog::open(&mut app, "image.mode.indexedColor").unwrap(); - let d = app.ui.dialogs.first().unwrap(); - let st = app.session.active().unwrap(); - let old = crate::filter_dialog::FilterPreviewKey { - doc: st.doc.id, - revision: st.revision, - dialog: d.id, - active: st.active_layer, - target: st.channel_view.target, - command: "image.mode.indexedColor".into(), - params: app.with_mask_target("image.mode.indexedColor", crate::filter_dialog::params_of(&d.fields)), - k: 1, - }; - let (release, wait) = std::sync::mpsc::channel(); - let ctx = egui::Context::default(); - app.filter_preview_worker - .start(old.clone(), ctx.clone(), move |_| { - wait.recv_timeout(std::time::Duration::from_secs(5)).unwrap(); - crate::filter_preview_worker::Computed { result: None, ms: 0.0 } - }) - .unwrap(); - if reopen { - app.ui.dialogs.clear(); - crate::filter_preview_worker::discard_closed(&mut app); - crate::filter_dialog::open(&mut app, "image.mode.indexedColor").unwrap(); - } else { - app.ui.dialogs.first_mut().unwrap().fields.insert("colors".into(), json!(16)); - } - assert!(ensure_filter_preview(&mut app, 0, &ctx).is_none()); - assert!(app.filter_preview.is_none()); - release.send(()).unwrap(); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - while app.filter_preview.is_none() { - assert!(std::time::Instant::now() < deadline); - ensure_filter_preview(&mut app, 0, &ctx); - if let Some(preview) = &app.filter_preview { - assert_ne!(preview.key, old, "an outdated preview was published"); - } - std::thread::sleep(std::time::Duration::from_millis(1)); - } - assert_eq!(app.session.active().unwrap().doc.mode, photocraft_color::ColorMode::Rgb); + fn a_preview_computed_mid_drag_reaches_the_canvas_and_then_converges() { + // A drag used to paint nothing at all: every completion was dropped for carrying stale + // parameters, so the canvas only ever showed an effect once the pointer had come to rest. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.background_jobs = true; + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let ctx = egui::Context::default(); + let (release, first) = gated_blur_preview(&mut app, &ctx); + + // The pointer moves on before the frame lands. + let moved = first + 4.0; + app.ui.dialogs[0].fields.insert("radius".into(), json!(moved)); + release.send(()).expect("released"); + drive_until(&mut app, &ctx, "the mid-drag frame", |a| a.filter_preview.is_some()); + + // The finished frame is painted even though the dialog has moved on. + let shown = app.filter_preview.as_ref().expect("the mid-drag frame was published"); + assert_eq!(shown.key.params["radius"].as_f64(), Some(first), "the frame is the one the worker finished"); + assert!(shown.result.is_some()); + assert_eq!(app.ui.dialogs[0].fields["radius"].as_f64(), Some(moved), "the slider kept moving"); + // The display gate lets it through, so the canvas has something to draw: this is what + // re-adding `params` to that gate would break. + assert!(ensure_filter_preview(&mut app, 0, &ctx).is_some(), "the canvas draws the mid-drag frame"); + + // And the preview still converges on the value the dialog holds. `fresh` has to stay an + // exact match for that: relaxed, the canvas would believe it already had the current + // request after the first frame of a drag and never compute again. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while app.filter_preview.as_ref().is_none_or(|p| p.key.params["radius"].as_f64() != Some(moved)) { + assert!(std::time::Instant::now() < deadline, "the preview never converged on the current radius"); + ensure_filter_preview(&mut app, 0, &ctx); + std::thread::sleep(std::time::Duration::from_millis(1)); } } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_mid_drag_frame_does_not_replace_a_frame_that_is_already_exact() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.background_jobs = true; + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let ctx = egui::Context::default(); + + // A frame for the radius the dialog holds right now reaches the canvas. This also proves + // the hand-built key is the one the canvas builds: it can only be published by the exact + // arm, which requires `finished == request`. + let (release, first) = gated_blur_preview(&mut app, &ctx); + release.send(()).expect("released"); + drive_until(&mut app, &ctx, "the first frame", |a| a.filter_preview.is_some()); + assert_eq!(app.filter_preview.as_ref().expect("published").key.params["radius"].as_f64(), Some(first)); + + // The slider moves away and a frame for the new radius is computed... + let moved = first + 4.0; + app.ui.dialogs[0].fields.insert("radius".into(), json!(moved)); + let (release_moved, _) = gated_blur_preview(&mut app, &ctx); + // ...and back to `first` before that frame lands. + app.ui.dialogs[0].fields.insert("radius".into(), json!(first)); + release_moved.send(()).expect("released"); + + // The frame for `moved` arrives late. It belongs to this preview, but the frame on screen + // is already exact for the dialog, so it must never be shown in place of it. Checked on + // every pass: a wrong frame published and then overwritten before the loop ends would + // otherwise never be seen. + drive_until(&mut app, &ctx, "the late frame to arrive", |a| { + if let Some(shown) = &a.filter_preview { + assert_ne!(shown.key.params["radius"].as_f64(), Some(moved), "a late frame replaced an exact one"); + } + !a.filter_preview_worker.busy() + }); + let shown = app.filter_preview.as_ref().expect("a frame is on screen"); + assert_eq!(shown.key.params["radius"].as_f64(), Some(first), "a late frame replaced an exact one"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_mid_drag_empty_result_leaves_the_preview_alone() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.background_jobs = true; + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let ctx = egui::Context::default(); + + let (release, first) = gated_blur_preview(&mut app, &ctx); + release.send(()).expect("released"); + drive_until(&mut app, &ctx, "the first frame", |a| a.filter_preview.is_some()); + assert!(app.filter_preview.as_ref().expect("published").result.is_some()); + + // The slider moves and a job for the new radius is started, then the slider moves again + // before that job lands: its result is stale by the time it arrives. + let moved = first + 4.0; + app.ui.dialogs[0].fields.insert("radius".into(), json!(moved)); + let d = app.ui.dialogs.first().expect("a dialog is open"); + let st = app.session.active().expect("a document is active"); + let key = crate::filter_dialog::FilterPreviewKey { + doc: st.doc.id, + revision: st.revision, + dialog: d.id, + active: st.active_layer, + target: st.channel_view.target, + command: "filter.blur.gaussianBlur".into(), + params: json!({"radius": moved}), + k: 1, + }; + let (release_empty, wait) = std::sync::mpsc::channel::<()>(); + app.filter_preview_worker + .start(key, ctx.clone(), move |_| { + wait.recv_timeout(std::time::Duration::from_secs(60)).expect("released"); + crate::filter_preview_worker::Computed { result: None, ms: 0.0 } + }) + .expect("the worker starts"); + app.ui.dialogs[0].fields.insert("radius".into(), json!(moved + 4.0)); + release_empty.send(()).expect("released"); + + // Storing an empty result here would drop the canvas back to the unfiltered layer for a + // frame. The exact request may still store one; a stale one may not. Checked on every + // pass, because the reopened preview that follows would otherwise repair the symptom. + drive_until(&mut app, &ctx, "the empty frame to arrive", |a| { + if let Some(shown) = &a.filter_preview { + assert!(shown.result.is_some(), "an empty mid-drag result blanked the canvas"); + } + !a.filter_preview_worker.busy() + }); + assert!(app.filter_preview.as_ref().expect("a preview is still shown").result.is_some(), "an empty mid-drag result blanked the canvas"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_reopened_dialog_never_paints_the_previous_dialogs_preview() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.background_jobs = true; + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let ctx = egui::Context::default(); + let first_dialog = app.ui.dialogs.first().expect("a dialog is open").id; + let (release, _) = gated_blur_preview(&mut app, &ctx); + + // Close that dialog and open another one for the same layer and the same command. Only the + // dialog id differs, which is exactly what `same_target` has to notice. + app.ui.dialogs.clear(); + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let second_dialog = app.ui.dialogs.first().expect("a dialog is open").id; + assert_ne!(second_dialog, first_dialog, "a reopened dialog gets a new id"); + release.send(()).expect("released"); + + // The first dialog's frame arrives late and must not be painted under the second one. + // `discard_closed` usually reclaims it a frame earlier, which is why this drives the gate + // directly: the id check is the part that has to hold when that cleanup misses. The + // invariant is checked on every pass, not only at the end, because a wrongly published + // frame can still be replaced by the reopened dialog's own preview before we stop. + drive_until(&mut app, &ctx, "the first dialog's frame to arrive", |a| { + if let Some(preview) = &a.filter_preview { + assert_ne!(preview.key.dialog, first_dialog, "a reopened dialog painted the previous dialog's preview"); + } + !a.filter_preview_worker.busy() + }); + } + #[test] fn windows_uses_a_visible_crosshair() { // #737: Windows' inverting crosshair vanishes over mid-grey; use a black/white glyph. @@ -5999,6 +6717,32 @@ mod tests { assert_ne!(ensure_texture(&mut app, &ctx, 1, Some(1)).unwrap().0, a, "reopening a preserved ID creates a fresh texture"); } + #[test] + fn only_themes_with_a_dot_pattern_tile_the_canvas_surround() { + // #4542: switching to Pro filled the empty workspace with red blocks on one GPU. Pro's + // dots are its canvas colour, so the tiled texture had nothing to show in the first place. + use crate::theme::ThemeKind; + let tiles = |ctx: &egui::Context, kind: ThemeKind| { + crate::theme::apply(ctx, kind); + let mut output = ctx.run_ui(Default::default(), |ui| paint_dots(ui, ui.max_rect(), true)); + output.textures_delta.clear(); + output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))) + }; + let uploaded = |ctx: &egui::Context| ctx.tex_manager().read().allocated().filter(|(_, meta)| meta.name == "canvas-dots").count(); + for kind in ThemeKind::ALL { + let ctx = egui::Context::default(); + let dotted = matches!(kind, ThemeKind::Studio | ThemeKind::StudioLight | ThemeKind::SolarizedDark); + assert_eq!(tiles(&ctx, kind), dotted, "{kind:?}"); + assert_eq!(uploaded(&ctx), usize::from(dotted), "{kind:?}"); + } + // The reported switch: a dotted theme, then Pro. + let ctx = egui::Context::default(); + assert!(tiles(&ctx, ThemeKind::Studio)); + assert!(!tiles(&ctx, ThemeKind::Pro)); + assert!(!tiles(&ctx, ThemeKind::ProMedium)); + assert_eq!(uploaded(&ctx), 1, "only Studio's dots were uploaded"); + } + #[test] fn closed_documents_release_cpu_canvas_textures() { let mut app = app_on_two_displays(); @@ -6072,6 +6816,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 @@ -6089,6 +6852,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; @@ -6548,6 +7312,57 @@ mod tests { assert!(hint.ends_with(&format!("paste a copied image with {}.", crate::notices::paste_hint(&wayland))), "{hint}"); } + /// A tiling window manager can hand PhotoCraft a canvas narrower than the Home card (460 + /// points): the two buttons must stay inside the window instead of being clipped at its edges. + #[test] + fn start_screen_buttons_stay_inside_a_narrow_canvas() { + use egui_kittest::kittest::Queryable; + for width in [390.0, 420.0, 520.0] { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(width, 760.0)).build_ui_state( + |ui, (app, ready): &mut (PhotocraftApp, bool)| { + if *ready { + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| document_area(app, ui)); + } + }, + (app, false), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default()); + h.state_mut().1 = true; + h.run_steps(3); + for label in ["New document", "Join us on Discord"] { + for node in h.query_all_by_label_contains(label) { + let r = node.rect(); + assert!(r.left() >= 0.0 && r.right() <= width, "`{label}` is clipped in a {width}-point-wide canvas: {r:?}"); + } + } + } + } + + /// #4382: Interface › Show home recent files off hides the Home screen's list, not the recents. + #[test] + fn home_recent_list_hides_when_the_preference_is_off() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.push_recent("/tmp/photocraft-217/hidden.psd"); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 760.0)).build_ui_state( + |ui, (app, ready): &mut (PhotocraftApp, bool)| { + if *ready { + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| document_area(app, ui)); + } + }, + (app, false), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default()); + h.state_mut().1 = true; + h.run_steps(3); + assert!(h.query_by_label("hidden.psd").is_some(), "listed by default"); + h.state_mut().0.run("prefs.set", serde_json::json!({"values": {"interface.showHomeRecentFiles": false}})).unwrap(); + h.run_steps(3); + assert!(h.query_by_label("hidden.psd").is_none(), "hidden when off"); + assert_eq!(h.state().0.ui.recent_files.len(), 1, "the list itself is kept"); + } + /// The Home screen's × takes a file off Recent without opening it, and its right-click menu /// removes one entry or clears the list; the preferences keep the change (#2691). #[test] @@ -6983,6 +7798,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(); @@ -7312,6 +8128,51 @@ mod tests { assert_ne!(dragged.center, center0, "a secondary window's Hand pans while transforming"); } + #[test] + fn guides_draw_over_the_transform_preview_and_under_its_box() { + // #5086: a guide crossing a layer under Free Transform was hidden behind the preview of + // the moving pixels. The preview is image content, drawn before the grid and guides like + // the document is; the box and handles still go over them, as in Photoshop. + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 150})).unwrap(); + app.ui.extras.guides = true; + app.ui.extras.rulers = false; + app.sync_views(); + crate::transform_tool::begin(&mut app, &ctx).expect("a transform starts"); + let mut view = app.ui.views[0].clone(); + view.zoom = 1.0; + view.center = [200.0, 150.0]; + view.fit_pending = false; + view.fill_pending = false; + let rect = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)); + let input = egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + canvas_view(&mut app, ui, 0, rect, view.clone(), true); + }); + out.textures_delta.clear(); + // At 100 % centred on the document, screen and document coordinates coincide. + let layer = Rect::from_min_max(pos2(100.0, 100.0), pos2(200.0, 160.0)); + let near = |r: Rect| (r.min - layer.min).length() < 1.0 && (r.max - layer.max).length() < 1.0; + let position = |pred: &dyn Fn(&egui::Shape) -> bool| out.shapes.iter().position(|s| pred(&s.shape)); + // The moving pixels: the mesh that spans the layer's box. + let preview = position(&|s| matches!(s, egui::Shape::Mesh(m) if near(m.calc_bounds()))); + // The guide: a vertical line at x = 150. + let guide = position(&|s| matches!(s, egui::Shape::LineSegment { points, .. } if points.iter().all(|p| (p.x - 150.0).abs() < 0.01))); + // The box: the closed four-point outline round the layer. + let frame = position(&|s| matches!(s, egui::Shape::Path(p) if p.closed && p.points.len() == 4 && near(Rect::from_points(&p.points)))); + let (Some(preview), Some(guide), Some(frame)) = (preview, guide, frame) else { + panic!("all painted: preview {preview:?}, guide {guide:?}, box {frame:?}"); + }; + assert!(preview < guide, "the guide ({guide}) draws over the transform preview ({preview})"); + assert!(guide < frame, "the box ({frame}) draws over the guide ({guide})"); + } + #[test] fn brush_drags_paint_no_stand_in_shape_over_the_canvas() { // #189: v0.1 drew the stroke being dragged as a foreground-coloured egui polyline as wide @@ -7615,6 +8476,37 @@ mod transform_controls_tests { use super::*; use serde_json::json; + #[test] + fn several_selected_layers_share_one_frame_without_handles() { + // As in Photoshop: Show Transform Controls frames every selected layer. Free Transform + // works on one layer, so the shared frame doesn't start it. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.session.execute("shape.create", json!({"kind": "rect", "rect": [20, 30, 40, 40], "fill": "#ff0000"})).unwrap(); + let a = app.session.active().unwrap().active_layer.unwrap(); + app.session.execute("shape.create", json!({"kind": "rect", "rect": [100, 120, 50, 30], "fill": "#ff0000"})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_show_transform = true; + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let one = transform_controls_rect(&app, &xf).unwrap(); + assert!(!transform_controls_several(&app)); + app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap(); + assert!(transform_controls_several(&app)); + let both = transform_controls_rect(&app, &xf).unwrap(); + assert!(both.contains_rect(one) && both.width() > one.width() && both.height() > one.height(), "{both:?} frames both"); + let (lt, rb) = (xf.to_doc(both.left_top()), xf.to_doc(both.right_bottom())); + assert!((lt[0] - 20.0).abs() < 1e-3 && (lt[1] - 30.0).abs() < 1e-3 && (rb[0] - 150.0).abs() < 1e-3 && (rb[1] - 150.0).abs() < 1e-3, "{lt:?} {rb:?}"); + let ctx = egui::Context::default(); + assert!(!begin_transform_controls_at(&mut app, &ctx, &xf, both.right_bottom())); + assert!(app.ui.transform.is_none()); + // The Background never joins the frame. + let bg = app.session.active().unwrap().doc.layers[0].id; + app.session.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap(); + assert_eq!(transform_controls_layers(&app).len(), 2); + } + #[test] fn move_transform_controls_start_free_transform_for_vector_shapes() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -7654,6 +8546,27 @@ mod transform_controls_tests { assert_eq!(app.ui.transform.as_ref().unwrap().rect, [200.0, 150.0, 300.0, 210.0]); } + /// #5297: with a selection the Move tool's transform controls are the selection's rectangle, + /// though the layer fills only part of it, and a press on them opens that box. + #[test] + fn move_transform_controls_frame_the_selection() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.rect", json!({"x": 60, "y": 80, "width": 100, "height": 120})).unwrap(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_show_transform = true; + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), zoom: 1.0, center: [200.0, 150.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let r = transform_controls_rect(&app, &xf).unwrap(); + assert_eq!(r, Rect::from_min_max(pos2(60.0, 80.0), pos2(160.0, 200.0))); + let ctx = egui::Context::default(); + assert!(begin_transform_controls_at(&mut app, &ctx, &xf, r.right_bottom())); + assert_eq!(app.ui.transform.as_ref().unwrap().rect, [60.0, 80.0, 160.0, 200.0]); + } + /// #2585: Free Transform on a position-locked layer is refused before the box opens, with /// Photoshop's alert, from ⌘T and from the Move tool's transform controls alike. It used to /// open, preview every drag and fail only at commit. @@ -7955,6 +8868,64 @@ mod rotation_preview_isolation_tests { } } + /// Assign and Convert to Profile have Photoshop's Preview. Assign changes only the profile, so + /// its preview must be shown through the profile it assigns (through the document's own it + /// looked unchanged); Convert keeps the look of in-gamut colours. Their `working` is the + /// working space from Color Settings, not the built-in default. + #[test] + fn profile_dialogs_preview_through_the_profile_they_choose() { + let mut session = photocraft_engine::Session::new(); + session.color.settings.monitor_profile = "srgb".into(); + session.color.settings.working_rgb = "display-p3".into(); + session.execute("file.new", json!({"width": 4, "height": 4})).unwrap(); + session.execute("edit.assignProfile", json!({"profile": "srgb"})).unwrap(); + session + .edit("Fixture", |doc, _| { + let layer = doc.layers.last_mut().unwrap(); + for y in 0..4 { + for x in 0..4 { + layer.surface_mut().unwrap().write_pixel(x, y, &[0.6, 0.35, 0.25, 1.0]); + } + } + Ok(()) + }) + .unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let shown_pixels = |app: &PhotocraftApp, doc: &Document| { + let (display, _) = canvas_display(app, doc, None); + display_image(display.as_deref(), &photocraft_compose::flatten(doc)).pixels[0] + }; + let original = app.session.active().unwrap().doc.clone(); + let before = shown_pixels(&app, &original); + let preview = |app: &mut PhotocraftApp, command: &str, profile: &str| { + crate::filter_dialog::open(app, command).unwrap(); + let fields = &mut app.ui.dialogs.last_mut().unwrap().fields; + assert_eq!(fields.get("__preview"), Some(&json!(true)), "{command} has a Preview checkbox, on"); + fields.insert("profile".into(), json!(profile)); + ensure_filter_preview(app, 0, &egui::Context::default()).expect("a preview"); + let result = app.filter_preview.as_ref().unwrap().result.clone().unwrap(); + let key = original.id.0 ^ (1u64 << 61); + assert_eq!(filter_preview_display_doc(app, &original, key).icc_profile, result.icc_profile, "the canvas shows it through its own profile"); + assert_eq!(app.session.active().unwrap().doc.icc_profile, original.icc_profile, "previewing changes nothing"); + app.ui.dialogs.clear(); + app.filter_preview = None; + result + }; + let description = |doc: &Document| photocraft_engine::color_cmds::profile_from_bytes(doc.icc_profile.as_ref().unwrap()).unwrap().description.clone(); + + let assigned = preview(&mut app, "edit.assignProfile", "prophoto-compat"); + assert_eq!(description(&assigned), photocraft_cms::Builtin::ProPhotoCompat.description()); + let shown = shown_pixels(&app, &assigned); + assert!(shown.r().abs_diff(before.r()) > 10 || shown.b().abs_diff(before.b()) > 10, "the assigned profile shows: {before:?} → {shown:?}"); + + let converted = preview(&mut app, "edit.convertToProfile", "working"); + assert_eq!(description(&converted), photocraft_cms::Builtin::DisplayP3.description(), "`working` is Color Settings' working space"); + let shown = shown_pixels(&app, &converted); + for (a, b) in [(shown.r(), before.r()), (shown.g(), before.g()), (shown.b(), before.b())] { + assert!(a.abs_diff(b) <= 2, "converting keeps the look: {before:?} → {shown:?}"); + } + } + /// A CPU-only app must be able to generate the actual rotation preview without a /// GPU adapter; checking the result also verifies Preview off and document integrity. #[test] @@ -7999,3 +8970,7 @@ mod rotation_preview_isolation_tests { #[cfg(test)] #[path = "canvas_display_memo_tests.rs"] mod display_memo_tests; + +#[cfg(test)] +#[path = "canvas/blend_viewport_tests.rs"] +mod blend_viewport_regression; diff --git a/crates/ui-egui/src/canvas/blend_viewport_tests.rs b/crates/ui-egui/src/canvas/blend_viewport_tests.rs new file mode 100644 index 0000000000..ae6a01742d --- /dev/null +++ b/crates/ui-egui/src/canvas/blend_viewport_tests.rs @@ -0,0 +1,198 @@ +use super::*; +use photocraft_color::BlendMode; +use serde_json::json; + +fn render_state() -> Option { + match std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default())) { + Ok(rs) => Some(rs), + Err(error) => { + if std::env::var_os("PHOTOCRAFT_REQUIRE_GPU_TESTS").is_some() { + std::panic::resume_unwind(error); + } + eprintln!("skipping blend viewport regression: no GPU adapter"); + None + } + } +} + +#[test] +fn blend_hover_refresh_is_limited_to_visible_canvas() { + check_blend_viewport(false, false); +} + +#[test] +fn blend_hover_cpu_fallback_refresh_tracks_visible_canvas() { + check_blend_viewport(true, false); +} + +#[test] +fn blend_hover_multiple_layers_tracks_visible_canvas() { + check_blend_viewport(false, true); +} + +#[test] +fn blend_hover_pattern_tiles_and_multiple_views_reuse_current_pixels() { + let _guard = photocraft_testkit::gpu_lock(); + let Some(rs) = render_state() else { return }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.set_wgpu(rs); + app.run("file.new", json!({"width":1024,"height":768,"background":"#336699"})).unwrap(); + let layer = app.run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + app.run("edit.fill", json!({"color":"#cc8844"})).unwrap(); + let ctx = egui::Context::default(); + let a = DRect::new(724, 200, 1324, 500); + let b = DRect::new(0, 200, 300, 500); + assert!(ensure_gpu(&mut app, &ctx, 0, a)); + app.ui.view.pattern_preview = true; + crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], Some(BlendMode::Multiply)); + assert!(ensure_gpu(&mut app, &ctx, 0, a)); + assert_visible_pixels(&mut app, b); + let bounds = app.session.active().unwrap().doc.bounds(); + assert_visible_pixels(&mut app, bounds); + crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], None); + assert!(ensure_gpu(&mut app, &ctx, 0, a)); + assert_visible_pixels(&mut app, bounds); + + app.ui.view.pattern_preview = false; + for mode in [BlendMode::Multiply, BlendMode::Screen] { + crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], Some(mode)); + assert!(ensure_gpu(&mut app, &ctx, 0, a)); + assert!(ensure_gpu(&mut app, &ctx, 0, b)); + for visible in [a, b, a, b] { + app.perf.last_refresh_px = 0; + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert_eq!(app.perf.last_refresh_px, 0, "unchanged views must retain coverage together"); + assert_visible_pixels(&mut app, visible.intersect(&bounds)); + } + } + // Enabling periodic display during an existing hover must catch up the unseen gap too. + app.ui.view.pattern_preview = true; + assert!(ensure_gpu(&mut app, &ctx, 0, a)); + assert_visible_pixels(&mut app, bounds); + crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], None); + assert!(ensure_gpu(&mut app, &ctx, 0, a)); + assert_visible_pixels(&mut app, bounds); +} + +#[test] +fn blend_hover_during_background_redraw_restores_committed_pixels() { + let _guard = photocraft_testkit::gpu_lock(); + let Some(rs) = render_state() else { return }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.set_wgpu(rs); + app.background_jobs = true; + app.run("file.new", json!({"width":2600,"height":1700,"background":"#336699"})).unwrap(); + let layer = app.run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + app.run("edit.fill", json!({"color":"#cc8844"})).unwrap(); + app.run("layer.layerStyle.blendingOptions", json!({"knockout":"shallow"})).unwrap(); + let ctx = egui::Context::default(); + let visible = DRect::new(256, 256, 512, 512); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert!(!app.redraws.is_empty(), "fixture must start a deferred full redraw"); + crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], Some(BlendMode::Multiply)); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + let panned = DRect::new(2048, 1024, 2304, 1280); + assert!(ensure_gpu(&mut app, &ctx, 0, panned)); + settle_redraw(&mut app); + assert_visible_pixels(&mut app, panned); + crate::blend_preview::hover(&mut app, &[photocraft_doc::LayerId(layer)], None); + assert!(ensure_gpu(&mut app, &ctx, 0, panned)); + settle_redraw(&mut app); + let bounds = app.session.active().unwrap().doc.bounds(); + assert_visible_pixels(&mut app, bounds); +} + +fn settle_redraw(app: &mut PhotocraftApp) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + let gpu = app.gpu.clone().unwrap(); + let id = app.session.active().unwrap().doc.id; + while !app.redraws.is_empty() { + crate::background_redraw::poll(app, &gpu, id); + assert!(std::time::Instant::now() < deadline, "background redraw did not finish"); + std::thread::sleep(std::time::Duration::from_millis(2)); + } +} + +fn assert_visible_pixels(app: &mut PhotocraftApp, visible: DRect) { + let (doc, _) = display_doc(app, 0); + let gpu = app.gpu.clone().unwrap(); + let reference = doc.id.0 ^ (1u64 << 60); + let (display, _) = canvas_display(app, &doc, None); + gpu.upload_composite(reference, &doc, display.as_deref()); + let (_, size, shown) = gpu.read_texels(doc.id.0).unwrap(); + let (_, _, want) = gpu.read_texels(reference).unwrap(); + for y in visible.y0..visible.y1 { + for x in visible.x0..visible.x1 { + let index = y as usize * size[0] as usize + x as usize; + for channel in 0..4 { + assert!((shown[index][channel] - want[index][channel]).abs() <= 2.0 / 255.0, "preview differs at ({x},{y}) channel{channel}"); + } + } + } +} + +fn check_blend_viewport(cpu_fallback: bool, multiple_layers: bool) { + let _guard = photocraft_testkit::gpu_lock(); + let Some(rs) = render_state() else { return }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.set_wgpu(rs); + app.run("file.new", json!({"width":1024,"height":768,"background":"#336699"})).unwrap(); + let layer = app.run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + app.run("edit.fill", json!({"color":"#cc8844"})).unwrap(); + if cpu_fallback { + app.run("layer.layerStyle.blendingOptions", json!({"knockout":"shallow"})).unwrap(); + } + let mut layers = vec![photocraft_doc::LayerId(layer)]; + if multiple_layers { + let other = app.run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + app.run("edit.fill", json!({"color": "#4488cc"})).unwrap(); + app.run("layer.setProps", json!({"layer": other, "opacity": 0.5})).unwrap(); + layers.push(photocraft_doc::LayerId(other)); + } + let ctx = egui::Context::default(); + let visible = DRect::new(256, 256, 512, 512); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert!(app.redraws.is_empty(), "small fixture must finish synchronously"); + if cpu_fallback { + assert!(!app.perf.last_refresh.starts_with("gpu"), "fixture must exercise CPU composition fallback"); + } + let id = app.session.active().unwrap().doc.id; + let gpu = app.gpu.clone().unwrap(); + let original = gpu.read_texels(id.0).unwrap(); + crate::blend_preview::hover(&mut app, &layers, Some(BlendMode::Multiply)); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + println!("BLEND_VIEWPORT kind={} pixels={} expected={} adapter={:?}", app.perf.last_refresh, app.perf.last_refresh_px, 256 * 256, app.perf.gpu_info); + assert_eq!(app.perf.last_refresh_px, 256 * 256, "hover must not recomposite offscreen pixels of a full-canvas layer"); + assert_visible_pixels(&mut app, visible); + app.perf.last_refresh_px = 0; + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert_eq!(app.perf.last_refresh_px, 0, "unchanged view and mode must reuse the texture"); + let zoomed_out = DRect::new(128, 128, 640, 640); + assert!(ensure_gpu(&mut app, &ctx, 0, zoomed_out)); + assert_eq!(app.perf.last_refresh_px, 512 * 512, "zooming out catches up the larger visible area"); + let panned = DRect::new(640, 256, 896, 512); + assert!(ensure_gpu(&mut app, &ctx, 0, panned)); + assert_eq!(app.perf.last_refresh_px, 256 * 256, "newly exposed pixels must catch up without a mode change"); + crate::blend_preview::hover(&mut app, &layers, Some(BlendMode::Screen)); + assert!(ensure_gpu(&mut app, &ctx, 0, panned)); + assert_eq!(app.perf.last_refresh_px, 256 * 256); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert_eq!(app.perf.last_refresh_px, 256 * 256, "returning view must show the current mode"); + assert_visible_pixels(&mut app, visible); + assert_visible_pixels(&mut app, panned); + crate::blend_preview::hover(&mut app, &layers, None); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert_eq!(gpu.read_texels(id.0).unwrap(), original, "cancel must restore the whole texture, including previously previewed offscreen pixels"); + crate::blend_preview::hover(&mut app, &layers, Some(BlendMode::Multiply)); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + app.run("layer.setProps", json!({"layer":layer,"blend":"Multiply"})).unwrap(); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + let bounds = app.session.active().unwrap().doc.bounds(); + assert_visible_pixels(&mut app, bounds); + app.run("file.new", json!({"width":1024,"height":768,"background":"white"})).unwrap(); + assert!(ensure_gpu(&mut app, &ctx, 1, visible)); + app.session.set_active(0); + app.sync_views(); + assert!(ensure_gpu(&mut app, &ctx, 0, visible)); + assert_visible_pixels(&mut app, bounds); +} diff --git a/crates/ui-egui/src/canvas/tabs_tests.rs b/crates/ui-egui/src/canvas/tabs_tests.rs index 4cc2bca0f0..42f5c58d8e 100644 --- a/crates/ui-egui/src/canvas/tabs_tests.rs +++ b/crates/ui-egui/src/canvas/tabs_tests.rs @@ -1,5 +1,6 @@ -//! #1276: a document tab strip with more tabs than fit keeps the active document's tab on the -//! strip, shrinks and elides the others, and moves the rest into a » menu (whole app, real tree). +//! #1276, #4487: a document tab strip with more tabs than fit keeps the active document's tab on +//! the strip, keeps whole titles on the tabs it shows, and moves the rest into a » menu (whole +//! app, real tree). use egui::vec2; use egui_kittest::Harness; @@ -166,3 +167,60 @@ fn ctrl_tab_with_one_document_or_a_focused_field() { } assert!(!Focus::Widget.allows(&crate::shortcuts::parse("Tab").unwrap()), "a focused widget keeps plain Tab"); } + +#[test] +fn quick_mask_mode_names_the_quick_mask_in_the_tab() { + let mut h = harness(1, ThemeKind::Pro); + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + assert_eq!(pro_tab_title(h.state(), 0), "ch1-p01.png @ 100% (Layer 1, RGB/8)*"); + h.state_mut().run("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + assert_eq!(pro_tab_title(h.state(), 0), "ch1-p01.png @ 100% (Quick Mask/8)*"); + h.state_mut().run("select.editInQuickMaskMode", json!({"on": false})).unwrap(); + assert_eq!(pro_tab_title(h.state(), 0), "ch1-p01.png @ 100% (Layer 1, RGB/8)*"); +} + +/// Whether any title painted on the strip was cut with '…'. +fn elided_on_strip(h: &Harness<'_, PhotocraftApp>) -> bool { + fn elided(shape: &egui::Shape, strip: egui::Rect) -> bool { + match shape { + egui::Shape::Text(text) => text.galley.elided && strip.intersects(text.visual_bounding_rect()), + egui::Shape::Vec(shapes) => shapes.iter().any(|shape| elided(shape, strip)), + _ => false, + } + } + let strip = strip(h).rect; + h.output().shapes.iter().any(|shape| elided(&shape.shape, strip)) +} + +/// #4487: a crowded strip keeps whole names on the tabs it shows and moves the rest into the » +/// menu instead of shrinking them all, and the menu lists every open document with the active one +/// checked, as Photoshop's does. +#[test] +fn crowded_tabs_keep_their_whole_names_and_the_menu_lists_every_document() { + for theme in [ThemeKind::Pro, ThemeKind::Studio] { + let mut h = harness(14, theme); + let s = strip(&h); + assert!(s.tabs.len() > 1 && s.tabs.len() < 14, "{theme:?}: {} shown", s.tabs.len()); + assert!(!elided_on_strip(&h), "{theme:?}: no tab title is cut"); + // The last new document is active; then one that was behind the menu, then the first. + for active in [13, 5, 0] { + h.state_mut().session.set_active(active); + h.run_steps(2); + let pro = crate::theme::Tokens::get(&h.ctx).pro; + let labels: Vec = + (0..14).map(|i| if pro { pro_tab_title(h.state(), i) } else { h.state().session.documents()[i].doc.name.clone() }).collect(); + h.get_by_label("More documents").click(); + h.run_steps(2); + for (i, label) in labels.iter().enumerate() { + // The menu bar's padding: an unchecked row keeps the check mark's room, so names line up. + let (on, off) = (format!("✔ {label}"), format!(" {label}")); + let (want, other) = if i == active { (&on, &off) } else { (&off, &on) }; + assert_eq!(h.query_all_by_label(want).count(), 1, "{theme:?}, active {active}: document {i} is in the menu as {want:?}"); + assert_eq!(h.query_all_by_label(other).count(), 0, "{theme:?}, active {active}: document {i} is not {other:?}"); + } + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.query_by_label(&format!("✔ {}", labels[active])).is_none(), "{theme:?}: the menu closed"); + } + } +} diff --git a/crates/ui-egui/src/canvas_menu_tests.rs b/crates/ui-egui/src/canvas_menu_tests.rs index f1bbbe8d0c..839dc1f765 100644 --- a/crates/ui-egui/src/canvas_menu_tests.rs +++ b/crates/ui-egui/src/canvas_menu_tests.rs @@ -248,6 +248,22 @@ fn control_channel_opening_uses_the_same_dismissal_guard() { } } +/// #7324: an agent's right-click on committed type opens the type layer menu, as on the canvas. +#[test] +fn control_channel_right_click_on_committed_type_opens_the_type_layer_menu() { + let mut h = harness(Tool::Type, false, 1.0); + h.state_mut().run("type.create", json!({"text": "Hello", "size": 72, "x": 100, "y": 180})).unwrap(); + h.run_steps(3); + let at = fractional_point(&h, 1.0); + let before = document_state(&h); + control_tap(&mut h, at, Popup::Tool); + let menu = h.state().ui.canvas_tool_menu.clone().unwrap(); + assert!(menu.type_layer && !menu.text); + assert!(h.state().ui.text_edit.is_none()); + // The new type is already the active layer, so the menu selects nothing either. + assert!(document_state(&h) == before, "opening the menu changes nothing"); +} + #[test] fn brush_picker_taps_preserve_pixels_selection_and_history_after_reopening() { for tool in [Tool::Brush, Tool::Eraser, Tool::QuickSelection] { diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index e1466f09ad..43418d5ff9 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -1,5 +1,6 @@ //! Canvas context menus for the selection tools, the Pen, the Zoom and Hand tools, an active Free -//! Transform box, text being edited with a Type tool and the pasteboard (outside the image). +//! Transform box, text being edited with a Type tool, committed type under a Type tool, the +//! pasteboard (outside the image) and a guide. use egui::Context; use photocraft_doc::LayerContent; @@ -31,6 +32,12 @@ pub struct CanvasToolMenu { /// Opened while a Type tool edits text: the text and type rows instead of the tool's actions. #[serde(default)] pub text: bool, + /// Opened with a Type tool over committed type (no edit running): the type layer rows. + #[serde(default)] + pub type_layer: bool, + /// Opened on a guide: the guide menu (`guide_select`) instead of the tool's actions. + #[serde(default)] + pub guide: bool, /// The misspelled word under the pointer when the type edit menu opened: its suggestions head /// the menu. #[serde(default)] @@ -134,6 +141,33 @@ pub const TYPE_EDIT_MENU: &[Row] = &[ Some(("Warp Text…", "type.warpText")), ]; +/// Right-click with a Type tool over type that isn't being edited (#7324): the type edit menu's +/// layer rows. Its edit rows would act on the document instead of the text, and Faux Bold/Italic +/// on characters being edited, so they are left out. +pub const TYPE_LAYER_MENU: &[Row] = &[ + Some(("Check Spelling…", "edit.checkSpelling")), + Some(("Find and Replace Text…", "edit.findAndReplaceText")), + None, + Some(("Rasterize Type", "type.rasterizeTypeLayer")), + Some(("Create Work Path", "type.createWorkPath")), + Some(("Convert to Shape", "type.convertToShape")), + None, + Some(("Horizontal", "type.orientation.horizontal")), + Some(("Vertical", "type.orientation.vertical")), + None, + Some(("None", "type.antiAlias.none")), + Some(("Sharp", "type.antiAlias.sharp")), + Some(("Crisp", "type.antiAlias.crisp")), + Some(("Strong", "type.antiAlias.strong")), + Some(("Smooth", "type.antiAlias.smooth")), + Some(("Windows LCD", "type.antiAlias.windowsLcd")), + Some(("Windows", "type.antiAlias.windows")), + None, + Some(("Convert to Paragraph Text", "type.convertToParagraphText")), + Some(("Convert to Point Text", "type.convertToPointText")), + Some(("Warp Text…", "type.warpText")), +]; + /// The type edit menu's Faux Bold and Faux Italic rows. pub const FAUX_BOLD: &str = "type.fauxBold"; pub const FAUX_ITALIC: &str = "type.fauxItalic"; @@ -145,6 +179,23 @@ const ENDS_TYPE_EDIT: [&str; 5] = /// The pasteboard menu's Select Custom Color… row. pub const PASTEBOARD_PICK: &str = "pasteboard.selectCustomColor"; +/// The guide menu's Edit Selected Guides row: New Guide's dialog on the selected guide. +pub const GUIDE_EDIT: &str = "guides.editSelected"; +/// The guide menu's Clear Selected Guides row. +pub const GUIDE_CLEAR: &str = "guides.clearSelected"; + +/// Photoshop's guide menu (right-click on a guide), in its order. Its two Selected Guides rows act +/// on the guide the right-click selected; the rest are the View menu's. +pub const GUIDE_MENU: &[Row] = &[ + Some(("Edit Selected Guides", GUIDE_EDIT)), + None, + Some(("Lock Guides", "view.lockGuides")), + Some(("Clear Guides", "view.clearGuides")), + Some(("Clear Canvas Guides", "view.clearCanvasGuides")), + Some(("Clear Selected Guides", GUIDE_CLEAR)), + Some(("Clear Selected Artboard Guides", "view.clearSelectedArtboardGuides")), +]; + /// The pasteboard colours in the menu's order, which Space+F steps through. const CANVAS_COLORS: [CanvasColor; 6] = [CanvasColor::Default, CanvasColor::Black, CanvasColor::DarkGray, CanvasColor::MediumGray, CanvasColor::LightGray, CanvasColor::Custom]; @@ -264,8 +315,14 @@ pub fn applies(tool: Tool) -> bool { | Tool::MagneticLasso | Tool::MagicWand | Tool::ObjectSelection - | Tool::Pen - ) + ) || path_menu(tool) +} + +/// Tools whose canvas right-click opens the path menu ([`PEN_MENU`]): the Pen, and the Path +/// Selection and Direct Selection tools, whose Photoshop menu offers the same whole-path actions +/// for the selected path (#1056). +pub fn path_menu(tool: Tool) -> bool { + matches!(tool, Tool::Pen | Tool::PathSelection | Tool::DirectSelection) } /// Tools whose plain canvas right-click offers the view menu. @@ -280,7 +337,9 @@ pub fn selection_rows(has_selection: bool) -> &'static [Row] { /// The rows of an open menu. pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { - if menu.pasteboard { + if menu.guide { + GUIDE_MENU + } else if menu.pasteboard { PASTEBOARD_MENU } else if menu.transform { TRANSFORM_MENU @@ -288,7 +347,9 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { VIEW_MENU } else if menu.text { TYPE_EDIT_MENU - } else if menu.tool == Tool::Pen { + } else if menu.type_layer { + TYPE_LAYER_MENU + } else if path_menu(menu.tool) { PEN_MENU } else { selection_rows(menu.has_selection) @@ -304,10 +365,13 @@ pub fn available(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> b /// Scale stays Free Transform's mode (only Free Transform is checked). Rotate is its own mode. /// Warp is not a box mode (it is its own session), and `for_command` would read it as Free. pub(crate) fn mode_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { + if menu.guide { + return command == "view.lockGuides" && app.ui.extras.lock_guides; + } if menu.pasteboard { return pasteboard_choice(command) == Some(app.session.prefs().interface.canvas_color); } - if menu.text { + if menu.text || menu.type_layer { return type_checked(app, command); } menu.transform @@ -333,6 +397,32 @@ pub fn editing_text(app: &PhotocraftApp, tool: Tool) -> bool { tool.is_type() && app.ui.text_edit.is_some() } +/// The committed type layer under document point `doc` when a right-click there with a Type tool +/// opens the type layer menu (#7324). The Type Mask tools are left out: to them existing type is +/// just pixels. +pub fn type_layer_at(app: &mut PhotocraftApp, tool: Tool, doc: [f64; 2]) -> Option { + if !tool.is_type() || tool.is_type_mask() || app.ui.text_edit.is_some() || !doc.iter().all(|v| v.is_finite()) { + return None; + } + crate::type_tool::hit_layer(app, doc[0], doc[1]) +} + +/// Make the right-clicked type layer `id` the active layer, which the structural rows (Rasterize +/// Type, Convert to Shape…) act on and the checks show. Inside a multiselection the selection stays, +/// so the formatting rows still act on every selected type layer, as the Type menu's do; outside it +/// the type replaces the selection, as a click with the Type tool does. +fn target_type_layer(app: &mut PhotocraftApp, id: photocraft_doc::LayerId) -> bool { + let Some(st) = app.session.active() else { return false }; + let selected = st.selected_layers(); + if st.active_layer == Some(id) { + true + } else if selected.contains(&id) { + photocraft_engine::layer_multi_cmds::set_selection(&mut app.session, selected, Some(id), Some(id)).is_ok() + } else { + app.session.select_layer(id).is_ok() + } +} + /// The misspelled word of the edited text under document point `doc`, with its suggestions. fn spelling_at(app: &mut PhotocraftApp, doc: [f64; 2]) -> Option { let layer = app.ui.text_edit.as_ref()?.layer; @@ -393,6 +483,8 @@ pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> pasteboard: false, anchor: None, text: false, + type_layer: false, + guide: false, spelling: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); @@ -407,7 +499,15 @@ fn pasteboard_choice(command: &str) -> Option { /// The engine command (and params) a pasteboard row runs, for the automation policy: every row /// changes a preference. -pub fn engine_call(menu: &CanvasToolMenu, command: &str) -> (String, Value) { +pub fn engine_call(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> (String, Value) { + if menu.guide { + let selected = crate::guide_select::selected(app); + return match command { + GUIDE_EDIT => ("view.editGuide".into(), json!({})), + GUIDE_CLEAR => ("view.deleteGuide".into(), selected.map_or(json!({}), |(v, i)| crate::guide_select::delete_params(v, i))), + _ => (command.into(), json!({})), + }; + } match pasteboard_choice(command) { Some(c) if menu.pasteboard => ("prefs.set".into(), json!({"values": {"interface.canvasColor": c.name()}})), _ if menu.pasteboard => ("prefs.set".into(), json!({})), @@ -437,7 +537,7 @@ pub fn pasteboard_at(app: &PhotocraftApp, tool: Tool, doc: [f64; 2]) -> bool { let Some(st) = app.session.active() else { return false }; let (w, h) = (f64::from(st.doc.size.width), f64::from(st.doc.size.height)); let outside = doc.iter().all(|v| v.is_finite()) && (doc[0] < 0.0 || doc[1] < 0.0 || doc[0] >= w || doc[1] >= h); - outside && tool != Tool::Pen && !crate::paint_mouse::has_brush_picker(tool) && !crate::paint_mouse::right_erases(app, tool) && app.ui.text_edit.is_none() + outside && !path_menu(tool) && !crate::paint_mouse::has_brush_picker(tool) && !crate::paint_mouse::right_erases(app, tool) && app.ui.text_edit.is_none() } /// Right-click on the pasteboard: its colour menu at screen point `pos`. @@ -457,12 +557,54 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: pasteboard: true, anchor: None, text: false, + type_layer: false, + guide: false, spelling: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true } +/// Right-click at document point `doc` on a guide that `tool` could drag (the Move tool, or ⌘ with +/// a tool on which ⌘ moves guides, `canvas::command_guide_at`): select the guide and open the guide +/// menu at screen point `pos`. +pub fn open_guide(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, mods: egui::Modifiers, pos: [f32; 2], doc: [f64; 2]) -> bool { + if !pos.iter().all(|v| v.is_finite()) || !doc.iter().all(|v| v.is_finite()) { + return false; + } + let hit = if tool == Tool::Move { crate::rulers::guide_at(app, doc[0], doc[1]) } else { crate::canvas::command_guide_at(app, tool, doc, mods) }; + let Some((vertical, index)) = hit else { return false }; + crate::guide_select::select(app, vertical, index); + app.ui.brush_picker = None; + app.ui.layer_menu = None; + app.ui.canvas_tool_menu = Some(CanvasToolMenu { + pos, + tool, + has_selection: false, + has_path: false, + path_name: None, + transform: false, + pasteboard: false, + anchor: None, + text: false, + type_layer: false, + guide: true, + spelling: None, + }); + crate::canvas_popup::mark_open(ctx, menu_id()); + true +} + +/// A guide row: the two Selected Guides rows act on the selected guide; the rest are the View +/// menu's commands. +fn choose_guide(app: &mut PhotocraftApp, ctx: &Context, command: &str) -> Result<(), String> { + match command { + GUIDE_EDIT => crate::new_guide_ui::open_edit(app).map(|_| ()), + GUIDE_CLEAR => crate::guide_select::delete_selected(app).map(|_| ()), + _ => crate::menus::invoke(app, ctx, command, json!({})).map(|_| ()), + } +} + /// A pasteboard row: set the colour, or open the Color Picker on the custom one. fn choose_pasteboard(app: &mut PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> Result<(), String> { if command == PASTEBOARD_PICK { @@ -470,11 +612,17 @@ fn choose_pasteboard(app: &mut PhotocraftApp, menu: &CanvasToolMenu, command: &s crate::color_picker_ui::open_for_target(app, "pasteboard", "Color Picker (Custom Canvas Color)", rgb); return Ok(()); } - let (command, params) = engine_call(menu, command); + let (command, params) = engine_call(app, menu, command); app.run(&command, params).map(|_| ()) } pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { + if menu.guide { + return match command { + GUIDE_EDIT | GUIDE_CLEAR => crate::guide_select::selected(app).is_some(), + _ => GUIDE_MENU.iter().flatten().any(|(_, id)| *id == command) && crate::menus::is_enabled(app, command), + }; + } if menu.pasteboard { return PASTEBOARD_MENU.iter().flatten().any(|(_, id)| *id == command); } @@ -493,7 +641,10 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) c => TYPE_EDIT_MENU.iter().flatten().any(|(_, id)| *id == c) && crate::menus::is_enabled(app, c), }; } - if menu.transform || menu.tool != Tool::Pen { + if menu.type_layer { + return TYPE_LAYER_MENU.iter().flatten().any(|(_, id)| *id == command) && crate::menus::is_enabled(app, command); + } + if menu.transform || !path_menu(menu.tool) { return crate::menus::is_enabled(app, command); } let Some(st) = app.session.active() else { return false }; @@ -536,10 +687,15 @@ pub fn open(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2]) - } /// [`open`] for a right-click at document point `doc`: over a misspelled word of the text being -/// edited, the menu starts with its spelling suggestions. +/// edited, the menu starts with its spelling suggestions; over committed type with a Type tool, the +/// type layer menu. pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2], doc: Option<[f64; 2]>) -> bool { let text = editing_text(app, tool); - if !(applies(tool) || text) || !pos.iter().all(|v| v.is_finite()) { + if !pos.iter().all(|v| v.is_finite()) { + return false; + } + let type_layer = !text && doc.and_then(|d| type_layer_at(app, tool, d)).is_some_and(|id| target_type_layer(app, id)); + if !(applies(tool) || text || type_layer) { return false; } app.ui.brush_picker = None; @@ -548,8 +704,20 @@ pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2] let has_path = crate::vector_ui::active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); let path_name = crate::vector_ui::active_path_name(app); let spelling = if text { doc.and_then(|d| spelling_at(app, d)) } else { None }; - app.ui.canvas_tool_menu = - Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name, transform: false, pasteboard: false, anchor: None, text, spelling }); + app.ui.canvas_tool_menu = Some(CanvasToolMenu { + pos, + tool, + has_selection, + has_path, + path_name, + transform: false, + pasteboard: false, + anchor: None, + text, + type_layer, + guide: false, + spelling, + }); crate::canvas_popup::mark_open(ctx, menu_id()); true } @@ -572,6 +740,8 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; pasteboard: false, anchor: Some(doc), text: false, + type_layer: false, + guide: false, spelling: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); @@ -609,15 +779,17 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { if !available(app, &menu, command) { return; } - let result = if menu.pasteboard { + let result = if menu.guide { + choose_guide(app, ctx, command) + } else if menu.pasteboard { choose_pasteboard(app, &menu, command) } else if menu.anchor.is_some() { choose_view(app, ctx, &menu, command) } else if menu.text && is_spelling(&menu, command) { choose_spelling(app, &menu, command) - } else if menu.text { + } else if menu.text || menu.type_layer { choose_type(app, ctx, command) - } else if menu.tool == Tool::Pen && !menu.transform { + } else if path_menu(menu.tool) && !menu.transform { choose_pen(app, ctx, &menu, command) } else if command == "select.toWorkPath" { // Photoshop's Make Work Path… asks for the tolerance first. @@ -635,7 +807,7 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { /// A spelling row: replace the word under the pointer (the caret lands after the replacement), or /// add it to the user dictionary. fn choose_spelling(app: &mut PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> Result<(), String> { - let (id, params) = engine_call(menu, command); + let (id, params) = engine_call(app, menu, command); let result = app.run(&id, params)?; if let Some(end) = result["end"].as_u64().map(|e| e as usize) && let Some(e) = app.ui.text_edit.as_mut() @@ -752,6 +924,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { || app.ui.tool != menu.tool || (menu.transform && app.ui.transform.is_none()) || (menu.text && app.ui.text_edit.is_none()) + || (menu.type_layer && app.ui.text_edit.is_some()) { app.ui.canvas_tool_menu = None; return; @@ -967,6 +1140,91 @@ mod tests { assert!(app.ui.text_edit.is_none() && text_of(&app, id).is_none(), "the type layer is pixels now"); } + /// A committed type layer reading "Hello" over the middle of the canvas, no edit running. + fn committed_type(app: &mut PhotocraftApp) -> u64 { + app.ui.tool = Tool::Type; + let id = app.run("type.create", json!({"text": "Hello", "size": 14, "x": 1, "y": 22})).unwrap()["layer"].as_u64().unwrap(); + assert!(app.ui.text_edit.is_none()); + id + } + + #[test] + fn right_click_on_committed_type_opens_the_type_layer_menu() { + use photocraft_doc::text::Orientation; + let mut app = app(); + let id = committed_type(&mut app); + assert_eq!(crate::type_tool::hit_layer(&mut app, 16.0, 16.0).map(|l| l.0), Some(id), "the canvas centre is on the type"); + let mut h = harness(app); + let center = h.state().last_canvas_rect.center(); + right_click(&mut h, center, Modifiers::NONE); + let menu = h.state().ui.canvas_tool_menu.clone().expect("right-click on committed type opens a menu (#7324)"); + assert!(menu.type_layer && !menu.text && !menu.pasteboard); + assert_eq!(rows(&menu), TYPE_LAYER_MENU); + assert!(h.state().ui.text_edit.is_none(), "the right-click doesn't start an edit"); + h.run_steps(2); + assert!(h.state().ui.canvas_tool_menu.is_some(), "the menu stays open without an edit"); + // An edit started some other way (here, as an agent's Type › Edit Text would) closes the + // menu, which has no text edit rows. + crate::menus::invoke(h.state_mut(), &Context::default(), "type.editText", json!({})).unwrap(); + h.run_steps(2); + assert!(h.state().ui.canvas_tool_menu.is_none(), "an edit closes the type layer menu"); + crate::type_tool::commit(h.state_mut()); + right_click(&mut h, center, Modifiers::NONE); + let menu = h.state().ui.canvas_tool_menu.clone().expect("committed again, the menu opens again"); + assert!(menu.type_layer && mode_checked(h.state(), &menu, "type.orientation.horizontal")); + choose(h.state_mut(), &Context::default(), "type.orientation.vertical"); + assert_eq!(text_of(h.state(), id).unwrap().orientation, Orientation::Vertical); + } + + #[test] + fn type_layer_menu_targets_the_type_under_the_pointer() { + let mut app = app(); + let id = committed_type(&mut app); + app.run("layer.new.layer", json!({})).unwrap(); + let ctx = Context::default(); + let active = |app: &PhotocraftApp| app.session.active().unwrap().active_layer.map(|l| l.0); + assert_ne!(active(&app), Some(id)); + // Away from the type, or with a Type Mask tool, nothing opens and nothing is selected. + assert!(!open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([30.0, 2.0]))); + assert!(!open_at(&mut app, &ctx, Tool::HorizontalTypeMask, [10.0, 10.0], Some([16.0, 16.0]))); + assert!(!open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([f64::NAN, 16.0]))); + assert_ne!(active(&app), Some(id)); + // Over the type: its layer becomes the target, and the menu has no text edit rows. + assert!(open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([16.0, 16.0]))); + assert_eq!(active(&app), Some(id)); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + for edit_row in ["edit.undo", "edit.cut", "edit.copy", "edit.paste", "select.all", FAUX_BOLD, FAUX_ITALIC] { + assert!(!available(&app, &menu, edit_row), "{edit_row} belongs to the edit menu only"); + } + assert!(entry_enabled(&app, &menu, "type.convertToParagraphText") && !entry_enabled(&app, &menu, "type.convertToPointText")); + choose(&mut app, &ctx, "type.rasterizeTypeLayer"); + assert!(text_of(&app, id).is_none(), "the type layer is pixels now"); + assert!(app.ui.text_edit.is_none()); + } + + /// Two selected type layers: the one right-clicked becomes active, and the selection stays. + #[test] + fn type_layer_menu_in_a_multiselection_acts_on_the_clicked_type() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.ui.tool = Tool::Type; + let top = app.run("type.create", json!({"text": "Top", "size": 30, "x": 10, "y": 40})).unwrap()["layer"].as_u64().unwrap(); + let bottom = app.run("type.create", json!({"text": "Low", "size": 30, "x": 10, "y": 90})).unwrap()["layer"].as_u64().unwrap(); + app.run("layer.select", json!({"layer": bottom})).unwrap(); + app.run("layer.select", json!({"layer": top, "mode": "add"})).unwrap(); + let at = [25.0, 80.0]; + assert_eq!(crate::type_tool::hit_layer(&mut app, at[0], at[1]).map(|l| l.0), Some(bottom)); + assert_eq!(app.session.active().unwrap().active_layer.map(|l| l.0), Some(top)); + let ctx = Context::default(); + assert!(open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some(at))); + let st = app.session.active().unwrap(); + assert_eq!(st.active_layer.map(|l| l.0), Some(bottom), "the clicked type is the target"); + assert_eq!(st.selected_layers().len(), 2, "the multiselection stays"); + choose(&mut app, &ctx, "type.rasterizeTypeLayer"); + assert!(text_of(&app, bottom).is_none(), "the clicked type is rasterized"); + assert!(text_of(&app, top).is_some(), "not the other selected type"); + } + #[test] fn type_edit_menu_lists_spelling_suggestions_for_the_word_under_the_pointer() { let mut app = app(); @@ -1374,7 +1632,7 @@ mod tests { let mut app = app(); app.ui.tool = Tool::Pen; for (x, y) in [(2.0, 2.0), (20.0, 2.0), (20.0, 20.0)] { - crate::vector_ui::pen_down(&mut app, x, y); + crate::vector_ui::pen_down(&mut app, x, y, false); crate::vector_ui::pen_up(&mut app); } crate::vector_ui::pen_commit(&mut app, true); @@ -1396,7 +1654,7 @@ mod tests { let mut app = app(); app.ui.tool = Tool::Pen; for (x, y) in [(2.0, 2.0), (20.0, 2.0), (20.0, 20.0)] { - crate::vector_ui::pen_down(&mut app, x, y); + crate::vector_ui::pen_down(&mut app, x, y, false); crate::vector_ui::pen_up(&mut app); } assert!(app.session.active().unwrap().doc.work_path.is_none()); @@ -1410,6 +1668,32 @@ mod tests { assert!(app.session.active().unwrap().doc.selection.is_some()); } + /// #1056: the Path Selection and Direct Selection tools' right-click opens the path menu for + /// the selected path, as the Pen's does, instead of nothing. + #[test] + fn path_selection_tools_open_the_path_menu() { + for tool in [Tool::PathSelection, Tool::DirectSelection] { + let mut app = app(); + app.ui.tool = Tool::Pen; + for (x, y) in [(2.0, 2.0), (20.0, 2.0), (20.0, 20.0)] { + crate::vector_ui::pen_down(&mut app, x, y, false); + crate::vector_ui::pen_up(&mut app); + } + crate::vector_ui::pen_commit(&mut app, false); + assert!(app.session.active().unwrap().doc.work_path.is_some()); + app.ui.tool = tool; + assert!(applies(tool)); + assert!(open(&mut app, &Context::default(), tool, [10.0, 10.0]), "{tool:?}"); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + assert_eq!(rows(&menu), PEN_MENU, "{tool:?}"); + assert!(menu.has_path && entry_enabled(&app, &menu, "path.toSelection"), "{tool:?}"); + choose(&mut app, &Context::default(), "path.toSelection"); + let dialog = app.ui.dialogs.last().map(|d| d.id).unwrap(); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert!(app.session.active().unwrap().doc.selection.is_some(), "{tool:?}: Make Selection made one"); + } + } + #[test] fn pen_menu_has_all_reference_rows_in_order_and_guards_unavailable_actions() { let labels: Vec<_> = PEN_MENU.iter().map(|row| row.map(|(label, _)| label)).collect(); @@ -1536,7 +1820,7 @@ mod tests { app.ui.tool = Tool::Pen; app.ui.tool_options.vector_mode = "shape".into(); for (x, y) in [(2.0, 2.0), (20.0, 2.0), (20.0, 20.0)] { - crate::vector_ui::pen_down(&mut app, x, y); + crate::vector_ui::pen_down(&mut app, x, y, false); crate::vector_ui::pen_up(&mut app); } assert!(open(&mut app, &Context::default(), Tool::Pen, [10.0, 10.0])); diff --git a/crates/ui-egui/src/channel_view.rs b/crates/ui-egui/src/channel_view.rs index b8be54d273..5cbc960b8e 100644 --- a/crates/ui-egui/src/channel_view.rs +++ b/crates/ui-egui/src/channel_view.rs @@ -106,15 +106,25 @@ fn render_with_source( return None; } let layer_mask = v.shown_layer_mask(doc); - // ⌥-click mask view (#196): the layer mask alone, in grayscale (white reveals); ⇧⌥ with - // nothing else shown: just the rubylith. Both straight from the mask, in parallel rows, so - // switching the view on a 24 MP document takes one frame. + // ⌥-click mask view (#196): the pixel mask alone, in grayscale (white reveals); ⇧⌥ with + // nothing else shown: just the rubylith. Use the compositor's cached feather for the pixel + // mask, but leave vector masks out: this view targets the pixel mask channel by itself. + let feathered_mask = v + .layer_mask + .and_then(|view| doc.layer(view.layer)) + .filter(|layer| layer.mask.as_ref().is_some_and(|mask| mask.enabled && photocraft_compose::masks::feather_sigma(mask.feather) > 0.0)) + .and_then(|layer| { + let mut pixel_mask_layer = layer.clone(); + pixel_mask_layer.vector_mask = None; + photocraft_compose::masks::combined_mask(&pixel_mask_layer, doc.bounds()) + }); + let shown_mask = layer_mask.map(|(m, mode)| (feathered_mask.as_ref().unwrap_or(&m.surface), mode)); let only_mask = ChannelView { layer_mask: None, ..v.clone() }.is_plain(doc); - match layer_mask { - Some((m, MaskViewMode::Gray)) => return Some(mask_px(&m.surface, r, factor, |g| Color32::from_gray(q(g)))), + match shown_mask { + Some((m, MaskViewMode::Gray)) => return Some(mask_px(m, r, factor, |g| Color32::from_gray(q(g)))), Some((m, MaskViewMode::Overlay)) if only_mask => { let c = AlphaChannel::DEFAULT_COLOR.to_rgb(); - return Some(mask_px(&m.surface, r, factor, |g| { + return Some(mask_px(m, r, factor, |g| { let a = 0.5 * (1.0 - g.clamp(0.0, 1.0)); Color32::from_rgba_premultiplied(q(c[0] * a), q(c[1] * a), q(c[2] * a), q(a)) })); @@ -138,9 +148,9 @@ fn render_with_source( } else if let Some(q) = doc.quick_mask.as_ref().filter(|_| !v.quick_mask_hidden) { solo_quick = true; gray(&q.surface) - } else if let Some((m, _)) = layer_mask { + } else if let Some((m, _)) = shown_mask { solo_mask = true; - gray(&m.surface) + gray(m) } else { let m = active.and_then(|id| doc.layer(id)).and_then(|l| l.mask.as_ref())?; solo_mask = true; @@ -204,8 +214,8 @@ fn render_with_source( } // ⇧⌥-click mask view (#196): the layer mask as Quick Mask's red over the hidden areas // (Photoshop's Layer Mask Display Options default). - if !solo_mask && let Some((m, MaskViewMode::Overlay)) = layer_mask { - tint(sample(&m.surface, r, factor), AlphaChannel::DEFAULT_COLOR, 0.5, &|x| 1.0 - x); + if !solo_mask && let Some((m, MaskViewMode::Overlay)) = shown_mask { + tint(sample(m, r, factor), AlphaChannel::DEFAULT_COLOR, 0.5, &|x| 1.0 - x); } Some(out.into_iter().map(|p| Color32::from_rgba_premultiplied(q(p[0]), q(p[1]), q(p[2]), q(p[3]))).collect()) } @@ -513,6 +523,35 @@ mod tests { assert!(px[36].a() <= 4, "next to nothing over the revealed end: {:?}", px[36]); } + #[test] + fn layer_mask_views_show_the_feathered_mask() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 37, "height": 21})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("paint.gradient", json!({"from": [0, 0], "to": [37, 0], "colors": ["#000000", "#ffffff"], "target": "mask"})).unwrap(); + s.execute("layer.layerMask.edit", json!({"feather": 4})).unwrap(); + s.execute("view.layerMask", json!({"mode": "gray"})).unwrap(); + + let st = s.active().unwrap(); + let bounds = st.doc.bounds(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap(); + let raw = layer.mask.as_ref().unwrap().surface.sample_channel(0, 0, 0); + let effective = photocraft_compose::masks::combined_mask(layer, bounds).unwrap(); + let softened = effective.sample_channel(0, 0, 0); + assert!((softened - raw).abs() > 0.01, "the fixture has a visible feather at its edge"); + + let gray = render(&st.doc, &st.channel_view, bounds, 1, false, st.active_layer).unwrap(); + assert_eq!(gray[0], Color32::from_gray(q(softened)), "gray view uses the compositor's feathered mask"); + + s.execute("view.layerMask", json!({"mode": "overlay"})).unwrap(); + let st = s.active().unwrap(); + let overlay = render(&st.doc, &st.channel_view, bounds, 1, false, st.active_layer).unwrap(); + let alpha = 0.5 * (1.0 - softened); + let red = AlphaChannel::DEFAULT_COLOR.to_rgb(); + assert_eq!(overlay[0], Color32::from_rgba_premultiplied(q(red[0] * alpha), q(red[1] * alpha), q(red[2] * alpha), q(alpha))); + } + /// `cargo test --release -p photocraft-ui-egui --lib mask_view_switch_24mp -- --ignored --nocapture` #[test] #[ignore = "benchmark"] diff --git a/crates/ui-egui/src/channels_panel.rs b/crates/ui-egui/src/channels_panel.rs index f232384139..7f175b6209 100644 --- a/crates/ui-egui/src/channels_panel.rs +++ b/crates/ui-egui/src/channels_panel.rs @@ -330,7 +330,11 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let load = icons::button(ui, "circle-dashed", 26.0, false, tl!("Load channel as selection")); if load.clicked() { - actions.push(("select.loadSelection".into(), json!({ "channel": target_ref }))); + // The selected mask row can coexist with a Composite engine target while mask + // painting is active. The footer follows the row the user sees as targeted. + let params = + if mask_targeted { json!({ "channel": "mask", "layer": masked.as_ref().map(|layer| layer.id.0) }) } else { json!({ "channel": target_ref }) }; + actions.push(("select.loadSelection".into(), params)); } actions.extend(drop(ui, &load, Footer::Load)); }); @@ -615,6 +619,68 @@ mod tests { assert!(h.state().session.active().unwrap().doc.selection.is_some(), "the white channel selects everything"); } + /// The footer follows the targeted row shown by the panel when mask painting is active, + /// even though the engine's channel-view target remains Composite. Other channel targets + /// continue to load their own values, and each load remains one undoable selection change. + #[test] + fn footer_loads_the_selected_layer_mask_and_preserves_color_alpha_targets() { + let (mut h, _) = channels_harness(); + h.state_mut().run("select.rect", json!({"x": 0, "y": 0, "width": 32, "height": 48})).unwrap(); + h.state_mut().run("layer.layerMask.hideAll", json!({})).unwrap(); + h.state_mut().run("layer.layerMask.revealSelection", json!({})).unwrap(); + h.state_mut().run("channel.target", json!({"channel": "composite"})).unwrap(); + h.state_mut().ui.mask_target = true; + h.run_steps(2); + + let click_load = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>| { + use egui::{Modifiers, PointerButton}; + let p = at(h, "Load channel as selection"); + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + }; + let sample = + |h: &egui_kittest::Harness<'_, PhotocraftApp>, x, y| h.state().session.active().unwrap().doc.selection.as_ref().unwrap().sample_channel(x, y, 0); + + assert_eq!(h.state().session.active().unwrap().channel_view.target, ChannelTarget::Composite); + click_load(&mut h); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 0.0), "mask row selection wins over the Composite engine target"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert!(h.state().session.active().unwrap().doc.selection.is_none()); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 0.0)); + + h.state_mut().ui.mask_target = false; + h.state_mut().run("channel.target", json!({"channel": "composite"})).unwrap(); + click_load(&mut h); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 1.0), "composite luminosity remains available"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 0.0)); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 1.0)); + + h.state_mut().run("select.rect", json!({"x": 0, "y": 0, "width": 32, "height": 48})).unwrap(); + h.state_mut().run("channel.new", json!({"fill": "selection"})).unwrap(); + h.state_mut().run("select.deselect", json!({})).unwrap(); + h.state_mut().run("channel.target", json!({"channel": "red"})).unwrap(); + click_load(&mut h); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 1.0), "color channel still loads its own plane"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 1.0)); + + h.state_mut().run("channel.target", json!({"channel": 1})).unwrap(); + click_load(&mut h); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 0.0), "alpha channel still loads its own plane"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 1.0)); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 0.0)); + } + /// A plain click on a row still targets it, and a drag released elsewhere does nothing. #[test] fn clicks_still_target_and_a_drop_elsewhere_does_nothing() { diff --git a/crates/ui-egui/src/chrome_ui.rs b/crates/ui-egui/src/chrome_ui.rs index b20e913a7d..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}; @@ -339,7 +390,7 @@ mod tests { let mut gray = doc(); gray.mode = photocraft_doc::ColorMode::Grayscale; gray.icc_profile = Some(photocraft_engine::color_cmds::working_profile(gray.mode).to_bytes()); - assert_eq!(profile_name(&gray), "Dot Gain 20%-compatible (gamma 1.732, Photocraft)"); + assert_eq!(profile_name(&gray), "Dot Gain 20%-compatible (gamma 1.732, PhotoCraft)"); let mut custom = (*photocraft_engine::color_cmds::working_profile(photocraft_doc::ColorMode::Cmyk)).clone(); custom.description = "PhotoCraft Studio CMYK".into(); 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/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index 0d51f5d2f5..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); @@ -685,9 +681,22 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, mode: &str, now: &Compo crate::field_tab::select_all(ui.ctx(), id, current_hex.trim_start_matches('#')); ui.memory_mut(|m| m.request_focus(id)); } - let typing = if ui.memory(|m| m.has_focus(id)) { ui.data(|d| d.get_temp::(id)) } else { None }; - let mut h = typing.unwrap_or_else(|| hex(rgb).trim_start_matches('#').to_ascii_uppercase()); + 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 => 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 { + edit = Some((c, Keep::Nothing)); + } if r.gained_focus() { crate::field_tab::select_all(ui.ctx(), id, &h); } @@ -743,6 +752,23 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, mode: &str, now: &Compo } } +fn take_hex_paste(ui: &egui::Ui) -> Option<[f32; 3]> { + ui.input_mut(|i| { + let mut found = None; + i.events.retain(|e| match e { + egui::Event::Paste(s) => match parse_hex(s) { + Some(c) => { + found = Some(c); + false + } + None => true, + }, + _ => true, + }); + found + }) +} + /// Add to Swatches was clicked: add the picked colour to the Swatches panel. pub fn take_add_swatch(app: &mut PhotocraftApp, f: &mut Map) { if f.remove("__addSwatch").and_then(|v| v.as_bool()) == Some(true) { @@ -758,6 +784,9 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result if !at[0].is_finite() || !at[1].is_finite() || at[0] < 0.0 || at[1] < 0.0 || at[0] >= f64::from(doc.size.width) || at[1] >= f64::from(doc.size.height) { return false; } - if doc.selection.as_ref().is_some_and(|selection| selection.sample_channel(at[0].floor() as i32, at[1].floor() as i32, 0) <= 0.0) { - return false; - } pick(app, &mut f, at, mods); match app.ui.dialogs.last_mut() { Some(d) => { @@ -708,9 +705,6 @@ pub fn pick(app: &PhotocraftApp, f: &mut Map, at: [f64; 2], mods: let (w, h) = (f64::from(st.doc.size.width.max(1)), f64::from(st.doc.size.height.max(1))); let [x, y] = at; let at = [x.floor().clamp(0.0, w - 1.0), y.floor().clamp(0.0, h - 1.0)]; - if st.doc.selection.as_ref().is_some_and(|selection| selection.sample_channel(at[0] as i32, at[1] as i32, 0) <= 0.0) { - return; - } let tool = if mods.shift { "add" } else if mods.alt { @@ -831,10 +825,12 @@ mod tests { assert_eq!(mask[5 * 40 + 25], 0.0, "even inverted previews stay within the selection"); } + // As in Photoshop, the eyedropper samples anywhere; only the result is scoped (#5244). open(&mut app); - assert!(!pick_top(&mut app, [25.0, 5.0], egui::Modifiers::NONE)); - let fields = &app.ui.dialogs.last().unwrap().fields; - assert!(points(fields, "points").is_empty(), "a click outside the selection is ignored"); + assert!(pick_top(&mut app, [25.0, 5.0], egui::Modifiers::NONE)); + assert_eq!(points(&app.ui.dialogs.last().unwrap().fields, "points"), vec![[25.0, 5.0]], "a colour outside the selection is sampled"); + let mask = proxy_mask(&app, &doc, 1, &json!({"select": "sampledColors", "points": [[25, 5]], "fuzziness": 0})).unwrap(); + assert_eq!(mask[5 * 40 + 25], 0.0, "but the preview stays within the selection"); assert!(pick_top(&mut app, [5.0, 5.0], egui::Modifiers::NONE)); assert_eq!(points(&app.ui.dialogs.last().unwrap().fields, "points"), vec![[5.0, 5.0]]); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index db908d0943..321c73ec60 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -131,8 +131,9 @@ pub enum Outcome { /// field's value is validated before the first one is applied, so a typo, an unknown field, a /// bad value or a bad nested key can't reply with success while nothing — or only half of it — /// changed (#412). -pub const UI_SET_FIELDS: [&str; 35] = [ +pub const UI_SET_FIELDS: [&str; 38] = [ "tool", + "strokeTrace", "panels", "dock", "dockTabs", @@ -164,9 +165,11 @@ pub const UI_SET_FIELDS: [&str; 35] = [ "cropOverlayShow", "cropOverlayOrientation", "cropShield", + "cropFill", "shapeStroke", "camTransformOnDrop", "tileGrid", + "layerFilter", ]; /// Most clicks one `ui.click` may queue (#982). Each click is a press and a release that the app @@ -395,7 +398,7 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) return err("context action is unavailable"); } // A pasteboard row changes a preference: the policy sees the `prefs.set` it runs. - let (command, params) = crate::canvas_tool_menu::engine_call(menu, id); + let (command, params) = crate::canvas_tool_menu::engine_call(app, menu, id); if let Some(authorize) = app.services.automation_command.as_ref() && let Err(error) = authorize(&command, ¶ms) { @@ -490,6 +493,19 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) None => None, }; let gradient_classic = bool_field(p, "gradientClassic")?; + // The stroke latency trace (`stroke_trace`), at runtime: on (stderr) or off. A CSV + // file is only for the user's own launch (`--stroke-trace `): a path over the + // control channel would write outside the automation write root. + let stroke_trace = match p.get("strokeTrace") { + None | Some(Value::Null) => None, + Some(Value::Bool(b)) => Some(if *b { "stderr" } else { "off" }), + Some(Value::String(v)) => match v.trim() { + "stderr" | "on" | "true" => Some("stderr"), + "off" | "false" => Some("off"), + _ => return Err("strokeTrace must be true, false, \"stderr\" or \"off\"; a CSV file is set at launch (--stroke-trace )".into()), + }, + Some(_) => return Err("strokeTrace must be true, false, \"stderr\" or \"off\"".into()), + }; // The Eyedropper's options bar (#1649): Sample Size, Sample, Show Sampling Ring. let eyedropper_size = match p.get("eyedropperSampleSize") { Some(v) => Some( @@ -511,6 +527,7 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) let cam_transform_on_drop = bool_field(p, "camTransformOnDrop")?; // Developer overlay of the active layer's storage tiles (tile_overlay.rs). let tile_grid = bool_field(p, "tileGrid")?; + let layer_filter = p.get("layerFilter").map(|v| crate::layer_filter_ui::patched(&app.ui, v)).transpose()?; // The Crop tool's overlay menu (#1919). let crop_overlay = match p.get("cropOverlay") { Some(v) => Some(v.as_str().and_then(crate::crop_overlay::CropOverlay::from_id).ok_or_else(|| { @@ -532,6 +549,15 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) Some(_) => return Err("cropOverlayOrientation must be 0, 1, 2 or 3".into()), None => None, }; + // The Crop tool's Fill (#3823): "background" (Background (Default)) or "contentAware". + let crop_content_aware = match p.get("cropFill") { + Some(v) => Some(match v.as_str() { + Some("background") => false, + Some("contentAware") => true, + _ => return Err("cropFill must be \"background\" or \"contentAware\"".into()), + }), + None => None, + }; // The Crop tool's gear menu: Show Cropped Area and the shield (#1919). let crop_shield = merged_object(&app.ui.tool_options.crop_shield, p.get("cropShield"), "cropShield")?; if crop_shield.as_ref().is_some_and(|s| !(0.0..=100.0).contains(&s.opacity)) { @@ -642,7 +668,7 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } } if let Some(t) = tool { - app.ui.tool = t; + crate::tool_memory::select(app, t); // Each tool keeps its own brush (#218), so switch it in before `brushSize` // below sets the new tool's size. crate::paint_mouse::sync_tool_brush(app); @@ -651,6 +677,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) if let Some(mode) = gradient_blend { app.ui.tool_options.gradient_blend_mode = mode; } + if let Some(v) = &stroke_trace { + app.stroke_trace.set(v); + } if let Some(classic) = gradient_classic { app.ui.tool_options.gradient_classic = classic; } @@ -672,9 +701,15 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) if let Some(on) = tile_grid { app.ui.view.tile_grid = on; } + if let Some(f) = layer_filter { + crate::layer_filter_ui::apply(&mut app.ui, f); + } if let Some(k) = crop_overlay { app.ui.tool_options.crop_overlay = k; } + if let Some(on) = crop_content_aware { + app.ui.tool_options.crop_content_aware = on; + } if let Some(v) = crop_overlay_show { app.ui.tool_options.crop_overlay_show = v; } @@ -985,7 +1020,7 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) let secondary = matches!(button.as_str(), "secondary" | "right"); let mods = modifiers_from(&m); if let Some(t) = tool { - app.ui.tool = t; + crate::tool_memory::select(app, t); } // Space held: the Crop tool moves the frame being drawn, a marquee, lasso or shape // being drawn moves instead of growing (hold_keys.rs). Like a modifier flag, it may be @@ -1008,6 +1043,13 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } continue; } + // ⌘/Ctrl-click with Curves open adds a point at the pixel's tone, on press only (#7325). + if !secondary && !space && button != "middle" && mods.command && crate::adjust_dialog::curves_on_top(app) { + if matches!(ev, ToolEvent::Down { .. }) { + crate::adjust_dialog::add_point_at(app, ctx, x, y, mods.shift); + } + continue; + } // A Properties Levels/Curves eyedropper owns the primary button the same way. if app.ui.dialogs.is_empty() && !matches!(s("button"), Some("secondary" | "right")) && crate::layer_picker::armed(app).is_some() { if matches!(ev, ToolEvent::Down { .. }) { @@ -1058,7 +1100,10 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } continue; } - if crate::canvas_tool_menu::applies(app.ui.tool) || crate::canvas_tool_menu::editing_text(app, app.ui.tool) { + if crate::canvas_tool_menu::applies(app.ui.tool) + || crate::canvas_tool_menu::editing_text(app, app.ui.tool) + || crate::canvas_tool_menu::type_layer_at(app, app.ui.tool, [x, y]).is_some() + { if down { crate::canvas_tool_menu::open_at(app, ctx, app.ui.tool, screen_point(app, x, y), Some([x, y])); } @@ -1235,10 +1280,10 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } None => err("missing `path`"), }, - "app.save" => match crate::targa_options_ui::bits_param(p, "app.save") { - Ok(tga_bits) => { + "app.save" => match crate::FormatChoices::from_params(p, "app.save") { + Ok(choices) => { let path = s("path").map(str::to_string); - run_waiting(app, p.get("wait").and_then(Value::as_bool).unwrap_or(true), move |app| app.save_automation_with(path, tga_bits)) + run_waiting(app, p.get("wait").and_then(Value::as_bool).unwrap_or(true), move |app| app.save_automation_with(path, choices)) } Err(e) => err(e), }, @@ -1293,6 +1338,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { "pos": menu.pos, "tool": menu.tool, "pasteboard": menu.pasteboard, + "guide": menu.guide, "entries": context_entries(app, menu) }) }), @@ -1315,7 +1361,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { "frame": app.frame, "session": photocraft_engine::inspect::session(&app.session), "document": app.session.active().map(photocraft_engine::inspect::document), - "perf": {"fps": app.fps, "timings": app.perf}, + "perf": {"fps": app.fps, "timings": app.perf, "strokeTrace": app.stroke_trace.last}, "brush": {"size": app.session.tools.brush.size, "hardness": app.session.tools.brush.hardness, "opacity": app.session.tools.brush.opacity}, "distort": app.distort.describe(), "jobs": crate::jobs_ui::inspect(app), @@ -1366,6 +1412,26 @@ mod tests { } } + #[test] + fn ui_set_turns_the_stroke_trace_on_and_off_at_runtime() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + assert!(!app.stroke_trace.enabled()); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"strokeTrace": true}))["ok"], true); + assert!(app.stroke_trace.enabled()); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"strokeTrace": "off"}))["ok"], true); + assert!(!app.stroke_trace.enabled()); + let bad = call(&mut app, &ctx, "ui.set", json!({"strokeTrace": 3, "tool": "Pencil"})); + assert_eq!(bad["ok"], false); + // A file path isn't accepted over the channel (it would write outside the automation + // write root); nothing is created. + let path = std::env::temp_dir().join(format!("photocraft-trace-{}.csv", std::process::id())); + let bad = call(&mut app, &ctx, "ui.set", json!({"strokeTrace": path.to_str().unwrap(), "tool": "Pencil"})); + assert_eq!(bad["ok"], false); + assert!(!path.exists()); + assert!(!app.stroke_trace.enabled() && app.ui.tool != crate::state::Tool::Pencil, "a rejected call applies nothing"); + } + #[test] fn camera_raw_control_updates_and_commits_or_cancels_an_open_raw() { use photocraft_raw::testgen::{DngSpec, mosaic, scene}; @@ -1391,7 +1457,11 @@ mod tests { assert!(app.ui.dialogs.is_empty()); let update = json!({"id":"filter.cameraRaw","params":{"ui":{"set":{"exposure":0.5}}}}); let original = app.session.active().unwrap().doc.clone(); - let changed = call(&mut app, &ctx, "ui.menu.invoke", update); + let changed = call(&mut app, &ctx, "ui.menu.invoke", update.clone()); + let undone = call(&mut app, &ctx, "ui.menu.invoke", json!({"id":"filter.cameraRaw","params":{"ui":{"undo":true}}})); + assert_eq!(undone["result"]["params"]["exposure"], 0.0, "{undone}"); + let redone = call(&mut app, &ctx, "ui.menu.invoke", json!({"id":"filter.cameraRaw","params":{"ui":{"redo":true}}})); + assert_eq!(redone["result"]["params"]["exposure"], 0.5, "{redone}"); assert_eq!(changed["ok"], true, "{changed}"); assert_eq!(changed["result"]["params"]["exposure"], 0.5); assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &original), "a preview must not commit"); @@ -1926,6 +1996,36 @@ mod tests { assert!(app.ui.tool_options.gradient_classic, "invalid mode must not change options"); } + /// #5048: the Layers panel filter is drivable; a bad field changes nothing. + #[test] + fn ui_set_drives_the_layers_panel_filter() { + use crate::layer_filter_ui::FilterBy; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "name", "name": "ink"}})); + assert_eq!(ok["ok"], true, "{ok}"); + assert_eq!((app.ui.layer_filter_by, app.ui.layer_filter_name.as_str()), (FilterBy::Name, "ink")); + let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "mode", "mode": "Pass Through", "kinds": ["type", "pixel", "type"]}})); + assert_eq!(ok["ok"], true, "{ok}"); + assert_eq!((app.ui.layer_filter_by, app.ui.layer_filter_mode), (FilterBy::Mode, photocraft_color::BlendMode::PassThrough)); + assert_eq!(app.ui.layer_filter, ["type", "pixel"], "kinds are kept once each"); + assert_eq!(app.ui.layer_filter_name, "ink", "fields left out keep their value"); + for bad in [ + json!({"layerFilter": "name"}), + json!({"layerFilter": {"by": "colour"}}), + json!({"layerFilter": {"kinds": ["pixels"]}}), + json!({"layerFilter": {"kinds": "pixel"}}), + json!({"layerFilter": {"name": 3}}), + json!({"layerFilter": {"mode": "Nonsense"}}), + json!({"layerFilter": {"by": "kind", "extra": true}}), + json!({"layerFilter": {"by": "kind"}, "tileGrid": "yes"}), + ] { + let r = call(&mut app, &ctx, "ui.set", bad.clone()); + assert_eq!(r["ok"], false, "{bad} → {r}"); + assert_eq!(app.ui.layer_filter_by, FilterBy::Mode, "{bad} changed nothing"); + } + } + #[test] fn ui_set_toggles_the_developer_tile_grid() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -1988,6 +2088,22 @@ mod tests { assert_eq!(Tool::ColorSampler.key(), 'I'); } + /// #3823: the Crop tool's Fill over the control channel, reported by `ui.inspect`. + #[test] + fn ui_set_drives_the_crop_fill() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"tool": "crop", "cropFill": "contentAware"}))["ok"], true); + assert!(app.ui.tool_options.crop_content_aware); + assert_eq!(call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["toolOptions"]["crop_content_aware"], true); + for bad in [json!("generativeExpand"), json!(true), json!(1)] { + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"cropFill": bad}))["ok"], false, "{bad}"); + assert!(app.ui.tool_options.crop_content_aware, "{bad}: unchanged"); + } + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"cropFill": "background"}))["ok"], true); + assert!(!app.ui.tool_options.crop_content_aware); + } + /// #1919: the Crop tool's overlay menu over the control channel, reported by `ui.inspect`. #[test] fn ui_set_drives_the_crop_overlay_options() { @@ -2325,6 +2441,37 @@ mod tests { assert_eq!(written.borrow().len(), 3, "a bad tgaBits writes nothing"); } + /// `app.save` takes the DDS options (`ddsCompression`, `ddsMipmaps`) and never stops at DDS + /// Options. + #[test] + fn automation_save_passes_dds_options_without_asking() { + use photocraft_io::{DdsCompression, DdsOptions}; + use std::cell::RefCell; + use std::rc::Rc; + let asked: Rc>> = Rc::default(); + let a = asked.clone(); + let services = crate::Services { + export: Some(Box::new(move |_d: &photocraft_doc::Document, _p: &str, s: &crate::ExportSettings| { + a.borrow_mut().push(s.dds); + Ok((b"out".to_vec(), Vec::new())) + })), + automation_write: Some(Box::new(|_: &str, _: &[u8]| Ok(()))), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + for params in [json!({"path": "a.dds"}), json!({"path": "b.dds", "ddsCompression": "bc1", "ddsMipmaps": true})] { + let r = call(&mut app, &ctx, "app.save", params.clone()); + assert_eq!(r["result"]["path"], params["path"], "{r}"); + assert!(app.dds_options.is_none(), "automation saves never prompt"); + } + assert_eq!(*asked.borrow(), [DdsOptions::default(), DdsOptions { compression: DdsCompression::Bc1, mipmaps: true }]); + let r = call(&mut app, &ctx, "app.save", json!({"path": "c.dds", "ddsCompression": "bc9"})); + assert!(r["error"].as_str().unwrap().contains("ddsCompression"), "{r}"); + assert_eq!(asked.borrow().len(), 2, "a bad ddsCompression writes nothing"); + } + #[test] fn automation_open_and_save_use_the_roots_and_reply_with_warnings() { use photocraft_color::{ColorMode, SampleType}; diff --git a/crates/ui-egui/src/corner_radius.rs b/crates/ui-egui/src/corner_radius.rs new file mode 100644 index 0000000000..b02e4d0eea --- /dev/null +++ b/crates/ui-egui/src/corner_radius.rs @@ -0,0 +1,232 @@ +//! On-canvas corner radius widgets for live rectangles (TOOL-214-12), as in Photoshop: with Path +//! Selection, the Rectangle tool, or the Move tool showing its transform controls, every selected +//! shape layer that is a live rectangle shows a round widget inside each corner, at the centre of +//! its arc. Dragging one rounds every corner of every selected rectangle to the same radius; +//! ⌥/Alt changes that corner only, on each of them. The drag previews the new outlines and +//! commits one `shape.edit` with `radii` per changed layer on release, as one history step; a +//! click without movement changes nothing. + +use egui::{Color32, Modifiers, Pos2, Stroke}; +use photocraft_doc::LayerContent; +use photocraft_doc::vector::LiveShape; + +use crate::PhotocraftApp; +use crate::canvas::ToolEvent; +use crate::state::Tool; + +/// A widget sits at least this many screen points inside its corner, so a sharp corner's widget +/// can still be grabbed. +const MIN_INSET: f64 = 10.0; + +/// A selected live rectangle: its layer, `[x, y, w, h]` with a positive size, and its radii. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Target { + layer: u64, + rect: [f64; 4], + radii: [f64; 4], +} + +/// A corner radius drag in progress. +#[derive(Clone, Debug, PartialEq)] +pub struct Drag { + /// Every selected live rectangle, as at the press. + targets: Vec, + /// The rectangle whose widget was pressed (an index into `targets`) and its corner, + /// `[tl, tr, br, bl]` order. + grabbed: usize, + corner: usize, + /// Every corner (no ⌥) or the dragged one only. + all: bool, + /// Where the press was. + start: [f64; 2], + /// Each target's radii so far. + now: Vec<[f64; 4]>, +} + +/// Do the widgets show with the current tool? +fn shows(app: &PhotocraftApp) -> bool { + app.ui.transform.is_none() + && match app.ui.tool { + Tool::PathSelection | Tool::Rectangle => true, + Tool::Move => app.ui.tool_options.move_show_transform, + _ => false, + } +} + +/// The selected layers that are live rectangles, front first. +fn targets(app: &PhotocraftApp) -> Vec { + if !shows(app) { + return Vec::new(); + } + let Some(st) = app.session.active() else { return Vec::new() }; + let mut out: Vec = st + .selected_layers() + .into_iter() + .filter_map(|id| { + let l = st.doc.layer(id)?; + let LayerContent::Shape(sh) = &l.content else { return None }; + let Some(LiveShape::Rect { rect: [x, y, w, h], radii }) = sh.live else { return None }; + let (x, w) = if w < 0.0 { (x + w, -w) } else { (x, w) }; + let (y, h) = if h < 0.0 { (y + h, -h) } else { (y, h) }; + (w > 0.0 && h > 0.0 && [x, y, w, h].iter().chain(&radii).all(|v| v.is_finite())).then_some(Target { layer: l.id.0, rect: [x, y, w, h], radii }) + }) + .collect(); + // `selected_layers` lists bottom to top; the topmost widget wins a press. + out.reverse(); + out +} + +/// The largest radius a corner of `rect` takes. +fn max_radius(rect: [f64; 4]) -> f64 { + rect[2].min(rect[3]) / 2.0 +} + +/// Corner `i` of `rect` and the unit-step direction pointing inside from it. +fn corner(rect: [f64; 4], i: usize) -> ([f64; 2], [f64; 2]) { + let [x, y, w, h] = rect; + match i { + 0 => ([x, y], [1.0, 1.0]), + 1 => ([x + w, y], [-1.0, 1.0]), + 2 => ([x + w, y + h], [-1.0, -1.0]), + _ => ([x, y + h], [1.0, -1.0]), + } +} + +/// Where corner `i`'s widget is drawn: the centre of its arc, or a few screen points inside a +/// sharper corner. +fn widget(rect: [f64; 4], radii: [f64; 4], i: usize, zoom: f64) -> [f64; 2] { + let (c, d) = corner(rect, i); + let r = radii.get(i).copied().unwrap_or(0.0).max(MIN_INSET / zoom).min(max_radius(rect)); + [c[0] + d[0] * r, c[1] + d[1] * r] +} + +/// The radius the pressed corner `i` asks for when the pointer has moved from `start` to `p`: its +/// radius at the press plus the move projected onto the corner's diagonal (inward rounds more), +/// at least 0. Relative, so a click on a sharp corner's inset widget changes nothing. Each +/// rectangle then caps it at half its own shorter side. +fn radius_after(rect: [f64; 4], i: usize, from: f64, start: [f64; 2], p: [f64; 2]) -> f64 { + let (_, d) = corner(rect, i); + let r = from + ((p[0] - start[0]) * d[0] + (p[1] - start[1]) * d[1]) / 2.0; + if r.is_finite() { r.max(0.0) } else { from } +} + +fn zoom(app: &PhotocraftApp) -> f64 { + f64::from(app.point_zoom().max(0.01)) +} + +/// The widget of `targets` within grabbing distance of document point `p`: the rectangle (an +/// index into `targets`) and its corner, the nearest when several are close. +fn hit(app: &PhotocraftApp, targets: &[Target], p: [f64; 2]) -> Option<(usize, usize)> { + let z = zoom(app); + let tol = 7.0 / z; + targets + .iter() + .enumerate() + .flat_map(|(t, g)| { + (0..4).map(move |i| { + let w = widget(g.rect, g.radii, i, z); + (t, i, (w[0] - p[0]).hypot(w[1] - p[1])) + }) + }) + .filter(|(.., d)| *d <= tol) + .min_by(|a, b| a.2.total_cmp(&b.2)) + .map(|(t, i, _)| (t, i)) +} + +/// Is a widget under document point `p`, or one being dragged? The canvas then shows the corner +/// radius cursor. +pub fn hovers(app: &PhotocraftApp, p: [f64; 2]) -> bool { + app.ui.corner_radius.is_some() || hit(app, &targets(app), p).is_some() +} + +/// Canvas pointer events for the corner widgets; false when the event is not theirs. +pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { + match ev { + ToolEvent::Down { x, y, .. } => { + if mods.command { + return false; + } + let targets = targets(app); + let Some((grabbed, corner)) = hit(app, &targets, [x, y]) else { return false }; + let now = targets.iter().map(|t| t.radii).collect(); + app.ui.corner_radius = Some(Drag { targets, grabbed, corner, all: !mods.alt, start: [x, y], now }); + // The press may have started the Move tool's snapping, which a radius drag has none of. + app.prefs_rt.snap = None; + app.prefs_rt.snap_lines.clear(); + app.move_mods = Default::default(); + true + } + ToolEvent::Move { x, y, .. } => { + let Some(d) = app.ui.corner_radius.as_mut() else { return false }; + let Some(g) = d.targets.get(d.grabbed) else { return true }; + let from = g.radii.get(d.corner).copied().unwrap_or(0.0).clamp(0.0, max_radius(g.rect)); + let r = radius_after(g.rect, d.corner, from, d.start, [x, y]); + // Every selected rectangle takes the same radius, within its own size. + for (t, now) in d.targets.iter().zip(d.now.iter_mut()) { + let r = r.min(max_radius(t.rect)); + if d.all { + *now = [r; 4]; + } else { + *now = t.radii; + if let Some(v) = now.get_mut(d.corner) { + *v = r; + } + } + } + true + } + ToolEvent::Up { x, y } => { + if app.ui.corner_radius.is_none() { + return false; + } + pointer(app, ToolEvent::Move { x, y, pressure: 1.0 }, mods); + let Some(d) = app.ui.corner_radius.take() else { return true }; + // One history step for every layer changed. + let key = format!("corner-radius-{}", app.ui.alloc_id()); + for (t, now) in d.targets.iter().zip(&d.now) { + if *now != t.radii + && let Err(e) = app.run("shape.edit", serde_json::json!({"layer": t.layer, "radii": now, "coalesce": key})) + { + app.ui.status = e; + app.ui.status_error = true; + break; + } + } + true + } + } +} + +/// The radius the dragged corner shows in the readout. +fn dragged_radius(d: &Drag) -> Option { + d.now.get(d.grabbed).and_then(|r| r.get(d.corner)).copied() +} + +/// The widgets of every selected live rectangle; while one is dragged, the new outlines and the +/// radius beside the pointer (`R: 25.0 px`), as in Photoshop. +pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32) { + let drag = app.ui.corner_radius.as_ref(); + if let Some(r) = drag.and_then(dragged_radius) + && let Some(at) = painter.ctx().input(|i| i.pointer.hover_pos()) + { + crate::canvas::draw_value_readout(app, painter.ctx(), "corner-radius-readout", at, ["R:"], [format!("{r:.1} px")]); + } + let z = zoom(app); + for t in targets(app) { + let now = drag.and_then(|d| d.targets.iter().position(|g| g.layer == t.layer).and_then(|i| d.now.get(i))); + let radii = now.copied().unwrap_or(t.radii); + if now.is_some() { + let path = photocraft_vector::shapes::rounded_rect(t.rect[0], t.rect[1], t.rect[2], t.rect[3], radii); + crate::vector_ui::draw_outline(painter, &path, to_scr, accent); + } + for i in 0..4 { + let p = to_scr(widget(t.rect, radii, i, z)); + painter.circle(p, 5.0, Color32::WHITE, Stroke::new(1.5, accent)); + painter.circle_filled(p, 2.5, accent); + } + } +} + +#[cfg(test)] +#[path = "corner_radius_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/corner_radius_tests.rs b/crates/ui-egui/src/corner_radius_tests.rs new file mode 100644 index 0000000000..b0b47c45be --- /dev/null +++ b/crates/ui-egui/src/corner_radius_tests.rs @@ -0,0 +1,186 @@ +//! TOOL-214-12: a live rectangle's corner radius widgets, as in Photoshop. + +use egui::Modifiers; +use photocraft_doc::LayerContent; +use photocraft_doc::vector::LiveShape; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +/// A 200 × 200 document at 100 % with a live rectangle at (40, 40), 120 × 80, radii 10. +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app.run("shape.create", json!({"kind": "roundedRect", "rect": [40, 40, 120, 80], "radii": 10})).unwrap(); + app.ui.tool = tool; + app +} + +fn radii(app: &PhotocraftApp) -> Option<[f64; 4]> { + let st = app.session.active()?; + let l = st.active_layer.and_then(|id| st.doc.layer(id))?; + match &l.content { + LayerContent::Shape(sh) => match sh.live { + Some(LiveShape::Rect { radii, .. }) => Some(radii), + _ => None, + }, + _ => None, + } +} + +fn steps(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.entries().len() +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], mods: Modifiers) { + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, mods); +} + +#[test] +fn dragging_a_widget_rounds_every_corner_in_one_step() { + let mut app = app(Tool::PathSelection); + let n = steps(&app); + // The top-left widget sits at the arc centre (50, 50); dragging it 15 px along the diagonal + // adds 15 to the radius. + drag(&mut app, [50.0, 50.0], [65.0, 65.0], Modifiers::NONE); + assert_eq!(radii(&app), Some([25.0; 4])); + assert_eq!(steps(&app), n + 1, "one history step"); + assert!(app.ui.corner_radius.is_none()); + assert!(app.session.undo()); + assert_eq!(radii(&app), Some([10.0; 4])); +} + +#[test] +fn alt_drags_one_corner_and_radii_stay_within_half_the_short_side() { + let mut app = app(Tool::Rectangle); + // The bottom-right widget, with ⌥: only that corner, capped at 40 (half of the height 80). + drag(&mut app, [150.0, 110.0], [20.0, 20.0], Modifiers::ALT); + assert_eq!(radii(&app), Some([10.0, 10.0, 40.0, 10.0])); + // Dragging out past the corner makes it sharp. + drag(&mut app, [150.0, 50.0], [190.0, 10.0], Modifiers::ALT); + assert_eq!(radii(&app), Some([10.0, 0.0, 40.0, 10.0])); + // The Rectangle tool drew no new shape. + assert_eq!(app.session.active().unwrap().doc.layers.len(), 2); +} + +#[test] +fn a_sharp_corner_keeps_a_grabbable_widget_and_a_click_changes_nothing() { + let mut app = app(Tool::PathSelection); + let id = app.session.active().unwrap().active_layer.unwrap().0; + app.run("shape.edit", json!({"layer": id, "radii": 0})).unwrap(); + let n = steps(&app); + // At 100 % the widget of a sharp corner is 10 px inside it. + drag(&mut app, [50.0, 50.0], [50.0, 50.0], Modifiers::NONE); + assert_eq!((radii(&app), steps(&app)), (Some([0.0; 4]), n), "a click commits nothing"); + drag(&mut app, [50.0, 50.0], [60.0, 60.0], Modifiers::NONE); + assert_eq!(radii(&app), Some([10.0; 4])); +} + +#[test] +fn only_live_rectangles_with_the_right_tools_take_the_press() { + for tool in [Tool::Move, Tool::DirectSelection, Tool::Brush] { + let mut a = app(tool); + assert!(!super::pointer(&mut a, ToolEvent::Down { x: 50.0, y: 50.0, pressure: 1.0 }, Modifiers::NONE), "{tool:?}"); + } + // ⌘/Ctrl leaves the press to the tool. + let mut a = app(Tool::PathSelection); + assert!(!super::pointer(&mut a, ToolEvent::Down { x: 50.0, y: 50.0, pressure: 1.0 }, Modifiers::COMMAND)); + // An ellipse has no corners. + let mut a = app_with(json!({"kind": "ellipse", "rect": [40, 40, 120, 80]})); + assert!(!super::pointer(&mut a, ToolEvent::Down { x: 50.0, y: 50.0, pressure: 1.0 }, Modifiers::NONE)); + // A press off the widgets is the tool's. + let mut a = app(Tool::PathSelection); + assert!(!super::pointer(&mut a, ToolEvent::Down { x: 100.0, y: 80.0, pressure: 1.0 }, Modifiers::NONE)); +} + +fn app_with(shape: serde_json::Value) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app.run("shape.create", shape).unwrap(); + app.ui.tool = Tool::PathSelection; + app +} + +/// Three live rectangles: (20, 20) 60 × 40 radii 5, (110, 20) 70 × 50 radii 0, and (20, 120) +/// 80 × 60 radii 0, the last one left unselected. Returns the ids, bottom first. +fn three(tool: Tool) -> (PhotocraftApp, Vec) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + let mut ids = Vec::new(); + for (rect, r) in [([20, 20, 60, 40], 5), ([110, 20, 70, 50], 0), ([20, 120, 80, 60], 0)] { + app.run("shape.create", json!({"kind": "rect", "rect": rect, "radii": r})).unwrap(); + ids.push(app.session.active().unwrap().active_layer.unwrap()); + } + app.run("layer.select", json!({"layer": ids[0].0})).unwrap(); + app.run("layer.select", json!({"layer": ids[1].0, "mode": "add"})).unwrap(); + app.ui.tool = tool; + (app, ids) +} + +fn radii_of(app: &PhotocraftApp, id: photocraft_doc::LayerId) -> [f64; 4] { + match &app.session.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => match sh.live { + Some(LiveShape::Rect { radii, .. }) => radii, + _ => panic!("live rect"), + }, + _ => panic!("shape"), + } +} + +#[test] +fn one_widget_rounds_every_selected_rectangle_alike_in_one_step() { + let (mut app, ids) = three(Tool::PathSelection); + let n = steps(&app); + // The second rectangle's top-left widget (sharp: 10 px inside, at (120, 30)), dragged 15 px + // in each direction: radius 15 on every corner of both selected rectangles. + drag(&mut app, [120.0, 30.0], [135.0, 45.0], Modifiers::NONE); + assert_eq!(radii_of(&app, ids[0]), [15.0; 4]); + assert_eq!(radii_of(&app, ids[1]), [15.0; 4]); + assert_eq!(radii_of(&app, ids[2]), [0.0; 4], "the unselected one is untouched"); + assert_eq!(steps(&app), n + 1, "one history step for both layers"); + assert!(app.session.undo()); + assert_eq!((radii_of(&app, ids[0]), radii_of(&app, ids[1])), ([5.0; 4], [0.0; 4])); +} + +#[test] +fn alt_changes_that_corner_on_each_and_each_stays_within_its_own_size() { + let (mut app, ids) = three(Tool::Rectangle); + // ⌥ on the first rectangle's top-left widget (radius 5, so drawn 10 px inside at (30, 30)), + // dragged far in: each rectangle's top-left corner only, capped at half of its own short side. + drag(&mut app, [30.0, 30.0], [100.0, 100.0], Modifiers::ALT); + assert_eq!(radii_of(&app, ids[0]), [20.0, 5.0, 5.0, 5.0]); + assert_eq!(radii_of(&app, ids[1]), [25.0, 0.0, 0.0, 0.0]); +} + +#[test] +fn the_move_tool_shows_and_drags_the_widgets_with_its_transform_controls() { + let (mut app, ids) = three(Tool::Move); + assert!(!super::pointer(&mut app, ToolEvent::Down { x: 120.0, y: 30.0, pressure: 1.0 }, Modifiers::NONE), "not without the controls"); + app.ui.tool_options.move_show_transform = true; + let before = app.session.active().unwrap().selected_layers(); + drag(&mut app, [120.0, 30.0], [130.0, 40.0], Modifiers::NONE); + assert_eq!((radii_of(&app, ids[0]), radii_of(&app, ids[1])), ([10.0; 4], [10.0; 4])); + assert_eq!(app.session.active().unwrap().selected_layers(), before, "the press neither moved nor reselected layers"); +} + +#[test] +fn the_cursor_and_readout_follow_the_widgets() { + let mut app = app(Tool::PathSelection); + // The corner radius cursor shows over a widget, not elsewhere. + assert!(super::hovers(&app, [50.0, 50.0])); + assert!(!super::hovers(&app, [100.0, 80.0])); + // While dragging it stays, and the readout shows the dragged corner's radius. + tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 50.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 62.0, y: 62.0, pressure: 1.0 }, Modifiers::NONE); + assert!(super::hovers(&app, [100.0, 80.0])); + assert_eq!(app.ui.corner_radius.as_ref().and_then(super::dragged_radius), Some(22.0)); + tool_event(&mut app, ToolEvent::Up { x: 62.0, y: 62.0 }, Modifiers::NONE); + assert!(!super::hovers(&app, [100.0, 80.0])); +} diff --git a/crates/ui-egui/src/crop_mode.rs b/crates/ui-egui/src/crop_mode.rs index 676c97dbec..a1264126bf 100644 --- a/crates/ui-egui/src/crop_mode.rs +++ b/crates/ui-egui/src/crop_mode.rs @@ -212,10 +212,11 @@ fn remap(p: [f64; 2], from: PressView, to: PressView, flip: bool) -> [f64; 2] { } /// A pointer point `p` the canvas mapped through the view as it is now, read in the press-time -/// view `press` (unchanged without one). +/// view `press` (unchanged without one, or while the view hasn't moved: the `f32` round trip +/// would nudge a click). pub fn to_press(app: &PhotocraftApp, press: Option, p: [f64; 2]) -> [f64; 2] { match (press, press_view(app)) { - (Some(press), Some(now)) => remap(p, now, press, app.ui.view.flip_horizontal), + (Some(press), Some(now)) if press != now => remap(p, now, press, app.ui.view.flip_horizontal), _ => p, } } @@ -239,7 +240,7 @@ pub fn control_point(app: &PhotocraftApp, p: [f64; 2]) -> [f64; 2] { _ => None, }; match (press, press_view(app)) { - (Some(press), Some(now)) if app.ui.tool == Tool::Crop => remap(p, press, now, app.ui.view.flip_horizontal), + (Some(press), Some(now)) if app.ui.tool == Tool::Crop && press != now => remap(p, press, now, app.ui.view.flip_horizontal), _ => p, } } @@ -452,6 +453,30 @@ mod tests { assert!(((c1[0] - c0[0]).hypot(c1[1] - c0[1]) - 30.0).abs() < 1e-3); } + /// A `ui.pointer` click (down and up at the same point, no move) changes nothing, upright or + /// turned: reading the point in the press view used to round it through `f32` and the view, + /// nudging the frame by a hair and pushing a box Undo step that undid nothing visible. + #[test] + fn a_ui_pointer_click_changes_nothing() { + let ctx = egui::Context::default(); + for deg in [0.0, -30.0, 41.5] { + let mut app = app(); + with_frame(&mut app); + app.ui.crop_angle = deg; + sync_frame(&mut app); + let (rect, angle) = (app.ui.crop_rect, app.ui.crop_angle); + // Inside the frame (moves the image) and outside it (turns the image). + for p in [[70.3, 50.7], [55.17, 41.9], [83.01, 59.33], [150.3, 50.7], [10.9, 90.1]] { + let ev = json!([{"kind": "down", "x": p[0], "y": p[1]}, {"kind": "up", "x": p[0], "y": p[1]}]); + let (req, _rx) = crate::control::ControlRequest::new("ui.pointer", json!({"events": ev})); + let crate::control::Outcome::Done(v) = crate::control::handle(&mut app, &ctx, &req) else { panic!("ui.pointer didn't finish") }; + assert_eq!(v["ok"], true, "{v}"); + assert_eq!((app.ui.crop_rect, app.ui.crop_angle), (rect, angle), "click at {p:?}, frame at {deg}°"); + assert_eq!(crate::crop_ui::is_enabled(&app, "edit.undo"), None, "click at {p:?}, frame at {deg}°: a box Undo step"); + } + } + } + #[test] fn the_readout_is_the_images_turn_clockwise_positive() { let mut app = app(); 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 54ec3795d2..00700ae5c0 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, @@ -881,8 +881,9 @@ pub fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option { None => hit_turned(app.ui.crop_rect?, deg, p, tolerance(app)), }; Some(match h { - // The arrow as the handle faces on screen: the frame's turn plus the view's. - Hit::Handle(hx, hy) => resize_icon(hx, hy, deg + f64::from(crate::crop_mode::view_rotation(app))), + // The arrow as the handle faces on screen: the frame's turn plus the view's, mirrored when + // the view is flipped. + Hit::Handle(hx, hy) => resize_icon(hx, hy, deg + f64::from(crate::crop_mode::view_rotation(app)), app.ui.view.flip_horizontal), // Inside the untouched default frame a drag draws a new one (`pointer`), so nothing moves (#4461). Hit::Inside if app.crop.default_frame => CursorIcon::Crosshair, Hit::Inside => CursorIcon::Move, @@ -947,9 +948,12 @@ pub fn draw_turn_cursor(ctx: &egui::Context, p: egui::Pos2, toward: egui::Vec2) } /// The resize arrow for handle (`hx`, `hy`) of a frame turned `deg` degrees: the handle's outward -/// direction, turned with the frame, to the nearest of the four arrows. -fn resize_icon(hx: i8, hy: i8, deg: f64) -> CursorIcon { +/// direction, turned with the frame and mirrored by a flipped view (`flip`), to the nearest of the +/// four arrows. +fn resize_icon(hx: i8, hy: i8, deg: f64, flip: bool) -> CursorIcon { let d = turn([f64::from(hx), f64::from(hy)], [0.0, 0.0], deg); + // View › Flip Horizontal mirrors the screen after the turn. + let d = if flip { [-d[0], d[1]] } else { d }; let a = d[1].atan2(d[0]).rem_euclid(std::f64::consts::PI); match ((a / std::f64::consts::FRAC_PI_4).round() as i64).rem_euclid(4) { 0 => CursorIcon::ResizeHorizontal, @@ -1203,10 +1207,10 @@ mod tests { let b = turned_bounds(r, 90.0); assert!((b[0] - 50.0).abs() < 1e-9 && (b[3] - 80.0).abs() < 1e-9, "{b:?}"); // Handle arrows turn with the frame. - assert_eq!(resize_icon(1, 0, 0.0), CursorIcon::ResizeHorizontal); - assert_eq!(resize_icon(1, 0, 90.0), CursorIcon::ResizeVertical); - assert_eq!(resize_icon(1, 0, 45.0), CursorIcon::ResizeNwSe); - assert_eq!(resize_icon(-1, -1, 90.0), CursorIcon::ResizeNeSw); + assert_eq!(resize_icon(1, 0, 0.0, false), CursorIcon::ResizeHorizontal); + assert_eq!(resize_icon(1, 0, 90.0, false), CursorIcon::ResizeVertical); + assert_eq!(resize_icon(1, 0, 45.0, false), CursorIcon::ResizeNwSe); + assert_eq!(resize_icon(-1, -1, 90.0, false), CursorIcon::ResizeNeSw); assert_eq!(normalized_angle(540.0), 180.0); assert_eq!(normalized_angle(-190.0), 170.0); assert_eq!(normalized_angle(f64::NAN), 0.0); @@ -1272,6 +1276,29 @@ mod tests { } } + /// Fill › Content-Aware Fill (#3823): ↵ on an extending frame fills the added canvas from the + /// image instead of the background colour; Background (Default) keeps the colour. + #[test] + fn commit_passes_the_fill_choice() { + for content_aware in [false, true] { + let mut app = app(SampleType::U16); + app.run("tools.setColors", json!({"foreground": "#808080", "background": "#000000"})).unwrap(); + app.run("edit.fill", json!({"contents": "foreground"})).unwrap(); + app.ui.tool_options.crop_content_aware = content_aware; + app.ui.crop_rect = Some([-10.0, -10.0, 210.0, 110.0]); + app.crop.default_frame = false; + crate::canvas::commit_crop(&mut app); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.size.width, st.doc.size.height), (220, 120)); + let corner = st.doc.layers[0].surface().unwrap().rgba(0, 0); + if content_aware { + assert!((corner[0] - 0.5).abs() < 0.05, "filled from the grey image: {corner:?}"); + } else { + assert!(corner[0] < 0.05, "the background colour: {corner:?}"); + } + } + } + #[test] fn moving_the_frame_snaps_its_edges() { let mut app = app(SampleType::U8); @@ -1697,6 +1724,22 @@ mod tests { } } + #[test] + fn enter_during_a_drag_ends_the_drag() { + // ↵ while the frame is still being dragged commits it; the release after must not bring + // back a frame in the old document's coordinates as a pending crop. + let mut app = app(SampleType::U8); + app.ui.crop_rect = Some([20.0, 20.0, 120.0, 70.0]); + tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 40.0, pressure: 1.0 }, NONE); + tool_event(&mut app, ToolEvent::Move { x: 60.0, y: 40.0, pressure: 1.0 }, NONE); + crate::canvas::commit_crop(&mut app); + tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 40.0 }, NONE); + ensure_frame(&mut app); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(100, 50)); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 100.0, 50.0])); + assert!(!pending(&app)); + } + #[test] fn degenerate_and_bad_input_never_panics() { let mut app = app(SampleType::U8); @@ -1786,6 +1829,12 @@ mod tests { assert_eq!(cursor(&app, [60.0, 60.0]), Some(CursorIcon::ResizeVertical)); assert_eq!(cursor(&app, [60.0, 40.0]), Some(CursorIcon::Move)); assert_eq!(cursor(&app, [150.0, 40.0]), Some(CursorIcon::None), "outside: the drawn turn cursor"); + // View › Flip Horizontal shows the top-left corner at the top right: its arrow mirrors too. + app.ui.view.flip_horizontal = true; + assert_eq!(cursor(&app, [20.0, 20.0]), Some(CursorIcon::ResizeNeSw), "flipped view"); + assert_eq!(cursor(&app, [100.0, 20.0]), Some(CursorIcon::ResizeNwSe), "flipped view"); + assert_eq!(cursor(&app, [60.0, 60.0]), Some(CursorIcon::ResizeVertical), "flipped view"); + app.ui.view.flip_horizontal = false; app.ui.tool = Tool::Brush; assert_eq!(cursor(&app, [60.0, 40.0]), None); } @@ -1916,6 +1965,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/dds_options_ui.rs b/crates/ui-egui/src/dds_options_ui.rs new file mode 100644 index 0000000000..c795643a7d --- /dev/null +++ b/crates/ui-egui/src/dds_options_ui.rs @@ -0,0 +1,148 @@ +//! DDS Options: the compression and mip chain File › Save As and Save a Copy ask for before +//! writing a DirectDraw Surface texture (#1327). Photoshop has no DDS writer of its own (a plug-in +//! adds one), so this follows the texture tools' usual choices: uncompressed, or BC1/BC2/BC3 +//! (DXT1/DXT3/DXT5), and whether to generate mipmaps (see `photocraft_io::DdsOptions`). +//! +//! The save is parked in [`Prompt`] while the modal is up (egui is immediate-mode) and written +//! once answered. Cancel drops the save. Scripts skip the prompt by passing `ddsCompression` or +//! `ddsMipmaps`. + +use photocraft_io::{DdsCompression, DdsOptions}; + +use crate::{ExportSettings, PhotocraftApp}; + +/// A save waiting on the DDS Options answer. +pub struct Prompt { + /// The document that requested the save, even if tabs change while this is open. + doc: photocraft_doc::DocId, + /// Where the texture goes. + pub path: String, + /// The options chosen, starting uncompressed without mipmaps. + pub options: DdsOptions, + /// Save a Copy: the document keeps its path and unsaved changes. + pub copy: bool, + /// The step the answered save is recorded as (`file.saveAs` or `file.saveACopy`), so an + /// action being recorded keeps it; `file.save` when File › Save confirmed the document's own + /// texture (#3531), which plays back on the file the action is played on. + pub record: &'static str, +} + +/// `true` when `path` names a DDS texture. +pub fn wants_prompt(path: &str) -> bool { + photocraft_engine::file_cmds::extension(path).and_then(|e| photocraft_codecs::from_extension(&e)) == Some(photocraft_codecs::Format::Dds) +} + +/// The DDS options a command or control call asks for (`"ddsCompression"`, `"ddsMipmaps"`), +/// which skip the prompt. +pub fn options_param(params: &serde_json::Value, cmd: &str) -> Result, String> { + photocraft_engine::file_cmds::dds_param(params, cmd).map_err(|e| e.to_string()) +} + +/// The params a save with `options` is recorded with. +pub fn record(options: DdsOptions, step: &mut serde_json::Value) { + step["ddsCompression"] = options.compression.name().into(); + step["ddsMipmaps"] = options.mipmaps.into(); +} + +/// Parks the save behind the prompt. A prompt already waiting is never replaced. +pub fn park(app: &mut PhotocraftApp, path: String, copy: bool, record: &'static str) -> Result<(), String> { + if app.save_options_open() { + return Err("Answer the TIFF, Targa or DDS Options before starting another save".into()); + } + let doc = app.active_doc_id()?; + app.dds_options = Some(Prompt { doc, path, options: DdsOptions::default(), copy, record }); + Ok(()) +} + +/// The block compressions go by their format names, which stay untranslated like other +/// technology names. +fn label(c: DdsCompression) -> &'static str { + match c { + DdsCompression::None => tl!("None"), + DdsCompression::Bc1 => "BC1 / DXT1", + DdsCompression::Bc2 => "BC2 / DXT3", + DdsCompression::Bc3 => "BC3 / DXT5", + } +} + +pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(p) = &mut app.dds_options else { return }; + let (mut ok, mut cancel) = (false, false); + let mut options = p.options; + let modal = egui::Modal::new(egui::Id::new("dds-options")).show(ctx, |ui| { + ui.set_max_width(340.0); + ui.label(egui::RichText::new(tl!("DDS Options")).font(crate::theme::semibold(15.0))); + ui.add_space(4.0); + crate::widgets::hairline(ui); + ui.add_space(8.0); + crate::widgets::section_label(ui, tl!("Compression")); + for c in DdsCompression::ALL { + ui.radio_value(&mut options.compression, c, label(c)); + } + ui.add_space(8.0); + crate::widgets::checkbox(ui, &mut options.mipmaps, tl!("Generate Mipmaps")); + ui.add_space(12.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 10.0; + ok = crate::widgets::primary_button(ui, tl!("OK"), 84.0).clicked(); + cancel = crate::widgets::secondary_button(ui, tl!("Cancel"), 84.0).clicked(); + }); + }); + cancel |= modal.should_close(); + p.options = options; + // A focused button takes Enter as its click without consuming the key: Enter on Cancel cancels. + if ok || (!cancel && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter))) { + let Some(p) = app.dds_options.take() else { return }; + let settings = ExportSettings { dds: p.options, ..app.save_settings() }; + let mut step = if p.record == "file.save" { serde_json::json!({}) } else { serde_json::json!({"path": p.path}) }; + record(p.options, &mut step); + let written = app.with_document(p.doc, |app| app.write_document(p.path, &settings, p.copy)); + if written.is_ok() { + // An action being recorded keeps the Save As or Save a Copy with this answer. + app.session.journal.push((p.record.into(), step)); + } + match written { + Err(e) => { + app.ui.status = crate::i18n::fmt(tl!("Save failed: {error}"), &[("error", &e)]); + app.ui.status_error = true; + } + Ok(Some(_)) if !p.copy => crate::discard_ui::saved_document(app, ctx, p.doc), + // A background save releases the close prompt when it finishes (`jobs_ui`). + Ok(_) => {} + } + } else if cancel { + app.dds_options = None; + app.ui.status = tl!("Save cancelled").into(); + app.ui.status_error = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dds_names() { + assert!(wants_prompt("a.dds")); + assert!(wants_prompt("dir/sub/a.DDS")); + assert!(wants_prompt("C:\\x\\y.dds")); + assert!(!wants_prompt("a.dds.psd")); + assert!(!wants_prompt(".dds")); + assert!(!wants_prompt("a.png")); + } + + #[test] + fn options_params() { + use serde_json::json; + assert_eq!(options_param(&json!({}), "file.saveAs"), Ok(None)); + assert_eq!(options_param(&json!({"ddsCompression": "bc3"}), "file.saveAs"), Ok(Some(DdsOptions { compression: DdsCompression::Bc3, mipmaps: false }))); + assert_eq!(options_param(&json!({"ddsMipmaps": true}), "file.saveAs"), Ok(Some(DdsOptions { compression: DdsCompression::None, mipmaps: true }))); + for bad in [json!({"ddsCompression": "bc7"}), json!({"ddsCompression": 3}), json!({"ddsMipmaps": "yes"})] { + assert!(options_param(&bad, "file.saveAs").is_err(), "{bad}"); + } + let mut step = json!({"path": "a.dds"}); + record(DdsOptions { compression: DdsCompression::Bc1, mipmaps: true }, &mut step); + assert_eq!(step, json!({"path": "a.dds", "ddsCompression": "bc1", "ddsMipmaps": true})); + assert_eq!(options_param(&step, "file.saveAs"), Ok(Some(DdsOptions { compression: DdsCompression::Bc1, mipmaps: true }))); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0530031f93..e27411b7e0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -104,11 +104,22 @@ fn older_dialog_above(ctx: &egui::Context, live: egui::LayerId, dialogs: &[Dialo ctx.memory(|m| m.layer_ids().skip_while(|l| *l != live).skip(1).any(|l| others.contains(&l))) } +/// The size a resizable dialog opens at, or `None` for a dialog sized by its contents. +fn resizable_dialog(fields: &serde_json::Map) -> Option { + if crate::prefs_ui::is_preferences(fields) { + Some(crate::prefs_ui::PREFERENCES_SIZE) + } else if fields.contains_key("__web") { + Some(egui::vec2(960.0, 900.0)) + } else { + None + } +} + fn show_modal( modal: egui::Modal, ctx: &egui::Context, id: egui::Id, - resizable: bool, + resizable: Option, interactive: bool, contents: impl FnOnce(&mut egui::Ui) -> T, ) -> egui::ModalResponse { @@ -117,20 +128,35 @@ fn show_modal( if !interactive { ui.disable(); } - if resizable { + if let Some(size) = resizable { let max = (ctx.content_rect().size() - egui::vec2(40.0, 40.0)).max(egui::vec2(560.0, 400.0)); - egui::Resize::default() - .id(id.with("resize")) - .default_size(egui::vec2(960.0, 900.0).min(max)) - .min_size(egui::vec2(560.0, 400.0)) - .max_size(max) - .show(ui, contents) + egui::Resize::default().id(id.with("resize")).default_size(size.min(max)).min_size(egui::vec2(560.0, 400.0)).max_size(max).show(ui, contents) } else { contents(ui) } }) } +/// Whether dialogs have a close box (×) in their title bar (#5496). Photoshop's modal dialogs have +/// one on Windows, where clicking it is Cancel, and none on macOS; Linux and the web follow Windows. +pub(crate) const CLOSE_BOX: bool = !cfg!(target_os = "macos"); + +/// The side of the close box's square, in points. +const CLOSE_BOX_SIZE: f32 = 20.0; + +/// The close box's square: flush with the title band's right end `right`, centred on the title +/// row at height `centre_y`. +fn close_box_rect(right: f32, centre_y: f32) -> egui::Rect { + egui::Rect::from_min_size(egui::pos2(right - CLOSE_BOX_SIZE, centre_y - CLOSE_BOX_SIZE / 2.0), egui::vec2(CLOSE_BOX_SIZE, CLOSE_BOX_SIZE)) +} + +/// Draws a title bar's close box in `rect`, the same × icon button the floating panels close with, +/// and says whether it was clicked. Its own child `Ui`, so the title row's layout is unchanged. +pub(crate) fn close_box(ui: &mut egui::Ui, rect: egui::Rect) -> bool { + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::right_to_left(egui::Align::Center))); + crate::icons::button(&mut child, "x", CLOSE_BOX_SIZE, false, tl!("Close")).clicked() +} + pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { crate::layer_style::color_picker::prune(app); crate::type_panels_ui::color_picker::prune(app); @@ -159,6 +185,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let place = place_id(&d); let start = pinned.or_else(|| ctx.data_mut(|m| m.get_persisted::(place))).or_else(|| crate::blur_pins::first_place(&d.fields, ctx)); let mut drag = egui::Vec2::ZERO; + let mut close_clicked = false; let mut sizing = false; let area = match start { Some(offset) => egui::Modal::default_area(id).anchor(egui::Align2::LEFT_TOP, offset), @@ -182,16 +209,21 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // Photoshop doesn't dim the window behind dialogs: previews must be judged at true contrast. let web = fields.contains_key("__web"); let web_popup_open = web && egui::Popup::is_any_open(ctx); - let modal = show_modal(modal, ctx, id, web, interactive, |ui| { + let resizable = resizable_dialog(&fields); + let modal = show_modal(modal, ctx, id, resizable, interactive, |ui| { sizing = ui.is_sizing_pass(); let available = ui.available_size(); // A dialog of a few short fields (`__compact`, Fit Image) is narrower (#4673): the title // rule and the button row fill the width they are given, so it needs its own maximum too. let compact = d.fields.contains_key("__compact"); + // The curve editor caps its graph allocation at 300 points (#5113). + let curves = fields.get("__adjust").and_then(Value::as_str) == Some("curves"); if !compact { - ui.set_min_width(380.0); + ui.set_min_width(if curves { 300.0 } else { 380.0 }); } - let wide = crate::prefs_ui::width(&d.fields, (ctx.content_rect().width() - 48.0).max(380.0)); + // A resizable dialog's contents fill its resizable area (#7420), the others the window. + let room = if resizable.is_some() { available.x } else { (ctx.content_rect().width() - 48.0).max(380.0) }; + let wide = crate::prefs_ui::width(&d.fields, room); if let Some(w) = wide { ui.set_min_width(w.min(460.0)); } @@ -211,6 +243,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { 600.0 } else if compact { 260.0 + } else if curves { + 300.0 } else { 440.0 })); @@ -234,8 +268,15 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let t = if crate::color_picker_ui::owns(&fields) { ui.add_sized(egui::vec2(ui.available_width(), 22.0), label).rect } else { ui.add(label).rect }; // The whole title band drags (#1921): the full width plus a little of the popup's // padding around it and the gap above the hairline, but none of the controls below. - let bar = egui::Rect::from_min_max(t.min - egui::vec2(7.0, 7.0), egui::pos2(ui.max_rect().right() + 7.0, t.bottom() + 4.0)); + // The close box (#5496) sits at the band's right end and is left out of the drag area: + // egui would let a drag that starts on a button move the dialog. + let close_rect = CLOSE_BOX.then(|| close_box_rect(ui.max_rect().right(), t.center().y)); + let bar_right = close_rect.map_or(ui.max_rect().right() + 7.0, |r| r.left()); + let bar = egui::Rect::from_min_max(t.min - egui::vec2(7.0, 7.0), egui::pos2(bar_right, t.bottom() + 4.0)); drag = ui.interact(bar, id.with("title"), egui::Sense::drag()).drag_delta(); + if let Some(r) = close_rect { + close_clicked = close_box(ui, r); + } ui.add_space(4.0); crate::widgets::hairline(ui); ui.add_space(8.0); @@ -285,6 +326,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), @@ -294,10 +336,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { DialogKind::Command if crate::tool_reset::owns(&fields) => crate::tool_reset::body(ui), DialogKind::Command if crate::crop_presets::owns(&fields) => crate::crop_presets::body(app, ui, &mut fields), DialogKind::Command if crate::variables_ui::owns(&fields) => crate::variables_ui::body(app, ui, &mut fields), - DialogKind::Command if crate::file_ui::owns(&fields) => crate::file_ui::body(app, ui, &mut fields), + DialogKind::Command if crate::file_ui::owns(&fields) => crate::file_ui::body(app, ui, &mut fields, d.id), 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), @@ -365,6 +410,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 { @@ -450,11 +497,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ctx.data_mut(|m| m.insert_persisted(place, offset)); } } - // Esc cancels (topmost dialog, no popup open). A click outside does nothing: Photoshop keeps - // the dialog, and the pointer may be panning or zooming the canvas under it. + // Esc and the title bar's close box cancel (topmost dialog, no popup open for Esc). A click + // outside does nothing: Photoshop keeps the dialog, and the pointer may be panning or + // zooming the canvas under it. if interactive && outcome.is_none() - && (modal.response.should_close() + && (close_clicked + || modal.response.should_close() || (modal.is_top_modal && !modal.any_popup_open && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)))) { outcome = Some(false); @@ -576,6 +625,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), @@ -614,7 +664,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { if result.is_ok() { crate::filter_dialog::remember(app, &cmd, &d.fields); // A step dialog of a playing action: the action continues after it. - crate::actions::resume(app, &d.fields); + crate::actions::resume(app, &d.fields)?; } result } @@ -727,6 +777,83 @@ mod tests { assert_eq!(crate::canvas::committed_filter_preview(&mut app, 0), None); } + /// #5496: a pointer click on the title bar's close box does what Cancel does: the dialog + /// closes, the live preview goes and nothing reaches the document or the journal. On macOS + /// dialogs have no close box. + #[test] + fn the_title_bar_close_box_cancels_like_cancel() { + use egui_kittest::kittest::Queryable; + let close = |button: &str| { + let (mut app, _) = filter_dialog_with_preview(false); + let revision = app.session.active().unwrap().revision; + let journal = app.session.journal.len(); + // The fonts are set up after the harness's first frame: the dialog appears after it. + let dialogs = std::mem::take(&mut app.ui.dialogs); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state(|ui, app| show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.state_mut().ui.dialogs = dialogs; + h.run_steps(4); + assert!(h.state().filter_preview.is_some(), "the preview is on screen"); + if !CLOSE_BOX { + assert_eq!(h.query_all_by_label("Close").count(), 0, "no close box on macOS"); + } + let target = h.query_by_label(button).map(|n| n.rect().center())?; + h.hover_at(target); + h.drag_at(target); + h.drop_at(target); + h.run_steps(3); + let app = h.state(); + Some(( + app.ui.dialogs.is_empty(), + app.filter_preview.is_none(), + app.session.active().unwrap().revision == revision, + app.session.journal.len() == journal, + )) + }; + let cancel = close("Cancel"); + assert_eq!(cancel, Some((true, true, true, true)), "Cancel closes, drops the preview and changes nothing"); + if CLOSE_BOX { + assert_eq!(close("Close"), cancel, "the × is Cancel"); + } + } + + /// #5496: the close box sits at the title band's right end, and the band stays a drag handle + /// around it: a drag that starts on the × neither moves nor closes the dialog, and the band + /// just left of the × still moves it. + #[test] + fn the_close_box_and_the_title_drag_keep_apart() { + use egui_kittest::kittest::Queryable; + if !CLOSE_BOX { + return; + } + let mut h = dialog_harness(egui::vec2(1280.0, 800.0), "filter.blur.gaussianBlur"); + let title = h.get_by_label("Gaussian Blur").rect(); + let x = h.get_by_label("Close").rect(); + let frame = rects(&h.ctx)[0]; + assert!((x.center().y - title.center().y).abs() < 2.0 && x.left() > title.right(), "the × is on the title row: {x:?}, {title:?}"); + assert!(frame.right() - x.right() < 16.0, "at its right end: {x:?} in {frame:?}"); + let drag = |h: &mut egui_kittest::Harness<'static, PhotocraftApp>, from: egui::Pos2, to: egui::Pos2| { + h.hover_at(from); + h.drag_at(from); + h.run_steps(2); + for i in 1..=8 { + h.hover_at(from + (to - from) * (i as f32 / 8.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); + }; + drag(&mut h, x.center(), x.center() + egui::vec2(-80.0, 60.0)); + assert_eq!(h.state().ui.dialogs.len(), 1, "a drag from the × doesn't close the dialog"); + assert!((h.get_by_label("Gaussian Blur").rect().min - title.min).length() < 1.0, "nor move it"); + let x = h.get_by_label("Close").rect(); + let beside = x.left_center() - egui::vec2(4.0, 0.0); + drag(&mut h, beside, beside + egui::vec2(-120.0, 40.0)); + assert_eq!(h.state().ui.dialogs.len(), 1, "a title drag doesn't close the dialog"); + let moved = h.get_by_label("Gaussian Blur").rect().min - title.min; + assert!((moved - egui::vec2(-120.0, 40.0)).length() < 2.0, "the band beside the × moved the dialog: {moved:?}"); + } + #[test] fn color_picker_has_inset_content_and_actions_at_the_right_edge() { use egui_kittest::{Harness, kittest::Queryable}; @@ -998,3 +1125,80 @@ mod tests { } } } + +#[cfg(test)] +mod preference_resize_tests { + use super::*; + + #[test] + fn preferences_can_resize_while_standard_native_dialogs_keep_their_layout() { + let prefs = json!({"__prefsui": "prefs"}); + let shortcuts = json!({"__prefsui": "shortcuts"}); + let ordinary = json!({}); + let web = json!({"__web": true}); + + assert_eq!(resizable_dialog(prefs.as_object().unwrap()), Some(crate::prefs_ui::PREFERENCES_SIZE)); + assert_eq!(resizable_dialog(shortcuts.as_object().unwrap()), None); + assert_eq!(resizable_dialog(ordinary.as_object().unwrap()), None); + assert_eq!(resizable_dialog(web.as_object().unwrap()), Some(egui::vec2(960.0, 900.0))); + } + + /// Drags the dialog's resize grip (its frame's bottom-right corner) by `by`. + fn drag_corner(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, by: egui::Vec2) { + let corner = frame(h).max - egui::vec2(14.0, 14.0); + h.event(egui::Event::PointerMoved(corner)); + h.step(); + h.event(egui::Event::PointerButton { pos: corner, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + h.event(egui::Event::PointerMoved(corner + by)); + h.step(); + h.event(egui::Event::PointerButton { pos: corner + by, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(4); + } + + fn frame(h: &egui_kittest::Harness<'_, PhotocraftApp>) -> egui::Rect { + let rects: Vec = h.ctx.data(|m| m.get_temp(egui::Id::new(RECTS))).unwrap(); + rects[0] + } + + /// The button row sits in the frame's bottom-right corner (its order follows the platform): + /// the contents fill the dialog, no empty space. + fn assert_buttons_in_corner(h: &egui_kittest::Harness<'_, PhotocraftApp>, when: &str) { + use egui_kittest::kittest::Queryable; + let f = frame(h); + let row = ["OK", "Cancel", "Apply"].map(|l| h.get_by_label(l).rect()).into_iter().reduce(|a, b| a.union(b)).unwrap(); + // Inside the frame (not past its edge) and near it. + let near = |gap: f32| (0.0..24.0).contains(&gap); + assert!(near(f.bottom() - row.bottom()) && near(f.right() - row.right()), "{when}: frame {f:?}, buttons {row:?}"); + } + + #[test] + fn preferences_contents_follow_the_dialog_when_it_is_resized() { + // #7420: the dialog resized but its contents kept a size from the window, leaving the + // buttons part-way up a dialog that opened, or was dragged, larger than them. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + crate::prefs_ui::open_preferences(&mut app, "interface"); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_ui_state( + |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { + show(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(5); + assert_buttons_in_corner(&h, "on open"); + let opened = frame(&h); + drag_corner(&mut h, egui::vec2(220.0, 80.0)); + assert!(frame(&h).width() > opened.width() + 150.0 && frame(&h).height() > opened.height() + 50.0, "wider: {:?}", frame(&h)); + assert_buttons_in_corner(&h, "wider"); + drag_corner(&mut h, egui::vec2(-500.0, -300.0)); + let smaller = frame(&h); + assert!(smaller.width() < opened.width() - 200.0 && smaller.height() < opened.height() - 150.0, "smaller: {smaller:?}"); + assert_buttons_in_corner(&h, "smaller"); + // The contents never ask for more than the dialog gives them, so it keeps its size. + h.run_steps(10); + assert_eq!(frame(&h), smaller); + } +} diff --git a/crates/ui-egui/src/direct_select.rs b/crates/ui-egui/src/direct_select.rs index addd5fd0de..b27d9ea466 100644 --- a/crates/ui-egui/src/direct_select.rs +++ b/crates/ui-egui/src/direct_select.rs @@ -67,6 +67,10 @@ pub struct DirectSelection { pub segment: Option<[usize; 2]>, #[serde(skip)] pub drag: Option, + /// The history step the drag's commit joins (`coalesce`): a Curvature Pen click on a segment + /// adds the anchor it then drags, and both are one step. + #[serde(skip)] + pub coalesce: Option, } #[derive(Clone, Debug, PartialEq)] @@ -80,6 +84,8 @@ pub enum Drag { #[derive(Clone, Debug, PartialEq)] pub enum Edit { Anchors(Vec<[usize; 2]>), + /// The Curvature Pen dragging anchors: the smooth knots around them reflow. + Curvature(Vec<[usize; 2]>), /// A handle, where it was at the press, and whether it moves alone (⌥). Handle([usize; 2], Handle, Point, bool), Segment([usize; 2], f64), @@ -88,11 +94,17 @@ pub enum Edit { } impl Edit { + /// The drag's end with ⇧ held: moving anchors locks to multiples of 45°, as in Photoshop (#5475). + fn constrain(&self, start: [f64; 2], p: [f64; 2], shift: bool) -> [f64; 2] { + if shift && matches!(self, Edit::Anchors(_)) { crate::stroke_constraint::snap45(start, p) } else { p } + } + /// The edit for a drag from `start` to `end`, as a command and its params (without target). fn command(&self, start: [f64; 2], end: [f64; 2]) -> (&'static str, Value) { let d = [end[0] - start[0], end[1] - start[1]]; match self { Edit::Anchors(a) => ("path.moveAnchors", json!({"anchors": a, "move": d})), + Edit::Curvature(a) => ("path.moveAnchors", json!({"anchors": a, "move": d, "curvature": true})), Edit::Handle([s, k], h, from, independent) => { ("path.moveHandle", json!({"subpath": s, "knot": k, "handle": h.name(), "to": [from.x + d[0], from.y + d[1]], "independent": independent})) } @@ -107,6 +119,7 @@ impl Edit { let mut p = base.clone(); match self { Edit::Anchors(a) => edit::move_anchors(&mut p, a, d), + Edit::Curvature(a) => edit::move_anchors_curvature(&mut p, a, d), Edit::Handle(k, h, from, independent) => edit::move_handle(&mut p, *k, *h, Point::new(from.x + d[0], from.y + d[1]), *independent), Edit::Segment(k, t) => edit::bend_segment(&mut p, *k, *t, d), Edit::Convert(k) => edit::convert_point(&mut p, *k, Some(Point::new(end[0], end[1]))), @@ -130,6 +143,12 @@ fn converting(tool: Tool, mods: Modifiers, pen_idle: bool) -> bool { !mods.command && (tool == Tool::ConvertPoint || (tool == Tool::Pen && mods.alt && pen_idle)) } +/// Is the Curvature Pen idle (no path being drawn, no ⌘/Ctrl)? Then it edits the active path in +/// place, as in Photoshop: a press on one of its anchors drags it, with no modifier. +fn curvature_idle(app: &PhotocraftApp, mods: Modifiers) -> bool { + app.ui.tool == Tool::CurvaturePen && !mods.command && app.ui.pen.as_ref().is_none_or(|p| p.knots.is_empty()) +} + /// The tools that take every pointer event on the canvas, not just a press that hits a path. fn owns(tool: Tool) -> bool { matches!(tool, Tool::DirectSelection | Tool::ConvertPoint) @@ -139,6 +158,7 @@ fn owns(tool: Tool) -> bool { /// Delete Anchor Point tools show it too: they edit the same paths. pub fn shows(app: &PhotocraftApp, mods: Modifiers) -> bool { direct(app.ui.tool, mods) + || curvature_idle(app, mods) || app.ui.direct_selection.drag.is_some() || matches!(app.ui.tool, Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint) } @@ -199,6 +219,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool let tool = app.ui.tool; match ev { ToolEvent::Down { x, y, .. } => { + if curvature_idle(app, mods) { + return curvature_press(app, [x, y]); + } let pen_idle = app.ui.pen.is_none(); // Pen + ⌥ is Convert Point only while no path is being drawn. let convert = converting(tool, mods, pen_idle); @@ -220,7 +243,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool let ds = &mut app.ui.direct_selection; match &mut ds.drag { Some(Drag::Edit { base, edit, start, end, preview, .. }) => { - *end = [x, y]; + *end = edit.constrain(*start, [x, y], mods.shift); *preview = edit.preview(base, *start, *end); } Some(Drag::Marquee { end, .. }) => *end = [x, y], @@ -230,7 +253,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool } ToolEvent::Up { x, y } => match app.ui.direct_selection.drag.take() { Some(Drag::Edit { target, base, edit, start, .. }) => { - release(app, target, &base, &edit, start, [x, y]); + let end = edit.constrain(start, [x, y], mods.shift); + release(app, target, &base, &edit, start, end); + app.ui.direct_selection.coalesce = None; true } Some(Drag::Marquee { start, add, .. }) => { @@ -326,6 +351,83 @@ fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) - true } +/// What the idle Curvature Pen has under document point `p` on the active paths: an anchor or a +/// segment, with the path it is on. Handles don't count. +fn curvature_target(app: &PhotocraftApp, p: [f64; 2]) -> Option<(PathRef, Path, Hit)> { + let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); + active_paths(app).into_iter().find_map(|(r, path)| match edit::hit(&path, Point::new(p[0], p[1]), tol, &[]) { + Some(h @ (Hit::Anchor(_) | Hit::Segment(..))) => Some((r, path, h)), + _ => None, + }) +} + +/// What a Curvature Pen press at `p` would do to an existing point, for its cursor: the anchor +/// under it (idle: one of the active path's; drawing: one of the path being drawn), or the active +/// path's segment it would add an anchor to. +pub fn curvature_hover(app: &PhotocraftApp, p: [f64; 2], mods: Modifiers) -> Option { + if app.ui.tool != Tool::CurvaturePen || mods.command { + return None; + } + if !curvature_idle(app, mods) { + return crate::vector_ui::curvature_anchor_at(app, p[0], p[1]).map(|i| Hit::Anchor([0, i])); + } + curvature_target(app, p).map(|(.., h)| h) +} + +/// The idle Curvature Pen's double-click on an anchor of the active path: smooth ↔ corner, and +/// the subpath reflows (one `path.convertPoint` with `curvature`). +pub(crate) fn curvature_toggle_active(app: &mut PhotocraftApp, p: [f64; 2]) { + let Some((target, _, Hit::Anchor([s, k]))) = curvature_target(app, p) else { return }; + let mut params = json!({"subpath": s, "knot": k, "curvature": true}); + if let (Some(o), Some(tp)) = (params.as_object_mut(), target.params().as_object()) { + o.extend(tp.clone()); + } + if let Err(e) = app.run("path.convertPoint", params) { + app.ui.status = e; + app.ui.status_error = true; + } + app.ui.direct_selection = DirectSelection { target: Some(target), anchors: vec![[s, k]], ..Default::default() }; +} + +/// Curvature Pen press with no path being drawn, on the active path: an anchor is grabbed and +/// dragged; a segment gets a new anchor there (`path.addAnchor`), dragged the same way. Either +/// way the curve reflows smoothly through the moved anchor, as in Photoshop (`path.moveAnchors` +/// with `curvature` on release), and an added anchor and its drag are one history step. False +/// when the press hits neither, so it starts a new path. +fn curvature_press(app: &mut PhotocraftApp, p: [f64; 2]) -> bool { + let Some((target, mut base, hit)) = curvature_target(app, p) else { return false }; + let mut coalesce = None; + let k = match hit { + Hit::Anchor(k) => k, + Hit::Segment([s, k], t) => { + let key = format!("curvature-add-{}", app.ui.alloc_id()); + let mut params = json!({"subpath": s, "knot": k, "t": t.clamp(0.01, 0.99), "coalesce": key}); + if let (Some(o), Some(tp)) = (params.as_object_mut(), target.params().as_object()) { + o.extend(tp.clone()); + } + let added = match app.run("path.addAnchor", params) { + Ok(v) => v, + Err(e) => { + app.ui.status = e; + app.ui.status_error = true; + return true; + } + }; + let n = |f: &str| added["anchor"][f].as_u64().and_then(|v| usize::try_from(v).ok()); + let (Some(s), Some(k), Some(path)) = (n("subpath"), n("knot"), active_paths(app).into_iter().find(|(r, _)| *r == target).map(|(_, p)| p)) else { + return true; + }; + base = path; + coalesce = Some(key); + [s, k] + } + Hit::Handle(..) => return false, + }; + app.ui.direct_selection = DirectSelection { target: Some(target), anchors: vec![k], coalesce, ..Default::default() }; + app.ui.direct_selection.drag = Some(Drag::Edit { target, base, edit: Edit::Curvature(vec![k]), start: p, end: p, preview: None }); + true +} + /// End of an edit drag: commit it as one command. A click (no movement) commits nothing, except /// Pen + ⌥ on an anchor, which makes it a corner. The Pen path being drawn takes the edit directly. fn release(app: &mut PhotocraftApp, target: PathRef, base: &Path, edit: &Edit, start: [f64; 2], end: [f64; 2]) { @@ -352,6 +454,9 @@ fn release(app: &mut PhotocraftApp, target: PathRef, base: &Path, edit: &Edit, s if let (Some(p), Some(t)) = (params.as_object_mut(), target.params().as_object()) { p.extend(t.clone()); } + if let (Some(p), Some(key)) = (params.as_object_mut(), app.ui.direct_selection.coalesce.take()) { + p.insert("coalesce".into(), json!(key)); + } if let Err(e) = app.run(id, params) { app.ui.status = e; app.ui.status_error = true; @@ -418,7 +523,15 @@ pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { /// the marquee. pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32, paths: bool) { let ds = &app.ui.direct_selection; - for (r, path) in if paths { candidates(app) } else { Vec::new() } { + // The idle Curvature Pen shows the anchors of the path it edits, not every shape's. + let shown = if !paths { + Vec::new() + } else if curvature_idle(app, painter.ctx().input(|i| i.modifiers)) { + active_paths(app) + } else { + candidates(app) + }; + for (r, path) in shown { let path = match &ds.drag { Some(Drag::Edit { target, preview: Some(p), .. }) if *target == r => p.clone(), _ => path, diff --git a/crates/ui-egui/src/direct_select_tests.rs b/crates/ui-egui/src/direct_select_tests.rs index c6b02c79b9..31a16bfcda 100644 --- a/crates/ui-egui/src/direct_select_tests.rs +++ b/crates/ui-egui/src/direct_select_tests.rs @@ -325,3 +325,195 @@ fn arrow_keys_nudge_the_selected_anchors() { assert!(app.session.undo() && app.session.undo()); assert_eq!(work(&app), before); } + +/// #5475: ⇧ during an anchor drag locks it to 45°, whether it's held from the press or added later. +#[test] +fn shift_locks_an_anchor_drag_to_45_degrees() { + let mut app = app(Tool::DirectSelection); + with_path(&mut app); + tool_event(&mut app, ToolEvent::Down { x: 40.0, y: 40.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 70.0, y: 44.0, pressure: 1.0 }, Modifiers::SHIFT); + let Some(Drag::Edit { preview: Some(p), .. }) = &app.ui.direct_selection.drag else { panic!("dragging") }; + assert_eq!(p.subpaths[0].knots[0].anchor, Point::new(70.0, 40.0), "the preview is locked too"); + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 47.0 }, Modifiers::SHIFT); + assert_eq!(anchor(&app, 0), Point::new(100.0, 40.0)); + + // Diagonal, and without ⇧ the drag stays free. + // (⇧ at the press would deselect the anchor, so it comes once the drag is under way.) + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 40.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 131.0, y: 69.0 }, Modifiers::SHIFT); + let a = anchor(&app, 0); + assert!((a.x - 130.0).abs() < 1e-9 && (a.y - 70.0).abs() < 1e-9, "{a:?}"); + drag(&mut app, [130.0, 70.0], [140.0, 73.0], Modifiers::NONE); + assert_eq!(anchor(&app, 0), Point::new(140.0, 73.0)); +} + +#[test] +fn the_curvature_pen_drags_an_anchor_of_the_active_path_without_a_modifier() { + // As in Photoshop: with no path being drawn, pressing an anchor and dragging moves it. + let mut app = app(Tool::CurvaturePen); + with_path(&mut app); + assert!(super::shows(&app, Modifiers::NONE), "the Curvature Pen shows the active path's anchors"); + let (before, n) = (work(&app), steps(&app)); + drag(&mut app, [40.5, 120.5], [50.5, 130.5], Modifiers::NONE); + assert_eq!(anchor(&app, 3), Point::new(50.0, 130.0)); + assert_eq!(anchor(&app, 0), Point::new(40.0, 40.0), "only the dragged anchor moved"); + assert_eq!(work(&app).subpaths.len(), 1, "no new path was started"); + assert!(app.ui.pen.is_none(), "nor is one being drawn"); + assert_eq!(steps(&app), n + 1, "one history step for the whole drag"); + assert!(app.session.undo()); + assert_eq!(work(&app), before); +} + +#[test] +fn the_curvature_pen_off_an_anchor_still_draws_and_edits_its_own_path() { + let mut app = app(Tool::CurvaturePen); + with_path(&mut app); + let before = work(&app); + // A press on empty canvas starts a new path, as before (a segment would get an anchor). + click(&mut app, [80.0, 170.0], Modifiers::NONE); + click(&mut app, [170.0, 170.0], Modifiers::NONE); + assert_eq!(app.ui.pen.as_ref().map(|p| p.knots.len()), Some(2)); + assert!(!super::shows(&app, Modifiers::NONE), "while drawing, the Curvature Pen shows its own path"); + // While drawing, a press on an anchor of the path being drawn moves that one, not the work path's. + drag(&mut app, [170.0, 170.0], [180.0, 160.0], Modifiers::NONE); + assert_eq!(app.ui.pen.as_ref().and_then(|p| p.knots.get(1)).map(|k| k[0]), Some([180.0, 160.0])); + assert_eq!(work(&app), before); +} + +#[test] +fn a_closed_curvature_pen_path_stays_editable_with_the_curvature_pen() { + let mut app = app(Tool::CurvaturePen); + app.ui.tool_options.vector_mode = "path".into(); + for p in [[40.0, 40.0], [160.0, 40.0], [100.0, 160.0], [40.0, 40.0]] { + click(&mut app, p, Modifiers::NONE); + } + assert!(app.ui.pen.is_none(), "clicking the first anchor closed the path"); + assert!(work(&app).subpaths[0].closed); + let n = steps(&app); + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 160.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 100.0, y: 180.0, pressure: 1.0 }, Modifiers::NONE); + let Some(Drag::Edit { preview: Some(shown), .. }) = app.ui.direct_selection.drag.clone() else { panic!("an anchor drag with a preview") }; + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 180.0 }, Modifiers::NONE); + assert_eq!(work(&app), shown, "the preview is what the drag commits"); + assert_eq!((work(&app).subpaths.len(), steps(&app)), (1, n + 1)); + // The whole loop reflows through the moved anchor, as the Curvature Pen drew it. + let pts = [[40.0, 40.0], [160.0, 40.0], [100.0, 180.0]]; + let want = photocraft_vector::edit::curvature_knots(&pts, &[false; 3], true); + for (k, w) in work(&app).subpaths[0].knots.iter().zip(want) { + assert_eq!([[k.anchor.x, k.anchor.y], [k.in_ctrl.x, k.in_ctrl.y], [k.out_ctrl.x, k.out_ctrl.y]], w); + } +} + +/// A Pen-drawn work path: a closed square of corners (40, 40)–(160, 160). +fn with_corner_square(app: &mut PhotocraftApp) { + let path = json!({"subpaths": [{"closed": true, "knots": [[40, 40], [160, 40], [160, 160], [40, 160]]}]}); + app.run("path.set", json!({"name": "work", "path": path})).unwrap(); +} + +#[test] +fn the_curvature_pen_adds_an_anchor_on_a_segment_and_drags_it_in_one_step() { + let mut app = app(Tool::CurvaturePen); + with_corner_square(&mut app); + let n = steps(&app); + // A press on the top edge adds an anchor there; the drag pulls it up. + drag(&mut app, [100.0, 40.0], [100.0, 10.0], Modifiers::NONE); + let sp = &work(&app).subpaths[0]; + assert_eq!(sp.knots.len(), 5, "one anchor added"); + assert_eq!(sp.knots[1].anchor, Point::new(100.0, 10.0)); + assert!(app.ui.pen.is_none(), "no new path was started"); + assert_eq!(steps(&app), n + 1, "adding and dragging the anchor is one history step"); + assert!(app.session.undo()); + assert_eq!(work(&app).subpaths[0].knots.len(), 4); + // A click on a segment just adds the anchor. + click(&mut app, [160.0, 100.0], Modifiers::NONE); + assert_eq!(work(&app).subpaths[0].knots.len(), 5); +} + +#[test] +fn a_curvature_pen_double_click_rounds_a_pen_corner_and_back() { + let mut app = app(Tool::CurvaturePen); + with_corner_square(&mut app); + let before = work(&app); + crate::vector_ui::curvature_toggle(&mut app, 160.0, 40.0); + let k = work(&app).subpaths[0].knots[1]; + assert!(k.smooth && k.in_ctrl != k.anchor && k.out_ctrl != k.anchor, "the corner turned smooth: {k:?}"); + crate::vector_ui::curvature_toggle(&mut app, 160.0, 40.0); + assert_eq!(work(&app), before); + // Off the path it does nothing (and starts nothing). + crate::vector_ui::curvature_toggle(&mut app, 100.0, 100.0); + assert_eq!((work(&app), app.ui.pen.is_none()), (before, true)); +} + +#[test] +fn the_curvature_pen_cursor_shows_what_a_press_does() { + use crate::tool_feedback::{Badge, badge}; + let mut app = app(Tool::CurvaturePen); + with_corner_square(&mut app); + for (at, want) in [([100.0, 40.0], Some(Badge::Add)), ([160.0, 40.0], Some(Badge::Move)), ([100.0, 100.0], None)] { + app.hover_doc = Some(at); + assert_eq!(badge(&app, Tool::CurvaturePen, Modifiers::NONE), want, "at {at:?}"); + } + // ⌘/Ctrl is Direct Selection: no Curvature Pen badge. + app.hover_doc = Some([160.0, 40.0]); + assert_eq!(badge(&app, Tool::CurvaturePen, Modifiers::COMMAND), None); + // While drawing, its own anchors take the move badge and the active path's segments none. + click(&mut app, [20.0, 180.0], Modifiers::NONE); + click(&mut app, [60.0, 190.0], Modifiers::NONE); + app.hover_doc = Some([60.0, 190.0]); + assert_eq!(badge(&app, Tool::CurvaturePen, Modifiers::NONE), Some(Badge::Move)); + app.hover_doc = Some([100.0, 40.0]); + assert_eq!(badge(&app, Tool::CurvaturePen, Modifiers::NONE), None); +} + +/// The real canvas: a Curvature Pen double-click on a Pen corner of the active path rounds it. +#[test] +fn a_curvature_pen_double_click_through_the_canvas_rounds_a_corner() { + use egui::{PointerButton, vec2}; + use egui_kittest::Harness; + let mut app = app(Tool::CurvaturePen); + with_corner_square(&mut app); + app.ui.extras.rulers = false; + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [100.0, 100.0]; + v.fit_pending = false; + h.run_steps(2); + let p = { + let app = h.state(); + let v = &app.ui.views[0]; + let xf = crate::canvas::ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: false, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + }; + xf.to_screen(160.0, 40.0) + }; + for _ in 0..2 { + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + for down in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + } + h.run_steps(1); + let k = work(h.state()).subpaths[0].knots[1]; + assert!(k.smooth && k.in_ctrl != k.anchor, "the double-clicked corner is smooth: {k:?}"); + assert!(h.state().ui.pen.is_none(), "and no path was started"); +} diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index 23248cd9ae..e9b5504bea 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. @@ -155,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; @@ -192,7 +213,13 @@ fn save(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) -> bool { return false; } match saved { - Ok(_) => !app.save_options_open() && !app.saving(), + // A Save Document script may have edited the document after it was written: still dirty. + // A write the platform can't confirm (a web download) leaves it dirty too, and counts. + Ok(_) => { + !app.save_options_open() + && !app.saving() + && index_of(app, doc).is_some_and(|i| app.services.write_unconfirmed || !app.session.documents()[i].is_dirty()) + } // Backing out of the file dialog is the user's choice, not an error. Err(e) if e == crate::file_dialog::CANCELLED => false, Err(e) => { @@ -205,12 +232,12 @@ fn save(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) -> bool { /// A completed save may release the close prompt. Choosing a path only starts a layered TIFF /// save; TIFF Options calls this after the write, and `jobs_ui` when a background save ends. -/// Copies and failed writes leave the doc dirty. +/// Copies and failed writes leave the doc dirty; so does any write when it is unconfirmed. pub(crate) fn saved_document(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) { // Still the prompt that asked (quitting may have replaced it meanwhile). if !app.save_options_open() && app.discard.as_ref().and_then(|p| p.docs.first()) == Some(&doc) - && index_of(app, doc).is_some_and(|i| !app.session.documents()[i].is_dirty()) + && index_of(app, doc).is_some_and(|i| app.services.write_unconfirmed || !app.session.documents()[i].is_dirty()) { advance(app, ctx); } @@ -225,6 +252,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 }; @@ -245,6 +276,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 @@ -264,8 +301,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { vec![(ButtonRole::Default, "Yes", Some(Key::Y), 84.0, Answer::Save), (ButtonRole::Alternate, "No", Some(Key::N), 84.0, Answer::Discard), cancel] }; 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: Vec = buttons.iter().map(|b| button_label(mac, b.1, b.2)).collect(); - let row: Vec = buttons.iter().zip(&labels).map(|(b, label)| DialogButton::new(b.0, label, b.3)).collect(); + 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).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))); @@ -360,17 +398,30 @@ enum Answer { } /// macOS keeps the plain button wording even though the keyboard shortcuts still work. +#[cfg(test)] fn button_label(mac: bool, label: &str, key: Option) -> String { - if mac { tl!(label).to_string() } else { key.map_or_else(|| tl!(label).to_string(), |k| mnemonic(label, k)) } + button_labels(mac, &[(label, key)]).into_iter().next().unwrap_or_default() } -/// "(S)ave": the key in parentheses, or appended ("Guardar (S)") when the translation doesn't start with it. -fn mnemonic(label: &str, key: Key) -> String { - let (label, k) = (tl!(label), key.name()); - match label.split_at_checked(1) { - Some((first, rest)) if first.eq_ignore_ascii_case(k) => format!("({first}){rest}"), - _ => format!("{label} ({k})"), - } +/// The labels of one dialog's buttons, each with its key in the same style: "(Y)es" when every +/// translation starts with its key, otherwise appended to all ("Ja (Y)", "Nein (N)") (#5564). +fn button_labels(mac: bool, buttons: &[(&str, Option)]) -> Vec { + let starts_with_key = |label: &str, k: Key| label.split_at_checked(1).is_some_and(|(first, _)| first.eq_ignore_ascii_case(k.name())); + let inline = buttons.iter().all(|&(label, key)| key.is_none_or(|k| starts_with_key(tl!(label), k))); + buttons + .iter() + .map(|&(label, key)| { + let text = tl!(label); + match key { + None => text.to_string(), + Some(_) if mac => text.to_string(), + Some(k) => match text.split_at_checked(1) { + Some((first, rest)) if inline => format!("({first}){rest}"), + _ => format!("{text} ({})", k.name()), + }, + } + }) + .collect() } #[cfg(test)] @@ -395,6 +446,32 @@ mod tests { app.session.documents()[i].doc.id } + #[test] + fn a_save_document_script_that_edits_the_document_keeps_the_prompt_up() { + use photocraft_engine::automate_cmds::ScriptBinding; + let mut app = app_with_docs(1); + let ctx = egui::Context::default(); + make_dirty(&mut app, 0); + app.session.active_mut().unwrap().path = Some("/pics/kept.psd".into()); + app.services.export = Some(Box::new(|_: &photocraft_doc::Document, _: &str, _: &crate::ExportSettings| Ok((b"out".to_vec(), Vec::new())))); + app.services.write = Some(Box::new(|_: &str, _: &[u8]| Ok(()))); + app.session.edit_prefs(|prefs| { + prefs.script_events.enabled = true; + prefs.script_events.bindings = vec![ScriptBinding { + event: "saveDocument".into(), + script: None, + steps: Some(json!([["layer.new.layer", {"name": "From the script"}]])), + name: "Binding".into(), + }]; + }); + let doc = doc_id(&app, 0); + assert!(!save(&mut app, &ctx, doc), "the script's layer was never written"); + assert!(app.session.documents()[0].is_dirty()); + // Without the script the same save releases the prompt. + app.session.edit_prefs(|prefs| prefs.script_events.enabled = false); + assert!(save(&mut app, &ctx, doc)); + } + #[test] fn clean_documents_close_without_asking() { let mut app = app_with_docs(2); @@ -442,6 +519,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); @@ -502,9 +611,9 @@ mod tests { #[test] fn mnemonic_labels_bracket_the_key() { - assert_eq!(mnemonic("Don't Save", Key::D), "(D)on't Save"); - assert_eq!(mnemonic("Guardar", Key::S), "Guardar (S)"); - assert_eq!(mnemonic("保存", Key::S), "保存 (S)"); + assert_eq!(button_label(false, "Don't Save", Some(Key::D)), "(D)on't Save"); + assert_eq!(button_label(false, "Guardar", Some(Key::S)), "Guardar (S)"); + assert_eq!(button_label(false, "保存", Some(Key::S)), "保存 (S)"); } type Prompted = egui_kittest::Harness<'static, PhotocraftApp>; @@ -542,31 +651,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"); @@ -641,12 +779,25 @@ mod tests { assert_eq!(button_label(false, "Cancel", Some(Key::C)), "(C)ancel"); } + /// German "Ja" doesn't start with Y, so every button appends its key rather than mixing + /// "Ja (Y)" with "(N)ein" (#5564). + #[test] + fn one_dialog_shows_every_key_in_the_same_style() { + let buttons = [("Yes", Some(Key::Y)), ("No", Some(Key::N)), ("Cancel", Some(Key::C))]; + assert_eq!(button_labels(false, &buttons), ["(Y)es", "(N)o", "(C)ancel"]); + let german = crate::i18n::Lang::from_code("de").unwrap(); + crate::i18n::with_language(german, || { + assert_eq!(button_labels(false, &buttons), ["Ja (Y)", "Nein (N)", "Abbrechen (C)"]); + assert_eq!(button_labels(true, &buttons), ["Ja", "Nein", "Abbrechen"]); + }); + } + #[test] fn macos_asks_dont_save_cancel_save_with_the_default_last() { 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"); @@ -683,11 +834,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"); @@ -723,7 +872,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); @@ -780,12 +929,39 @@ mod tests { assert!(h.state().session.documents().is_empty(), "closed once saved"); } + /// #6414: on the web a save stays dirty (the download isn't confirmed), and answering Save + /// still closes the tab after one write. + #[test] + fn saving_from_the_prompt_closes_after_an_unconfirmed_write() { + use std::{cell::RefCell, rc::Rc}; + let written: Rc>> = Rc::default(); + let w = written.clone(); + let mut app = app_with_docs(1); + make_dirty(&mut app, 0); + app.session.active_mut().unwrap().path = Some("/pics/kept.psd".into()); + app.services.write_unconfirmed = true; + app.services.export = Some(Box::new(|_: &photocraft_doc::Document, _: &str, _: &crate::ExportSettings| Ok((b"out".to_vec(), Vec::new())))); + app.services.write = Some(Box::new(move |p: &str, _: &[u8]| { + w.borrow_mut().push(p.to_string()); + Ok(()) + })); + let mut h = prompt_for(app, "file.close", egui::os::OperatingSystem::Windows); + h.key_press(Key::Y); + h.run_steps(3); + assert_eq!(*written.borrow(), ["/pics/kept.psd"]); + assert!(h.state().discard.is_none()); + assert!(h.state().session.documents().is_empty(), "closed after one write"); + } + #[path = "tiff_tests.rs"] mod tiff_tests; #[path = "targa_tests.rs"] mod targa_tests; + #[path = "dds_tests.rs"] + mod dds_tests; + /// One frame with the window's close button pressed; the commands the guard sent. fn window_close_commands(app: &mut PhotocraftApp) -> Vec { let mut info = egui::ViewportInfo::default(); diff --git a/crates/ui-egui/src/discard_ui/tests/dds_tests.rs b/crates/ui-egui/src/discard_ui/tests/dds_tests.rs new file mode 100644 index 0000000000..f1c88dec81 --- /dev/null +++ b/crates/ui-egui/src/discard_ui/tests/dds_tests.rs @@ -0,0 +1,217 @@ +//! DDS Options on Save As and Save a Copy: the compression and mipmaps it asks for, the scripted +//! `ddsCompression` and `ddsMipmaps` that skip it, and action recording and playback (#1327). +use super::*; +use crate::{FileDialogAnswer, dds_options_ui}; +use egui_kittest::kittest::Queryable; +use photocraft_io::{DdsCompression, DdsOptions}; +use std::{cell::RefCell, rc::Rc}; + +/// Each write: the path, and the DDS options the export was asked for. +type Writes = Rc>>; + +const PLAIN: DdsOptions = DdsOptions { compression: DdsCompression::None, mipmaps: false }; + +/// One 8×8 RGB document whose exports record their DDS options, in a harness showing DDS Options +/// and answering a save dialog with `to`. +fn dds_app(to: &str) -> (Prompted, Writes) { + let mut app = app_with_docs(1); + let writes: Writes = Rc::default(); + let (asked, written) = (Rc::new(RefCell::new(PLAIN)), writes.clone()); + let seen = asked.clone(); + app.services.file_dialog = Some(crate::file_dialog::fake(vec![Some(FileDialogAnswer::SaveTo(to.into()))]).0); + app.services.export = Some(Box::new(move |_, _, settings| { + *seen.borrow_mut() = settings.dds; + Ok((Vec::new(), Vec::new())) + })); + app.services.write = Some(Box::new(move |path, _| { + written.borrow_mut().push((path.to_string(), *asked.borrow())); + Ok(()) + })); + let h = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state( + |ui, app| { + show(app, ui.ctx()); + dds_options_ui::show(app, ui.ctx()); + app.poll_file_dialog(ui.ctx(), None); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.ctx.set_os(egui::os::OperatingSystem::Windows); + (h, writes) +} + +/// Save As to a DDS texture asks first, starting uncompressed without mipmaps, and writes only +/// what is chosen once answered. +#[test] +fn save_as_to_a_dds_texture_asks_for_its_compression() { + let (mut h, writes) = dds_app("unused.dds"); + h.state_mut().save_as(Some("t.dds".into())).unwrap(); + h.run_steps(2); + assert_eq!(h.state().dds_options.as_ref().map(|p| (p.options, p.copy)), Some((PLAIN, false))); + assert!(writes.borrow().is_empty(), "nothing is written before the answer"); + assert!(h.state_mut().save_as(Some("other.dds".into())).is_err(), "one save at a time"); + h.get_by_label("BC3 / DXT5").click(); + h.run_steps(2); + h.get_by_label("Generate Mipmaps").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + let chosen = DdsOptions { compression: DdsCompression::Bc3, mipmaps: true }; + assert_eq!(writes.borrow().as_slice(), &[("t.dds".to_string(), chosen)]); + assert!(h.state().dds_options.is_none()); + assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some("t.dds")); + + // Cancel writes nothing. + h.state_mut().save_as(Some("c.dds".into())).unwrap(); + h.run_steps(2); + h.key_press(Key::Escape); + h.run_steps(3); + assert!(h.state().dds_options.is_none()); + assert_eq!(writes.borrow().len(), 1, "cancelled: nothing more written"); + assert_eq!(h.state().ui.status, "Save cancelled"); +} + +/// Scripts choose with `ddsCompression` or `ddsMipmaps` and are never asked; bad values write +/// nothing, and other formats aren't asked. +#[test] +fn scripted_dds_options_and_other_formats_skip_the_prompt() { + let (mut h, writes) = dds_app("unused.dds"); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "s.dds", "ddsCompression": "bc1"})).unwrap(); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "m.dds", "ddsMipmaps": true})).unwrap(); + assert!(h.state().dds_options.is_none()); + for bad in [json!({"ddsCompression": "bc7"}), json!({"ddsMipmaps": 1})] { + let mut p = bad.clone(); + p["path"] = json!("bad.dds"); + assert!(crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", p).is_err(), "{bad}"); + } + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "p.png"})).unwrap(); + assert!(h.state().dds_options.is_none()); + assert_eq!( + writes.borrow().as_slice(), + &[ + ("s.dds".to_string(), DdsOptions { compression: DdsCompression::Bc1, mipmaps: false }), + ("m.dds".to_string(), DdsOptions { compression: DdsCompression::None, mipmaps: true }), + ("p.png".to_string(), PLAIN), + ] + ); +} + +/// Save a Copy to a DDS texture asks too, and the copy leaves the document's path and unsaved +/// state. +#[test] +fn save_a_copy_to_a_dds_texture_asks_and_keeps_the_document_unsaved() { + let (mut h, writes) = dds_app("copy.dds"); + make_dirty(h.state_mut(), 0); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveACopy", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dds_options.as_ref().map(|p| (p.options, p.copy)), Some((PLAIN, true))); + assert!(dds_options_ui::park(h.state_mut(), "other.dds".into(), false, "file.saveAs").is_err(), "the waiting prompt is kept"); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(writes.borrow().as_slice(), &[("copy.dds".to_string(), PLAIN)]); + let st = h.state().session.active().unwrap(); + assert!(st.is_dirty()); + assert_eq!(st.path, None); +} + +/// An action being recorded keeps a DDS Save As or Save a Copy with its options, answered in DDS +/// Options or scripted, and plays them back without asking. +#[test] +fn recorded_dds_saves_keep_their_options_and_replay_without_asking() { + let (mut h, writes) = dds_app("copy.dds"); + let ctx = h.ctx.clone(); + h.state_mut().run("actions.record", json!({})).unwrap(); + h.state_mut().save_as(Some("t.dds".into())).unwrap(); + h.run_steps(2); + h.get_by_label("BC1 / DXT1").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveACopy", json!({})).unwrap(); + h.run_steps(3); + h.get_by_label("Generate Mipmaps").click(); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "s.dds", "ddsCompression": "bc2"})).unwrap(); + h.state_mut().run("actions.stop", json!({})).unwrap(); + let want = [ + ("t.dds".to_string(), DdsOptions { compression: DdsCompression::Bc1, mipmaps: false }), + ("copy.dds".to_string(), DdsOptions { compression: DdsCompression::None, mipmaps: true }), + ("s.dds".to_string(), DdsOptions { compression: DdsCompression::Bc2, mipmaps: false }), + ]; + assert_eq!(writes.borrow().as_slice(), &want); + let steps = &h.state().session.actions.list[0].steps; + assert_eq!( + steps, + &[ + ("file.saveAs".into(), json!({"path": "t.dds", "ddsCompression": "bc1", "ddsMipmaps": false})), + ("file.saveACopy".into(), json!({"path": "copy.dds", "ddsCompression": "none", "ddsMipmaps": true})), + ("file.saveAs".into(), json!({"path": "s.dds", "ddsCompression": "bc2", "ddsMipmaps": false})) + ] + ); + writes.borrow_mut().clear(); + let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert!(h.state().dds_options.is_none(), "playback doesn't ask"); + assert_eq!(writes.borrow().as_slice(), &want); +} + +/// File › Save confirming the document's own texture asks DDS Options, and an action being +/// recorded keeps it as a plain Save with the answer (scripted options too): played back, it +/// writes the texture the action is played on as recorded, without asking (#3531). +#[test] +fn recorded_plain_save_of_a_dds_texture_keeps_its_options() { + let (mut h, writes) = dds_app("own.dds"); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "own.dds", "ddsCompression": "none"})).unwrap(); + assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some("own.dds")); + h.state_mut().run("actions.record", json!({})).unwrap(); + crate::menus::invoke(h.state_mut(), &ctx, "file.save", json!({})).unwrap(); + h.run_steps(3); + assert!(h.state().dds_options.is_some(), "confirming its own texture asks DDS Options"); + h.get_by_label("BC1 / DXT1").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + h.state_mut().services.file_dialog = Some(crate::file_dialog::fake(vec![Some(FileDialogAnswer::SaveTo("own.dds".into()))]).0); + crate::menus::invoke(h.state_mut(), &ctx, "file.save", json!({"ddsCompression": "bc3", "ddsMipmaps": true})).unwrap(); + h.run_steps(3); + assert!(h.state().dds_options.is_none(), "scripted options don't ask"); + h.state_mut().run("actions.stop", json!({})).unwrap(); + let bc1 = DdsOptions { compression: DdsCompression::Bc1, mipmaps: false }; + let bc3 = DdsOptions { compression: DdsCompression::Bc3, mipmaps: true }; + assert_eq!(&writes.borrow()[1..], &[("own.dds".to_string(), bc1), ("own.dds".to_string(), bc3)]); + let steps = &h.state().session.actions.list[0].steps; + assert_eq!( + steps, + &[ + ("file.save".into(), json!({"ddsCompression": "bc1", "ddsMipmaps": false})), + ("file.save".into(), json!({"ddsCompression": "bc3", "ddsMipmaps": true})) + ] + ); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "other.dds", "ddsCompression": "none"})).unwrap(); + writes.borrow_mut().clear(); + let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert!(h.state().dds_options.is_none(), "playback doesn't ask"); + assert_eq!(writes.borrow().as_slice(), &[("other.dds".to_string(), bc1), ("other.dds".to_string(), bc3)]); +} + +/// Enter on a focused Cancel cancels: egui clicks the focused button on Enter without consuming +/// the key, so the dialog's Enter-for-OK must not save as well. +#[test] +fn enter_on_a_focused_cancel_writes_nothing() { + let (mut h, writes) = dds_app("unused.dds"); + h.state_mut().save_as(Some("t.dds".into())).unwrap(); + h.run_steps(2); + h.get_by_label("Cancel").focus(); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + assert!(h.state().dds_options.is_none()); + assert!(writes.borrow().is_empty(), "nothing written"); + assert_eq!(h.state().ui.status, "Save cancelled"); +} diff --git a/crates/ui-egui/src/discard_ui/tests/targa_tests.rs b/crates/ui-egui/src/discard_ui/tests/targa_tests.rs index 565fe01037..0a05370ecf 100644 --- a/crates/ui-egui/src/discard_ui/tests/targa_tests.rs +++ b/crates/ui-egui/src/discard_ui/tests/targa_tests.rs @@ -201,3 +201,46 @@ fn closing_after_a_targa_save_waits_for_targa_options() { assert!(h.state().discard.is_none()); assert!(h.state().session.documents().is_empty()); } + +/// File › Save confirming the document's own Targa asks Targa Options, and an action being +/// recorded keeps it as a plain Save with the bits chosen: played back, it writes the Targa the +/// action is played on with them, without asking (#3531). +#[test] +fn recorded_plain_save_of_a_targa_keeps_its_bits() { + let (mut h, writes) = targa_app(false, "own.tga"); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "own.tga", "tgaBits": 24})).unwrap(); + h.state_mut().run("actions.record", json!({})).unwrap(); + crate::menus::invoke(h.state_mut(), &ctx, "file.save", json!({})).unwrap(); + h.run_steps(3); + assert!(h.state().targa_options.is_some(), "confirming its own Targa asks Targa Options"); + h.get_by_label("32 bits/pixel").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + h.state_mut().run("actions.stop", json!({})).unwrap(); + let steps = &h.state().session.actions.list[0].steps; + assert_eq!(steps, &[("file.save".into(), json!({"tgaBits": 32}))]); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "other.tga", "tgaBits": 24})).unwrap(); + writes.borrow_mut().clear(); + let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert!(h.state().targa_options.is_none(), "playback doesn't ask"); + assert_eq!(writes.borrow().as_slice(), &[("other.tga".to_string(), asked(Some(32)))]); +} + +/// Enter on a focused Cancel cancels: egui clicks the focused button on Enter without consuming +/// the key, so the dialog's Enter-for-OK must not save as well. +#[test] +fn enter_on_a_focused_cancel_writes_nothing() { + let (mut h, writes) = targa_app(false, "unused.tga"); + h.state_mut().save_as(Some("t.tga".into())).unwrap(); + h.run_steps(2); + h.get_by_label("Cancel").focus(); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + assert!(h.state().targa_options.is_none()); + assert!(writes.borrow().is_empty(), "nothing written"); + assert_eq!(h.state().ui.status, "Save cancelled"); +} 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..eb52dfb5a8 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)); @@ -78,7 +79,7 @@ fn closing_after_tiff_save_waits_for_options_and_writes_the_requested_document() #[test] fn cancelling_failing_or_copying_a_tiff_keeps_the_original_unsaved() { - for outcome in ["cancel", "export error", "write error", "copy"] { + for outcome in ["cancel", "enter on cancel", "export error", "write error", "copy"] { let (mut h, writes, saved, _) = tiff_prompt("file.close"); h.key_press(Key::Y); h.run_steps(3); @@ -89,6 +90,11 @@ fn cancelling_failing_or_copying_a_tiff_keeps_the_original_unsaved() { h.get_by_label("Discard Layers and Save a Copy").click(); h.run_steps(2); } + // Enter on a focused Cancel is its click, not OK. + "enter on cancel" => { + h.get_by_label("Cancel").focus(); + h.run_steps(2); + } _ => {} } h.key_press(if outcome == "cancel" { Key::Escape } else { Key::Enter }); 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..b65985cd40 --- /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(), crate::FormatChoices::default())?; + app.journal_as_plain_save(&path); + Ok(r) + } else { + app.save_in_place(path, crate::FormatChoices::default()) + } + }) +} diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index 9f0966b5c3..4987d8cc4d 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -22,22 +22,27 @@ use crate::canvas::{ToolEvent, ViewXform}; #[derive(Default)] pub struct Distort { pub liquify: Option, + /// Edit › Content-Aware Fill… (caf_ui). + pub caf: Option, pub puppet: Option, pub perspective: Option, pub vanishing: Option, /// Filter Gallery dialog (gallery_ui). pub gallery: Option, + /// The document the open sessions belong to, noted by [`drop_foreign`] on the first frame they are open. + owner: Option, } impl Distort { pub fn active(&self) -> bool { - self.liquify.is_some() || self.puppet.is_some() || self.perspective.is_some() || self.vanishing.is_some() + self.liquify.is_some() || self.caf.is_some() || self.puppet.is_some() || self.perspective.is_some() || self.vanishing.is_some() } /// Short description for `ui.inspect`. pub fn describe(&self) -> Value { json!({ "liquify": self.liquify.as_ref().map(|d| d.describe()), + "contentAwareFill": self.caf.as_ref().map(|d| d.describe()), "puppet": self.puppet.as_ref().map(|p| p.describe()), "perspective": self.perspective.as_ref().map(|p| p.describe()), "vanishingPoint": self.vanishing.as_ref().map(|v| v.describe()), @@ -109,6 +114,9 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val "filter.filterGallery" if ui.is_some() && app.distort.gallery.is_some() => Some(crate::gallery_ui::control(app, ui.unwrap_or(&Value::Null))), "filter.liquify" if empty => Some(crate::liquify_ui::open(app, ctx).map(|_| json!({"liquify": app.distort.describe()["liquify"]}))), "filter.liquify" if ui.is_some() && app.distort.liquify.is_some() => Some(crate::liquify_ui::control(app, ui.unwrap_or(&Value::Null))), + "edit.contentAwareFill" if ui.is_some() && app.distort.caf.is_some() => Some(crate::caf_ui::control(app, ui.unwrap_or(&Value::Null))), + // `ui` addresses the workspace: closed, that is an error, not a fill with the defaults. + "edit.contentAwareFill" if ui.is_some() => Some(Err("Content-Aware Fill is not open".into())), "edit.puppetWarp" | "layer.smartObjects.puppetWarp" if empty => { Some(crate::puppet_ui::begin(app, ctx, id).map(|_| json!({"puppet": app.distort.describe()["puppet"]}))) } @@ -129,6 +137,10 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val /// Pointer events (document coordinates) while a mode is active. True when consumed. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { + if app.distort.caf.is_some() { + crate::caf_ui::pointer(app, ev, mods); + return true; + } if app.distort.liquify.is_some() { crate::liquify_ui::pointer(app, ev, mods); return true; @@ -162,6 +174,14 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } return true; } + if app.distort.caf.is_some() { + if enter(ctx) { + crate::caf_ui::commit(app); + } else if esc(ctx) { + crate::caf_ui::cancel(app); + } + return true; + } if app.distort.liquify.is_some() { if enter(ctx) { crate::liquify_ui::commit(app); @@ -236,8 +256,32 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { false } -/// Full-window dialogs (Liquify, Filter Gallery). +/// Drops the open sessions once the active document is not the one they were started on (closed, or another one +/// opened or switched to). Without this, Puppet Warp's preview of the old document replaced the new document's canvas (#5092). +pub fn drop_foreign(app: &mut PhotocraftApp) { + let current = app.session.active().map(|s| s.doc.id); + if !app.distort.active() && app.distort.gallery.is_none() { + app.distort.owner = None; + } else if app.distort.owner.is_none() { + app.distort.owner = current; + } else if app.distort.owner != current { + app.distort.liquify = None; + // Stops its background preview too; what Apply wrote stays, as with Cancel. + crate::caf_ui::cancel(app); + app.distort.puppet = None; + app.distort.perspective = None; + app.distort.vanishing = None; + app.distort.gallery = None; + app.distort.owner = None; + } +} + +/// Full-window dialogs (Liquify, Content-Aware Fill, Filter Gallery). pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + drop_foreign(app); + if app.distort.caf.is_some() { + crate::caf_ui::show(app, ctx); + } if app.distort.gallery.is_some() { crate::gallery_ui::show(app, ctx); } diff --git a/crates/ui-egui/src/doc_props_ui.rs b/crates/ui-egui/src/doc_props_ui.rs index 8c10b8c442..6a959c38d7 100644 --- a/crates/ui-egui/src/doc_props_ui.rs +++ b/crates/ui-egui/src/doc_props_ui.rs @@ -6,6 +6,7 @@ use egui::{Align2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2}; use photocraft_doc::{ColorMode, Document, Layer, LayerId, SampleType}; use serde_json::{Value, json}; +use crate::props_layout::stacked_label; use crate::theme::Tokens; use crate::{PhotocraftApp, icons, widgets}; @@ -100,7 +101,7 @@ fn committed_field(ui: &mut egui::Ui, id: &str, current: f32, u: crate::layer_pr // Shown in the ruler unit (cm when the rulers are in centimeters); 100% is the current size. let extent = f64::from(current); let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| u.shown(extent, extent)); - let r = widgets::value_field(ui, &mut v, 0.01..=300_000.0, u.unit.suffix(), 74.0); + let r = widgets::value_field(ui, &mut v, 0.0..=300_000.0, u.unit.suffix(), 74.0); if r.dragged() || r.has_focus() { ui.data_mut(|d| d.insert_temp(key, v)); return None; @@ -151,19 +152,19 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); ui.vertical(|ui| { ui.horizontal(|ui| { - field_label(ui, "W", 18.0); + stacked_label(ui, "W", &["W", "H"], 18.0, 4.0); if let Some(v) = committed_field(ui, "w", w as f32, lu) { run.extend(canvas_resize_params((w, h), Some(v), None, linked).map(|p| ("image.canvasSize".to_string(), p))); } - field_label(ui, "X", 22.0); + stacked_label(ui, "X", &["X", "Y"], 22.0, 4.0); dim_field(ui, "0 px"); }); ui.horizontal(|ui| { - field_label(ui, "H", 18.0); + stacked_label(ui, "H", &["W", "H"], 18.0, 4.0); if let Some(v) = committed_field(ui, "h", h as f32, lu) { run.extend(canvas_resize_params((w, h), None, Some(v), linked).map(|p| ("image.canvasSize".to_string(), p))); } - field_label(ui, "Y", 22.0); + stacked_label(ui, "Y", &["X", "Y"], 22.0, 4.0); dim_field(ui, "0 px"); }); }); @@ -318,6 +319,64 @@ mod tests { assert_eq!(canvas_resize_params((2400, 1500), Some(2400.2), None, false), None); } + /// #5661: a 1 x 1 px document at 300 ppi is 0.0033 in wide. With the rulers in inches the + /// Width/Height fields clamped that to 0.01 in (3 px) and resized the document unasked. + #[test] + fn tiny_document_in_inches_is_not_resized_by_showing_it() { + use egui_kittest::Harness; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1, "height": 1, "resolution": 300, "background": "white"})).unwrap(); + app.session.prefs.edit(|p| p.units_and_rulers.rulers = photocraft_engine::prefs::Unit::Inches); + let undo = app.session.active().unwrap().history.entries().len(); + let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + properties(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + assert_eq!((st.doc.size.width, st.doc.size.height), (1, 1)); + assert_eq!(st.history.entries().len(), undo, "no history step"); + } + + /// Horizontal centre of every single-letter label the panel drew, by letter. + fn letter_centres(h: &egui_kittest::Harness<'_, PhotocraftApp>) -> Vec<(String, f32)> { + h.output() + .shapes + .iter() + .filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t) } else { None }) + .filter(|t| t.galley.text().chars().count() == 1) + .map(|t| (t.galley.text().to_string(), t.pos.x + t.galley.size().x * 0.5)) + .collect() + } + + /// #7314: the Canvas section's W over H (and X over Y) share a centre; a narrow "H" used to sit + /// flush right under the wider "W". + #[test] + fn canvas_size_labels_are_centred_on_each_other() { + use egui_kittest::Harness; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 40, "height": 20, "background": "white"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + properties(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + let centres = letter_centres(&h); + let at = |l: &str| centres.iter().find(|(s, _)| s == l).map(|(_, x)| *x).unwrap_or_else(|| panic!("no {l} label in {centres:?}")); + assert!((at("W") - at("H")).abs() < 0.01, "W and H: {centres:?}"); + assert!((at("X") - at("Y")).abs() < 0.01, "X and Y: {centres:?}"); + } + #[test] fn document_dropdowns_map_to_live_mode_commands() { for id in MODES.iter().map(|m| m.2).chain(DEPTHS.iter().map(|d| d.2)) { diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 0aab2769ea..0412c4fc67 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -38,11 +38,13 @@ pub enum Group { History, /// Layers | Channels | Paths. Layers, + /// Brushes presets. + Brushes, } impl Group { /// Photoshop Essentials order, top to bottom. - pub const ALL: [Group; 6] = [Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History, Group::Layers]; + pub const ALL: [Group; 7] = [Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History, Group::Layers, Group::Brushes]; pub fn key(self) -> &'static str { match self { @@ -52,6 +54,7 @@ impl Group { Group::Navigator => "navigator", Group::History => "history", Group::Layers => "layers", + Group::Brushes => "brushes", } } @@ -65,6 +68,7 @@ impl Group { Group::Navigator => 210.0, Group::History => 200.0, Group::Layers => 320.0, + Group::Brushes => 200.0, } } @@ -78,6 +82,7 @@ impl Group { Group::Navigator => 140.0, Group::History => 130.0, Group::Layers => 200.0, + Group::Brushes => 150.0, } } @@ -86,6 +91,7 @@ impl Group { pub fn preferred_fill(self) -> f32 { match self { Group::Layers => 500.0, + Group::Brushes => 100.0, g => g.min_height(), } } @@ -94,6 +100,7 @@ impl Group { pub fn min_height(self) -> f32 { match self { Group::Layers => 140.0, + Group::Brushes => 100.0, _ => 80.0, } } @@ -108,6 +115,7 @@ impl Group { Group::Navigator => &["Navigator", "Histogram", "Info"], Group::History => &["History", "Actions", "Layer Comps"], Group::Layers => &["Layers", "Channels", "Paths"], + Group::Brushes => &["Brushes"], } } @@ -125,6 +133,7 @@ impl Group { Group::Navigator => &mut tabs.navigator, Group::History => &mut tabs.history, Group::Layers => &mut tabs.layers, + Group::Brushes => &mut tabs.brushes, } } @@ -141,6 +150,7 @@ impl Group { Group::Navigator => panels.navigator, Group::History => panels.history, Group::Layers => panels.layers, + Group::Brushes => panels.brushes, } } @@ -152,6 +162,7 @@ impl Group { Group::Navigator => &mut panels.navigator, Group::History => &mut panels.history, Group::Layers => &mut panels.layers, + Group::Brushes => &mut panels.brushes, } } } @@ -510,10 +521,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod ui.set_min_height(inner); body(app, ui, g, tab); } else { - // The pro Color field stretches to the group's height instead of a fixed 120 pt. - if t.pro && g == Group::Color && tab == 0 { - ui.data_mut(|d| d.insert_temp(crate::panels::color_field_fill_id(), inner)); - } + crate::panels::offer_color_field_fill(ui, t.pro, g, tab); // No scroll fade: it darkens real colours, swatches and previews at the edge // (#2726); the scroll bar already shows there is more. ui.spacing_mut().scroll.fade.strength = 0.0; diff --git a/crates/ui-egui/src/dock_tests.rs b/crates/ui-egui/src/dock_tests.rs index beb6b913de..f0a64562c4 100644 --- a/crates/ui-egui/src/dock_tests.rs +++ b/crates/ui-egui/src/dock_tests.rs @@ -48,7 +48,7 @@ fn short_columns_squeeze_groups_down_to_their_minimum_and_never_go_negative() { #[test] fn bad_stored_values_are_sanitised() { let mut l = DockLayout { order: vec![Group::Layers, Group::Layers, Group::Color], ..Default::default() }; - assert_eq!(l.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]); + assert_eq!(l.order(), vec![Group::Layers, Group::Brushes, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]); for bad in [f32::NAN, -10.0, f32::INFINITY, 1e12] { l.heights.insert(Group::Color, bad); let h = l.height(Group::Color); @@ -56,7 +56,7 @@ fn bad_stored_values_are_sanitised() { } // Unknown fields, wrong types and old UI state all load. let back: DockLayout = serde_json::from_value(json!({"order": ["layers"], "bogus": 1})).unwrap(); - assert_eq!(back.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]); + assert_eq!(back.order(), vec![Group::Layers, Group::Brushes, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]); let mut ui = serde_json::to_value(crate::state::UiState::default()).unwrap(); ui.as_object_mut().unwrap().remove("dock"); let ui: crate::state::UiState = serde_json::from_value(ui).unwrap(); @@ -308,12 +308,13 @@ fn reset_workspace_restores_the_default_layout_and_new_workspaces_keep_theirs() let (app, _, _) = app_with_layers(); let mut h = harness(app, vec2(1200.0, 800.0), ThemeKind::ProMedium); let ctx = h.ctx.clone(); - let default = last_rects(&h.ctx); h.state_mut().ui.dock.heights.insert(Group::Properties, 120.0); h.state_mut().ui.dock.set_collapsed(Group::Color, true); crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.newWorkspace", json!({"name": "Tall Layers"})).unwrap(); let mine = h.state().ui.dock.clone(); crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.essentials", json!({})).unwrap(); + h.run_steps(3); + let default = last_rects(&h.ctx); assert_eq!(h.state().ui.dock, DockLayout::default()); h.state_mut().ui.dock.heights.insert(Group::Color, 300.0); crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.resetWorkspace", json!({})).unwrap(); @@ -802,7 +803,7 @@ fn window_character_opens_its_own_group_and_keeps_properties() { let old = DockLayout { order: vec![Group::Color, Group::Properties, Group::Navigator, Group::History, Group::Layers], ..Default::default() }; assert_eq!(old.order(), Group::ALL.to_vec()); let moved = DockLayout { order: vec![Group::Layers, Group::Color], ..Default::default() }; - assert_eq!(moved.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]); + assert_eq!(moved.order(), vec![Group::Layers, Group::Brushes, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]); } type StripProbe = Option<(Vec<(usize, Rect)>, Rect, Option, Rect)>; diff --git a/crates/ui-egui/src/empty_workspace_tests.rs b/crates/ui-egui/src/empty_workspace_tests.rs new file mode 100644 index 0000000000..f7473dbccc --- /dev/null +++ b/crates/ui-egui/src/empty_workspace_tests.rs @@ -0,0 +1,89 @@ +//! With no document open, double-clicking the empty workspace shows File › Open, as in Photoshop +//! (#5367): with Auto Show the Home Screen on (the Home screen's blank background) and off (the +//! plain workspace). A single click does nothing, and the Home screen's own buttons keep theirs. + +use std::cell::RefCell; +use std::rc::Rc; + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +use crate::file_dialog::{FileDialogReply, FileDialogRequest}; +use crate::{PhotocraftApp, Services}; + +type Requests = Rc>>; + +fn harness(home: bool) -> (Harness<'static, PhotocraftApp>, Requests) { + let requests: Requests = Default::default(); + let held = requests.clone(); + let services = Services { file_dialog: Some(Box::new(move |request, _parent, reply| held.borrow_mut().push((request, reply)))), ..Default::default() }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + app.session.edit_prefs(|p| p.general.auto_show_home_screen = home); + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + // Fonts set up after the first frame only apply from the next one. + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + app.poll_file_dialog(&ctx, None); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + (h, requests) +} + +fn button(h: &mut Harness<'static, PhotocraftApp>, at: Pos2, pressed: bool) { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); +} + +/// `count` clicks two frames apart at `at`, after letting the clock move past earlier clicks. +fn clicks(h: &mut Harness<'static, PhotocraftApp>, at: Pos2, count: usize) { + h.hover_at(at); + for _ in 0..40 { + h.step(); + } + for _ in 0..count { + button(h, at, true); + h.step(); + button(h, at, false); + h.step(); + } + h.run_steps(3); +} + +fn opens(requests: &Requests) -> usize { + requests.borrow().iter().filter(|(r, _)| matches!(r, FileDialogRequest::Open { multiple: true, .. })).count() +} + +#[test] +fn double_clicking_the_empty_workspace_shows_file_open() { + for home in [false, true] { + let (mut h, requests) = harness(home); + assert!(h.state().session.documents().is_empty()); + // Well away from the Home screen's centred card. + let blank = pos2(40.0, 760.0); + clicks(&mut h, blank, 1); + assert_eq!(opens(&requests), 0, "home {home}: a single click opens nothing"); + clicks(&mut h, blank, 2); + assert_eq!(opens(&requests), 1, "home {home}: a double-click shows File › Open"); + } +} + +#[test] +fn the_home_screens_own_widgets_keep_their_double_clicks() { + let (mut h, requests) = harness(true); + // The subtitle is selectable text: a double-click there selects a word, not File › Open. + let at = h.get_by_label_contains("Create a new document or open an existing file").rect().center(); + clicks(&mut h, at, 2); + assert_eq!(opens(&requests), 0, "the label took the double-click"); + // The New document button opens the New Document dialog, not File › Open. + let at = h.get_by_label_contains("New document").rect().center(); + clicks(&mut h, at, 1); + assert_eq!(opens(&requests), 0); + assert_eq!(h.state().ui.dialogs.len(), 1, "the New Document dialog"); +} diff --git a/crates/ui-egui/src/eraser_ui.rs b/crates/ui-egui/src/eraser_ui.rs index 59fc4d4926..01a2e74a00 100644 --- a/crates/ui-egui/src/eraser_ui.rs +++ b/crates/ui-egui/src/eraser_ui.rs @@ -27,7 +27,7 @@ pub fn click(app: &mut PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { "tolerance": o.tolerance, "antiAlias": o.anti_alias, "contiguous": o.contiguous, - "sampleAllLayers": o.sample_all_layers, + "sampleAllLayers": o.sample_all.magic_eraser, "opacity": o.magic_eraser_opacity, }); let r = app.run("paint.magicEraser", p); @@ -92,7 +92,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo widgets::value_field(ui, &mut o.tolerance, 0.0..=255.0, "", 58.0); widgets::checkbox(ui, &mut o.anti_alias, tl!("Anti-alias")); widgets::checkbox(ui, &mut o.contiguous, tl!("Contiguous")); - widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + widgets::checkbox(ui, &mut o.sample_all.magic_eraser, tl!("Sample All Layers")); widgets::vline(ui, 22.0); opt(ui, tl!("Opacity:")); widgets::value_field(ui, &mut o.magic_eraser_opacity, 1.0..=100.0, "%", 62.0); diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index a322ee108d..275221e050 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -38,17 +38,35 @@ fn seed_remembered(app: &PhotocraftApp, f: &mut Map) { f.insert(key.into(), json!(v)); } } - if let Some(v) = saved.get("metadata").and_then(Value::as_str).filter(|v| ["none", "all"].contains(v)) { + if let Some(v) = saved.get("metadata").and_then(Value::as_str).filter(|v| ["none", "exif", "all"].contains(v)) { f.insert("metadata".into(), json!(v)); } if let Some(v) = saved.get("tgaBits").and_then(Value::as_str).filter(|v| ["auto", "24", "32"].contains(v)) { f.insert("tgaBits".into(), json!(v)); } + if let Some(v) = saved.get("sizing").and_then(Value::as_str).filter(|v| ["scale", "pixels"].contains(v)) { + f.insert("sizing".into(), json!(v)); + } + if let Some(v) = saved.get("lockAspect").and_then(Value::as_bool) { + f.insert("lockAspect".into(), json!(v)); + } + if let Some(v) = saved.get("embedProfile").and_then(Value::as_bool) { + f.insert("embedProfile".into(), json!(v)); + } + if let Some(v) = saved.get("profile").and_then(Value::as_str).filter(|v| ["document", "srgb"].contains(v)) { + f.insert("profile".into(), json!(v)); + } + for key in ["width", "height"] { + if let Some(v) = saved.get(key).and_then(Value::as_f64).filter(|v| v.is_finite() && (1.0..=MAX_PX).contains(v)) { + f.insert(key.into(), json!(v.round())); + } + } } /// Record the settings for the next Export As (see [`seed_remembered`]). fn remember(app: &mut PhotocraftApp, f: &Map) { - const KEYS: [&str; 7] = ["format", "quality", "lossless", "transparency", "scale", "metadata", "tgaBits"]; + const KEYS: [&str; 13] = + ["format", "quality", "lossless", "transparency", "scale", "metadata", "tgaBits", "sizing", "width", "height", "lockAspect", "profile", "embedProfile"]; let saved: Map = KEYS.iter().filter_map(|k| f.get(*k).map(|v| ((*k).to_string(), v.clone()))).collect(); app.session.prefs.edit(|p| { p.dialogs.insert(COMMAND.into(), Value::Object(saved)); @@ -67,9 +85,24 @@ pub fn open(app: &mut PhotocraftApp) -> Result { f.insert("scale".into(), json!(100)); f.insert("metadata".into(), json!("none")); f.insert("tgaBits".into(), json!("auto")); + f.insert("sizing".into(), json!("scale")); + f.insert("lockAspect".into(), json!(true)); + f.insert("profile".into(), json!("document")); + f.insert("embedProfile".into(), json!(true)); + f.insert("width".into(), json!(st.doc.size.width)); + f.insert("height".into(), json!(st.doc.size.height)); f.insert("__w".into(), json!(st.doc.size.width)); f.insert("__h".into(), json!(st.doc.size.height)); seed_remembered(app, &mut f); + let explicit = s(&f, "sizing") == "pixels" && f.contains_key("width") && f.contains_key("height"); + if !explicit && let Ok(size) = resize_from_scale(f64::from(st.doc.size.width), f64::from(st.doc.size.height), n(&f, "scale", 100.0)) { + f.insert("width".into(), json!(size.width)); + f.insert("height".into(), json!(size.height)); + if (n(&f, "scale", 100.0) - size.scale).abs() > 1e-9 { + f.insert("scale".into(), json!(size.scale)); + } + f.insert("sizing".into(), json!("scale")); + } Ok(app.ui.open_dialog(DialogKind::Command, f)) } @@ -210,30 +243,25 @@ fn n(f: &Map, k: &str, d: f64) -> f64 { /// The document as exported: scaled (engine Image Size, bicubic) and flattened over white when /// transparency is off or the format has no alpha. fn export_document(doc: &Document, f: &Map, max_side: Option) -> Result { - let mut s = photocraft_engine::Session::new(); - s.add_document(doc.clone(), None); - let mut scale = n(f, "scale", 100.0) / 100.0; - if let Some(m) = max_side { - let long = doc.size.width.max(doc.size.height) as f64 * scale; - if long > m as f64 { - scale *= m as f64 / long; - } + let mut session = photocraft_engine::Session::new(); + session.add_document(doc.clone(), None); + if s(f, "profile") == "srgb" && doc.icc_profile.is_some() { + session.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "perceptual"})).map_err(|e| e.to_string())?; } - // Both dimensions are sent: a scale can leave a 1 px width unchanged while halving the - // height, and the engine would otherwise keep the aspect ratio from the width alone. - let (w, h) = scaled_size(doc.size.width as f64, doc.size.height as f64, scale); + let (w, h) = export_pixels(f64::from(doc.size.width), f64::from(doc.size.height), f, max_side)?; if s_fmt(f) == "ico" && (w > 256.0 || h > 256.0) && max_side.is_none() { return Err("ICO export is limited to 256 × 256 pixels; reduce the export scale".into()); } if (w - doc.size.width as f64).abs() >= 1.0 || (h - doc.size.height as f64).abs() >= 1.0 { - s.execute("image.imageSize", json!({"width": w, "height": h, "resample": if max_side.is_some() { "bilinear" } else { "bicubic" }})) + session + .execute("image.imageSize", json!({"width": w, "height": h, "resample": if max_side.is_some() { "bilinear" } else { "bicubic" }})) .map_err(|e| e.to_string())?; } let fmt = s_fmt(f); if !f.get("transparency").and_then(Value::as_bool).unwrap_or(true) || fmt == "jpg" { - s.execute("layer.flattenImage", json!({})).map_err(|e| e.to_string())?; + session.execute("layer.flattenImage", json!({})).map_err(|e| e.to_string())?; } - s.active().map(|d| (*d.doc).clone()).ok_or_else(|| "export failed".into()) + session.active().map(|d| (*d.doc).clone()).ok_or_else(|| "export failed".into()) } /// The exported pixel size: each side scaled and rounded on its own, never below 1 px. The @@ -242,6 +270,84 @@ fn scaled_size(w: f64, h: f64, scale: f64) -> (f64, f64) { ((w * scale).round().max(1.0), (h * scale).round().max(1.0)) } +/// Largest side Export As will ask Image Size for. The same cap `image.imageSize` enforces. +const MAX_PX: f64 = 300_000.0; + +/// Pixel size, percent scale and whether the two sides stay linked. +#[derive(Clone, Copy, Debug, PartialEq)] +struct ExportSize { + width: f64, + height: f64, + scale: f64, +} + +fn finite_px(v: f64) -> Result { + if !v.is_finite() || !(1.0..=MAX_PX).contains(&v) { + return Err(format!("export size must be within 1..={MAX_PX} pixels")); + } + Ok(v.round()) +} + +/// Percent scale applied to both sides. `scale` is a percent in 1..=1000. +fn resize_from_scale(src_w: f64, src_h: f64, scale: f64) -> Result { + if !scale.is_finite() || !(1.0..=1000.0).contains(&scale) { + return Err("scale must be within 1..=1000 percent".into()); + } + let (width, height) = scaled_size(src_w.max(1.0), src_h.max(1.0), scale / 100.0); + if width > MAX_PX || height > MAX_PX { + return Err(format!("export size must be within 1..={MAX_PX} pixels")); + } + Ok(ExportSize { width, height, scale }) +} + +/// An edited side. With `lock`, the other side keeps the source aspect; otherwise it stays. +fn resize_from_side(src_w: f64, src_h: f64, width: f64, height: f64, lock: bool, edited_width: bool) -> Result { + let src_w = src_w.max(1.0); + let src_h = src_h.max(1.0); + let (width, height) = if edited_width { + let width = finite_px(width)?; + let height = if lock { finite_px(src_h * width / src_w)? } else { finite_px(height)? }; + (width, height) + } else { + let height = finite_px(height)?; + let width = if lock { finite_px(src_w * height / src_h)? } else { finite_px(width)? }; + (width, height) + }; + let scale = if edited_width { width / src_w * 100.0 } else { height / src_h * 100.0 }; + Ok(ExportSize { width, height, scale }) +} + +/// The pixels `export_document` resizes to. Explicit width and height win; otherwise the percent +/// scale does, and a preview limit shrinks the long side before rounding (the same reduction the +/// scale path used before pixel fields existed). +fn export_pixels(src_w: f64, src_h: f64, f: &Map, max_side: Option) -> Result<(f64, f64), String> { + // Exact pixels apply only in pixel mode. Scale mode keeps the percent, so each layer in a + // multi-layer export is scaled from its own size rather than forced to the preview's pixels. + let explicit = if s(f, "sizing") == "pixels" { f.get("width").and_then(Value::as_f64).zip(f.get("height").and_then(Value::as_f64)) } else { None }; + let (mut w, mut h) = if let Some((w, h)) = explicit { + (finite_px(w)?, finite_px(h)?) + } else { + let mut scale = n(f, "scale", 100.0) / 100.0; + if !scale.is_finite() || scale <= 0.0 { + return Err("export scale must be a positive number".into()); + } + if let Some(m) = max_side { + let long = src_w.max(src_h) * scale; + if long > f64::from(m) { + scale *= f64::from(m) / long; + } + } + return Ok(scaled_size(src_w, src_h, scale)); + }; + if let Some(m) = max_side { + let long = w.max(h); + if long > f64::from(m) && long > 0.0 { + (w, h) = scaled_size(w, h, f64::from(m) / long); + } + } + Ok((w, h)) +} + /// Export As already supports lossy WebP; reuse the Quick Export defaults when /// choosing that format so both flows expose the same quality and lossless options. fn set_format_defaults(f: &mut Map, fmt: &str, prefs: &photocraft_engine::prefs::Export) { @@ -276,12 +382,17 @@ fn tga_bits(f: &Map) -> Option { fn settings(f: &Map) -> ExportSettings { let fmt = s_fmt(f); let quality = n(f, "quality", 85.0).clamp(1.0, 100.0) as u8; + let metadata = s(f, "metadata"); ExportSettings { jpeg_quality: (fmt == "jpg").then_some(quality), webp_lossless: fmt != "webp" || lossless(f), webp_quality: (fmt == "webp" && !lossless(f)).then_some(quality), - xmp_all: s(f, "metadata") == "all", + xmp_all: metadata == "all", tga_bits: if fmt == "tga" { tga_bits(f) } else { None }, + embed_icc: f.get("embedProfile").and_then(Value::as_bool).unwrap_or(true), + // Metadata: None drops EXIF and resolution as well as XMP. A missing key keeps them, + // which is what Export As wrote before the choice existed. + embed_metadata: metadata != "none", ..Default::default() } } @@ -293,8 +404,13 @@ fn preview_proxy(doc: &Document, f: &Map) -> Result) -> u64 { - let scale = n(f, "scale", 100.0) / 100.0; - let full = doc.size.width as f64 * scale * doc.size.height as f64 * scale; + let full = match (f.get("width").and_then(Value::as_f64), f.get("height").and_then(Value::as_f64)) { + (Some(w), Some(h)) if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 => w * h, + _ => { + let scale = n(f, "scale", 100.0) / 100.0; + doc.size.width as f64 * scale * doc.size.height as f64 * scale + } + }; let small = (proxy.size.width as f64 * proxy.size.height as f64).max(1.0); (bytes as f64 * full / small) as u64 } @@ -406,31 +522,193 @@ fn poll_or_start_preview( /// The native exporter uses this same IO encoder. Keep the entire estimate off the UI thread. #[cfg(not(target_arch = "wasm32"))] fn estimate_native(doc: &Document, proxy: &Document, f: &Map) -> Option { - let settings = settings(f); - let mut options = photocraft_io::ExportOptions::default(); - if let Some(quality) = settings.jpeg_quality { - options.encode.jpeg_quality = quality; - } - options.encode.webp_lossless = settings.webp_lossless; - if let Some(quality) = settings.webp_quality { - options.encode.webp_quality = quality; - } - options.tiff_layers = settings.tiff_layers; - options.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; - options.tga_bits = settings.tga_bits; - options.image_previews = settings.image_previews; - options.psd_maximize_compatibility = settings.psd_maximize_compatibility; - let bytes = photocraft_io::export(proxy, &format!("estimate.{}", s_fmt(f)), &options).ok()?.bytes; + let bytes = photocraft_io::export(proxy, &format!("estimate.{}", s_fmt(f)), &settings(f).io_options()).ok()?.bytes; Some(scaled_estimate(bytes.len(), doc, proxy, f)) } +fn write_size(f: &mut Map, size: ExportSize, sizing: &str) { + f.insert("width".into(), json!(size.width)); + f.insert("height".into(), json!(size.height)); + f.insert("scale".into(), json!(size.scale)); + f.insert("sizing".into(), json!(sizing)); +} + +/// Scale, width and height. A scale edit sets both sides; a side edit sets exact pixels and, when +/// the link is on, the other side. The widget is compared with the value it was given so a field +/// that only clamps an out-of-range scale does not overwrite an exact pixel size. +fn size_fields(ui: &mut egui::Ui, f: &mut Map, src_w: f64, src_h: f64) { + let t = Tokens::get(ui.ctx()); + let stored = n(f, "scale", 100.0); + let passed = stored.clamp(1.0, 1000.0).round(); + let mut sc = passed as f32; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Scale")).color(t.text_dim)); + crate::widgets::value_field(ui, &mut sc, 1.0..=1000.0, "%", 70.0); + }); + let sc_r = f64::from(sc).round().clamp(1.0, 1000.0); + if (sc_r - passed).abs() > 0.01 + && let Ok(size) = resize_from_scale(src_w, src_h, sc_r) + { + write_size(f, size, "scale"); + } + let lock = f.get("lockAspect").and_then(Value::as_bool).unwrap_or(true); + let stored_w = n(f, "width", src_w.max(1.0)); + let mut width = stored_w.clamp(1.0, MAX_PX) as f32; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Width")).color(t.text_dim)); + crate::widgets::value_field(ui, &mut width, 1.0..=MAX_PX as f32, "px", 70.0); + }); + let width_r = f64::from(width).round(); + if (width_r - stored_w.round()).abs() > 0.01 + && let Ok(size) = resize_from_side(src_w, src_h, width_r, n(f, "height", src_h.max(1.0)), lock, true) + { + write_size(f, size, "pixels"); + } + let stored_h = n(f, "height", src_h.max(1.0)); + let mut height = stored_h.clamp(1.0, MAX_PX) as f32; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Height")).color(t.text_dim)); + crate::widgets::value_field(ui, &mut height, 1.0..=MAX_PX as f32, "px", 70.0); + }); + let height_r = f64::from(height).round(); + if (height_r - stored_h.round()).abs() > 0.01 + && let Ok(size) = resize_from_side(src_w, src_h, n(f, "width", src_w.max(1.0)), height_r, lock, false) + { + write_size(f, size, "pixels"); + } + let mut linked = lock; + if crate::widgets::checkbox(ui, &mut linked, tl!("Constrain proportions")).changed() { + f.insert("lockAspect".into(), json!(linked)); + if linked && let Ok(size) = resize_from_side(src_w, src_h, n(f, "width", src_w.max(1.0)), n(f, "height", src_h.max(1.0)), true, true) { + write_size(f, size, "pixels"); + } + } +} + +fn apply_preset(f: &mut Map, settings: &photocraft_engine::export_preset_cmds::ExportPresetSettings, src_w: f64, src_h: f64) { + f.insert("format".into(), json!(settings.format)); + f.insert("quality".into(), json!(settings.quality)); + f.insert("lossless".into(), json!(settings.lossless)); + f.insert("transparency".into(), json!(settings.transparency)); + f.insert("scale".into(), json!(settings.scale)); + f.insert("lockAspect".into(), json!(settings.lock_aspect)); + f.insert("sizing".into(), json!(settings.sizing)); + f.insert("metadata".into(), json!(settings.metadata)); + f.insert("profile".into(), json!(settings.profile)); + f.insert("embedProfile".into(), json!(settings.embed_profile)); + f.insert("tgaBits".into(), json!(settings.tga_bits)); + if settings.sizing == "pixels" { + f.insert("width".into(), json!(settings.width)); + f.insert("height".into(), json!(settings.height)); + } else if let Ok(size) = resize_from_scale(src_w, src_h, settings.scale) { + f.insert("width".into(), json!(size.width)); + f.insert("height".into(), json!(size.height)); + f.insert("scale".into(), json!(size.scale)); + } +} + +fn preset_settings(f: &Map) -> Value { + let metadata = s(f, "metadata"); + let metadata = if ["none", "exif", "all"].contains(&metadata.as_str()) { metadata } else { "none".into() }; + let bits = s(f, "tgaBits"); + let bits = if ["auto", "24", "32"].contains(&bits.as_str()) { bits } else { "auto".into() }; + let scale = n(f, "scale", 100.0); + let scale = if scale.is_finite() { scale.clamp(1.0, 1000.0) } else { 100.0 }; + json!({ + "format": s_fmt(f), + "quality": n(f, "quality", 85.0).clamp(1.0, 100.0).round(), + "lossless": lossless(f), + "transparency": f.get("transparency").and_then(Value::as_bool).unwrap_or(true), + "scale": scale, + "width": n(f, "width", 1.0).clamp(1.0, MAX_PX).round(), + "height": n(f, "height", 1.0).clamp(1.0, MAX_PX).round(), + "lockAspect": f.get("lockAspect").and_then(Value::as_bool).unwrap_or(true), + "sizing": if s(f, "sizing") == "pixels" { "pixels" } else { "scale" }, + "metadata": metadata, + "profile": if s(f, "profile") == "srgb" { "srgb" } else { "document" }, + "embedProfile": f.get("embedProfile").and_then(Value::as_bool).unwrap_or(true), + "tgaBits": bits, + }) +} + +fn preset_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, src_w: f64, src_h: f64) { + let t = Tokens::get(ui.ctx()); + ui.add_space(8.0); + let names: Vec = app.session.presets.export_presets.iter().map(|p| p.name.clone()).collect(); + if !names.is_empty() { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Preset")).color(t.text_dim)); + let mut selected = s(f, "__preset"); + let mut opts = Vec::with_capacity(names.len() + 1); + opts.push(("".to_string(), "—")); + for name in &names { + opts.push((name.clone(), name.as_str())); + } + if crate::widgets::dropdown(ui, "export-preset", &mut selected, &opts, 130.0) { + f.insert("__preset".into(), json!(selected)); + if let Some(preset) = app.session.presets.export_presets.iter().find(|p| p.name == s(f, "__preset")).cloned() { + apply_preset(f, &preset.settings, src_w, src_h); + } + } + }); + if !s(f, "__preset").is_empty() && crate::widgets::secondary_button(ui, tl!("Delete"), 84.0).clicked() { + let name = s(f, "__preset"); + match app.run("file.export.presets.delete", json!({"name": name})) { + Ok(_) => { + f.remove("__preset"); + f.remove("__presetError"); + } + Err(e) => { + f.insert("__presetError".into(), json!(e)); + } + } + } + } + if f.get("__savingPreset").and_then(Value::as_bool) != Some(true) { + if crate::widgets::secondary_button(ui, tl!("Save Preset…"), 160.0).clicked() { + f.insert("__savingPreset".into(), json!(true)); + f.remove("__presetError"); + } + } else { + let enter = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + let label = ui.label(tl!("Preset Name")); + let mut name = s(f, "__presetName"); + ui.add(egui::TextEdit::singleline(&mut name).id_salt("export-preset-name").desired_width(160.0).char_limit(255)).labelled_by(label.id); + f.insert("__presetName".into(), json!(name)); + ui.horizontal(|ui| { + if crate::widgets::secondary_button(ui, tl!("Save"), 70.0).clicked() || enter { + match app.run("file.export.presets.save", json!({"name": name, "settings": preset_settings(f)})) { + Ok(v) => { + if let Some(saved) = v.get("preset").and_then(|p| p.get("name")).and_then(Value::as_str) { + f.insert("__preset".into(), json!(saved)); + } + f.remove("__savingPreset"); + f.remove("__presetName"); + f.remove("__presetError"); + } + Err(e) => { + f.insert("__presetError".into(), json!(e)); + } + } + } + if crate::widgets::secondary_button(ui, tl!("Cancel"), 70.0).clicked() { + f.remove("__savingPreset"); + f.remove("__presetError"); + } + }); + } + if let Some(error) = f.get("__presetError").and_then(Value::as_str) { + ui.add(egui::Label::new(error).wrap()); + } +} + pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); let Some(doc) = app.session.active().map(|s| s.doc.clone()) else { return }; ui.horizontal_top(|ui| { // Left: settings. ui.vertical(|ui| { - ui.set_width(220.0); + ui.set_width(248.0); ui.label(egui::RichText::new(tl!("File Settings")).font(crate::theme::semibold(12.0)).color(t.text)); ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("Format")).color(t.text_dim)); @@ -470,25 +748,35 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = vec![("none".into(), tl!("None")), ("all".into(), tl!("All"))]; + let opts: Vec<(String, &str)> = vec![("none".into(), tl!("None")), ("exif".into(), tl!("EXIF")), ("all".into(), tl!("All"))]; if crate::widgets::dropdown(ui, "export-metadata", &mut m, &opts, 130.0) { f.insert("metadata".into(), json!(m)); } }); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Color Profile")).color(t.text_dim)); + let mut profile = s(f, "profile"); + if profile != "srgb" { + profile = "document".into(); + } + let opts: Vec<(String, &str)> = vec![("document".into(), tl!("Document Profile")), ("srgb".into(), tl!("Convert to sRGB"))]; + if crate::widgets::dropdown(ui, "export-profile", &mut profile, &opts, 130.0) { + f.insert("profile".into(), json!(profile)); + } + }); + let mut embed = f.get("embedProfile").and_then(Value::as_bool).unwrap_or(true); + crate::widgets::checkbox(ui, &mut embed, tl!("Embed Color Profile")); + f.insert("embedProfile".into(), json!(embed)); ui.add_space(8.0); ui.label(egui::RichText::new(tl!("Image Size")).font(crate::theme::semibold(12.0)).color(t.text)); - let mut sc = n(f, "scale", 100.0) as f32; - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Scale")).color(t.text_dim)); - crate::widgets::value_field(ui, &mut sc, 1.0..=1000.0, "%", 70.0); - }); - f.insert("scale".into(), json!(sc.round())); let (bw, bh) = (n(f, "__w", f64::from(doc.size.width)), n(f, "__h", f64::from(doc.size.height))); - let (w, h) = scaled_size(bw, bh, f64::from(sc) / 100.0); + size_fields(ui, f, bw, bh); + let (w, h) = (n(f, "width", bw), n(f, "height", bh)); ui.label(egui::RichText::new(format!("{w} × {h} px")).color(t.text_dim).size(11.5)); if fmt == "ico" && (w > 256.0 || h > 256.0) { ui.label(egui::RichText::new(tl!("ICO is limited to 256 × 256 pixels")).color(t.warning)); } + preset_fields(app, ui, f, bw, bh); }); ui.add_space(12.0); // Right: preview + estimated size. @@ -496,14 +784,19 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = ui.data(|d| d.get_temp(key)); let preview = match cached.filter(|cached| cached.signature == sig) { @@ -614,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")?; @@ -901,4 +1197,120 @@ mod tests { f.insert("format".into(), json!("png")); assert_eq!(settings(&f).tga_bits, None, "only TGA has bits per pixel"); } + + #[test] + fn exact_pixels_lock_aspect_and_leave_the_source() { + let linked = resize_from_scale(400.0, 200.0, 50.0).unwrap(); + assert_eq!((linked.width, linked.height, linked.scale), (200.0, 100.0, 50.0)); + let from_width = resize_from_side(400.0, 200.0, 100.0, 200.0, true, true).unwrap(); + assert_eq!((from_width.width, from_width.height), (100.0, 50.0)); + let from_height = resize_from_side(400.0, 200.0, 100.0, 40.0, true, false).unwrap(); + assert_eq!((from_height.width, from_height.height), (80.0, 40.0)); + let free = resize_from_side(400.0, 200.0, 100.0, 80.0, false, true).unwrap(); + assert_eq!((free.width, free.height), (100.0, 80.0)); + assert!(resize_from_scale(400.0, 200.0, 0.0).is_err()); + assert!(resize_from_side(400.0, 200.0, 0.0, 10.0, false, true).is_err()); + assert!(resize_from_side(10.0, 10.0, 400_000.0, 10.0, false, true).is_err()); + + let doc = Document::with_background( + "x", + photocraft_doc::Size::new(400, 200), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_doc::Color::WHITE, + ); + let before = doc.clone(); + let mut f = Map::new(); + f.insert("format".into(), json!("png")); + f.insert("scale".into(), json!(100)); + f.insert("sizing".into(), json!("pixels")); + f.insert("width".into(), json!(120)); + f.insert("height".into(), json!(40)); + let out = export_document(&doc, &f, None).unwrap(); + assert_eq!((out.size.width, out.size.height), (120, 40), "explicit pixels win over the percent scale"); + let proxy = export_document(&doc, &f, Some(20)).unwrap(); + assert_eq!((proxy.size.width, proxy.size.height), (20, 7)); + assert_eq!(doc, before, "export leaves the source document unchanged"); + } + + #[test] + fn profile_and_metadata_follow_the_existing_exporter() { + use photocraft_cms::Builtin; + + let mut f = Map::new(); + f.insert("format".into(), json!("jpg")); + assert!(settings(&f).embed_metadata, "a missing metadata choice keeps EXIF and resolution"); + assert!(settings(&f).embed_icc); + f.insert("metadata".into(), json!("none")); + f.insert("embedProfile".into(), json!(false)); + let none = settings(&f); + assert!(!none.xmp_all && !none.embed_metadata && !none.embed_icc); + f.insert("metadata".into(), json!("exif")); + let exif = settings(&f); + assert!(!exif.xmp_all && exif.embed_metadata); + f.insert("metadata".into(), json!("all")); + f.insert("embedProfile".into(), json!(true)); + let all = settings(&f); + assert!(all.xmp_all && all.embed_metadata && all.embed_icc); + + let mut doc = Document::with_background( + "x", + photocraft_doc::Size::new(8, 8), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_doc::Color::WHITE, + ); + doc.icc_profile = Some(Builtin::DisplayP3.profile().to_bytes()); + doc.metadata.xmp = Some("secret".into()); + let before = doc.clone(); + f.insert("profile".into(), json!("srgb")); + f.insert("width".into(), json!(8)); + f.insert("height".into(), json!(8)); + f.insert("metadata".into(), json!("none")); + f.insert("embedProfile".into(), json!(false)); + let converted = export_document(&doc, &f, None).unwrap(); + let parsed = photocraft_cms::Profile::parse(converted.icc_profile.as_deref().unwrap()).unwrap(); + assert!(parsed.same_colors(Builtin::Srgb.profile()), "Convert to sRGB replaces the document profile"); + let stripped = photocraft_io::export(&converted, "out.jpg", &settings(&f).io_options()).unwrap().bytes; + assert!(!stripped.windows(b"ICC_PROFILE".len()).any(|w| w == b"ICC_PROFILE")); + assert!(!stripped.windows(b"secret".len()).any(|w| w == b"secret")); + f.insert("metadata".into(), json!("all")); + f.insert("embedProfile".into(), json!(true)); + let kept = photocraft_io::export(&converted, "out.jpg", &settings(&f).io_options()).unwrap().bytes; + assert!(kept.windows(b"ICC_PROFILE".len()).any(|w| w == b"ICC_PROFILE")); + assert!(kept.windows(b"secret".len()).any(|w| w == b"secret")); + assert_eq!(doc, before); + } + + #[test] + fn applying_a_preset_restores_its_size_mode() { + use photocraft_engine::export_preset_cmds::ExportPresetSettings; + + let pixels = ExportPresetSettings { + format: "png".into(), + quality: 80, + lossless: false, + transparency: true, + scale: 100.0, + width: 64, + height: 32, + lock_aspect: false, + sizing: "pixels".into(), + metadata: "exif".into(), + profile: "document".into(), + embed_profile: false, + tga_bits: "auto".into(), + }; + let mut f = Map::new(); + apply_preset(&mut f, &pixels, 400.0, 200.0); + assert_eq!(f.get("width"), Some(&json!(64))); + assert_eq!(f.get("height"), Some(&json!(32))); + assert_eq!(f.get("sizing"), Some(&json!("pixels"))); + assert_eq!(f.get("embedProfile"), Some(&json!(false))); + let scale = ExportPresetSettings { sizing: "scale".into(), scale: 25.0, ..pixels }; + apply_preset(&mut f, &scale, 400.0, 200.0); + assert_eq!(f.get("width"), Some(&json!(100.0))); + assert_eq!(f.get("height"), Some(&json!(50.0))); + assert_eq!(f.get("sizing"), Some(&json!("scale"))); + } } diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 5f7dfc5c98..bb827274ff 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -161,6 +161,116 @@ fn answer(open: &Open, answer: Option) { std::thread::spawn(move || reply.send(answer)).join().unwrap(); } +#[test] +fn menu_place_starts_transform_for_each_chosen_image() { + for command in ["file.placeEmbedded", "file.placeLinked"] { + let (mut app, open, _) = app(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let bytes = photocraft_io::export(&app.session.active().unwrap().doc, "source.png", &Default::default()).unwrap().bytes; + let ctx = egui::Context::default(); + let initial_layers = app.session.active().unwrap().doc.layer_count(); + let initial_history = app.session.active().unwrap().history.past_len(); + let linked_dir = (command == "file.placeLinked").then(|| { + let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("photocraft-menu-place-{}-{unique}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + dir + }); + for index in 0..2 { + menus::invoke(&mut app, &ctx, command, json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + let picked = if let Some(dir) = &linked_dir { + let path = dir.join(format!("drawing-{index}.png")); + std::fs::write(&path, &bytes).unwrap(); + FileDialogAnswer::Paths(vec![path.to_string_lossy().into_owned()]) + } else { + FileDialogAnswer::Contents(format!("drawing-{index}.png"), bytes.clone()) + }; + answer(&open, Some(picked)); + app.poll_file_dialog(&ctx, None); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer_count(), initial_layers + index + 1); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, format!("drawing-{index}")); + let transform = app.ui.transform.as_ref().expect("File > Place must offer transform handles after the picker completes"); + assert_eq!(transform.made, Some(crate::state::MadeLayer::Place)); + if index == 0 { + let layer = st.active_layer.unwrap(); + let photocraft_doc::LayerContent::Smart(smart) = &st.doc.layer(layer).unwrap().content else { panic!("placed Smart Object") }; + let original_scale = smart.transform.m[0]; + let transform = app.ui.transform.as_mut().unwrap(); + let left = transform.quad[0][0]; + for corner in &mut transform.quad { + corner[0] = left + (corner[0] - left) * 2.0; + } + crate::transform_tool::commit(&mut app); + assert!(app.ui.transform.is_none(), "resizing the placed image must commit"); + let st = app.session.active().unwrap(); + let photocraft_doc::LayerContent::Smart(smart) = &st.doc.layer(layer).unwrap().content else { panic!("placed Smart Object") }; + assert!((smart.transform.m[0] - original_scale * 2.0).abs() < 1e-8, "placement width must double"); + assert_eq!(app.session.active().unwrap().history.past_len(), initial_history + 1); + } else { + crate::transform_tool::cancel(&mut app); + assert_eq!(app.session.active().unwrap().doc.layer_count(), initial_layers + 1); + assert_eq!(app.session.active().unwrap().history.past_len(), initial_history + 1); + } + } + if let Some(dir) = linked_dir { + for index in 0..2 { + std::fs::remove_file(dir.join(format!("drawing-{index}.png"))).unwrap(); + } + std::fs::remove_dir(dir).unwrap(); + } + } +} + +#[test] +fn cancelled_or_invalid_menu_place_does_not_start_transform() { + for command in ["file.placeEmbedded", "file.placeLinked"] { + for reply in [None, Some(FileDialogAnswer::Contents("broken.png".into(), b"not an image".to_vec()))] { + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + let before = app.session.active().unwrap().doc.clone(); + let history = app.session.active().unwrap().history.past_len(); + menus::invoke(&mut app, &ctx, command, json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, reply); + app.poll_file_dialog(&ctx, None); + assert!(app.ui.transform.is_none()); + assert_eq!(app.session.active().unwrap().doc, before); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + } + } +} + +#[test] +fn menu_place_preserves_an_unfinished_transform() { + for start_before_picker in [true, false] { + let (mut app, open, _) = app(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("layer.new.layerFromBackground", json!({})).unwrap(); + let bytes = photocraft_io::export(&app.session.active().unwrap().doc, "source.png", &Default::default()).unwrap().bytes; + let ctx = egui::Context::default(); + if !start_before_picker { + menus::invoke(&mut app, &ctx, "file.placeEmbedded", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + } + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + let transform = app.ui.transform.clone(); + let doc = app.session.active().unwrap().doc.clone(); + let history = app.session.active().unwrap().history.past_len(); + if start_before_picker { + assert!(menus::invoke(&mut app, &ctx, "file.placeEmbedded", json!({})).is_err()); + assert!(!app.file_dialog_open()); + } else { + answer(&open, Some(FileDialogAnswer::Contents("source.png".into(), bytes))); + app.poll_file_dialog(&ctx, None); + } + assert_eq!(app.ui.transform, transform); + assert_eq!(app.session.active().unwrap().doc, doc); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + } +} + #[test] fn open_starts_in_the_last_used_folder() { let (mut app, open, _) = app(); @@ -219,10 +329,23 @@ fn scripts_browse_lists_only_script_files() { assert!(matches!( open.borrow().first().map(|(r, _)| r), Some(FileDialogRequest::Open { multiple: false, extensions: Some(exts), .. }) - if exts.iter().map(String::as_str).collect::>() == ["psjs", "ccjs", "jsx", "js", "jsxbin", "json", "txt"] + if exts.iter().map(String::as_str).collect::>() == ["psjs", "ccjs", "jsx", "js", "jsxbin", "json", "txt", "pcdroplet"] )); } +#[test] +fn scripts_browse_extendscript_picks_report_the_error() { + // Photoshop scripts stay in the picker (matching its filter) and choosing one shows why it won't run. + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + menus::invoke(&mut app, &ctx, "file.scripts.browse", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::Contents("resize.jsx".into(), b"alert('hi');".to_vec()))); + app.poll_file_dialog(&ctx, None); + assert!(app.ui.status_error); + assert!(app.ui.status.contains("not Photoshop ExtendScript"), "{}", app.ui.status); +} + #[test] fn last_used_dir_takes_the_most_recent_parent() { assert_eq!(last_used_dir(&[]), None); @@ -564,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(); @@ -668,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(); @@ -742,6 +943,14 @@ fn export_as_with_several_artboards_selected_writes_one_file_each() { // The sibling path is built with `Path::join`, so Windows turns the separator into `\`. let second = written.borrow()[1].replace('\\', "/"); assert!(second.starts_with("/out/boards-2-") && second.ends_with(".jpg"), "{:?}", written.borrow()); + let written = written.borrow(); + assert_eq!(written.len(), 2); + // First file uses the exact path from the dialog (forward slashes in test) + assert_eq!(std::path::Path::new(&written[0]).file_name().and_then(|n| n.to_str()), Some("boards.jpg")); + // Second file is generated by batch_paths which uses platform path separators + let sibling = std::path::Path::new(&written[1]); + assert_eq!(sibling.parent(), std::path::Path::new(&written[0]).parent()); + assert_eq!(sibling.file_name().and_then(|name| name.to_str()), Some("boards-2-Capsule.jpg")); let mut got = sizes.borrow().iter().map(|(_, w, h)| (*w, *h)).collect::>(); got.sort_unstable(); assert_eq!(got, [(10, 25), (20, 15)], "each board at its own size, scaled to 50 %"); @@ -794,3 +1003,128 @@ fn save_as_openraster_of_what_it_cannot_hold_is_a_copy() { assert!(err.contains("OpenRaster"), "{err}"); assert_eq!(written.borrow().len(), 1); } + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn render_video_menu_asks_for_directory_and_writes_frames_only_after_confirmation() { + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + let dir = std::env::temp_dir().join(format!( + "pc-render-video-ui-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + let path = dir.to_string_lossy().into_owned(); + + let result = menus::invoke(&mut app, &ctx, "file.export.renderVideo", json!({})).unwrap(); + assert_eq!(result["fileDialog"], "folder"); + app.poll_file_dialog(&ctx, None); + assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Folder { .. }, _)])); + assert!(!dir.exists(), "a menu click must not create an output directory"); + + answer(&open, None); + app.poll_file_dialog(&ctx, None); + assert!(!dir.exists(), "cancelling the picker must not render video"); + + menus::invoke(&mut app, &ctx, "file.export.renderVideo", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::FolderTo(path.clone()))); + app.poll_file_dialog(&ctx, None); + let rendered: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|entry| entry.unwrap().path()).collect(); + assert_eq!(rendered.len(), 1, "a document without a timeline exports one frame"); + assert_eq!(rendered[0].extension().and_then(|ext| ext.to_str()), Some("png")); + std::fs::remove_dir_all(&dir).unwrap(); + + // Commands that already supply a directory must not open a file picker. + let result = menus::invoke(&mut app, &ctx, "file.export.renderVideo", json!({"dir": path})).unwrap(); + assert!(result["frames"].as_u64().is_some()); + 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 d67e56dbf0..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. @@ -89,6 +98,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); } @@ -105,6 +116,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(); @@ -131,6 +145,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) { @@ -150,6 +168,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({ @@ -182,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) { @@ -193,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 { @@ -230,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(_) => { @@ -242,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)])), } } } @@ -262,8 +327,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), @@ -323,5 +401,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 bdefab6229..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]); @@ -317,6 +346,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()); @@ -572,6 +623,29 @@ fn a_drop_slot_counts_documents_not_the_tabs_shown() { assert_eq!(crate::canvas::TabStrip { rect: strip.rect, tabs: vec![] }.slot(10.0), 0); } +/// #5687: files dropped from another app's window bring PhotoCraft to the front (a focus request +/// in the frame that takes the drop), so the source window ends up behind it. +#[test] +fn a_drop_brings_the_window_to_the_front() { + let dir = std::env::temp_dir().join(format!("photocraft-drop-focus-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("photo.png"); + std::fs::write(&file, png_bytes(8, 8)).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 600.0)).with_max_steps(8).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()) + }); + h.run_steps(2); + let focus = |h: &egui_kittest::Harness<'_, PhotocraftApp>| { + h.output().viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::Focus)) + }; + assert!(!focus(&h), "no focus request without a drop"); + h.input_mut().dropped_files.push(dropped(file, Err("read from the path".into()))); + h.step(); + assert!(focus(&h), "the drop brings the window forward"); + let _ = std::fs::remove_dir_all(&dir); +} + /// The whole app (eframe harness, real layout): over the tab strip, the pointer read from the OS /// service picks a slot; files dropped there open (in the background) at it; dropped on the canvas they're placed in Free Transform, and Esc takes the place back. #[test] @@ -883,3 +957,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/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 4b708fa1c0..14a512c288 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -55,8 +55,10 @@ fn form(app: &mut PhotocraftApp, command: &str, label: &str, fields: Value, choi json!({"dialog": app.ui.open_dialog(DialogKind::Command, f)}) } -/// File › Scripts › Browse… lists these script files (plus the JSON/text the engine parses). -const SCRIPT_EXTENSIONS: &[&str] = &["psjs", "ccjs", "jsx", "js", "jsxbin", "json", "txt"]; +/// File › Scripts › Browse… lists Photoshop's script types (matching its filter, #2452) plus the +/// JSON/text/droplet files the engine parses; selecting a Photoshop script gets the engine's +/// "not Photoshop ExtendScript" error instead of hiding those files from the picker. +const SCRIPT_EXTENSIONS: &[&str] = &["psjs", "ccjs", "jsx", "js", "jsxbin", "json", "txt", "pcdroplet"]; /// Menu items fronted here (dialogs before the engine command). `None` when not ours. pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { @@ -74,7 +76,7 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &V app.session.active()?; json!({"dialog": open_print(app)}) } - "file.printOneCopy" if app.session.file_menu.last_print.is_none() => { + "file.printOneCopy" if photocraft_engine::print_cmds::last_print(&app.session).is_none() => { app.session.active()?; json!({"dialog": open_print(app)}) } @@ -104,7 +106,7 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &V "file.scripts.statistics" => json!({"dialog": crate::stack_dialog::open_statistics(app)}), "file.scripts.browse" => { return Some(app.pick_file_bytes_filtered(SCRIPT_EXTENSIONS, |app, name, bytes| { - let r = app.run("file.scripts.browse", json!({"script": String::from_utf8_lossy(&bytes)})); + let r = app.run("file.scripts.browse", json!({"name": name, "script": String::from_utf8_lossy(&bytes)})); if r.is_ok() { let template = "Ran script {name}"; app.ui.status = crate::i18n::fmt(tl!(template), &[("name", &name)]); @@ -166,11 +168,12 @@ pub fn ok_label(f: &Map) -> Option<&'static str> { } } -pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { +/// The body of Save for Web or Print (dialog `id`). +pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, id: u64) { if f.contains_key("__web") { web_body(app, ui, f); } else { - print_body(app, ui, f); + print_body(app, ui, f, id); } } @@ -726,8 +729,9 @@ pub fn open_print(app: &mut PhotocraftApp) -> u64 { f.insert("__label".into(), json!("PhotoCraft Print Settings")); // Asked once per opening: the Printer menu lists what the spooler knows now. let printers = app.session.execute("file.printers", json!({})).unwrap_or_default(); - f.insert("__printers".into(), printers["printers"].clone()); - if let Some(Value::Object(m)) = app.session.file_menu.last_print.clone() { + f.insert("__systemPrinters".into(), printers["printers"].clone()); + // The settings of the last print, also from an earlier session (#7410). + if let Some(Value::Object(m)) = photocraft_engine::print_cmds::last_print(&app.session) { f.extend(m); } for (k, v) in [ @@ -755,9 +759,35 @@ pub fn open_print(app: &mut PhotocraftApp) -> u64 { ] { f.entry(k.to_string()).or_insert(v); } + // Windows: the system's printers, and the paper from the chosen printer's driver settings. + if let Ok(list) = craft_print::rgb::printers() { + f.insert("__printers".into(), json!(list.iter().map(|p| p.name.clone()).collect::>())); + let known = |n: &str| list.iter().any(|p| p.name == n); + if !known(&s(&f, "printer", "")) { + let fallback = list.iter().find(|p| p.default).or(list.first()).map(|p| p.name.clone()).unwrap_or_default(); + f.insert("printer".into(), json!(fallback)); + f.remove("printerSettings"); + } + } app.ui.open_dialog(DialogKind::Command, f) } +/// Takes the paper from the printer's driver settings (Windows), as Photoshop does. +fn driver_page(f: &mut Map) { + let printer = s(f, "printer", ""); + let settings = f.get("printerSettings").and_then(Value::as_str).and_then(|h| craft_print::rgb::DevMode::from_hex(h).ok()); + match craft_print::rgb::page(&printer, settings.as_ref()) { + Ok(pg) => { + f.insert("paper".into(), json!([pg.paper.0, pg.paper.1])); + f.insert("orientation".into(), json!("portrait")); + f.remove("__pageError"); + } + Err(e) => { + f.insert("__pageError".into(), json!(e.to_string())); + } + } +} + /// Points per unit for the Position and Size fields. fn unit_pt(units: &str) -> f64 { match units { @@ -836,10 +866,42 @@ fn print_paper_controls(ui: &mut egui::Ui, f: &mut Map) { } } -fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { +/// Save as PDF › Browse…: where to write the PDF, into Print dialog `id` once the file dialog +/// answers. Without it the only way to save a PDF was to type a full path (#5328). +fn browse_pdf(app: &mut PhotocraftApp, id: u64, suggested: &str) { + let r = app.pick_save(suggested, move |app, path| { + let d = app.ui.dialog_mut(id).ok_or("the dialog was closed")?; + d.fields.insert("output".into(), json!(path)); + Ok(Value::Null) + }); + if let Err(e) = r + && e != crate::file_dialog::CANCELLED + { + app.ui.status = e; + } +} + +fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, id: u64) { let t = Tokens::get(ui.ctx()); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + // PDF exports and previews retain editable paper on Windows too. Only a real native + // submission takes its media from the printer; entering that mode refreshes the driver + // geometry after any export-only paper edits. + let printers: Option> = if !b(f, "dryRun", false) && s(f, "output", "").is_empty() { + f.get("__printers").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) + } else { + None + }; + if printers.is_some() { + if !b(f, "__driverPageInitialized", false) { + driver_page(f); + f.insert("__driverPageInitialized".into(), json!(true)); + } + } else { + f.remove("__driverPageInitialized"); + } let p = params(f); + let mut browse = false; ui.horizontal_top(|ui| { // Page preview. ui.vertical(|ui| { @@ -915,17 +977,57 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = opts.iter().map(|(k, l)| (k.clone(), l.as_str())).collect(); - if crate::widgets::dropdown(ui, "print-printer", &mut pr, &o, 240.0) { - f.insert("printer".into(), json!(pr)); + if let Some(printers) = &printers { + // Windows: Photoshop's Printer menu and Print Settings… (the driver's own dialog). + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Printer")).color(t.text_dim)); + let before = s(f, "printer", ""); + let opts: Vec<(&str, &str)> = printers.iter().map(|n| (n.as_str(), n.as_str())).collect(); + dropdown_str(ui, "print-printer", f, "printer", &opts, 220.0); + if s(f, "printer", "") != before { + f.remove("printerSettings"); + driver_page(f); + } + }); + ui.horizontal(|ui| { + number(ui, f, "copies", tl!("Copies"), 1.0..=999.0, "", 1.0); + if ui.button(tl!("Print Settings…")).clicked() { + let printer = s(f, "printer", ""); + let current = f.get("printerSettings").and_then(Value::as_str).and_then(|h| craft_print::rgb::DevMode::from_hex(h).ok()); + match craft_print::rgb::settings_dialog(&printer, current.as_ref(), None) { + Ok(Some(dm)) => { + f.insert("printerSettings".into(), json!(dm.to_hex())); + driver_page(f); + } + Ok(None) => {} + Err(e) => { + f.insert("__pageError".into(), json!(e.to_string())); + } + } + } + }); + let paper = p.get("paper").and_then(Value::as_array).map(|a| (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64))); + let text = match paper { + Some((Some(w), Some(h))) => format!("{:.0} × {:.0} mm · {}", w / 72.0 * 25.4, h / 72.0 * 25.4, tl!("set in Print Settings…")), + _ => String::new(), + }; + ui.label(egui::RichText::new(text).color(t.text_dim).size(11.0)); + if let Some(e) = f.get("__pageError").and_then(Value::as_str) { + ui.label(egui::RichText::new(e).color(t.warning).size(11.0)); } - }); - number(ui, f, "copies", tl!("Copies"), 1.0..=999.0, "", 1.0); - print_paper_controls(ui, f); + } else { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Printer")).color(t.text_dim)); + let mut pr = s(f, "printer", ""); + let opts = printer_options(f.get("__systemPrinters"), &pr); + let o: Vec<(String, &str)> = opts.iter().map(|(k, l)| (k.clone(), l.as_str())).collect(); + if crate::widgets::dropdown(ui, "print-printer", &mut pr, &o, 240.0) { + f.insert("printer".into(), json!(pr)); + } + }); + number(ui, f, "copies", tl!("Copies"), 1.0..=999.0, "", 1.0); + print_paper_controls(ui, f); + } head(ui, tl!("Color Management")); dropdown_str( ui, @@ -977,6 +1079,11 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) -> Result { @@ -1058,7 +1176,9 @@ fn print_confirm(app: &mut PhotocraftApp, f: &Map) -> Result) -> Result< let (doc, _, _) = web_cmds::web_document(&st.doc, &proxy, &settings).map_err(|e| e.to_string())?; f.insert("__paletteApproximate".into(), json!(scale < 1.0)); let buf = photocraft_compose::flatten(&doc); - let mut optimized = - web_cmds::optimize(&buf.px, doc.size.width as usize, doc.bounds(), &settings, None, None, doc.resolution_dpi, false).map_err(|e| e.to_string())?; + let mut optimized = web_cmds::optimize(&buf.px, doc.size.width as usize, doc.bounds(), &settings, None, None, None, doc.resolution_dpi, false) + .map_err(|e| e.to_string())?; // Tiny transparent features may disappear in the proxy; keep room for their index. if settings.transparency { optimized.color_table.truncate(255); @@ -137,7 +137,7 @@ pub fn optimized(ui: &mut egui::Ui, key: egui::Id, signature: &str, p: &Value, p } else { let settings = WebSettings::from_params(&base, "preview").map_err(|e| e.to_string())?; let encoded = Arc::new( - web_cmds::optimize(px, w as usize, photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &settings, None, None, 72.0, true) + web_cmds::optimize(px, w as usize, photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &settings, None, None, None, 72.0, true) .map_err(|e| e.to_string())?, ); ui.data_mut(|d| d.insert_temp(key, (signature, encoded.clone()))); diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index f4dacbc575..a908df5eba 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -7,6 +7,8 @@ use std::sync::Arc; use photocraft_doc::Document; +use photocraft_engine::color_cmds::ColorSettings; +use photocraft_engine::installed_profiles::InstalledProfile; use serde_json::{Map, Value, json}; use crate::PhotocraftApp; @@ -110,7 +112,8 @@ pub fn parse_spec(spec: &str) -> Vec { Kind::Color(crate::color_picker_ui::parse_hex(default)) } else if v == "doc" { Kind::Document - } else if v == "json" || v.starts_with("layer id") || v.starts_with('[') || v.starts_with('{') { + } else if v == "json" || v.starts_with("layer id") || v.starts_with('[') || v.starts_with('{') || v.trim_end_matches('?') == "index|name" { + // `index|name`: a reference (an alpha channel…) the dialog sets through another control. Kind::Json } else if let Some(n) = v.strip_prefix("int[").and_then(|r| r.strip_suffix(']')).and_then(|n| n.parse().ok()) { Kind::Grid(n) @@ -200,6 +203,9 @@ fn is_pixel_param_of(command: &str, key: &str) -> bool { /// Sharpen, Sharpen More, Sharpen Edges, Find Edges, Emboss and Despeckle use fixed one-pixel /// kernels with no size parameter, so a proxy changes their look: edges average away, making /// the sharpening and edge detection invisible. These must preview at full resolution (#3929). +/// +/// Content-Aware Fill searches for matching patches, and the matches found on a reduced copy +/// differ from those found at full size, so its preview would not show what OK writes (#6530). const FULL_RESOLUTION_PREVIEW: &[&str] = &[ "filter.distort.ripple", "filter.stylize.diffuse", @@ -222,6 +228,7 @@ const FULL_RESOLUTION_PREVIEW: &[&str] = &[ "filter.stylize.findEdges", "filter.stylize.emboss", "filter.noise.despeckle", + "edit.contentAwareFill", ]; /// Whether `command` with `params` previews at full resolution: [`FULL_RESOLUTION_PREVIEW`], every @@ -279,6 +286,9 @@ pub const PREVIEWED: &[&str] = &[ "layer.matting.colorDecontaminate", "layer.layerStyle.scaleEffects", "type.warpText", + // The preview shows the image through the chosen profile, as Photoshop's Preview does. + "edit.assignProfile", + "edit.convertToProfile", ]; pub fn has_dialog(command: &str) -> bool { @@ -293,8 +303,6 @@ pub fn has_dialog(command: &str) -> bool { command, "image.trim" | "select.refineEdge" - | "edit.assignProfile" - | "edit.convertToProfile" | "view.proofSetup" | "layer.layerStyle.globalLight" | "image.mode.colorTable" @@ -388,6 +396,11 @@ fn apply_command_defaults(app: &PhotocraftApp, command: &str, fields: &mut Map) { } apply_command_defaults(app, &command, fields); // The schema has no default for the Blur Gallery's pins: start again from the opening one. - if command == crate::blur_pins::COMMAND { + if crate::blur_pins::handles(&command) { crate::blur_pins::reset(fields); } } @@ -458,9 +471,12 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { } restore_remembered(app, command, spec.params, &mut fields); apply_command_defaults(app, command, &mut fields); - if command == crate::blur_pins::COMMAND { + if crate::blur_pins::handles(command) { crate::blur_pins::init(app, &mut fields); } + if command == LENS_BLUR { + lens_blur_channels(app, &mut fields); + } if parse_spec(spec.params).iter().any(|p| p.kind == Kind::Document) { // The document picker lists every open document (params refer to them by index). let names: Vec = app.session.documents().iter().map(|d| d.doc.name.clone()).collect(); @@ -556,6 +572,10 @@ pub(crate) fn source_label(key: &str) -> String { if key == "localized" { return "Localized Color Clusters".into(); } + // Convert to Profile's, Proof Setup's…: spelled out, as in Photoshop (not "Bpc"). + if key == "bpc" { + return "Black Point Compensation".into(); + } // A name that already reads as text ("iPhone 14 Pro Max", "Web 1920") is not camelCase. if key.contains(' ') { return key.to_string(); @@ -583,9 +603,63 @@ fn uses_logarithmic_slider(min: f32, max: f32) -> bool { min > 0.0 && max / min > 500.0 } +const LENS_BLUR: &str = "filter.blur.lensBlur"; + +/// Lens Blur's Depth Map Source lists the document's alpha channels after None, Transparency +/// and Layer Mask, as in Photoshop (#7400): their names go to `__channels`. A `channel` source +/// without a channel this document has (references aren't remembered) opens as None. +fn lens_blur_channels(app: &PhotocraftApp, f: &mut Map) { + let names: Vec = app.session.active().map(|d| d.doc.channels.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); + let known = f.get("depthChannel").and_then(|v| channel_index(v, &names)).is_some(); + if f.get("depthMap").and_then(Value::as_str) == Some("channel") && !known { + f.insert("depthMap".into(), json!("none")); + f.remove("depthChannel"); + } + f.insert("__channels".into(), json!(names)); +} + +/// Lens Blur's Source menu entry for the alpha channel at `index`. By index, not name: two +/// channels may share a name, and each must be choosable. +fn channel_choice(index: usize) -> String { + format!("channel:{index}") +} + +/// The alpha channel `v` names (an index, or a name: the first channel so named), as the +/// engine reads `depthChannel`. +fn channel_index(v: &Value, names: &[String]) -> Option { + match v { + Value::Number(n) => n.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < names.len()), + Value::String(name) => names.iter().position(|n| n == name), + _ => None, + } +} + /// Schema dialogs whose `profile` menu lists the installed profiles after the built-ins. const PROFILE_DIALOGS: &[&str] = &["edit.assignProfile", "edit.convertToProfile", "view.proofSetup"]; +/// Which installed profiles a menu of `cmd` lists after its built-ins, if it lists any: every +/// destination profile for the `profile` menus of [`PROFILE_DIALOGS`], and for Color Settings' +/// working spaces those of that space, as Photoshop lists them (#6323). An RGB working space is a +/// display or colour-space profile, not a scanner's or a printer's. +fn installed_filter(cmd: &str, key: &str) -> Option bool> { + use photocraft_cms::{ColorSpace, ProfileClass}; + match (cmd, key) { + (c, "profile") if PROFILE_DIALOGS.contains(&c) => Some(|_| true), + ("edit.colorSettings", "workingRgb") => { + Some(|i| i.color_space == ColorSpace::Rgb && matches!(i.class, ProfileClass::Display | ProfileClass::ColorSpace)) + } + ("edit.colorSettings", "workingCmyk") => Some(|i| i.color_space == ColorSpace::Cmyk), + ("edit.colorSettings", "workingGray") => Some(|i| i.color_space == ColorSpace::Gray), + _ => None, + } +} + +/// The `installed` profiles a menu of `cmd` lists (see [`installed_filter`]), by path and description. +fn installed_choices(cmd: &str, key: &str, installed: &[InstalledProfile]) -> Vec<(String, String)> { + let Some(fits) = installed_filter(cmd, key) else { return Vec::new() }; + installed.iter().filter(|i| i.is_destination() && fits(i)).map(|i| (i.path.clone(), i.description.clone())).collect() +} + /// Commands whose `angle` is a direction in document angles (counter-clockwise from three /// o'clock): a light or a blur direction, edited on a dial as in Photoshop's Motion Blur, Emboss /// and Global Light (#3152). Rotation amounts (Twirl, Rotate Arbitrary, Crop, Lens Correction, @@ -607,6 +681,8 @@ fn uses_angle_dial(command: &str, key: &str) -> bool { /// eyedropper like Photoshop's. While armed, the next click on the image writes the merged /// composite colour into the `color` field (the same sampler the Color Picker uses). const REPLACE_COLOR: &str = "image.adjustments.replaceColor"; +/// What OK on an edited smart filter runs (`smart_ui::confirm_command`); its preview too. +const SMART_FILTER_SET_PARAMS: &str = "layer.smartFilter.setParams"; const COLOR_EYEDROPPER: &str = "__colorEyedropper"; fn color_eyedropper_owns(f: &Map) -> bool { @@ -637,6 +713,49 @@ pub(crate) fn color_eyedropper_sample(app: &mut PhotocraftApp, x: f64, y: f64) - true } +/// The Color Picker of Replace Color's Result swatch writes here (#6278). +const RESULT_PICKED: &str = "__resultPicked"; +/// The Result the picker opened on: a picked colour that differs from it sets the sliders. +const RESULT_FROM: &str = "__resultFrom"; + +/// Replace Color's Result swatch (#6278), as in Photoshop: the sampled colour after the Hue, +/// Saturation and Lightness shift. Picking a Result in its Color Picker sets the three sliders so +/// the sampled colour becomes it. +fn replace_color_result(ui: &mut egui::Ui, f: &mut Map) { + use photocraft_algo::tone; + let t = Tokens::get(ui.ctx()); + let from = f.get("color").and_then(Value::as_str).and_then(crate::color_picker_ui::parse_hex).unwrap_or([0.0; 3]); + let picked = f.get(RESULT_PICKED).and_then(Value::as_str).and_then(crate::color_picker_ui::parse_hex); + if let Some(to) = picked + && f.get(RESULT_PICKED) != f.get(RESULT_FROM) + { + let (hue, saturation, lightness) = tone::replace_color_shift(from, to); + for (key, v) in [("hue", hue), ("saturation", saturation), ("lightness", lightness)] { + f.insert(key.into(), json!(v.round())); + } + f.remove(RESULT_PICKED); + f.remove(RESULT_FROM); + } + let num = |key: &str| f.get(key).and_then(Value::as_f64).unwrap_or(0.0) as f32; + let rgb = tone::replace_color_result(from, num("hue"), num("saturation"), num("lightness")); + let bytes = rgb.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + let name = tl!("Result"); + let mut clicked = false; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(name).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + clicked = crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(bytes[0], bytes[1], bytes[2]), name).clicked(); + ui.label(egui::RichText::new(crate::color_picker_ui::hex(rgb)).color(t.text_dim).monospace()); + }); + }); + if clicked { + let hex = json!(crate::color_picker_ui::hex(rgb)); + f.insert(RESULT_PICKED.into(), hex.clone()); + f.insert(RESULT_FROM.into(), hex); + crate::color_picker_ui::request_field(f, RESULT_PICKED, &crate::color_picker_ui::title_for(name)); + } +} + /// Dialog body for filter commands. pub fn body(ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); @@ -696,7 +815,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { ui.add_space(4.0); } else { // A double-click on the slider puts it back to the filter's default. - let gestures = crate::widgets::RowGestures { reset: Some(default), wheel_step: None }; + let gestures = crate::widgets::RowGestures { reset: Some(default), wheel_step: None, ..Default::default() }; crate::widgets::slider_row_with(ui, &label(&p.key), &mut v, min..=max, unit, None, gestures); } }); @@ -711,14 +830,29 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { let mut cur = f.get(&p.key).and_then(Value::as_str).unwrap_or(initial).to_string(); let labels: Vec = options.iter().map(|o| choice_label(o)).collect(); let mut opts: Vec<(String, &str)> = options.iter().cloned().zip(labels.iter().map(String::as_str)).collect(); - // Like Photoshop, the profile menus also list every profile installed in - // the system (printer and paper profiles), by description. - let installed = if p.key == "profile" && PROFILE_DIALOGS.contains(&cmd.as_str()) { - photocraft_engine::installed_profiles::installed().into_iter().filter(|i| i.is_destination()).collect() + // Lens Blur's Source: each alpha channel by its name, in place of `channel`. + let depth = cmd == LENS_BLUR && p.key == "depthMap"; + let names: Vec = if depth { + f.get("__channels").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect() } else { Vec::new() }; - opts.extend(installed.iter().map(|i| (i.path.clone(), i.description.as_str()))); + let channels: Vec = (0..names.len()).map(channel_choice).collect(); + if depth { + opts.retain(|(v, _)| v != "channel"); + opts.extend(channels.iter().cloned().zip(names.iter().map(String::as_str))); + if cur == "channel" + && let Some(i) = f.get("depthChannel").and_then(|v| channel_index(v, &names)) + { + cur = channel_choice(i); + } + } + // Like Photoshop, the profile menus also list the profiles installed in the + // system (printer and paper profiles, press conditions), by description. + let lists_installed = installed_filter(&cmd, &p.key).is_some(); + let installed = + if lists_installed { installed_choices(&cmd, &p.key, &photocraft_engine::installed_profiles::installed()) } else { Vec::new() }; + opts.extend(installed.iter().map(|(path, description)| (path.clone(), description.as_str()))); // Assign Profile: only what the document's colour mode can take (#4951). if p.key == "profile" && let Some(allowed) = f.get("__profiles").and_then(Value::as_array) @@ -728,10 +862,16 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { // A profile given by path that isn't listed (typed by an agent, or spelled with // different case) still shows, by its file name, instead of a blank menu. let stem = std::path::Path::new(&cur).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - if p.key == "profile" && PROFILE_DIALOGS.contains(&cmd.as_str()) && !opts.iter().any(|(v, _)| *v == cur) { + if lists_installed && !opts.iter().any(|(v, _)| *v == cur) { opts.push((cur.clone(), stem.as_str())); } crate::widgets::dropdown(ui, &format!("flt-{cmd}-{}", p.key), &mut cur, &opts, 170.0); + if let Some(i) = cur.strip_prefix("channel:").filter(|_| depth).and_then(|i| i.parse::().ok()) { + f.insert("depthChannel".into(), json!(i)); + cur = "channel".into(); + } else if depth { + f.remove("depthChannel"); + } f.insert(p.key.clone(), json!(cur)); }); } @@ -822,11 +962,19 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { ui.horizontal(|ui| { ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim)); crate::widgets::value_field(ui, &mut v, -30000.0..=30000.0, "", 80.0); + // A seed gets a fresh random value in one click (#5710): Clouds, Add Noise, + // Fibers… then render a new pattern, as each run of Photoshop's does. + if p.key == "seed" && ui.button(tl!("Random")).clicked() { + v = random_seed(ui.input(|i| i.time).to_bits(), v as i64) as f32; + } }); f.insert(p.key, json!(v.round() as i64)); } } } + if cmd == REPLACE_COLOR { + replace_color_result(ui, f); + } // Read-only context the dialog opener supplies (e.g. the monitor profile in use). if let Some(note) = f.get("__note").and_then(Value::as_str) { ui.add_space(4.0); @@ -839,6 +987,15 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { } } +/// A new seed for a dialog's Random button: 0..=30000 (what the field takes), never `current`. +/// `entropy` varies per click (the input time); the process's random hasher keys mix it. +fn random_seed(entropy: u64, current: i64) -> i64 { + use std::hash::BuildHasher; + let h = std::collections::hash_map::RandomState::new().hash_one(entropy); + let seed = (h % 30_001) as i64; + if seed == current { (seed + 1) % 30_001 } else { seed } +} + /// User-facing params (strip the dialog's private `__` keys). pub fn params_of(f: &Map) -> Value { // A document picker left at "None" (-1) means "not given". @@ -861,12 +1018,23 @@ pub fn preview_document_with( k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>, ) -> Option { - preview_document_for_target(doc, active, command, params, k, cancel, photocraft_engine::channel_cmds::ChannelTarget::Composite, &[]) + preview_document_for_target( + doc, + active, + command, + params, + k, + cancel, + photocraft_engine::channel_cmds::ChannelTarget::Composite, + &[], + &ColorSettings::default(), + ) } /// Run the preview with the same pixel target as the final command, including individual channels. /// `documents` are the open documents in session order: document params (Match Color's Source, -/// Displace's map) index into them. +/// Displace's map) index into them. `color` is the app's Edit › Color Settings: Assign and Convert +/// to Profile's `working` is the working space chosen there, not the built-in default. #[allow(clippy::too_many_arguments)] pub(crate) fn preview_document_for_target( doc: &Document, @@ -877,15 +1045,18 @@ pub(crate) fn preview_document_for_target( cancel: Option<&photocraft_engine::jobs::JobCtx>, target: photocraft_engine::channel_cmds::ChannelTarget, documents: &[Arc], + color: &ColorSettings, ) -> Option { if command == crate::select_mask_view::COMMAND { return crate::select_mask_view::preview_document(doc, active, params, k, cancel); } // Warp Text renders the type again from the layer model (font size, position), which a - // reduced proxy doesn't scale: it runs at full size and the result is reduced (#218). - let (run_k, reduce_k) = if command == "type.warpText" { (1, k) } else { (k, 1) }; + // reduced proxy doesn't scale: it runs at full size and the result is reduced (#218). So does + // a smart filter being edited: the smart object renders again from its source (#6324). + let (run_k, reduce_k) = if command == "type.warpText" || command == SMART_FILTER_SET_PARAMS { (1, k) } else { (k, 1) }; let proxy = crate::proxy::proxy_document(doc, run_k); let mut s = photocraft_engine::Session::new(); + s.color.settings = color.clone(); s.set_inline_job_ctx(cancel.cloned()); let doc_id = proxy.id; s.add_document(proxy, None); @@ -962,7 +1133,13 @@ pub struct FilterPreview { pub committing: bool, } -/// Match the full request before accepting a worker result, including a reopened dialog. +/// Identity of one filter dialog's live preview. +/// +/// `PartialEq` is the whole request: it says a worker result is exactly what the dialog is asking +/// for now. [`Self::same_target`] is the looser question of whether a result belongs to this +/// preview at all; two keys that agree on it name the same dialog, layer, document and preview +/// resolution, and differ only in parameters — which is what a slider drag produces, since the +/// worker computed for the value the pointer passed a moment ago. #[derive(Clone, Debug, PartialEq)] pub struct FilterPreviewKey { pub doc: photocraft_doc::DocId, @@ -975,10 +1152,77 @@ pub struct FilterPreviewKey { pub k: u32, } +impl FilterPreviewKey { + /// Everything except the parameters: same dialog, document, revision, layer, channel target, + /// command and proxy factor. + /// + /// The canvas shows whatever this preview was computed for, so a result whose parameters are + /// a few frames old still belongs on screen while a slider is moving; matching on everything + /// would mean no frame of a drag is ever shown, because every completion lands after the + /// parameters moved on. A reopened dialog has a new id, a different command or a different + /// document, and a committed document a new revision, so neither survives this test. + pub fn same_target(&self, other: &Self) -> bool { + self.doc == other.doc + && self.revision == other.revision + && self.dialog == other.dialog + && self.active == other.active + && self.target == other.target + && self.command == other.command + && self.k == other.k + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn color_settings_working_spaces_list_installed_profiles_of_their_space() { + // #6323: a press condition installed in the system can be the CMYK working space, as in + // Photoshop's Color Settings; each working-space menu lists only profiles of its space. + use photocraft_cms::{ColorSpace, ProfileClass}; + let p = |path: &str, class, color_space| InstalledProfile { path: path.into(), description: path.to_uppercase(), class, color_space }; + let installed = [ + p("gracol.icc", ProfileClass::Output, ColorSpace::Cmyk), + p("monitor.icc", ProfileClass::Display, ColorSpace::Rgb), + p("printer-rgb.icc", ProfileClass::Output, ColorSpace::Rgb), + p("scanner.icc", ProfileClass::Input, ColorSpace::Rgb), + p("gray.icc", ProfileClass::Output, ColorSpace::Gray), + p("link.icc", ProfileClass::DeviceLink, ColorSpace::Cmyk), + ]; + let paths = |key: &str| installed_choices("edit.colorSettings", key, &installed).into_iter().map(|(path, _)| path).collect::>(); + assert_eq!(paths("workingCmyk"), ["gracol.icc"]); + assert_eq!(paths("workingRgb"), ["monitor.icc"]); + assert_eq!(paths("workingGray"), ["gray.icc"]); + assert!(paths("policyCmyk").is_empty() && paths("intent").is_empty()); + assert_eq!(installed_choices("edit.colorSettings", "workingCmyk", &installed)[0].1, "GRACOL.ICC", "listed by description"); + // The profile dialogs keep listing every destination profile. + assert_eq!(installed_choices("edit.convertToProfile", "profile", &installed).len(), 5); + assert!(installed_choices("filter.blur.gaussianBlur", "profile", &installed).is_empty()); + } + + /// #5710: the seed field's Random button always gives a different seed the field can hold. + #[test] + fn random_seeds_stay_in_range_and_change() { + let mut seen = std::collections::HashSet::new(); + for i in 0..200u64 { + let s = random_seed(i, 7); + assert!((0..=30_000).contains(&s), "{s}"); + assert_ne!(s, 7, "a click always changes the seed"); + seen.insert(s); + } + assert!(seen.len() > 100, "seeds spread out: {}", seen.len()); + assert_ne!(random_seed(1, 30_000), 30_000); + // Every seed dialog shows the button: Clouds, Add Noise and the like carry a `seed` param. + let specs = photocraft_engine::command_specs(); + assert!( + specs + .iter() + .filter(|c| c.id.starts_with("filter.")) + .any(|c| parse_spec(c.params).iter().any(|p| p.key == "seed" && matches!(p.kind, Kind::Int { .. }))) + ); + } + /// Preset names such as "iPhone 14 Pro Max" (the artboard dialog's choices) are text, not /// camelCase keys: they used to read "I Phone 14 Pro Max". #[test] @@ -988,6 +1232,56 @@ mod tests { assert_eq!(source_label("centerX"), "Center X"); } + /// Convert to Profile's and Proof Setup's `bpc` read "Bpc": spelled out, and translated. + #[test] + fn black_point_compensation_is_spelled_out() { + assert_eq!(source_label("bpc"), "Black Point Compensation"); + // English is the source: it has no catalog. + for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") { + assert!(crate::i18n::has(lang, "Black Point Compensation"), "{lang:?}"); + } + } + + fn key() -> FilterPreviewKey { + FilterPreviewKey { + doc: photocraft_doc::DocId(7), + revision: 11, + dialog: 3, + active: Some(photocraft_doc::LayerId(2)), + target: Default::default(), + command: "filter.blur.gaussianBlur".into(), + params: json!({"radius": 4.0}), + k: 1, + } + } + + #[test] + fn same_target_ignores_parameters_but_nothing_else() { + // A drag only changes the parameters, and that must not hide the finished frame. + let base = key(); + let mut moved = base.clone(); + moved.params = json!({"radius": 40.0}); + assert!(base.same_target(&moved)); + assert!(moved.same_target(&base)); + + // Anything else names a different preview and must not be shown. + for break_it in [ + |k: &mut FilterPreviewKey| k.doc = photocraft_doc::DocId(8), + |k: &mut FilterPreviewKey| k.revision = 12, + |k: &mut FilterPreviewKey| k.dialog = 4, + |k: &mut FilterPreviewKey| k.active = Some(photocraft_doc::LayerId(5)), + |k: &mut FilterPreviewKey| k.active = None, + |k: &mut FilterPreviewKey| k.target = photocraft_engine::channel_cmds::ChannelTarget::LayerMask, + |k: &mut FilterPreviewKey| k.command = "filter.blur.motionBlur".into(), + |k: &mut FilterPreviewKey| k.k = 2, + ] { + let mut other = base.clone(); + break_it(&mut other); + assert!(!other.same_target(&base), "{other:?} must not pass as {base:?}"); + assert!(!base.same_target(&other)); + } + } + #[test] fn only_direction_angles_get_the_dial() { // Every listed command still has a degree `angle` range, so the list cannot go stale. @@ -1283,6 +1577,38 @@ mod tests { assert_eq!(params["amount"].as_f64(), Some(37.5)); } + /// #7400: Lens Blur's Source lists the document's alpha channels, by name, each choosable even + /// when two share a name; `depthChannel` is set through that menu, not shown as a field, and + /// like the other references it isn't remembered, so a remembered `channel` source opens as None. + #[test] + fn lens_blur_source_lists_the_alpha_channels() { + let spec = photocraft_engine::commands::find(LENS_BLUR).unwrap().params; + let params = parse_spec(spec); + assert!(params.iter().any(|p| p.key == "depthChannel" && p.kind == Kind::Json), "depthChannel is no dialog field"); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 10})).unwrap(); + app.run("channel.new", json!({"name": "Depth Map", "fill": "white"})).unwrap(); + let id = open(&mut app, LENS_BLUR).unwrap(); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields["__channels"], json!(["Depth Map"])); + // The menu's values are channel indices, so two channels with one name are both choosable. + app.run("channel.new", json!({"name": "Depth Map", "fill": "black"})).unwrap(); + let id = open(&mut app, LENS_BLUR).unwrap(); + let names: Vec = serde_json::from_value(app.ui.dialog_mut(id).unwrap().fields["__channels"].clone()).unwrap(); + assert_eq!(names, ["Depth Map", "Depth Map"]); + assert_eq!((channel_choice(0), channel_choice(1)), ("channel:0".to_string(), "channel:1".to_string())); + assert_eq!(channel_index(&json!(1), &names), Some(1), "the second one by its index"); + assert_eq!(channel_index(&json!("Depth Map"), &names), Some(0), "a name is the first so named, as the engine reads it"); + assert_eq!(channel_index(&json!(2), &names), None); + + app.session.prefs.edit(|p| { + p.dialogs.insert(LENS_BLUR.into(), json!({"depthMap": "channel", "depthChannel": "Depth Map"})); + }); + let id = open(&mut app, LENS_BLUR).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f["depthMap"], "none", "no channel was remembered with it"); + assert!(f.get("depthChannel").is_none()); + } + #[test] fn wide_positive_ranges_use_the_logarithmic_slider_path() { assert!(uses_logarithmic_slider(0.1, 1000.0), "Gaussian Blur radius"); @@ -1367,6 +1693,12 @@ mod tests { assert!(missing.is_empty(), "missing Italian dynamic labels: {missing:#?}"); } + #[test] + fn spanish_covers_generated_filter_options_and_gallery_names() { + let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("es-ES").unwrap()); + assert!(missing.is_empty(), "missing Spanish dynamic labels: {missing:#?}"); + } + /// The Select menu's generated dialogs (Select and Mask, Modify ▸ …) show their /// params, choices and title in every UI language, not just Korean. #[test] @@ -1461,6 +1793,7 @@ mod tests { None, photocraft_engine::channel_cmds::ChannelTarget::Composite, &documents, + &ColorSettings::default(), ) .unwrap_or_else(|| panic!("{label}: no preview")); let got = preview.layers[0].surface().unwrap().pixel(24, 16); @@ -1476,6 +1809,7 @@ mod tests { None, photocraft_engine::channel_cmds::ChannelTarget::Composite, &documents, + &ColorSettings::default(), ) .unwrap(); let px = preview.layers[0].surface().unwrap().pixel(12, 8); @@ -1764,6 +2098,15 @@ mod tests { assert_eq!(preview_factor("image.mode.bitmap", &json!({"method": "threshold"}), 4), 4); } + /// #6530: the Content-Aware Fill preview runs at full resolution, for every Sampling choice, + /// because its patch search finds different matches on a reduced copy. + #[test] + fn content_aware_fill_previews_at_full_resolution() { + for params in [json!({}), json!({"sampling": "auto"}), json!({"sampling": "rectangular"})] { + assert_eq!(preview_factor("edit.contentAwareFill", ¶ms, 4), 1, "{params}"); + } + } + /// #2063: proxy previews scaled what isn't a length (Tree's canvas fraction, Lens Correction's /// perspective), clamped negative lengths to +0.1 (Offset left/up), left Shadows/Highlights' /// radii in document pixels and rounded Mosaic's 10 px cells to 3 proxy px (12 px). @@ -1824,6 +2167,42 @@ mod tests { assert_eq!(photocraft_compose::flatten(&shown).px, photocraft_compose::flatten(&full).px); } + /// #5766: Curves on a smart object adds a smart filter, which re-renders the source through + /// the object's placement. On a reduced proxy that placement stayed in full-size document + /// coordinates, so the preview showed the object `k` times too large until OK. + #[test] + fn smart_object_curves_preview_keeps_its_size_on_a_reduced_proxy() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 16, "y": 16, "width": 16, "height": 16})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let st = s.active().unwrap(); + let (doc, layer) = ((*st.doc).clone(), st.active_layer.unwrap()); + let alpha = |d: &Document, x: i32, y: i32| match &d.layer(layer).unwrap().content { + photocraft_doc::LayerContent::Smart(sm) => sm.cache.as_ref().unwrap().pixel(x, y).last().copied().unwrap(), + _ => panic!("a smart object"), + }; + let k = 4; + let shown = preview_document(&doc, Some(layer), "image.adjustments.curves", &json!({"points": [[0, 20], [90, 150], [255, 230]]}), k).unwrap(); + let photocraft_doc::LayerContent::Smart(sm) = &shown.layer(layer).unwrap().content else { panic!("a smart object") }; + assert_eq!(sm.smart_filters.len(), 1, "the preview adds the Curves smart filter"); + assert_eq!(shown.size, crate::proxy::proxy_document(&doc, k).size); + // The 16 px square at (16, 16) is a 4 px square at (4, 4) on the proxy, not 16 px at (16, 16). + // Its corners are partly covered by the smart object's resampling (220/255), its inside fully. + for (x, y) in [(5, 5), (5, 6), (6, 6)] { + assert!(alpha(&shown, x, y) > 0.99, "({x}, {y}) is inside the reduced square"); + } + for (x, y) in [(4, 4), (7, 7)] { + assert!(alpha(&shown, x, y) > 0.5, "({x}, {y}) is a corner of the reduced square"); + } + for (x, y) in [(2, 2), (9, 9), (12, 12), (15, 15)] { + assert!(alpha(&shown, x, y) < 0.01, "({x}, {y}) is outside the reduced square"); + } + } + /// #2063: a pixel-sized parameter divided by the proxy factor was clamped to the command's /// minimum (Pointillize's 5 px cells became 3 px proxy cells = 12 px), so the preview factor /// drops until every pixel-sized value fits its range. @@ -2201,8 +2580,7 @@ mod tests { app.run("tools.setColors", json!({"foreground": "#ff0000"})).unwrap(); let id = open(&mut app, REPLACE_COLOR).unwrap(); assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#ff0000"), "the dialog starts from the foreground colour"); - assert!(!color_eyedropper_armed(&app)); - app.ui.dialog_mut(id).unwrap().fields.insert(COLOR_EYEDROPPER.into(), json!(true)); + // #6279: armed as it opens, so the first click on the image samples. assert!(color_eyedropper_armed(&app)); assert!(color_eyedropper_sample(&mut app, 4.0, 4.0)); assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#00ff00")); @@ -2218,7 +2596,7 @@ mod tests { assert!(!color_eyedropper_sample(&mut app, 4.0, 4.0)); } - /// #4143: the eyedropper button arms and disarms the canvas picker. + /// #4143: the eyedropper button disarms and re-arms the canvas picker (#6279: it opens armed). #[test] fn replace_color_dialog_arms_the_eyedropper_button() { use egui_kittest::Harness; @@ -2231,12 +2609,52 @@ mod tests { PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); open(h.state_mut(), REPLACE_COLOR).unwrap(); h.run_steps(3); - h.get_by_label("Pick Color From Image").click(); - h.run_steps(2); - assert!(color_eyedropper_armed(h.state())); + assert!(color_eyedropper_armed(h.state()), "armed as the dialog opens"); h.get_by_label("Pick Color From Image").click(); h.run_steps(2); assert!(!color_eyedropper_armed(h.state())); + h.get_by_label("Pick Color From Image").click(); + h.run_steps(2); + assert!(color_eyedropper_armed(h.state())); + } + + /// #6278: the Result swatch shows the sampled colour after the shift, and a colour picked for + /// it sets Hue, Saturation and Lightness so the sampled colour becomes it. + #[test] + fn replace_color_result_swatch_sets_the_sliders() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.run("tools.setColors", json!({"foreground": "#cc3333"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(900.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), &mut app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(h.state_mut(), REPLACE_COLOR).unwrap(); + h.run_steps(3); + // No shift yet: the Result is the sampled colour. + assert_eq!(h.get_all_by_label("#cc3333").count(), 2, "the sampled colour and the Result both read #cc3333"); + h.get_by_role_and_label(egui::accesskit::Role::ColorWell, "Result").click(); + h.run_steps(3); + let picker = h.state().ui.dialogs.last().unwrap(); + assert_ne!(picker.id, id, "the Color Picker opened over the dialog"); + assert_eq!(picker.fields["color"], json!("#cc3333"), "on the current Result"); + let picker = picker.id; + h.state_mut().ui.dialog_mut(picker).unwrap().fields.insert("color".into(), json!("#3355cc")); + crate::dialogs::confirm(h.state_mut(), picker).unwrap(); + h.state_mut().ui.dialogs.retain(|d| d.id != picker); + h.run_steps(3); + let f = h.state_mut().ui.dialog_mut(id).unwrap().fields.clone(); + let n = |k: &str| f[k].as_f64().unwrap() as f32; + let got = photocraft_algo::tone::replace_color_result([0.8, 0.2, 0.2], n("hue"), n("saturation"), n("lightness")); + let want = crate::color_picker_ui::parse_hex("#3355cc").unwrap(); + assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.02), "the sliders turn the sampled colour into the pick: {got:?}"); + assert!(n("hue") != 0.0, "the hue moved"); + // Moving a slider afterwards is not mistaken for another pick. + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("lightness".into(), json!(10.0)); + h.run_steps(2); + assert_eq!(h.state_mut().ui.dialog_mut(id).unwrap().fields["lightness"], json!(10.0)); } /// #4457: Localized Color Clusters and its Range, as in Photoshop's dialog; the sampled point diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs index 90eaf4f6fe..b068f3339d 100644 --- a/crates/ui-egui/src/filter_preview_ui.rs +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -43,12 +43,21 @@ pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mu // decides whether the filtered result (rather than the original) is shown. let preview_on = f.get("__preview").and_then(Value::as_bool) == Some(true); let filtered: Option<(FilterPreviewKey, std::sync::Arc)> = app.filter_preview.as_ref().and_then(|p| { - (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() + // An edited smart filter previews as `layer.smartFilter.setParams` (#6324). + let previewed = crate::smart_ui::preview_command(f, &cmd); + (preview_on && p.key.dialog == dialog && p.key.doc == doc_id && p.key.command == previewed) + .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()); @@ -237,7 +246,7 @@ fn upload(ctx: &egui::Context, app: &PhotocraftApp, name: &str, doc: &Document) } /// The reduction that fits a `w`×`h` image inside a `max_side` texture (1: it already fits). -fn texture_factor(w: u32, h: u32, max_side: usize) -> u32 { +pub(crate) fn texture_factor(w: u32, h: u32, max_side: usize) -> u32 { let max_side = u32::try_from(max_side).unwrap_or(u32::MAX).max(1); w.max(h).div_ceil(max_side).max(1) } @@ -324,6 +333,7 @@ mod tests { None, ChannelTarget::Alpha(0), &[], + &Default::default(), ) .unwrap(); let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/filter_preview_worker.rs b/crates/ui-egui/src/filter_preview_worker.rs index 48f29400fb..3006323c20 100644 --- a/crates/ui-egui/src/filter_preview_worker.rs +++ b/crates/ui-egui/src/filter_preview_worker.rs @@ -160,7 +160,7 @@ mod tests { let (old, result) = finish(&mut worker); assert!(result.is_ok()); let latest = key(1, 0, 256); - assert_ne!(old, latest); // The canvas must discard this result before uploading. + assert_ne!(old, latest); // The canvas paints this as the drag's frame; the newest one is computed next. assert!(worker.start(latest.clone(), ctx, |_| Computed { result: None, ms: 1.0 }).unwrap()); assert_eq!(finish(&mut worker).0, latest); } diff --git a/crates/ui-egui/src/font_preview.rs b/crates/ui-egui/src/font_preview.rs index 74c334d682..678da4191c 100644 --- a/crates/ui-egui/src/font_preview.rs +++ b/crates/ui-egui/src/font_preview.rs @@ -244,14 +244,20 @@ mod tests { fn a_system_arabic_face_renders_both_samples() { let mut engine = photocraft_text::TextEngine::new(); engine.fonts.load_system_fonts(); - let both = engine.fonts.families().into_iter().find(|f| sample_texts(&engine.fonts.charmap(f, 400, false)).1.is_some()); - let Some(family) = both else { - eprintln!("skipped: no installed family covers both Latin and Arabic"); + let candidates: Vec = engine.fonts.families().into_iter().filter(|f| sample_texts(&engine.fonts.charmap(f, 400, false)).1.is_some()).collect(); + // A face can map the Arabic characters to blank glyphs (#5259): `render` then rightly gives + // no sample, so every candidate is rendered and the first one with an Arabic sample is checked. + let mut rendered = None; + for family in &candidates { + if let Some(arabic) = images(&mut engine, family, 1, MEDIUM).and_then(|i| i.arabic) { + rendered.get_or_insert((family.clone(), arabic)); + } + } + let Some((family, arabic)) = rendered else { + eprintln!("skipped: no installed family renders both Latin and Arabic ({} claim both)", candidates.len()); return; }; - let images = images(&mut engine, &family, 1, MEDIUM).expect(&family); - let arabic = images.arabic.expect("Arabic sample"); - assert_eq!(arabic.size, [ARABIC_WIDTH as usize, HEIGHT as usize]); + assert_eq!(arabic.size, [ARABIC_WIDTH as usize, HEIGHT as usize], "{family}"); assert!(arabic.pixels.iter().any(|p| p.a() > 0), "{family}"); } } diff --git a/crates/ui-egui/src/frame_tool.rs b/crates/ui-egui/src/frame_tool.rs index 28eb556282..b2102b1cfb 100644 --- a/crates/ui-egui/src/frame_tool.rs +++ b/crates/ui-egui/src/frame_tool.rs @@ -1,6 +1,6 @@ //! Frame tool (K): drag on the canvas to draw a rectangular or elliptical frame (the options bar //! picks the shape). ⇧ makes it a square or circle, ⌥ draws it from the centre. Drawn over the -//! selected pixel layer's content, the frame takes that layer in, as in Photoshop; else it is an +//! selected layer's content, the frame takes that layer in, as in Photoshop; else it is an //! empty placeholder. Each frame is one engine command (`layer.new.frame`), so one undo step; this //! module only turns the drag into it. @@ -48,12 +48,13 @@ fn drawn(d: &FrameDrag) -> [i64; 4] { [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])].map(|v| v.round() as i64) } -/// The selected pixel layer the frame `r` takes in: one whose content it overlaps and that can -/// move (not the Background or a position-locked layer). +/// The selected pixel, type, shape or smart object layer the frame `r` takes in: one whose content +/// it overlaps and that can move (not the Background or a position-locked layer). fn content_layer(app: &PhotocraftApp, r: [i64; 4]) -> Option { let st = app.session.active()?; let l = st.doc.layer(st.active_layer?)?; - if !matches!(l.content, LayerContent::Raster(_)) || l.locks.position || l.locks.all { + let framable = matches!(l.content, LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_)); + if !framable || l.locks.position || l.locks.all { return None; } let b = l.surface()?.content_bounds(); @@ -63,7 +64,9 @@ fn content_layer(app: &PhotocraftApp, r: [i64; 4]) -> Option { /// Pointer input for the Frame tool. Returns true when the event was its own. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { - if app.ui.tool != Tool::Frame { + // ⌘ is the Move tool (`hold_keys::cmd_moves`): a press with it held is not this tool's, + // so it drags a guide or the layer (#6288). A drag already under way keeps its events. + if app.ui.tool != Tool::Frame || (app.ui.frame_tool.drag.is_none() && crate::canvas::event_tool(app, mods) != Tool::Frame) { return false; } let (square, centred) = (mods.shift, mods.alt); diff --git a/crates/ui-egui/src/gallery_ui.rs b/crates/ui-egui/src/gallery_ui.rs index 83928e29b4..89eac953e9 100644 --- a/crates/ui-egui/src/gallery_ui.rs +++ b/crates/ui-egui/src/gallery_ui.rs @@ -92,6 +92,8 @@ pub struct GalleryDialog { thumb_src: Surface, thumbs: Vec>, pub preview_ms: f64, + /// Editing smart filter `index` of the layer: OK updates it instead of adding a filter. + pub smart_filter: Option, history: EditHistory>, } @@ -104,6 +106,7 @@ impl GalleryDialog { "zoom": if self.zoom > 0.0 { json!(self.zoom) } else { json!("fit") }, "previewMs": self.preview_ms, "rendering": self.rendering().is_some(), + "smartFilter": self.smart_filter, }) } @@ -269,18 +272,12 @@ fn image(surf: &Surface, w: usize, h: usize) -> egui::ColorImage { egui::ColorImage::new([w, h], px.iter().map(|p| Color32::from_rgba_unmultiplied(p[0], p[1], p[2], if alpha { p[3] } else { 255 })).collect()) } -/// Opens the dialog on the active layer, starting from the last gallery stack used. -pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { - let (layer, src, _) = crate::distort_ui::active_pixels(app)?; - let st = app.session.active().ok_or("no document")?; - let canvas = st.doc.bounds(); - let layer_name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default(); - let last = app.session.journal.iter().rev().find(|(id, _)| id == "filter.filterGallery").map(|(_, p)| p.clone()); - let mut effects: Vec = last - .as_ref() - .and_then(|p| p.get("effects").and_then(Value::as_array).cloned()) - .unwrap_or_default() - .iter() +/// The effect layers of a `filter.filterGallery` params object (unknown filters are skipped). +fn effects_from(p: &Value) -> Vec { + p.get("effects") + .and_then(Value::as_array) + .into_iter() + .flatten() .filter_map(|e| { let f = GalleryFilter::from_key(e.get("filter")?.as_str()?)?; let mut l = EffectLayer::new(f); @@ -290,7 +287,56 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { l.visible = e.get("visible").and_then(Value::as_bool).unwrap_or(true); Some(l) }) - .collect(); + .collect() +} + +/// A recorded `[r, g, b, a]` colour param. +fn colour_param(p: &Value, key: &str) -> Option<[f32; 4]> { + let a = p.get(key)?.as_array()?; + let c = |j: usize, d: f32| a.get(j).and_then(Value::as_f64).map_or(Some(d), |v| Some(v as f32).filter(|v| v.is_finite())); + Some([c(0, 0.0)?, c(1, 0.0)?, c(2, 0.0)?, c(3, 1.0)?]) +} + +/// Opens the dialog on the active layer, starting from the last gallery stack used. +pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { + let (layer, src, _) = crate::distort_ui::active_pixels(app)?; + let last = app.session.journal.iter().rev().find(|(id, _)| id == "filter.filterGallery").map(|(_, p)| p.clone()); + let effects = last.as_ref().map(effects_from).unwrap_or_default(); + let (fg, bg) = (app.session.tools.foreground, app.session.tools.background); + open_on(app, layer, src, effects, [fg, bg], None) +} + +/// Re-opens Filter Gallery smart filter `index` of smart object `layer` with its recorded stack, +/// previewing on the pixels below it; OK updates that filter (`layer.smartFilter.setParams`). +pub fn open_smart_filter(app: &mut PhotocraftApp, layer: LayerId, index: usize) -> Result<(), String> { + let st = app.session.active().ok_or("no document")?; + let Some(photocraft_doc::LayerContent::Smart(sm)) = st.doc.layer(layer).map(|l| &l.content) else { + return Err("the layer is not a smart object".into()); + }; + let filter = sm.smart_filters.get(index).ok_or("no such smart filter")?; + if filter.command != "filter.filterGallery" { + return Err("the smart filter is not a Filter Gallery filter".into()); + } + let p = filter.params.clone(); + let src = photocraft_engine::smart_cmds::render_below_filter(&st.doc, sm, index) + .map_err(|e| e.to_string())? + .ok_or("the smart object's contents are unavailable (missing linked file?)")?; + let fg = colour_param(&p, "foreground").unwrap_or(app.session.tools.foreground); + let bg = colour_param(&p, "background").unwrap_or(app.session.tools.background); + open_on(app, layer, src, effects_from(&p), [fg, bg], Some(index)) +} + +fn open_on( + app: &mut PhotocraftApp, + layer: LayerId, + src: Surface, + mut effects: Vec, + colours: [[f32; 4]; 2], + smart_filter: Option, +) -> Result<(), String> { + let st = app.session.active().ok_or("no document")?; + let canvas = st.doc.bounds(); + let layer_name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default(); if effects.is_empty() { effects.push(EffectLayer::new(GalleryFilter::ColoredPencil)); } @@ -312,8 +358,8 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { effects, selected: sel, open: open_cats, - foreground: app.session.tools.foreground, - background: app.session.tools.background, + foreground: colours[0], + background: colours[1], zoom: 0.0, center: [(canvas.x0 + canvas.x1) as f32 / 2.0, (canvas.y0 + canvas.y1) as f32 / 2.0], tex: None, @@ -325,6 +371,7 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { thumb_src, thumbs: vec![None; GalleryFilter::ALL.len()], preview_ms: 0.0, + smart_filter, history, }); Ok(()) @@ -336,6 +383,9 @@ pub fn commit(app: &mut PhotocraftApp) -> Result { if !d.effects.iter().any(|e| e.visible) { return Ok(json!({"committed": false})); } + if let Some(index) = d.smart_filter { + return app.run("layer.smartFilter.setParams", json!({"layer": d.layer.0, "index": index, "params": d.params()})); + } let mut p = d.params(); p["layer"] = json!(d.layer.0); app.run("filter.filterGallery", p) @@ -859,6 +909,51 @@ mod tests { assert_eq!(app.session.active().unwrap().history.past_len(), before + 1, "cancel records nothing"); } + #[test] + fn smart_filter_double_click_edits_the_stored_gallery_stack() { + let ctx = egui::Context::default(); + let mut app = app(); + app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let stack = json!({"effects": [{"filter": "chrome", "params": {"detail": 2, "smoothness": 9}, "visible": true}], "foreground": [1.0, 0.0, 0.0, 1.0], "background": [0.0, 0.0, 1.0, 1.0]}); + app.run("filter.filterGallery", stack).unwrap(); + app.run("filter.blur.gaussianBlur", json!({"radius": 2.0})).unwrap(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + let smart = |app: &PhotocraftApp| match &app.session.active().unwrap().doc.layer(layer).unwrap().content { + photocraft_doc::LayerContent::Smart(sm) => sm.clone(), + _ => panic!("not a smart object"), + }; + let steps = app.session.active().unwrap().history.past_len(); + + let sm = smart(&app); + crate::smart_ui::open_editor(&mut app, &ctx, layer.0, &sm, 0); + assert!(app.ui.dialogs.is_empty(), "no generic parameter form"); + let d = app.distort.gallery.as_ref().expect("the gallery dialog opens"); + assert_eq!((d.smart_filter, d.foreground, d.background), (Some(0), [1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0])); + assert_eq!(d.effects.len(), 1); + assert_eq!((d.effects[0].filter, &d.effects[0].params["detail"]), (GalleryFilter::Chrome, &json!(2))); + // The preview starts from the pixels below the filter (no blur, no chrome). + let below = photocraft_engine::smart_cmds::render_below_filter(&app.session.active().unwrap().doc, &sm, 0).unwrap().unwrap(); + let d = app.distort.gallery.as_ref().unwrap(); + assert_eq!(d.src.read_region(d.canvas), below.read_region(d.canvas)); + + control(&mut app, &json!({"params": {"detail": 7}})).unwrap(); + control(&mut app, &json!({"commit": true})).unwrap(); + assert!(app.distort.gallery.is_none()); + let sm = smart(&app); + assert_eq!(sm.smart_filters.len(), 2, "edited in place, not added"); + assert_eq!(sm.smart_filters[0].command, "filter.filterGallery"); + assert_eq!(sm.smart_filters[0].params["effects"][0]["params"]["detail"], json!(7)); + assert_eq!(sm.smart_filters[0].params["foreground"], json!([1.0, 0.0, 0.0, 1.0])); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(smart(&app).smart_filters[0].params["effects"][0]["params"]["detail"], json!(2)); + + // Not a gallery filter / no such filter: an error, no dialog. + assert!(open_smart_filter(&mut app, layer, 1).is_err()); + assert!(open_smart_filter(&mut app, layer, 9).is_err()); + assert!(app.distort.gallery.is_none()); + } + #[test] fn preview_and_thumbnails_render() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 046d4ac2a7..7ec4c192b2 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; @@ -35,6 +36,10 @@ pub use photocraft_gpu::{DeviceHealth, Fault}; /// Default tile side; documents up to this size (and the device limit) use a single texture. pub const DEFAULT_TILE: u32 = 8192; +/// Mip levels no larger than this on either side are redrawn whole on every edit rather than +/// just their damage (see [`mip_damage`], #6418). Every corrupted level measured was at most +/// 64 px; this leaves a factor of two. +const WHOLE_MIP_SIDE: u32 = 128; const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; /// Canvas texture format of high-bit documents (see [`GpuCanvas::format_for`]). const FORMAT_HIGH: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; @@ -110,6 +115,26 @@ 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 +/// Intel adapters under DX12 only, where some drivers corrupt reduced previews when a pass +/// samples one mip level and renders into another of the same texture (#892, Intel UHD 620). +/// Everywhere else it renders straight into the level: on an NVIDIA DX12 adapter the scratch +/// textures, created per level on every refresh, cost about 1 ms of CPU time per brush dab +/// (#4515). `PHOTOCRAFT_SEPARATE_MIPS` (`setting`) forces it on (`1`) or off (`0`). +pub fn separate_mip_targets(backend: wgpu::Backend, vendor: u32, setting: Option<&str>) -> bool { + const INTEL: u32 = 0x8086; + match setting.map(str::trim) { + Some("1") => true, + Some("0") => false, + _ => backend == wgpu::Backend::Dx12 && vendor == INTEL, + } } /// Can `adapter` use `Rgba16Float` as a sampled, filtered, render-target and copy texture? @@ -141,10 +166,18 @@ impl GpuCanvas { let health = photocraft_gpu::DeviceHealth::watch(&rs.device); let mut res = Resources::new(&rs.device, &rs.queue, rs.target_format, high != HighPolicy::Off); res.health = health.clone(); - res.separate_mip_targets = rs.adapter.get_info().backend == wgpu::Backend::Dx12; + 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). @@ -176,6 +209,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`. @@ -188,7 +227,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 } @@ -224,10 +267,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 @@ -236,7 +276,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. @@ -259,6 +299,15 @@ impl GpuCanvas { } } + /// Take any GPU measurement that has landed since the last call, without compositing + /// anything. [`Compositor::render`] carries measurements out in its stats, but a refresh is + /// the only thing that collects them, so the last refresh before the app goes idle would + /// never be reported. The frame loop calls this so it is. + pub fn take_gpu_timing(&self) -> Option { + let mut r = self.rs.renderer.write(); + r.callback_resources.get_mut::()?.compositor.as_mut()?.take_gpu_timing() + } + /// GPU bytes the wgpu compositor holds: resident layer pages and caches (effect maps and /// lower-stack chunks). Keep the scorecard's memory measurements inclusive of reuse. pub fn compositor_bytes(&self) -> Option<(u64, usize)> { @@ -267,6 +316,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(); @@ -414,6 +485,8 @@ impl GpuCanvas { }, }; comp.set_health(self.health.clone()); + // Measured, not waited for: the timestamps come back a frame or two later. + comp.measure_gpu_time(device, queue); if let Some(b) = res.compositor_budget { comp.set_memory_budget(b); } @@ -432,10 +505,16 @@ impl GpuCanvas { } // A full refresh whose layer pages don't fit the memory budget would re-upload the // evicted ones every time (no faster than the CPU, and more memory): the banded CPU - // compositor does it, and edits in the view (damage rects) stay on the GPU. - if region == doc.bounds() && !comp.fits_budget(doc, region) { - res.compositor = Some(comp); - return Err(photocraft_gpu::Unsupported(OVER_BUDGET.into())); + // 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 { + 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 { @@ -572,8 +651,10 @@ impl GpuCanvas { Ok(stats) => { out.kind = if damage.is_some() { "gpu-rect" } else { "gpu-full" }; out.px = region.width() as u64 * region.height() as u64; + // Encoding and submitting only: the GPU is still running this work. out.composite_ms = now_ms() - t0; out.uploads = stats.tiles_uploaded as u64; + out.gpu = stats.gpu_time; return out; } Err(e) => out.fallback = Some(e.0), @@ -812,13 +893,21 @@ pub struct Refresh { pub upload_ms: f64, /// Layer tiles the wgpu compositor uploaded. pub uploads: u64, + /// How long the GPU spent executing a recent wgpu composite, once its timestamps came back + /// (a frame or two after that refresh, so usually an earlier one than this). It carries its + /// own pixel count and is never this refresh's unless they match. `None` when the device has + /// no timestamp queries, on the CPU compositor, and on wasm: no number at all is reported + /// rather than `composite_ms`, which measures encoding and submitting, not executing. It + /// leaves out layer-tile uploads (`uploads` above counts them), so a first refresh of a + /// document under-reports by more than the few per cent of a warm one. + pub gpu: Option, /// Why the wgpu compositor wasn't used. pub fallback: Option, } -/// How the app creates its wgpu device: egui's defaults, except that the texture and buffer size -/// limits are the adapter's own rather than egui's 8192 px cap, so large documents stay on the -/// GPU compositor. +/// How the app creates its wgpu device: egui's defaults clamped to the adapter's supported limits, +/// with the adapter's own texture and buffer sizes rather than egui's 8192 px cap, so large +/// documents stay on the GPU compositor. pub fn wgpu_setup() -> egui_wgpu::WgpuSetup { let mut setup = egui_wgpu::WgpuSetup::without_display_handle(); use_adapter_limits(&mut setup); @@ -827,7 +916,8 @@ pub fn wgpu_setup() -> egui_wgpu::WgpuSetup { /// Make `setup` request the adapter's own texture and buffer size limits instead of egui's /// default 8192 px cap (which sent every document wider or taller than 8192 px to the CPU -/// compositor). Everything else (features, the WebGL2 base limits on GL) stays egui's default. +/// compositor). Other limits use egui's defaults (WebGL2's on GL), clamped to adapter support; +/// features stay egui's default. pub fn use_adapter_limits(setup: &mut egui_wgpu::WgpuSetup) { use_adapter_limits_with(setup, |_| {}); } @@ -838,16 +928,51 @@ pub fn use_adapter_limits_with(setup: &mut egui_wgpu::WgpuSetup, on_adapter: imp if let egui_wgpu::WgpuSetup::CreateNew(create) = setup { create.device_descriptor = std::sync::Arc::new(move |adapter| { on_adapter(adapter); - wgpu::DeviceDescriptor { label: Some("photocraft wgpu device"), required_limits: device_limits(adapter), ..Default::default() } + device_descriptor(adapter) }); } } -/// egui's base limits (WebGL2's on GL) with the adapter's maximum texture and buffer sizes. +/// How the app asks for its device: egui's defaults, the adapter's texture and buffer size +/// limits ([`device_limits`]), and timestamp queries where the adapter has them, so the canvas +/// can report how long the GPU actually spends compositing ([`photocraft_gpu::GpuTimer`]). +/// A bench or test that builds its own device should use this rather than [`device_limits`] +/// alone, or it measures a device the app never creates. (`bevel_bench`, `fx_bench` and +/// `interactive_bench` still call [`device_limits`] directly and so report no GPU time.) +pub fn device_descriptor(adapter: &wgpu::Adapter) -> wgpu::DeviceDescriptor<'static> { + wgpu::DeviceDescriptor { + label: Some("photocraft wgpu device"), + required_limits: device_limits(adapter), + required_features: timing_features(adapter), + ..Default::default() + } +} + +/// The timestamp-query features when the adapter supports all of them, else none: asking for a +/// feature an adapter lacks fails device creation, and a canvas without GPU timing is far better +/// than no canvas. +/// +/// `PHOTOCRAFT_NO_GPU_TIMING` suppresses the request as well as the timer +/// ([`photocraft_gpu::Compositor::measure_gpu_time`]), so the variable also rescues a driver +/// that advertises the features and then fails or misbehaves on device creation — not only one +/// whose timestamps come back wrong. +pub fn timing_features(adapter: &wgpu::Adapter) -> wgpu::Features { + let want = photocraft_gpu::timing::FEATURES; + if std::env::var_os("PHOTOCRAFT_NO_GPU_TIMING").is_some() { + return wgpu::Features::empty(); + } + if adapter.features().contains(want) { want } else { wgpu::Features::empty() } +} + +/// egui's base limits (WebGL2's on GL), clamped to adapter support, with its maximum texture and buffer sizes. pub fn device_limits(adapter: &wgpu::Adapter) -> wgpu::Limits { - let base = if adapter.get_info().backend == wgpu::Backend::Gl { wgpu::Limits::downlevel_webgl2_defaults() } else { wgpu::Limits::default() }; - let a = adapter.limits(); - wgpu::Limits { max_texture_dimension_2d: a.max_texture_dimension_2d, max_buffer_size: a.max_buffer_size, ..base } + device_limits_for(adapter.get_info().backend, adapter.limits()) +} + +fn device_limits_for(backend: wgpu::Backend, supported: wgpu::Limits) -> wgpu::Limits { + let base = if backend == wgpu::Backend::Gl { wgpu::Limits::downlevel_webgl2_defaults() } else { wgpu::Limits::default() }; + let base = base.or_worse_values_from(&supported); + wgpu::Limits { max_texture_dimension_2d: supported.max_texture_dimension_2d, max_buffer_size: supported.max_buffer_size, ..base } } /// Milliseconds since first call (monotonic). Always 0 on wasm, where `std::time` is unavailable. @@ -869,7 +994,9 @@ pub struct Perf { pub ui_ms: f64, /// Worst UI frame of the last ~second (ms). pub ui_max_ms: f64, - /// Last canvas refresh: CPU composite (ms). + /// Last canvas refresh, CPU side (ms): the CPU composite on the CPU path, and on the GPU + /// path the cost of *encoding and submitting* the composite — not of running it, which is + /// `gpu_ms`. pub composite_ms: f64, /// Last canvas refresh: conversion + upload (ms). pub upload_ms: f64, @@ -881,6 +1008,16 @@ pub struct Perf { pub gpu: bool, /// Layer tiles uploaded by the last GPU composite. pub gpu_uploads: u64, + /// How long the GPU spent executing a recent composite (ms), from timestamp queries read + /// back after the fact. `None` on the CPU compositor, on a device without timestamp queries + /// and on wasm — there is then no honest number to report, and `composite_ms` is not one. + /// Excludes layer-tile uploads and end-of-batch work (see `photocraft_gpu::timing`), so it + /// is a few per cent under the GPU's full share of a warm refresh and further under a cold + /// one. + pub gpu_ms: Option, + /// Pixels of the refresh `gpu_ms` measured, which is a frame or two behind + /// `last_refresh_px` and may be a different size. + pub gpu_ms_px: u64, /// Why the last refresh fell back to the CPU compositor (None = GPU composited). pub gpu_fallback: Option, /// Adapter, backend, driver and fallback state (Help › System Info). @@ -888,9 +1025,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, @@ -898,6 +1039,9 @@ pub struct Perf { pub spans: std::collections::BTreeMap<&'static str, f64>, #[serde(skip)] max_decay: f64, + /// Composite and upload time of this frame's canvas refreshes, in ms (the stroke trace). + #[serde(skip)] + pub(crate) frame_refresh_ms: f64, } /// What the app renders with and why (Help › System Info, Preferences › Performance, the `perf` @@ -913,6 +1057,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`). @@ -924,6 +1071,9 @@ pub struct GpuInfo { pub canvas: String, /// Why the GPU canvas was dropped during this session (device lost or a GPU error). pub lost: Option, + /// Present mode and frame latency ("fifo, latency 2"; launch overrides marked, see the + /// app's `presentation`). Empty in hosts that don't set it. + pub present: String, } impl GpuInfo { @@ -940,6 +1090,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. @@ -1012,16 +1163,35 @@ pub fn backend_name(b: wgpu::Backend) -> &'static str { impl Perf { pub fn record(&mut self, kind: &'static str, px: u64, composite_ms: f64, upload_ms: f64) { + self.frame_refresh_ms += composite_ms + upload_ms; self.last_refresh = kind; self.last_refresh_px = px; self.composite_ms = composite_ms; self.upload_ms = upload_ms; if !kind.starts_with("gpu") { self.gpu_uploads = 0; + // The CPU compositor did this one: there is no GPU time, and the last GPU + // refresh's would be read as this refresh's. + self.gpu_ms = None; + self.gpu_ms_px = 0; } else { self.gpu_fallback = None; } } + + /// Record a canvas refresh and any GPU measurement that came back with it. + pub fn record_refresh(&mut self, r: &Refresh) { + self.record(r.kind, r.px, r.composite_ms, r.upload_ms); + if let Some(t) = r.gpu { + self.record_gpu(t); + } + } + + /// Record a GPU composite measurement that came back (see [`photocraft_gpu::GpuTiming`]). + pub fn record_gpu(&mut self, t: photocraft_gpu::GpuTiming) { + self.gpu_ms = Some(t.ms); + self.gpu_ms_px = t.px; + } pub fn span(&mut self, name: &'static str, ms: f64) { self.spans.insert(name, (ms * 100.0).round() / 100.0); } @@ -1163,21 +1333,53 @@ 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 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 { +/// 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).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 +/// 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. @@ -1195,11 +1397,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 @@ -1263,6 +1466,70 @@ 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, 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); + } + 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, @@ -1279,7 +1546,7 @@ struct Resources { views: HashMap, out_linear: bool, /// Build each mip level in a scratch texture and copy it back instead of rendering into - /// the level directly (DX12 only: works around reduced-preview corruption on Intel drivers). + /// the level directly (see [`separate_mip_targets`]). separate_mip_targets: bool, /// The wgpu layer compositor (created on first use). compositor: Option, @@ -1290,10 +1557,13 @@ 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`]). - canvas_budget: u64, + canvas_budget: std::sync::Arc, encode_bgl: wgpu::BindGroupLayout, encode_pipeline: wgpu::RenderPipeline, lut_bgl: wgpu::BindGroupLayout, @@ -1557,9 +1827,10 @@ 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: canvas_budget(physical_memory()), + canvas_budget: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(canvas_budget(physical_memory(), None))), encode_bgl, encode_pipeline, } @@ -1580,6 +1851,20 @@ fn mip_count(w: u32, h: u32) -> u32 { 32 - w.max(h).max(1).leading_zeros() } +/// The rect (x0, y0, x1, y1) of a `lw`×`lh` mip level to redraw when `prev` changed in the level +/// above: that damage halved and padded by one texel for odd-size rounding, or the whole level +/// when it is at most [`WHOLE_MIP_SIDE`] on either side. On AMD's Vulkan driver a pass that draws +/// only part of a level of 64 px or less corrupts texels outside its scissor, at the level's +/// top-left (#6418: RX 6900 XT, driver 26.8.1; DX12 on the same card is fine). Such a level costs +/// next to nothing to redraw, so this holds for every adapter; the levels after it are smaller +/// still, so the rest of the chain is redrawn whole too. +fn mip_damage([x0, y0, x1, y1]: [u32; 4], lw: u32, lh: u32) -> [u32; 4] { + if lw.max(lh) <= WHOLE_MIP_SIDE { + return [0, 0, lw, lh]; + } + [(x0 / 2).saturating_sub(1), (y0 / 2).saturating_sub(1), (x1.div_ceil(2) + 1).min(lw), (y1.div_ceil(2) + 1).min(lh)] +} + /// A buffer holding `contents`, like `DeviceExt::create_buffer_init` but without its panic when /// the device was lost (an invalid buffer has no mapped range): the buffer is then just invalid. fn init_buffer(device: &wgpu::Device, label: &str, contents: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { @@ -1598,7 +1883,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(); @@ -1677,9 +1962,7 @@ impl DocTextures { (x0, y0, x1, y1) = (x0 - tx, y0 - ty, x1 - tx, y1 - ty); for level in 1..t.levels.len() { let (lw, lh) = ((tw >> level).max(1), (th >> level).max(1)); - // Damage at this level, padded by one texel for odd-size rounding. - (x0, y0) = ((x0 / 2).saturating_sub(1), (y0 / 2).saturating_sub(1)); - (x1, y1) = ((x1.div_ceil(2) + 1).min(lw), (y1.div_ceil(2) + 1).min(lh)); + [x0, y0, x1, y1] = mip_damage([x0, y0, x1, y1], lw, lh); let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pc_mip"), layout: &res.mip_bgl, @@ -1688,8 +1971,8 @@ impl DocTextures { wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&res.mip_sampler) }, ], }); - // Keep the sampled source and render attachment in separate resources on DX12 - // (`Resources::separate_mip_targets`): some Intel DX12 drivers corrupt reduced + // Keep the sampled source and render attachment in separate resources on Intel + // DX12 adapters ([`separate_mip_targets`]): some of their drivers corrupt reduced // previews when both are mip levels of the same texture, even though the // subresources do not overlap. Elsewhere render straight into the level, which // spares a scratch texture and a copy per level on every edit. @@ -2195,19 +2478,203 @@ mod mip_tests; mod tests { use super::*; + /// A GPU measurement arrives a frame or two after its refresh, so most GPU refreshes carry + /// none; the last one reported must stand until a newer one lands. A CPU refresh is + /// different: there is no GPU time at all then, and keeping the old number would show it as + /// this refresh's. Runs anywhere — the GPU timing tests need hardware and CI has none. + #[test] + fn a_gpu_measurement_stands_until_replaced_and_a_cpu_refresh_clears_it() { + let timing = |ms, px| photocraft_gpu::GpuTiming { ms, px, spans: 1 }; + let refresh = |kind, gpu| Refresh { kind, gpu, ..Default::default() }; + let mut perf = Perf::default(); + assert_eq!(perf.gpu_ms, None, "nothing is reported before a measurement"); + + perf.record_refresh(&refresh("gpu-full", Some(timing(83.9, 24_000_000)))); + assert_eq!((perf.gpu_ms, perf.gpu_ms_px), (Some(83.9), 24_000_000)); + + // The common case: a GPU refresh whose own measurement has not come back yet. + perf.record_refresh(&refresh("gpu-rect", None)); + assert_eq!((perf.gpu_ms, perf.gpu_ms_px), (Some(83.9), 24_000_000), "the last measurement stands"); + + perf.record_refresh(&refresh("gpu-rect", Some(timing(11.2, 1_098_640)))); + assert_eq!((perf.gpu_ms, perf.gpu_ms_px), (Some(11.2), 1_098_640), "a newer measurement replaces it"); + + // The CPU compositor did this one: the GPU number belongs to no refresh on screen. + perf.record_refresh(&refresh("full", None)); + assert_eq!((perf.gpu_ms, perf.gpu_ms_px), (None, 0), "a CPU refresh clears it rather than showing a stale one"); + + // And it comes back on the next GPU refresh that carries one. + perf.record_refresh(&refresh("gpu-full", Some(timing(42.0, 6_000_000)))); + assert_eq!((perf.gpu_ms, perf.gpu_ms_px), (Some(42.0), 6_000_000)); + } + + #[test] + fn device_limits_support_v3d_shader_variable_limit() { + let supported = wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..wgpu::Limits::default() }; + let requested = device_limits_for(wgpu::Backend::Vulkan, supported.clone()); + assert_eq!(requested.max_inter_stage_shader_variables, 15); + assert!(requested.check_limits(&supported)); + assert_eq!(requested.max_texture_dimension_2d, 7680); + assert_eq!(requested.max_buffer_size, supported.max_buffer_size); + } + + #[test] + fn device_limits_support_weaker_gl_adapters() { + let supported = wgpu::Limits { + max_texture_dimension_2d: 1024, + max_texture_dimension_3d: 128, + max_bind_groups: 2, + max_buffer_size: 128 << 20, + ..wgpu::Limits::downlevel_webgl2_defaults() + }; + let requested = device_limits_for(wgpu::Backend::Gl, supported.clone()); + assert!(requested.check_limits(&supported)); + assert_eq!(requested, supported); + } + + #[test] + fn device_limits_preserve_backend_defaults_on_capable_adapters() { + let supported = wgpu::Limits { + max_texture_dimension_2d: 16_384, + max_buffer_size: 1 << 30, + max_bind_groups: 8, + max_inter_stage_shader_variables: 32, + min_uniform_buffer_offset_alignment: 64, + min_storage_buffer_offset_alignment: 64, + ..wgpu::Limits::default() + }; + for (backend, base) in [ + (wgpu::Backend::Vulkan, wgpu::Limits::default()), + (wgpu::Backend::Dx12, wgpu::Limits::default()), + (wgpu::Backend::Metal, wgpu::Limits::default()), + (wgpu::Backend::Gl, wgpu::Limits::downlevel_webgl2_defaults()), + ] { + let requested = device_limits_for(backend, supported.clone()); + assert!(requested.check_limits(&supported)); + assert_eq!(requested, wgpu::Limits { max_texture_dimension_2d: 16_384, max_buffer_size: 1 << 30, ..base }, "{backend:?}"); + } + } + + #[test] + fn device_limits_support_larger_minimum_alignments() { + let supported = wgpu::Limits { min_uniform_buffer_offset_alignment: 512, min_storage_buffer_offset_alignment: 1024, ..wgpu::Limits::default() }; + for backend in [wgpu::Backend::Vulkan, wgpu::Backend::Gl] { + let requested = device_limits_for(backend, supported.clone()); + assert!(requested.check_limits(&supported)); + assert_eq!(requested.min_uniform_buffer_offset_alignment, 512); + assert_eq!(requested.min_storage_buffer_offset_alignment, 1024); + } + } + + /// The scratch-texture workaround is for Intel's DX12 drivers (#892); other DX12 adapters + /// render straight into the level like every other backend (#4515). + #[test] + fn separate_mip_targets_only_for_intel_on_dx12() { + use wgpu::Backend::{Dx12, Metal, Vulkan}; + let (intel, nvidia, amd) = (0x8086, 0x10de, 0x1002); + assert!(separate_mip_targets(Dx12, intel, None)); + assert!(!separate_mip_targets(Dx12, nvidia, None)); + assert!(!separate_mip_targets(Dx12, amd, None)); + assert!(!separate_mip_targets(Vulkan, intel, None)); + assert!(!separate_mip_targets(Metal, 0, None)); + // PHOTOCRAFT_SEPARATE_MIPS forces either way; anything else leaves the default. + assert!(separate_mip_targets(Dx12, nvidia, Some("1"))); + assert!(!separate_mip_targets(Dx12, intel, Some(" 0 "))); + assert!(separate_mip_targets(Dx12, intel, Some("yes"))); + } + + /// #6418: levels of at most WHOLE_MIP_SIDE are redrawn whole; larger ones keep the damage. + #[test] + fn small_mip_levels_are_redrawn_whole() { + // A 3 × 3 dab at (800, 500) of a 1791 × 1083 tile, at mip 1 (895 × 541): halved and padded. + assert_eq!(mip_damage([800, 500, 803, 503], 895, 541), [399, 249, 403, 253]); + // At most 128 px on either side: the whole level, wherever the damage is. + assert_eq!(mip_damage([100, 60, 102, 63], 111, 67), [0, 0, 111, 67]); + assert_eq!(mip_damage([0, 0, 1, 1], 128, 2), [0, 0, 128, 2]); + assert_eq!(mip_damage([0, 0, 1, 1], 129, 2), [0, 0, 2, 2]); + } + #[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)), 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); + // 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)), 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, 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)); } + /// 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, 0), 3 * G); + assert_eq!(canvas_budget(ram, vram), 3 * 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), 0), 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] @@ -2238,12 +2705,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/src/gpu_canvas_mip_tests.rs b/crates/ui-egui/src/gpu_canvas_mip_tests.rs index a1c3e8ee14..b9eac3e0b9 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"); @@ -101,3 +102,47 @@ fn check_mips(gpu: &GpuCanvas) { } } } + +/// #6418: on AMD's Vulkan driver, a pass that drew only part of a small mip level corrupted texels +/// outside its scissor, at the level's top-left (RX 6900 XT, driver 26.8.1). Whether a texture is +/// hit depends on where the driver placed it, so this paints dabs on canvases of several sizes at +/// both depths and compares every level with a full rebuild of the same pixels. +#[test] +fn partial_updates_match_a_full_rebuild_at_every_mip_level() { + 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 rebuild comparison: no GPU adapter"); + return; + }; + let gpu = GpuCanvas::new(&rs); + for (depth, w, h) in [(SampleType::U8, 255, 255), (SampleType::U8, 1024, 768), (SampleType::F32, 512, 512), (SampleType::F32, 1791, 1083)] { + let mut full = Buffer::filled(Rect::from_xywh(0, 0, w, h), [0.0; 4]); + for (i, px) in full.px.iter_mut().enumerate() { + let (x, y) = (i as u32 % w, i as u32 / w); + *px = [x as f32 / w as f32, y as f32 / h as f32, ((x + y) % 7) as f32 / 7.0, 1.0]; + } + gpu.upload_buffer_full(1, &full, depth); + let dabs = [Rect::from_xywh((w / 2) as i32, (h / 2) as i32, w / 3, h / 3), Rect::from_xywh(w as i32 - 1, h as i32 - 1, 1, 1)]; + for (dab, color) in dabs.into_iter().zip([[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]]) { + assert!(gpu.upload_buffer_rect(1, &Buffer::filled(dab, color))); + for y in dab.y0..dab.y1 { + for x in dab.x0..dab.x1 { + full.px[y as usize * w as usize + x as usize] = color; + } + } + gpu.upload_buffer_full(2, &full, depth); + for (level, (partial, rebuilt)) in read_mips(&gpu, 1).iter().zip(&read_mips(&gpu, 2)).enumerate() { + let lw = (w >> level).max(1) as usize; + for (i, (a, b)) in partial.iter().zip(rebuilt).enumerate() { + assert!( + a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.005), + "{depth:?} {w}x{h} dab {dab:?} mip {level} at ({}, {}): {a:?} != {b:?}", + i % lw, + i / lw + ); + } + } + } + } +} 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/gradient_ui.rs b/crates/ui-egui/src/gradient_ui.rs index be35f66028..dd91c1a74d 100644 --- a/crates/ui-egui/src/gradient_ui.rs +++ b/crates/ui-egui/src/gradient_ui.rs @@ -219,6 +219,11 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> app.gradient.drag = None; return false; } + // ⌘ is the Move tool (`hold_keys::cmd_moves`): a press with it held drags a guide or the layer + // (#6288). A drag already under way keeps its events. + if app.gradient.drag.is_none() && crate::canvas::event_tool(app, mods) != Tool::Gradient { + return false; + } let zoom = app.point_zoom().max(0.01); match ev { ToolEvent::Down { x, y, .. } => { diff --git a/crates/ui-egui/src/gradient_ui/properties_panel.rs b/crates/ui-egui/src/gradient_ui/properties_panel.rs index 39b961ee20..d975102539 100644 --- a/crates/ui-egui/src/gradient_ui/properties_panel.rs +++ b/crates/ui-egui/src/gradient_ui/properties_panel.rs @@ -49,7 +49,8 @@ fn options(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { if let Some(value) = numeric_row(ui, "angle", "Angle", angle, -180.0..=180.0, "°") { let _ = app.run(cmds::SET, json!({"layer": layer.id.0, "angle": value})); } - if let Some(value) = numeric_row(ui, "scale", "Scale", scale * 100.0, 10.0..=150.0, "%") { + let (min, max) = cmds::SCALE_RANGE; + if let Some(value) = numeric_row(ui, "scale", "Scale", scale * 100.0, min * 100.0..=max * 100.0, "%") { let _ = app.run(cmds::SET, json!({"layer": layer.id.0, "scale": value})); } ui.horizontal_wrapped(|ui| { diff --git a/crates/ui-egui/src/guide_cursor_tests.rs b/crates/ui-egui/src/guide_cursor_tests.rs new file mode 100644 index 0000000000..fd0e5c0595 --- /dev/null +++ b/crates/ui-egui/src/guide_cursor_tests.rs @@ -0,0 +1,204 @@ +//! #6288: ⌘ (Ctrl off the Mac) over a guide is the Move tool's guide drag with every tool on +//! which ⌘ is the Move tool, and the pointer shows it (the guide's double arrow), as in +//! Photoshop. Driven through real egui input, so the held key goes through `hold_keys`. + +use egui::{Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::state::Tool; + +fn harness(tool: Tool) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.tool = tool; + app.run("view.newGuide", json!({"orientation": "vertical", "position": 200})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [200.0, 150.0]; + v.fit_pending = false; + h.run_steps(2); + h +} + +fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { + let app = h.state(); + let v = &app.ui.views[0]; + ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + } + .to_screen(x, y) +} + +fn hover(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32, m: Modifiers) -> egui::CursorIcon { + h.event(egui::Event::ModifiersChanged(m)); + let p = screen(h, x, y); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(2); + h.output().platform_output.cursor_icon +} + +fn drag(h: &mut Harness<'static, PhotocraftApp>, from: f32, to: f32, m: Modifiers) { + let p = screen(h, from, 150.0); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: m }); + h.run_steps(1); + for k in 1..=4 { + let q = screen(h, from + (to - from) * k as f32 / 4.0, 150.0); + h.event(egui::Event::PointerMoved(q)); + h.run_steps(1); + } + let q = screen(h, to, 150.0); + h.event(egui::Event::PointerButton { pos: q, button: PointerButton::Primary, pressed: false, modifiers: m }); + h.run_steps(2); +} + +fn diagonal(h: &mut Harness<'static, PhotocraftApp>, a: [f32; 2], b: [f32; 2], m: Modifiers) { + h.event(egui::Event::ModifiersChanged(m)); + let p = screen(h, a[0], a[1]); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: m }); + h.run_steps(1); + for k in 1..=4 { + let t = k as f32 / 4.0; + let q = screen(h, a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t); + h.event(egui::Event::PointerMoved(q)); + h.run_steps(1); + } + let q = screen(h, b[0], b[1]); + h.event(egui::Event::PointerButton { pos: q, button: PointerButton::Primary, pressed: false, modifiers: m }); + h.run_steps(2); +} + +/// The cursor over the guide with ⌘ held, and where a ⌘-drag from 200 to 240 leaves it. +fn command_drag(tool: Tool, locked: bool) -> (egui::CursorIcon, Vec) { + let mut h = harness(tool); + h.state_mut().ui.extras.lock_guides = locked; + let c = hover(&mut h, 200.0, 150.0, Modifiers::COMMAND); + drag(&mut h, 200.0, 240.0, Modifiers::COMMAND); + (c, h.state().session.active().unwrap().doc.guides.vertical.clone()) +} + +const GUIDE: egui::CursorIcon = egui::CursorIcon::ResizeHorizontal; + +#[test] +fn command_over_a_guide_shows_the_guide_cursor_and_drags_it_with_painting_tools() { + for tool in [ + Tool::Brush, + Tool::Eraser, + Tool::Gradient, + Tool::PaintBucket, + Tool::CloneStamp, + Tool::Eyedropper, + Tool::Dodge, + Tool::SpotHealing, + Tool::Smudge, + Tool::QuickSelection, + Tool::RectMarquee, + Tool::Lasso, + ] { + assert_eq!(command_drag(tool, false), (GUIDE, vec![240.0]), "{tool:?}"); + } +} + +/// The Frame and Artboard tools took a ⌘ press as their own (a new frame or board) although ⌘ +/// is the Move tool on them, so the guide cursor promised a drag that never moved the guide. +#[test] +fn command_drags_a_guide_with_the_frame_and_artboard_tools() { + for tool in [Tool::Frame, Tool::Artboard] { + assert_eq!(command_drag(tool, false), (GUIDE, vec![240.0]), "{tool:?}"); + } + // Off a guide, ⌘ is the Move tool too: no frame is drawn. + let mut h = harness(Tool::Frame); + let layers = h.state().session.active().unwrap().doc.layers.len(); + diagonal(&mut h, [100.0, 100.0], [160.0, 140.0], Modifiers::COMMAND); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), layers, "⌘-drag with the Frame tool draws no frame"); + assert!(h.state().ui.frame_tool.drag.is_none()); + // Without ⌘ the Frame tool still draws its frame. + diagonal(&mut h, [20.0, 20.0], [120.0, 90.0], Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), layers + 1, "a plain drag draws a frame"); +} + +#[test] +fn locked_guides_show_no_guide_cursor_and_do_not_move() { + for tool in [Tool::Brush, Tool::Eraser, Tool::Frame] { + let (cursor, guides) = command_drag(tool, true); + assert_ne!(cursor, GUIDE, "{tool:?}"); + assert_eq!(guides, vec![200.0], "{tool:?}"); + } +} + +/// Tools that keep ⌘ for themselves (Photoshop: the Pen's Direct Selection, the Hand, Zoom, Crop, +/// type, shapes, slices) neither show the guide cursor nor drag the guide. +#[test] +fn tools_with_their_own_command_keep_it() { + for tool in [Tool::Pen, Tool::Hand, Tool::Zoom, Tool::Crop, Tool::Type, Tool::Rectangle, Tool::Slice] { + let (cursor, guides) = command_drag(tool, false); + assert_ne!(cursor, GUIDE, "{tool:?}"); + assert_eq!(guides, vec![200.0], "{tool:?}"); + } +} + +/// Window › Modifier Keys: a latched ⌘ made the press take the guide but left the brush cursor. +#[test] +fn sticky_command_shows_the_guide_cursor() { + for tool in [Tool::Brush, Tool::Eraser, Tool::RectMarquee] { + let mut h = harness(tool); + h.state_mut().ui.shell.sticky_command = true; + assert_eq!(hover(&mut h, 200.0, 150.0, Modifiers::NONE), GUIDE, "{tool:?}"); + drag(&mut h, 200.0, 240.0, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().doc.guides.vertical, vec![240.0], "{tool:?}"); + } +} + +/// The one decision for "a ⌘ press here takes the guide" outside the Move tool: not without ⌘, +/// not with a locked guide, not during a stroke, not for a tool that keeps ⌘. +#[test] +fn command_guide_at_decides_for_every_tool_that_moves_guides() { + use crate::canvas::command_guide_at; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 200})).unwrap(); + let cmd = Modifiers::COMMAND; + for tool in [Tool::Brush, Tool::Eraser, Tool::Gradient, Tool::PaintBucket, Tool::Eyedropper, Tool::Frame, Tool::Artboard, Tool::MagicWand] { + assert_eq!(command_guide_at(&app, tool, [200.0, 100.0], cmd), Some((true, 0)), "{tool:?}"); + assert_eq!(command_guide_at(&app, tool, [200.0, 100.0], Modifiers::NONE), None, "{tool:?} without ⌘"); + assert_eq!(command_guide_at(&app, tool, [120.0, 100.0], cmd), None, "{tool:?} off the guide"); + } + for tool in [Tool::Move, Tool::Pen, Tool::Hand, Tool::Zoom, Tool::Crop, Tool::Type, Tool::Rectangle, Tool::Slice, Tool::PathSelection] { + assert_eq!(command_guide_at(&app, tool, [200.0, 100.0], cmd), None, "{tool:?} keeps ⌘"); + } + app.ui.extras.lock_guides = true; + assert_eq!(command_guide_at(&app, Tool::Brush, [200.0, 100.0], cmd), None, "locked guides"); + app.ui.extras.lock_guides = false; + // A stroke under way keeps the pointer: ⌘ pressed mid-stroke does not take the guide. + app.ui.tool = Tool::Brush; + crate::canvas::tool_event(&mut app, crate::canvas::ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, Modifiers::NONE); + assert!(app.drag.is_some()); + assert_eq!(command_guide_at(&app, Tool::Brush, [200.0, 100.0], cmd), None, "during a stroke"); +} diff --git a/crates/ui-egui/src/guide_layout_ui.rs b/crates/ui-egui/src/guide_layout_ui.rs index 8d47e628c8..894535e687 100644 --- a/crates/ui-egui/src/guide_layout_ui.rs +++ b/crates/ui-egui/src/guide_layout_ui.rs @@ -194,6 +194,9 @@ fn number(ui: &mut egui::Ui, f: &mut Map, key: &str, count: bool) let mut value = f.get(key).and_then(Value::as_f64).unwrap_or(0.0) as f32; if widgets::value_field(ui, &mut value, (if count { 1.0 } else { 0.0 })..=if count { 1000.0 } else { 300_000.0 }, if count { "" } else { "px" }, 68.0) .changed() + // A disabled field still clamps its value into range (a zero count to 1); writing that + // back would switch the axis on through its `positive` fallback (#5625). + && ui.is_enabled() { f.insert(key.into(), if count { json!(value.round() as u64) } else { json!(value) }); } diff --git a/crates/ui-egui/src/guide_layout_ui/tests.rs b/crates/ui-egui/src/guide_layout_ui/tests.rs index 299b3fb83e..673f1663bf 100644 --- a/crates/ui-egui/src/guide_layout_ui/tests.rs +++ b/crates/ui-egui/src/guide_layout_ui/tests.rs @@ -104,6 +104,32 @@ fn tab_skips_disabled_rows_and_margins() { assert_eq!(f["gutter"], 4.0); } +/// The disabled Number field must not clamp a zero count to 1 and so switch its axis on (#5625). +#[test] +fn disabled_number_fields_do_not_turn_their_axis_on() { + let mut app = app(); + let id = open(&mut app); + let mut h = harness(app); + h.run_steps(3); + let f = &h.state().ui.dialogs.first().unwrap().fields; + assert_eq!(f["rows"], 0, "the default layout has no rows"); + assert!(!on(f, "__rowsEnabled", positive(f, "rows")), "Rows stays off"); + let mut app = h.into_state(); + crate::dialogs::confirm(&mut app, id).unwrap(); + let guides = &app.session.active().unwrap().doc.guides; + assert!(!guides.vertical.is_empty(), "the columns were created"); + assert!(guides.horizontal.is_empty(), "no row guides at the top and bottom"); + + let mut app = self::app(); + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("columns".into(), json!(0)); + let mut h = harness(app); + h.run_steps(3); + let f = &h.state().ui.dialogs.first().unwrap().fields; + assert_eq!(f["columns"], 0); + assert!(!on(f, "__columnsEnabled", positive(f, "columns")), "Columns stays off"); +} + /// New Guide Layout offers New Guide's colours, by value, and keeps another one as Custom. #[test] fn color_list_is_new_guides() { diff --git a/crates/ui-egui/src/guide_select.rs b/crates/ui-egui/src/guide_select.rs new file mode 100644 index 0000000000..815d02862f --- /dev/null +++ b/crates/ui-egui/src/guide_select.rs @@ -0,0 +1,110 @@ +//! Selecting a guide on the canvas, as in Photoshop: a click on a guide with the Move tool selects +//! it (drawn in Photoshop's selected-guide blue), a press anywhere else deselects it, Delete +//! removes it, and right-click opens the guide menu (`canvas_tool_menu`), whose Edit Selected +//! Guides reopens New Guide on it. +//! +//! One guide at a time. The selection is view state, not a history step: it names the guide by +//! document, orientation and position, as guide colours do, so it lapses by itself when the guide +//! moves away, is deleted or undone, and the index is looked up when it is used. + +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::PhotocraftApp; + +/// Photoshop draws a selected guide in this blue, whatever the guide's own colour. +pub const SELECTED_COLOR: egui::Color32 = egui::Color32::from_rgb(0x00, 0x00, 0xff); + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct SelectedGuide { + /// The document's index among the open documents. + pub doc: usize, + pub vertical: bool, + pub position: f64, +} + +/// The selected guide's orientation and index in the active document, while guides can be +/// interacted with (shown, unlocked). +pub fn selected(app: &PhotocraftApp) -> Option<(bool, usize)> { + let sel = app.ui.selected_guide?; + if !interactive(app) || app.session.active_index() != Some(sel.doc) { + return None; + } + let guides = &app.session.active()?.doc.guides; + let list = if sel.vertical { &guides.vertical } else { &guides.horizontal }; + list.iter().position(|p| *p == sel.position).map(|i| (sel.vertical, i)) +} + +/// Is the guide at `position` the selected one (drawn blue)? +pub fn is_selected(app: &PhotocraftApp, vertical: bool, position: f64) -> bool { + selected(app).is_some() && app.ui.selected_guide.is_some_and(|s| s.vertical == vertical && s.position == position) +} + +/// Guides take clicks only while they're shown and unlocked (as `rulers::guide_at` requires). +fn interactive(app: &PhotocraftApp) -> bool { + app.ui.extras.guides && app.ui.view.shows(app.ui.view.show.canvas_guides) && !app.ui.extras.lock_guides +} + +/// Select guide `index` of the active document. +pub fn select(app: &mut PhotocraftApp, vertical: bool, index: usize) { + let Some(doc) = app.session.active_index() else { return }; + let Some(guides) = app.session.active().map(|st| &st.doc.guides) else { return }; + let list = if vertical { &guides.vertical } else { &guides.horizontal }; + app.ui.selected_guide = list.get(index).map(|position| SelectedGuide { doc, vertical, position: *position }); +} + +pub fn clear(app: &mut PhotocraftApp) { + app.ui.selected_guide = None; +} + +/// Delete the selected guide (Delete, Clear Selected Guides). False when no guide is selected. +pub fn delete_selected(app: &mut PhotocraftApp) -> Result { + let Some((vertical, index)) = selected(app) else { return Ok(false) }; + app.run("view.deleteGuide", delete_params(vertical, index))?; + clear(app); + Ok(true) +} + +pub fn delete_params(vertical: bool, index: usize) -> serde_json::Value { + json!({"orientation": if vertical { "vertical" } else { "horizontal" }, "index": index}) +} + +/// ⌥-click on guide `index`: turn it, so a vertical guide becomes horizontal through the point +/// clicked (`at`, document pixels) and the other way round, as one undoable step (`view.editGuide`); +/// it stays selected. +pub fn flip(app: &mut PhotocraftApp, vertical: bool, index: usize, at: [f64; 2]) { + let (from, to) = if vertical { ("vertical", "horizontal") } else { ("horizontal", "vertical") }; + let position = if vertical { at[1] } else { at[0] }; + let params = json!({"orientation": from, "index": index, "newOrientation": to, "position": (position * 1000.0).round() / 1000.0}); + match app.run("view.editGuide", params) { + Ok(_) => { + let last = app.session.active().map(|st| if vertical { st.doc.guides.horizontal.len() } else { st.doc.guides.vertical.len() }); + if let Some(n) = last.and_then(|n| n.checked_sub(1)) { + select(app, !vertical, n); + } + } + Err(e) => app.ui.status = e, + } +} + +/// Delete or Backspace with no modifiers while a guide is selected deletes the guide, before Edit › +/// Clear can reach the pixels, as in Photoshop. +pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + use crate::shortcut_dispatch::Focus; + // Text fields and widgets that delete with these keys themselves keep them (#1534). + if selected(app).is_none() || app.ui.text_edit.is_some() || matches!(Focus::of(ctx), Focus::Text | Focus::DeletingWidget) { + return false; + } + let mods = ctx.input(|i| i.modifiers); + if !mods.is_none() || !ctx.input_mut(|i| i.consume_key(mods, egui::Key::Delete) || i.consume_key(mods, egui::Key::Backspace)) { + return false; + } + if let Err(e) = delete_selected(app) { + app.ui.status = e; + app.ui.status_error = true; + } + true +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/guide_select/tests.rs b/crates/ui-egui/src/guide_select/tests.rs new file mode 100644 index 0000000000..a591f47547 --- /dev/null +++ b/crates/ui-egui/src/guide_select/tests.rs @@ -0,0 +1,308 @@ +use egui::{Key, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use super::*; +use crate::canvas::{ToolEvent, tool_event}; +use crate::canvas_tool_menu::{GUIDE_CLEAR, GUIDE_EDIT}; +use crate::state::Tool; + +/// A 200 × 100 document with the Move tool, a vertical guide at 50 and a magenta horizontal one +/// at 30. +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::Move; + app.run("view.newGuide", json!({"orientation": "vertical", "position": 50})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 30, "color": "magenta"})).unwrap(); + app +} + +fn click(app: &mut PhotocraftApp, x: f64, y: f64) { + tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Up { x, y }, Modifiers::NONE); +} + +fn guides(app: &PhotocraftApp) -> (Vec, Vec) { + let g = &app.session.active().unwrap().doc.guides; + (g.vertical.clone(), g.horizontal.clone()) +} + +fn past(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +#[test] +fn a_click_selects_a_guide_without_moving_it_and_a_press_elsewhere_deselects() { + let mut app = app(); + let steps = past(&app); + click(&mut app, 50.0, 70.0); + assert_eq!(selected(&app), Some((true, 0))); + assert!(is_selected(&app, true, 50.0) && !is_selected(&app, false, 30.0)); + assert_eq!((guides(&app), past(&app)), ((vec![50.0], vec![30.0]), steps), "a click is not a move"); + // One guide at a time: clicking another selects it instead. + click(&mut app, 120.0, 30.0); + assert_eq!(selected(&app), Some((false, 0))); + click(&mut app, 120.0, 70.0); + assert_eq!(selected(&app), None); + // A press with another tool deselects too. + click(&mut app, 50.0, 70.0); + app.ui.tool = Tool::RectMarquee; + tool_event(&mut app, ToolEvent::Down { x: 150.0, y: 80.0, pressure: 1.0 }, Modifiers::NONE); + assert_eq!(selected(&app), None); +} + +#[test] +fn a_moved_guide_stays_selected_and_dragging_one_off_the_canvas_deletes_it() { + let mut app = app(); + tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 70.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 80.0, y: 70.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 80.0, y: 70.0 }, Modifiers::NONE); + assert_eq!(guides(&app).0, vec![80.0]); + assert_eq!(selected(&app), Some((true, 0))); + tool_event(&mut app, ToolEvent::Down { x: 80.0, y: 70.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: -20.0, y: 70.0 }, Modifiers::NONE); + assert!(guides(&app).0.is_empty()); + assert_eq!(selected(&app), None); +} + +#[test] +fn the_selection_lapses_when_its_guide_goes_or_guides_are_hidden_or_locked() { + let mut app = app(); + click(&mut app, 50.0, 70.0); + app.ui.extras.lock_guides = true; + assert_eq!(selected(&app), None, "locked guides can't be selected"); + app.ui.extras.lock_guides = false; + app.ui.extras.guides = false; + assert_eq!(selected(&app), None, "nor hidden ones"); + app.ui.extras.guides = true; + assert_eq!(selected(&app), Some((true, 0))); + app.run("view.clearGuides", json!({})).unwrap(); + assert_eq!(selected(&app), None); + app.session.undo(); + assert_eq!(selected(&app), Some((true, 0)), "undo brings the guide back, still selected"); + // Another document's guide at the same place isn't the selected one. + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 50})).unwrap(); + assert_eq!(selected(&app), None); +} + +#[test] +fn delete_removes_the_selected_guide_and_leaves_the_pixels() { + let mut app = app(); + app.run("select.all", json!({})).unwrap(); + let ctx = egui::Context::default(); + let press = |app: &mut PhotocraftApp, key| { + let modifiers = Modifiers::NONE; + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); + }; + click(&mut app, 120.0, 30.0); + press(&mut app, Key::Delete); + assert_eq!(guides(&app), (vec![50.0], vec![])); + assert_eq!(selected(&app), None); + click(&mut app, 50.0, 70.0); + press(&mut app, Key::Backspace); + assert_eq!(guides(&app), (vec![], vec![])); + let st = app.session.active().unwrap(); + assert_eq!(st.history.undo_label(), Some("Delete Guide"), "Edit › Clear didn't run"); + assert!(st.doc.selection.is_some()); + // One undoable step per guide. + app.session.undo(); + assert_eq!(guides(&app), (vec![50.0], vec![])); +} + +fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + crate::canvas_tool_menu::show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default()); + h.run_steps(4); + h +} + +fn right_click(h: &mut Harness<'static, PhotocraftApp>, p: Pos2) { + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +/// The vertical guide through the middle of the document, on screen: the view centres the image. +fn guide_on_screen(h: &Harness<'static, PhotocraftApp>) -> Pos2 { + h.state().last_canvas_rect.center() + vec2(0.0, 20.0) +} + +fn centred_guide_app() -> PhotocraftApp { + let mut app = app(); + app.run("view.clearGuides", json!({})).unwrap(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 100})).unwrap(); + app +} + +#[test] +fn right_click_on_a_guide_selects_it_and_opens_photoshops_guide_menu() { + let mut h = harness(centred_guide_app()); + let at = guide_on_screen(&h); + right_click(&mut h, at); + let menu = h.state().ui.canvas_tool_menu.clone().expect("the guide menu"); + assert!(menu.guide); + assert_eq!(selected(h.state()), Some((true, 0))); + let labels: Vec> = crate::canvas_tool_menu::rows(&menu).iter().map(|r| r.map(|(label, _)| label)).collect(); + assert_eq!( + labels, + [ + Some("Edit Selected Guides"), + None, + Some("Lock Guides"), + Some("Clear Guides"), + Some("Clear Canvas Guides"), + Some("Clear Selected Guides"), + Some("Clear Selected Artboard Guides"), + ] + ); + let app = h.state(); + for id in [GUIDE_EDIT, GUIDE_CLEAR, "view.lockGuides", "view.clearGuides", "view.clearCanvasGuides"] { + assert!(crate::canvas_tool_menu::available(app, &menu, id), "{id}"); + } + assert!(!crate::canvas_tool_menu::available(app, &menu, "view.clearSelectedArtboardGuides"), "no artboard"); + assert!(!crate::canvas_tool_menu::available(app, &menu, "edit.freeTransform"), "not a guide row"); + // Off a guide, the Move tool's right-click is still the layer picker's. + h.key_press(Key::Escape); + h.run_steps(2); + let off = h.state().last_canvas_rect.center() + vec2(-60.0, 20.0); + right_click(&mut h, off); + assert!(h.state().ui.canvas_tool_menu.is_none()); +} + +#[test] +fn guide_menu_rows_clear_the_selected_guide_and_lock_guides() { + let mut h = harness(centred_guide_app()); + h.state_mut().run("view.newGuide", json!({"orientation": "vertical", "position": 20})).unwrap(); + let at = guide_on_screen(&h); + right_click(&mut h, at); + let ctx = h.ctx.clone(); + crate::canvas_tool_menu::choose(h.state_mut(), &ctx, GUIDE_CLEAR); + assert_eq!(guides(h.state()).0, vec![20.0], "only the right-clicked guide goes"); + h.state_mut().run("view.newGuide", json!({"orientation": "vertical", "position": 100})).unwrap(); + h.run_steps(2); + let at = guide_on_screen(&h); + right_click(&mut h, at); + crate::canvas_tool_menu::choose(h.state_mut(), &ctx, "view.lockGuides"); + assert!(h.state().ui.extras.lock_guides); + assert_eq!(selected(h.state()), None, "locked guides aren't selected"); +} + +#[test] +fn edit_selected_guides_opens_new_guide_on_the_guide_and_applies_the_edit() { + let mut app = app(); + app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "percent"})).unwrap(); + click(&mut app, 120.0, 30.0); + let id = crate::new_guide_ui::open_edit(&mut app).unwrap(); + let f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + assert_eq!((f["orientation"].as_str(), f["position"].as_str(), f["color"].as_str()), (Some("horizontal"), Some("30 %"), Some("magenta"))); + assert_eq!(f["__label"], "Edit Selected Guides"); + // Turn it vertical at 30 % of the width; the colour isn't touched, so it keeps its own. + let mut edited = f.clone(); + edited.insert("orientation".into(), json!("vertical")); + edited.insert("position".into(), json!("30")); + app.ui.dialogs.retain(|d| d.id != id); + crate::new_guide_ui::confirm(&mut app, &edited).unwrap(); + assert_eq!(guides(&app), (vec![50.0, 60.0], vec![])); + let g = &app.session.active().unwrap().doc.guides; + assert_eq!((g.color(true, 60.0), g.color(false, 30.0)), (Some([0xff, 0x00, 0xff]), None)); + assert_eq!(selected(&app), Some((true, 1)), "the edited guide stays selected"); +} + +#[test] +fn editing_an_uncoloured_guide_keeps_the_preference_colour_unless_one_is_chosen() { + let mut app = app(); + click(&mut app, 50.0, 70.0); + let id = crate::new_guide_ui::open_edit(&mut app).unwrap(); + let f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + // The default preference colour is Cyan's, so a guide dragged from a ruler shows as Cyan. + assert_eq!(f["color"].as_str(), Some("cyan")); + let params = crate::new_guide_ui::params(&app, &f); + assert!(params.get("color").is_none(), "{params}"); + let mut yellow = f.clone(); + yellow.insert("color".into(), json!("yellow")); + assert_eq!(crate::new_guide_ui::params(&app, &yellow)["color"], "yellow"); + // A refused position keeps the dialog open with the message and the guide untouched. + let mut bad = f.clone(); + bad.insert("position".into(), json!("far")); + app.ui.dialogs.retain(|d| d.id != id); + assert!(crate::new_guide_ui::confirm(&mut app, &bad).is_err()); + assert_eq!(guides(&app).0, vec![50.0]); + assert!(app.ui.dialogs.last().is_some_and(|d| d.fields.contains_key("__error"))); + // A preference colour that isn't a named one shows as Custom…. + app.ui.dialogs.clear(); + app.run("prefs.set", json!({"path": "guidesGridAndSlices.guideColor", "value": "#123456"})).unwrap(); + let id = crate::new_guide_ui::open_edit(&mut app).unwrap(); + let f = &app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields; + assert_eq!((f["color"].as_str(), f["customColor"].as_str()), (Some("custom"), Some("#123456"))); +} + +#[test] +fn edit_selected_guides_needs_a_selected_guide() { + let mut app = app(); + assert!(crate::new_guide_ui::open_edit(&mut app).is_err()); + assert!(app.ui.dialogs.is_empty()); +} + +/// ⌥-click turns a guide through the point clicked, keeping its colour, as one undoable step, and +/// leaves it selected; a locked guide can't be turned. +#[test] +fn alt_click_turns_a_guide_through_the_point_clicked() { + let mut app = app(); + let alt = Modifiers::ALT; + tool_event(&mut app, ToolEvent::Down { x: 120.0, y: 30.0, pressure: 1.0 }, alt); + tool_event(&mut app, ToolEvent::Up { x: 120.0, y: 30.0 }, alt); + assert_eq!(guides(&app), (vec![50.0, 120.0], vec![]), "the magenta horizontal guide is now vertical at x = 120"); + let g = &app.session.active().unwrap().doc.guides; + assert_eq!(g.color(true, 120.0), Some([0xff, 0x00, 0xff])); + assert_eq!(selected(&app), Some((true, 1))); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Edit Guide")); + app.session.undo(); + assert_eq!(guides(&app), (vec![50.0], vec![30.0])); + app.ui.extras.lock_guides = true; + tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 70.0, pressure: 1.0 }, alt); + tool_event(&mut app, ToolEvent::Up { x: 50.0, y: 70.0 }, alt); + assert_eq!(guides(&app), (vec![50.0], vec![30.0]), "locked guides stay put"); +} + +/// Double-clicking a guide opens Preferences › Guides, Grid & Slices, as in Photoshop. +#[test] +fn double_click_on_a_guide_opens_the_guide_preferences() { + let mut h = harness(centred_guide_app()); + let at = guide_on_screen(&h); + h.hover_at(at); + for _ in 0..40 { + h.step(); + } + for _ in 0..2 { + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + let dialog = h.state().ui.dialogs.last().map(|d| serde_json::to_string(&d.fields).unwrap_or_default()).unwrap_or_default(); + assert!(dialog.contains("guidesGridAndSlices"), "{dialog}"); + assert_eq!(guides(h.state()).0, vec![100.0], "the guide didn't move"); +} diff --git a/crates/ui-egui/src/i18n/LICENSE-translations.txt b/crates/ui-egui/src/i18n/LICENSE-translations.txt index c33e7a21e9..93a609f946 100644 --- a/crates/ui-egui/src/i18n/LICENSE-translations.txt +++ b/crates/ui-egui/src/i18n/LICENSE-translations.txt @@ -1,5 +1,6 @@ -PhotoCraft's Korean, German, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, Turkish, -Hungarian and Serbian UI translation catalogs are licensed under MIT OR Apache-2.0, at your option. +PhotoCraft's Korean, German, Spanish, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, +Turkish, Hungarian and Serbian UI translation catalogs are licensed under MIT OR Apache-2.0, +at your option. The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE. Korean translations: PhotoCraft contributors, original translations of @@ -8,6 +9,11 @@ PhotoCraft's English labels; no proprietary translation resources were used. German translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. +Spanish translations: contributed by @gorevyoneticisi with OpenAI Codex as an AI contributor. +The resulting catalog is licensed under MIT OR Apache-2.0 as stated above; the translations are +original translations of PhotoCraft's English labels, and no proprietary translation resources +were used. + Brazilian Portuguese translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 805c20f76a..6789b75134 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -562,6 +562,9 @@ # Field Blur pins (#3373) No pin selected Není vybrán žádný špendlík Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kliknutím do obrazu přidáte špendlík; tažením špendlík přesunete, tažením jeho prstence změníte rozostření. Delete odstraní vybraný špendlík. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Tažením úchytů na obrysu ho změníte a otočíte, čtverečkem zaoblíte rohy a body uvnitř nastavíte, kde začíná rozostření. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Tažením špendlíku přesunete pás, tažením jeho prstence změníte rozostření. Plné čáry rozšiřují ostrý pás, přerušované nastavují přechod a body ho otáčejí. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Tažením úchytů na obrysu ho změníte a otočíte; prstenec nastavuje úhel rozostření. Iris Blur… Rozostření clonou… Tilt-Shift… Tilt-shift… Path Blur… Rozostření podél cesty… @@ -997,7 +1000,6 @@ Contiguous Souvislé Contrast Kontrast Control: Řízení: - Convert document's colors to the working space Převést barvy dokumentu do pracovního prostoru barev Convert to sRGB Převést do sRGB Copies Kopie Copper Měď @@ -1052,6 +1054,7 @@ Delete Group Odstranit skupinu Delete Layer Mask Odstranit masku vrstvy Delete Layers Odstranit vrstvy + Add Shortcut Přidat zkratku Delete Shortcut Odstranit zkratku Delete Timeline Odstranit časovou osu Delete Tool Preset Odstranit přednastavení nástroje @@ -1580,7 +1583,6 @@ Use Default Použít výchozí Use Legacy Použít starší Use Measurement Scale Použít měřítko měření - Use the embedded profile (instead of the working space) Použít vložený profil (místo pracovního prostoru barev) Variables bind a layer's visibility, text or pixels to a named data slot. Proměnné vážou viditelnost, text nebo pixely vrstvy na pojmenované datové pole. Vertical scale Svislé měřítko Vibrance Živost @@ -1909,6 +1911,8 @@ Camera Raw Defaults Výchozí nastavení Camera Raw Canvas border Okraj plátna Show canvas dots Zobrazit tečky plátna + Clip thumbnails to layer bounds Oříznout miniatury na hranice vrstvy + Clip Thumbnails to Layer Bounds Oříznout miniatury na hranice vrstvy Canvas color Barva plátna Canvas custom color Vlastní barva plátna Color picker Výběr barvy @@ -2099,7 +2103,8 @@ Expand group ({key}-click: all groups) Rozbalit skupinu ({key}+kliknutí: všechny skupiny) Fade steps Kroky zeslabení Hide the layer's effects ({key}-click: all layers) Skrýt efekty vrstvy ({key}+kliknutí: všechny vrstvy) - Import Brushes… (.abr) Importovat štětce… (.abr) + Import Brushes… (.abr or image) Importovat štětce… (.abr nebo obrázek) + Import Brush from Image… Importovat štětec z obrázku… Move Group Down Posunout skupinu dolů Move Group Up Posunout skupinu nahoru Reset the brush to the defaults Obnovit výchozí nastavení štětce @@ -2214,7 +2219,6 @@ Cancelled opening {name} Otevírání {name} zrušeno The document “{name}” does not have an embedded {mode} profile. Dokument „{name}“ nemá vložený profil {mode}. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}“ má vložený barevný profil, který neodpovídá aktuálnímu pracovnímu prostoru barev {mode}. - Embedded: {embedded} Vložený: {embedded} Working: {working} Pracovní: {working} Developing {name} Vyvolávání {name} Cancelled {label} Zrušeno: {label} @@ -2438,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á @@ -2504,6 +2507,8 @@ fontWeight Italic Kurzíva Color Picker (Layer Style Color) Výběr barvy (barva stylu vrstvy) Auto Hide Notifications Automaticky skrývat oznámení Notification Duration (seconds) Trvání oznámení (sekundy) + Bundle Arrow-Key Nudges Slučovat posuny šipkami + Nudge Bundle Pause (ms) Pauza slučování posunů (ms) Appearance Mode Režim vzhledu Dark Theme Tmavý motiv Light Theme Světlý motiv @@ -2636,6 +2641,26 @@ cameraData Lens Objektiv Snap to Origin Přichytit k počátku Search settings… Hledat nastavení… No matching settings Žádná odpovídající nastavení +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Nástroj Štětec vzorkování + Sampling Area Overlay Překrytí oblasti vzorkování + Show Sampling Area Zobrazit oblast vzorkování + Indicates Označuje + Sampling Area Oblast vzorkování + Excluded Area Vyloučená oblast + Sampling Area Options Volby oblasti vzorkování + Rectangular Obdélníková + Fill Settings Nastavení výplně + Rotation Adaptation Přizpůsobení otočení + Very High Velmi vysoká + High Vysoká + Low Nízká + Full Úplná + Mirror Zrcadlit + Output Settings Nastavení výstupu + Output To Výstup do + Duplicate Layer Duplikovat vrstvu + Contract Zúžit # Dynamic labels the tl! literal scanner cannot see: filter and Filter Gallery parameter names and # choices (humanized from their keys), Camera Raw controls, render filters (Flame, Picture Frame, Tree), @@ -2776,7 +2801,6 @@ cameraData Lens Objektiv Content-Aware Fill Výplň s ohledem na obsah Content-Aware Move Přesun s ohledem na obsah Continue Pokračovat - Contract Zúžit Contrasty Kontrastní Conté Crayon Křída Conté Convert to Linked Převést na propojený @@ -2852,7 +2876,6 @@ cameraData Lens Objektiv Draft Koncept Dry Brush Suchý štětec Duplicate Duplikovat - Duplicate Layer Duplikovat vrstvu Duplicate Layer Comp Duplikovat kompozici vrstev Duplicate Layers Duplikovat vrstvy Duplicate Path Duplikovat cestu @@ -2979,7 +3002,6 @@ cameraData Lens Objektiv Hide Skrýt Hide Others Skrýt ostatní Hide {app} Skrýt {app} - High Vysoká High Pass Horní propust Highlight Světla Highlight Amount Míra světel @@ -3066,7 +3088,6 @@ cameraData Lens Objektiv Long Lines Dlouhé čáry Long Strokes Dlouhé tahy Loop Smyčka - Low Nízká Lower Dolní Lum Jas Lut LUT @@ -3482,6 +3503,7 @@ cameraRaw Vibrance Živost cameraRaw Whites Bílé layerLabel No Color Žádná barva Loading fonts… Načítání písem… + Response curve Křivka odezvy Simulate Pressure Simulovat přítlak Background Eraser Guma pozadí Healing Brush Retušovací štětec @@ -3717,6 +3739,7 @@ navigator Zoom Zvětšení Remove after each stroke Odstranit po každém tahu Apply the removal ({key}) Použít odstranění ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tahy k odstranění: {n} (Enter použije, Esc zruší) + Show home recent files Zobrazit nedávné soubory na úvodní obrazovce Clear Brush Controls Obnovit ovládací prvky stopy Reset All Locked Settings Obnovit všechna zamknutá nastavení Copy Texture to Other Tools Kopírovat texturu do jiných nástrojů @@ -3743,5 +3766,93 @@ navigator Zoom Zvětšení Action playback stopped Přehrávání akce zastaveno Step {step} is waiting for its dialog Krok {step} čeká na svůj dialog @plural {n} action|{n} actions {n} akce|{n} akce|{n} akcí + Background (Default) Pozadí (výchozí) +# Batch / Image Processor + Include All Subfolders Zahrnout všechny podsložky Auto Add/Delete Automaticky přidat/odstranit + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Nezapínejte současně zrcadlení v nastavení tisku: obě převrácení se navzájem zruší. + Print Settings… Nastavení tisku… + Remember to turn off the printer's own colour management in Print Settings… Nezapomeňte v nastavení tisku vypnout vlastní správu barev tiskárny… + Sent to {printer} Odesláno do {printer} + set in Print Settings… nastavuje se v nastavení tisku… ICO is limited to 256 × 256 pixels ICO je omezen na 256 × 256 pixelů +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Použít vložený profil ({profile}) + Convert to the working space ({profile}) Převést do pracovního prostoru barev ({profile}) + Remember this choice for future images Zapamatovat si tuto volbu pro další obrázky + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Dotaz znovu zapnete volbou Ask On Mismatch v nabídce Úpravy › Nastavení barev. + Button Mode Režim tlačítka + Button Mode On Režim tlačítka zapnutý + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Ztmavit rozhraní + Lighten Interface Zesvětlit rozhraní +# Bevel & Emboss Technique. + Technique Technika + Chisel Hard Tvrdé tesání + Chisel Soft Měkké tesání + Open color-less EXR as ACEScg Otevřít EXR bez údaje o barevném prostoru jako ACEScg + +# View › Guides › right-click a guide + Edit Selected Guides Upravit vybraná vodítka + Clear Selected Guides Vymazat vybraná vodítka + Pasting from other apps is unavailable Vkládání z jiných aplikací není k dispozici + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft v této relaci nemá přístup k systémové schránce. Na Waylandu sandbox Flatpaku skrývá protokol, přes který se čtou obrázky z jiných aplikací. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Chcete-li vkládat z jiných aplikací, spusťte PhotoCraft pod XWaylandem: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Není co vložit: PhotoCraft zde nemůže číst systémovou schránku + Delete this set Smazat tuto sadu + New Set… Nová sada… + PhotoCraft Defaults Výchozí PhotoCraft + Save a list of these shortcuts as a web page Uložit seznam těchto zkratek jako webovou stránku + Save the changes to this set Uložit změny do této sady + Save these shortcuts as a .kys file Uložit tyto zkratky jako soubor .kys + Save these shortcuts as a new set Uložit tyto zkratky jako novou sadu + Set deleted. Sada smazána. + Set saved. Sada uložena. + Set: Sada: + Summarize… Shrnout… + There are too many sets: delete one first. Sad je příliš mnoho: nejprve jednu smažte. + set name název sady + {set} (modified) {set} (změněno) + Show file name in title Zobrazit název souboru v titulu + Accept and Go to Conflict Přijmout a přejít ke konfliktu + Changes undone. Změny vráceny. + Choose a new shortcut for this command, or leave it without one. Zvolte pro tento příkaz novou zkratku, nebo ho nechte bez ní. + Put this command's shortcuts back as they were Vrátit zkratky tohoto příkazu do původního stavu + Take the shortcut from the other command and select it Odebrat zkratku druhému příkazu a vybrat ho + Undo Changes Vrátit změny + {shortcut} is reserved by the system and can't be assigned. {shortcut} je vyhrazena systémem a nelze ji přiřadit. + Low-Latency Drawing (vsync off, applies at next launch) Kreslení s nízkou latencí (bez vertikální synchronizace, platí po dalším spuštění) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Volná transformace se stále použije; zkuste to znovu, až skončí. + The transform couldn't be applied. Transformaci nelze použít. +# 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, 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} + 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í. + Decrease Brush Size Zmenšit velikost štětce + Increase Brush Size Zvětšit velikost štětce + Decrease Brush Hardness Snížit tvrdost štětce + Increase Brush Hardness Zvýšit tvrdost štětce + Memory Usage Využití paměti + Per-document RAM limit (MB) Limit RAM na dokument (MB) + Installed RAM: {ram} MB; this limit: {percent}% Nainstalovaná RAM: {ram} MB; tento limit: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Omezuje pixely plus Historii každého otevřeného dokumentu. Nejstarší stavy zpět se při překročení limitu zahodí. + Applies immediately when saved. Memory is not reserved in advance. Platí hned po uložení. Paměť se předem nerezervuje. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Při GPU paměti Automaticky omezuje i GPU rozpočet. Strop je čtvrtina RAM a, je-li známo, tři osminy paměti karty, minimum 512 MB. Vyšší limit GPU rozpočet nezvýší. + Use Použít + In Front Vpředu + Behind Vzadu + +# DDS Options (#1327) + DDS Options Možnosti DDS + Generate Mipmaps Generovat mipmapy diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 88ef957675..3aa7e7212b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -561,6 +561,9 @@ # Field Blur pins (#3373) No pin selected Kein Pin ausgewählt Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klicken Sie ins Bild, um einen Pin hinzuzufügen; ziehen Sie einen Pin, um ihn zu verschieben, oder seinen Ring, um die Unschärfe zu ändern. Entf entfernt den ausgewählten Pin. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Ziehen Sie die Griffe am Umriss, um ihn zu skalieren und zu drehen, das Quadrat, um die Ecken abzurunden, und die Punkte innen, um festzulegen, wo die Unschärfe beginnt. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Ziehen Sie den Pin, um das Band zu verschieben, oder seinen Ring, um die Unschärfe zu ändern. Ziehen Sie die durchgezogenen Linien, um das scharfe Band zu verbreitern, die gestrichelten für den Übergang und die Punkte zum Drehen. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Ziehen Sie die Griffe am Umriss, um ihn zu skalieren und zu drehen; der Ring legt den Unschärfewinkel fest. Iris Blur… Irisweichzeichner… Tilt-Shift… Tilt-Shift… Path Blur… Pfadweichzeichner… @@ -978,7 +981,6 @@ Contiguous Benachbart Contrast Kontrast Control: Steuerung: - Convert document's colors to the working space Farben des Dokuments in den Arbeitsfarbraum konvertieren Convert to sRGB In sRGB konvertieren Copies Kopien Copper Kupfer @@ -1035,6 +1037,7 @@ Delete Group Gruppe löschen Delete Layer Mask Ebenenmaske löschen Delete Layers Ebenen löschen + Add Shortcut Kurzbefehl hinzufügen Delete Shortcut Kurzbefehl löschen Delete Timeline Zeitleiste löschen Delete Tool Preset Werkzeugvorgabe löschen @@ -1562,7 +1565,6 @@ Use Default Standard verwenden Use Legacy Legacy verwenden Use Measurement Scale Messskala verwenden - Use the embedded profile (instead of the working space) Eingebettetes Profil verwenden (statt des Arbeitsfarbraums) Variables bind a layer's visibility, text or pixels to a named data slot. Variablen binden Sichtbarkeit, Text oder Pixel einer Ebene an einen benannten Datenslot. Vertical scale Vertikale Skalierung Vibrance Dynamik @@ -1883,6 +1885,8 @@ Camera Raw Defaults Camera-Raw-Standardeinstellungen Canvas border Zeichenflächenrand Show canvas dots Punkte der Zeichenfläche anzeigen + Clip thumbnails to layer bounds Miniaturbilder auf Ebenenbegrenzung beschneiden + Clip Thumbnails to Layer Bounds Miniaturbilder auf Ebenenbegrenzung beschneiden Canvas color Zeichenflächenfarbe Canvas custom color Benutzerdefinierte Zeichenflächenfarbe Color picker Farbwähler @@ -2071,7 +2075,8 @@ Expand group ({key}-click: all groups) Gruppe ausklappen ({key}-Klick: alle Gruppen) Fade steps Verblassen-Schritte Hide the layer's effects ({key}-click: all layers) Effekte der Ebene ausblenden ({key}-Klick: alle Ebenen) - Import Brushes… (.abr) Pinsel importieren… (.abr) + Import Brushes… (.abr or image) Pinsel importieren… (.abr oder Bild) + Import Brush from Image… Pinsel aus Bild importieren… Move Group Down Gruppe nach unten Move Group Up Gruppe nach oben Reset the brush to the defaults Pinsel auf Standardwerte zurücksetzen @@ -2184,7 +2189,6 @@ Cancelled opening {name} Öffnen von {name} abgebrochen The document “{name}” does not have an embedded {mode} profile. Das Dokument „{name}“ hat kein eingebettetes {mode}-Profil. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Das Dokument „{name}“ hat ein eingebettetes Farbprofil, das nicht zum aktuellen {mode}-Arbeitsfarbraum passt. - Embedded: {embedded} Eingebettet: {embedded} Working: {working} Arbeitsfarbraum: {working} Developing {name} {name} wird entwickelt Cancelled {label} {label} abgebrochen @@ -2477,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 @@ -2544,6 +2547,8 @@ fontWeight Italic Kursiv Color Picker (Layer Style Color) Farbwähler (Ebenenstilfarbe) Auto Hide Notifications Benachrichtigungen automatisch ausblenden Notification Duration (seconds) Dauer der Benachrichtigung (Sekunden) + Bundle Arrow-Key Nudges Pfeiltasten-Verschiebungen bündeln + Nudge Bundle Pause (ms) Pause für gebündelte Verschiebungen (ms) Appearance Mode Erscheinungsmodus Dark Theme Dunkles Design Light Theme Helles Design @@ -2670,7 +2675,26 @@ cameraData Lens Objektiv Snap to Origin An Ursprung ausrichten Search settings… Einstellungen durchsuchen… No matching settings Keine passenden Einstellungen +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Aufnahmepinsel-Werkzeug + Sampling Area Overlay Überlagerung des Aufnahmebereichs + Show Sampling Area Aufnahmebereich einblenden + Indicates Kennzeichnet + Sampling Area Aufnahmebereich + Excluded Area Ausgeschlossener Bereich + Sampling Area Options Optionen für Aufnahmebereich + Rectangular Rechteckig + Fill Settings Füllungseinstellungen + Rotation Adaptation Drehungsanpassung + Very High Sehr hoch + Full Voll + Mirror Spiegeln + Output Settings Ausgabeeinstellungen + Output To Ausgabe in + Duplicate Layer Ebene duplizieren + Contract Verkleinern Loading fonts… Schriften werden geladen… + Response curve Reaktionskurve Tool Werkzeug Simulate Pressure Druck simulieren Stroke Path… Pfad nachziehen… @@ -3107,6 +3131,7 @@ curves Smooth Glätten Remove after each stroke Nach jedem Strich entfernen Apply the removal ({key}) Entfernen anwenden ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Striche zum Entfernen: {n} (Eingabe wendet an, Esc bricht ab) + Show home recent files Zuletzt geöffnete Dateien auf dem Startbildschirm anzeigen Clear Brush Controls Pinselsteuerungen löschen Reset All Locked Settings Alle gesperrten Einstellungen zurücksetzen Copy Texture to Other Tools Struktur auf andere Werkzeuge kopieren @@ -3133,5 +3158,95 @@ curves Smooth Glätten Action playback stopped Aktionswiedergabe angehalten Step {step} is waiting for its dialog Schritt {step} wartet auf seinen Dialog @plural {n} action|{n} actions {n} Aktion|{n} Aktionen + Background (Default) Hintergrund (Standard) + Content-Aware Fill Inhaltsbasiert füllen +# Batch / Image Processor + Include All Subfolders Alle Unterordner einschließen Auto Add/Delete Autom. hinzuf./löschen + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Aktiviere nicht zusätzlich das Spiegelbild in den Druckeinstellungen: Die beiden Spiegelungen heben sich auf. + Print Settings… Druckeinstellungen… + Remember to turn off the printer's own colour management in Print Settings… Denke daran, die eigene Farbverwaltung des Druckers in den Druckeinstellungen auszuschalten… + Sent to {printer} An {printer} gesendet + set in Print Settings… in den Druckeinstellungen festlegen… ICO is limited to 256 × 256 pixels ICO ist auf 256 × 256 Pixel begrenzt +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Eingebettetes Profil verwenden ({profile}) + Convert to the working space ({profile}) In den Arbeitsfarbraum konvertieren ({profile}) + Remember this choice for future images Diese Auswahl für künftige Bilder merken + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Die Abfrage lässt sich mit „Ask On Mismatch“ unter Bearbeiten › Farbeinstellungen wieder einschalten. + Button Mode Schaltflächenmodus + Button Mode On Schaltflächenmodus an + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Benutzeroberfläche abdunkeln + Lighten Interface Benutzeroberfläche aufhellen +# Bevel & Emboss Technique. + Technique Technik + Chisel Hard Meißeln hart + Chisel Soft Meißeln weich + Open color-less EXR as ACEScg Farbangabenloses EXR als ACEScg öffnen +# Export As metadata and profile options + Color Profile Farbprofil + EXIF EXIF + +# View › Guides › right-click a guide + Edit Selected Guides Ausgewählte Hilfslinien bearbeiten + Clear Selected Guides Ausgewählte Hilfslinien löschen + Pasting from other apps is unavailable Einfügen aus anderen Apps ist nicht verfügbar + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft kann in dieser Sitzung nicht auf die Systemzwischenablage zugreifen. Unter Wayland verbirgt eine Flatpak-Sandbox das Protokoll, über das Bilder aus anderen Apps gelesen werden. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Um aus anderen Apps einzufügen, starte PhotoCraft unter XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nichts einzufügen: PhotoCraft kann die Systemzwischenablage hier nicht lesen + Delete this set Diesen Satz löschen + New Set… Neuer Satz… + PhotoCraft Defaults PhotoCraft-Standard + Save a list of these shortcuts as a web page Liste dieser Kurzbefehle als Webseite speichern + Save the changes to this set Änderungen in diesem Satz speichern + Save these shortcuts as a .kys file Diese Kurzbefehle als .kys-Datei speichern + Save these shortcuts as a new set Diese Kurzbefehle als neuen Satz speichern + Set deleted. Satz gelöscht. + Set saved. Satz gespeichert. + Set: Satz: + Summarize… Zusammenfassen… + There are too many sets: delete one first. Zu viele Sätze: Löschen Sie zuerst einen. + set name Name des Satzes + {set} (modified) {set} (geändert) + Show file name in title Dateiname im Titel anzeigen + Accept and Go to Conflict Übernehmen und zum Konflikt + Changes undone. Änderungen rückgängig gemacht. + Choose a new shortcut for this command, or leave it without one. Wählen Sie einen neuen Kurzbefehl für diesen Befehl oder lassen Sie ihn ohne. + Put this command's shortcuts back as they were Die Kurzbefehle dieses Befehls wiederherstellen + Take the shortcut from the other command and select it Den Kurzbefehl dem anderen Befehl entziehen und diesen auswählen + Undo Changes Änderungen rückgängig machen + {shortcut} is reserved by the system and can't be assigned. {shortcut} ist vom System reserviert und kann nicht zugewiesen werden. + Low-Latency Drawing (vsync off, applies at next launch) Zeichnen mit geringer Latenz (ohne VSync, gilt ab dem nächsten Start) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Frei transformieren wird noch angewendet; versuchen Sie es erneut, wenn es fertig ist. + The transform couldn't be applied. Die Transformation konnte nicht angewendet werden. +# 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, 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} + 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. + Memory Usage Speichernutzung + Per-document RAM limit (MB) RAM-Limit pro Dokument (MB) + Installed RAM: {ram} MB; this limit: {percent}% Installierter RAM: {ram} MB; dieses Limit: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Begrenzt Pixel plus Verlauf jedes offenen Dokuments. Die ältesten Widerrufsschritte werden verworfen, wenn das Limit überschritten wird. + Applies immediately when saved. Memory is not reserved in advance. Gilt sofort nach dem Speichern. Speicher wird nicht im Voraus reserviert. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Bei GPU-Speicher = Automatisch begrenzt dies auch das GPU-Ebenenbudget. Das Budget ist auf ein Viertel des RAM und, wenn bekannt, drei Achtel des Grafikspeichers begrenzt, mindestens 512 MB. Ein höheres Limit erhöht das GPU-Budget nicht. + Use Verwenden + In Front Davor + Behind Dahinter + Blend Mischen + +# DDS Options (#1327) + DDS Options DDS-Optionen + Compression Komprimierung + Generate Mipmaps Mipmaps erzeugen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e8889313cb..7b653fbbf6 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -768,6 +768,7 @@ Default restored. Έγινε επαναφορά των προεπιλογών. Define at least one variable first. Ορίστε πρώτα τουλάχιστον μία μεταβλητή. Delete Group Διαγραφή ομάδας + Add Shortcut Προσθήκη συντόμευσης Delete Shortcut Διαγραφή συντόμευσης Delete Timeline Διαγραφή χρονογραμμής Delete count group Διαγραφή ομάδας καταμέτρησης @@ -1612,7 +1613,6 @@ Cancelled opening {name} Ακυρώθηκε το άνοιγμα του {name} The document “{name}” does not have an embedded {mode} profile. Το έγγραφο «{name}» δεν έχει ενσωματωμένο προφίλ {mode}. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Το έγγραφο «{name}» έχει ενσωματωμένο χρωματικό προφίλ που δεν ταιριάζει με τον τρέχοντα χώρο εργασίας {mode}. - Embedded: {embedded} Ενσωματωμένο: {embedded} Working: {working} Χώρος εργασίας: {working} Cancelled {label} Ακυρώθηκε: {label} Esc to cancel Esc για ακύρωση @@ -1656,6 +1656,8 @@ Camera Raw Defaults Προεπιλογές Camera Raw Canvas border Περίγραμμα καμβά Show canvas dots Εμφάνιση κουκκίδων καμβά + Clip thumbnails to layer bounds Περικοπή μικρογραφιών στα όρια του επιπέδου + Clip Thumbnails to Layer Bounds Περικοπή μικρογραφιών στα όρια του επιπέδου Canvas color Χρώμα καμβά Canvas custom color Προσαρμοσμένο χρώμα καμβά Color picker Επιλογέας χρώματος @@ -2239,7 +2241,6 @@ Control: Χειριστήριο: Conté Crayon Μολύβι κοντέ Convert Frames > Video Layers Μετατροπή καρέ > Επίπεδα βίντεο - Convert document's colors to the working space Μετατροπή των χρωμάτων του εγγράφου στον χώρο εργασίας Convert to Linked Μετατροπή σε συνδεδεμένο Convert to Paragraph Text Μετατροπή σε κείμενο παραγράφου Convert to Point Text Μετατροπή σε κείμενο σημείου @@ -2401,6 +2402,9 @@ # Field Blur pins (#3373) No pin selected Δεν έχει επιλεγεί καρφίτσα Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Κάντε κλικ στην εικόνα για να προσθέσετε καρφίτσα· σύρετε μια καρφίτσα για να τη μετακινήσετε ή τον δακτύλιό της για να αλλάξετε τη θόλωση. Το Delete αφαιρεί την επιλεγμένη καρφίτσα. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Σύρετε τις λαβές στο περίγραμμα για αλλαγή μεγέθους και περιστροφή, το τετράγωνο για στρογγύλεμα των γωνιών και τις κουκκίδες μέσα για να ορίσετε πού αρχίζει η θόλωση. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Σύρετε την καρφίτσα για να μετακινήσετε τη ζώνη ή τον δακτύλιό της για να αλλάξετε τη θόλωση. Σύρετε τις συνεχείς γραμμές για να διευρύνετε την ευκρινή ζώνη, τις διακεκομμένες για τη μετάβαση και τις κουκκίδες για περιστροφή. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Σύρετε τις λαβές στο περίγραμμα για αλλαγή μεγέθους και περιστροφή· ο δακτύλιος ορίζει τη γωνία θόλωσης. File Info Πληροφορίες αρχείου File Settings Ρυθμίσεις αρχείου Fill Path Γέμισμα διαδρομής @@ -2531,7 +2535,8 @@ Image Size: Μέγεθος εικόνας: Image interpolation Παρεμβολή εικόνας Image previews Προεπισκοπήσεις εικόνων - Import Brushes… (.abr) Εισαγωγή πινέλων… (.abr) + Import Brushes… (.abr or image) Εισαγωγή πινέλων… (.abr ή εικόνα) + Import Brush from Image… Εισαγωγή πινέλου από εικόνα… Import Presets Εισαγωγή προεπιλογών Imported Εισήχθη In Είσοδος @@ -3219,7 +3224,6 @@ Use File › Open to open an image. Χρησιμοποιήστε Αρχείο › Άνοιγμα για να ανοίξετε μια εικόνα. Use Legacy Χρήση παλαιάς λειτουργίας Use Measurement Scale Χρήση κλίμακας μέτρησης - Use the embedded profile (instead of the working space) Χρήση ενσωματωμένου προφίλ (αντί του χώρου εργασίας) Uses the foreground and background colours. Χρησιμοποιεί τα χρώματα προσκηνίου και φόντου. Vanishing Point Σημείο φυγής Vanishing Point… Σημείο φυγής… @@ -3435,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 Επανασχεδίαση @@ -3609,6 +3612,8 @@ fontWeight Thin Πολύ λεπτά Color Picker ({name}) Επιλογέας χρώματος ({name}) Auto Hide Notifications Αυτόματη απόκρυψη ειδοποιήσεων Notification Duration (seconds) Διάρκεια ειδοποίησης (δευτερόλεπτα) + Bundle Arrow-Key Nudges Ομαδοποίηση μετατοπίσεων με βέλη + Nudge Bundle Pause (ms) Παύση ομαδοποίησης μετατοπίσεων (ms) Appearance Mode Λειτουργία εμφάνισης Pro Medium Pro Medium Studio Light Studio Light @@ -3726,7 +3731,24 @@ cameraData Lens Φακός Snap to Origin Προσκόλληση στην αρχή Search settings… Αναζήτηση ρυθμίσεων… No matching settings Δεν βρέθηκαν ρυθμίσεις +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Εργαλείο πινέλου δειγματοληψίας + Sampling Area Overlay Επικάλυψη περιοχής δειγματοληψίας + Show Sampling Area Εμφάνιση περιοχής δειγματοληψίας + Indicates Υποδεικνύει + Sampling Area Περιοχή δειγματοληψίας + Excluded Area Εξαιρούμενη περιοχή + Sampling Area Options Επιλογές περιοχής δειγματοληψίας + Rectangular Ορθογώνια + Fill Settings Ρυθμίσεις γεμίσματος + Rotation Adaptation Προσαρμογή περιστροφής + Very High Πολύ υψηλή + Full Πλήρης + Mirror Κατοπτρισμός + Output Settings Ρυθμίσεις εξόδου + Output To Έξοδος σε Loading fonts… Φόρτωση γραμματοσειρών… + Response curve Καμπύλη απόκρισης Simulate Pressure Προσομοίωση πίεσης Stroke Path… Περίγραμμα διαδρομής… Background Eraser Γόμα φόντου @@ -3807,6 +3829,7 @@ curves Smooth Εξομάλυνση Remove after each stroke Αφαίρεση μετά από κάθε πινελιά Apply the removal ({key}) Εφαρμογή της αφαίρεσης ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Πινελιές προς αφαίρεση: {n} (Enter εφαρμόζει, Esc ακυρώνει) + Show home recent files Εμφάνιση πρόσφατων αρχείων στην αρχική οθόνη Clear Brush Controls Απαλοιφή στοιχείων ελέγχου πινέλου Reset All Locked Settings Επαναφορά όλων των κλειδωμένων ρυθμίσεων Copy Texture to Other Tools Αντιγραφή υφής σε άλλα εργαλεία @@ -3833,5 +3856,95 @@ curves Smooth Εξομάλυνση Action playback stopped Η αναπαραγωγή της ενέργειας σταμάτησε Step {step} is waiting for its dialog Το βήμα {step} περιμένει το παράθυρο διαλόγου του @plural {n} action|{n} actions {n} ενέργεια|{n} ενέργειες + Background (Default) Φόντο (προεπιλογή) +# Batch / Image Processor + Include All Subfolders Συμπερίληψη όλων των υποφακέλων Auto Add/Delete Αυτόματη προσθήκη/διαγραφή + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Μην ενεργοποιείτε επίσης τον κατοπτρισμό στις ρυθμίσεις εκτύπωσης: οι δύο αναστροφές αλληλοαναιρούνται. + Print Settings… Ρυθμίσεις εκτύπωσης… + Remember to turn off the printer's own colour management in Print Settings… Θυμηθείτε να απενεργοποιήσετε τη διαχείριση χρωμάτων του εκτυπωτή στις ρυθμίσεις εκτύπωσης… + Sent to {printer} Στάλθηκε στον {printer} + set in Print Settings… ορίζεται στις ρυθμίσεις εκτύπωσης… ICO is limited to 256 × 256 pixels Το ICO περιορίζεται σε 256 × 256 εικονοστοιχεία +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Χρήση ενσωματωμένου προφίλ ({profile}) + Convert to the working space ({profile}) Μετατροπή στον χώρο εργασίας ({profile}) + Remember this choice for future images Απομνημόνευση αυτής της επιλογής για μελλοντικές εικόνες + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Ενεργοποιήστε ξανά την ερώτηση με το Ask On Mismatch στο Επεξεργασία › Ρυθμίσεις χρωμάτων. + Button Mode Λειτουργία κουμπιών + Button Mode On Λειτουργία κουμπιών ενεργή + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Σκουρότερο περιβάλλον + Lighten Interface Φωτεινότερο περιβάλλον +# Bevel & Emboss Technique. + Technique Τεχνική + Chisel Hard Σκληρή σμίλευση + Chisel Soft Μαλακή σμίλευση + Open color-less EXR as ACEScg Άνοιγμα EXR χωρίς χρωματική δήλωση ως ACEScg +# Export As metadata and profile options + EXIF EXIF + +# View › Guides › right-click a guide + Edit Selected Guides Επεξεργασία επιλεγμένων οδηγών + Clear Selected Guides Απαλοιφή επιλεγμένων οδηγών + Pasting from other apps is unavailable Η επικόλληση από άλλες εφαρμογές δεν είναι διαθέσιμη + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. Το PhotoCraft δεν έχει πρόσβαση στο πρόχειρο του συστήματος σε αυτή τη συνεδρία. Στο Wayland, το sandbox του Flatpak κρύβει το πρωτόκολλο μέσω του οποίου διαβάζονται εικόνες από άλλες εφαρμογές. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Για επικόλληση από άλλες εφαρμογές, ξεκινήστε το PhotoCraft σε XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Τίποτα για επικόλληση: το PhotoCraft δεν μπορεί να διαβάσει εδώ το πρόχειρο του συστήματος + Delete this set Διαγραφή αυτού του συνόλου + New Set… Νέο σύνολο… + PhotoCraft Defaults Προεπιλογές PhotoCraft + Save a list of these shortcuts as a web page Αποθήκευση λίστας αυτών των συντομεύσεων ως ιστοσελίδας + Save the changes to this set Αποθήκευση των αλλαγών σε αυτό το σύνολο + Save these shortcuts as a .kys file Αποθήκευση αυτών των συντομεύσεων ως αρχείου .kys + Save these shortcuts as a new set Αποθήκευση αυτών των συντομεύσεων ως νέου συνόλου + Set deleted. Το σύνολο διαγράφηκε. + Set saved. Το σύνολο αποθηκεύτηκε. + Set: Σύνολο: + Summarize… Σύνοψη… + There are too many sets: delete one first. Υπάρχουν πάρα πολλά σύνολα: διαγράψτε πρώτα ένα. + set name όνομα συνόλου + {set} (modified) {set} (τροποποιημένο) + Show file name in title Εμφάνιση ονόματος αρχείου στον τίτλο + Accept and Go to Conflict Αποδοχή και μετάβαση στη σύγκρουση + Changes undone. Οι αλλαγές αναιρέθηκαν. + Choose a new shortcut for this command, or leave it without one. Επιλέξτε νέα συντόμευση για αυτήν την εντολή ή αφήστε την χωρίς. + Put this command's shortcuts back as they were Επαναφορά των συντομεύσεων αυτής της εντολής όπως ήταν + Take the shortcut from the other command and select it Αφαίρεση της συντόμευσης από την άλλη εντολή και επιλογή της + Undo Changes Αναίρεση αλλαγών + {shortcut} is reserved by the system and can't be assigned. Η συντόμευση {shortcut} δεσμεύεται από το σύστημα και δεν μπορεί να εκχωρηθεί. + Low-Latency Drawing (vsync off, applies at next launch) Σχεδίαση χαμηλής καθυστέρησης (χωρίς vsync, ισχύει στην επόμενη εκκίνηση) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Ο Ελεύθερος μετασχηματισμός εφαρμόζεται ακόμη· δοκιμάστε ξανά όταν ολοκληρωθεί. + The transform couldn't be applied. Δεν ήταν δυνατή η εφαρμογή του μετασχηματισμού. +# 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, 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} + 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. Αντικαταστήστε το με αυτό το έγγραφο ή ακυρώστε και χρησιμοποιήστε Αρχείο › Αποθήκευση ως για να κρατήσετε και τα δύο. + Decrease Brush Size Μείωση μεγέθους πινέλου + Increase Brush Size Αύξηση μεγέθους πινέλου + Decrease Brush Hardness Μείωση σκληρότητας πινέλου + Increase Brush Hardness Αύξηση σκληρότητας πινέλου + Memory Usage Χρήση μνήμης + Per-document RAM limit (MB) Όριο RAM ανά έγγραφο (MB) + Installed RAM: {ram} MB; this limit: {percent}% Εγκατεστημένη RAM: {ram} MB· αυτό το όριο: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Περιορίζει τα pixel συν το Ιστορικό κάθε ανοιχτού εγγράφου. Οι παλαιότερες καταστάσεις αναίρεσης απορρίπτονται όταν ξεπεραστεί το όριο. + Applies immediately when saved. Memory is not reserved in advance. Ισχύει αμέσως με την αποθήκευση. Η μνήμη δεν δεσμεύεται εκ των προτέρων. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Με Μνήμη GPU σε Αυτόματο, περιορίζει και τον προϋπολογισμό GPU. Ανώτατο όριο το 1/4 της RAM και, αν είναι γνωστό, τα 3/8 της μνήμης κάρτας, ελάχιστο 512 MB. Μεγαλύτερο όριο δεν αυξάνει τον προϋπολογισμό GPU. + Use Χρήση + In Front Μπροστά + Behind Πίσω + +# DDS Options (#1327) + DDS Options Επιλογές DDS + Generate Mipmaps Δημιουργία mipmap diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 37f88f4294..eab6215dff 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -558,6 +558,10 @@ # Field Blur pins (#3373) No pin selected Ningún pin seleccionado Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Haga clic en la imagen para añadir un pin; arrastre un pin para moverlo o su anillo para cambiar el desenfoque. Suprimir elimina el pin seleccionado. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Arrastre los manejadores del contorno para cambiar su tamaño y girarlo, el cuadrado para redondear las esquinas y los puntos interiores para fijar dónde empieza el desenfoque. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Arrastre el pin para mover la banda o su anillo para cambiar el desenfoque. Arrastre las líneas continuas para ensanchar la banda nítida, las discontinuas para fijar la transición y los puntos para girarla. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Arrastre los manejadores del contorno para cambiar su tamaño y girarlo; el anillo fija el ángulo de desenfoque. + Blur Angle Ángulo de desenfoque Iris Blur… Desenfoque de iris… Tilt-Shift… Cambio de inclinación… Path Blur… Desenfoque de trazado… @@ -986,7 +990,6 @@ Contiguous Contiguo Contrast Contraste Control: Control: - Convert document's colors to the working space Convertir los colores del documento al espacio de trabajo Convert to sRGB Convertir a sRGB Copies Ejemplares Copper Cobre @@ -1041,6 +1044,7 @@ Delete Group Eliminar grupo Delete Layer Mask Eliminar máscara de capa Delete Layers Eliminar capas + Add Shortcut Añadir método abreviado Delete Shortcut Eliminar método abreviado Delete Timeline Eliminar línea de tiempo Delete Tool Preset Eliminar ajuste preestablecido de herramienta @@ -1568,7 +1572,6 @@ Use Default Usar el predeterminado Use Legacy Usar la versión heredada Use Measurement Scale Usar la escala de medición - Use the embedded profile (instead of the working space) Usar el perfil incrustado (en lugar del espacio de trabajo) Variables bind a layer's visibility, text or pixels to a named data slot. Las variables vinculan la visibilidad, el texto o los píxeles de una capa a un espacio de datos con nombre. Vertical scale Escala vertical Vibrance Intensidad @@ -1896,6 +1899,8 @@ Camera Raw Defaults Valores predeterminados de Camera Raw Canvas border Borde del lienzo Show canvas dots Mostrar puntos del lienzo + Clip thumbnails to layer bounds Recortar miniaturas a los límites de la capa + Clip Thumbnails to Layer Bounds Recortar miniaturas a los límites de la capa Canvas color Color del lienzo Canvas custom color Color personalizado del lienzo Color picker Selector de color @@ -2086,7 +2091,8 @@ Expand group ({key}-click: all groups) Expandir grupo ({key}-clic: todos los grupos) Fade steps Pasos de desvanecimiento Hide the layer's effects ({key}-click: all layers) Ocultar los efectos de la capa ({key}-clic: todas las capas) - Import Brushes… (.abr) Importar pinceles… (.abr) + Import Brushes… (.abr or image) Importar pinceles… (.abr o imagen) + Import Brush from Image… Importar pincel desde imagen… Move Group Down Mover grupo abajo Move Group Up Mover grupo arriba Reset the brush to the defaults Restablecer el pincel a los valores predeterminados @@ -2200,7 +2206,6 @@ Cancelled opening {name} Se canceló la apertura de {name} The document “{name}” does not have an embedded {mode} profile. El documento «{name}» no tiene un perfil {mode} incrustado. The document “{name}” has an embedded color profile that does not match the current {mode} working space. El documento «{name}» tiene un perfil de color incrustado que no coincide con el espacio de trabajo {mode} actual. - Embedded: {embedded} Incrustado: {embedded} Working: {working} Espacio de trabajo: {working} Developing {name} Revelando {name} Cancelled {label} Se canceló {label} @@ -2507,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 @@ -2573,6 +2577,8 @@ fontWeight Italic Cursiva Color Picker (Layer Style Color) Selector de color (color del estilo de capa) Auto Hide Notifications Ocultar notificaciones automáticamente Notification Duration (seconds) Duración de la notificación (segundos) + Bundle Arrow-Key Nudges Agrupar desplazamientos con las flechas + Nudge Bundle Pause (ms) Pausa para agrupar desplazamientos (ms) Appearance Mode Modo de apariencia Dark Theme Tema oscuro Light Theme Tema claro @@ -2711,7 +2717,24 @@ cameraData Lens Objetivo Snap to Origin Ajustar a origen Search settings… Buscar ajustes… No matching settings No hay ajustes que coincidan +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Herramienta Pincel de muestreo + Sampling Area Overlay Superposición del área de muestreo + Show Sampling Area Mostrar área de muestreo + Indicates Indica + Sampling Area Área de muestreo + Excluded Area Área excluida + Sampling Area Options Opciones del área de muestreo + Rectangular Rectangular + Fill Settings Ajustes de relleno + Rotation Adaptation Adaptación de rotación + Very High Muy alta + Full Completa + Mirror Reflejar + Output Settings Ajustes de salida + Output To Salida a Loading fonts… Cargando fuentes… + Response curve Curva de respuesta Tool Herramienta Simulate Pressure Simular presión Stroke Path… Contornear trazado… @@ -2794,6 +2817,1283 @@ curves Smooth Suavizar Remove after each stroke Eliminar después de cada trazo Apply the removal ({key}) Aplicar la eliminación ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Trazos por eliminar: {n} (Intro aplica, Esc cancela) +# Dynamic labels (filter and adjustment parameters, Camera Raw, panels, dialogs) that the +# other catalogs already translate +@id select.all Seleccionar todo + Delete Smart Filters Eliminar filtros inteligentes + Enable Smart Filters Activar filtros inteligentes + New Layer… Nueva capa… + Delete Layer… Eliminar capa… + Select Similar Colors Seleccionar colores similares + Canvas rotation Rotación del lienzo + Extensions Extensiones + Libraries Bibliotecas + 3D 3D + Toolbars Barras de herramientas + Wand Varita + Healing Corrección + Pen Pluma + Hand Mano + Sampler Muestreador + Magnetic Lasso Lazo magnético + Art History Brush Pincel histórico artístico + Spot Healing Corrección puntual + Patch Parche + Content-Aware Move Mover con detección de contenido + Perspective Crop Recorte con perspectiva + Dodge/Burn Sobreexponer/Subexponer + Dynamics Dinámica + Tilt Inclinación + Defaults restored. Valores predeterminados restaurados. + Stop Detener + Create new set Crear nuevo conjunto + Technology Previews Previsualizaciones de tecnología + Create new layer Crear nueva capa + Group · {n} layer Grupo · {n} capa + General Blend Fusión general + Advanced Blending Fusión avanzada + RGB RGB + Spot Channel Canal de tinta plana + Working Gray Plate Placa gris de trabajo + HSL/Grayscale HSL/Escala de grises + Tone Curve Curva de tonos + Split Toning División de tonos + Lens Corrections Correcciones de lente + Camera Calibration Calibración de cámara + Snapshots Instantáneas + Recovery Recuperación + Fill Light Luz de relleno + Convert to Grayscale Convertir a escala de grises + Sharpness Nitidez + Color Noise Reduction Reducción de ruido de color + Luminance Smoothness Suavizado de luminancia + Lens Objetivo + ISO ISO + Focal Length35mm Longitud focal equivalente (35 mm) + Aspect Ratio Relación de aspecto + Time Hora + Enter description… Introduzca una descripción… + ColorSync ColorSync + ProPhoto ProPhoto + ACES ACES + Industry Standard Estándar del sector + Uncoated CMYK CMYK sin estucar + Newspaper Papel de periódico + Working Spaces Espacios de trabajo + Color Management Policies Normas de gestión de color + Detect Faces Detectar caras + Localize Color Clusters Localizar grupos de color + Duplicated selection name Nombre de selección duplicado + Operation Operación + New channel only Solo canal nuevo + Color indicates El color indica + Create Snapshot Crear instantánea + Delete Snapshot Eliminar instantánea + New Snapshot Nueva instantánea + Snapshot 1 Instantánea 1 + Step Forward Avanzar un paso + Step Backward Retroceder un paso + Nonlinear History Historial no lineal + Set New Snapshot as Default Definir nueva instantánea como predeterminada + Automatically Create First Snapshot Crear automáticamente la primera instantánea + Automatically Create New Snapshot When Saving Crear automáticamente una instantánea nueva al guardar + Show New Snapshot Dialog by Default Mostrar de forma predeterminada el cuadro de diálogo Nueva instantánea + Clear History Borrar historial + Panel Options… Opciones de panel… + View menu Menú Vista + Keep view in sync Mantener la vista sincronizada + {zoom}% {zoom}% + Mouse Controls Controles del ratón + Initial Direction of Stroke Dirección inicial del trazo + Direction of Stroke Dirección del trazo + Humidity Humedad + Auto Clean Brush Limpiar el pincel automáticamente + Spacing: Espaciado: + New Layer from Brush Nueva capa a partir del pincel + New Brush Nuevo pincel + New Tool Preset Nuevo ajuste preestablecido de herramienta + Auto-Leading Interlineado automático + Scale Horizontally Escala horizontal + Scale Vertically Escala vertical + Mac LCD LCD de Mac + Mac Sharp Nítido de Mac + Mac Crisp Definido de Mac + Mac Strong Fuerte de Mac + Mac Smooth Suavizado de Mac + Superscript Superíndice + Subscript Subíndice + Ligature Ligadura + Alternative Alternativa + Ultra Light Ultrafina + Extra Light Extrafina + Semi Bold Seminegrita + Extra Bold Extranegrita + Ultra Bold Ultranegrita + Condensed Condensada + First Line Indent Sangría de primera línea + Hanging Punctuation Puntuación colgante + Roman Hanging Punctuation Puntuación latina colgante + Avoid Widows/Orphans Evitar líneas viudas y huérfanas + Compose Composición + Adobe Single-line Composer Compositor de línea única de Adobe + Adobe Every-line Composer Compositor multilínea de Adobe + Word Spacing Espacio entre palabras + Letter Spacing Espacio entre caracteres + Glyph Scaling Escala de glifos + Auto-leading % Interlineado automático % + Single Word Justification Justificación de una sola palabra + Justify Justificar + Glyph Glifo + Alternates for Current Selection Alternativas para la selección actual + Digits Dígitos + Path: Trazado: + Path to Illustrator… Trazado a Illustrator… + Clipping Path Trazado de recorte + New Path Nuevo trazado + Duplicate Path Duplicar trazado + Auto-Hide Ocultar automáticamente + X X + Y Y + Frame Sync Sincronización de fotogramas + Quick Sync Sincronización rápida + Audio Audio + Frame Duration Duración del fotograma + Loop Bucle + Split at Playhead Dividir en el cabezal de reproducción + Flatten Frames Into Clips Acoplar fotogramas en clips + Flatten Frames Into Layers Acoplar fotogramas en capas + Convert Frames > Video Layers Convertir fotogramas > Capas de vídeo + New Video Layer Nueva capa de vídeo + New Video Group Nuevo grupo de vídeo + Limit Playback Limitar reproducción + Enable Timeline Quick Config Activar configuración rápida de la línea de tiempo + Set Start Timecode… Establecer código de tiempo inicial… + Timecode Código de tiempo + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 fps + Current Time Tiempo actual + Out Salida + In Entrada + Duration Duración + Repeat Repetir + Play Once Reproducir una vez + Info Options… Opciones de información… + First Color Readout Primera lectura de color + Second Color Readout Segunda lectura de color + Actual Color Color real + Grayscale Slider Regulador de escala de grises + Web Color Color web + HSB Color Color HSB + Total Ink Tinta total + Opacity Mask Máscara de opacidad + Document Size Tamaño del documento + Timing Temporización + Status Information Información de estado + Saved Multichannel Multicanal guardado + Mouse Coordinates Coordenadas del ratón + Ruler Units Unidades de regla + Hide Ocultar + Restore last viewed comp Restaurar la última composición visualizada + Select All Layers Seleccionar todas las capas + Keep proportions Mantener proporciones + Interpolation Interpolación + Caption Pie de foto + Caption Credits Créditos del pie de foto + Keywords Palabras clave + Copyright Info URL URL de información de copyright + Copyright Status Estado del copyright + Copyrighted Con copyright + Public Domain Dominio público + Unknown Desconocido + Credit Crédito + Transmission Reference Referencia de transmisión + Urgency Urgencia + High Alta + Low Baja + Category Categoría + Supplemental Categories Categorías suplementarias + Object Name / Title Nombre del objeto / título + Date Created Fecha de creación + City Ciudad + Province / State Provincia / estado + Country País + Byline Autor + Byline Title Cargo del autor + Phone Teléfono + Email Correo electrónico + URLs URL + Last Modified Última modificación + EXIF EXIF + GPS GPS + Camera Model Modelo de cámara + Camera Make Fabricante de la cámara + EXIF Version Versión de EXIF + Exposure Program Programa de exposición + Aperture Abertura + Shutter Speed Velocidad de obturación + Metering Mode Modo de medición + Light Source Fuente de luz + Flash Flash + Lens Info Información del objetivo + ISO Speed Sensibilidad ISO + Subject Distance Distancia al sujeto + DateTime Original Fecha y hora originales + Create Date Fecha de creación + Modify Date Fecha de modificación + Raw Data Datos sin procesar + Import… Importar… + PDF Presentation… Presentación PDF… + Create Multiple Picture Packages Crear varios conjuntos de imágenes + Picture Package… Conjunto de imágenes… + Web Photo Gallery… Galería de fotografías web… + Choose… Elegir… + Select Folder… Seleccionar carpeta… + Override Action "Save" Commands Omitir comandos «Guardar» de la acción + File Naming Nombres de archivo + Compatibility Compatibilidad + Mac OS Mac OS + Unix Unix + Include ICC Profile Incluir perfil ICC + Error File Archivo de errores + Stop For Errors Detener si hay errores + Log Errors To File Registrar errores en un archivo + Errors Errores + Settings Configuración + Settings: Configuración: + Working Space Espacio de trabajo + RGB: RGB: + CMYK: CMYK: + Gray: Gris: + Spot: Tinta plana: + Advanced Controls Controles avanzados + Desaturate Monitor Colors By Desaturar colores del monitor un + Blend RGB Colors Using Gamma 1.0 Fusionar colores RGB con gamma 1,0 + Profile Mismatches: Ask When Opening Diferencias de perfil: preguntar al abrir + Ask When Pasting Preguntar al pegar + Missing Profiles: Ask When Opening Perfiles no disponibles: preguntar al abrir + Embed profiles Incrustar perfiles + Ask Before Saving Saved Embedded Preguntar antes de guardar el perfil incrustado + Coerce to sRGB Forzar sRGB + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB RGB de gama amplia + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE C + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + Minimalistic Minimalista + Expand All Groups Expandir todos los grupos + Photo (2 × 3 in) Foto (2 × 3 pulg.) + Photo (4 × 6 in) Foto (4 × 6 pulg.) + Photo (5 × 7 in) Foto (5 × 7 pulg.) + Photo (8 × 10 in) Foto (8 × 10 pulg.) + Recent Items Elementos recientes + Delete Preset… Eliminar ajuste preestablecido… + Preset Name: Nombre del ajuste preestablecido: + Width and Height Anchura y altura + Color Profile Perfil de color + Units Unidades + Create new count group Crear nuevo grupo de recuento + Adjustment Layer Capa de ajuste + Drag to move the layer Arrastre para mover la capa + Go to Layer Ir a la capa + After (X) Después (X) + Reference point location Ubicación del punto de referencia + Toggle reference point Mostrar u ocultar el punto de referencia + Add Pins Añadir chinchetas + Delete Pins Eliminar chinchetas + Rotate Auto Rotación automática + Rotate Fixed Rotación fija + Select Plane Seleccionar plano + Measure Medir + Heal Corrección + Heal Off Corrección desactivada + Heal On Corrección activada + Heal Auto Corrección automática + Blend Fusión + Brush Size Tamaño del pincel + Heal Color Color de corrección + Result Resultado + Duplicate Duplicar + Fast Rápida + Accurate Precisa + Add to Añadir a + Subtract from Restar de + Intersect with Formar intersección con + Capture Comp Capturar composición + No Layer Comps No hay composiciones de capas + Adobe PDF Presets… Ajustes preestablecidos de Adobe PDF… + 3D… 3D… + Technology Previews… Previsualizaciones de tecnología… + Add a mask (from the selection; ⌥ inverts) Añadir una máscara (a partir de la selección; ⌥ invierte) + Add to selection (⇧) Añadir a la selección (⇧) + Airbrush: keeps painting while the pointer rests. Aerógrafo: sigue pintando mientras el puntero permanece inmóvil. + Click the first point or press ↵ to close · Esc cancels Haga clic en el primer punto o pulse ↵ para cerrar · Esc cancela + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Haga clic para añadir un punto · arrástrelo fuera o use ⌘/Ctrl-clic o Supr para quitarlo + Click to apply, ⌥-click to clear overrides, double-click for Style Options Haga clic para aplicar, use ⌥-clic para borrar anulaciones y doble clic para Opciones de estilo + Click to zoom in · ⌥-click to zoom out Haga clic para acercar · use ⌥-clic para alejar + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Clic: esquina · Arrastrar: suavizar · Clic en primer punto: cerrar · ↩ terminar · Esc cancelar + Commit Perspective Warp (↩) Confirmar Deformación de perspectiva (↩) + Commit Puppet Warp (↩) Confirmar Deformación de posición libre (↩) + Commit any current edits (⌘↩) Confirmar las ediciones actuales (⌘↩) + Commit current crop operation (↵) Confirmar la operación de recorte actual (↵) + Commit transform (↩) Confirmar transformación (↩) + Commit warp (↩) Confirmar deformación (↩) + Create a new layer (⇧⌘N) Crear una capa nueva (⇧⌘N) + Custom pattern Motivo personalizado + General Brushes Pinceles generales + Grid (⌘') Cuadrícula (⌘') + Intersect with selection (⇧⌥) Formar intersección con la selección (⇧⌥) + New document… ⌘N Nuevo documento… ⌘N + Open… ⌘O Abrir… ⌘O + Rulers (⌘R) Reglas (⌘R) + Search commands (⌘K) Buscar comandos (⌘K) + Show Guides (⌘;) Mostrar guías (⌘;) + Subtract from selection (⌥) Restar de la selección (⌥) + Uses the foreground and background colors. Usa los colores frontal y de fondo. + ↑↓ navigate ↵ run esc close ↑↓ navegar ↵ ejecutar esc cerrar + Adds grain to the soft edges of the brush tip. Añade granulado a los bordes suaves de la punta del pincel. + Keeps the current texture when switching presets. Mantiene la textura actual al cambiar de ajuste preestablecido. + Watercolour look: lighter interior, darker rims. Aspecto de acuarela: interior más claro y bordes más oscuros. + Collapse group (⌥-click: all groups) Contraer grupo (⌥-clic: todos los grupos) + Expand group (⌥-click: all groups) Expandir grupo (⌥-clic: todos los grupos) + Hide the layer's effects (⌥-click: all layers) Ocultar los efectos de la capa (⌥-clic: todas las capas) + Show the layer's effects (⌥-click: all layers) Mostrar los efectos de la capa (⌥-clic: todas las capas) +cameraRaw Blacks Negros +cameraRaw Masking Enmascarado +@id edit.stroke Contornear +@id edit.transform.scale Escala +cameraRaw Luminance Luminancia +cameraRaw Color Color +cameraRaw Vibrance Intensidad +cameraRaw Color Grading Gradación de color +cameraRaw Global Global +cameraRaw Hue Tono +cameraRaw Luminance Detail Detalle de luminancia +cameraRaw Color Detail Detalle de color +cameraRaw Roughness Rugosidad +cameraRaw Whites Blancos +layerLabel No Color Sin color + Preferences… Preferencias… + Actual Pixels Píxeles reales + Clipping Mask Máscara de recorte + Tool Options Opciones de herramienta + Color Swatches Muestras de color + Linear Dodge Sobreexposición lineal + Preview Image Previsualizar imagen + Save As Guardar como + Remove All Eliminar todo + Unlock Desbloquear + Enable Activar + Disable Desactivar + On Activado + Clear Selection Borrar selección + Invert Selection Invertir selección + Select Inverse Seleccionar inverso + Contract Contraer + Sample Size Tamaño de muestra + Gamma Gamma + Input Entrada + Input Levels Niveles de entrada + Output Levels Niveles de salida + Layer Styles Estilos de capa + Blending Mode Modo de fusión + Stroke Width Anchura del trazo + Shadow Sombra + Adjustment Layers Capas de ajuste + Text Layer Capa de texto + Documents Documentos + File Name Nombre de archivo + File Format Formato de archivo + Compression Compresión + Artboards Mesas de trabajo + Snap to Grid Ajustar a cuadrícula + Full Screen Pantalla completa + Fullscreen Pantalla completa + Workspaces Espacios de trabajo + Show All Mostrar todo + Hidden Oculto + Duplicate Layer Duplicar capa + Ungroup Desagrupar + Guide Layout… Composición de guías… + Page Setup… Configurar página… + Reset Preferences Restablecer preferencias + Documentation Documentación + Check for Updates Buscar actualizaciones + Loading… Cargando… + Processing… Procesando… + Saving… Guardando… + Exporting… Exportando… + Please wait… Espere… + No selection Ninguna selección + No layers Ninguna capa + No recent files Ningún archivo reciente + Unsupported file format Formato de archivo no compatible + Failed to open file No se pudo abrir el archivo + Failed to save file No se pudo guardar el archivo + Operation canceled Operación cancelada + Changes have not been saved. Los cambios no se han guardado. + Are you sure? ¿Está seguro? + Discard Changes Descartar cambios + Open File Abrir archivo + Save File Guardar archivo + Save Changes Guardar cambios + Rendering… Procesando… + Importing… Importando… + Ready Listo + Failed Error + Warning Advertencia + Information Información + Retry Reintentar + Continue Continuar + Skip Omitir + Resume Reanudar + Reset All Restablecer todo + Defaults Valores predeterminados + Apply to All Aplicar a todo + Confirm Confirmar + Finish Finalizar + Back Atrás + Next Siguiente + Previous Anterior + More Más + Less Menos + Manual Manual + Disabled Desactivado + Locked Bloqueado + Unlocked Desbloqueado + Delete… Eliminar… + Create… Crear… + Edit… Editar… + Change… Cambiar… + Transformation Transformación + Scaling Escala + Canvas Size Tamaño de lienzo + Percentage Porcentaje + Degrees Grados + Middle Medio + Diameter Diámetro + Intensity Intensidad + Sensitivity Sensibilidad + Softness Suavidad + Feather Radius Radio de calado + Library Biblioteca + Layer Properties Propiedades de capa + Groups Grupos + Masks Máscaras + Add Mask Añadir máscara + Delete Mask Eliminar máscara + Invert Mask Invertir máscara + Enable Mask Activar máscara + Disable Mask Desactivar máscara + Apply Mask Aplicar máscara + Import Settings Importar ajustes + Export Settings Exportar ajustes + Shortcuts Métodos abreviados + Keyboard Teclado + Mouse Ratón + Tablet Tableta + Display Pantalla + Panel Panel + Hide Grid Ocultar cuadrícula + Fit to Window Ajustar a ventana + Live Preview Previsualización en vivo + Before Antes + After Después + Compare Comparar + File Size Tamaño de archivo + Filename Nombre de archivo + Open Folder Abrir carpeta + Choose Folder Elegir carpeta + Browse Examinar + Find Buscar + Replace Reemplazar + Date Fecha + Learn More Más información + Report a Bug Informar de un error + Updates Actualizaciones + Update Actualizar + Restart Required Es necesario reiniciar + Restart Reiniciar + Quit Salir + Quit PhotoCraft Salir de PhotoCraft + Close Window Cerrar ventana + New Window Nueva ventana + Open Image Abrir imagen + Save Image Guardar imagen + Export Image Exportar imagen + Import Image Importar imagen + Export As Exportar como + Export to Exportar a + Export Options Opciones de exportación + Import Options Opciones de importación + No Selection Ninguna selección + No Image Ninguna imagen + No Layers Ninguna capa + No Results Ningún resultado + No Results Found No se encontraron resultados + Nothing to Undo Nada que deshacer + Nothing to Redo Nada que rehacer + Changes Saved Cambios guardados + Save before closing? ¿Guardar antes de cerrar? + Are you sure you want to quit? ¿Está seguro de que desea salir? + The file could not be opened. No se pudo abrir el archivo. + The file could not be saved. No se pudo guardar el archivo. + Unsupported format Formato no compatible + Out of memory Memoria insuficiente + Permission denied Permiso denegado + File not found No se encontró el archivo + Operation failed Error en la operación + Operation completed Operación completada + Operation cancelled Operación cancelada + Sharpen Amount Cantidad de enfoque + Sharpen Radius Radio de enfoque + Sharpen Detail Detalle de enfoque + Sharpen Masking Enmascarado de enfoque + Noise Luminance Luminancia del ruido + Noise Luminance Detail Detalle de luminancia del ruido + Noise Color Color del ruido + Noise Color Detail Detalle de color del ruido + Grain Amount Cantidad de granulado + Grain Size Tamaño del granulado + Grain Roughness Rugosidad del granulado + Vignette Roundness Redondez de la viñeta + Vignette Feather Calado de la viñeta + Vignette Highlights Iluminaciones de la viñeta + Vignette Style Estilo de viñeta + Planes Planos + Plane Plano + Shadow Amount Cantidad de sombra + Shadow Tone Tono de sombra + Shadow Radius Radio de sombra + Highlight Amount Cantidad de iluminaciones + Highlight Tone Tono de iluminaciones + Highlight Radius Radio de iluminaciones + Midtone Medio tono + Black Clip Recorte negro + White Clip Recorte blanco + Fuzziness Tolerancia + Source Layer Capa de origen + Neutralize Neutralizar + Use Selection In Source Usar selección en origen + Use Selection In Target Usar selección en destino + Flame Type Tipo de llama + Randomize Length Aleatorizar longitud + Interval Intervalo + Adjust Interval For Loops Ajustar intervalo para bucles + Use Custom Color Usar color personalizado + Flame Lines Líneas de llama + Flame Bottom Alignment Alineación inferior de la llama + Flame Style Estilo de llama + Flame Shape Forma de llama + Randomize Shapes Aleatorizar formas + Frame Marco + Arrangement Disposición + Thickness Grosor + Vine Color Color de la enredadera + Flower Color Color de la flor + Leaf Color Color de las hojas + Base Tree Type Tipo de árbol base + Light Direction Dirección de la luz + Leaves Amount Cantidad de hojas + Leaves Size Tamaño de las hojas + Branches Height Altura de las ramas + Branches Thickness Grosor de las ramas + Default Leaves Hojas predeterminadas + Leaves Color Color de las hojas + Custom Branch Color Color personalizado de las ramas + Branches Color Color de las ramas + Flat Shading Sombreado plano + Date and Time Fecha y hora + Scale Units Unidades de escala + Scale Factor Factor de escala + Area Área + Perimeter Perímetro + Circularity Circularidad + Gray Value (Minimum) Valor de gris (mínimo) + Gray Value (Maximum) Valor de gris (máximo) + Gray Value (Mean) Valor de gris (media) + Gray Value (Median) Valor de gris (mediana) + Integrated Density Densidad integrada + Length Longitud + In Black Negro de entrada + In White Blanco de entrada + Out Black Negro de salida + Out White Blanco de salida + Tint Color Color de tinte + Stops Paradas + Center X Centro X + Center Y Centro Y + Cell Size Tamaño de celda + Generators Generadores + Wavelength Min Longitud de onda mínima + Wavelength Max Longitud de onda máxima + Amplitude Min Amplitud mínima + Amplitude Max Amplitud máxima + Undefined Areas Áreas no definidas + Preserve Conservar + Max Radius Radio máximo + Depth Mode Modo de profundidad + Solid Front Faces Caras frontales sólidas + Mask Incomplete Blocks Enmascarar bloques incompletos + Stylization Estilización + Cleanliness Limpieza + Bristle Detail Detalle de cerdas + Lighting Iluminación + Shine Brillo + Max Offset Desplazamiento máximo + Map Document Mapa del documento + Map Path Ruta del mapa + Map Layer Capa del mapa + Ridges Crestas + Light Type Tipo de luz + Light X Luz X + Light Y Luz Y + Light Z Luz Z + Target X Destino X + Target Y Destino Y + Hotspot Punto focal + Elevation Elevación + Ambient Ambiente + Warmth Calidez + Gloss Brillo + Metallic Metálico + Ambience Ambiente + White Is High El blanco es alto + Blade Curvature Curvatura de la hoja + Depth Map Mapa de profundidad + Focal Distance Distancia focal + Focus Enfoque + Transition Transición + Radius X Radio X + Radius Y Radio Y + Pins Chinchetas + Speed Velocidad + Taper Estrechamiento + Start X Inicio X + Start Y Inicio Y + End X Final X + End Y Final Y + Kernel Núcleo + Input Mode Modo de entrada + Row Order Orden de filas + Eliminate Eliminar + Create By Crear por + List Lista + Pencil Width Anchura del lápiz + Stroke Pressure Presión del trazo + Paper Brightness Brillo del papel + Number Of Levels Número de niveles + Edge Simplicity Simplicidad del borde + Edge Fidelity Fidelidad del borde + Brush Detail Detalle del pincel + Highlight Area Área de iluminaciones + Glow Size Tamaño del resplandor + Glow Brightness Brillo del resplandor + Brush Type Tipo de pincel + Stroke Size Tamaño del trazo + Stroke Detail Detalle del trazo + Highlight Strength Intensidad de iluminaciones + Smoothness Suavidad + Edge Thickness Grosor del borde + Edge Intensity Intensidad del borde + Posterization Posterización + Stroke Length Longitud del trazo + Relief Relieve + Definition Definición + Texture Coverage Cobertura de textura + Shadow Intensity Intensidad de sombras + Edge Width Anchura del borde + Edge Brightness Brillo del borde + Direction Balance Equilibrio de dirección + Black Intensity Intensidad de negros + White Intensity Intensidad de blancos + Dark Intensity Intensidad de oscuros + Custom Pixel Aspect Ratio Relación de aspecto de píxel personalizada + 32-bit Preview Options Opciones de vista previa de 32 bits + New Guide Nueva guía + Join the ArtCraft Discord Únase al Discord de ArtCraft + Report an Issue Informar de un problema + Load Files into Stack Cargar archivos en pila + Statistics Estadísticas + Duplicate Layers Duplicar capas + Edit Toolbar Editar barra de herramientas + Import Brushes Importar pinceles + Load 3D LUT Cargar LUT 3D + Press keys Pulse las teclas + Rename Group Cambiar nombre del grupo + Rename Tool Preset Cambiar nombre del ajuste preestablecido + Search commands, tools and panels Buscar comandos, herramientas y paneles + Slice Options Opciones de sector + Rename Brush Cambiar el nombre del pincel + Opening Abriendo + Reading Leyendo + Working Procesando + Accented Edges Bordes acentuados + Angled Strokes Trazos angulares + Anisotropic Anisotrópico + Around Center Alrededor del centro + Artistic Artístico + Bamboo Bambú + Bas Relief Bajorrelieve + Berries Bayas + Blast Ráfaga + Bleach Bypass Omisión de blanqueado + Blocks Bloques + Brick Ladrillo + Brush Strokes Trazos de pincel + Burlap Arpillera + Candle Light Luz de vela + Circle Círculo + Clumped Agrupado + Coarse Dots Puntos gruesos + Colored Pencil Lápiz de color + Contrasty Contrastado + Cool Frío + Craquelure Cuarteado + Cross Cruz + Cross Process Proceso cruzado + Crosshatch Sombreado cruzado + Cutout Recortado + Daisies Margaritas + Dark Rough Oscuro rugoso + Dark Strokes Trazos oscuros + Darken Only Solo oscurecer + Day For Night Noche americana + Deep Blue Azul intenso + Deep Emerald Esmeralda intenso + Deep Red Rojo intenso + Deep Yellow Amarillo intenso + Dot Punto + Double Line Línea doble + Draft Borrador + Dry Brush Pincel seco + Duplication Duplicación + Edge Extension Extensión de bordes + Edge Only Solo borde + Enlarged Ampliado + Even Fields Campos pares + Faded Film Película desvanecida + Film Grain Granulado de película + Fine Fino + Fine Dots Puntos finos + Flat Plano + Fresco Fresco + From Left Desde la izquierda + From Right Desde la derecha + Frosted Escarchado + Generic Genérico + Glass Vidrio + Glowing Edges Bordes resplandecientes + Grainy Dots Puntos granulados + Hearts Corazones + Heptagon Heptágono + Hexagon Hexágono + Horizontal Only Solo horizontal + Infinite Infinito + Ink Outlines Contornos de tinta + Ivy Hiedra + Left Diagonal Diagonal izquierda + Level Based Basado en niveles + Light Rough Claro rugoso + Lighten Only Solo aclarar + Long Lines Líneas largas + Long Strokes Trazos largos + Lower Inferior + Medium Dots Puntos medianos + Medium Lines Líneas medianas + Medium Strokes Trazos medianos + Mono Contrast Contraste monocromo + Mosaic Tiles Azulejos de mosaico + Multiple Flames Along Path Varias llamas a lo largo del trazado + Multiple Flames One Direction Varias llamas en una dirección + Multiple Flames Path Directions Varias llamas en las direcciones del trazado + Multiple Flames Various Length Varias llamas de distinta longitud + Octagon Octágono + Odd Fields Campos impares + One Flame Along Path Una llama a lo largo del trazado + Out From Center Desde el centro hacia fuera + Oval Óvalo + Overlay Edge Borde superpuesto + Palette Knife Espátula + Parallel Paralelo + Patchwork Retales + Pentagon Pentágono + Point Punto + Pointed Puntiagudo + Pond Ripples Ondas de estanque + Pyramids Pirámides + Random Aleatorio + Right Diagonal Diagonal derecha + Rope Cuerda + Roses Rosas + Sandstone Arenisca + Scallops Festones + Sepia Sepia + Short Lines Líneas cortas + Short Strokes Trazos cortos + Simple Simple + Light Intensity Intensidad de la luz + Spray Radius Radio de rociado + Stroke Direction Dirección del trazo + Graininess Granulosidad + Glow Amount Cantidad de resplandor + Clear Amount Cantidad de transparencia + Distortion Distorsión + Ripple Size Tamaño de onda + Ripple Magnitude Magnitud de onda + Charcoal Area Área de carboncillo + Chalk Area Área de tiza + Charcoal Thickness Grosor del carboncillo + Light Dark Balance Equilibrio claro/oscuro + Foreground Level Nivel frontal + Background Level Nivel de fondo + Pattern Type Tipo de motivo + Image Balance Equilibrio de imagen + Darkness Oscuridad + Fiber Length Longitud de fibra + Crack Spacing Espaciado de grietas + Crack Depth Profundidad de grietas + Crack Brightness Brillo de grietas + Grain Type Tipo de granulado + Tile Size Tamaño de azulejo + Grout Width Anchura de junta + Lighten Grout Aclarar junta + Square Size Tamaño de cuadrado + Border Thickness Grosor del borde + Correct Distortion Corregir distorsión + Correct Vignette Corregir viñeta + Correct C A Corregir AC + Auto Scale Escala automática + Red Cyan Rojo/Cian + Blue Yellow Azul/Amarillo + Vignette Amount Cantidad de viñeta + Vignette Midpoint Punto medio de la viñeta + Constraints Restricciones + Curve Highlights Curva de iluminaciones + Curve Lights Curva de claros + Curve Darks Curva de oscuros + Curve Shadows Curva de sombras + Curve Splits Divisiones de curva + Point Curve Curva de puntos + Point Curve Red Curva de puntos roja + Point Curve Green Curva de puntos verde + Point Curve Blue Curva de puntos azul + Hsl Hue Tono HSL + Hsl Sat Saturación HSL + Hsl Lum Luminancia HSL + Grade Shadows Gradación de sombras + Grade Midtones Gradación de medios tonos + Grade Highlights Gradación de iluminaciones + Grade Global Gradación global + Grade Blending Fusión de gradación + Grade Balance Equilibrio de gradación + Sine Seno + Sketch Boceto + Snowflakes Copos de nieve + Sparkle Destellos + Spatter Salpicadura + Speckle Motas + Spin Giro + Sprinkles Espolvoreado + Squareness Cuadratura + Stagger Escalonado + Stained Glass Vidriera + Star Estrella + Stars Estrellas + Stippled Punteado + Stretch Estirar + Teal Orange Verde azulado y naranja + Tiny Lens Lente diminuta + To Center Hacia el centro + Unaltered Sin alterar + Underpainting Pintura base + Underwater Bajo el agua + Upper Superior + Vertical Only Solo vertical + Vine With Flowers Enredadera con flores + Vine With Leaves Enredadera con hojas + Violent Violento + Warm Cálido + Watercolor Acuarela + Waves Ondas + Wide Blurry Ancho borroso + Wide Sharp Ancho nítido + Wrap Envolver + Zigzag Zigzag + Wetness Humedad + AI Integrations Integraciones de IA + Add Noise Añadir ruido + Apply Data Set Aplicar conjunto de datos + Apply Image Aplicar imagen + Arbitrary Arbitrario + Artboard from Group Mesa de trabajo a partir de grupo + Artboard from Layers Mesa de trabajo a partir de capas + Artboards to Files Mesas de trabajo en archivos + Artboards to PDF Mesas de trabajo en PDF + Assign Profile Asignar perfil + At En + Auto-Align Layers Alinear capas automáticamente + Auto-Blend Layers Fusionar capas automáticamente + Batch Lote + Border Borde + Box Blur Desenfoque de cuadro + Calculations Cálculos + Camera Raw Camera Raw + Camera Raw Filter Filtro de Camera Raw + Channel1 Canal 1 + Channel2 Canal 2 + Channel3 Canal 3 + Channel4 Canal 4 + Clone Clonar + Color Decontaminate Descontaminar color + Color Halftone Semitono de color + Color Lookup Tables Tablas de consulta de colores + Color Settings Ajustes de color + Color Table Tabla de colores + Conditional Mode Change Cambio de modo condicional + Content-Aware Fill Relleno según el contenido + Convert to Linked Convertir en vinculado + Convert to Profile Convertir a perfil + Crystallize Cristalizar + Data Datos + Data Sets as Files Conjuntos de datos en archivos + De-Interlace Desentrelazar + Define Brush Preset Definir ajuste preestablecido de pincel + Define Custom Shape Definir forma personalizada + Define Pattern Definir motivo + Defringe Eliminar halos + Diffuse Difundir + Diffuse Glow Resplandor difuso + Displace Desplazar + Do you want to save you made to “{name}” before quitting? ¿Desea guardar los cambios realizados en «{name}» antes de salir? + Dust & Scratches Polvo y arañazos + Export Contents Exportar contenido + Export Preferences Preferencias de exportación + Export/Import Presets Exportar/importar ajustes preestablecidos + Extrude Extruir + Fibers Fibras + Field Blur Desenfoque de campo + File Info Información del archivo + Filter Gallery Galería de filtros + Find and Replace Text Buscar y reemplazar texto + Fit Image Ajustar imagen + Flame Llama + Focus Area Área de enfoque + Gaussian Blur Desenfoque gaussiano + Global Light Luz global + Group from Layers Grupo a partir de capas + HDR Toning Tonificación HDR + High Pass Paso alto + Highlight Iluminación + Image Processor Procesador de imágenes + Install Plug-in Instalar plugin + Interpret Footage Interpretar material de archivo + Iris Blur Desenfoque de iris + Jag Dentado + Label Etiqueta + Layer Comps to Files Composiciones de capa en archivos + Layer Content Options Opciones de contenido de capa + Layer From Background Capa a partir del fondo + Layer from Background Capa a partir del fondo + Layers to Files Capas en archivos + Legacy Heredado + Lens Blur Desenfoque de lente + Lens Correction Corrección de lente + Lens Flare Destello + Lighting Effects Efectos de iluminación + Liquify Licuar + Lock Layers Bloquear capas + Lum Lum + Match Color Igualar color + Merge to HDR Pro Combinar en HDR Pro + Mezzotint Grabado + Mosaic Mosaico + Motion Blur Desenfoque de movimiento + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Arrastrar y soltar archivos de forma nativa aún no es compatible con Wayland. Use Archivo › Abrir o ejecute PhotoCraft mediante XWayland con `WAYLAND_DISPLAY= photocraft`. + Neon Glow Resplandor de neón + New Guide Layout Nuevo diseño de guías + New Video Layer from File Nueva capa de vídeo desde archivo + Ocean Ripple Ondas marinas + Oil Paint Pintura al óleo + Open As Abrir como + Package Empaquetar + Paint Daubs Pinceladas + Path Blur Desenfoque de trazado + Paths to Illustrator Trazados a Illustrator + Photomerge Photomerge + Picture Frame Marco + Pinch Pellizcar + Place Embedded Colocar incrustado + Place Linked Colocar vinculado + Place Scale Marker Colocar marcador de escala + Plastic Wrap Plastificado + Pointillize Puntillista + Polar Coordinates Coordenadas polares + Polar To Rectangular Polar a rectangular + Poster Edges Bordes de cartel + Preset Manager Gestor de ajustes preestablecidos + Profile Perfil + Radial Blur Desenfoque radial + Raw Defaults Valores predeterminados de Raw + Rectangular To Polar Rectangular a polar + Relink to File Volver a vincular al archivo + Render Video Procesar vídeo + Replace Color Reemplazar color + Replace Contents Reemplazar contenido + Replace Footage Reemplazar material de archivo + Resolve Missing Fonts Resolver fuentes que faltan + Ripple Ondulación + Rough Pastels Pasteles rugosos + Sat Sat + Save a Copy Guardar una copia + Save for Web (Legacy) Guardar para web (heredado) + Scale Effects Escalar efectos + Script Events Manager Administrador de eventos de secuencias de comandos + Search menus Buscar en menús + Select and Mask Seleccionar y aplicar máscara + Set Measurement Scale Definir escala de medida + Shadows/Highlights Sombras/iluminaciones + Shape Blur Desenfoque de forma + Shear Inclinación + Smart Blur Desenfoque inteligente + Smart Sharpen Enfoque suavizado + Smudge Stick Barra de difuminado + Solid Color Color sólido + Spherize Esferizar + Spin Blur Desenfoque de rotación + Spot Tinta plana + Sprayed Strokes Trazos con spray + Strokes Trazos + Sumi-e Sumi-e + Surface Blur Desenfoque de superficie + Texturizer Texturizar + Tiles Azulejos + Tilt-Shift Cambio de inclinación + Trace Contour Trazar contorno + Trap Reventado + Tree Árbol + Trim Cortar + Turbulent Turbulento + Twirl Remolino + Unsharp Mask Máscara de enfoque + Use File › Open to open an image. Use Archivo › Abrir para abrir una imagen. + Vanishing Point Punto de fuga + Variable Data Sets Conjuntos de datos variables + Video Frames to Layers Fotogramas de vídeo a capas + WIA Support Compatibilidad con WIA + Warp Text Deformar texto + Wind Viento + Vertical Type Tool Herramienta Texto vertical + About {app} Acerca de {app} + Hide {app} Ocultar {app} + Quit {app} Salir de {app} + Services Servicios + Hide Others Ocultar otros + Lut LUT + Cooling L B B Enfriamiento LBB + Cooling80 Enfriamiento 80 + Cooling82 Enfriamiento 82 + Hsb HSB + Hsl HSL + Movie Prime Película Prime + Prime105 Prime 105 + Prime35 Prime 35 + Warming L B A Calentamiento LBA + Warming81 Calentamiento 81 + Warming85 Calentamiento 85 +@id filter.sharpen.sharpen Enfocar + Good Buena + Best Óptima + Adjustment Properties Propiedades de ajuste + Default Foreground and Background Colors Colores frontal y de fondo predeterminados + Move Layer Mover capa + Rectangular Selection Selección rectangular + Reorder Layer Reordenar capa + Select Layer Seleccionar capa + Set Colors Definir colores + Switch Foreground and Background Colors Intercambiar colores frontal y de fondo + Target Destino +# Path, channel, layer style, status and System Info labels the Czech catalog added + Make Work Path… Crear trazado de trabajo… + Create Vector Mask Crear máscara vectorial + Make Selection… Crear selección… + Fill Path… Rellenar trazado… + Clipping Path… Trazado de recorte… + Free Transform Path Transformación libre de trazado + Unite Shapes at Overlap Unir formas superpuestas + Subtract Shapes at Overlap Restar formas superpuestas + Copy Fill Copiar relleno + Copy Complete Stroke Copiar trazo completo + Paste Fill Pegar relleno + Paste Complete Stroke Pegar trazo completo + Make Symmetry Path Crear trazado de simetría + Disable Symmetry Path Desactivar trazado de simetría + Select Custom Color… Seleccionar color personalizado… + Color Picker (Custom Canvas Color) Selector de color (color de lienzo personalizado) + Duplicate Channel Duplicar canal + Delete Channel Eliminar canal + Rename Channel… Cambiar nombre del canal… + Merge Spot Channel Combinar canal de tinta plana + Convert to Alpha Channel Convertir en canal alfa + Exit Quick Mask Salir de Máscara rápida + New Channel… Nuevo canal… + New Spot Channel… Nuevo canal de tinta plana… + Split Channels Dividir canales + Merge Channels… Combinar canales… + Color Indicates Masked Areas El color indica áreas enmascaradas + Color Indicates Selected Areas El color indica áreas seleccionadas + ✓ Color Indicates Masked Areas ✓ El color indica áreas enmascaradas + ✓ Color Indicates Selected Areas ✓ El color indica áreas seleccionadas + Up Arriba + To Hasta + Shallow Superficial + Deep Profundo + Blend Interior Effects as Group Fusionar efectos interiores como un grupo + Blend Clipped Layers as Group Fusionar capas recortadas como un grupo + Transparency Shapes Layer Capa de formas de transparencia + Layer Mask Hides Effects La máscara de capa oculta los efectos + Vector Mask Hides Effects La máscara vectorial oculta los efectos + General Blending Fusión general + This Layer: Esta capa: + Underlying Layer: Capa subyacente: + Nothing to paste: the clipboard holds no image Nada que pegar: el portapapeles no contiene ninguna imagen + Preferences saved Preferencias guardadas + Invalid panel layout; restored the default dock Disposición de paneles no válida; se restauró el acoplamiento predeterminado + Preferences were reset: {error} Se restablecieron las preferencias: {error} + Recovered {name} Se recuperó {name} + Couldn't save preferences: {error}. Retrying… No se pudieron guardar las preferencias: {error}. Reintentando… + Autosave failed: {error} Error de guardado automático: {error} + Exported presets to {path} Se exportaron los ajustes preestablecidos a {path} + Ran script {name} Se ejecutó la secuencia de comandos {name} + Sent to the printer ({spooler}) Se envió a la impresora ({spooler}) + Printed to {path} Se imprimió en {path} + Exported {path} ({size}) Se exportó {path} ({size}) + Exported {path} Se exportó {path} + Added swatch {name} Se añadió la muestra {name} + Step {step} ({id}) failed: {error} Error en el paso {step} ({id}): {error} + Could not rename the action: {error} No se pudo cambiar el nombre de la acción: {error} + Action playback failed: {error} Error al reproducir la acción: {error} + Clone source set at {x}, {y} Origen de clonación definido en {x}, {y} + Color management: {error} Gestión de color: {error} + Opened {url} Se abrió {url} + No custom shape "{name}": pick one in the options bar or Window › Shapes No hay ninguna forma personalizada «{name}»: elija una en la barra de opciones o en Ventana › Formas + Saved a copy as {path} Se guardó una copia como {path} + Exported contents to {path} Se exportó el contenido a {path} + {label} is not available: {reason} {label} no está disponible: {reason} + Saved {path}; image assets: {n} in {dir} Se guardó {path}; recursos de imagen: {n} en {dir} + {message} (notes: {n}; {warning}) {message} (notas: {n}; {warning}) + Preferences can't be saved No se pueden guardar las preferencias + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft seguirá intentándolo; hasta que el guardado se complete, los cambios de preferencias se perderán al cerrar. + GPU retry scheduled Reintento de GPU programado + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Guarde su trabajo y reinicie PhotoCraft para volver a intentar la aceleración por GPU. La interpretación por CPU seguirá activa durante esta sesión. + GPU device was lost; using the CPU renderer. Se perdió el dispositivo GPU; se usará el renderizador por CPU. + GPU error; using the CPU renderer. Error de GPU; se usará el renderizador por CPU. + Couldn't open {name}: {error} No se pudo abrir {name}: {error} + Toggle the Brush Settings panel Mostrar u ocultar el panel Ajustes de pincel + New Brush Preset Nuevo ajuste preestablecido de pincel + Grid view Vista de cuadrícula + List view Vista de lista + {verb} {mask} {layer} {verb} {mask} {layer} + Unlink Desenlazar + Link Enlazar + Commit transform Confirmar transformación + Cancel transform Cancelar transformación + Scroll menu up Desplazar menú hacia arriba + Scroll menu down Desplazar menú hacia abajo + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Esta capa de texto debe rasterizarse antes de continuar. Su texto dejará de ser editable. ¿Rasterizar el texto? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Esta capa de forma debe rasterizarse antes de continuar. Dejará de ser editable como forma. ¿Rasterizar la forma? + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Este objeto inteligente debe rasterizarse antes de continuar. Su contenido dejará de ser editable. ¿Rasterizar el objeto inteligente? + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Esta capa de relleno debe rasterizarse antes de continuar. Su contenido de relleno dejará de ser editable. ¿Rasterizar la capa? + This layer must be rasterized before proceeding. Esta capa debe rasterizarse antes de continuar. + Color Picker (Background Color) Selector de color (color de fondo) + Color Picker (Foreground Color) Selector de color (color frontal) + Adwaita (Light) Adwaita (claro) + Adwaita (Dark) Adwaita (oscuro) + Open Recent is unavailable on the web Abrir recientes no está disponible en la web + Graphics adapter: {adapter} Adaptador gráfico: {adapter} + Backend: {backend} ({type}) Backend: {backend} ({type}) + Driver: {driver} Controlador: {driver} + GPU backend preference: {backend} Preferencia de backend de GPU: {backend} + Selected at launch: {backend} Seleccionado al iniciar: {backend} + Image compositor: {processor} Compositor de imágenes: {processor} + Fallback: {reason} Alternativa: {reason} + This session: {reason} Esta sesión: {reason} + Window renderer: {renderer} Renderizador de ventanas: {renderer} + Software adapter Adaptador de software + Graphics adapter Adaptador gráfico + unknown type tipo desconocido + Platform: {os} {arch} Plataforma: {os} {arch} + previous reading kept; the last re-read failed: {reason} se conservó la lectura anterior; la última relectura falló: {reason} + Monitor profile: {profile} Perfil del monitor: {profile} + Display profiles: {reason} Perfiles de pantalla: {reason} + Monitor profile in use on {display}: {profile} Perfil de monitor en uso en {display}: {profile} + Monitor profile in use: {profile} Perfil de monitor en uso: {profile} + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. El lienzo se muestra como sRGB. Elija un perfil de monitor en Edición › Ajustes de color. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. El lienzo de {display} se muestra como sRGB. Elija un perfil de monitor en Edición › Ajustes de color. + Display profile not used Perfil de pantalla sin usar + Monitor profile on {display} (main window): {profile} Perfil de monitor en {display} (ventana principal): {profile} + Monitor profile on {display}: {profile} Perfil de monitor en {display}: {profile} + fallback alternativa + The document does not have an embedded colour profile. Working space: {working}. El documento no tiene un perfil de color incrustado. Espacio de trabajo: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} El documento tiene un perfil de color incrustado que no coincide con el espacio de trabajo.\nIncrustado: {embedded}\nTrabajo: {working} + Importing {what} from {file}… Importando {what} de {file}… +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} Se importó {n} pincel de {file}|Se importaron {n} pinceles de {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Se importó {n} degradado de {file}|Se importaron {n} degradados de {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} Se importó {n} motivo de {file}|Se importaron {n} motivos de {file} + Data Set: {name} ({index}/{total}) Conjunto de datos: {name} ({index}/{total}) + (none) (ninguno) +navigator Zoom Zoom + Apply {option}: {state} Aplicar {option}: {state} + This adjustment ({key}) is kept as imported and can't be edited yet. Este ajuste ({key}) se conserva como se importó y aún no puede editarse. + Join the ArtCraft Discord ({url}) Unirse al Discord de ArtCraft ({url}) + {index} of {total} {index} de {total} + {shortcut} assigned. {shortcut} asignado. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} ya está en uso por {commands} y se quitará al hacer clic en Aceptar. + The current gradient (pick one in Window › Gradients) El degradado actual (elija uno en Ventana › Degradados) + auto automático + Baseline Shift Desplazamiento de línea de base + Breeze Dark Breeze oscuro + Breeze Light Breeze claro + brushes pinceles + Color Picker Selector de color + Current Actual + Dark Gray Gris oscuro + Fill Color Color de relleno + gradients degradados + Graphic and web Gráficos y web + layer mask máscara de capa + Lock All Bloquear todo + Lock Image Pixels Bloquear píxeles de la imagen + Lock Position Bloquear posición + Lock Transparent Pixels Bloquear píxeles transparentes + manual manual + Medium Gray Gris medio + New document Documento nuevo + New Selection Selección nueva + No Document Sin documento + Note tool Herramienta Nota + off desactivado + on activado + Recent Files Archivos recientes + Rgb RGB + Shortcut Método abreviado + Show Rulers Mostrar reglas + Solarized Dark Solarized oscuro + sRGB sRGB + Stroke Color Color del trazo + unknown desconocido + Unsaved Changes Cambios sin guardar + vector mask máscara vectorial + ZigZag Zigzag + Show home recent files Mostrar archivos recientes en la pantalla de inicio Clear Brush Controls Borrar controles de pincel Reset All Locked Settings Restaurar todos los ajustes bloqueados Copy Texture to Other Tools Copiar textura en otras herramientas @@ -2808,7 +4108,6 @@ curves Smooth Suavizar @plural Saved {n} action to {file}|Saved {n} actions to {file} Se guardó {n} acción en {file}|Se guardaron {n} acciones en {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} paso sin equivalente en Photoshop omitido|{n} pasos sin equivalente en Photoshop omitidos - File Naming Nombres de archivo Starting serial Número de serie inicial # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Insertar detención… @@ -2820,5 +4119,94 @@ curves Smooth Suavizar Action playback stopped Reproducción de la acción detenida Step {step} is waiting for its dialog El paso {step} está esperando su cuadro de diálogo @plural {n} action|{n} actions {n} acción|{n} acciones + Background (Default) Fondo (predeterminado) +# Batch / Image Processor + Include All Subfolders Incluir todas las subcarpetas Auto Add/Delete Añadir/eliminar automáticamente + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. No actives también Imagen especular en Ajustes de impresión: las dos inversiones se anulan. + Print Settings… Ajustes de impresión… + Remember to turn off the printer's own colour management in Print Settings… Recuerda desactivar la gestión de color de la impresora en Ajustes de impresión… + Sent to {printer} Enviado a {printer} + set in Print Settings… se establece en Ajustes de impresión… ICO is limited to 256 × 256 pixels ICO está limitado a 256 × 256 píxeles +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Usar el perfil incrustado ({profile}) + Convert to the working space ({profile}) Convertir al espacio de trabajo ({profile}) + Remember this choice for future images Recordar esta opción para próximas imágenes + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Vuelve a activar el aviso con Ask On Mismatch en Edición › Ajustes de color. + Button Mode Modo de botón + Button Mode On Modo de botón activado + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Oscurecer interfaz + Lighten Interface Aclarar interfaz +# Bevel & Emboss Technique. + Technique Técnica + Chisel Hard Cincelar duro + Chisel Soft Cincelar suave + Open color-less EXR as ACEScg Abrir EXR sin espacio de color como ACEScg +# Export As metadata and profile options + +# View › Guides › right-click a guide + Edit Selected Guides Editar guías seleccionadas + Clear Selected Guides Borrar guías seleccionadas + Pasting from other apps is unavailable Pegar desde otras aplicaciones no está disponible + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft no puede acceder al portapapeles del sistema en esta sesión. En Wayland, el entorno aislado de Flatpak oculta el protocolo con el que se leen las imágenes de otras aplicaciones. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Para pegar desde otras aplicaciones, inicia PhotoCraft en XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nada que pegar: PhotoCraft no puede leer aquí el portapapeles del sistema + Delete this set Eliminar este conjunto + New Set… Nuevo conjunto… + PhotoCraft Defaults Valores por defecto de PhotoCraft + Save a list of these shortcuts as a web page Guardar una lista de estos métodos abreviados como página web + Save the changes to this set Guardar los cambios en este conjunto + Save these shortcuts as a .kys file Guardar estos métodos abreviados como archivo .kys + Save these shortcuts as a new set Guardar estos métodos abreviados como un conjunto nuevo + Set deleted. Conjunto eliminado. + Set saved. Conjunto guardado. + Set: Conjunto: + Summarize… Resumir… + There are too many sets: delete one first. Hay demasiados conjuntos: elimine uno primero. + set name nombre del conjunto + {set} (modified) {set} (modificado) + Show file name in title Mostrar nombre de archivo en el título + Accept and Go to Conflict Aceptar e ir al conflicto + Changes undone. Cambios deshechos. + Choose a new shortcut for this command, or leave it without one. Elija un nuevo método abreviado para este comando o déjelo sin ninguno. + Put this command's shortcuts back as they were Restaurar los métodos abreviados de este comando como estaban + Take the shortcut from the other command and select it Quitar el método abreviado al otro comando y seleccionarlo + Undo Changes Deshacer cambios + {shortcut} is reserved by the system and can't be assigned. {shortcut} está reservado por el sistema y no se puede asignar. + Low-Latency Drawing (vsync off, applies at next launch) Dibujo de baja latencia (sin sincronización vertical, se aplica al volver a iniciar) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Transformación libre aún se está aplicando; inténtelo de nuevo cuando termine. + The transform couldn't be applied. No se pudo aplicar la transformación. +# 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, 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} + 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. + Decrease Brush Size Reducir tamaño del pincel + Increase Brush Size Aumentar tamaño del pincel + Decrease Brush Hardness Reducir dureza del pincel + Increase Brush Hardness Aumentar dureza del pincel + Memory Usage Uso de memoria + Per-document RAM limit (MB) Límite de RAM por documento (MB) + Installed RAM: {ram} MB; this limit: {percent}% RAM instalada: {ram} MB; este límite: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Limita los píxeles más el Historial de cada documento abierto. Se descartan los estados de deshacer más antiguos al superar el límite. + Applies immediately when saved. Memory is not reserved in advance. Se aplica al guardar. La memoria no se reserva de antemano. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Con Memoria GPU en Automático, también limita el presupuesto GPU. Está topeado a un cuarto de la RAM y, si se conoce, tres octavos de la memoria de la tarjeta, mínimo 512 MB. Subir el límite no aumenta el presupuesto GPU. + Use Usar + In Front Delante + Behind Detrás + +# DDS Options (#1327) + DDS Options Opciones de DDS + Generate Mipmaps Generar mipmaps diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f7d508fb5c..80c7f110fe 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -546,6 +546,10 @@ # Field Blur pins (#3373) No pin selected Aucune épingle sélectionnée Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Cliquez dans l’image pour ajouter une épingle ; faites glisser une épingle pour la déplacer, ou son anneau pour modifier le flou. Suppr supprime l’épingle sélectionnée. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Faites glisser les poignées du contour pour le redimensionner et le faire pivoter, le carré pour arrondir ses angles et les points intérieurs pour définir où commence le flou. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Faites glisser l’épingle pour déplacer la bande, ou son anneau pour modifier le flou. Faites glisser les lignes pleines pour élargir la bande nette, les lignes pointillées pour régler la transition et les points pour la faire pivoter. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Faites glisser les poignées du contour pour le redimensionner et le faire pivoter ; l’anneau règle l’angle de flou. + Blur Angle Angle de flou Iris Blur… Flou d'iris… Tilt-Shift… Inclinaison-décalage… Path Blur… Flou de tracé… @@ -972,7 +976,6 @@ Contiguous Contigu Contrast Contraste Control: Contrôle : - Convert document's colors to the working space Convertir les couleurs du document vers l'espace de travail Convert to sRGB Convertir en sRVB Copies Exemplaires Copper Cuivre @@ -1027,6 +1030,7 @@ Delete Group Supprimer le groupe Delete Layer Mask Supprimer le masque de calque Delete Layers Supprimer les calques + Add Shortcut Ajouter un raccourci Delete Shortcut Supprimer le raccourci Delete Timeline Supprimer le montage Delete Tool Preset Supprimer le préréglage d'outil @@ -1547,7 +1551,6 @@ Use Default Utiliser la valeur par défaut Use Legacy Utiliser l'ancienne version Use Measurement Scale Utiliser l'échelle de mesure - Use the embedded profile (instead of the working space) Utiliser le profil incorporé (au lieu de l'espace de travail) Variables bind a layer's visibility, text or pixels to a named data slot. Les variables lient la visibilité, le texte ou les pixels d'un calque à un emplacement de données nommé. Vertical scale Échelle verticale Vibrance Vibrance @@ -1865,6 +1868,8 @@ Camera Raw Defaults Valeurs par défaut de Camera Raw Canvas border Bordure du canevas Show canvas dots Afficher les points du canevas + Clip thumbnails to layer bounds Rogner les vignettes aux limites du calque + Clip Thumbnails to Layer Bounds Rogner les vignettes aux limites du calque Canvas color Couleur du canevas Canvas custom color Couleur personnalisée du canevas Color picker Sélecteur de couleurs @@ -2051,7 +2056,8 @@ Expand group ({key}-click: all groups) Développer le groupe ({key}-clic : tous les groupes) Fade steps Étapes d'estompage Hide the layer's effects ({key}-click: all layers) Masquer les effets du calque ({key}-clic : tous les calques) - Import Brushes… (.abr) Importer des pinceaux… (.abr) + Import Brushes… (.abr or image) Importer des pinceaux… (.abr ou image) + Import Brush from Image… Importer un pinceau depuis une image… Move Group Down Descendre le groupe Move Group Up Monter le groupe Reset the brush to the defaults Réinitialiser le pinceau aux valeurs par défaut @@ -2160,7 +2166,6 @@ Cancelled opening {name} Ouverture de {name} annulée The document “{name}” does not have an embedded {mode} profile. Le document « {name} » n'a pas de profil {mode} incorporé. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Le document « {name} » a un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail {mode} actuel. - Embedded: {embedded} Incorporé : {embedded} Working: {working} Espace de travail : {working} Developing {name} Développement de {name} Cancelled {label} {label} : annulé @@ -2857,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 @@ -2921,6 +2925,8 @@ fontWeight Italic Italique Color Picker (Layer Style Color) Sélecteur de couleurs (couleur du style de calque) Auto Hide Notifications Masquer automatiquement les notifications Notification Duration (seconds) Durée de la notification (secondes) + Bundle Arrow-Key Nudges Regrouper les déplacements aux flèches + Nudge Bundle Pause (ms) Pause de regroupement des déplacements (ms) Appearance Mode Mode d’apparence Dark Theme Thème sombre Light Theme Thème clair @@ -3045,7 +3051,26 @@ cameraData Lens Objectif Snap to Origin Aligner sur l'origine Search settings… Rechercher des réglages… No matching settings Aucun réglage correspondant +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Outil Pinceau d’échantillonnage + Sampling Area Overlay Incrustation de la zone d’échantillonnage + Show Sampling Area Afficher la zone d’échantillonnage + Indicates Indique + Sampling Area Zone d’échantillonnage + Excluded Area Zone exclue + Sampling Area Options Options de la zone d’échantillonnage + Rectangular Rectangulaire + Fill Settings Paramètres de remplissage + Rotation Adaptation Adaptation de la rotation + Very High Très élevée + Full Totale + Mirror Miroir + Output Settings Paramètres de sortie + Output To Sortie vers + Duplicate Layer Dupliquer le calque + Contract Contracter Loading fonts… Chargement des polices… + Response curve Courbe de réponse Tool Outil Simulate Pressure Simuler la pression Stroke Path… Contourner le tracé… @@ -3111,6 +3136,7 @@ curves Smooth Lisser Remove after each stroke Supprimer après chaque trait Apply the removal ({key}) Appliquer la suppression ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traits à supprimer : {n} (Entrée applique, Échap annule) + Show home recent files Afficher les fichiers récents sur l'écran d'accueil Clear Brush Controls Effacer les commandes de pinceau Reset All Locked Settings Réinitialiser tous les paramètres verrouillés Copy Texture to Other Tools Copier la texture dans d'autres outils @@ -3137,5 +3163,97 @@ curves Smooth Lisser Action playback stopped Exécution de l’action arrêtée Step {step} is waiting for its dialog L’étape {step} attend sa boîte de dialogue @plural {n} action|{n} actions {n} action|{n} actions + Background (Default) Arrière-plan (par défaut) + Content-Aware Fill Remplissage d'après le contenu +# Batch / Image Processor + Include All Subfolders Inclure tous les sous-dossiers Auto Add/Delete Ajout/suppr. auto + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Ne cochez pas aussi Image miroir dans les paramètres d’impression : les deux inversions s’annulent. + Print Settings… Paramètres d’impression… + Remember to turn off the printer's own colour management in Print Settings… Pensez à désactiver la gestion des couleurs de l’imprimante dans les paramètres d’impression… + Sent to {printer} Envoyé à {printer} + set in Print Settings… défini dans les paramètres d’impression… ICO is limited to 256 × 256 pixels Le format ICO est limité à 256 × 256 pixels +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Utiliser le profil incorporé ({profile}) + Convert to the working space ({profile}) Convertir vers l'espace de travail ({profile}) + Remember this choice for future images Mémoriser ce choix pour les prochaines images + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Réactivez la question avec Ask On Mismatch dans Édition › Paramètres de couleur. + Button Mode Mode bouton + Button Mode On Mode bouton activé + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Assombrir l'interface + Lighten Interface Éclaircir l'interface +# Bevel & Emboss Technique. + Technique Technique + Chisel Hard Ciselage dur + Chisel Soft Ciselage doux + Open color-less EXR as ACEScg Ouvrir les EXR sans espace colorimétrique en ACEScg + +# View › Guides › right-click a guide + Edit Selected Guides Modifier les repères sélectionnés + Clear Selected Guides Effacer les repères sélectionnés + Pasting from other apps is unavailable Le collage depuis d’autres applications est indisponible + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft ne peut pas accéder au presse-papiers du système dans cette session. Sous Wayland, le bac à sable Flatpak masque le protocole par lequel les images des autres applications sont lues. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Pour coller depuis d’autres applications, lancez PhotoCraft sous XWayland : `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Rien à coller : PhotoCraft ne peut pas lire ici le presse-papiers du système + Delete this set Supprimer ce jeu + New Set… Nouveau jeu… + PhotoCraft Defaults Valeurs par défaut de PhotoCraft + Save a list of these shortcuts as a web page Enregistrer la liste de ces raccourcis en page web + Save the changes to this set Enregistrer les modifications de ce jeu + Save these shortcuts as a .kys file Enregistrer ces raccourcis dans un fichier .kys + Save these shortcuts as a new set Enregistrer ces raccourcis comme nouveau jeu + Set deleted. Jeu supprimé. + Set saved. Jeu enregistré. + Set: Jeu : + Summarize… Résumer… + There are too many sets: delete one first. Il y a trop de jeux : supprimez-en un d’abord. + set name nom du jeu + {set} (modified) {set} (modifié) + Random Aléatoire + Show file name in title Afficher le nom de fichier dans le titre + Accept and Go to Conflict Accepter et aller au conflit + Changes undone. Modifications annulées. + Choose a new shortcut for this command, or leave it without one. Choisissez un nouveau raccourci pour cette commande ou laissez-la sans raccourci. + Put this command's shortcuts back as they were Rétablir les raccourcis de cette commande + Take the shortcut from the other command and select it Retirer le raccourci de l’autre commande et la sélectionner + Undo Changes Annuler les modifications + {shortcut} is reserved by the system and can't be assigned. {shortcut} est réservé par le système et ne peut pas être attribué. + Low-Latency Drawing (vsync off, applies at next launch) Dessin à faible latence (sans synchronisation verticale, au prochain lancement) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. La transformation manuelle est encore en cours d'application ; réessayez quand elle sera terminée. + The transform couldn't be applied. Impossible d'appliquer la transformation. +# 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, 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} + 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. + Decrease Brush Size Réduire la taille du pinceau + Increase Brush Size Augmenter la taille du pinceau + Decrease Brush Hardness Réduire la dureté du pinceau + Increase Brush Hardness Augmenter la dureté du pinceau + Memory Usage Utilisation de la mémoire + Per-document RAM limit (MB) Limite RAM par document (Mo) + Installed RAM: {ram} MB; this limit: {percent}% RAM installée : {ram} Mo ; cette limite : {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Limite les pixels plus l’Historique de chaque document ouvert. Les plus anciens états d’annulation sont supprimés au-delà de la limite. + Applies immediately when saved. Memory is not reserved in advance. S’applique dès l’enregistrement. La mémoire n’est pas réservée à l’avance. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Si Mémoire GPU est Automatique, cela limite aussi le budget GPU. Il est plafonné à un quart de la RAM et, si connu, aux trois huitièmes de la mémoire de la carte, minimum 512 Mo. Dépasser ces plafonds n’augmente pas le budget GPU. + Use Utiliser + In Front Devant + Behind Derrière + +# DDS Options (#1327) + DDS Options Options DDS + Compression Compression + Generate Mipmaps Générer les mipmaps diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 70c2294205..ca747637ce 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -563,6 +563,9 @@ Field Blur… Mezőelmosás… No pin selected Nincs kijelölt tű Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kattints a képre tű hozzáadásához; húzd a tűt az áthelyezéséhez, vagy a gyűrűjét az elmosás módosításához. A Delete törli a kijelölt tűt. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Húzd a körvonal fogantyúit az átméretezéshez és az elforgatáshoz, a négyzetet a sarkok lekerekítéséhez, a belső pontokat pedig annak beállításához, hol kezdődjön az elmosás. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Húzd a tűt a sáv áthelyezéséhez, vagy a gyűrűjét az elmosás módosításához. Húzd a folytonos vonalakat az éles sáv szélesítéséhez, a szaggatott vonalakat az átmenet beállításához, a pontokat pedig a sáv elforgatásához. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Húzd a körvonal fogantyúit az átméretezéshez és az elforgatáshoz; a gyűrű az elmosás szögét állítja be. Fill Path… Útvonal kitöltése… Fill Screen Képernyő kitöltése Filter Szűrő @@ -606,6 +609,7 @@ HSB/HSL HSB/HSL Huge Óriási Image Processor Képfeldolgozó + Include All Subfolders Az összes almappa belefoglalása Indexed Color… Indexelt szín… Info Információ Install Plug-in Bővítmény telepítése @@ -1190,7 +1194,7 @@ Capture Brush Size in Preset Ecsetméret rögzítése az előbeállításban Include Tool Settings Eszközbeállítások belefoglalása Include Color Szín belefoglalása - Enter a name for the brush preset Adjon nevet az ecset-előbeállításnak + Enter a name for the brush preset Adj nevet az ecset-előbeállításnak A brush preset with this name already exists Már van ilyen nevű ecset-előbeállítás New Crop Preset… Új körbevágási előbeállítás… New Tool Preset Új eszköz-előbeállítás @@ -2486,9 +2490,8 @@ navigator Zoom Nagyítás mértéke Camera Raw Filter ({layer}) Camera Raw szűrő ({layer}) Camera Raw isn't open A Camera Raw nincs megnyitva Cancelled opening {name} Megnyitás megszakítva: {name} - The document “{name}” does not have an embedded {mode} profile. A(z) „{name}” dokumentumnak nincs beágyazott {mode}-profilja. - The document “{name}” has an embedded color profile that does not match the current {mode} working space. A(z) „{name}” dokumentum beágyazott színprofilja nem egyezik az aktuális {mode}-munkaszíntérrel. - Embedded: {embedded} Beágyazott: {embedded} + The document “{name}” does not have an embedded {mode} profile. Nincs beágyazott profil ({mode}) ebben a dokumentumban: „{name}” + The document “{name}” has an embedded color profile that does not match the current {mode} working space. A dokumentum beágyazott színprofilja nem egyezik az aktuális munkaszíntérrel ({mode}): „{name}” Working: {working} Munkaszíntér: {working} Cancelled {label} Megszakítva: {label} Clarity Tisztaság @@ -2702,7 +2705,6 @@ cameraRaw Yellow Sárga Contents: Tartalom: Contributors Közreműködők Contributors: {contributors} · Commits: {commits} Közreműködők: {contributors} · Commitok: {commits} - Convert document's colors to the working space A dokumentum színeinek átalakítása a munkaszíntérbe Convert to sRGB Átalakítás sRGB-re Cool Hideg Copies Példányszám @@ -2731,6 +2733,7 @@ cameraRaw Yellow Sárga Defaults Alapértékek Delete Törlés Delete Shortcut Billentyűparancs törlése + Add Shortcut Billentyűparancs hozzáadása Description Leírás Diffusion Diffúzió DirectX 12 DirectX 12 @@ -2855,6 +2858,8 @@ cameraRaw Yellow Sárga No saved presets yet. Még nincs mentett előbeállítás. Not a web colour: click for the nearest web colour Nem webes szín: kattints a legközelebbi webes színért Notification Duration (seconds) Értesítés időtartama (másodperc) + Bundle Arrow-Key Nudges Nyílbillentyűs mozgatások összevonása + Nudge Bundle Pause (ms) Összevonási szünet (ms) Number Szám OK OK Only Web Colors Csak webes színek @@ -3001,7 +3006,6 @@ cameraRaw Yellow Sárga URLs URL-ek Use Default Alapértelmezés használata Use shift key for tool switch Eszközváltás a Shift billentyűvel - Use the embedded profile (instead of the working space) A beágyazott profil használata (a munkaszíntér helyett) Username Felhasználónév Version {version} Verzió: {version} Vulkan Vulkan @@ -3126,6 +3130,7 @@ cameraRaw Yellow Sárga Concise Tömör Constrain proportions Arányok megtartása Constraints Megkötések + Background (Default) Háttér (alapértelmezett) Content-Aware Fill Tartalomérzékeny kitöltés Content-Aware Move Tool Tartalomérzékeny mozgatás Contract Szűkítés @@ -3307,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 @@ -3536,6 +3540,8 @@ cameraRaw Yellow Sárga shortcut billentyűparancs Shortcut Billentyűparancs Show canvas dots Vászonpontok megjelenítése + Clip thumbnails to layer bounds Bélyegképek levágása a réteg határaira + Clip Thumbnails to Layer Bounds Bélyegképek levágása a réteg határaira Show channels in color Csatornák megjelenítése színesen Show crosshair in brush tip Szálkereszt az ecsethegyben Show extension panels Bővítménypanelek megjelenítése @@ -4011,7 +4017,25 @@ cameraRaw Yellow Sárga The layer or its mask changed. Drag the mask onto the Trash again. A réteg vagy a maszkja megváltozott. Húzd újra a maszkot a Kukába. Transform On Drop Átalakítás elengedéskor Vanishing Point is not active Az Enyészpont nem aktív +# Edit › Content-Aware Fill workspace + Excluded Area Kizárt terület + Fill Settings Kitöltési beállítások + Full Teljes + Indicates Jelzi + Mirror Tükrözés + Output Settings Kimeneti beállítások + Output To Kimenet célja + Rectangular Téglalap + Rotation Adaptation Elforgatás-adaptáció + Sampling Area Mintavételi terület + Sampling Area Options Mintavételi terület beállításai + Sampling Area Overlay Mintavételi terület átfedése + Sampling Brush Tool Mintavételi ecset + Show Sampling Area Mintavételi terület megjelenítése + Very High Nagyon magas Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattints a négy sarokra + Response curve Válaszgörbe + Show home recent files Legutóbbi fájlok megjelenítése a kezdőképernyőn Clear Brush Controls Ecsetvezérlők törlése Reset All Locked Settings Összes zárolt beállítás visszaállítása Copy Texture to Other Tools Textúra másolása más eszközökre @@ -4042,7 +4066,7 @@ cameraRaw Yellow Sárga Drag to pan · Scroll to zoom · Double-click to fit Húzás: mozgatás · Görgetés: nagyítás · Dupla kattintás: igazítás Gradient Preset Options Színátmenet-előbeállítás beállításai Import .GRD .GRD importálása - Choose a gradient file (.grd). Válasszon színátmenetfájlt (.grd). + Choose a gradient file (.grd). Válassz színátmenetfájlt (.grd). Save Settings… Beállítások mentése… Load Settings… Beállítások betöltése… Save these settings as a Camera Raw .xmp file A beállítások mentése Camera Raw .xmp-fájlként @@ -4050,6 +4074,13 @@ cameraRaw Yellow Sárga Loaded Camera Raw settings from {file} Camera Raw-beállítások betöltve innen: {file} Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw-beállítások betöltve innen: {file} ({n} nem támogatott) Auto Add/Delete Automatikus hozzáadás/törlés + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. A nyomtatási beállításokban ne jelöld be a tükrözést is: a két tükrözés kioltja egymást. + Print Settings… Nyomtatási beállítások… + Remember to turn off the printer's own colour management in Print Settings… Ne felejtsd el kikapcsolni a nyomtató saját színkezelését a nyomtatási beállításokban… + Sent to {printer} Elküldve ide: {printer} + set in Print Settings… a nyomtatási beállításokban adható meg… ICO is limited to 256 × 256 pixels Az ICO legfeljebb 256 × 256 képpont lehet # Channel Options and New Spot Channel (#3761) @@ -4058,3 +4089,81 @@ cameraRaw Yellow Sárga Ink Characteristics Festék jellemzői Solidity: Fedőképesség: Redrawing Újrarajzolás +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) A beágyazott profil használata ({profile}) + Convert to the working space ({profile}) Átalakítás a munkaszíntérbe ({profile}) + Remember this choice for future images A választás megjegyzése a későbbi képekhez + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. A kérdést a Szerkesztés › Színbeállítások „Profileltérés: rákérdezés megnyitáskor” beállításával kapcsolhatod vissza. + Button Mode Gomb mód + Button Mode On Gomb mód be + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Felület sötétítése + Lighten Interface Felület világosítása +# Bevel & Emboss Technique. + Technique Technika + Chisel Hard Kemény vésés + Chisel Soft Lágy vésés + Open color-less EXR as ACEScg Színinformáció nélküli EXR megnyitása ACEScg-ként + +# View › Guides › right-click a guide + Edit Selected Guides Kijelölt segédvonalak szerkesztése + Clear Selected Guides Kijelölt segédvonalak törlése + Pasting from other apps is unavailable A beillesztés más alkalmazásokból nem érhető el + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. A PhotoCraft ebben a munkamenetben nem fér hozzá a rendszer vágólapjához. Waylanden a Flatpak homokozó elrejti azt a protokollt, amelyen keresztül más alkalmazások képei olvashatók. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Ha más alkalmazásokból szeretne beilleszteni, indítsa a PhotoCraftot XWayland alatt: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nincs mit beilleszteni: a PhotoCraft itt nem tudja olvasni a rendszer vágólapját + Delete this set Készlet törlése + New Set… Új készlet… + PhotoCraft Defaults PhotoCraft alapértelmezések + Save a list of these shortcuts as a web page A billentyűparancsok listájának mentése weboldalként + Save the changes to this set A készlet változásainak mentése + Save these shortcuts as a .kys file A billentyűparancsok mentése .kys fájlként + Save these shortcuts as a new set A billentyűparancsok mentése új készletként + Set deleted. Készlet törölve. + Set saved. Készlet mentve. + Set: Készlet: + Summarize… Összegzés… + There are too many sets: delete one first. Túl sok készlet van: előbb töröljön egyet. + set name készlet neve + {set} (modified) {set} (módosítva) + Show file name in title Fájlnév megjelenítése a címben + Accept and Go to Conflict Elfogadás és ugrás az ütközéshez + Changes undone. Változások visszavonva. + Choose a new shortcut for this command, or leave it without one. Válasszon új billentyűparancsot ehhez a parancshoz, vagy hagyja nélküle. + Put this command's shortcuts back as they were A parancs billentyűparancsainak visszaállítása + Take the shortcut from the other command and select it A billentyűparancs elvétele a másik parancstól, és annak kijelölése + Undo Changes Változások visszavonása + {shortcut} is reserved by the system and can't be assigned. A(z) {shortcut} a rendszer számára fenntartott, nem rendelhető hozzá. + Low-Latency Drawing (vsync off, applies at next launch) Kis késleltetésű rajzolás (vsync nélkül, a következő indításkor lép életbe) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. A szabad alakítás alkalmazása még folyamatban van; próbáld újra, ha befejeződött. + The transform couldn't be applied. Az átalakítás nem alkalmazható. +# 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, 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} + 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. + Decrease Brush Size Ecsetméret csökkentése + Increase Brush Size Ecsetméret növelése + Decrease Brush Hardness Ecsetkeménység csökkentése + Increase Brush Hardness Ecsetkeménység növelése + Memory Usage Memóriahasználat + Per-document RAM limit (MB) Dokumentumonkénti RAM-korlát (MB) + Installed RAM: {ram} MB; this limit: {percent}% Telepített RAM: {ram} MB; ez a korlát: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Minden nyitott dokumentum pixeljeit és előzményeit korlátozza. A legrégebbi visszavonási állapotok törlődnek a korlát túllépésekor. + Applies immediately when saved. Memory is not reserved in advance. Mentéskor azonnal érvényes. A memória nincs előre lefoglalva. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Automatikus GPU-memóriánál a GPU-rétegkeretet is korlátozza. Felső határ a RAM negyede és, ha ismert, a kártyamemória háromnyolcada, minimum 512 MB. Magasabb korlát a GPU-keretet nem növeli. + Use Használat + In Front Elöl + Behind Mögött + +# DDS Options (#1327) + DDS Options DDS-beállítások + Generate Mipmaps Mipmapek létrehozása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index dcc02ec185..4fd06fd88f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -539,6 +539,10 @@ # Field Blur pins (#3373) No pin selected Tidak ada pin yang dipilih Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klik gambar untuk menambah pin; seret pin untuk memindahkannya, atau cincinnya untuk mengubah blur. Delete menghapus pin yang dipilih. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Seret pegangan pada garis luar untuk mengubah ukuran dan memutarnya, kotak untuk membulatkan sudutnya, dan titik di dalam untuk menentukan di mana blur dimulai. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Seret pin untuk memindahkan pita, atau cincinnya untuk mengubah blur. Seret garis utuh untuk melebarkan pita tajam, garis putus-putus untuk mengatur transisi, dan titik untuk memutarnya. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Seret pegangan pada garis luar untuk mengubah ukuran dan memutarnya; cincin mengatur sudut blur. + Blur Angle Sudut Blur Iris Blur… Iris Blur… Tilt-Shift… Tilt-Shift… Path Blur… Path Blur… @@ -966,7 +970,6 @@ Contiguous Berdekatan Contrast Kontras Control: Kontrol: - Convert document's colors to the working space Konversi warna dokumen ke working space Convert to sRGB Konversi ke sRGB Copies Salinan Copper Tembaga @@ -1021,6 +1024,7 @@ Delete Group Hapus Grup Delete Layer Mask Hapus Mask Layer Delete Layers Hapus Layer + Add Shortcut Tambah Pintasan Delete Shortcut Hapus Pintasan Delete Timeline Hapus Timeline Delete Tool Preset Hapus Preset Alat @@ -1541,7 +1545,6 @@ Use Default Pakai Default Use Legacy Pakai Versi Lama Use Measurement Scale Pakai Skala Pengukuran - Use the embedded profile (instead of the working space) Pakai profil tertanam (bukan working space) Variables bind a layer's visibility, text or pixels to a named data slot. Variabel mengikat visibilitas, teks, atau piksel sebuah layer ke slot data bernama. Vertical scale Skala vertikal Vibrance Vibrance @@ -1859,6 +1862,8 @@ Camera Raw Defaults Default Camera Raw Canvas border Border kanvas Show canvas dots Tampilkan titik kanvas + Clip thumbnails to layer bounds Pangkas thumbnail ke batas layer + Clip Thumbnails to Layer Bounds Pangkas Thumbnail ke Batas Layer Canvas color Warna kanvas Canvas custom color Warna kustom kanvas Color picker Pemilih warna @@ -2045,7 +2050,8 @@ Expand group ({key}-click: all groups) Bentangkan grup ({key}-klik: semua grup) Fade steps Langkah pudar Hide the layer's effects ({key}-click: all layers) Sembunyikan efek layer ({key}-klik: semua layer) - Import Brushes… (.abr) Impor Kuas… (.abr) + Import Brushes… (.abr or image) Impor Kuas… (.abr atau gambar) + Import Brush from Image… Impor Kuas dari Gambar… Move Group Down Pindah Grup ke Bawah Move Group Up Pindah Grup ke Atas Reset the brush to the defaults Reset kuas ke default @@ -2153,7 +2159,6 @@ Cancelled opening {name} Pembukaan {name} dibatalkan The document “{name}” does not have an embedded {mode} profile. Dokumen “{name}” tidak memiliki profil {mode} tertanam. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokumen “{name}” memiliki profil warna tertanam yang tidak cocok dengan working space {mode} saat ini. - Embedded: {embedded} Tertanam: {embedded} Working: {working} Working space: {working} Developing {name} Mengembangkan {name} Cancelled {label} {label} dibatalkan @@ -2844,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 @@ -2908,6 +2912,8 @@ fontWeight Italic Miring Color Picker (Layer Style Color) Pemilih Warna (Warna Gaya Lapisan) Auto Hide Notifications Sembunyikan notifikasi secara otomatis Notification Duration (seconds) Durasi notifikasi (detik) + Bundle Arrow-Key Nudges Gabungkan pergeseran tombol panah + Nudge Bundle Pause (ms) Jeda penggabungan pergeseran (ms) Appearance Mode Mode tampilan Dark Theme Tema gelap Light Theme Tema terang @@ -3032,7 +3038,26 @@ cameraData Lens Lensa Snap to Origin Jepret ke Titik Asal Search settings… Cari pengaturan… No matching settings Tidak ada pengaturan yang cocok +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Alat Kuas Pengambilan Sampel + Sampling Area Overlay Hamparan Area Pengambilan Sampel + Show Sampling Area Tampilkan Area Pengambilan Sampel + Indicates Menandai + Sampling Area Area Pengambilan Sampel + Excluded Area Area yang Dikecualikan + Sampling Area Options Opsi Area Pengambilan Sampel + Rectangular Persegi Panjang + Fill Settings Pengaturan Isian + Rotation Adaptation Adaptasi Rotasi + Very High Sangat Tinggi + Full Penuh + Mirror Cermin + Output Settings Pengaturan Keluaran + Output To Keluaran Ke + Duplicate Layer Duplikat Layer + Contract Ciutkan Loading fonts… Memuat font… + Response curve Kurva respons Tool Alat Simulate Pressure Simulasikan Tekanan Stroke Path… Stroke Path… @@ -3098,6 +3123,7 @@ curves Smooth Haluskan Remove after each stroke Hapus setelah setiap goresan Apply the removal ({key}) Terapkan penghapusan ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Goresan untuk dihapus: {n} (Enter menerapkan, Esc membatalkan) + Show home recent files Tampilkan file terbaru di layar beranda Clear Brush Controls Hapus Kontrol Kuas Reset All Locked Settings Atur Ulang Semua Pengaturan Terkunci Copy Texture to Other Tools Salin Tekstur ke Alat Lain @@ -3124,5 +3150,97 @@ curves Smooth Haluskan Action playback stopped Pemutaran tindakan dihentikan Step {step} is waiting for its dialog Langkah {step} menunggu dialognya @plural {n} action|{n} actions {n} tindakan + Background (Default) Background (Default) + Content-Aware Fill Isi Content-Aware +# Batch / Image Processor + Include All Subfolders Sertakan semua subfolder Auto Add/Delete Tambah/Hapus Otomatis + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Jangan aktifkan juga Gambar Cermin di Pengaturan Cetak: kedua pembalikan saling meniadakan. + Print Settings… Pengaturan Cetak… + Remember to turn off the printer's own colour management in Print Settings… Ingat untuk menonaktifkan pengelolaan warna milik printer di Pengaturan Cetak… + Sent to {printer} Dikirim ke {printer} + set in Print Settings… diatur di Pengaturan Cetak… ICO is limited to 256 × 256 pixels ICO dibatasi hingga 256 × 256 piksel +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Pakai profil tertanam ({profile}) + Convert to the working space ({profile}) Konversi ke working space ({profile}) + Remember this choice for future images Ingat pilihan ini untuk gambar berikutnya + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Aktifkan lagi pertanyaan ini lewat Ask On Mismatch di Edit › Pengaturan Warna. + Button Mode Mode tombol + Button Mode On Mode tombol aktif + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Gelapkan Antarmuka + Lighten Interface Cerahkan Antarmuka +# Bevel & Emboss Technique. + Technique Teknik + Chisel Hard Pahat Keras + Chisel Soft Pahat Lembut + Open color-less EXR as ACEScg Buka EXR tanpa informasi warna sebagai ACEScg + +# View › Guides › right-click a guide + Edit Selected Guides Edit Panduan Terpilih + Clear Selected Guides Hapus Panduan Terpilih + Pasting from other apps is unavailable Menempel dari aplikasi lain tidak tersedia + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft tidak dapat mengakses papan klip sistem dalam sesi ini. Di Wayland, sandbox Flatpak menyembunyikan protokol yang digunakan untuk membaca gambar dari aplikasi lain. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Untuk menempel dari aplikasi lain, jalankan PhotoCraft di bawah XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Tidak ada yang ditempel: PhotoCraft tidak dapat membaca papan klip sistem di sini + Delete this set Hapus set ini + New Set… Set Baru… + PhotoCraft Defaults Bawaan PhotoCraft + Save a list of these shortcuts as a web page Simpan daftar pintasan ini sebagai halaman web + Save the changes to this set Simpan perubahan ke set ini + Save these shortcuts as a .kys file Simpan pintasan ini sebagai file .kys + Save these shortcuts as a new set Simpan pintasan ini sebagai set baru + Set deleted. Set dihapus. + Set saved. Set disimpan. + Set: Set: + Summarize… Ringkas… + There are too many sets: delete one first. Terlalu banyak set: hapus satu terlebih dahulu. + set name nama set + {set} (modified) {set} (diubah) + Random Acak + Show file name in title Tampilkan nama file di judul + Accept and Go to Conflict Terima dan Buka Konflik + Changes undone. Perubahan dibatalkan. + Choose a new shortcut for this command, or leave it without one. Pilih pintasan baru untuk perintah ini, atau biarkan tanpa pintasan. + Put this command's shortcuts back as they were Kembalikan pintasan perintah ini seperti semula + Take the shortcut from the other command and select it Ambil pintasan dari perintah lain dan pilih perintah itu + Undo Changes Batalkan Perubahan + {shortcut} is reserved by the system and can't be assigned. {shortcut} dicadangkan oleh sistem dan tidak dapat ditetapkan. + Low-Latency Drawing (vsync off, applies at next launch) Menggambar latensi rendah (tanpa vsync, berlaku saat peluncuran berikutnya) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Transformasi Bebas masih diterapkan; coba lagi setelah selesai. + The transform couldn't be applied. Transformasi tidak dapat diterapkan. +# 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, 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} + 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. + Decrease Brush Size Perkecil ukuran kuas + Increase Brush Size Perbesar ukuran kuas + Decrease Brush Hardness Kurangi kekerasan kuas + Increase Brush Hardness Tambah kekerasan kuas + Memory Usage Penggunaan memori + Per-document RAM limit (MB) Batas RAM per dokumen (MB) + Installed RAM: {ram} MB; this limit: {percent}% RAM terpasang: {ram} MB; batas ini: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Membatasi piksel plus Riwayat setiap dokumen terbuka. Status urungkan tertua dibuang jika batas terlampaui. + Applies immediately when saved. Memory is not reserved in advance. Berlaku segera setelah disimpan. Memori tidak dipesan di muka. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Jika Memori GPU Otomatis, ini juga membatasi anggaran GPU. Plafon seperempat RAM dan, jika diketahui, tiga per delapan memori kartu, minimum 512 MB. Menaikkan batas tidak menambah anggaran GPU. + Use Gunakan + In Front Di Depan + Behind Di Belakang + +# DDS Options (#1327) + DDS Options Opsi DDS + Compression Kompresi + Generate Mipmaps Buat mipmap diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c077d90aa1..1143b138f9 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -554,6 +554,9 @@ # Field Blur pins (#3373) No pin selected Nessuna puntina selezionata Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Fai clic sull’immagine per aggiungere una puntina; trascina una puntina per spostarla o il suo anello per cambiare la sfocatura. Canc rimuove la puntina selezionata. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Trascina le maniglie sul contorno per ridimensionarlo e ruotarlo, il quadrato per arrotondarne gli angoli e i punti interni per stabilire dove inizia la sfocatura. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Trascina la puntina per spostare la banda o il suo anello per cambiare la sfocatura. Trascina le linee continue per allargare la banda nitida, quelle tratteggiate per la transizione e i punti per ruotarla. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Trascina le maniglie sul contorno per ridimensionarlo e ruotarlo; l’anello imposta l’angolo di sfocatura. Iris Blur… Sfocatura diaframma… Tilt-Shift… Miniatura… Path Blur… Sfocatura tracciato… @@ -982,7 +985,6 @@ Contiguous Contiguo Contrast Contrasto Control: Controllo: - Convert document's colors to the working space Converti colori documento in spazio di lavoro Convert to sRGB Converti in sRGB Copies Copie Copper Rame @@ -1039,6 +1041,7 @@ Delete Group Elimina gruppo Delete Layer Mask Elimina maschera di livello Delete Layers Elimina livelli + Add Shortcut Aggiungi scorciatoia Delete Shortcut Elimina scorciatoia Delete Timeline Elimina timeline Delete Tool Preset Elimina predefinito strumento @@ -1564,7 +1567,6 @@ Use Default Usa predefinito Use Legacy Usa legacy Use Measurement Scale Usa scala misurazione - Use the embedded profile (instead of the working space) Usa profilo incorporato (invece dello spazio di lavoro) Variables bind a layer's visibility, text or pixels to a named data slot. Le variabili collegano visibilità, testo o pixel di un livello a uno slot dati nominato. Vertical scale Scala verticale Vibrance Vividezza @@ -1891,6 +1893,8 @@ Camera Raw Defaults Predefiniti Camera Raw Canvas border Bordo quadro Show canvas dots Mostra punti del quadro + Clip thumbnails to layer bounds Ritaglia le miniature ai limiti del livello + Clip Thumbnails to Layer Bounds Ritaglia le miniature ai limiti del livello Canvas color Colore quadro Canvas custom color Colore personalizzato quadro Color picker Selettore colore @@ -2082,6 +2086,8 @@ Fade steps Passi dissolvenza Hide the layer's effects ({key}-click: all layers) Nascondi effetti livello ({key}-clic: tutti i livelli) Import Brushes… (.abr) Importa pennelli… (.abr) + Import Brushes… (.abr or image) Importa pennelli… (.abr o immagine) + Import Brush from Image… Importa pennello da immagine… Move Group Down Sposta gruppo giù Move Group Up Sposta gruppo su Reset the brush to the defaults Reimposta pennello ai predefiniti @@ -2196,7 +2202,6 @@ Cancelled opening {name} Apertura {name} annullata The document “{name}” does not have an embedded {mode} profile. Il documento “{name}” non ha un profilo {mode} incorporato. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Il documento “{name}” ha un profilo colore incorporato che non corrisponde allo spazio di lavoro {mode} corrente. - Embedded: {embedded} Incorporato: {embedded} Working: {working} Di lavoro: {working} Developing {name} Sviluppo di {name} Cancelled {label} {label} annullato @@ -2507,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 @@ -2573,6 +2577,8 @@ fontWeight Italic Corsivo Color Picker (Layer Style Color) Selettore colore (colore dello stile livello) Auto Hide Notifications Nascondi automaticamente le notifiche Notification Duration (seconds) Durata della notifica (secondi) + Bundle Arrow-Key Nudges Raggruppa gli spostamenti con le frecce + Nudge Bundle Pause (ms) Pausa per raggruppare gli spostamenti (ms) Appearance Mode Modalità aspetto Dark Theme Tema scuro Light Theme Tema chiaro @@ -2699,7 +2705,24 @@ cameraData Lens Obiettivo Snap to Origin Aggancia all'origine Search settings… Cerca impostazioni… No matching settings Nessuna impostazione corrispondente +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Strumento Pennello di campionamento + Sampling Area Overlay Sovrapposizione area di campionamento + Show Sampling Area Mostra area di campionamento + Indicates Indica + Sampling Area Area di campionamento + Excluded Area Area esclusa + Sampling Area Options Opzioni area di campionamento + Rectangular Rettangolare + Fill Settings Impostazioni di riempimento + Rotation Adaptation Adattamento rotazione + Very High Molto alta + Full Completa + Mirror Rifletti + Output Settings Impostazioni di output + Output To Output in Loading fonts… Caricamento font… + Response curve Curva di risposta Tool Strumento Simulate Pressure Simula pressione Stroke Path… Traccia tracciato… @@ -4072,6 +4095,7 @@ navigator Zoom Zoom Remove after each stroke Rimuovi dopo ogni tratto Apply the removal ({key}) Applica la rimozione ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tratti da rimuovere: {n} (Invio applica, Esc annulla) + Show home recent files Mostra i file recenti nella schermata iniziale Clear Brush Controls Cancella controlli pennello Reset All Locked Settings Ripristina tutte le impostazioni bloccate Copy Texture to Other Tools Copia texture su altri strumenti @@ -4098,5 +4122,246 @@ navigator Zoom Zoom Action playback stopped Esecuzione dell’azione interrotta Step {step} is waiting for its dialog Il passaggio {step} attende la sua finestra di dialogo @plural {n} action|{n} actions {n} azione|{n} azioni + Background (Default) Sfondo (predefinito) +# Batch / Image Processor + Include All Subfolders Includi tutte le sottocartelle Auto Add/Delete Aggiungi/elimina automaticamente + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Non attivare anche Immagine speculare nelle impostazioni di stampa: le due inversioni si annullano. + Print Settings… Impostazioni di stampa… + Remember to turn off the printer's own colour management in Print Settings… Ricorda di disattivare la gestione del colore della stampante nelle impostazioni di stampa… + Sent to {printer} Inviato a {printer} + set in Print Settings… impostato nelle impostazioni di stampa… ICO is limited to 256 × 256 pixels ICO è limitato a 256 × 256 pixel +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Usa profilo incorporato ({profile}) + Convert to the working space ({profile}) Converti in spazio di lavoro ({profile}) + Remember this choice for future images Ricorda questa scelta per le prossime immagini + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Riattiva la richiesta con Ask On Mismatch in Modifica › Impostazioni colore. + Button Mode Modalità pulsante + Button Mode On Modalità pulsante attiva +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Scurisci interfaccia + Lighten Interface Schiarisci interfaccia +# Bevel & Emboss Technique. + Technique Tecnica + Chisel Hard Cesello duro + Chisel Soft Cesello morbido + Open color-less EXR as ACEScg Apri gli EXR senza spazio colore come ACEScg + +# Color management, Liquify, glyph group, warp and Camera Raw labels the Serbian, Vietnamese and Japanese catalogs added + Adobe-rgb-compat Adobe RGB (compatibile) + All shortcuts reset to Photoshop defaults. Tutte le scorciatoie ripristinate ai predefiniti di Photoshop. + Arc Lower Arco inferiore + Arc Upper Arco superiore + Arrows Frecce + Ask On Mismatch Chiedi se il profilo non corrisponde + Ask On Missing Chiedi se manca il profilo + Ask On Paste Chiedi quando incolli + Based On Basato su + Basic Latin Latino di base + Bend Curvatura + Blend Text Gamma Gamma per la fusione del testo + Bloat Gonfiamento + Bpc Compensazione punto nero + Coated-cmyk CMYK patinato + Convert Converti + Currency Valute + Cyrillic Cirillico + Default Photoshop Size Dimensione predefinita di Photoshop + Display-p3 Display P3 + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Trascina un riquadro ritaglio · trascina dentro per spostare · bordi ridimensionano ({ratio} proporzione, {centre} centro) · Spazio sposta durante disegno · {commit} conferma · Esc annulla + Drag: draw freehand · Release on the start point: close Trascina: disegna a mano libera · Rilascia sul punto iniziale: chiudi + Forward Warp Altera in avanti + Freeze Lasso Lazo congela + Freeze Mask Congela maschera + Gray-gamma-2.2 Grigio gamma 2,2 + Greek Greco + Horizontal Distortion Distorsione orizzontale + Import Photoshop brushes (.abr) Importa pennelli di Photoshop (.abr) + Intent Intento + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Crenatura: metrica, ottica o un valore in 1/1000 em (Alt+←/→ al cursore) + Latin 1 Latino 1 + Latin Extended-A Latino esteso A + Latin Extended-B Latino esteso B + Linear-srgb sRGB lineare + Mask Channel Canale maschera + Mask None Nessuna maschera + Math Symbols Simboli matematici + Monitor Profile Profilo monitor + Numbers Numeri + Pick Color From Image Preleva colore dall'immagine + Policy Cmyk Criterio CMYK + Policy Gray Criterio grigio + Policy Rgb Criterio RGB + Prophoto-compat ProPhoto RGB (compatibile) + Pucker Piega + Push Left Spingi a sinistra + R g b RGB + Rec2020 Rec. 2020 + Reconstruct All Ricostruisci tutto + Save Path Salva tracciato + Sgray sGray + Source Channel Canale sorgente + Source Invert Inverti sorgente + Source1 Channel Canale sorgente 1 + Source1 Invert Inverti sorgente 1 + Source2 Channel Canale sorgente 2 + Source2 Invert Inverti sorgente 2 + Temporary Temporanei + Thaw Mask Scongela maschera + Twirl Clockwise Vortice orario + Twirl Counterclockwise Vortice antiorario + Vertical Distortion Distorsione verticale + Working Cmyk CMYK di lavoro + Working Gray Grigio di lavoro + Working Rgb RGB di lavoro + blue blu + gray grigio + green verde + none nessuno + orange arancione + red rosso + violet viola + yellow giallo +# Shortcut commands, find and replace, profile prompts and metadata labels the German, Hungarian and Serbian catalogs added + Export As: {name} Esporta come: {name} + Color Sampler Tool Strumento Campionatore colore + Proof Setup… Configurazione prova… + Toggle Layer Mask Overlay Mostra/nascondi sovrapposizione maschera di livello + Increase Type Size Aumenta dimensione testo + Decrease Type Size Riduci dimensione testo + Increase Type Size by 10 Aumenta dimensione testo di 10 + Decrease Type Size by 10 Riduci dimensione testo di 10 + Make Selection from Path Crea selezione dal tracciato + Target Composite Channel Seleziona canale composito + Target Channel 3 Seleziona canale 3 + Target Channel 4 Seleziona canale 4 + Target Channel 5 Seleziona canale 5 + Target Channel 6 Seleziona canale 6 + Target Channel 7 Seleziona canale 7 + Target Channel 8 Seleziona canale 8 + Target Channel 9 Seleziona canale 9 + Fill with Foreground Color Riempi con colore primo piano + Fill with Background Color Riempi con colore sfondo + Fill with Foreground Color, Preserve Transparency Riempi con colore primo piano, conserva trasparenza + Fill with Background Color, Preserve Transparency Riempi con colore sfondo, conserva trasparenza + Decrease Brush Size Riduci dimensione pennello + Increase Brush Size Aumenta dimensione pennello + Decrease Brush Hardness Riduci durezza pennello + Increase Brush Hardness Aumenta durezza pennello + Space Barra spaziatrice + Hand Tool (hold) Strumento Mano (tieni premuto) + Zoom In (hold, drag to scrub) Ingrandisci (tieni premuto, trascina per lo zoom continuo) + Zoom Out (hold) Riduci (tieni premuto) + Erase with Brush or Pencil (hold) Cancella con Pennello o Matita (tieni premuto) + Author Title Qualifica autore + Copyright Url URL copyright + Title Titolo + Working-cmyk CMYK di lavoro + Simulate Paper Simula carta + Warming Filter (85) Filtro caldo (85) + Cw Orario + Ccw Antiorario + Nearest Vicino più prossimo + R G B RGB + W: L: + A: A: + Pattern Fill… Riempimento pattern… + Any Qualsiasi + Cmyk CMYK + Dont Enlarge Non ingrandire + Change Find Cambia e trova + Case Sensitive Maiuscole/minuscole + Whole Word Parola intera + Forward Avanti + Checkerboard Scacchiera + The document does not have an embedded colour profile. Working space: {working}. Il documento non ha un profilo colore incorporato. Spazio di lavoro: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Il documento ha un profilo colore incorporato che non corrisponde allo spazio di lavoro.\nIncorporato: {embedded}\nDi lavoro: {working} +@id edit.clear Cancella +@id file.close Chiudi +@id layer.layerMask.apply Applica +cameraRaw Balance Bilanciamento +cameraRaw Clarity Chiarezza +cameraRaw Color Mixer Miscelatore colore +cameraRaw Contrast Contrasto +cameraRaw Curve Curva +cameraRaw Dehaze Elimina foschia +cameraRaw Detail Dettaglio +cameraRaw Effects Effetti +cameraRaw Exposure Esposizione +cameraRaw Feather Sfumatura +cameraRaw Highlights Luci +cameraRaw Midpoint Punto medio +cameraRaw Midtones Mezzi toni +cameraRaw Radius Raggio +cameraRaw Roundness Rotondità +cameraRaw Saturation Saturazione +cameraRaw Shadows Ombre +cameraRaw Size Dimensione +cameraRaw Texture Texture +colorRange Invert Inverti +document Orientation Orientamento +layerLabel Magenta Magenta +layerLabel Red Rosso +layers Fill Riempimento +layers Fill: Riempimento: +shape Appearance Aspetto +# View › Guides › right-click a guide + Edit Selected Guides Modifica guide selezionate + Clear Selected Guides Cancella guide selezionate + Pasting from other apps is unavailable Incollare da altre app non è disponibile + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft non può accedere agli appunti di sistema in questa sessione. Su Wayland, la sandbox di Flatpak nasconde il protocollo con cui vengono lette le immagini delle altre app. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Per incollare da altre app, avvia PhotoCraft in XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Niente da incollare: qui PhotoCraft non può leggere gli appunti di sistema + Delete this set Elimina questo set + New Set… Nuovo set… + PhotoCraft Defaults Predefiniti di PhotoCraft + Save a list of these shortcuts as a web page Salva un elenco di queste scorciatoie come pagina web + Save the changes to this set Salva le modifiche in questo set + Save these shortcuts as a .kys file Salva queste scorciatoie come file .kys + Save these shortcuts as a new set Salva queste scorciatoie come nuovo set + Set deleted. Set eliminato. + Set saved. Set salvato. + Set: Set: + Summarize… Riepiloga… + There are too many sets: delete one first. Ci sono troppi set: eliminane prima uno. + set name nome del set + {set} (modified) {set} (modificato) + Show file name in title Mostra nome file nel titolo + Accept and Go to Conflict Accetta e vai al conflitto + Changes undone. Modifiche annullate. + Choose a new shortcut for this command, or leave it without one. Scegli una nuova scorciatoia per questo comando o lascialo senza. + Put this command's shortcuts back as they were Ripristina le scorciatoie di questo comando com'erano + Take the shortcut from the other command and select it Togli la scorciatoia all'altro comando e selezionalo + Undo Changes Annulla modifiche + {shortcut} is reserved by the system and can't be assigned. {shortcut} è riservata dal sistema e non può essere assegnata. + Low-Latency Drawing (vsync off, applies at next launch) Disegno a bassa latenza (senza vsync, si applica al prossimo avvio) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Trasformazione libera è ancora in corso di applicazione; riprova quando è terminata. + The transform couldn't be applied. Impossibile applicare la trasformazione. +# 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, 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} + 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. + Memory Usage Utilizzo memoria + Per-document RAM limit (MB) Limite RAM per documento (MB) + Installed RAM: {ram} MB; this limit: {percent}% RAM installata: {ram} MB; questo limite: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Limita i pixel più la Cronologia di ogni documento aperto. Gli stati Annulla più vecchi vengono scartati oltre il limite. + Applies immediately when saved. Memory is not reserved in advance. Si applica al salvataggio. La memoria non è riservata in anticipo. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Con Memoria GPU Automatica limita anche il budget GPU. È limitato a un quarto della RAM e, se nota, a tre ottavi della memoria della scheda, minimo 512 MB. Alzare il limite non aumenta il budget GPU. + Use Usa + In Front Davanti + Behind Dietro + +# DDS Options (#1327) + DDS Options Opzioni DDS + Generate Mipmaps Genera mipmap diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9f074e3e30..8564b9b308 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -556,6 +556,9 @@ # Field Blur pins (#3373) No pin selected ピンが選択されていません Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 画像をクリックしてピンを追加します。ピンをドラッグして移動し、リングをドラッグしてぼかしを変更します。Delete キーで選択したピンを削除します。 + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. 輪郭上のハンドルをドラッグしてサイズ変更と回転、四角形で角の丸み、内側の点でぼかしの開始位置を設定します。 + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. ピンをドラッグして帯を移動し、リングでぼかしを変更します。実線で鮮明な帯の幅、破線で移行範囲、点で回転を調整します。 + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. 輪郭上のハンドルをドラッグしてサイズ変更と回転を行います。リングでぼかしの角度を設定します。 Iris Blur… 虹彩絞りぼかし… Tilt-Shift… チルトシフト… Path Blur… パスぼかし… @@ -985,7 +988,6 @@ Contiguous 隣接 Contrast コントラスト Control: コントロール: - Convert document's colors to the working space ドキュメントの色を作業用スペースに変換 Convert to sRGB sRGB に変換 Copies 部数 Copper 銅 @@ -1040,6 +1042,7 @@ Delete Group グループを削除 Delete Layer Mask レイヤーマスクを削除 Delete Layers レイヤーを削除 + Add Shortcut ショートカットを追加 Delete Shortcut ショートカットを削除 Delete Timeline タイムラインを削除 Delete Tool Preset ツールプリセットを削除 @@ -1565,7 +1568,6 @@ Use Default 既定を使用 Use Legacy 従来方式を使用 Use Measurement Scale 測定スケールを使用 - Use the embedded profile (instead of the working space) 埋め込みプロファイルを使用 (作業用スペースの代わり) Variables bind a layer's visibility, text or pixels to a named data slot. 変数は、レイヤーの表示状態・テキスト・ピクセルを、名前付きのデータ枠に結び付けます。 Vertical scale 垂直比率 Vibrance 自然な彩度 @@ -1893,6 +1895,8 @@ Camera Raw Defaults Camera Raw の既定値 Canvas border カンバスの境界 Show canvas dots カンバスのドットを表示 + Clip thumbnails to layer bounds サムネールをレイヤーの境界でクリップ + Clip Thumbnails to Layer Bounds サムネールをレイヤーの境界でクリップ Canvas color カンバスの色 Canvas custom color カンバスのカスタム色 Color picker カラーピッカー @@ -2084,6 +2088,8 @@ Fade steps フェードのステップ数 Hide the layer's effects ({key}-click: all layers) レイヤーの効果を隠す ({key}+クリック: すべてのレイヤー) Import Brushes… (.abr) ブラシを読み込み…(.abr) + Import Brushes… (.abr or image) ブラシを読み込み…(.abr または画像) + Import Brush from Image… 画像からブラシを読み込み… Move Group Down グループを下へ移動 Move Group Up グループを上へ移動 Reset the brush to the defaults ブラシを既定に戻す @@ -2194,7 +2200,6 @@ Cancelled opening {name} {name} を開くのをキャンセルしました The document “{name}” does not have an embedded {mode} profile. ドキュメント「{name}」には埋め込み {mode} プロファイルがありません。 The document “{name}” has an embedded color profile that does not match the current {mode} working space. ドキュメント「{name}」の埋め込みカラープロファイルは、現在の {mode} 作業用スペースと一致しません。 - Embedded: {embedded} 埋め込み: {embedded} Working: {working} 作業用スペース: {working} Developing {name} {name} を現像中 Cancelled {label} {label} をキャンセルしました @@ -2408,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 特細 @@ -3517,6 +3521,8 @@ fontWeight Italic 斜体 Color Picker (Layer Style Color) カラーピッカー (レイヤースタイルの色) Auto Hide Notifications 通知を自動的に隠す Notification Duration (seconds) 通知の表示時間 (秒) + Bundle Arrow-Key Nudges 矢印キーの移動をまとめる + Nudge Bundle Pause (ms) 移動をまとめる間隔(ms) Appearance Mode 外観モード Dark Theme ダークテーマ Light Theme ライトテーマ @@ -3640,7 +3646,24 @@ cameraData Lens レンズ Snap to Origin 原点にスナップ Search settings… 設定を検索… No matching settings 一致する設定はありません +# Edit › Content-Aware Fill workspace + Sampling Brush Tool サンプリングブラシツール + Sampling Area Overlay サンプリング領域のオーバーレイ + Show Sampling Area サンプリング領域を表示 + Indicates 表示対象 + Sampling Area サンプリング領域 + Excluded Area 除外された領域 + Sampling Area Options サンプリング領域オプション + Rectangular 長方形 + Fill Settings 塗りつぶし設定 + Rotation Adaptation 回転への適応 + Very High 非常に高い + Full フル + Mirror ミラー + Output Settings 出力設定 + Output To 出力先 Loading fonts… フォントを読み込み中… + Response curve 応答カーブ Simulate Pressure 筆圧をシミュレート Stroke Path… パスのストローク… Background Eraser 背景消しゴム @@ -3718,7 +3741,8 @@ curves Smooth スムーズ No object found there — drag a rectangle around it そこにオブジェクトが見つかりません — 周囲に矩形をドラッグしてください Remove after each stroke ストロークごとに削除 Apply the removal ({key}) 削除を適用 ({key}) - Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n} (Enter で適用、Esc で取り消し) + Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n}(Enter で適用、Esc で取り消し) + Show home recent files ホーム画面に最近使ったファイルを表示 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… アクションを読み込み… @@ -3742,6 +3766,9 @@ curves Smooth スムーズ Action playback stopped アクションの再生を停止しました Step {step} is waiting for its dialog ステップ {step} はダイアログを待っています @plural {n} action|{n} actions アクション {n} 個 + Background (Default) 背景(既定) +# Batch / Image Processor + Include All Subfolders すべてのサブフォルダーを含める # Liquify tool names (shown via tl!(tool.label()) in the UI, so they are not found by the static scan) Forward Warp 前方ワープ @@ -3809,9 +3836,93 @@ curves Smooth スムーズ Vector Mask Hides Effects ベクトルマスクで効果を隠す Auto Add/Delete 自動追加・削除 +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. 印刷設定の「鏡像」も有効にしないでください。2回反転すると元に戻ります。 + Print Settings… 印刷設定… + Remember to turn off the printer's own colour management in Print Settings… 印刷設定でプリンター独自のカラーマネジメントを無効にしてください… + Sent to {printer} {printer} に送信しました + set in Print Settings… 印刷設定で指定… # Restored rows (custom print paper #3820, brush presets, Vanishing Point help, brush panel menu) Drag to pan · Scroll to zoom · Double-click to fit ドラッグで移動 · スクロールでズーム · ダブルクリックで全体表示 Clear Brush Controls ブラシのコントロールを消去 Reset All Locked Settings ロックされた設定をすべて初期化 Copy Texture to Other Tools テクスチャを他のツールにコピー ICO is limited to 256 × 256 pixels ICO は 256 × 256 ピクセルまでです +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) 埋め込みプロファイルを使用 ({profile}) + Convert to the working space ({profile}) 作業用スペースに変換 ({profile}) + Remember this choice for future images 今後の画像でもこの選択を使用 + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. 確認を再び表示するには、編集 › カラー設定 の Ask On Mismatch をオンにします。 + Button Mode ボタンモード + Button Mode On ボタンモード オン +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface インターフェイスを暗くする + Lighten Interface インターフェイスを明るくする +# Bevel & Emboss Technique. + Technique テクニック + Chisel Hard チゼルハード + Chisel Soft チゼルソフト + Open color-less EXR as ACEScg 色空間のないEXRをACEScgとして開く +# View › Guides › right-click a guide + Edit Selected Guides 選択したガイドを編集 + Clear Selected Guides 選択したガイドを消去 + Pasting from other apps is unavailable 他のアプリからの貼り付けは利用できません + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. このセッションでは PhotoCraft がシステムのクリップボードにアクセスできません。Wayland では、Flatpak のサンドボックスが他のアプリの画像を読み取るためのプロトコルを隠します。 + To paste from other apps, start PhotoCraft under XWayland: `{command}` 他のアプリから貼り付けるには、XWayland 上で PhotoCraft を起動してください: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here 貼り付けるものがありません: ここでは PhotoCraft がシステムのクリップボードを読み取れません + Delete this set このセットを削除 + New Set… 新規セット… + PhotoCraft Defaults PhotoCraft 初期設定 + Save a list of these shortcuts as a web page これらのショートカットの一覧を Web ページとして保存 + Save the changes to this set このセットへの変更を保存 + Save these shortcuts as a .kys file これらのショートカットを .kys ファイルとして保存 + Save these shortcuts as a new set これらのショートカットを新規セットとして保存 + Set deleted. セットを削除しました。 + Set saved. セットを保存しました。 + Set: セット: + Summarize… 概要… + There are too many sets: delete one first. セットが多すぎます。先に 1 つ削除してください。 + set name セット名 + {set} (modified) {set}(変更あり) + Show file name in title タイトルにファイル名を表示 + Accept and Go to Conflict 適用して競合へ移動 + Changes undone. 変更を取り消しました。 + Choose a new shortcut for this command, or leave it without one. このコマンドに新しいショートカットを選択するか、なしのままにしてください。 + Put this command's shortcuts back as they were このコマンドのショートカットを元に戻す + Take the shortcut from the other command and select it もう一方のコマンドからショートカットを外して選択 + Undo Changes 変更を取り消し + {shortcut} is reserved by the system and can't be assigned. {shortcut} はシステムで予約されているため割り当てられません。 + Low-Latency Drawing (vsync off, applies at next launch) 低遅延描画(垂直同期オフ、次回起動時に適用) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. 自由変形を適用中です。完了してからもう一度お試しください。 + The transform couldn't be applied. 変形を適用できませんでした。 +# 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, 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} + 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. この書類で置き換えるか、キャンセルしてファイル › 名前を付けて保存で両方を残してください。 + Decrease Brush Size ブラシサイズを小さく + Increase Brush Size ブラシサイズを大きく + Decrease Brush Hardness ブラシの硬さを下げる + Increase Brush Hardness ブラシの硬さを上げる + Memory Usage メモリ使用量 + Per-document RAM limit (MB) ドキュメントあたりの RAM 上限 (MB) + Installed RAM: {ram} MB; this limit: {percent}% 搭載 RAM: {ram} MB、この上限: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. 開いている各ドキュメントのピクセルと履歴を制限します。上限を超えると最も古い取り消し状態が破棄されます。 + Applies immediately when saved. Memory is not reserved in advance. 保存するとすぐに適用されます。メモリは事前に予約されません。 + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. GPU メモリが自動のとき、GPU レイヤー予算も制限します。上限は搭載 RAM の 1/4、判明していればグラフィックメモリの 3/8、最低 512 MB です。これ以上に上げても GPU 予算は増えません。 + Use 使用 + In Front 前面 + Behind 背面 + +# DDS Options (#1327) + DDS Options DDS オプション + Compression 圧縮 + Generate Mipmaps ミップマップを生成 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index eefa162a65..b99fde8236 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -507,7 +507,7 @@ Save Default Type Styles 기본 문자 스타일 저장 Deselect 선택 해제 Reselect 다시 선택 - Inverse 반대로 + Inverse 반전 Inverse Selection 선택 영역 반전 Similar 유사한 항목 Color Range… 색상 범위… @@ -543,6 +543,9 @@ # Field Blur pins (#3373) No pin selected 선택한 핀 없음 Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 이미지를 클릭하여 핀을 추가합니다. 핀을 드래그하여 이동하고, 링을 드래그하여 흐림 정도를 바꿉니다. Delete 키로 선택한 핀을 삭제합니다. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. 윤곽선의 핸들을 드래그하여 크기를 바꾸고 회전하고, 사각형으로 모서리를 둥글게 하며, 안쪽 점으로 흐림이 시작되는 위치를 설정합니다. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. 핀을 드래그하여 띠를 이동하고 링을 드래그하여 흐림 정도를 바꿉니다. 실선으로 선명한 띠를 넓히고, 점선으로 전환 범위를, 점으로 회전을 조정합니다. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. 윤곽선의 핸들을 드래그하여 크기를 바꾸고 회전합니다. 링으로 흐림 각도를 설정합니다. Iris Blur… 조리개 흐림 효과… Tilt-Shift… 기울기-이동… Path Blur… 패스 흐림 효과… @@ -740,6 +743,7 @@ Brush Settings 브러시 설정 Brushes 브러시 Channels 채널 + Channel 채널 Character 문자 Character Styles 문자 스타일 Clone Source 복제 원본 @@ -961,7 +965,6 @@ Contiguous 인접 Contrast 대비 Control: 제어: - Convert document's colors to the working space 문서 색상을 작업 색상 공간으로 변환 Convert to sRGB sRGB로 변환 Copies 부수 Copper 구리 @@ -1018,6 +1021,7 @@ Delete Group 그룹 삭제 Delete Layer Mask 레이어 마스크 삭제 Delete Layers 레이어 삭제 + Add Shortcut 단축키 추가 Delete Shortcut 단축키 삭제 Delete Timeline 타임라인 삭제 Delete Tool Preset 도구 사전 설정 삭제 @@ -1207,6 +1211,7 @@ Levels 레벨 Lighten 밝게 하기 Lightness 명도 + Result 결과 Lights 밝은 계열 Limits: 제한: Linear Gradient 선형 그레이디언트 @@ -1396,10 +1401,10 @@ Imported {n} shortcuts from {file}. Click OK to keep them. {file}에서 단축키 {n}개를 가져왔습니다. 유지하려면 확인을 클릭하세요. {n} commands are not in PhotoCraft. 명령 {n}개는 PhotoCraft에 없습니다. Keyboard shortcuts imported 키보드 단축키를 가져왔습니다 - Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. 키보드 단축키 세트 {set}을(를) 여기서 사용합니다. 단축키 {n}개가 기본값과 다릅니다. 편집 › 키보드 단축키에서 확인할 수 있고, 모두 기본값으로 재설정으로 되돌릴 수 있습니다. - Import keyboard shortcuts? 키보드 단축키를 가져올까요? - PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft가 이 컴퓨터에서 다른 이미지 편집기의 키보드 단축키 세트 {set}을(를) 찾았습니다. 단축키 {n}개가 PhotoCraft와 다릅니다. 가져올까요? - Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. 편집 › 키보드 단축키 › 모두 기본값으로 재설정으로 가져오기를 되돌릴 수 있습니다. + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. 키보드 단축키 세트 {set}을(를) 여기서 사용합니다. 단축키 {n}개가 기본값과 다릅니다. 편집 › 키보드 단축키에서 확인할 수 있고, 모두 기본값으로 초기화로 되돌릴 수 있습니다. + Import keyboard shortcuts? 키보드 단축키를 가져오시겠습니까? + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft가 이 컴퓨터에서 다른 이미지 편집기의 키보드 단축키 세트 {set}을(를) 찾았습니다. 단축키 {n}개가 PhotoCraft와 다릅니다. 가져오시겠습니까? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. 편집 › 키보드 단축키 › 모두 기본값으로 초기화를 누르면 가져오기를 되돌릴 수 있습니다. Don't Import 가져오지 않음 The keyboard shortcut set was not imported: {error} 키보드 단축키 세트를 가져오지 못했습니다: {error} Reset Section 섹션 초기화 @@ -1539,7 +1544,6 @@ Use Default 기본값 사용 Use Legacy 기존 방식 사용 Use Measurement Scale 측정 비율 사용 - Use the embedded profile (instead of the working space) 포함된 프로파일 사용(작업 색상 공간 대신) Variables bind a layer's visibility, text or pixels to a named data slot. 변수는 레이어의 표시 여부, 텍스트 또는 픽셀을 이름이 지정된 데이터 슬롯에 연결합니다. Vertical scale 세로 비율 Vibrance 활기 @@ -1815,8 +1819,8 @@ Zoom Tool 돋보기 도구 {name} Properties {name} 속성 Revert “{name}” to the last saved version? Your changes will be lost. “{name}”을(를) 마지막으로 저장한 버전으로 되돌릴까요? 변경 사항이 사라집니다. - Do you want to save the changes you made to “{name}” before quitting? 종료하기 전에 “{name}”의 변경 사항을 저장할까요? - Do you want to save the changes you made to “{name}” before closing? 닫기 전에 “{name}”의 변경 사항을 저장할까요? + Do you want to save the changes you made to “{name}” before quitting? 종료하기 전에 “{name}”의 변경 사항을 저장하시겠습니까? + Do you want to save the changes you made to “{name}” before closing? 닫기 전에 “{name}”의 변경 사항을 저장하시겠습니까? Doc: {flat}/{layered} 문서: {flat}/{layered} Scratch: {size} 스크래치: {size} Untagged {mode} 프로파일 없는 {mode} @@ -1859,6 +1863,8 @@ Camera Raw Defaults Camera Raw 기본값 Canvas border 캔버스 테두리 Show canvas dots 캔버스 점 표시 + Clip thumbnails to layer bounds 썸네일을 레이어 경계로 자르기 + Clip Thumbnails to Layer Bounds 썸네일을 레이어 경계로 자르기 Canvas color 캔버스 색상 Canvas custom color 캔버스 사용자 정의 색상 Color picker 색상 피커 @@ -2045,7 +2051,8 @@ Expand group ({key}-click: all groups) 그룹 펼치기 ({key} 키를 누른 채 클릭: 모든 그룹) Fade steps 약화 단계 Hide the layer's effects ({key}-click: all layers) 레이어 효과 숨기기 ({key} 키를 누른 채 클릭: 모든 레이어) - Import Brushes… (.abr) 브러시 가져오기… (.abr) + Import Brushes… (.abr or image) 브러시 가져오기… (.abr 또는 이미지) + Import Brush from Image… 이미지에서 브러시 가져오기… Move Group Down 그룹 아래로 이동 Move Group Up 그룹 위로 이동 Reset the brush to the defaults 브러시를 기본값으로 초기화 @@ -2122,12 +2129,12 @@ The current gradient (pick one in Window › Gradients) 현재 그레이디언트(창 › 그레이디언트에서 선택) Select a type layer to edit its character and paragraph settings. 문자 및 단락 설정을 편집하려면 문자 레이어를 선택하세요. # Gradient Editor window, opened from the options bar's gradient swatch. - Gradient Editor 그라디언트 편집기 - Gradient picker 그라디언트 선택기 - Save As Preset 프리셋으로 저장 - The gradient the Gradient tool paints with 그라디언트 도구가 칠하는 그라디언트입니다 - Adds the current gradient to the Gradients panel 현재 그라디언트를 그라디언트 패널에 추가합니다 - Gradient stops 그라디언트 스톱 + Gradient Editor 그레이디언트 편집기 + Gradient picker 그레이디언트 선택기 + Save As Preset 사전 설정으로 저장 + The gradient the Gradient tool paints with 그레이디언트 도구가 칠하는 그레이디언트입니다 + Adds the current gradient to the Gradients panel 현재 그레이디언트를 그레이디언트 패널에 추가합니다 + Gradient stops 그레이디언트 스톱 Text color 텍스트 색상 Type Options 문자 옵션 Anti-aliasing method 앤티 앨리어스 방식 @@ -2155,7 +2162,6 @@ Cancelled opening {name} {name} 열기를 취소했습니다 The document “{name}” does not have an embedded {mode} profile. 문서 “{name}”에 포함된 {mode} 프로파일이 없습니다. The document “{name}” has an embedded color profile that does not match the current {mode} working space. 문서 “{name}”에 포함된 색상 프로파일이 현재 {mode} 작업 색상 공간과 일치하지 않습니다. - Embedded: {embedded} 포함됨: {embedded} Working: {working} 작업 색상 공간: {working} Developing {name} {name} 현상 중 Cancelled {label} {label} 작업을 취소했습니다 @@ -3018,7 +3024,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 더 가늘게 @@ -3038,7 +3043,7 @@ fontWeight Italic 기울임 Rotate View 보기 회전 Rotate View Tool 보기 회전 도구 Reset View 보기 재설정 - Drag around the centre to rotate the view · Shift constrains to 15° 중심 주위를 드래그해 보기를 회전합니다 · Shift는 15°로 제한 + Drag around the centre to rotate the view · Shift constrains to 15° 중심 주위를 드래그해 보기 회전 · Shift로 15° 단위 제한 # Painting symmetry presets. Dual Axis 이중 축 @@ -3067,7 +3072,7 @@ fontWeight Italic 기울임 PhotoCraft — Screen Color Picker PhotoCraft — 화면 색상 선택기 Rasterize svg groups deeper than 이 깊이보다 깊은 SVG 그룹 래스터화 - 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0이면 이 임계값을 끕니다. 100단계보다 깊이 중첩된 SVG 그룹은 항상 래스터화됩니다. + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0이면 이 한계값을 끕니다. 100단계보다 깊이 중첩된 SVG 그룹은 항상 래스터화됩니다. Rule of Thirds 삼등분 법칙 Golden Ratio 황금 비율 Golden Spiral 황금 나선 @@ -3083,6 +3088,8 @@ fontWeight Italic 기울임 Color Picker (Layer Style Color) 색상 피커 (레이어 스타일 색상) Auto Hide Notifications 알림 자동 숨기기 Notification Duration (seconds) 알림 지속 시간(초) + Bundle Arrow-Key Nudges 방향키 이동을 하나로 묶기 + Nudge Bundle Pause (ms) 이동 묶음 대기 시간(ms) Appearance Mode 화면 모드 Dark Theme 어두운 테마 Light Theme 밝은 테마 @@ -3135,15 +3142,15 @@ fontWeight Italic 기울임 Dashed line 파선 Dash 선 Gap 간격 - Dash / gap lengths × stroke width 선 / 간격 길이 × 획 너비 + Dash / gap lengths × stroke width 선 / 간격 길이 × 획 폭 Add pair 쌍 추가 Remove pair 쌍 제거 Dash offset 파선 오프셋 Open paths use centered strokes 열린 패스는 가운데 획을 사용합니다 Use finite lengths and a positive pattern total 유한한 길이와 양수 패턴 합계를 사용하세요 - No subject found — try Object Selection or the Lasso 피사체를 찾을 수 없습니다 — 개체 선택 또는 올가미를 사용해 보세요 + No subject found — try Object Selection or the Lasso 피사체를 찾을 수 없습니다 — 오브젝트 선택 또는 올가미를 사용해 보세요 Quick Selection found nothing there 빠른 선택으로 해당 위치에서 아무것도 찾지 못했습니다 - Object Selection found nothing there — try a tighter rectangle 개체 선택으로 해당 위치에서 아무것도 찾지 못했습니다 — 더 좁은 사각형을 사용해 보세요 + Object Selection found nothing there — try a tighter rectangle 오브젝트 선택으로 해당 위치에서 아무것도 찾지 못했습니다 — 더 좁은 사각형을 사용해 보세요 No subject found — try a rectangle around the object 피사체를 찾을 수 없습니다 — 개체 주위에 사각형을 그려 보세요 Auto Erase 자동 지우기 Click the first point or press {key} to close · {del} removes a point · Esc cancels 첫 점을 클릭하거나 {key}를 눌러 닫기 · {del}로 점 제거 · Esc로 취소 @@ -3214,7 +3221,24 @@ cameraData Lens 렌즈 Snap to Origin 원점에 스냅 Search settings… 설정 검색… No matching settings 일치하는 설정 없음 +# Edit › Content-Aware Fill workspace + Sampling Brush Tool 샘플링 브러시 도구 + Sampling Area Overlay 샘플링 영역 오버레이 + Show Sampling Area 샘플링 영역 표시 + Indicates 표시 대상 + Sampling Area 샘플링 영역 + Excluded Area 제외 영역 + Sampling Area Options 샘플링 영역 옵션 + Rectangular 사각형 + Fill Settings 채우기 설정 + Rotation Adaptation 회전 적응 + Very High 매우 높음 + Full 전체 + Mirror 미러 + Output Settings 출력 설정 + Output To 출력 위치 Loading fonts… 글꼴 불러오는 중… + Response curve 응답 곡선 Simulate Pressure 압력 시뮬레이션 Stroke Path… 패스 획… Background Eraser 배경 지우개 @@ -3284,6 +3308,7 @@ curves Smooth 매끄럽게 Remove after each stroke 획마다 제거 Apply the removal ({key}) 제거 적용 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 제거할 획: {n} (Enter 적용, Esc 취소) + Show home recent files 홈 화면에 최근 파일 표시 Clear Brush Controls 브러시 제어 지우기 Reset All Locked Settings 모든 잠긴 설정 재설정 Copy Texture to Other Tools 다른 도구로 텍스처 복사 @@ -3310,5 +3335,99 @@ curves Smooth 매끄럽게 Action playback stopped 액션 재생이 중지되었습니다 Step {step} is waiting for its dialog {step}단계가 대화 상자를 기다리고 있습니다 @plural {n} action|{n} actions 액션 {n}개 + Background (Default) 배경(기본값) +# Batch / Image Processor + Include All Subfolders 모든 하위 폴더 포함 Auto Add/Delete 자동 추가/삭제 + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. 인쇄 설정에서도 이미지 반전을 켜지 마세요. 두 번 반전하면 원래대로 돌아갑니다. + Print Settings… 인쇄 설정… + Remember to turn off the printer's own colour management in Print Settings… 인쇄 설정에서 프린터 자체의 색상 관리를 끄세요… + Sent to {printer} {printer}(으)로 전송됨 + set in Print Settings… 인쇄 설정에서 지정… ICO is limited to 256 × 256 pixels ICO는 256 × 256픽셀로 제한됩니다 +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) 포함된 프로파일 사용({profile}) + Convert to the working space ({profile}) 작업 색상 공간으로 변환({profile}) + Remember this choice for future images 이후 이미지에도 이 선택 기억 + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. 편집 › 색상 설정의 Ask On Mismatch로 이 확인을 다시 켤 수 있습니다. + Button Mode 단추 모드 + Button Mode On 단추 모드 켜짐 + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface 인터페이스 어둡게 + Lighten Interface 인터페이스 밝게 +# Bevel & Emboss Technique. + Technique 기법 + Chisel Hard 단단하게 조각 + Chisel Soft 부드럽게 조각 + Open color-less EXR as ACEScg 색 공간 정보 없는 EXR을 ACEScg로 열기 +# Export As metadata and profile options + Color Profile 색상 프로필 + EXIF EXIF + +# View › Guides › right-click a guide + Edit Selected Guides 선택한 안내선 편집 + Clear Selected Guides 선택한 안내선 지우기 + Pasting from other apps is unavailable 다른 앱에서 붙여넣기를 사용할 수 없습니다 + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. 이 세션에서는 PhotoCraft가 시스템 클립보드에 접근할 수 없습니다. Wayland에서는 Flatpak 샌드박스가 다른 앱의 이미지를 읽는 데 쓰이는 프로토콜을 숨깁니다. + To paste from other apps, start PhotoCraft under XWayland: `{command}` 다른 앱에서 붙여넣으려면 XWayland에서 PhotoCraft를 실행하세요: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here 붙여넣을 내용 없음: 여기서는 PhotoCraft가 시스템 클립보드를 읽을 수 없습니다 + Delete this set 이 세트 삭제 + New Set… 새 세트… + PhotoCraft Defaults PhotoCraft 기본값 + Save a list of these shortcuts as a web page 이 단축키 목록을 웹 페이지로 저장 + Save the changes to this set 이 세트에 변경 사항 저장 + Save these shortcuts as a .kys file 이 단축키를 .kys 파일로 저장 + Save these shortcuts as a new set 이 단축키를 새 세트로 저장 + Set deleted. 세트를 삭제했습니다. + Set saved. 세트를 저장했습니다. + Set: 세트: + Summarize… 요약… + There are too many sets: delete one first. 세트가 너무 많습니다. 먼저 하나를 삭제하세요. + set name 세트 이름 + {set} (modified) {set}(수정됨) + Show file name in title 제목에 파일 이름 표시 + Accept and Go to Conflict 적용 후 충돌 항목으로 이동 + Changes undone. 변경 사항을 취소했습니다. + Choose a new shortcut for this command, or leave it without one. 이 명령의 새 단축키를 선택하거나 단축키 없이 두세요. + Put this command's shortcuts back as they were 이 명령의 단축키를 원래대로 되돌리기 + Take the shortcut from the other command and select it 다른 명령에서 단축키를 빼고 그 명령 선택 + Undo Changes 변경 사항 취소 + {shortcut} is reserved by the system and can't be assigned. {shortcut}은(는) 시스템에서 예약되어 할당할 수 없습니다. + Low-Latency Drawing (vsync off, applies at next launch) 저지연 그리기(수직 동기화 끔, 다음 실행 시 적용) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. 자유 변형을 아직 적용하는 중입니다. 완료된 후 다시 시도하세요. + The transform couldn't be applied. 변형을 적용할 수 없습니다. +# 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, 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} + 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. 이 문서로 대치하거나, 취소하고 파일 › 다른 이름으로 저장을 사용해 둘 다 보관하세요. + Decrease Brush Size 브러시 크기 줄이기 + Increase Brush Size 브러시 크기 늘리기 + Decrease Brush Hardness 브러시 경도 줄이기 + Increase Brush Hardness 브러시 경도 늘리기 + Memory Usage 메모리 사용량 + Per-document RAM limit (MB) 문서당 RAM 한도(MB) + Installed RAM: {ram} MB; this limit: {percent}% 설치된 RAM: {ram} MB; 이 한도: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. 열린 각 문서의 픽셀과 기록을 제한합니다. 한도를 넘으면 가장 오래된 실행 취소 상태가 버려집니다. + Applies immediately when saved. Memory is not reserved in advance. 저장하면 바로 적용됩니다. 메모리를 미리 예약하지 않습니다. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. GPU 메모리가 자동이면 GPU 레이어 예산도 제한합니다. 상한은 설치 RAM의 1/4, 알려진 경우 그래픽 메모리의 3/8, 최소 512MB입니다. 이보다 올려도 GPU 예산은 늘지 않습니다. + Use 사용 + In Front 앞 + Behind 뒤 + Blend 혼합 + +# DDS Options (#1327) + DDS Options DDS 옵션 + Compression 압축 + Generate Mipmaps 밉맵 생성 diff --git a/crates/ui-egui/src/i18n/live_tests.rs b/crates/ui-egui/src/i18n/live_tests.rs index 89c47ef1ac..7d8d6457ef 100644 --- a/crates/ui-egui/src/i18n/live_tests.rs +++ b/crates/ui-egui/src/i18n/live_tests.rs @@ -42,6 +42,46 @@ fn ukrainian_native_app_menu_keeps_product_and_language_names() { }); } +#[test] +fn ukrainian_color_picker_titles_draw_in_the_selected_language() { + with_language(Lang::EN, || { + for (target, expected) in [("foreground", "Вибір кольору (основний колір)"), ("background", "Вибір кольору (колір тла)")] + { + let mut h = harness(); + h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "uk"})).unwrap(); + crate::color_picker_ui::open(h.state_mut(), target); + h.run_steps(4); + let text = drawn_text(&h); + assert!(text.iter().any(|text| text == expected), "missing title {expected}"); + assert!(!text.iter().any(|text| text.starts_with("Color Picker")), "no English picker title"); + } + assert_eq!(tr(lang("uk"), "Color Picker (Custom Canvas Color)"), "Вибір кольору (власний колір полотна)"); + }); +} + +#[test] +fn ukrainian_rasterize_prompts_explain_the_loss_of_editable_content() { + use crate::rasterize_prompt::Kind; + + for kind in Kind::ALL { + let translated = tr(lang("uk"), kind.message()); + assert_ne!(translated, kind.message(), "{kind:?}: the warning must be translated"); + assert!(translated.contains("більше не можна буде редагувати"), "{kind:?}: explain the loss of editability"); + } + with_language(Lang::EN, || { + let mut h = harness(); + h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "uk"})).unwrap(); + h.state_mut().run("type.create", json!({"text": "Editable text"})).unwrap(); + let layer = h.state().session.active().unwrap().active_layer.unwrap(); + crate::rasterize_prompt::open(h.state_mut(), Kind::Type, layer, crate::state::Tool::Brush, [10.0, 10.0, 1.0]); + h.run_steps(4); + let expected = tr(lang("uk"), Kind::Type.message()); + let text = drawn_text(&h); + assert!(text.iter().any(|text| text == expected), "the actual dialog draws the Ukrainian warning"); + assert!(!text.iter().any(|text| text == Kind::Type.message())); + }); +} + #[test] fn french_and_korean_plural_messages_keep_placeholders() { for (n, expected) in [(0, "0 élément"), (1, "1 élément"), (2, "2 éléments"), (21, "21 éléments")] { diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index b976e395fc..c7a0d574b2 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -514,6 +514,21 @@ mod tests { assert_eq!(Lang::from_pref("xx-unknown"), Lang::EN); } + /// The brush size/hardness shortcut rows (`tools.*` commands without a menu item) must be translated in + /// every catalog, or they show English in the Keyboard Shortcuts dialog and cannot be found by searching. + #[test] + fn brush_size_and_hardness_shortcuts_are_translated_in_every_language() { + let keys = ["Decrease Brush Size", "Increase Brush Size", "Decrease Brush Hardness", "Increase Brush Hardness"]; + for lang in Lang::all().filter(|lang| *lang != Lang::EN) { + for key in keys { + assert!(has(lang, key), "{} has no translation for {key:?}", lang.code()); + } + } + let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); + assert_eq!(tr(zh, "Decrease Brush Size"), "减小画笔大小"); + assert_eq!(tr(zh, "Increase Brush Size"), "增大画笔大小"); + } + #[test] fn simplified_chinese_covers_dynamic_shortcuts_and_layer_counts() { let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 68e98a6fef..791d173cb0 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -546,6 +546,9 @@ # Field Blur pins (#3373) No pin selected Geen speld geselecteerd Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klik in de afbeelding om een speld toe te voegen; sleep een speld om hem te verplaatsen, of zijn ring om de vervaging te wijzigen. Delete verwijdert de geselecteerde speld. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Sleep de grepen op de omtrek om hem te schalen en te draaien, het vierkantje om de hoeken af te ronden en de punten binnenin om in te stellen waar de vervaging begint. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Sleep de speld om de band te verplaatsen, of zijn ring om de vervaging te wijzigen. Sleep de doorgetrokken lijnen om de scherpe band te verbreden, de stippellijnen voor de overgang en de punten om hem te draaien. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Sleep de grepen op de omtrek om hem te schalen en te draaien; de ring stelt de vervagingshoek in. Iris Blur… Irisvervaging… Tilt-Shift… Tilt-shift… Path Blur… Padvervaging… @@ -964,7 +967,6 @@ Contiguous Aaneengesloten Contrast Contrast Control: Besturing: - Convert document's colors to the working space Kleuren van document omzetten naar de werkruimte Convert to sRGB Omzetten naar sRGB Copies Exemplaren Copper Koper @@ -1021,6 +1023,7 @@ Delete Group Groep verwijderen Delete Layer Mask Laagmasker verwijderen Delete Layers Lagen verwijderen + Add Shortcut Sneltoets toevoegen Delete Shortcut Sneltoets verwijderen Delete Timeline Tijdlijn verwijderen Delete Tool Preset Gereedschapsvoorinstelling verwijderen @@ -1545,7 +1548,6 @@ Use Default Standaard gebruiken Use Legacy Verouderde versie gebruiken Use Measurement Scale Meetschaal gebruiken - Use the embedded profile (instead of the working space) Het ingesloten profiel gebruiken (in plaats van de werkruimte) Variables bind a layer's visibility, text or pixels to a named data slot. Variabelen koppelen de zichtbaarheid, tekst of pixels van een laag aan een benoemd gegevensveld. Vertical scale Verticale schaal Vibrance Levendigheid @@ -1865,6 +1867,8 @@ Camera Raw Defaults Standaardwaarden Camera Raw Canvas border Canvasrand Show canvas dots Canvasstippen tonen + Clip thumbnails to layer bounds Miniaturen bijsnijden tot laaggrenzen + Clip Thumbnails to Layer Bounds Miniaturen bijsnijden tot laaggrenzen Canvas color Canvaskleur Canvas custom color Aangepaste canvaskleur Color picker Kleurkiezer @@ -2051,7 +2055,8 @@ Expand group ({key}-click: all groups) Groep uitvouwen ({key}-klik: alle groepen) Fade steps Afzwakstappen Hide the layer's effects ({key}-click: all layers) De effecten van de laag verbergen ({key}-klik: alle lagen) - Import Brushes… (.abr) Penselen importeren… (.abr) + Import Brushes… (.abr or image) Penselen importeren… (.abr of afbeelding) + Import Brush from Image… Penseel uit afbeelding importeren… Move Group Down Groep omlaag Move Group Up Groep omhoog Reset the brush to the defaults Het penseel terugzetten naar de standaardwaarden @@ -2162,7 +2167,6 @@ Cancelled opening {name} Openen van {name} geannuleerd The document “{name}” does not have an embedded {mode} profile. Het document “{name}” heeft geen ingesloten {mode}-profiel. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Het document “{name}” heeft een ingesloten kleurprofiel dat niet overeenkomt met de huidige {mode}-werkruimte. - Embedded: {embedded} Ingesloten: {embedded} Working: {working} Werkruimte: {working} Developing {name} {name} wordt ontwikkeld Cancelled {label} {label} geannuleerd @@ -2437,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 @@ -3675,6 +3678,8 @@ cameraRaw Whites Witten Color Picker (Layer Style Color) Kleurkiezer (kleur van laagstijl) Auto Hide Notifications Meldingen automatisch verbergen Notification Duration (seconds) Duur van melding (seconden) + Bundle Arrow-Key Nudges Pijltjestoetsverschuivingen bundelen + Nudge Bundle Pause (ms) Pauze voor bundelen van verschuivingen (ms) Appearance Mode Weergavemodus Next Appearance Mode Volgende weergavemodus Sync with system Synchroniseren met systeem @@ -3785,7 +3790,24 @@ cameraData Lens Lens Search settings… Instellingen zoeken… No matching settings Geen overeenkomende instellingen PhotoCraft — Screen Color Picker PhotoCraft — Schermkleurkiezer +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Gereedschap Monsterpenseel + Sampling Area Overlay Overlay van monstergebied + Show Sampling Area Monstergebied tonen + Indicates Geeft aan + Sampling Area Monstergebied + Excluded Area Uitgesloten gebied + Sampling Area Options Opties voor monstergebied + Rectangular Rechthoekig + Fill Settings Vulinstellingen + Rotation Adaptation Rotatieaanpassing + Very High Zeer hoog + Full Volledig + Mirror Spiegelen + Output Settings Uitvoerinstellingen + Output To Uitvoer naar Loading fonts… Lettertypen laden… + Response curve Responscurve Simulate Pressure Druk simuleren Stroke Path… Pad omlijnen… Background Eraser Achtergrondgum @@ -3869,6 +3891,7 @@ curves Smooth Vloeiend maken Remove after each stroke Verwijderen na elke streek Apply the removal ({key}) Verwijdering toepassen ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Streken om te verwijderen: {n} (Enter past toe, Esc annuleert) + Show home recent files Recente bestanden tonen op het startscherm Clear Brush Controls Penseelbesturing wissen Reset All Locked Settings Alle vergrendelde instellingen opnieuw instellen Copy Texture to Other Tools Structuur kopiëren naar andere gereedschappen @@ -3895,5 +3918,93 @@ curves Smooth Vloeiend maken Action playback stopped Afspelen van de handeling gestopt Step {step} is waiting for its dialog Stap {step} wacht op zijn dialoogvenster @plural {n} action|{n} actions {n} handeling|{n} handelingen + Background (Default) Achtergrond (standaard) +# Batch / Image Processor + Include All Subfolders Alle submappen opnemen Auto Add/Delete Automatisch toevoegen/verwijderen + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Schakel niet ook Spiegelbeeld in bij de afdrukinstellingen: de twee spiegelingen heffen elkaar op. + Print Settings… Afdrukinstellingen… + Remember to turn off the printer's own colour management in Print Settings… Vergeet niet het eigen kleurbeheer van de printer uit te schakelen in de afdrukinstellingen… + Sent to {printer} Verzonden naar {printer} + set in Print Settings… instellen in de afdrukinstellingen… ICO is limited to 256 × 256 pixels ICO is beperkt tot 256 × 256 pixels +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Het ingesloten profiel gebruiken ({profile}) + Convert to the working space ({profile}) Omzetten naar de werkruimte ({profile}) + Remember this choice for future images Deze keuze onthouden voor volgende afbeeldingen + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Zet de vraag weer aan met Ask On Mismatch in Bewerken › Kleurinstellingen. + Button Mode Knopmodus + Button Mode On Knopmodus aan + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Interface donkerder maken + Lighten Interface Interface lichter maken +# Bevel & Emboss Technique. + Technique Techniek + Chisel Hard Beitel hard + Chisel Soft Beitel zacht + Open color-less EXR as ACEScg EXR zonder kleurruimte als ACEScg openen + +# View › Guides › right-click a guide + Edit Selected Guides Geselecteerde hulplijnen bewerken + Clear Selected Guides Geselecteerde hulplijnen wissen + Pasting from other apps is unavailable Plakken vanuit andere apps is niet beschikbaar + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft heeft in deze sessie geen toegang tot het systeemklembord. Onder Wayland verbergt een Flatpak-sandbox het protocol waarmee afbeeldingen uit andere apps worden gelezen. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Om vanuit andere apps te plakken, start je PhotoCraft onder XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Niets om te plakken: PhotoCraft kan het systeemklembord hier niet lezen + Delete this set Deze set verwijderen + New Set… Nieuwe set… + PhotoCraft Defaults PhotoCraft-standaard + Save a list of these shortcuts as a web page Een lijst van deze sneltoetsen als webpagina opslaan + Save the changes to this set De wijzigingen in deze set opslaan + Save these shortcuts as a .kys file Deze sneltoetsen als .kys-bestand opslaan + Save these shortcuts as a new set Deze sneltoetsen als nieuwe set opslaan + Set deleted. Set verwijderd. + Set saved. Set opgeslagen. + Set: Set: + Summarize… Samenvatten… + There are too many sets: delete one first. Er zijn te veel sets: verwijder er eerst een. + set name naam van de set + {set} (modified) {set} (gewijzigd) + Show file name in title Bestandsnaam in titel weergeven + Accept and Go to Conflict Accepteren en naar conflict + Changes undone. Wijzigingen ongedaan gemaakt. + Choose a new shortcut for this command, or leave it without one. Kies een nieuwe sneltoets voor deze opdracht of laat hem zonder. + Put this command's shortcuts back as they were De sneltoetsen van deze opdracht herstellen + Take the shortcut from the other command and select it De sneltoets van de andere opdracht afnemen en die selecteren + Undo Changes Wijzigingen ongedaan maken + {shortcut} is reserved by the system and can't be assigned. {shortcut} is door het systeem gereserveerd en kan niet worden toegewezen. + Low-Latency Drawing (vsync off, applies at next launch) Tekenen met lage latentie (zonder vsync, na de volgende start) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Vrije transformatie wordt nog toegepast; probeer het opnieuw als die klaar is. + The transform couldn't be applied. De transformatie kon niet worden toegepast. +# 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, 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} + 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. + Decrease Brush Size Penseelgrootte verkleinen + Increase Brush Size Penseelgrootte vergroten + Decrease Brush Hardness Penseelhardheid verlagen + Increase Brush Hardness Penseelhardheid verhogen + Memory Usage Geheugengebruik + Per-document RAM limit (MB) RAM-limiet per document (MB) + Installed RAM: {ram} MB; this limit: {percent}% Geïnstalleerd RAM: {ram} MB; deze limiet: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Beperkt pixels plus Geschiedenis van elk geopend document. Oudste herstelstappen verdwijnen als de limiet wordt overschreden. + Applies immediately when saved. Memory is not reserved in advance. Geldt meteen na opslaan. Geheugen wordt niet vooraf gereserveerd. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Bij GPU-geheugen Automatisch beperkt dit ook het GPU-laagbudget. Maximum is een kwart RAM en, indien bekend, drie achtste van het videogeheugen, minstens 512 MB. Hoger zetten vergroot het GPU-budget niet. + Use Gebruiken + In Front Voor + Behind Achter + +# DDS Options (#1327) + DDS Options DDS-opties + Generate Mipmaps Mipmaps genereren diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 2d76183794..23e253d62d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -555,6 +555,9 @@ # Field Blur pins (#3373) No pin selected Nie zaznaczono pinezki Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kliknij obraz, aby dodać pinezkę; przeciągnij pinezkę, aby ją przesunąć, lub jej pierścień, aby zmienić rozmycie. Delete usuwa zaznaczoną pinezkę. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Przeciągnij uchwyty na obrysie, aby zmienić rozmiar i obrócić kształt, kwadrat, aby zaokrąglić narożniki, a punkty wewnątrz, aby ustawić, gdzie zaczyna się rozmycie. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Przeciągnij pinezkę, aby przesunąć pas, lub jej pierścień, aby zmienić rozmycie. Przeciągnij linie ciągłe, aby poszerzyć ostry pas, przerywane, aby ustawić przejście, a punkty, aby go obrócić. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Przeciągnij uchwyty na obrysie, aby zmienić rozmiar i obrócić kształt; pierścień ustawia kąt rozmycia. Iris Blur… Rozmycie irysowe… Tilt-Shift… Tilt-shift… Path Blur… Rozmycie ścieżki… @@ -983,7 +986,6 @@ Contiguous Sąsiadujące Contrast Kontrast Control: Sterowanie: - Convert document's colors to the working space Konwertuj kolory dokumentu na przestrzeń roboczą Convert to sRGB Konwertuj na sRGB Copies Kopie Copper Miedź @@ -1040,6 +1042,7 @@ Delete Group Usuń grupę Delete Layer Mask Usuń maskę warstwy Delete Layers Usuń warstwy + Add Shortcut Dodaj skrót Delete Shortcut Usuń skrót Delete Timeline Usuń oś czasu Delete Tool Preset Usuń ustawienie wstępne narzędzia @@ -1561,7 +1564,6 @@ Use Default Użyj domyślnego Use Legacy Użyj starszej wersji Use Measurement Scale Użyj skali pomiaru - Use the embedded profile (instead of the working space) Użyj osadzonego profilu (zamiast przestrzeni roboczej) Variables bind a layer's visibility, text or pixels to a named data slot. Zmienne wiążą widoczność, tekst lub piksele warstwy z nazwanym polem danych. Vertical scale Skala pionowa Vibrance Jaskrawość @@ -1887,6 +1889,8 @@ Camera Raw Defaults Domyślne ustawienia Camera Raw Canvas border Obramowanie płótna Show canvas dots Pokaż kropki płótna + Clip thumbnails to layer bounds Przycinaj miniatury do granic warstwy + Clip Thumbnails to Layer Bounds Przycinaj miniatury do granic warstwy Canvas color Kolor płótna Canvas custom color Własny kolor płótna Color picker Próbnik kolorów @@ -2075,7 +2079,8 @@ Expand group ({key}-click: all groups) Rozwiń grupę (kliknięcie z {key}: wszystkie grupy) Fade steps Kroki zanikania Hide the layer's effects ({key}-click: all layers) Ukryj efekty warstwy (kliknięcie z {key}: wszystkie warstwy) - Import Brushes… (.abr) Importuj pędzle… (.abr) + Import Brushes… (.abr or image) Importuj pędzle… (.abr lub obraz) + Import Brush from Image… Importuj pędzel z obrazu… Move Group Down Przesuń grupę w dół Move Group Up Przesuń grupę w górę Reset the brush to the defaults Przywróć domyślne ustawienia pędzla @@ -2181,7 +2186,6 @@ Cancelled opening {name} Anulowano otwieranie {name} The document “{name}” does not have an embedded {mode} profile. Dokument „{name}” nie ma osadzonego profilu {mode}. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}” ma osadzony profil kolorów, który nie pasuje do bieżącej przestrzeni roboczej {mode}. - Embedded: {embedded} Osadzony: {embedded} Working: {working} Przestrzeń robocza: {working} Cancelled {label} Anulowano: {label} Esc to cancel Esc, aby anulować @@ -3467,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 @@ -3519,6 +3522,8 @@ cameraRaw Roughness Chropowatość Color Picker (Layer Style Color) Próbnik kolorów (kolor stylu warstwy) Auto Hide Notifications Automatycznie ukrywaj powiadomienia Notification Duration (seconds) Czas trwania powiadomienia (sekundy) + Bundle Arrow-Key Nudges Grupuj przesunięcia strzałkami + Nudge Bundle Pause (ms) Przerwa grupowania przesunięć (ms) Appearance Mode Tryb wyglądu Dark Theme Ciemny motyw Light Theme Jasny motyw @@ -3643,7 +3648,24 @@ cameraData Lens Obiektyw Snap to Origin Przyciągnij do początku Search settings… Szukaj ustawień… No matching settings Brak pasujących ustawień +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Narzędzie Pędzel próbkowania + Sampling Area Overlay Nakładka obszaru próbkowania + Show Sampling Area Pokaż obszar próbkowania + Indicates Wskazuje + Sampling Area Obszar próbkowania + Excluded Area Obszar wykluczony + Sampling Area Options Opcje obszaru próbkowania + Rectangular Prostokątny + Fill Settings Ustawienia wypełnienia + Rotation Adaptation Dopasowanie obrotu + Very High Bardzo wysoka + Full Pełna + Mirror Odbicie lustrzane + Output Settings Ustawienia wyjścia + Output To Wyjście do Loading fonts… Wczytywanie czcionek… + Response curve Krzywa odpowiedzi Simulate Pressure Symuluj nacisk Stroke Path… Obrysuj ścieżkę… Background Eraser Gumka tła @@ -3707,6 +3729,7 @@ curves Smooth Wygładź Remove after each stroke Usuwaj po każdym pociągnięciu Apply the removal ({key}) Zastosuj usuwanie ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Pociągnięcia do usunięcia: {n} (Enter stosuje, Esc anuluje) + Show home recent files Pokaż ostatnie pliki na ekranie głównym Clear Brush Controls Wyczyść elementy sterujące pędzlem Reset All Locked Settings Resetuj wszystkie zablokowane ustawienia Copy Texture to Other Tools Kopiuj teksturę do innych narzędzi @@ -3733,5 +3756,95 @@ curves Smooth Wygładź Action playback stopped Odtwarzanie operacji zatrzymane Step {step} is waiting for its dialog Krok {step} czeka na swoje okno dialogowe @plural {n} action|{n} actions {n} operacja|{n} operacje|{n} operacji + Background (Default) Tło (domyślne) +# Batch / Image Processor + Include All Subfolders Uwzględnij wszystkie podfoldery Auto Add/Delete Automatyczne dodawanie/usuwanie + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Nie włączaj też odbicia lustrzanego w ustawieniach druku: oba odbicia wzajemnie się znoszą. + Print Settings… Ustawienia druku… + Remember to turn off the printer's own colour management in Print Settings… Pamiętaj o wyłączeniu zarządzania kolorami drukarki w ustawieniach druku… + Sent to {printer} Wysłano do {printer} + set in Print Settings… ustawiane w ustawieniach druku… ICO is limited to 256 × 256 pixels ICO jest ograniczony do 256 × 256 pikseli +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Użyj osadzonego profilu ({profile}) + Convert to the working space ({profile}) Konwertuj na przestrzeń roboczą ({profile}) + Remember this choice for future images Zapamiętaj ten wybór dla kolejnych obrazów + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Pytanie można włączyć ponownie opcją Ask On Mismatch w Edycja › Ustawienia kolorów. + Button Mode Tryb przycisku + Button Mode On Tryb przycisku włączony + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Przyciemnij interfejs + Lighten Interface Rozjaśnij interfejs +# Bevel & Emboss Technique. + Technique Technika + Chisel Hard Dłuto twarde + Chisel Soft Dłuto miękkie + Open color-less EXR as ACEScg Otwieraj EXR bez profilu koloru jako ACEScg + +# View › Guides › right-click a guide + Edit Selected Guides Edytuj zaznaczone prowadnice + Clear Selected Guides Usuń zaznaczone prowadnice + Pasting from other apps is unavailable Wklejanie z innych aplikacji jest niedostępne + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft nie ma w tej sesji dostępu do schowka systemowego. W Waylandzie piaskownica Flatpaka ukrywa protokół, przez który odczytywane są obrazy z innych aplikacji. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Aby wklejać z innych aplikacji, uruchom PhotoCraft pod XWaylandem: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nie ma czego wkleić: PhotoCraft nie może tutaj odczytać schowka systemowego + Delete this set Usuń ten zestaw + New Set… Nowy zestaw… + PhotoCraft Defaults Domyślne PhotoCraft + Save a list of these shortcuts as a web page Zapisz listę tych skrótów jako stronę internetową + Save the changes to this set Zapisz zmiany w tym zestawie + Save these shortcuts as a .kys file Zapisz te skróty jako plik .kys + Save these shortcuts as a new set Zapisz te skróty jako nowy zestaw + Set deleted. Zestaw usunięty. + Set saved. Zestaw zapisany. + Set: Zestaw: + Summarize… Podsumuj… + There are too many sets: delete one first. Zestawów jest za dużo: najpierw usuń jeden. + set name nazwa zestawu + {set} (modified) {set} (zmieniony) + Show file name in title Pokaż nazwę pliku w tytule + Accept and Go to Conflict Akceptuj i przejdź do konfliktu + Changes undone. Zmiany cofnięte. + Choose a new shortcut for this command, or leave it without one. Wybierz nowy skrót dla tego polecenia lub zostaw je bez skrótu. + Put this command's shortcuts back as they were Przywróć skróty tego polecenia + Take the shortcut from the other command and select it Odbierz skrót drugiemu poleceniu i zaznacz je + Undo Changes Cofnij zmiany + {shortcut} is reserved by the system and can't be assigned. {shortcut} jest zarezerwowany przez system i nie można go przypisać. + Low-Latency Drawing (vsync off, applies at next launch) Rysowanie z małym opóźnieniem (bez synchronizacji pionowej, od następnego uruchomienia) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Przekształcanie swobodne jest nadal stosowane; spróbuj ponownie, gdy się zakończy. + The transform couldn't be applied. Nie można zastosować przekształcenia. +# 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, 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} + 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. + Decrease Brush Size Zmniejsz rozmiar pędzla + Increase Brush Size Zwiększ rozmiar pędzla + Decrease Brush Hardness Zmniejsz twardość pędzla + Increase Brush Hardness Zwiększ twardość pędzla + Memory Usage Użycie pamięci + Per-document RAM limit (MB) Limit RAM na dokument (MB) + Installed RAM: {ram} MB; this limit: {percent}% Zainstalowany RAM: {ram} MB; ten limit: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Ogranicza piksele plus Historię każdego otwartego dokumentu. Najstarsze stany cofania są usuwane po przekroczeniu limitu. + Applies immediately when saved. Memory is not reserved in advance. Obowiązuje zaraz po zapisaniu. Pamięć nie jest rezerwowana z góry. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Przy pamięci GPU = Automatycznie ogranicza też budżet GPU. Limit to 1/4 RAM i, jeśli znane, 3/8 pamięci karty, minimum 512 MB. Wyższy limit nie zwiększa budżetu GPU. + Use Użyj + In Front Z przodu + Behind Z tyłu + +# DDS Options (#1327) + DDS Options Opcje DDS + Compression Kompresja + Generate Mipmaps Generuj mipmapy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0d48ba9b47..327533ad88 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -540,6 +540,9 @@ # Field Blur pins (#3373) No pin selected Nenhum alfinete selecionado Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Clique na imagem para adicionar um alfinete; arraste um alfinete para movê-lo ou o anel dele para alterar o desfoque. Delete remove o alfinete selecionado. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Arraste as alças do contorno para redimensionar e girar, o quadrado para arredondar os cantos e os pontos internos para definir onde o desfoque começa. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Arraste o alfinete para mover a faixa ou o anel para alterar o desfoque. Arraste as linhas contínuas para alargar a faixa nítida, as tracejadas para definir a transição e os pontos para girá-la. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Arraste as alças do contorno para redimensionar e girar; o anel define o ângulo de desfoque. Iris Blur… Desfoque de íris… Tilt-Shift… Tilt-shift… Path Blur… Desfoque de demarcador… @@ -966,7 +969,6 @@ Contiguous Contíguo Contrast Contraste Control: Controle: - Convert document's colors to the working space Converter as cores do documento para o espaço de trabalho Convert to sRGB Converter para sRGB Copies Cópias Copper Cobre @@ -1023,6 +1025,7 @@ Delete Group Excluir grupo Delete Layer Mask Excluir máscara de camada Delete Layers Excluir camadas + Add Shortcut Adicionar atalho Delete Shortcut Excluir atalho Delete Timeline Excluir linha do tempo Delete Tool Preset Excluir predefinição de ferramenta @@ -1543,7 +1546,6 @@ Use Default Usar padrão Use Legacy Usar legado Use Measurement Scale Usar escala de medida - Use the embedded profile (instead of the working space) Usar o perfil incorporado (em vez do espaço de trabalho) Variables bind a layer's visibility, text or pixels to a named data slot. As variáveis vinculam a visibilidade, o texto ou os pixels de uma camada a um campo de dados nomeado. Vertical scale Escala vertical Vibrance Vibração @@ -1860,6 +1862,8 @@ Camera Raw Defaults Padrões do Camera Raw Canvas border Borda da tela de pintura Show canvas dots Mostrar pontos da tela de pintura + Clip thumbnails to layer bounds Recortar miniaturas aos limites da camada + Clip Thumbnails to Layer Bounds Recortar miniaturas aos limites da camada Canvas color Cor da tela de pintura Canvas custom color Cor personalizada da tela de pintura Color picker Seletor de cores @@ -2046,7 +2050,8 @@ Expand group ({key}-click: all groups) Expandir grupo ({key}-clique: todos os grupos) Fade steps Etapas de atenuação Hide the layer's effects ({key}-click: all layers) Ocultar os efeitos da camada ({key}-clique: todas as camadas) - Import Brushes… (.abr) Importar pincéis… (.abr) + Import Brushes… (.abr or image) Importar pincéis… (.abr ou imagem) + Import Brush from Image… Importar pincel de imagem… Move Group Down Mover grupo para baixo Move Group Up Mover grupo para cima Reset the brush to the defaults Redefinir o pincel para os padrões @@ -2156,7 +2161,6 @@ Cancelled opening {name} Abertura de {name} cancelada The document “{name}” does not have an embedded {mode} profile. O documento “{name}” não tem um perfil {mode} incorporado. The document “{name}” has an embedded color profile that does not match the current {mode} working space. O documento “{name}” tem um perfil de cores incorporado que não corresponde ao espaço de trabalho {mode} atual. - Embedded: {embedded} Incorporado: {embedded} Working: {working} Espaço de trabalho: {working} Developing {name} Revelando {name} Cancelled {label} {label} cancelado @@ -2861,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 @@ -2925,6 +2928,8 @@ fontWeight Italic Itálico Color Picker (Layer Style Color) Seletor de cores (cor do estilo de camada) Auto Hide Notifications Ocultar notificações automaticamente Notification Duration (seconds) Duração da notificação (segundos) + Bundle Arrow-Key Nudges Agrupar deslocamentos com as setas + Nudge Bundle Pause (ms) Pausa para agrupar deslocamentos (ms) Appearance Mode Modo de aparência Dark Theme Tema escuro Light Theme Tema claro @@ -3049,7 +3054,27 @@ cameraData Lens Lente Snap to Origin Ajustar à origem Search settings… Pesquisar configurações… No matching settings Nenhuma configuração encontrada +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Ferramenta Pincel de amostragem + Sampling Area Overlay Sobreposição da área de amostragem + Show Sampling Area Mostrar área de amostragem + Indicates Indica + Sampling Area Área de amostragem + Excluded Area Área excluída + Sampling Area Options Opções da área de amostragem + Rectangular Retangular + Fill Settings Configurações de preenchimento + Rotation Adaptation Adaptação de rotação + Very High Muito alta + Full Completa + Mirror Espelhar + Output Settings Configurações de saída + Output To Saída para + New Layer Nova camada + Duplicate Layer Duplicar camada + Contract Contrair Loading fonts… Carregando fontes… + Response curve Curva de resposta Tool Ferramenta Simulate Pressure Simular pressão Stroke Path… Traçar demarcador… @@ -3067,7 +3092,6 @@ curves Smooth Suavizar Smooth the curve values Suavizar os valores da curva Drag to draw the curve · {key}-click draws a straight line Arraste para desenhar a curva · {key}-clique traça uma linha reta Grid Size: Tamanho da grade: - New Layer Nova camada Vanishing Point: click four corners to create a plane Ponto de fuga: clique em quatro cantos para criar um plano Vanishing Point is not active Ponto de fuga não está ativo Apply Vanishing Point ({key}) Aplicar ponto de fuga ({key}) @@ -3112,7 +3136,6 @@ layerLabel No Color Sem cor Clear Selection Limpar seleção Invert Selection Inverter seleção Select Inverse Inverter seleção - Contract Contrair Sample Size Tamanho da amostra Strength Intensidade Gamma Gama @@ -3139,7 +3162,6 @@ layerLabel No Color Sem cor Workspaces Áreas de trabalho Show All Mostrar tudo Hidden Oculto - Duplicate Layer Duplicar camada Ungroup Desagrupar Lock Transparent Pixels Bloquear pixels transparentes Lock Image Pixels Bloquear pixels da imagem @@ -3899,6 +3921,7 @@ layerLabel No Color Sem cor Remove after each stroke Remover após cada traço Apply the removal ({key}) Aplicar a remoção ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traços a remover: {n} (Enter aplica, Esc cancela) + Show home recent files Mostrar arquivos recentes na tela inicial Clear Brush Controls Limpar controles de pincel Reset All Locked Settings Redefinir todas as configurações bloqueadas Copy Texture to Other Tools Copiar textura para outras ferramentas @@ -3925,5 +3948,93 @@ layerLabel No Color Sem cor Action playback stopped Reprodução da ação interrompida Step {step} is waiting for its dialog A etapa {step} está aguardando sua caixa de diálogo @plural {n} action|{n} actions {n} ação|{n} ações + Background (Default) Plano de fundo (padrão) +# Batch / Image Processor + Include All Subfolders Incluir todas as subpastas Auto Add/Delete Adicionar/excluir automaticamente + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Não ative também a imagem espelhada nas configurações de impressão: as duas inversões se anulam. + Print Settings… Configurações de impressão… + Remember to turn off the printer's own colour management in Print Settings… Lembre-se de desativar o gerenciamento de cores da própria impressora nas configurações de impressão… + Sent to {printer} Enviado para {printer} + set in Print Settings… definido nas configurações de impressão… ICO is limited to 256 × 256 pixels ICO é limitado a 256 × 256 pixels +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Usar o perfil incorporado ({profile}) + Convert to the working space ({profile}) Converter para o espaço de trabalho ({profile}) + Remember this choice for future images Lembrar esta escolha para as próximas imagens + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Reative a pergunta com Ask On Mismatch em Editar › Configurações de cores. + Button Mode Modo de botão + Button Mode On Modo de botão ativado + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Escurecer interface + Lighten Interface Clarear interface +# Bevel & Emboss Technique. + Technique Técnica + Chisel Hard Cinzel duro + Chisel Soft Cinzel suave + Open color-less EXR as ACEScg Abrir EXR sem espaço de cor como ACEScg + +# View › Guides › right-click a guide + Edit Selected Guides Editar guias selecionadas + Clear Selected Guides Limpar guias selecionadas + Pasting from other apps is unavailable Colar de outros aplicativos não está disponível + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. O PhotoCraft não consegue acessar a área de transferência do sistema nesta sessão. No Wayland, a sandbox do Flatpak oculta o protocolo pelo qual as imagens de outros aplicativos são lidas. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Para colar de outros aplicativos, execute o PhotoCraft no XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nada para colar: o PhotoCraft não consegue ler a área de transferência do sistema aqui + Delete this set Excluir este conjunto + New Set… Novo conjunto… + PhotoCraft Defaults Padrões do PhotoCraft + Save a list of these shortcuts as a web page Salvar uma lista destes atalhos como página da web + Save the changes to this set Salvar as alterações neste conjunto + Save these shortcuts as a .kys file Salvar estes atalhos como arquivo .kys + Save these shortcuts as a new set Salvar estes atalhos como um novo conjunto + Set deleted. Conjunto excluído. + Set saved. Conjunto salvo. + Set: Conjunto: + Summarize… Resumir… + There are too many sets: delete one first. Há conjuntos demais: exclua um primeiro. + set name nome do conjunto + {set} (modified) {set} (modificado) + Show file name in title Exibir nome do arquivo no título + Accept and Go to Conflict Aceitar e ir para o conflito + Changes undone. Alterações desfeitas. + Choose a new shortcut for this command, or leave it without one. Escolha um novo atalho para este comando ou deixe-o sem atalho. + Put this command's shortcuts back as they were Restaurar os atalhos deste comando como estavam + Take the shortcut from the other command and select it Tirar o atalho do outro comando e selecioná-lo + Undo Changes Desfazer alterações + {shortcut} is reserved by the system and can't be assigned. {shortcut} é reservado pelo sistema e não pode ser atribuído. + Low-Latency Drawing (vsync off, applies at next launch) Desenho de baixa latência (sem sincronização vertical, aplicado na próxima inicialização) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. A Transformação livre ainda está sendo aplicada; tente novamente quando terminar. + The transform couldn't be applied. Não foi possível aplicar a transformação. +# 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, 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} + 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. + Decrease Brush Size Diminuir tamanho do pincel + Increase Brush Size Aumentar tamanho do pincel + Decrease Brush Hardness Diminuir dureza do pincel + Increase Brush Hardness Aumentar dureza do pincel + Memory Usage Uso de memória + Per-document RAM limit (MB) Limite de RAM por documento (MB) + Installed RAM: {ram} MB; this limit: {percent}% RAM instalada: {ram} MB; este limite: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Limita os pixels mais o Histórico de cada documento aberto. Os estados de desfazer mais antigos são descartados ao exceder o limite. + Applies immediately when saved. Memory is not reserved in advance. Aplica-se ao salvar. A memória não é reservada antecipadamente. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Com Memória GPU Automática, também limita o orçamento GPU. O teto é um quarto da RAM e, se conhecido, três oitavos da memória da placa, mínimo 512 MB. Aumentar o limite não aumenta o orçamento GPU. + Use Usar + In Front Na frente + Behind Atrás + +# DDS Options (#1327) + DDS Options Opções de DDS + Generate Mipmaps Gerar mipmaps diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 11a3cb6b2e..6b2e368454 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -346,6 +346,10 @@ # Field Blur pins (#3373) No pin selected Булавка не выбрана Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Щелкните изображение, чтобы добавить булавку; перетащите булавку, чтобы переместить ее, или ее кольцо, чтобы изменить размытие. Delete удаляет выбранную булавку. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Перетаскивайте маркеры на контуре, чтобы изменить размер и повернуть его, квадрат — чтобы скруглить углы, а точки внутри — чтобы задать, где начинается размытие. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Перетащите булавку, чтобы сдвинуть полосу, или ее кольцо, чтобы изменить размытие. Сплошные линии расширяют резкую полосу, пунктирные задают переход, а точки поворачивают ее. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Перетаскивайте маркеры на контуре, чтобы изменить размер и повернуть его; кольцо задает угол размытия. + Blur Angle Угол размытия Iris Blur… Диафрагменное размытие… Tilt-Shift… Наклон-сдвиг… Path Blur… Размытие по контуру… @@ -909,9 +913,7 @@ Newspaper Газетная бумага Working Spaces Рабочие пространства Color Management Policies Правила управления цветом - Convert document's colors to the working space Преобразовать цвета документа в рабочее пространство Assign working space Назначить рабочее пространство - Use the embedded profile (instead of the working space) Использовать встроенный профиль (вместо рабочего пространства) Leave as is (don't color manage) Оставить как есть (без управления цветом) Discard the embedded profile (don't color manage) Не использовать встроенный профиль (без управления цветом) Embed Color Profile Встроить цветовой профиль @@ -1038,6 +1040,8 @@ Rename Brush… Переименовать кисть… Delete Brush Удалить кисть Import Brushes… Импортировать кисти… + Import Brushes… (.abr or image) Импортировать кисти… (.abr или изображение) + Import Brush from Image… Импортировать кисть из изображения… Gradient Preset Options Параметры наборов градиентов Import .GRD Импортировать .GRD Choose a gradient file (.grd). Выберите файл градиентов (.grd). @@ -1696,6 +1700,7 @@ These settings aren't available in PhotoCraft yet. Эти параметры пока недоступны в PhotoCraft. Shortcut removed. Комбинация клавиш удалена. All shortcuts reset to PhotoCraft defaults. Все комбинации клавиш сброшены к стандартным PhotoCraft. + Add Shortcut Добавить комбинацию Delete Shortcut Удалить комбинацию Press keys… Нажмите клавиши… shortcut комбинация @@ -2304,6 +2309,8 @@ Camera Raw Defaults Значения Camera Raw по умолчанию Canvas border Рамка холста Show canvas dots Показывать точки холста + Clip thumbnails to layer bounds Обрезать миниатюры по границам слоя + Clip Thumbnails to Layer Bounds Обрезать миниатюры по границам слоя Canvas color Цвет холста Canvas custom color Свой цвет холста Classic Классическая @@ -2476,7 +2483,6 @@ Cancelled opening {name} Открытие {name} отменено The document “{name}” does not have an embedded {mode} profile. В документе «{name}» нет встроенного профиля {mode}. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Встроенный цветовой профиль документа «{name}» не совпадает с текущим рабочим пространством {mode}. - Embedded: {embedded} Встроенный: {embedded} Working: {working} Рабочее пространство: {working} Developing {name} Обработка {name} Cancelled {label} {label}: отменено @@ -2706,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 Сверхтонкий @@ -2772,6 +2777,8 @@ fontWeight Italic Курсив Color Picker (Layer Style Color) Палитра цветов (цвет стиля слоя) Auto Hide Notifications Автоскрытие уведомлений Notification Duration (seconds) Длительность уведомления (секунды) + Bundle Arrow-Key Nudges Объединять сдвиги стрелками + Nudge Bundle Pause (ms) Пауза объединения сдвигов (мс) Appearance Mode Режим оформления Dark Theme Тёмная тема Light Theme Светлая тема @@ -2968,7 +2975,26 @@ cameraData Lens Объектив Rasterize Layer Style Растрировать стиль слоя Search settings… Поиск настроек… No matching settings Нет подходящих настроек +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Кисть области выборки + Sampling Area Overlay Наложение области выборки + Show Sampling Area Показать область выборки + Indicates Обозначает + Sampling Area Область выборки + Excluded Area Исключённая область + Sampling Area Options Параметры области выборки + Rectangular Прямоугольная + Fill Settings Параметры заливки + Rotation Adaptation Адаптация поворота + Very High Очень высокая + Full Полная + Mirror Отражение + Output Settings Параметры вывода + Output To Вывод в + Duplicate Layer Дубликат слоя + Contract Сжать Loading fonts… Загрузка шрифтов… + Response curve Кривая отклика Tool Инструмент Simulate Pressure Имитировать нажим Stroke Path… Обвести контур… @@ -3055,6 +3081,7 @@ curves Smooth Сгладить Remove after each stroke Удалять после каждого штриха Apply the removal ({key}) Применить удаление ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихов к удалению: {n} (Enter применяет, Esc отменяет) + Show home recent files Показывать недавние файлы на домашнем экране Clear Brush Controls Очистить параметры кисти Reset All Locked Settings Сбросить все заблокированные параметры Copy Texture to Other Tools Скопировать текстуру в другие инструменты @@ -3081,7 +3108,18 @@ curves Smooth Сгладить Action playback stopped Воспроизведение операции остановлено Step {step} is waiting for its dialog Шаг {step} ожидает своего диалога @plural {n} action|{n} actions {n} операция|{n} операции|{n} операций + Background (Default) Фон (по умолчанию) + Content-Aware Fill Заливка с учётом содержимого +# Batch / Image Processor + Include All Subfolders Включить все вложенные папки Auto Add/Delete Автодобавление/удаление + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Не включайте также зеркальное отражение в параметрах печати: два отражения отменяют друг друга. + Print Settings… Параметры печати… + Remember to turn off the printer's own colour management in Print Settings… Не забудьте отключить управление цветом самого принтера в параметрах печати… + Sent to {printer} Отправлено на {printer} + set in Print Settings… задаётся в параметрах печати… ICO is limited to 256 × 256 pixels ICO ограничен размером 256 × 256 пикселей # Dynamic selection context menu and appearance favorites @@ -3103,3 +3141,85 @@ curves Smooth Сгладить Gray Value (Median) Уровень серого (медиана) Integrated Density Интегральная плотность Length Длина +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Использовать встроенный профиль ({profile}) + Convert to the working space ({profile}) Преобразовать в рабочее пространство ({profile}) + Remember this choice for future images Запомнить этот выбор для следующих изображений + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Снова включить вопрос можно параметром Ask On Mismatch в Редактирование › Настройки цвета. + Button Mode Режим кнопок + Button Mode On Режим кнопок включён + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Сделать интерфейс темнее + Lighten Interface Сделать интерфейс светлее +# Bevel & Emboss Technique. + Technique Метод + Chisel Hard Жесткая огранка + Chisel Soft Мягкая огранка + Open color-less EXR as ACEScg Открывать EXR без цветового профиля как ACEScg + +# View › Guides › right-click a guide + Edit Selected Guides Изменить выбранные направляющие + Clear Selected Guides Удалить выбранные направляющие + Pasting from other apps is unavailable Вставка из других приложений недоступна + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. В этом сеансе PhotoCraft не может получить доступ к системному буферу обмена. В Wayland песочница Flatpak скрывает протокол, через который читаются изображения из других приложений. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Чтобы вставлять из других приложений, запустите PhotoCraft под XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Нечего вставить: здесь PhotoCraft не может прочитать системный буфер обмена + Delete this set Удалить этот набор + New Set… Новый набор… + PhotoCraft Defaults Стандартные PhotoCraft + Save a list of these shortcuts as a web page Сохранить список этих комбинаций как веб-страницу + Save the changes to this set Сохранить изменения в этом наборе + Save these shortcuts as a .kys file Сохранить эти комбинации как файл .kys + Save these shortcuts as a new set Сохранить эти комбинации как новый набор + Set deleted. Набор удалён. + Set saved. Набор сохранён. + Set: Набор: + Summarize… Сводка… + There are too many sets: delete one first. Слишком много наборов: сначала удалите один. + set name имя набора + {set} (modified) {set} (изменён) + Random Случайно + Show file name in title Показывать имя файла в заголовке + Accept and Go to Conflict Принять и перейти к конфликту + Changes undone. Изменения отменены. + Choose a new shortcut for this command, or leave it without one. Выберите новую комбинацию для этой команды или оставьте её без комбинации. + Put this command's shortcuts back as they were Вернуть комбинации этой команды + Take the shortcut from the other command and select it Снять комбинацию с другой команды и выбрать её + Undo Changes Отменить изменения + {shortcut} is reserved by the system and can't be assigned. Комбинация {shortcut} зарезервирована системой и не может быть назначена. + Low-Latency Drawing (vsync off, applies at next launch) Рисование с малой задержкой (без вертикальной синхронизации, после перезапуска) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Свободное трансформирование ещё применяется; повторите попытку, когда оно завершится. + The transform couldn't be applied. Не удалось применить трансформирование. +# 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, 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} + 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. Замените его этим документом или отмените и используйте Файл › Сохранить как, чтобы сохранить оба. + Decrease Brush Size Уменьшить размер кисти + Increase Brush Size Увеличить размер кисти + Decrease Brush Hardness Уменьшить жёсткость кисти + Increase Brush Hardness Увеличить жёсткость кисти + Memory Usage Использование памяти + Per-document RAM limit (MB) Лимит ОЗУ на документ (МБ) + Installed RAM: {ram} MB; this limit: {percent}% Установлено ОЗУ: {ram} МБ; этот лимит: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Ограничивает пиксели и историю каждого открытого документа. Старые состояния отмены удаляются при превышении лимита. + Applies immediately when saved. Memory is not reserved in advance. Применяется сразу после сохранения. Память заранее не резервируется. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. При GPU-памяти «Авто» это также ограничивает бюджет GPU. Потолок — четверть ОЗУ и, если известно, три восьмых памяти карты, минимум 512 МБ. Больший лимит GPU-бюджет не увеличивает. + Command Command + Use Использовать + In Front Спереди + Behind Сзади + +# DDS Options (#1327) + DDS Options Параметры DDS + Compression Сжатие + Generate Mipmaps Создать MIP-карты diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f47fe20162..5530f81ce6 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -535,6 +535,9 @@ Field Blur… Zamućenje polja… No pin selected Nijedna čioda nije izabrana Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kliknite na sliku da dodate čiodu; prevucite čiodu da je pomerite ili njen prsten da promenite zamućenje. Delete uklanja izabranu čiodu. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Prevucite ručke na obrisu da promenite veličinu i rotirate ga, kvadrat da zaoblite uglove, a tačke unutra da odredite gde zamućenje počinje. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Prevucite čiodu da pomerite traku ili njen prsten da promenite zamućenje. Prevucite pune linije da proširite oštru traku, isprekidane linije da podesite prelaz, a tačke da je rotirate. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Prevucite ručke na obrisu da promenite veličinu i rotirate ga; prsten određuje ugao zamućenja. Iris Blur… Iris zamućenje… Tilt-Shift… Tilt-shift… Path Blur… Zamućenje duž putanje… @@ -954,7 +957,6 @@ Contiguous Susedno Contrast Kontrast Control: Kontrola: - Convert document's colors to the working space Pretvori boje dokumenta u radni prostor Convert to sRGB Pretvori u sRGB Copies Primerci Copper Bakar @@ -1009,6 +1011,7 @@ Delete Group Obriši grupu Delete Layer Mask Obriši masku sloja Delete Layers Obriši slojeve + Add Shortcut Dodaj prečicu Delete Shortcut Obriši prečicu Delete Timeline Obriši vremensku liniju Delete Tool Preset Obriši šablon alatke @@ -1526,7 +1529,6 @@ Use Default Koristi podrazumevano Use Legacy Koristi staro Use Measurement Scale Koristi razmeru merenja - Use the embedded profile (instead of the working space) Koristi ugrađeni profil (umesto radnog prostora) Variables bind a layer's visibility, text or pixels to a named data slot. Promenljive vezuju vidljivost, tekst ili piksele sloja za imenovano polje podataka. Vertical scale Vertikalna razmera Vibrance Živost @@ -1844,6 +1846,8 @@ Camera Raw Defaults Podrazumevana Camera Raw podešavanja Canvas border Ivica platna Show canvas dots Prikaži tačke na platnu + Clip thumbnails to layer bounds Iseci sličice prema granicama sloja + Clip Thumbnails to Layer Bounds Iseci sličice prema granicama sloja Canvas color Boja platna Canvas custom color Prilagođena boja platna Color picker Birač boja @@ -2139,7 +2143,6 @@ Cancelled opening {name} Otkazano otvaranje: {name} The document “{name}” does not have an embedded {mode} profile. Dokument „{name}“ nema ugrađeni {mode} profil. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}“ ima ugrađeni profil boja koji se ne slaže sa trenutnim {mode} radnim prostorom. - Embedded: {embedded} Ugrađeni: {embedded} Working: {working} Radni prostor: {working} Developing {name} Razvijanje: {name} Cancelled {label} Otkazano: {label} @@ -2833,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 @@ -2897,6 +2899,8 @@ fontWeight Italic Kurziv Color Picker (Layer Style Color) Birač boja (boja stila sloja) Auto Hide Notifications Automatski sakrij obaveštenja Notification Duration (seconds) Trajanje obaveštenja (sekunde) + Bundle Arrow-Key Nudges Objedini pomeranja strelicama + Nudge Bundle Pause (ms) Pauza za objedinjavanje pomeranja (ms) Appearance Mode Režim izgleda Presentation Prikaz Dark Theme Tamna tema @@ -3000,6 +3004,7 @@ cameraData Lens Objektiv Create a new rectangular frame Napravi novi pravougaoni okvir Create a new elliptical frame Napravi novi eliptični okvir Include in playback Uključi u reprodukciju + Include All Subfolders Uključi sve podfascikle Perspective Crop Tool Alatka za perspektivno opsecanje Show Grid Prikaži mrežu Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Prevucite okvir ili kliknite četiri ugla · prevucite uglove na ivice iskošenog oblika · {commit} potvrđuje · Esc otkazuje @@ -3730,6 +3735,7 @@ layerLabel No Color Bez boje Color Settings Podešavanja boja Color Table Tabela boja Conditional Mode Change Uslovna promena režima + Background (Default) Pozadina (podrazumevano) Content-Aware Fill Popuna prema sadržaju Convert to Linked Pretvori u povezani Convert to Profile Pretvori u profil @@ -4200,9 +4206,17 @@ shape Appearance Izgled Choose a gradient file (.grd). Izaberite datoteku gradijenta (.grd). Auto Add/Delete Automatsko dodavanje/brisanje +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Nemojte uključiti i ogledalsku sliku u podešavanjima štampanja: dva preslikavanja se poništavaju. + Print Settings… Podešavanja štampanja… + Remember to turn off the printer's own colour management in Print Settings… Ne zaboravite da isključite upravljanje bojama samog štampača u podešavanjima štampanja… + Sent to {printer} Poslato na {printer} + set in Print Settings… podešava se u podešavanjima štampanja… # Custom print paper (#3820) Paper Width Širina papira Paper Height Visina papira + +# ICO dimensions ICO is limited to 256 × 256 pixels ICO je ograničen na 256 × 256 piksela # Channel Options and New Spot Channel (#3761) @@ -4211,3 +4225,99 @@ shape Appearance Izgled Ink Characteristics Karakteristike mastila Solidity: Punoća: Redrawing Ponovno iscrtavanje +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Alatka četkica za uzorkovanje + Sampling Area Overlay Preklop oblasti uzorkovanja + Show Sampling Area Prikaži oblast uzorkovanja + Indicates Označava + Sampling Area Oblast uzorkovanja + Excluded Area Izuzeta oblast + Sampling Area Options Opcije oblasti uzorkovanja + Rectangular Pravougaona + Fill Settings Podešavanja popune + Rotation Adaptation Prilagođavanje rotacije + Very High Veoma visoka + Full Potpuna + Mirror Preslikavanje + Output Settings Podešavanja izlaza + Output To Izlaz u + Response curve Kriva odziva +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Koristi ugrađeni profil ({profile}) + Convert to the working space ({profile}) Pretvori u radni prostor ({profile}) + Remember this choice for future images Zapamti ovaj izbor za buduće slike + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Pitanje ponovo uključite opcijom Ask On Mismatch u Uređivanje › Podešavanja boja. + Button Mode Režim dugmadi + Button Mode On Režim dugmadi uključen + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Zatamni interfejs + Lighten Interface Posvetli interfejs +# Bevel & Emboss Technique. + Technique Tehnika + Chisel Hard Tvrdo klesanje + Chisel Soft Meko klesanje + Open color-less EXR as ACEScg Otvori EXR bez podatka o bojama kao ACEScg + Show home recent files Prikaži nedavne datoteke na početnom ekranu + +# View › Guides › right-click a guide + Edit Selected Guides Uredi izabrane vodilje + Clear Selected Guides Ukloni izabrane vodilje + Pasting from other apps is unavailable Lepljenje iz drugih aplikacija nije dostupno + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft u ovoj sesiji ne može da pristupi sistemskoj ostavi. Na Wayland-u, Flatpak sandbox skriva protokol preko kog se čitaju slike iz drugih aplikacija. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Da biste lepili iz drugih aplikacija, pokrenite PhotoCraft pod XWayland-om: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Nema šta da se nalepi: PhotoCraft ovde ne može da čita sistemsku ostavu + Delete this set Obriši ovaj skup + New Set… Novi skup… + PhotoCraft Defaults Podrazumevano za PhotoCraft + Save a list of these shortcuts as a web page Sačuvaj listu ovih prečica kao veb stranicu + Save the changes to this set Sačuvaj izmene u ovom skupu + Save these shortcuts as a .kys file Sačuvaj ove prečice kao .kys datoteku + Save these shortcuts as a new set Sačuvaj ove prečice kao novi skup + Set deleted. Skup je obrisan. + Set saved. Skup je sačuvan. + Set: Skup: + Summarize… Rezime… + There are too many sets: delete one first. Previše skupova: prvo obrišite jedan. + set name ime skupa + {set} (modified) {set} (izmenjen) + Show file name in title Prikaži naziv datoteke u naslovu + Accept and Go to Conflict Prihvati i idi na sukob + Changes undone. Izmene su poništene. + Choose a new shortcut for this command, or leave it without one. Izaberite novu prečicu za ovu komandu ili je ostavite bez prečice. + Put this command's shortcuts back as they were Vrati prečice ove komande kakve su bile + Take the shortcut from the other command and select it Uzmi prečicu drugoj komandi i izaberi je + Undo Changes Poništi izmene + {shortcut} is reserved by the system and can't be assigned. {shortcut} je rezervisana od strane sistema i ne može se dodeliti. + Low-Latency Drawing (vsync off, applies at next launch) Crtanje sa malim kašnjenjem (bez vsync-a, primenjuje se pri sledećem pokretanju) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Slobodna transformacija se još primenjuje; pokušajte ponovo kad se završi. + The transform couldn't be applied. Transformacija nije mogla da se primeni. +# 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, 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} + 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. + Decrease Brush Size Smanji veličinu četkice + Increase Brush Size Povećaj veličinu četkice + Decrease Brush Hardness Smanji tvrdoću četkice + Increase Brush Hardness Povećaj tvrdoću četkice + Memory Usage Upotreba memorije + Per-document RAM limit (MB) RAM limit po dokumentu (MB) + Installed RAM: {ram} MB; this limit: {percent}% Instalisan RAM: {ram} MB; ovaj limit: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Ograničava piksele plus Istoriju svakog otvorenog dokumenta. Najstarija stanja opoziva se odbacuju kad se prekorači limit. + Applies immediately when saved. Memory is not reserved in advance. Važi odmah po čuvanju. Memorija se unapred ne rezerviše. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Kad je GPU memorija Automatski, ograničava i GPU budžet. Plafon je četvrtina RAM-a i, ako je poznato, tri osmine memorije kartice, najmanje 512 MB. Veći limit ne povećava GPU budžet. + Use Koristi + In Front Ispred + Behind Iza + +# DDS Options (#1327) + DDS Options DDS opcije + Generate Mipmaps Generiši mipmape diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 948c967467..2e6f96a0e7 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -534,6 +534,9 @@ Field Blur… Alan Bulanıklığı… No pin selected Seçili pim yok Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Pim eklemek için görüntüye tıklayın; taşımak için pimi, bulanıklığını değiştirmek için halkasını sürükleyin. Delete seçili pimi siler. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Yeniden boyutlandırmak ve döndürmek için dış çizgideki tutamaçları, köşeleri yuvarlamak için kareyi, bulanıklığın nerede başlayacağını belirlemek için içerideki noktaları sürükleyin. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Bandı taşımak için pimi, bulanıklığını değiştirmek için halkasını sürükleyin. Keskin bandı genişletmek için düz çizgileri, geçişi ayarlamak için kesikli çizgileri, döndürmek için noktaları sürükleyin. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Yeniden boyutlandırmak ve döndürmek için dış çizgideki tutamaçları sürükleyin; halka bulanıklık açısını belirler. Iris Blur… İris Bulanıklığı… Tilt-Shift… Tilt-Shift… Path Blur… Yol Bulanıklığı… @@ -946,7 +949,6 @@ Contiguous Bitişik Contrast Kontrast Control: Kontrol: - Convert document's colors to the working space Belgenin renklerini çalışma alanına dönüştürün Convert to sRGB sRGB'ye dönüştür Copies Kopyalar Copper Bakır @@ -1004,6 +1006,7 @@ Delete Layer Mask Katman Maskesini Sil Delete Layers Katmanları Sil Delete Shortcut Kısayolu Sil + Add Shortcut Kısayol Ekle Delete Timeline Zaman Çizelgesini Sil Delete Tool Preset Araç Ön Ayarını Sil Delete count group Sayım grubunu sil @@ -1521,7 +1524,6 @@ Use Default Varsayılanı Kullan Use Legacy Eskiyi Kullan Use Measurement Scale Ölçüm Ölçeğini Kullan - Use the embedded profile (instead of the working space) Gömülü profili kullanın (çalışma alanı yerine) Variables bind a layer's visibility, text or pixels to a named data slot. Değişkenler bir katmanın görünürlüğünü, metnini veya piksellerini adlandırılmış bir veri yuvasına bağlar. Vertical scale Dikey ölçek Vibrance Canlılık @@ -1841,6 +1843,8 @@ Camera Raw Defaults Camera Raw Varsayılanları Canvas border Kanvas kenarlığı Show canvas dots Kanvas noktalarını göster + Clip thumbnails to layer bounds Küçük resimleri katman sınırlarına kırp + Clip Thumbnails to Layer Bounds Küçük resimleri katman sınırlarına kırp Canvas color Kanvas rengi Canvas custom color Kanvas özel rengi Color picker Renk seçici @@ -2136,7 +2140,6 @@ Cancelled opening {name} {name} açılışı iptal edildi The document “{name}” does not have an embedded {mode} profile. '{name}' belgesinde gömülü bir {mode} profili yok. The document “{name}” has an embedded color profile that does not match the current {mode} working space. '{name}' belgesinin gömülü renk profili geçerli {mode} çalışma alanıyla eşleşmiyor. - Embedded: {embedded} Gömülü: {embedded} Working: {working} Çalışma alanı: {working} Developing {name} {name} geliştiriliyor Cancelled {label} {label} iptal edildi @@ -2407,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 @@ -3289,6 +3291,7 @@ cameraRaw Color Renk Compose Oluştur Condensed Yoğunlaştırılmış Conditional Mode Change Koşullu Mod Değişikliği + Background (Default) Arka plan (varsayılan) Content-Aware Fill İçeriğe Uygun Dolgu Convert Frames > Video Layers Çerçeveleri Dönüştür > Video Katmanları Convert to Linked Bağlantılıya Dönüştür @@ -3358,6 +3361,7 @@ cameraRaw Color Renk How often fastening points are placed by themselves Sabitleme noktalarının kendi başlarına ne sıklıkla yerleştirildiği ISO Speed ISO Hızı Image Processor Görüntü İşlemcisi + Include All Subfolders Tüm Alt Klasörleri Dahil Et In içinde Install Plug-in Eklentiyi Yükle Interpret Footage Görüntüyü Yorumlama @@ -3642,6 +3646,8 @@ cameraRaw Whites Beyazlar Color Picker (Layer Style Color) Renk Seçici (Katman Stili Rengi) Auto Hide Notifications Bildirimleri Otomatik Gizle Notification Duration (seconds) Bildirim Süresi (saniye) + Bundle Arrow-Key Nudges Ok tuşlarıyla kaydırmaları birleştir + Nudge Bundle Pause (ms) Kaydırma birleştirme duraklaması (ms) Appearance Mode Görünüm Modu Presentation Sunum Next Appearance Mode Sonraki Görünüm Modu @@ -3823,6 +3829,24 @@ cameraData Lens Lens @plural {n} step|{n} steps {n} adım @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} fırça ön ayarı uyarısı daha) @plural {what} with {n} warning|{what} with {n} warnings {what}, {n} uyarı ile +# Edit › Content-Aware Fill workspace + Excluded Area Hariç Tutulan Alan + Fill Settings Dolgu Ayarları + Full Tam + Indicates Gösterir + Mirror Yansıt + Output Settings Çıkış Ayarları + Output To Çıkış Hedefi + Rectangular Dikdörtgen + Rotation Adaptation Döndürme Uyarlaması + Sampling Area Örnekleme Alanı + Sampling Area Options Örnekleme Alanı Seçenekleri + Sampling Area Overlay Örnekleme Alanı Kaplaması + Sampling Brush Tool Örnekleme Fırçası Aracı + Show Sampling Area Örnekleme Alanını Göster + Very High Çok Yüksek + Response curve Yanıt eğrisi + Show home recent files Ana ekranda son dosyaları göster Clear Brush Controls Fırça Denetimlerini Temizle Reset All Locked Settings Tüm Kilitli Ayarları Sıfırla Copy Texture to Other Tools Dokuyu Diğer Araçlara Kopyala @@ -3861,6 +3885,13 @@ cameraData Lens Lens Loaded Camera Raw settings from {file} Camera Raw ayarları {file} dosyasından yüklendi Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw ayarları {file} dosyasından yüklendi ({n} desteklenmiyor) Auto Add/Delete Otomatik Ekle/Sil + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Yazdırma Ayarlarında ayrıca Ayna Görüntüsünü açmayın: iki yansıtma birbirini iptal eder. + Print Settings… Yazdırma Ayarları… + Remember to turn off the printer's own colour management in Print Settings… Yazdırma Ayarlarında yazıcının kendi renk yönetimini kapatmayı unutmayın… + Sent to {printer} {printer} yazıcısına gönderildi + set in Print Settings… Yazdırma Ayarlarında belirlenir… ICO is limited to 256 × 256 pixels ICO 256 × 256 piksel ile sınırlıdır # Channel Options and New Spot Channel (#3761) @@ -3869,3 +3900,81 @@ cameraData Lens Lens Ink Characteristics Mürekkep Özellikleri Solidity: Doluluk: Redrawing Yeniden çiziliyor +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Gömülü profili kullanın ({profile}) + Convert to the working space ({profile}) Çalışma alanına dönüştürün ({profile}) + Remember this choice for future images Bu seçimi sonraki görüntüler için hatırla + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Soruyu Düzen › Renk Ayarları'ndaki Ask On Mismatch ile yeniden açabilirsiniz. + Button Mode Düğme modu + Button Mode On Düğme modu açık + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Arayüzü koyulaştır + Lighten Interface Arayüzü açıklaştır +# Bevel & Emboss Technique. + Technique Teknik + Chisel Hard Sert Keski + Chisel Soft Yumuşak Keski + Open color-less EXR as ACEScg Renk bilgisi olmayan EXR'yi ACEScg olarak aç + +# View › Guides › right-click a guide + Edit Selected Guides Seçili Kılavuzları Düzenle + Clear Selected Guides Seçili Kılavuzları Temizle + Pasting from other apps is unavailable Diğer uygulamalardan yapıştırma kullanılamıyor + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft bu oturumda sistem panosuna erişemiyor. Wayland'de Flatpak korumalı alanı, diğer uygulamalardaki görüntülerin okunduğu protokolü gizler. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Diğer uygulamalardan yapıştırmak için PhotoCraft'ı XWayland altında başlatın: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Yapıştırılacak bir şey yok: PhotoCraft burada sistem panosunu okuyamıyor + Delete this set Bu Kümeyi Sil + New Set… Yeni Küme… + PhotoCraft Defaults PhotoCraft Varsayılanları + Save a list of these shortcuts as a web page Bu kısayolların listesini web sayfası olarak kaydet + Save the changes to this set Bu kümedeki değişiklikleri kaydet + Save these shortcuts as a .kys file Bu kısayolları .kys dosyası olarak kaydet + Save these shortcuts as a new set Bu kısayolları yeni bir küme olarak kaydet + Set deleted. Küme silindi. + Set saved. Küme kaydedildi. + Set: Küme: + Summarize… Özetle… + There are too many sets: delete one first. Çok fazla küme var: önce birini silin. + set name küme adı + {set} (modified) {set} (değiştirildi) + Show file name in title Başlıkta dosya adını göster + Accept and Go to Conflict Kabul Et ve Çakışmaya Git + Changes undone. Değişiklikler geri alındı. + Choose a new shortcut for this command, or leave it without one. Bu komut için yeni bir kısayol seçin veya kısayolsuz bırakın. + Put this command's shortcuts back as they were Bu komutun kısayollarını eski haline getir + Take the shortcut from the other command and select it Kısayolu diğer komuttan al ve o komutu seç + Undo Changes Değişiklikleri Geri Al + {shortcut} is reserved by the system and can't be assigned. {shortcut} sistem tarafından ayrılmıştır ve atanamaz. + Low-Latency Drawing (vsync off, applies at next launch) Düşük gecikmeli çizim (dikey eşitleme kapalı, sonraki açılışta geçerli) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Serbest Dönüştürme hâlâ uygulanıyor; bittiğinde yeniden deneyin. + The transform couldn't be applied. Dönüştürme uygulanamadı. +# 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, 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} + 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. + Decrease Brush Size Fırça boyutunu küçült + Increase Brush Size Fırça boyutunu büyüt + Decrease Brush Hardness Fırça sertliğini azalt + Increase Brush Hardness Fırça sertliğini artır + Memory Usage Bellek kullanımı + Per-document RAM limit (MB) Belge başına RAM sınırı (MB) + Installed RAM: {ram} MB; this limit: {percent}% Kurulu RAM: {ram} MB; bu sınır: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Her açık belgenin piksellerini ve Geçmişini sınırlar. Sınır aşılınca en eski geri alma durumları atılır. + Applies immediately when saved. Memory is not reserved in advance. Kaydedilince hemen uygulanır. Bellek önceden ayrılmaz. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. GPU Belleği Otomatikken GPU katman bütçesini de sınırlar. Tavan, RAM’in dörtte biri ve biliniyorsa kart belleğinin sekizde üçü, en az 512 MB. Sınırı yükseltmek GPU bütçesini artırmaz. + Use Kullan + In Front Önde + Behind Arkada + +# DDS Options (#1327) + DDS Options DDS Seçenekleri + Generate Mipmaps Mipmap oluştur diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 2a72fdaf19..5280372cad 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -324,6 +324,9 @@ # Field Blur pins (#3373) No pin selected Шпильку не вибрано Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Клацніть зображення, щоб додати шпильку; перетягніть шпильку, щоб перемістити її, або її кільце, щоб змінити розмиття. Delete видаляє вибрану шпильку. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Перетягуйте маркери на контурі, щоб змінити розмір і повернути його, квадрат — щоб заокруглити кути, а точки всередині — щоб задати, де починається розмиття. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Перетягніть шпильку, щоб зсунути смугу, або її кільце, щоб змінити розмиття. Суцільні лінії розширюють різку смугу, пунктирні задають перехід, а точки повертають її. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Перетягуйте маркери на контурі, щоб змінити розмір і повернути його; кільце задає кут розмиття. Iris Blur… Розмиття діафрагми… Tilt-Shift… Нахил і зсув… Path Blur… Розмиття вздовж контуру… @@ -887,9 +890,7 @@ Newspaper Газета Working Spaces Робочі простори Color Management Policies Політики керування кольором - Convert document's colors to the working space Перетворити кольори документа в робочий простір Assign working space Призначити робочий простір - Use the embedded profile (instead of the working space) Використати вбудований профіль (замість робочого простору) Leave as is (don't color manage) Залишити як є (без керування кольором) Discard the embedded profile (don't color manage) Відкинути вбудований профіль (без керування кольором) Embed Color Profile Вбудувати колірний профіль @@ -1677,6 +1678,7 @@ These settings aren't available in PhotoCraft yet. Ці параметри ще недоступні у PhotoCraft. Shortcut removed. Клавіатурне скорочення вилучено. All shortcuts reset to PhotoCraft defaults. Усі клавіатурні скорочення скинуто до типових значень PhotoCraft. + Add Shortcut Додати клавіатурне скорочення Delete Shortcut Видалити клавіатурне скорочення Press keys… Натисніть клавіші… shortcut клавіатурне скорочення @@ -2279,6 +2281,8 @@ Camera Raw Defaults Типові параметри Camera Raw Canvas border Межа полотна Show canvas dots Показувати крапки полотна + Clip thumbnails to layer bounds Обрізати мініатюри до меж шару + Clip Thumbnails to Layer Bounds Обрізати мініатюри до меж шару Canvas color Колір полотна Canvas custom color Довільний колір полотна Classic Класична @@ -2449,7 +2453,6 @@ Cancelled opening {name} Відкриття {name} скасовано The document “{name}” does not have an embedded {mode} profile. Документ «{name}» не має вбудованого профілю {mode}. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Вбудований колірний профіль документа «{name}» не відповідає поточному робочому простору {mode}. - Embedded: {embedded} Вбудований: {embedded} Working: {working} Робочий простір: {working} Developing {name} Обробка {name} Cancelled {label} Скасовано: {label} @@ -2676,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 Екстралегкий @@ -2903,7 +2905,8 @@ fontWeight Italic Курсивний Frame {current} / {total} Кадр {current} / {total} Create new brush from the current settings Створити новий пензель із поточних параметрів Delete brush Видалити пензель - Import Brushes… (.abr) Імпортувати пензлі… (.abr) + Import Brushes… (.abr or image) Імпортувати пензлі… (.abr або зображення) + Import Brush from Image… Імпортувати пензель із зображення… System Info Інформація про систему Click to sample the foreground colour · {key}-click for background Клацання бере зразок переднього плану · {key}+клацання для фону Create Warped Text Створити деформований текст @@ -3605,6 +3608,8 @@ cameraRaw Masking Маскування Color Picker (Layer Style Color) Палітра кольорів (колір стилю шару) Auto Hide Notifications Автоматично приховувати сповіщення Notification Duration (seconds) Тривалість сповіщення (секунди) + Bundle Arrow-Key Nudges Поєднувати зсуви стрілками + Nudge Bundle Pause (ms) Пауза поєднання зсувів (мс) Appearance Mode Режим вигляду Dark Theme Темна тема Light Theme Світла тема @@ -3717,7 +3722,24 @@ cameraData Lens Об'єктив Search settings… Пошук налаштувань… No matching settings Немає відповідних налаштувань PhotoCraft — Screen Color Picker PhotoCraft — Піпетка кольору екрана +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Пензель області вибірки + Sampling Area Overlay Накладання області вибірки + Show Sampling Area Показати область вибірки + Indicates Позначає + Sampling Area Область вибірки + Excluded Area Виключена область + Sampling Area Options Параметри області вибірки + Rectangular Прямокутна + Fill Settings Параметри заливки + Rotation Adaptation Адаптація повороту + Very High Дуже висока + Full Повна + Mirror Віддзеркалення + Output Settings Параметри виводу + Output To Вивести в Loading fonts… Завантаження шрифтів… + Response curve Крива відгуку Simulate Pressure Імітувати натиск Stroke Path… Обвести контур… Background Eraser Фонова гумка @@ -3808,6 +3830,7 @@ curves Smooth Згладити Remove after each stroke Видаляти після кожного штриха Apply the removal ({key}) Застосувати видалення ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихів для видалення: {n} (Enter застосовує, Esc скасовує) + Show home recent files Показувати нещодавні файли на головному екрані Clear Brush Controls Очистити параметри пензля Reset All Locked Settings Скинути всі заблоковані параметри Copy Texture to Other Tools Скопіювати текстуру в інші інструменти @@ -3834,5 +3857,103 @@ curves Smooth Згладити Action playback stopped Відтворення операції зупинено Step {step} is waiting for its dialog Крок {step} очікує на свій діалог @plural {n} action|{n} actions {n} операція|{n} операції|{n} операцій + Background (Default) Фон (типовий) +# Batch / Image Processor + Include All Subfolders Включити всі вкладені папки Auto Add/Delete Автододавання/видалення + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Не вмикайте також дзеркальне відображення в параметрах друку: два відображення скасовують одне одного. + Print Settings… Параметри друку… + Remember to turn off the printer's own colour management in Print Settings… Не забудьте вимкнути керування кольором самого принтера в параметрах друку… + Sent to {printer} Надіслано на {printer} + set in Print Settings… задається в параметрах друку… ICO is limited to 256 × 256 pixels ICO обмежено розміром 256 × 256 пікселів +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Використати вбудований профіль ({profile}) + Convert to the working space ({profile}) Перетворити в робочий простір ({profile}) + Remember this choice for future images Запам’ятати цей вибір для наступних зображень + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Знову ввімкнути запитання можна параметром Ask On Mismatch у Редагування › Налаштування кольору. + Button Mode Режим кнопок + Button Mode On Режим кнопок увімкнено + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Зробити інтерфейс темнішим + Lighten Interface Зробити інтерфейс світлішим +# Bevel & Emboss Technique. + Technique Метод + Chisel Hard Жорстке різьблення + Chisel Soft М'яке різьблення + Open color-less EXR as ACEScg Відкривати EXR без колірного профілю як ACEScg + +# View › Guides › right-click a guide + Edit Selected Guides Редагувати вибрані напрямні + Clear Selected Guides Очистити вибрані напрямні + Pasting from other apps is unavailable Вставлення з інших застосунків недоступне + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. У цьому сеансі PhotoCraft не має доступу до системного буфера обміну. У Wayland пісочниця Flatpak приховує протокол, через який читаються зображення з інших застосунків. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Щоб вставляти з інших застосунків, запустіть PhotoCraft під XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Нічого вставити: тут PhotoCraft не може прочитати системний буфер обміну + Delete this set Видалити цей набір + New Set… Новий набір… + PhotoCraft Defaults Стандартні PhotoCraft + Save a list of these shortcuts as a web page Зберегти список цих клавіатурних скорочень як вебсторінку + Save the changes to this set Зберегти зміни в цьому наборі + Save these shortcuts as a .kys file Зберегти ці клавіатурні скорочення як файл .kys + Save these shortcuts as a new set Зберегти ці клавіатурні скорочення як новий набір + Set deleted. Набір видалено. + Set saved. Набір збережено. + Set: Набір: + Summarize… Зведення… + There are too many sets: delete one first. Забагато наборів: спершу видаліть один. + set name назва набору + {set} (modified) {set} (змінено) + Show file name in title Показувати ім’я файла в заголовку + Accept and Go to Conflict Прийняти й перейти до конфлікту + Changes undone. Зміни скасовано. + Choose a new shortcut for this command, or leave it without one. Виберіть нове клавіатурне скорочення для цієї команди або залиште її без нього. + Put this command's shortcuts back as they were Повернути клавіатурні скорочення цієї команди + Take the shortcut from the other command and select it Забрати скорочення в іншої команди й вибрати її + Undo Changes Скасувати зміни + {shortcut} is reserved by the system and can't be assigned. {shortcut} зарезервовано системою, його не можна призначити. + Low-Latency Drawing (vsync off, applies at next launch) Малювання з малою затримкою (без вертикальної синхронізації, після перезапуску) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Вільне трансформування ще застосовується; спробуйте знову, коли воно завершиться. + The transform couldn't be applied. Не вдалося застосувати трансформування. +# 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, 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} + 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. Замініть його цим документом або скасуйте й скористайтеся Файл › Зберегти як, щоб зберегти обидва. + Decrease Brush Size Зменшити розмір пензля + Increase Brush Size Збільшити розмір пензля + Decrease Brush Hardness Зменшити жорсткість пензля + Increase Brush Hardness Збільшити жорсткість пензля +# Dynamic color-picker titles and warnings about losing editable layer content. + Color Picker (Background Color) Вибір кольору (колір тла) + Color Picker (Foreground Color) Вибір кольору (основний колір) + Color Picker (Custom Canvas Color) Вибір кольору (власний колір полотна) + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Щоб продовжити, потрібно растеризувати цей текстовий шар. Після цього його текст більше не можна буде редагувати. Растеризувати текст? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Щоб продовжити, потрібно растеризувати цей шар фігури. Після цього його більше не можна буде редагувати як фігуру. Растеризувати фігуру? + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Щоб продовжити, потрібно растеризувати цей смартоб’єкт. Після цього його вміст більше не можна буде редагувати. Растеризувати смартоб’єкт? + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Щоб продовжити, потрібно растеризувати цей шар заливки. Після цього його заливку більше не можна буде редагувати. Растеризувати шар? + Memory Usage Використання пам’яті + Per-document RAM limit (MB) Ліміт RAM на документ (МБ) + Installed RAM: {ram} MB; this limit: {percent}% Встановлено RAM: {ram} МБ; цей ліміт: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Обмежує пікселі плюс Історію кожного відкритого документа. Найстаріші стани скасування відкидаються після перевищення ліміту. + Applies immediately when saved. Memory is not reserved in advance. Застосовується одразу після збереження. Пам’ять заздалегідь не резервується. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Якщо пам’ять GPU — Авто, це також обмежує бюджет GPU. Стеля — чверть RAM і, якщо відомо, три восьмих пам’яті карти, мінімум 512 МБ. Більший ліміт бюджет GPU не збільшує. + Use Використати + In Front Спереду + Behind Ззаду + +# DDS Options (#1327) + DDS Options Параметри DDS + Compression Стиснення + Generate Mipmaps Створити MIP-карти diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 88b855a707..8f8e1c3443 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -511,6 +511,9 @@ # Field Blur pins (#3373) No pin selected Chưa chọn ghim nào Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Bấm vào ảnh để thêm ghim; kéo ghim để di chuyển, hoặc kéo vòng của ghim để đổi độ mờ. Delete xóa ghim đã chọn. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Kéo các tay nắm trên đường viền để đổi kích thước và xoay, kéo ô vuông để bo tròn góc, và kéo các chấm bên trong để đặt nơi bắt đầu làm mờ. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Kéo ghim để di chuyển dải, hoặc kéo vòng của ghim để đổi độ mờ. Kéo các đường liền để nới rộng dải sắc nét, các đường nét đứt để đặt vùng chuyển tiếp, và các chấm để xoay dải. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Kéo các tay nắm trên đường viền để đổi kích thước và xoay; vòng tròn đặt góc làm mờ. Filter Gallery… Thư viện bộ lọc… Find Edges Tìm cạnh Flame… Ngọn lửa… @@ -1567,7 +1570,6 @@ cameraRaw Shadows Vùng tối Cancelled opening {name} Đã hủy mở {name} The document “{name}” does not have an embedded {mode} profile. Tài liệu “{name}” không có hồ sơ {mode} nhúng. The document “{name}” has an embedded color profile that does not match the current {mode} working space. Tài liệu “{name}” có hồ sơ màu nhúng không khớp với không gian làm việc {mode} hiện tại. - Embedded: {embedded} Nhúng: {embedded} Working: {working} Không gian làm việc: {working} Cancelled {label} Đã hủy {label} Cancel transform (Esc) Hủy biến đổi (Esc) @@ -1580,8 +1582,6 @@ cameraRaw Shadows Vùng tối Embedded Profile Mismatch Hồ sơ nhúng không khớp Assign working space Gán không gian làm việc Leave as is (don't color manage) Giữ nguyên (không quản lý màu) - Use the embedded profile (instead of the working space) Dùng hồ sơ nhúng (thay vì không gian làm việc) - Convert document's colors to the working space Chuyển màu tài liệu sang không gian làm việc Discard the embedded profile (don't color manage) Bỏ hồ sơ nhúng (không quản lý màu) Native file drag-and-drop is unavailable Kéo thả tệp gốc không khả dụng Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Kéo thả tệp gốc chưa được hỗ trợ trên Wayland. Dùng Tệp › Mở, hoặc chạy PhotoCraft dưới XWayland với `WAYLAND_DISPLAY= photocraft`. @@ -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 @@ -2664,6 +2663,8 @@ shape Appearance Diện mạo Brush preview color Màu xem trước cọ Cache levels Mức bộ nhớ đệm Show canvas dots Hiện chấm vùng vẽ + Clip thumbnails to layer bounds Cắt hình thu nhỏ theo biên của lớp + Clip Thumbnails to Layer Bounds Cắt hình thu nhỏ theo biên của lớp Color picker Bộ chọn màu Color space Không gian màu Column width Độ rộng cột @@ -2804,6 +2805,8 @@ fontWeight Italic Nghiêng Never Show Overlay Không bao giờ hiện lớp phủ Auto Hide Notifications Tự động ẩn thông báo Notification Duration (seconds) Thời lượng thông báo (giây) + Bundle Arrow-Key Nudges Gộp dịch chuyển bằng phím mũi tên + Nudge Bundle Pause (ms) Thời gian chờ gộp dịch chuyển (ms) Dark Theme Giao diện tối Light Theme Giao diện sáng Pro Medium Pro trung bình @@ -3587,11 +3590,30 @@ curves Smooth Làm mượt Stroke Path… Đi nét đường dẫn… Apply mask to layer before removing? Áp dụng mặt nạ vào lớp trước khi xóa? Delete the vector mask? Xóa mặt nạ vectơ? + Add Shortcut Thêm phím tắt + Response curve Đường cong phản hồi Object Finder Trình tìm đối tượng No object found there — drag a rectangle around it Không tìm thấy đối tượng ở đó — hãy kéo một hình chữ nhật quanh nó Remove after each stroke Xóa sau mỗi nét vẽ Apply the removal ({key}) Áp dụng việc xóa ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy) +# Edit › Content-Aware Fill workspace + Excluded Area Vùng loại trừ + Fill Settings Cài đặt tô + Full Toàn phần + Indicates Biểu thị + Mirror Phản chiếu + Output Settings Cài đặt đầu ra + Output To Đầu ra tới + Rectangular Chữ nhật + Rotation Adaptation Thích ứng xoay + Sampling Area Vùng lấy mẫu + Sampling Area Options Tùy chọn vùng lấy mẫu + Sampling Area Overlay Lớp phủ vùng lấy mẫu + Sampling Brush Tool Công cụ Cọ lấy mẫu + Show Sampling Area Hiện vùng lấy mẫu + Very High Rất cao + Show home recent files Hiện tệp gần đây trên màn hình chính Clear Brush Controls Xóa điều khiển cọ Reset All Locked Settings Đặt lại tất cả cài đặt đã khóa Copy Texture to Other Tools Sao chép họa tiết sang các công cụ khác @@ -3618,9 +3640,99 @@ curves Smooth Làm mượt Action playback stopped Đã dừng phát hành động Step {step} is waiting for its dialog Bước {step} đang chờ hộp thoại của nó @plural {n} action|{n} actions {n} hành động + Background (Default) Nền (mặc định) Presentation Trình bày +# Batch / Image Processor + Include All Subfolders Bao gồm tất cả thư mục con Gradient Preset Options Tùy chọn thiết lập sẵn chuyển sắc Import .GRD Nhập .GRD Choose a gradient file (.grd). Chọn tệp chuyển sắc (.grd). Auto Add/Delete Tự động thêm/xóa + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Đừng bật thêm Ảnh phản chiếu trong thiết lập in: hai lần lật sẽ triệt tiêu nhau. + Print Settings… Thiết lập in… + Remember to turn off the printer's own colour management in Print Settings… Nhớ tắt quản lý màu riêng của máy in trong thiết lập in… + Sent to {printer} Đã gửi tới {printer} + set in Print Settings… đặt trong thiết lập in… ICO is limited to 256 × 256 pixels ICO giới hạn ở 256 × 256 pixel +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Dùng hồ sơ nhúng ({profile}) + Convert to the working space ({profile}) Chuyển sang không gian làm việc ({profile}) + Remember this choice for future images Ghi nhớ lựa chọn này cho các ảnh sau + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Bật lại câu hỏi bằng Ask On Mismatch trong Chỉnh sửa › Cài đặt màu. + Button Mode Chế độ nút + Button Mode On Chế độ nút bật + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Làm tối giao diện + Lighten Interface Làm sáng giao diện +# Bevel & Emboss Technique. + Technique Kỹ thuật + Chisel Hard Đục cứng + Chisel Soft Đục mềm + Open color-less EXR as ACEScg Mở EXR không khai báo màu sắc dưới dạng ACEScg + +# View › Guides › right-click a guide + Edit Selected Guides Sửa đường căn đã chọn + Clear Selected Guides Xóa đường căn đã chọn + Pasting from other apps is unavailable Không thể dán từ ứng dụng khác + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft không truy cập được bộ nhớ tạm của hệ thống trong phiên này. Trên Wayland, sandbox của Flatpak ẩn giao thức dùng để đọc hình ảnh từ ứng dụng khác. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Để dán từ ứng dụng khác, hãy khởi động PhotoCraft dưới XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Không có gì để dán: PhotoCraft không thể đọc bộ nhớ tạm của hệ thống ở đây + Delete this set Xóa bộ này + New Set… Bộ mới… + PhotoCraft Defaults Mặc định của PhotoCraft + Save a list of these shortcuts as a web page Lưu danh sách các phím tắt này thành trang web + Save the changes to this set Lưu thay đổi vào bộ này + Save these shortcuts as a .kys file Lưu các phím tắt này thành tệp .kys + Save these shortcuts as a new set Lưu các phím tắt này thành bộ mới + Set deleted. Đã xóa bộ. + Set saved. Đã lưu bộ. + Set: Bộ: + Summarize… Tóm tắt… + There are too many sets: delete one first. Có quá nhiều bộ: hãy xóa một bộ trước. + set name tên bộ + {set} (modified) {set} (đã sửa đổi) + Show file name in title Hiển thị tên tệp trong tiêu đề + Accept and Go to Conflict Chấp nhận và đến xung đột + Changes undone. Đã hoàn tác thay đổi. + Choose a new shortcut for this command, or leave it without one. Chọn phím tắt mới cho lệnh này, hoặc để lệnh không có phím tắt. + Put this command's shortcuts back as they were Khôi phục phím tắt của lệnh này như cũ + Take the shortcut from the other command and select it Lấy phím tắt khỏi lệnh kia và chọn lệnh đó + Undo Changes Hoàn tác thay đổi + {shortcut} is reserved by the system and can't be assigned. {shortcut} được hệ thống dành riêng và không thể gán. + Low-Latency Drawing (vsync off, applies at next launch) Vẽ độ trễ thấp (tắt vsync, áp dụng ở lần khởi động sau) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Biến đổi tự do vẫn đang được áp dụng; hãy thử lại khi hoàn tất. + The transform couldn't be applied. Không thể áp dụng phép biến đổi. +# 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, 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} + 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. + Decrease Brush Size Giảm kích thước cọ + Increase Brush Size Tăng kích thước cọ + Decrease Brush Hardness Giảm độ cứng của cọ + Increase Brush Hardness Tăng độ cứng của cọ + Memory Usage Sử dụng bộ nhớ + Per-document RAM limit (MB) Giới hạn RAM mỗi tài liệu (MB) + Installed RAM: {ram} MB; this limit: {percent}% RAM đã cài: {ram} MB; giới hạn này: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Giới hạn pixel cộng Lịch sử của mỗi tài liệu đang mở. Các trạng thái hoàn tác cũ nhất bị bỏ khi vượt giới hạn. + Applies immediately when saved. Memory is not reserved in advance. Áp dụng ngay khi lưu. Bộ nhớ không được dành trước. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Khi Bộ nhớ GPU là Tự động, cũng giới hạn ngân sách GPU. Trần là 1/4 RAM và, nếu biết, 3/8 bộ nhớ card, tối thiểu 512 MB. Tăng giới hạn không làm tăng ngân sách GPU. + Use Dùng + In Front Phía trước + Behind Phía sau + +# DDS Options (#1327) + DDS Options Tùy chọn DDS + Compression Nén + Generate Mipmaps Tạo mipmap diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2842771124..5efbe28990 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -561,6 +561,10 @@ # Field Blur pins (#3373) No pin selected 未选择图钉 Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 单击图像以添加图钉;拖动图钉可移动它,拖动其圆环可更改模糊程度。按 Delete 键删除所选图钉。 + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. 拖动轮廓上的手柄可调整大小并旋转,拖动方块可将角变圆,拖动内部的点可设置模糊开始的位置。 + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. 拖动图钉可移动条带,拖动其圆环可更改模糊程度。拖动实线可加宽清晰条带,拖动虚线可设置过渡,拖动圆点可旋转。 + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. 拖动轮廓上的手柄可调整大小并旋转;圆环设置模糊角度。 + Blur Angle 模糊角度 Iris Blur… 光圈模糊… Tilt-Shift… 移轴模糊… Path Blur… 路径模糊… @@ -768,6 +772,7 @@ Brush Settings 画笔设置 Brushes 画笔 Channels 通道 + Channel 通道 Character 字符 Character Styles 字符样式 Clone Source 仿制源 @@ -988,7 +993,6 @@ Contiguous 连续 Contrast 对比度 Control: 控制: - Convert document's colors to the working space 将文档颜色转换到工作空间 Convert to sRGB 转换为 sRGB Copies 份数 Copper 铜色 @@ -1044,6 +1048,7 @@ Delete Group 删除组 Delete Layer Mask 删除图层蒙版 Delete Layers 删除图层 + Add Shortcut 添加快捷键 Delete Shortcut 删除快捷键 Delete Timeline 删除时间轴 Delete Tool Preset 删除工具预设 @@ -1241,6 +1246,7 @@ Levels 色阶 Lighten 变亮 Lightness 明度 + Result 结果 Lights 亮部 Limits: 限制: Linear Gradient 线性渐变 @@ -1579,7 +1585,6 @@ Use Default 使用默认值 Use Legacy 使用旧版 Use Measurement Scale 使用测量比例 - Use the embedded profile (instead of the working space) 使用嵌入的配置文件(替代工作空间) Uses the foreground and background colors. 使用前景色和背景色。 Variables bind a layer's visibility, text or pixels to a named data slot. 变量将图层的可见性、文字或像素绑定到具名数据项。 Vertical scale 垂直缩放 @@ -1906,6 +1911,8 @@ Camera Raw Defaults Camera Raw 默认设置 Canvas border 画布边框 Show canvas dots 显示画布圆点 + Clip thumbnails to layer bounds 将缩略图裁剪到图层边界 + Clip Thumbnails to Layer Bounds 将缩略图裁剪到图层边界 Canvas color 画布颜色 Canvas custom color 画布自定颜色 Color picker 拾色器 @@ -2094,7 +2101,8 @@ Expand group (⌥-click: all groups) 展开组 (⌥-单击:所有组) Fade steps 渐隐步数 Hide the layer's effects (⌥-click: all layers) 隐藏图层效果 (⌥-单击:所有图层) - Import Brushes… (.abr) 导入画笔… (.abr) + Import Brushes… (.abr or image) 导入画笔… (.abr 或图像) + Import Brush from Image… 从图像导入画笔… Move Group Down 向下移动组 Move Group Up 向上移动组 Reset the brush to the defaults 将画笔重置为默认值 @@ -2261,7 +2269,6 @@ cameraRaw Blending 混合 Cancelled opening {name} 已取消打开 {name} The document “{name}” does not have an embedded {mode} profile. 文档“{name}”没有嵌入的 {mode} 配置文件。 The document “{name}” has an embedded color profile that does not match the current {mode} working space. 文档“{name}”嵌入的颜色配置文件与当前的 {mode} 工作空间不匹配。 - Embedded: {embedded} 嵌入:{embedded} Working: {working} 工作空间:{working} Developing {name} 正在处理 {name} Cancelled {label} 已取消 {label} @@ -2449,7 +2456,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 特细 @@ -2515,6 +2521,8 @@ fontWeight Italic 斜体 Color Picker (Layer Style Color) 拾色器(图层样式颜色) Auto Hide Notifications 自动隐藏通知 Notification Duration (seconds) 通知持续时间(秒) + Bundle Arrow-Key Nudges 合并方向键微移 + Nudge Bundle Pause (ms) 微移合并间隔(毫秒) Appearance Mode 外观模式 Dark Theme 深色主题 Light Theme 浅色主题 @@ -2641,7 +2649,28 @@ cameraData Lens 镜头 Snap to Origin 贴紧原点 Search settings… 搜索设置… No matching settings 没有匹配的设置 +# Edit › Content-Aware Fill workspace + Sampling Brush Tool 取样画笔工具 + Sampling Area Overlay 取样区域叠加 + Show Sampling Area 显示取样区域 + Indicates 指示 + Sampling Area 取样区域 + Excluded Area 排除区域 + Sampling Area Options 取样区域选项 + Rectangular 矩形 + Fill Settings 填充设置 + Rotation Adaptation 旋转适应 + Very High 非常高 + High 高 + Low 低 + Full 完全 + Mirror 镜像 + Output Settings 输出设置 + Output To 输出到 + Duplicate Layer 复制图层 + Contract 收缩 Loading fonts… 正在加载字体… + Response curve 响应曲线 Tool 工具 Simulate Pressure 模拟压力 Stroke Path… 描边路径… @@ -2719,6 +2748,7 @@ curves Smooth 平滑 Remove after each stroke 每次描边后移除 Apply the removal ({key}) 应用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描边:{n}(Enter 应用,Esc 取消) + Show home recent files 在主屏幕上显示最近的文件 Clear Brush Controls 清除画笔控件 Reset All Locked Settings 复位所有锁定的设置 Copy Texture to Other Tools 将纹理复制到其他工具 @@ -2745,5 +2775,101 @@ curves Smooth 平滑 Action playback stopped 动作播放已停止 Step {step} is waiting for its dialog 步骤 {step} 正在等待其对话框 @plural {n} action|{n} actions {n} 个动作 + Background (Default) 背景(默认) + Content-Aware Fill 内容识别填充 +# Batch / Image Processor + Include All Subfolders 包含所有子文件夹 Auto Add/Delete 自动添加/删除 + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. 不要同时勾选打印设置中的镜像:两次翻转会相互抵消。 + Print Settings… 打印设置… + Remember to turn off the printer's own colour management in Print Settings… 请记得在打印设置中关闭打印机自身的色彩管理… + Sent to {printer} 已发送到 {printer} + set in Print Settings… 在打印设置中设置… ICO is limited to 256 × 256 pixels ICO 最大为 256 × 256 像素 +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) 使用嵌入的配置文件({profile}) + Convert to the working space ({profile}) 转换到工作空间({profile}) + Remember this choice for future images 以后打开的图像都使用此选择 + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. 可在 编辑 › 颜色设置 中打开 Ask On Mismatch,重新显示此提示。 + Button Mode 按钮模式 + Button Mode On 按钮模式 开 + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface 界面变暗 + Lighten Interface 界面变亮 +# Bevel & Emboss Technique. + Technique 方法 + Chisel Hard 雕刻清晰 + Chisel Soft 雕刻柔和 + Open color-less EXR as ACEScg 将无色彩空间信息的 EXR 以 ACEScg 打开 +# Export As metadata and profile options + Color Profile 颜色配置文件 + EXIF EXIF + +# View › Guides › right-click a guide + Edit Selected Guides 编辑所选参考线 + Clear Selected Guides 清除所选参考线 + Pasting from other apps is unavailable 无法从其他应用粘贴 + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft 在此会话中无法访问系统剪贴板。在 Wayland 上,Flatpak 沙盒会隐藏读取其他应用图像所用的协议。 + To paste from other apps, start PhotoCraft under XWayland: `{command}` 要从其他应用粘贴,请在 XWayland 下启动 PhotoCraft:`{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here 没有可粘贴的内容:PhotoCraft 在此无法读取系统剪贴板 + Delete this set 删除此组 + New Set… 新建组… + PhotoCraft Defaults PhotoCraft 默认值 + Save a list of these shortcuts as a web page 将这些快捷键的列表存储为网页 + Save the changes to this set 将更改存储到此组 + Save these shortcuts as a .kys file 将这些快捷键存储为 .kys 文件 + Save these shortcuts as a new set 将这些快捷键存储为新组 + Set deleted. 已删除组。 + Set saved. 已存储组。 + Set: 组: + Summarize… 摘要… + There are too many sets: delete one first. 组太多:请先删除一个。 + set name 组名称 + {set} (modified) {set}(已修改) + Random 随机 + Show file name in title 标题中显示文件名 + Accept and Go to Conflict 接受并转到冲突 + Changes undone. 已撤消更改。 + Choose a new shortcut for this command, or leave it without one. 为此命令选择新的快捷键,或保留为无快捷键。 + Put this command's shortcuts back as they were 将此命令的快捷键恢复原样 + Take the shortcut from the other command and select it 从另一个命令移除该快捷键并选中它 + Undo Changes 撤消更改 + {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系统保留,无法指定。 + Low-Latency Drawing (vsync off, applies at next launch) 低延迟绘画(关闭垂直同步,下次启动时生效) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. 自由变换仍在应用中;请在完成后重试。 + The transform couldn't be applied. 无法应用变换。 +# 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, 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} + 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. 用此文档替换它,或取消并使用“文件 › 另存为”以保留两者。 + Decrease Brush Size 减小画笔大小 + Increase Brush Size 增大画笔大小 + Decrease Brush Hardness 减小画笔硬度 + Increase Brush Hardness 增大画笔硬度 + Memory Usage 内存使用 + Per-document RAM limit (MB) 每个文档的 RAM 上限 (MB) + Installed RAM: {ram} MB; this limit: {percent}% 已安装 RAM:{ram} MB;此上限:{percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. 限制每个打开文档的像素加历史记录。超出上限时会丢弃最旧的撤销状态。 + Applies immediately when saved. Memory is not reserved in advance. 保存后立即生效。不会预先占用内存。 + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. 当 GPU 内存为自动时,这也会限制 GPU 图层预算。上限为安装 RAM 的四分之一,以及(若已知)显存的八分之三,最低 512 MB。超过这些上限不会增加 GPU 预算。 + Use 使用 + In Front 前面 + Behind 后面 + Blend 混合 + +# DDS Options (#1327) + DDS Options DDS 选项 + Compression 压缩 + Generate Mipmaps 生成 Mipmap diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d02e06646e..ea0af79451 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -566,6 +566,10 @@ # Field Blur pins (#3373) No pin selected 未選取圖釘 Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 按一下影像即可新增圖釘;拖移圖釘可移動它,拖移其圓環可變更模糊程度。按 Delete 鍵刪除所選圖釘。 + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. 拖移輪廓上的控點可調整大小並旋轉,拖移方塊可將角變圓,拖移內部的點可設定模糊開始的位置。 + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. 拖移圖釘可移動條帶,拖移其圓環可變更模糊程度。拖移實線可加寬清晰條帶,拖移虛線可設定過渡,拖移圓點可旋轉。 + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. 拖移輪廓上的控點可調整大小並旋轉;圓環設定模糊角度。 + Blur Angle 模糊角度 Iris Blur… 光圈模糊… Tilt-Shift… 傾斜偏移… Path Blur… 路徑模糊… @@ -773,6 +777,7 @@ Brush Settings 筆刷設定 Brushes 筆刷 Channels 色版 + Channel 色版 Character 字元 Character Styles 字元樣式 Clone Source 仿製來源 @@ -992,7 +997,6 @@ Contiguous 連續的 Contrast 對比 Control: 控制: - Convert document's colors to the working space 將文件顏色轉換為工作空間 Convert to sRGB 轉換為 sRGB Copies 份數 Copper 銅色 @@ -1047,6 +1051,7 @@ Delete Group 刪除群組 Delete Layer Mask 刪除圖層遮色片 Delete Layers 刪除圖層 + Add Shortcut 新增快速鍵 Delete Shortcut 刪除快速鍵 Delete Timeline 刪除時間軸 Delete Tool Preset 刪除工具預設集 @@ -1242,6 +1247,7 @@ Levels 色階 Lighten 變亮 Lightness 明亮 + Result 結果 Lights 燈光 Limits: 限制: Linear Gradient 線性漸層 @@ -1575,7 +1581,6 @@ Use Default 使用預設值 Use Legacy 使用舊版 Use Measurement Scale 使用測量比例 - Use the embedded profile (instead of the working space) 使用內嵌描述檔 (而非工作空間) Variables bind a layer's visibility, text or pixels to a named data slot. 變數可將圖層的可見度、文字或像素繫結至具名的資料欄位。 Vertical scale 垂直縮放 Vibrance 自然飽和度 @@ -1899,6 +1904,8 @@ Camera Raw Defaults Camera Raw 預設值 Canvas border 畫布邊框 Show canvas dots 顯示畫布圓點 + Clip thumbnails to layer bounds 將縮圖裁切至圖層邊界 + Clip Thumbnails to Layer Bounds 將縮圖裁切至圖層邊界 Canvas color 畫布顏色 Canvas custom color 畫布自訂顏色 Color picker 檢色器 @@ -2087,7 +2094,8 @@ Expand group ({key}-click: all groups) 展開群組 ({key}+按一下:全部群組) Fade steps 淡出步數 Hide the layer's effects ({key}-click: all layers) 隱藏圖層效果 ({key}+按一下:所有圖層) - Import Brushes… (.abr) 匯入筆刷… (.abr) + Import Brushes… (.abr or image) 匯入筆刷… (.abr 或影像) + Import Brush from Image… 從影像匯入筆刷… Move Group Down 下移群組 Move Group Up 上移群組 Reset the brush to the defaults 將筆刷重設為預設值 @@ -2200,7 +2208,6 @@ Cancelled opening {name} 已取消開啟 {name} The document “{name}” does not have an embedded {mode} profile. 文件「{name}」沒有嵌入的 {mode} 描述檔。 The document “{name}” has an embedded color profile that does not match the current {mode} working space. 文件「{name}」嵌入的色彩描述檔與目前的 {mode} 工作空間不符。 - Embedded: {embedded} 嵌入:{embedded} Working: {working} 工作空間:{working} Developing {name} 正在處理 {name} Cancelled {label} 已取消 {label} @@ -2419,7 +2426,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 特細 @@ -2485,6 +2491,8 @@ fontWeight Italic 斜體 Color Picker (Layer Style Color) 檢色器(圖層樣式顏色) Auto Hide Notifications 自動隱藏通知 Notification Duration (seconds) 通知持續時間(秒) + Bundle Arrow-Key Nudges 合併方向鍵微移 + Nudge Bundle Pause (ms) 微移合併間隔(毫秒) Appearance Mode 外觀模式 Dark Theme 深色主題 Light Theme 淺色主題 @@ -2611,7 +2619,28 @@ cameraData Lens 鏡頭 Snap to Origin 靠齊原點 Search settings… 搜尋設定… No matching settings 沒有符合的設定 +# Edit › Content-Aware Fill workspace + Sampling Brush Tool 取樣筆刷工具 + Sampling Area Overlay 取樣區域覆蓋 + Show Sampling Area 顯示取樣區域 + Indicates 指示 + Sampling Area 取樣區域 + Excluded Area 排除區域 + Sampling Area Options 取樣區域選項 + Rectangular 矩形 + Fill Settings 填色設定 + Rotation Adaptation 旋轉調整 + Very High 非常高 + High 高 + Low 低 + Full 完全 + Mirror 鏡像 + Output Settings 輸出設定 + Output To 輸出至 + Duplicate Layer 複製圖層 + Contract 縮減 Loading fonts… 正在載入字體… + Response curve 回應曲線 Tool 工具 Simulate Pressure 模擬壓力 Stroke Path… 筆畫路徑… @@ -2704,6 +2733,7 @@ curves Smooth 平滑 Remove after each stroke 每次描邊後移除 Apply the removal ({key}) 套用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描邊:{n}(Enter 套用,Esc 取消) + Show home recent files 在主畫面上顯示最近的檔案 Clear Brush Controls 清除筆刷控制項 Reset All Locked Settings 重設所有鎖定的設定 Copy Texture to Other Tools 複製紋理至其他工具 @@ -2730,5 +2760,101 @@ curves Smooth 平滑 Action playback stopped 動作播放已停止 Step {step} is waiting for its dialog 步驟 {step} 正在等待其對話框 @plural {n} action|{n} actions {n} 個動作 + Background (Default) 背景(預設) + Content-Aware Fill 內容感知填滿 +# Batch / Image Processor + Include All Subfolders 包含所有子資料夾 Auto Add/Delete 自動增加/刪除 + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. 請勿同時勾選列印設定中的鏡像:兩次翻轉會互相抵消。 + Print Settings… 列印設定… + Remember to turn off the printer's own colour management in Print Settings… 請記得在列印設定中關閉印表機本身的色彩管理… + Sent to {printer} 已傳送至 {printer} + set in Print Settings… 在列印設定中設定… ICO is limited to 256 × 256 pixels ICO 最大為 256 × 256 像素 +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) 使用內嵌描述檔 ({profile}) + Convert to the working space ({profile}) 轉換為工作空間 ({profile}) + Remember this choice for future images 之後開啟的影像都使用此選擇 + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. 可在 編輯 › 色彩設定 中開啟 Ask On Mismatch,重新顯示此提示。 + Button Mode 按鈕模式 + Button Mode On 按鈕模式 開 + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface 介面變暗 + Lighten Interface 介面變亮 +# Bevel & Emboss Technique. + Technique 方法 + Chisel Hard 雕刻清晰 + Chisel Soft 雕刻柔和 + Open color-less EXR as ACEScg 將無色彩空間資訊的 EXR 以 ACEScg 開啟 +# Export As metadata and profile options + Color Profile 色彩描述檔 + EXIF EXIF + +# View › Guides › right-click a guide + Edit Selected Guides 編輯所選參考線 + Clear Selected Guides 清除所選參考線 + Pasting from other apps is unavailable 無法從其他應用程式貼上 + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft 在此工作階段中無法存取系統剪貼簿。在 Wayland 上,Flatpak 沙盒會隱藏讀取其他應用程式影像所用的協定。 + To paste from other apps, start PhotoCraft under XWayland: `{command}` 若要從其他應用程式貼上,請在 XWayland 下啟動 PhotoCraft:`{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here 沒有可貼上的內容:PhotoCraft 在此無法讀取系統剪貼簿 + Delete this set 刪除此組合 + New Set… 新增組合… + PhotoCraft Defaults PhotoCraft 預設值 + Save a list of these shortcuts as a web page 將這些快速鍵的清單儲存為網頁 + Save the changes to this set 將變更儲存到此組合 + Save these shortcuts as a .kys file 將這些快速鍵儲存為 .kys 檔案 + Save these shortcuts as a new set 將這些快速鍵儲存為新組合 + Set deleted. 已刪除組合。 + Set saved. 已儲存組合。 + Set: 組合: + Summarize… 摘要… + There are too many sets: delete one first. 組合太多:請先刪除一個。 + set name 組合名稱 + {set} (modified) {set}(已修改) + Random 隨機 + Show file name in title 標題中顯示檔案名稱 + Accept and Go to Conflict 接受並前往衝突 + Changes undone. 已還原變更。 + Choose a new shortcut for this command, or leave it without one. 為此指令選擇新的快速鍵,或保持沒有快速鍵。 + Put this command's shortcuts back as they were 將此指令的快速鍵恢復原狀 + Take the shortcut from the other command and select it 從另一個指令移除該快速鍵並選取它 + Undo Changes 還原變更 + {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系統保留,無法指定。 + Low-Latency Drawing (vsync off, applies at next launch) 低延遲繪圖(關閉垂直同步,下次啟動時生效) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. 任意變形仍在套用中;請在完成後再試一次。 + The transform couldn't be applied. 無法套用變形。 +# 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, 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} + 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. 以此文件取代它,或取消並使用「檔案 › 另存新檔」以保留兩者。 + Decrease Brush Size 縮小筆刷大小 + Increase Brush Size 放大筆刷大小 + Decrease Brush Hardness 降低筆刷硬度 + Increase Brush Hardness 提高筆刷硬度 + Memory Usage 記憶體使用量 + Per-document RAM limit (MB) 每個文件的 RAM 上限 (MB) + Installed RAM: {ram} MB; this limit: {percent}% 已安裝 RAM:{ram} MB;此上限:{percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. 限制每個開啟文件的像素加歷史記錄。超出上限時會捨棄最舊的復原狀態。 + Applies immediately when saved. Memory is not reserved in advance. 儲存後立即套用。不會預先保留記憶體。 + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. 當 GPU 記憶體為自動時,這也會限制 GPU 圖層預算。上限為安裝 RAM 的四分之一,以及(若已知)顯示卡記憶體的八分之三,最低 512 MB。超過這些上限不會增加 GPU 預算。 + Use 使用 + In Front 前面 + Behind 後面 + Blend 混合 + +# DDS Options (#1327) + DDS Options DDS 選項 + Compression 壓縮 + Generate Mipmaps 產生 Mipmap diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 4839fdfc76..7fdaf4f7d0 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -39,6 +39,8 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("diamond", include_bytes!("../../../assets/icons/diamond.svg")), ("direct-select", include_bytes!("../../../assets/icons/direct-select.svg")), ("droplet", include_bytes!("../../../assets/icons/droplet.svg")), + // Original: a dashed horizontal ellipse (Elliptical Marquee), on Lucide's grid and stroke. + ("ellipse-dashed", include_bytes!("../../../assets/icons/ellipse-dashed.svg")), ("ellipsis", include_bytes!("../../../assets/icons/ellipsis.svg")), ("eraser", include_bytes!("../../../assets/icons/eraser.svg")), // Derived from Lucide's eraser (ISC): with a sparkle (Magic Eraser) / a transparency grid (Background Eraser). @@ -101,6 +103,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("pause", include_bytes!("../../../assets/icons/pause.svg")), ("plus", include_bytes!("../../../assets/icons/plus.svg")), ("pointer", include_bytes!("../../../assets/icons/pointer.svg")), + ("quick-selection", include_bytes!("../../../assets/icons/quick-selection.svg")), ("rectangle-horizontal", include_bytes!("../../../assets/icons/rectangle-horizontal.svg")), ("rectangle-vertical", include_bytes!("../../../assets/icons/rectangle-vertical.svg")), ("redo-2", include_bytes!("../../../assets/icons/redo-2.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 215e5994e8..37d0b4aeab 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -87,7 +87,7 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::Move => "move", Tool::Artboard => "artboard", Tool::RectMarquee => "square-dashed", - Tool::EllipseMarquee => "circle-dashed", + Tool::EllipseMarquee => "ellipse-dashed", Tool::SingleRowMarquee => "marquee-row", Tool::SingleColumnMarquee => "marquee-column", Tool::Brush => "brush", @@ -133,7 +133,7 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::Dodge => "lollipop", Tool::Burn => "flame", Tool::Sponge => "cloud", - Tool::QuickSelection => "circle-dashed", + Tool::QuickSelection => "quick-selection", Tool::ObjectSelection => "square-dashed-mouse-pointer", Tool::Pen => "pen-tool", Tool::FreeformPen => "pen-freeform", @@ -248,6 +248,65 @@ mod tests { ); } + /// Rasterize an icon to its ink mask at `size` px. + fn ink(name: &str, size: u32) -> Vec { + let egui::ImageSource::Bytes { bytes, .. } = white_icons().source(name) else { panic!("icon must be embedded bytes") }; + let image = egui_extras::image::load_svg_bytes_with_size( + bytes.as_ref(), + egui::load::SizeHint::Size { width: size, height: size, maintain_aspect_ratio: true }, + &Default::default(), + ) + .unwrap(); + assert_eq!(image.size, [size as usize; 2]); + image.pixels.iter().map(|p| p.a() > 16).collect() + } + + #[test] + fn quick_selection_icon_differs_from_ellipse_marquee() { + // #5116: Quick Selection used to reuse the Ellipse Marquee's dashed circle. + assert_ne!(tool_icon(Tool::QuickSelection), tool_icon(Tool::EllipseMarquee)); + for size in [14_u32, 28] { + let (quick, ellipse) = (ink(tool_icon(Tool::QuickSelection), size), ink(tool_icon(Tool::EllipseMarquee), size)); + let shared = quick.iter().zip(&ellipse).filter(|(a, b)| **a && **b).count(); + let union = quick.iter().zip(&ellipse).filter(|(a, b)| **a || **b).count(); + assert!(union > 0, "icons must have ink at {size}px"); + let overlap = shared as f32 / union as f32; + assert!(overlap < 0.5, "the two glyphs must read as distinct: {overlap:.2} of their ink overlaps at {size}px"); + // The dashed selection "C" must actually rasterize down the left edge, where the brush has no ink. + let s = size as usize; + assert!(quick.iter().enumerate().any(|(i, a)| *a && i % s < s / 5), "the dashed selection edge is missing at {size}px"); + } + } + + /// Ink bounding box `(width, height)` of a rasterized mask. + fn ink_extent(mask: &[bool], size: u32) -> (usize, usize) { + let s = size as usize; + let (xs, ys): (Vec, Vec) = mask.iter().enumerate().filter(|(_, a)| **a).map(|(i, _)| (i % s, i / s)).unzip(); + let span = |v: &[usize]| v.iter().max().zip(v.iter().min()).map_or(0, |(hi, lo)| hi - lo + 1); + (span(&xs), span(&ys)) + } + + #[test] + fn ellipse_marquee_icon_is_a_wide_ellipse() { + // #6875: Photoshop's Elliptical Marquee glyph is a horizontal ellipse, not a circle. + assert_ne!(tool_icon(Tool::EllipseMarquee), "circle-dashed"); + for size in [14_u32, 28] { + let (w, h) = ink_extent(&ink(tool_icon(Tool::EllipseMarquee), size), size); + assert!(w as f32 >= h as f32 * 1.15, "ellipse marquee ink is {w}x{h} at {size}px: not wider than tall"); + } + } + + #[test] + fn quick_selection_icon_is_a_tall_selection_with_a_brush() { + // #6875: Photoshop's Quick Selection glyph is a dashed, upright rounded "C" with a brush. + let size = 28_u32; + let s = size as usize; + let quick = ink(tool_icon(Tool::QuickSelection), size); + // The left edge of the selection runs most of the icon's height. + let left_rows = (0..s).filter(|y| (0..s / 5).any(|x| quick.get(y * s + x).copied().unwrap_or(false))).count(); + assert!(left_rows * 5 > s * 2, "left selection edge covers only {left_rows} of {s} rows"); + } + #[test] fn shared_catalog_preserves_existing_icons() { // Characterize the pre-extraction loader across every real asset. The UI's tint, diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 3754e821fc..e06182870c 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -317,6 +317,56 @@ fn curves_picker_samples_document_coordinates_through_the_view_transform() { assert!((red - 32.0).abs() < 0.6, "sampled document-right blue patch through the flipped 400% view: {red}"); } +/// #7325: ⌘/Ctrl-click on the image with Curves open adds a point at the pixel's tone. The +/// modifier is the press's own, a drag onto another tone adds nothing more, and the Hand tool +/// does not take the press for a pan. +#[test] +fn command_click_under_curves_adds_one_point_per_press_not_per_frame() { + // The Hand tool too: the press is the dialog's, not the start of a pan. + for hand in [false, true] { + let mut h = harness(); + if hand { + h.state_mut().ui.tool = crate::state::Tool::Hand; + } + h.state_mut().run("select.rect", json!({"x": 0, "y": 0, "width": 80, "height": 300})).unwrap(); + h.state_mut().run("edit.fill", json!({"color": "#804020"})).unwrap(); + h.state_mut().run("select.rect", json!({"x": 80, "y": 0, "width": 320, "height": 300})).unwrap(); + h.state_mut().run("edit.fill", json!({"color": "#20a0e0"})).unwrap(); + h.state_mut().run("select.deselect", json!({})).unwrap(); + let view = &mut h.state_mut().ui.views[0]; + (view.zoom, view.center, view.fit_pending) = (4.0, [200.0, 150.0], false); + let dialog = crate::adjust_dialog::open(h.state_mut(), "image.adjustments.curves").unwrap(); + h.run_steps(3); + let points = |h: &Harness<'static, PhotocraftApp>| { + let d = h.state().ui.dialogs.iter().find(|d| d.id == dialog).unwrap(); + d.fields.get("points").and_then(serde_json::Value::as_array).map_or(0, Vec::len) + }; + let canvas = h.state().last_canvas_rect; + // Document x 65 (the brown strip) and 100 (the blue), both left of the centred dialog. + let (brown, blue) = (pos2(canvas.left() + 60.0, canvas.center().y), pos2(canvas.left() + 200.0, canvas.center().y)); + h.hover_at(brown); + h.run_steps(1); + press(&mut h, brown, true); + press(&mut h, brown, false); + assert_eq!(points(&h), 2, "a plain click adds no point (hand: {hand})"); + + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::COMMAND }; + // ⌘ stays held through the drag, so only the press can tell the frames apart. + h.event(egui::Event::ModifiersChanged(Modifiers::COMMAND)); + h.event(button(brown, true)); + h.run_steps(1); + assert_eq!(points(&h), 3, "the press adds the point (hand: {hand})"); + for t in [0.25, 0.5, 0.75, 1.0] { + h.event(egui::Event::PointerMoved(brown + (blue - brown) * t)); + h.run_steps(1); + } + h.event(button(blue, false)); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!(points(&h), 3, "dragging onto another tone adds nothing more (hand: {hand})"); + } +} + /// Type tool (#206): Alt+←/→ at a collapsed caret kerns the pair before it by 20/1000 em (100 /// with ⌘/Ctrl), one history step per press; ⌘/Ctrl+←/→ moves by word; Alt+Shift+→ extends /// the selection by a word. diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index f2b7d0fbc0..70ce973be0 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -84,6 +84,21 @@ fn frames_for_at_least(h: &mut Harness<'_, PhotocraftApp>, ms: u64, min_frames: (n, worst) } +/// Step at least `min` frames and until `ready` holds; returns (frames, slowest frame in ms). +/// The test fails when `ready` still does not hold after 60 s. +fn frames_until(h: &mut Harness<'_, PhotocraftApp>, min: u64, ready: impl Fn(&Harness<'_, PhotocraftApp>) -> bool) -> (u64, f64) { + let t = Instant::now(); + let (mut n, mut worst) = (0, 0.0f64); + while n < min || !ready(h) { + assert!(t.elapsed() < Duration::from_secs(60), "not ready after {n} frames"); + let f = Instant::now(); + h.step(); + worst = worst.max(f.elapsed().as_secs_f64() * 1000.0); + n += 1; + } + (n, worst) +} + #[test] fn the_frame_loop_keeps_running_during_a_slow_job_and_esc_cancels_it() { let mut h = app_harness(); @@ -91,7 +106,9 @@ fn the_frame_loop_keeps_running_during_a_slow_job_and_esc_cancels_it() { let doc_before = h.state().session.active().unwrap().doc.clone(); let job = slow_job(h.state_mut(), &gate); let frame0 = h.state().frame; - let (n, worst) = frames_for_at_least(&mut h, 400, 10); + // The worker thread reports "Filtering tiles" when it starts, which can be after the dialog appears. + let (n, worst) = frames_until(&mut h, 10, |h| h.query_by_label("Filtering tiles").is_some()); + assert!(h.state().session.job(job).is_some(), "the job is still running"); assert!(h.state().frame - frame0 >= 10, "the UI kept rendering ({n} frames)"); eprintln!("{n} frames during the job, slowest {worst:.1} ms"); // The status bar shows the job, and after the delay the modal progress dialog. diff --git a/crates/ui-egui/src/kys_import.rs b/crates/ui-egui/src/kys_import.rs index 41edaa1725..75f46a775b 100644 --- a/crates/ui-egui/src/kys_import.rs +++ b/crates/ui-egui/src/kys_import.rs @@ -32,12 +32,19 @@ pub fn plan(app: &PhotocraftApp, set: &KysSet) -> Plan { photocraft_engine::kys::plan(menu.chain(specs), set) } +/// The set's tool keys matched to our tools by name (Photoshop's names are ours): tool +/// shortcut id (`tools.select.move`) → letter, and the tools PhotoCraft doesn't have. +pub fn plan_tools(set: &KysSet) -> (BTreeMap, Vec) { + photocraft_engine::kys::plan_tools(photocraft_engine::shell_commands::TOOLS.iter().map(|t| (t.0, t.1)), set) +} + /// Put the plan into the open Keyboard Shortcuts dialog (opening one when none is), as /// overrides the OK button applies: a key equal to the command's default clears its override. /// Returns the dialog id and the plan as JSON. pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Result { let set = parse(text).map_err(|e| format!("{file_name}: {e}"))?; let plan = plan(app, &set); + let (tool_keys, unknown_tools) = plan_tools(&set); let id = match app.ui.dialogs.iter().find(|d| d.fields.get("__prefsui").and_then(Value::as_str) == Some("shortcuts")) { Some(d) => d.id, None => crate::prefs_ui::open_shortcuts(app, 0), @@ -52,16 +59,38 @@ pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Resu overrides.insert(cmd.clone(), sc.clone()); } } + // Tool keys (#5036): a letter other than the tool's own becomes its override; its own + // letter clears one, so the tool keeps cycling with the others that share it. + for tool in crate::state::Tool::ALL { + let id = crate::tool_shortcuts::id(tool); + let Some(key) = tool_keys.get(&id) else { continue }; + if crate::tool_shortcuts::default(tool).is_some_and(|d| d.eq_ignore_ascii_case(key)) { + overrides.remove(&id); + } else { + overrides.insert(id, key.clone()); + } + } + // A row's other keys are the command's further keys (#3665): Copy's F3 beside ⌘C. + let mut alternates: BTreeMap> = f.get("alternates").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + for (cmd, keys) in &plan.alternate_keys { + let main = plan.set.get(cmd).map(String::as_str); + if photocraft_engine::prefs::Preferences::default().alternates(cmd, main) == *keys { + alternates.remove(cmd); + } else { + alternates.insert(cmd.clone(), keys.clone()); + } + } let shown = std::path::Path::new(file_name).file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| file_name.to_string()); - let n = plan.set.len().to_string(); + let n = (plan.set.len() + tool_keys.len()).to_string(); let mut message = crate::i18n::fmt(tl!("Imported {n} shortcuts from {file}. Click OK to keep them."), &[("n", &n), ("file", &shown)]); - if !plan.unknown.is_empty() { - let u = plan.unknown.len().to_string(); + if !plan.unknown.is_empty() || !unknown_tools.is_empty() { + let u = (plan.unknown.len() + unknown_tools.len()).to_string(); message.push(' '); message.push_str(&crate::i18n::fmt(tl!("{n} commands are not in PhotoCraft."), &[("n", &u)])); } f.insert("tab".into(), json!(0)); f.insert("overrides".into(), json!(overrides)); + f.insert("alternates".into(), json!(alternates)); f.insert("selected".into(), json!("")); f.insert("capture".into(), json!(false)); f.insert("message".into(), json!(message)); @@ -74,7 +103,11 @@ pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Resu "unknown": plan.unknown, "unreadable": plan.unreadable, "alternates": plan.alternates, + "alternateKeys": plan.alternate_keys, "toolKeys": set.tool_keys, + "tools": tool_keys, + "unknownTools": unknown_tools, + "windows": set.windows, })) } diff --git a/crates/ui-egui/src/kys_import_tests.rs b/crates/ui-egui/src/kys_import_tests.rs index cf389e7650..b31ee98e68 100644 --- a/crates/ui-egui/src/kys_import_tests.rs +++ b/crates/ui-egui/src/kys_import_tests.rs @@ -61,7 +61,7 @@ fn app() -> PhotocraftApp { fn parses_rows_shortcuts_entities_and_tool_keys() { let set = parse(SAMPLE).unwrap(); assert_eq!(set.commands.len(), 11); - assert_eq!(set.commands[2], KysCommand { name: "Undo".into(), shortcuts: vec!["Cmd+Z".into(), "F1".into()] }); + assert_eq!(set.commands[2], KysCommand { name: "Undo".into(), shortcuts: vec!["Cmd+Z".into(), "F1".into()], id: None }); assert_eq!(set.commands[7].name, "Black & White..."); assert_eq!(set.tool_keys, 1, "the empty Default Colors row carries no key"); } @@ -104,7 +104,12 @@ fn rows_match_our_commands_by_label_with_photoshop_tie_breaks() { #[test] fn an_unreadable_shortcut_is_reported_not_imported() { let app = app(); - let set = super::KysSet { name: None, commands: vec![KysCommand { name: "Undo".into(), shortcuts: vec!["Cmd+Shift+Ctrl".into()] }], tool_keys: 0 }; + let set = super::KysSet { + name: None, + commands: vec![KysCommand { name: "Undo".into(), shortcuts: vec!["Cmd+Shift+Ctrl".into()], id: None }], + tool_keys: 0, + ..Default::default() + }; let p = plan(&app, &set); assert!(p.set.is_empty()); assert_eq!(p.unreadable, vec!["Undo".to_string()]); @@ -143,7 +148,9 @@ fn import_button_fills_the_dialog_and_ok_applies_the_set() { assert!(overrides.get("select.all").is_none(), "already the default: {overrides}"); assert!(overrides.get("view.zoomIn").is_none(), "Cmd+= is among the keys, so the default stays: {overrides}"); let message = field(&h, "message"); - assert_eq!(message, json!("Imported 9 shortcuts from texcuts.kys. Click OK to keep them. 2 commands are not in PhotoCraft.")); + // 9 commands and the Move Tool's V (its own letter: no override). + assert_eq!(message, json!("Imported 10 shortcuts from texcuts.kys. Click OK to keep them. 2 commands are not in PhotoCraft.")); + assert!(overrides.get("tools.select.move").is_none(), "{overrides}"); assert_eq!(field(&h, "capture"), json!(false)); assert_eq!(field(&h, "messageWarning"), json!(false), "an import clears the earlier shortcut conflict warning"); // Nothing is applied until OK. @@ -180,6 +187,31 @@ fn an_opened_kys_file_opens_the_dialog_filled() { assert!(bad.contains("not a keyboard shortcut set (.kys)"), "{bad}"); } +/// #3665: a row listing Copy's ⌘C and F3 keeps both; the old import kept the first key only, +/// and a row of F3 alone left ⌘C unbound only because the set says so. +#[test] +fn an_imported_row_keeps_its_second_key() { + let mut app = app(); + let set = "\ + F3Cmd+C\ + Cmd+X\ + Cmd+ZF1\ + "; + let r = super::import_text(&mut app, "ps.kys", set).unwrap(); + assert_eq!(r["alternateKeys"]["edit.copy"], json!(["F3"])); + let id = r["dialog"].as_u64().unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert!(f["overrides"].get("edit.copy").is_none(), "Copy keeps ⌘C: {}", f["overrides"]); + assert!(f["alternates"].get("edit.copy").is_none(), "F3 is Copy's default alternate"); + assert_eq!(f["alternates"]["edit.cut"], json!([]), "the set gives Cut no F2"); + assert_eq!(f["alternates"]["edit.undo"], json!(["F1"])); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(crate::shortcuts::effective_shortcut(&app, "edit.copy", Some("Cmd+C")).as_deref(), Some("Cmd+C")); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.copy"), ["F3"]); + assert!(crate::shortcuts::effective_alternates(&app, "edit.cut").is_empty()); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.undo"), ["F1"]); +} + type Store = std::sync::Arc>>; /// An app beside a Photoshop whose live set is `set`; `saved_prefs` is this app's own saved @@ -295,3 +327,31 @@ fn an_oversized_file_is_refused() { assert!(e.contains("larger than 4 MiB"), "{e}"); assert!(app.ui.dialogs.is_empty()); } + +#[test] +fn a_windows_set_with_tool_keys_imports_as_photoshop_meant_it() { + // Photoshop on Windows writes Ctrl for the primary modifier; the tool rows give letters. + let set = " +\tCtrl+CF3 +\tCtrl+Shift+Alt+L +\tV +\tN +\tQ +"; + let mut app = app(); + let r = super::import_text(&mut app, "win.kys", set).unwrap(); + assert_eq!((r["windows"].as_bool(), r["unknownTools"].clone()), (Some(true), json!(["Imaginary Tool"]))); + assert_eq!(r["tools"], json!({"tools.select.move": "V", "tools.select.pencil": "N"})); + let id = r["dialog"].as_u64().unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert!(f["overrides"].get("edit.copy").is_none(), "Ctrl+C on Windows is the default ⌘C: {}", f["overrides"]); + assert!(f["overrides"].get("tools.select.move").is_none(), "Move's own V keeps it cycling"); + assert_eq!(f["overrides"]["tools.select.pencil"], json!("N")); + let levels = f["overrides"].as_object().unwrap().iter().find(|(id, _)| id.contains("levels")).map(|(_, v)| v.clone()); + assert_eq!( + levels.as_ref().and_then(Value::as_str).and_then(photocraft_engine::prefs::normalize_shortcut), + photocraft_engine::prefs::normalize_shortcut("Cmd+Alt+Shift+L") + ); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(crate::tool_shortcuts::shortcut(&app, crate::state::Tool::Pencil).as_deref(), Some("N")); +} diff --git a/crates/ui-egui/src/layer_filter_ui.rs b/crates/ui-egui/src/layer_filter_ui.rs new file mode 100644 index 0000000000..e933d0c165 --- /dev/null +++ b/crates/ui-egui/src/layer_filter_ui.rs @@ -0,0 +1,162 @@ +//! The Layers panel's filter row (#5048): a filter-type dropdown, as in Photoshop, then the +//! controls for that type. Kind toggles layer kinds, Name matches part of the layer name, Mode +//! picks one blend mode. The criteria live in `UiState` (`layer_filter*`), so the control channel +//! can read and set them. + +use photocraft_color::BlendMode; +use photocraft_doc::{Layer, LayerContent}; +use serde::{Deserialize, Serialize}; + +use crate::state::UiState; +use crate::{PhotocraftApp, icons, widgets}; + +/// What the Layers panel filters by. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FilterBy { + #[default] + Kind, + Name, + Mode, +} + +/// The filter criteria, copied out of `UiState` for one frame of the Layers panel. +#[derive(Clone, Debug, PartialEq)] +pub struct Filter { + pub by: FilterBy, + pub kinds: Vec, + /// As typed; matched trimmed and ignoring case. + pub name: String, + pub mode: BlendMode, +} + +impl Filter { + pub fn of(ui: &UiState) -> Self { + Self { by: ui.layer_filter_by, kinds: ui.layer_filter.clone(), name: ui.layer_filter_name.clone(), mode: ui.layer_filter_mode } + } + + /// Whether the panel is filtered. Like Photoshop, Mode always filters (by Normal at first); + /// Kind and Name only once a kind is toggled or some text is typed. + pub fn active(&self) -> bool { + match self.by { + FilterBy::Kind => !self.kinds.is_empty(), + FilterBy::Name => !self.name.trim().is_empty(), + FilterBy::Mode => true, + } + } + + /// Whether `l` is listed under this filter. + pub fn shows(&self, l: &Layer) -> bool { + if !self.active() { + return true; + } + match self.by { + FilterBy::Kind => self.kinds.iter().any(|k| k == kind(l)), + FilterBy::Name => l.name.to_lowercase().contains(&self.name.trim().to_lowercase()), + FilterBy::Mode => l.blend == self.mode, + } + } +} + +/// The kinds the Kind filter toggles. +pub const KINDS: [&str; 5] = ["pixel", "adjustment", "type", "shape", "smart"]; + +/// `ui.set {layerFilter: {by?, kinds?, name?, mode?}}`: `current` with the given fields replaced, +/// or an error naming the bad field (nothing changes then). `by` is `kind`, `name` or `mode`; +/// `kinds` a list of [`KINDS`]; `mode` a blend mode name such as `Multiply` or `Pass Through`. +pub fn patched(current: &UiState, v: &serde_json::Value) -> Result { + let Some(o) = v.as_object() else { return Err("layerFilter must be an object".into()) }; + if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "mode"].contains(&k.as_str())) { + return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, mode)")); + } + let mut f = + Filter { by: current.layer_filter_by, kinds: current.layer_filter.clone(), name: current.layer_filter_name.clone(), mode: current.layer_filter_mode }; + if let Some(by) = o.get("by") { + f.by = serde_json::from_value(by.clone()).map_err(|_| "layerFilter.by must be kind, name or mode".to_string())?; + } + if let Some(kinds) = o.get("kinds") { + let list = kinds.as_array().ok_or("layerFilter.kinds must be a list")?; + f.kinds = Vec::new(); + for k in list { + let Some(k) = k.as_str().filter(|k| KINDS.contains(k)) else { + return Err(format!("layerFilter.kinds: unknown kind {k} (kinds: {})", KINDS.join(", "))); + }; + if !f.kinds.iter().any(|have| have == k) { + f.kinds.push(k.to_string()); + } + } + } + if let Some(name) = o.get("name") { + f.name = name.as_str().ok_or("layerFilter.name must be a string")?.to_string(); + } + if let Some(mode) = o.get("mode") { + let name = mode.as_str().ok_or("layerFilter.mode must be a blend mode name")?; + f.mode = photocraft_engine::commands::blend_from_str(name) + .filter(|m| *m == BlendMode::PassThrough || BlendMode::LAYER_MODES.contains(m)) + .ok_or_else(|| format!("layerFilter.mode: unknown blend mode `{name}`"))?; + } + Ok(f) +} + +/// Store `f` as the Layers panel's filter. +pub fn apply(ui: &mut UiState, f: Filter) { + ui.layer_filter_by = f.by; + ui.layer_filter = f.kinds; + ui.layer_filter_name = f.name; + ui.layer_filter_mode = f.mode; +} + +/// The kind filter's name for `l`'s content ("" for groups, which no kind matches). +fn kind(l: &Layer) -> &'static str { + match &l.content { + LayerContent::Raster(_) => "pixel", + LayerContent::Adjustment(_) | LayerContent::Fill(_) => "adjustment", + LayerContent::Text(_) => "type", + LayerContent::Shape(_) => "shape", + LayerContent::Smart(_) => "smart", + LayerContent::Group(_) => "", + } +} + +/// Draw the filter row: the type dropdown, then that type's controls. +pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let by = [(FilterBy::Kind, tl!("Kind")), (FilterBy::Name, tl!("Name")), (FilterBy::Mode, tl!("Mode"))]; + widgets::dropdown(ui, "layer-filter-by", &mut app.ui.layer_filter_by, &by, 64.0); + ui.add_space(4.0); + match app.ui.layer_filter_by { + FilterBy::Kind => { + for (kind, icon, tip) in [ + ("pixel", "image", tl!("Filter for pixel layers")), + ("adjustment", "adjustment-layer", tl!("Filter for adjustment layers")), + ("type", "type", tl!("Filter for type layers")), + ("shape", "square", tl!("Filter for shape layers")), + ("smart", "app-window", tl!("Filter for smart objects")), + ] { + let on = app.ui.layer_filter.iter().any(|k| k == kind); + if icons::button(ui, icon, 22.0, on, tip).clicked() { + if on { + app.ui.layer_filter.retain(|k| k != kind); + } else { + app.ui.layer_filter.push(kind.to_string()); + } + } + } + } + FilterBy::Name => { + let width = ui.available_width().max(48.0); + widgets::text_field(ui, ui.id().with("layer-filter-name"), &mut app.ui.layer_filter_name, tl!("Search"), width, false); + } + FilterBy::Mode => { + let modes: Vec<(BlendMode, &str)> = std::iter::once(BlendMode::PassThrough).chain(BlendMode::LAYER_MODES).map(|m| (m, m.label())).collect(); + let width = ui.available_width().max(96.0); + widgets::dropdown(ui, "layer-filter-mode", &mut app.ui.layer_filter_mode, &modes, width); + } + } + }); +} + +#[cfg(test)] +#[path = "layer_filter_ui_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/layer_filter_ui_tests.rs b/crates/ui-egui/src/layer_filter_ui_tests.rs new file mode 100644 index 0000000000..16400ae120 --- /dev/null +++ b/crates/ui-egui/src/layer_filter_ui_tests.rs @@ -0,0 +1,136 @@ +//! #5048: the Layers panel's filter-type dropdown (Kind, Name, Mode). + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use photocraft_color::BlendMode; +use photocraft_doc::Document; +use serde_json::json; + +use super::{Filter, FilterBy}; +use crate::PhotocraftApp; +use crate::state::UiState; + +/// Background, "Ink" (Multiply) and "Paper" in a group "Sheets", and "ink notes" (type) on top. +fn session() -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Paper"})).unwrap(); + s.execute("layer.groupLayers", json!({"name": "Sheets"})).unwrap(); + let paper = layer(&s.active().unwrap().doc, "Paper"); + s.execute("layer.select", json!({"layer": paper})).unwrap(); + let ink = s.execute("layer.new.layer", json!({"name": "Ink"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": ink, "blend": "Multiply"})).unwrap(); + let sheets = layer(&s.active().unwrap().doc, "Sheets"); + s.execute("layer.select", json!({"layer": sheets})).unwrap(); + let text = s.execute("type.create", json!({"text": "ink notes", "x": 4, "y": 20, "size": 12})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": text, "name": "ink notes"})).unwrap(); + s +} + +fn layer(doc: &Document, name: &str) -> u64 { + doc.walk().into_iter().find(|(_, _, l)| l.name == name).map(|(_, _, l)| l.id.0).unwrap_or_else(|| panic!("no layer {name}")) +} + +/// The names `filter` lists, top to bottom. +fn listed(doc: &Document, filter: &Filter) -> Vec { + crate::layer_tree_ui::display_rows(doc, filter.active()).into_iter().filter(|(_, l)| filter.shows(l)).map(|(_, l)| l.name.clone()).collect() +} + +fn filter(by: FilterBy, kinds: &[&str], name: &str, mode: BlendMode) -> Filter { + Filter::of(&UiState { + layer_filter_by: by, + layer_filter: kinds.iter().map(|k| k.to_string()).collect(), + layer_filter_name: name.into(), + layer_filter_mode: mode, + ..UiState::default() + }) +} + +#[test] +fn each_filter_type_lists_its_matches() { + let s = session(); + let doc = &s.active().unwrap().doc; + let all = listed(doc, &Filter::of(&UiState::default())); + assert_eq!(all, ["ink notes", "Sheets", "Ink", "Paper", "Background"]); + // Kind: groups match no kind, as before. + assert_eq!(listed(doc, &filter(FilterBy::Kind, &["pixel"], "", BlendMode::Normal)), ["Ink", "Paper", "Background"]); + assert_eq!(listed(doc, &filter(FilterBy::Kind, &["type"], "", BlendMode::Normal)), ["ink notes"]); + // Name: part of the name, ignoring case and surrounding spaces; groups match too. + assert_eq!(listed(doc, &filter(FilterBy::Name, &[], " INK ", BlendMode::Normal)), ["ink notes", "Ink"]); + assert_eq!(listed(doc, &filter(FilterBy::Name, &[], "sheet", BlendMode::Normal)), ["Sheets"]); + assert_eq!(listed(doc, &filter(FilterBy::Name, &[], "nothing like it", BlendMode::Normal)), Vec::::new()); + // Mode: one blend mode; a group's own Pass Through counts. + assert_eq!(listed(doc, &filter(FilterBy::Mode, &[], "", BlendMode::Multiply)), ["Ink"]); + assert_eq!(listed(doc, &filter(FilterBy::Mode, &[], "", BlendMode::PassThrough)), ["Sheets"]); + assert_eq!(listed(doc, &filter(FilterBy::Mode, &[], "", BlendMode::Normal)), ["ink notes", "Paper", "Background"]); +} + +#[test] +fn only_a_criterion_turns_kind_and_name_on_while_mode_always_filters() { + let kinds_left_on = filter(FilterBy::Name, &["pixel"], "", BlendMode::Multiply); + assert!(!kinds_left_on.active(), "the other types' criteria don't apply"); + assert!(!filter(FilterBy::Name, &[], " ", BlendMode::Normal).active(), "spaces are no criterion"); + assert!(!filter(FilterBy::Kind, &[], "ink", BlendMode::Normal).active()); + assert!(filter(FilterBy::Mode, &[], "", BlendMode::Normal).active(), "Mode filters by Normal at first, like Photoshop"); +} + +#[test] +fn saved_settings_without_the_new_fields_still_load() { + let mut saved = serde_json::to_value(UiState::default()).unwrap(); + let fields = saved.as_object_mut().unwrap(); + for key in ["layer_filter_by", "layer_filter_name", "layer_filter_mode"] { + assert!(fields.remove(key).is_some(), "{key} is saved"); + } + let ui: UiState = serde_json::from_value(saved).unwrap(); + assert_eq!((ui.layer_filter_by, ui.layer_filter_name.as_str(), ui.layer_filter_mode), (FilterBy::Kind, "", BlendMode::Normal)); + let round = serde_json::to_value(UiState { layer_filter_by: FilterBy::Mode, ..UiState::default() }).unwrap(); + assert_eq!(round["layer_filter_by"], "mode"); +} + +fn harness() -> Harness<'static, PhotocraftApp> { + let session = session(); + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(session, crate::Services::default()) + }); + h.run_steps(8); + h +} + +/// The names of the rows the Layers panel drew, top to bottom. +fn drawn(h: &Harness<'_, PhotocraftApp>) -> Vec { + let doc = &h.state().session.active().unwrap().doc; + let mut rows = crate::layer_row_ui::recorded(&h.ctx); + rows.sort_by(|a, b| a.row.top().total_cmp(&b.row.top())); + rows.iter().filter_map(|r| doc.walk().into_iter().find(|(_, _, l)| l.id.0 == r.layer).map(|(_, _, l)| l.name.clone())).collect() +} + +#[test] +fn the_dropdown_switches_the_panel_between_filter_types() { + let mut h = harness(); + assert_eq!(drawn(&h), ["ink notes", "Sheets", "Ink", "Paper", "Background"]); + // The type is a real dropdown now: open it and pick Name. + // A dropdown shows its choice as the combo box's value. + h.get_by_value("Kind").click(); + h.run_steps(4); + h.query_all_by_label("Name").last().expect("Name in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + assert_eq!(drawn(&h), ["ink notes", "Sheets", "Ink", "Paper", "Background"], "no text yet: every layer"); + h.state_mut().ui.layer_filter_name = "ink".into(); + h.run_steps(4); + assert_eq!(drawn(&h), ["ink notes", "Ink"]); + // Mode starts at Normal; the kind toggles left on under Kind don't leak into it. + h.state_mut().ui.layer_filter = vec!["type".into()]; + h.state_mut().ui.layer_filter_by = FilterBy::Mode; + h.run_steps(4); + assert_eq!(drawn(&h), ["ink notes", "Paper", "Background"]); + h.state_mut().ui.layer_filter_mode = BlendMode::Multiply; + h.run_steps(4); + assert_eq!(drawn(&h), ["Ink"]); + // Back to Kind: its own criterion applies again. + h.state_mut().ui.layer_filter_by = FilterBy::Kind; + h.run_steps(4); + assert_eq!(drawn(&h), ["ink notes"]); +} diff --git a/crates/ui-egui/src/layer_props_ui.rs b/crates/ui-egui/src/layer_props_ui.rs index c2ea848c5e..57335a897b 100644 --- a/crates/ui-egui/src/layer_props_ui.rs +++ b/crates/ui-egui/src/layer_props_ui.rs @@ -6,7 +6,7 @@ use egui::{Rect, Sense, Stroke, pos2, vec2}; use photocraft_doc::{Layer, LayerContent}; use serde_json::{Value, json}; -use crate::props_layout::{LABEL_GAP, LABEL_W, quick_actions_ui, section}; +use crate::props_layout::{LABEL_GAP, LABEL_W, quick_actions_ui, section, stacked_label}; use crate::theme::{ROW_GAP, Tokens}; use crate::{PhotocraftApp, widgets}; @@ -33,11 +33,10 @@ pub fn transform_params(layer: u64, b: [i32; 4], w: Option, h: Option, Some(json!({"layer": layer, "rect": b, "quad": quad})) } -/// A number field that reports a value once committed (drag released, Enter, focus lost). -fn field(ui: &mut egui::Ui, id: &str, label: &str, current: f32, width: f32, u: LenUnit, extent: f64) -> Option { - let t = Tokens::get(ui.ctx()); - let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 22.0), Sense::hover()); - ui.painter().text(pos2(r.right() - 2.0, r.center().y), egui::Align2::RIGHT_CENTER, tl!(label), egui::FontId::proportional(12.0), t.text_dim); +/// A number field that reports a value once committed (drag released, Enter, focus lost). `label` is its own +/// label and the one stacked with it in the same column ("W" over "X"), which share a centre. +fn field(ui: &mut egui::Ui, id: &str, label: (&str, &str), current: f32, width: f32, u: LenUnit, extent: f64) -> Option { + stacked_label(ui, tl!(label.0), &[tl!(label.0), tl!(label.1)], LABEL_W, 2.0); let key = ui.make_persistent_id(("layer-props-field", id)); let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| u.shown(f64::from(current), extent)); let resp = widgets::value_field(ui, &mut v, -300_000.0..=300_000.0, u.unit.suffix(), width); @@ -137,24 +136,24 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { let w = ((ui.available_width() - 2.0 * (LABEL_W + LABEL_GAP) - LINK_W - 2.0 * LABEL_GAP) / 2.0).max(36.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = LABEL_GAP; - if let Some(v) = field(ui, "w", "W", b.width() as f32, w, u, dw) { + if let Some(v) = field(ui, "w", ("W", "X"), b.width() as f32, w, u, dw) { run.extend(transform_params(layer.id.0, bb, Some(v), None, linked).map(|p| ("edit.transform".to_string(), p))); } if crate::icons::button(ui, if linked { "link" } else { "unlink" }, LINK_W, linked, tl!("Link width and height")).clicked() { ui.data_mut(|d| d.insert_temp(link_key, !linked)); } - if let Some(v) = field(ui, "h", "H", b.height() as f32, w, u, dh) { + if let Some(v) = field(ui, "h", ("H", "Y"), b.height() as f32, w, u, dh) { run.extend(transform_params(layer.id.0, bb, None, Some(v), linked).map(|p| ("edit.transform".to_string(), p))); } }); ui.add_space(ROW_GAP); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = LABEL_GAP; - if let Some(v) = field(ui, "x", "X", b.x0 as f32, w, u, dw) { + if let Some(v) = field(ui, "x", ("X", "W"), b.x0 as f32, w, u, dw) { run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": (v - b.x0 as f32).round() as i32, "dy": 0}))); } ui.add_space(LINK_W + LABEL_GAP); - if let Some(v) = field(ui, "y", "Y", b.y0 as f32, w, u, dh) { + if let Some(v) = field(ui, "y", ("Y", "H"), b.y0 as f32, w, u, dh) { run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": 0, "dy": (v - b.y0 as f32).round() as i32}))); } }); @@ -281,6 +280,40 @@ mod tests { } } + /// #7314: in the Transform section W sits over X and H over Y, and each pair shares a centre. + #[test] + fn transform_labels_are_centred_on_each_other() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 100, "height": 60})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(280.0, 900.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().cumulative_pass_nr() == 0 { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + return; + } + let state = app.session.active().unwrap(); + let layer = state.doc.layer(state.active_layer.unwrap()).unwrap().clone(); + properties(app, ui, &layer); + }, + app, + ); + h.run_steps(3); + let centres: Vec<(String, f32)> = h + .output() + .shapes + .iter() + .filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t) } else { None }) + .filter(|t| t.galley.text().chars().count() == 1) + .map(|t| (t.galley.text().to_string(), t.pos.x + t.galley.size().x * 0.5)) + .collect(); + let at = |l: &str| centres.iter().find(|(s, _)| s == l).map(|(_, x)| *x).unwrap_or_else(|| panic!("no {l} label in {centres:?}")); + assert!((at("W") - at("X")).abs() < 0.01, "W and X: {centres:?}"); + assert!((at("H") - at("Y")).abs() < 0.01, "H and Y: {centres:?}"); + } + #[test] fn transform_fields_show_and_take_the_ruler_unit() { use photocraft_engine::prefs::Unit; diff --git a/crates/ui-egui/src/layer_props_ui_tests.rs b/crates/ui-egui/src/layer_props_ui_tests.rs new file mode 100644 index 0000000000..93055ee342 --- /dev/null +++ b/crates/ui-egui/src/layer_props_ui_tests.rs @@ -0,0 +1,78 @@ +//! The Layers panel's blend mode, Opacity, Fill and lock controls change every selected layer, +//! as in Photoshop, not only the active one. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::PhotocraftApp; + +fn app() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app + }); + h.run_steps(6); + h +} + +/// The Layers panel's lock buttons change every selected layer (as do blend mode, Opacity and +/// Fill), not only the active one. +#[test] +fn the_layers_panel_locks_every_selected_layer() { + let mut h = app(); + let ids: Vec = h.state().session.active().unwrap().doc.layers.iter().map(|l| l.id.0).collect(); + let extra = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().run("layer.select", json!({"layer": ids[1], "mode": "add"})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().selected_layers().len(), 2); + h.query_by_label_contains("Lock all").expect("the Lock all button").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + let locked = |id: u64| st.doc.layer(photocraft_doc::LayerId(id)).unwrap().locks.all; + assert!(locked(extra) && locked(ids[1]), "both selected layers lock"); + assert!(!locked(ids[0]), "the unselected Background doesn't"); +} + +/// The Basic layout's single lock toggle also locks every selected layer. +#[test] +fn the_basic_lock_toggle_locks_every_selected_layer() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::Studio); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("prefs.set", json!({"path": "interface.theme", "value": crate::theme::ThemeKind::Studio.id()})).unwrap(); + app.ui.theme = crate::theme::ThemeKind::Studio; + app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap(); + app + }); + let a = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let b = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().run("layer.select", json!({"layer": a, "mode": "add"})).unwrap(); + h.run_steps(4); + h.get_by_label("Lock layer").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + let locked = |id: u64| st.doc.layer(photocraft_doc::LayerId(id)).unwrap().locks.all; + assert!(locked(a) && locked(b), "both selected layers lock"); +} + +/// The Background, selected with other layers, is left out of their change (Opacity here) +/// until Photoshop's behaviour is confirmed (#5856): it stays the Background. +#[test] +fn a_selected_background_is_left_out_of_a_several_layer_change() { + let mut h = app(); + let st = h.state().session.active().unwrap(); + let (bg, top) = (st.doc.layers[0].id.0, st.active_layer.unwrap().0); + h.state_mut().run("layer.select", json!({"layer": bg})).unwrap(); + h.state_mut().run("layer.select", json!({"layer": top, "mode": "add"})).unwrap(); + h.run_steps(3); + h.get_by_label("Lock all").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + let l = |id: u64| st.doc.layer(photocraft_doc::LayerId(id)).unwrap().clone(); + assert!(l(top).locks.all, "the layer locks"); + assert!(st.doc.is_background(photocraft_doc::LayerId(bg)) && !l(bg).locks.all, "the Background is left as it was"); +} diff --git a/crates/ui-egui/src/layer_reveal_tests.rs b/crates/ui-egui/src/layer_reveal_tests.rs index e2c65a3222..181e42dc17 100644 --- a/crates/ui-egui/src/layer_reveal_tests.rs +++ b/crates/ui-egui/src/layer_reveal_tests.rs @@ -132,3 +132,32 @@ fn reveal_survives_odd_documents() { h.state_mut().sync_views(); h.run_steps(3); } +#[test] +fn layers_scroll_position_survives_document_switches() { + for theme in [ThemeKind::ProMedium, ThemeKind::Studio] { + let (app, inner, _, _, _) = tall_doc(); + let mut h = harness(app, theme); + h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap(); + h.run_steps(4); + let before = row(&h, "Deep inside").unwrap(); + assert!(in_view(&h, before)); + + // A short second document clamps its own scroll offset to zero. That must + // not erase the first document's position or trigger a selection change. + h.state_mut().run("file.new", json!({"width": 20, "height": 20})).unwrap(); + h.state_mut().sync_views(); + h.run_steps(4); + for _ in 0..3 { + h.state_mut().session.set_active(0); + h.state_mut().sync_views(); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(inner)); + let restored = row(&h, "Deep inside").unwrap(); + assert!(in_view(&h, restored), "{theme:?}: restored bottom row: {restored:?}"); + assert!((restored.top() - before.top()).abs() < 0.5, "{theme:?}: restored exact scroll position"); + h.state_mut().session.set_active(1); + h.state_mut().sync_views(); + h.run_steps(4); + } + } +} diff --git a/crates/ui-egui/src/layer_row_ui.rs b/crates/ui-egui/src/layer_row_ui.rs index 7ac725a7cb..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 @@ -203,6 +216,16 @@ pub fn panel_menu(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui) { } ui.close(); } + ui.separator(); + // Photoshop's Clip Thumbnails to Layer Bounds: frame each layer's pixels, not the canvas (#4281). + let mut clip = app.session.prefs().interface.clip_thumbnails_to_layer_bounds; + if crate::widgets::checkbox(ui, &mut clip, tl!("Clip Thumbnails to Layer Bounds")).changed() { + if let Err(e) = app.run("prefs.set", json!({"path": "interface.clipThumbnailsToLayerBounds", "value": clip})) { + app.ui.status = e; + app.ui.status_error = true; + } + ui.close(); + } } fn rects_id() -> egui::Id { diff --git a/crates/ui-egui/src/layer_row_ui/edit_tests.rs b/crates/ui-egui/src/layer_row_ui/edit_tests.rs index 829df351c7..0c217dd3d0 100644 --- a/crates/ui-egui/src/layer_row_ui/edit_tests.rs +++ b/crates/ui-egui/src/layer_row_ui/edit_tests.rs @@ -174,7 +174,7 @@ fn edit_type_command_rejects_missing_or_non_type_layers_without_changes() { } #[test] -fn reentering_type_edit_keeps_the_session_and_cancel_restores_cached_pixels() { +fn layer_reselection_commits_type_edit_and_cancel_preserves_committed_text() { let mut s = session(); let id = s.execute("type.create", json!({"text": "Hello", "size": 48, "x": 100, "y": 180})).unwrap()["layer"].as_u64().unwrap(); // Stand-in for a PSD's retained Photoshop rendering, displaced from our glyph layout. @@ -197,10 +197,13 @@ fn reentering_type_edit_keeps_the_session_and_cancel_restores_cached_pixels() { let key = h.state().ui.text_edit.as_ref().expect("inline text editor").session.clone(); h.event(Event::Text("Changed".into())); h.run_steps(2); + // The first row click commits the edit; the second opens a new session (#5584). double_click(&mut h, at); - assert_eq!(h.state().ui.text_edit.as_ref().unwrap().session, key); + assert_ne!(h.state().ui.text_edit.as_ref().unwrap().session, key); assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps + 1); crate::type_tool::cancel(h.state_mut()); + assert_eq!(text(&h, id), "Changed"); + assert!(h.state_mut().session.undo()); assert_eq!(text(&h, id), "Hello"); let st = h.state().session.active().unwrap(); let LayerContent::Text(t) = &st.doc.layer(LayerId(id)).unwrap().content else { panic!("text") }; diff --git a/crates/ui-egui/src/layer_shortcut_tests.rs b/crates/ui-egui/src/layer_shortcut_tests.rs index a363c340bf..96ab2520dc 100644 --- a/crates/ui-egui/src/layer_shortcut_tests.rs +++ b/crates/ui-egui/src/layer_shortcut_tests.rs @@ -122,3 +122,69 @@ fn alt_click_on_a_layer_thumbnail_fits_the_layer() { assert_eq!(v.center, [310.0, 205.0], "centred on the layer's content"); assert!(v.zoom > before.zoom * 4.0, "zoomed in on 20 × 10 px: {} -> {}", before.zoom, v.zoom); } + +/// ⌘-click a layer's thumbnail loads its transparency as a selection without changing the +/// selected layer; on the Background, which has no transparency, it is a plain click, and a hidden +/// layer still loads (measured on Photoshop 25.4, 2026-10-17). +#[test] +fn command_click_on_a_thumbnail_loads_transparency_except_on_the_background() { + use egui::{Event, PointerButton, pos2, vec2}; + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 400, "height": 300})).unwrap(); + let background = s.active().unwrap().active_layer.unwrap().0; + let shape = s.execute("layer.new.layer", json!({"name": "Shape"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 100, "y": 50, "width": 120, "height": 100})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Top"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + let ctx = h.ctx.clone(); + let (r, _) = crate::control::ControlRequest::new("ui.set", json!({"dock": {"collapsed": ["color", "properties", "history", "navigator"]}})); + crate::control::handle(h.state_mut(), &ctx, &r); + h.run_steps(8); + let click = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, layer: u64, modifiers: Modifiers| { + let row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == layer).unwrap().row; + let at = pos2(row.left() + 46.0, row.center().y); + h.hover_at(at); + h.event(Event::ModifiersChanged(modifiers)); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers }); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(1); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); + }; + let bounds = |h: &egui_kittest::Harness<'_, PhotocraftApp>| { + let st = h.state().session.active().unwrap(); + st.doc.selection.as_ref().map(|s| s.content_bounds()) + }; + let top = h.state().session.active().unwrap().active_layer.unwrap(); + let selected = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().selected_layers(); + click(&mut h, shape, Modifiers::COMMAND); + assert_eq!(bounds(&h), Some(photocraft_geom::Rect::new(100, 50, 220, 150)), "the layer's pixels are selected"); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(top), "the selected layer stays"); + assert_eq!(selected(&h), vec![top]); + click(&mut h, background, Modifiers::COMMAND); + assert_eq!( + bounds(&h), + Some(photocraft_geom::Rect::new(100, 50, 220, 150)), + "the Background has no transparency to load: a plain click, which leaves the selection and selects the row" + ); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(photocraft_doc::LayerId(background))); + assert_eq!(selected(&h), vec![photocraft_doc::LayerId(background)], "the Background alone, not toggled into the set"); + // Already in a multi-selection: the ⌘-click still leaves it the only selected layer. + h.state_mut().run("layer.select", json!({"layer": top.0})).unwrap(); + h.state_mut().run("layer.select", json!({"layer": background, "mode": "add"})).unwrap(); + h.run_steps(2); + assert_eq!(selected(&h).len(), 2); + click(&mut h, background, Modifiers::COMMAND); + assert_eq!(selected(&h), vec![photocraft_doc::LayerId(background)], "replaced, not removed from the set"); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(photocraft_doc::LayerId(background))); + h.state_mut().run("layer.setProps", json!({"layer": shape, "visible": false})).unwrap(); + click(&mut h, shape, Modifiers::COMMAND); + assert_eq!(bounds(&h), Some(photocraft_geom::Rect::new(100, 50, 220, 150)), "a hidden layer's transparency loads too"); +} diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index af3e36c904..d876c18278 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -86,6 +86,7 @@ fn spec(kind: &str) -> &'static [(&'static str, &'static str, P)] { const POS: &[(&str, &str)] = &[("outside", "Outside"), ("inside", "Inside"), ("center", "Center")]; const GSTYLE: &[(&str, &str)] = &[("linear", "Linear"), ("radial", "Radial"), ("angle", "Angle"), ("reflected", "Reflected"), ("diamond", "Diamond")]; const BSTYLE: &[(&str, &str)] = &[("inner", "Inner Bevel"), ("outer", "Outer Bevel"), ("emboss", "Emboss"), ("pillow", "Pillow Emboss")]; + const TECHNIQUE: &[(&str, &str)] = &[("smooth", "Smooth"), ("chiselHard", "Chisel Hard"), ("chiselSoft", "Chisel Soft")]; const DIR: &[(&str, &str)] = &[("up", "Up"), ("down", "Down")]; const SRC: &[(&str, &str)] = &[("edge", "Edge"), ("center", "Center")]; const CONTOUR: P = P::Contour(Some("antiAlias")); @@ -163,6 +164,7 @@ fn spec(kind: &str) -> &'static [(&'static str, &'static str, P)] { ], "bevelEmboss" => &[ ("style", "Style", P::Choice(BSTYLE)), + ("technique", "Technique", P::Choice(TECHNIQUE)), ("depth", "Depth", P::Slider(1.0, 1000.0, "%")), ("direction", "Direction", P::Choice(DIR)), ("size", "Size", P::Slider(0.0, 250.0, "px")), @@ -413,14 +415,15 @@ fn set_param(f: &mut Map, id: &str, key: &str, value: Value) { /// A fresh instance id: one past the highest numbered `fxN` in the list. fn next_id(f: &Map) -> String { - let max = effects_of(f) + let used: std::collections::HashSet = effects_of(f) .iter() .filter_map(|e| e.get("id").and_then(Value::as_str)) .filter_map(|id| id.strip_prefix("fx")) .filter_map(|n| n.parse::().ok()) - .max() - .unwrap_or(0); - format!("fx{}", max + 1) + .collect(); + let max = used.iter().copied().max().unwrap_or(0); + let next = max.checked_add(1).or_else(|| (1_u64..).find(|n| !used.contains(n))).unwrap_or(1); + format!("fx{next}") } /// Adds an instance of `kind`, right after the last one of its kind (the list, @@ -458,6 +461,39 @@ fn remove_instance(f: &mut Map, id: &str) { /// the layer's order), the blending options and the selected page. `select` /// names a kind to open: its first instance is selected (and enabled), or one /// is created. `light` is the document's global light angle. +/// Whether the selected page is a Drop Shadow or Inner Shadow, which a drag on the image moves +/// while the dialog is open, as in Photoshop (#5701; [`drag_shadow`]). +pub fn drags_shadow(f: &Map) -> bool { + let selected = f.get("selected").and_then(Value::as_str).unwrap_or_default(); + entry(f, selected).and_then(|e| e.get("kind")).and_then(Value::as_str).is_some_and(|k| matches!(k, "dropShadow" | "innerShadow")) +} + +/// A drag on the image with a shadow page selected: `start` is true on the press (it records the +/// shadow's angle and distance then), `total` the pointer's movement since in document pixels. +/// The shadow's offset follows the pointer, kept as Photoshop's whole-degree angle and whole-pixel +/// distance (offset = (−cos angle, sin angle) × distance, the light opposite the shadow); with +/// Use Global Light the shared angle moves too ([`set_param`]). +pub fn drag_shadow(f: &mut Map, start: bool, total: [f64; 2]) { + const KEY: &str = "__shadowDrag"; + let Some(id) = f.get("selected").and_then(Value::as_str).map(str::to_string).filter(|_| drags_shadow(f)) else { return }; + let Some(p) = entry(f, &id).and_then(|e| e.get("params")) else { return }; + if start || f.get(KEY).is_none() { + let angle = p.get("angle").and_then(Value::as_f64).unwrap_or(120.0); + let distance = p.get("distance").and_then(Value::as_f64).unwrap_or(0.0); + f.insert(KEY.into(), json!([angle, distance])); + } + let Some([angle, distance]) = f.get(KEY).and_then(Value::as_array).and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])) else { return }; + let a = angle.to_radians(); + let (ox, oy) = (-a.cos() * distance + total[0], a.sin() * distance + total[1]); + let d = ox.hypot(oy); + if !d.is_finite() { + return; + } + let new_angle = if d >= 0.5 { oy.atan2(-ox).to_degrees().round() } else { angle }; + set_param(f, &id, "angle", json!(new_angle)); + set_param(f, &id, "distance", json!(d.round().clamp(0.0, 30000.0))); +} + pub fn initial_fields(layer: &Layer, select: Option<&str>, light: f32) -> Map { let mut f = Map::new(); f.insert("layer".into(), json!(layer.id.0)); @@ -1343,6 +1379,38 @@ mod tests { use super::*; use photocraft_doc::StrokeFx; + /// #5701: with a shadow page selected, a drag on the image moves the shadow: its offset + /// follows the pointer, as whole degrees and pixels, the global light angle with it. + #[test] + fn dragging_on_the_image_moves_the_selected_shadow() { + let shadow = |id: &str, kind: &str, global: bool| json!({"id": id, "kind": kind, "on": true, "params": {"angle": 120.0, "distance": 10.0, "useGlobalLight": global}}); + let mut f = Map::new(); + f.insert("selected".into(), json!("fx1")); + f.insert("globalLight".into(), json!(120.0)); + f.insert("effects".into(), json!([shadow("fx1", "dropShadow", true), shadow("fx2", "innerShadow", true), shadow("fx3", "dropShadow", false)])); + assert!(drags_shadow(&f)); + let params = |f: &Map, id: &str| { + let p = &entry(f, id).unwrap()["params"]; + (p["angle"].as_f64().unwrap(), p["distance"].as_f64().unwrap()) + }; + // The press keeps the shadow where it is: (5, 8.66) px. + drag_shadow(&mut f, true, [0.0, 0.0]); + assert_eq!(params(&f, "fx1"), (120.0, 10.0)); + // Dragged to (15, 0): straight right of the layer, the light from the left (180°). + drag_shadow(&mut f, false, [10.0, -8.660_254]); + assert_eq!(params(&f, "fx1"), (180.0, 15.0)); + assert_eq!(params(&f, "fx2").0, 180.0, "the global light angle is shared"); + assert_eq!(params(&f, "fx3"), (120.0, 10.0), "an instance with its own angle is left alone"); + // Back onto the layer: no offset, and the angle the drag started with. + drag_shadow(&mut f, false, [-5.0, -8.660_254]); + assert_eq!(params(&f, "fx1"), (120.0, 0.0)); + // Not on a shadow page: nothing moves. + f.insert("selected".into(), json!(BLENDING)); + assert!(!drags_shadow(&f)); + drag_shadow(&mut f, true, [50.0, 50.0]); + assert_eq!(params(&f, "fx1"), (120.0, 0.0)); + } + fn session() -> photocraft_engine::Session { let mut s = photocraft_engine::Session::new(); s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); @@ -1436,6 +1504,25 @@ mod tests { } } + #[test] + fn adding_an_effect_after_maximum_instance_id_uses_an_unused_id() { + // Imported and scripted effects can provide arbitrary instance IDs. + let layer = Layer::raster("x", photocraft_doc::PixelFormat::RGBA8); + let mut fields = initial_fields(&layer, None, 120.0); + fields.insert( + "effects".into(), + json!([ + {"id": "fx1", "kind": "stroke", "on": true, "params": {}}, + {"id": format!("fx{}", u64::MAX), "kind": "stroke", "on": false, "params": {}} + ]), + ); + add_instance(&mut fields, "stroke"); + + let ids: Vec<&str> = effects_of(&fields).iter().filter_map(|e| e.get("id").and_then(Value::as_str)).collect(); + assert_eq!(ids, ["fx1", "fx18446744073709551615", "fx2"]); + assert_eq!(fields["selected"], json!("fx2")); + } + #[test] fn initial_fields_lists_every_instance_and_selects() { let l = Layer::raster("x", photocraft_doc::PixelFormat::RGBA8); @@ -1857,6 +1944,26 @@ mod tests { assert_eq!(d.doc.layer(id).unwrap().effects.items.len(), 2); } + #[test] + fn bevel_technique_is_selectable_and_round_trips() { + let Some((_, _, P::Choice(options))) = spec("bevelEmboss").iter().find(|(k, _, _)| *k == "technique") else { + panic!("Bevel & Emboss has no Technique dropdown"); + }; + let values: Vec<&str> = options.iter().map(|(v, _)| *v).collect(); + assert_eq!(values, ["smooth", "chiselHard", "chiselSoft"]); + + let mut s = session(); + s.execute("layer.layerStyle.bevelEmboss", json!({"technique": "chiselSoft"})).unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let mut f = initial_fields(st.doc.layer(id).unwrap(), Some("bevelEmboss"), st.doc.global_light.angle); + assert_eq!(entry(&f, "fx1").unwrap()["params"]["technique"], json!("chiselSoft")); + set_param(&mut f, "fx1", "technique", json!("chiselHard")); + apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + let Effect::BevelEmboss(b) = &s.active().unwrap().doc.layer(id).unwrap().effects.items[0] else { panic!("expected bevel") }; + assert_eq!(b.technique, photocraft_doc::effects::BevelTechnique::ChiselHard); + } + #[test] fn unchanged_global_light_angle_is_not_applied() { let layer = Layer::raster("x", photocraft_doc::PixelFormat::RGBA8); @@ -1990,6 +2097,56 @@ mod tests { assert_eq!(color(&d.doc), photocraft_color::Color::rgb(0.0, 1.0, 0.0), "OK commits with Preview off"); } + /// #3648's steps in one dialog session: disable the stroke row, add a Color Overlay, + /// untick Preview. Preview off shows the document as saved — the stroke was never + /// disabled in it, so it keeps rendering until OK, exactly like Photoshop's preview + /// semantics; nothing disabled resurrects, and OK applies the dialog's state. + #[test] + fn issue_steps_disable_stroke_add_overlay_untick_preview() { + let mut s = session(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + // Steps 1-3: a stroke is enabled and OK'd; reopening the dialog loads it enabled. + s.execute("layer.layerStyle.stroke", json!({"size": 40, "position": "inside", "color": "#00ff00"})).unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let mut f = initial_fields(st.doc.layer(id).unwrap(), None, st.doc.global_light.angle); + let rows = f["effects"].as_array().unwrap(); + assert_eq!(rows.len(), 1, "the saved stroke is the only row"); + assert_eq!((rows[0]["kind"].as_str(), rows[0]["on"].as_bool()), (Some("stroke"), Some(true))); + // Step 4: disable the stroke row — a pending edit, the document is untouched. + f["effects"][0]["on"] = json!(false); + // Step 5: add a Color Overlay (a new instance: no fx snapshot, factory defaults). + f["effects"].as_array_mut().unwrap().push(json!({ + "id": "fx2", + "kind": "colorOverlay", + "on": true, + "params": {"color": "#0000ff"}, + })); + // While Preview is on, the live preview honours the in-dialog disable: the stroke + // is disabled in the scratch document, the overlay renders. + let shown = preview_document(&st.doc, &s.patterns, &f).unwrap(); + let mut shown_effects = shown.layer(id).unwrap().effects.items.iter(); + assert!(!shown_effects.find(|e| kind_of(e) == "stroke").is_none_or(|e| e.enabled()), "the preview leaves the stroke off"); + assert!(shown_effects.find(|e| kind_of(e) == "colorOverlay").is_some_and(|e| e.enabled()), "the preview shows the overlay"); + // Step 6: untick Preview — the canvas shows the real document, whose stroke is + // still enabled (the disable is an unapplied edit). This is the state the issue + // reports; it is the saved state, not a resurrected effect. + f.insert("preview".into(), json!(false)); + let green: Vec = serde_json::from_value(s.execute("document.pixel", json!({"x": 32, "y": 32})).unwrap()).unwrap(); + assert!(green[1] > 0.9 && green[0] < 0.1, "preview off shows the saved stroke: {green:?}"); + // OK (with Preview off) applies the dialog state: the stroke is now disabled in the + // document too, the overlay shows, and the dialog — which never models the list's + // master switch — leaves it at its default. + apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + let d = s.active().unwrap(); + let effects = &d.doc.layer(id).unwrap().effects; + assert!(!effects.items.iter().find(|e| kind_of(e) == "stroke").is_none_or(|e| e.enabled()), "OK disables the stroke"); + assert!(effects.items.iter().find(|e| kind_of(e) == "colorOverlay").is_some_and(|e| e.enabled()), "OK keeps the overlay on"); + assert!(effects.enabled, "the list's master switch keeps its value"); + let blue: Vec = serde_json::from_value(s.execute("document.pixel", json!({"x": 32, "y": 32})).unwrap()).unwrap(); + assert!(blue[2] > 0.9 && blue[1] < 0.1, "after OK only the overlay renders: {blue:?}"); + } + #[test] fn new_style_saves_pending_state_and_a_swatch_apply_restores_it() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); 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/layer_style/color_picker/tests.rs b/crates/ui-egui/src/layer_style/color_picker/tests.rs index 9bfe484573..2d87c81ef8 100644 --- a/crates/ui-egui/src/layer_style/color_picker/tests.rs +++ b/crates/ui-egui/src/layer_style/color_picker/tests.rs @@ -163,7 +163,7 @@ fn stale_targets_and_invalid_colors_never_apply_or_leave_preview() { app.ui.dialog_mut(picker).unwrap().fields.insert(TARGET.into(), json!(null)); } _ => { - app.ui.dialog_mut(parent).unwrap().fields.insert("layer".into(), json!(999999)); + app.ui.dialog_mut(parent).unwrap().fields.insert("layer".into(), json!(u64::MAX)); } } let current_parent = app.ui.dialogs.iter().find(|d| d.id == parent).cloned(); diff --git a/crates/ui-egui/src/layer_thumb_bounds_tests.rs b/crates/ui-egui/src/layer_thumb_bounds_tests.rs new file mode 100644 index 0000000000..66ea820cd6 --- /dev/null +++ b/crates/ui-egui/src/layer_thumb_bounds_tests.rs @@ -0,0 +1,75 @@ +//! #4281: Clip Thumbnails to Layer Bounds. A small object in a large document fills its Layers +//! panel thumbnail instead of being a speck in an empty canvas-sized one. + +use egui::vec2; +use egui_kittest::Harness; +use photocraft_doc::LayerId; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::mask_thumbs_ui::THUMB_LAYER; + +fn harness() -> (Harness<'static, PhotocraftApp>, u64) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 2000, "height": 1000})).unwrap(); + let id = s.execute("layer.new.layer", json!({"name": "Speck"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 100, "y": 200, "width": 20, "height": 10})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1440.0, 1000.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + let ctx = h.ctx.clone(); + let (req, _rx) = crate::control::ControlRequest::new("ui.set", json!({"dockTabs": {"layers": 0}})); + crate::control::handle(h.state_mut(), &ctx, &req); + h.run_steps(8); + (h, id) +} + +fn frame(h: &Harness<'_, PhotocraftApp>, id: u64) -> Rect { + *h.state().thumb_frames.get(&LayerId(id)).expect("layer thumbnail drawn") +} + +fn texture_area(h: &Harness<'_, PhotocraftApp>, id: u64) -> usize { + let (_, tex) = h.state().thumbs.get(&(LayerId(id), THUMB_LAYER)).expect("layer thumbnail drawn"); + let [w, h] = tex.size(); + w * h +} + +#[test] +fn layer_thumbnails_frame_the_canvas_by_default() { + let (h, id) = harness(); + assert!(!h.state().session.prefs().interface.clip_thumbnails_to_layer_bounds); + assert_eq!(frame(&h, id), Rect::new(0, 0, 2000, 1000)); +} + +#[test] +fn clipped_layer_thumbnails_frame_the_layer_pixels() { + let (mut h, id) = harness(); + let before = h.state().thumbs.get(&(LayerId(id), THUMB_LAYER)).map(|(r, _)| *r).unwrap(); + h.state_mut().run("prefs.set", json!({"path": "interface.clipThumbnailsToLayerBounds", "value": true})).unwrap(); + h.run_steps(4); + assert_eq!(frame(&h, id), Rect::new(100, 200, 120, 210), "framed on the filled rect"); + let after = h.state().thumbs.get(&(LayerId(id), THUMB_LAYER)).map(|(r, _)| *r).unwrap(); + assert_ne!(before, after, "toggling the preference rebuilds the thumbnail"); + assert_eq!(texture_area(&h, id), 64 * 64, "still a 64 px square texture"); + + h.state_mut().run("prefs.set", json!({"path": "interface.clipThumbnailsToLayerBounds", "value": false})).unwrap(); + h.run_steps(4); + assert_eq!(frame(&h, id), Rect::new(0, 0, 2000, 1000), "and back to the canvas"); +} + +#[test] +fn empty_and_off_canvas_layers_keep_the_canvas_frame() { + let canvas = Rect::new(0, 0, 50, 40); + let raster = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8); + let mut empty = photocraft_doc::Layer::new("Empty", photocraft_doc::LayerContent::Raster(raster)); + assert_eq!(crate::layer_thumb_frame(&empty, canvas, true), canvas); + empty.surface_mut().unwrap().write_pixel(-10, -10, &[1.0, 0.0, 0.0, 1.0]); + assert_eq!(crate::layer_thumb_frame(&empty, canvas, true), canvas, "pixels only off the canvas"); + empty.surface_mut().unwrap().write_pixel(45, 5, &[1.0, 0.0, 0.0, 1.0]); + assert_eq!(crate::layer_thumb_frame(&empty, canvas, true), Rect::new(0, 0, 46, 6), "the layer bounds, cut to the canvas"); + assert_eq!(crate::layer_thumb_frame(&empty, canvas, false), canvas); +} diff --git a/crates/ui-egui/src/layer_tree_ui.rs b/crates/ui-egui/src/layer_tree_ui.rs index 2c1040c246..d00dffb8da 100644 --- a/crates/ui-egui/src/layer_tree_ui.rs +++ b/crates/ui-egui/src/layer_tree_ui.rs @@ -39,7 +39,7 @@ pub fn display_rows(doc: &Document, show_all: bool) -> Vec<(usize, &Layer)> { /// Mask session's working text, which is no layer to the user: Photoshop shows only the mask /// overlay while you type (#3036). pub fn panel_rows<'a>(app: &PhotocraftApp, doc: &'a Document) -> Vec<(usize, &'a Layer)> { - let mut rows = display_rows(doc, !app.ui.layer_filter.is_empty()); + let mut rows = display_rows(doc, crate::layer_filter_ui::Filter::of(&app.ui).active()); if let Some(working) = app.ui.text_edit.as_ref().filter(|ed| ed.mask).map(|ed| ed.layer) { rows.retain(|(_, l)| l.id.0 != working); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2cd006087d..3e3b1d2e5b 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 @@ -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; @@ -37,6 +38,7 @@ pub mod brush_preview; pub mod brush_resize; pub mod brush_sections; pub mod brushes_tab; +pub mod caf_ui; pub(crate) mod cam_preview; mod cam_transform; mod camera_raw_detail_ui; @@ -58,6 +60,7 @@ pub mod color_picker_ui; pub mod color_range_ui; pub mod comps_ui; pub mod control; +pub mod corner_radius; pub mod credits; pub mod crop_mode; pub mod crop_overlay; @@ -66,6 +69,7 @@ pub mod crop_shield; pub mod crop_size; pub mod crop_straighten; pub mod crop_ui; +pub mod dds_options_ui; pub mod delete_layer_prompt; pub mod dialog_alt_reset; pub mod dialog_blend_ui; @@ -73,6 +77,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; @@ -100,6 +105,7 @@ pub mod gpu_canvas; pub mod gpu_status; pub mod gradient_ui; pub mod guide_layout_ui; +pub mod guide_select; pub mod hold_keys; pub mod i18n; mod icon_data; @@ -108,6 +114,7 @@ pub mod jobs_ui; pub mod kys_import; pub mod lasso_ui; pub(crate) mod last_brush; +pub mod layer_filter_ui; pub mod layer_menu_ui; pub mod layer_pick_ui; pub mod layer_picker; @@ -125,6 +132,7 @@ mod mask_props_ui; pub mod mask_thumbs_ui; pub mod mask_trash_prompt; pub mod menu_catalog; +pub mod menu_keys; pub mod menu_nav; pub mod menus; pub mod monitor_status; @@ -138,6 +146,7 @@ pub mod new_doc_ui; pub mod new_guide_ui; pub mod new_layer_ui; pub mod notices; +pub mod nudge_bundle; mod numeric_expression; pub mod object_finder; mod opacity_keys; @@ -190,6 +199,7 @@ pub mod spring_tools; pub mod stack_dialog; pub mod state; pub mod stroke_constraint; +pub mod stroke_trace; pub mod stroke_trail; pub mod stroke_ui; pub mod stylus; @@ -263,12 +273,24 @@ pub struct ExportSettings { /// Targa: bits per pixel from Targa Options or Export As (`None`: 32 with an alpha channel or /// transparency, else 24). pub tga_bits: Option, + /// DDS: the compression and mip chain from DDS Options (uncompressed without mipmaps unless + /// chosen). + pub dds: photocraft_io::DdsOptions, /// PSD/PSB: embed a thumbnail (Preferences › File Handling › Image Previews; see /// [`PhotocraftApp::save_settings`]). pub image_previews: bool, /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB /// File Compatibility; see [`PhotocraftApp::save_settings`]). pub psd_maximize_compatibility: bool, + /// Embed the document's ICC profile when the format supports it. On for Save As and, unless + /// Export As turns it off, for export. + pub embed_icc: bool, + /// 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 { @@ -280,8 +302,68 @@ impl Default for ExportSettings { tiff_layers: true, xmp_all: true, tga_bits: None, + dds: photocraft_io::DdsOptions::default(), image_previews: true, psd_maximize_compatibility: true, + embed_icc: true, + embed_metadata: true, + document_path: None, + } + } +} + +impl ExportSettings { + /// The IO export options these encoder settings select. Save As and Export As share this so + /// a profile or metadata choice cannot be applied in one and forgotten in the other. + pub fn io_options(&self) -> photocraft_io::ExportOptions { + let mut opts = photocraft_io::ExportOptions::default(); + if let Some(q) = self.jpeg_quality { + opts.encode.jpeg_quality = q; + } + opts.encode.webp_lossless = self.webp_lossless; + if let Some(q) = self.webp_quality { + opts.encode.webp_quality = q; + } + opts.encode.embed_icc = self.embed_icc; + opts.encode.embed_metadata = self.embed_metadata; + opts.tiff_layers = self.tiff_layers; + opts.xmp = if self.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; + opts.tga_bits = self.tga_bits; + opts.encode.dds = self.dds; + opts.image_previews = self.image_previews; + opts.psd_maximize_compatibility = self.psd_maximize_compatibility; + opts.document_path = self.document_path.clone(); + opts + } +} + +/// The format answers a save takes instead of asking: the Targa bits per pixel and the DDS options +/// (`"tgaBits"`, `"ddsCompression"`, `"ddsMipmaps"`). `None` asks in Save As and takes the +/// default in automation saves, which never prompt. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct FormatChoices { + pub tga_bits: Option, + pub dds: Option, +} + +impl FormatChoices { + /// The answers in command `cmd`'s params; a bad value is an error. + pub fn from_params(params: &Value, cmd: &str) -> Result { + Ok(FormatChoices { tga_bits: targa_options_ui::bits_param(params, cmd)?, dds: dds_options_ui::options_param(params, cmd)? }) + } + + /// The export settings with these answers applied. + pub(crate) fn apply(self, settings: ExportSettings) -> ExportSettings { + ExportSettings { tga_bits: self.tga_bits, dds: self.dds.unwrap_or(settings.dds), ..settings } + } + + /// Adds the answers to a recorded save step. + fn record(self, step: &mut Value) { + if let Some(bits) = self.tga_bits { + step["tgaBits"] = bits.bits().into(); + } + if let Some(dds) = self.dds { + dds_options_ui::record(dds, step); } } } @@ -312,11 +394,27 @@ 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. pub type SaveFileFn = std::sync::Arc Result, String> + Send + Sync>; +/// The revision a document counts as saved at after `written` went to its writer: that one when +/// the writer confirms the bytes were stored, else still `previous`, so the document stays +/// unsaved. A browser download is only a request the user can cancel (#4962). +pub(crate) fn saved_revision_after_write(confirmed: bool, written: u64, previous: u64) -> u64 { + if confirmed { written } else { previous } +} + /// Who a finished save serves. #[derive(Clone, Copy)] pub(crate) enum SaveKind { @@ -342,6 +440,8 @@ pub type EncodePngFn = Box Result, String>>; pub type ClipboardSetFn = Box Result<(), String>>; /// Read an RGBA8 image from the OS clipboard. pub type ClipboardGetFn = Box Option<(u32, u32, Vec)>>; +/// Whether the last clipboard read found no way to reach the OS clipboard at all. +pub type ClipboardUnreadableFn = Box bool>; /// Shared queue of (file name, bytes) delivered asynchronously. pub type Inbox = std::sync::Arc)>>>; /// Read the saved preferences text (`None` when there is none yet). @@ -409,6 +509,13 @@ 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, + /// `write` only asks for the bytes to be kept (a browser download, which the user can + /// cancel and which reports nothing back), so a save through it names the document but + /// leaves its changes unsaved and the leave-page guard armed (#4962). + pub write_unconfirmed: bool, /// 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, @@ -429,11 +536,19 @@ 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, + /// True when the last `clipboard_get_image` read had no clipboard to read, as in a Flatpak on + /// Wayland (#5031), rather than a clipboard that holds no image. `None` where that can't happen. + pub clipboard_unreadable: Option, /// Preferences persistence: the platform config directory on native, browser storage on /// the web (see `prefs_ui`). pub load_prefs: Option, @@ -457,8 +572,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. @@ -511,6 +627,8 @@ pub struct PhotocraftApp { navigator_gpu: HashMap, /// Documents whose GPU canvas texture holds the plain document (`canvas::ensure_gpu`). navigator_plain: std::collections::HashSet, + /// The Info panel's Color Sampler readouts for (document, revision) (`panels::sampler_readouts`). + info_samplers: Option, /// Display profile readings (#569). monitors: monitor_status::State, checker: Option, @@ -529,9 +647,9 @@ pub struct PhotocraftApp { /// Footprint trail of a retouching drag (see `stroke_trail`). trail: Option, /// Remove tool strokes waiting to be applied together (Remove after each stroke off), the - /// document they belong to, and their tint on the canvas (`retouch_ui`). + /// document, layer and channel they belong to, and their tint on the canvas (`retouch_ui`). pub(crate) remove_pending: Vec>, - pub(crate) remove_pending_doc: Option, + pub(crate) remove_pending_target: Option, pub(crate) remove_trail: Option, /// The open menus' items, reused between frames (`menus::ItemCache`). pub(crate) menu_cache: Option, @@ -603,6 +721,9 @@ pub struct PhotocraftApp { fonts_ready: bool, /// Screen rect of the main canvas last frame (for overlays and the navigator). pub last_canvas_rect: egui::Rect, + /// Window › Arrange: the first document of the shown run of tiles, when there are more + /// documents than tiles (`canvas::arranged_cells`). + pub(crate) arrange_start: usize, /// Physical pixels per egui point of the canvas last frame (`ctx.pixels_per_point`, which /// folds in both the display scale and Interface › UI Scale). The canvas maps document pixels /// to physical pixels, so point-space geometry divides the view zoom by this (see @@ -614,12 +735,15 @@ 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, - 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). + thumb_frames: HashMap, /// History snapshot thumbnails by (document, snapshot id): a snapshot never changes, so each /// is made once (`snapshot_thumb`, #2966). snapshot_thumbs: HashMap<(DocId, u64), egui::TextureHandle>, @@ -642,6 +766,9 @@ pub struct PhotocraftApp { pub(crate) color_range: Option, /// Image › Adjustments dialog preview through a temporary clipped adjustment layer. pub(crate) adjust_preview: Option, + /// The Properties panel's live adjustment-layer drag: its damage region and coverage + /// (`adjust_preview::live_key`). + pub(crate) live_adjust_preview: Option, /// Synthetic input events queued by automation (`ui.click`, `ui.key`, …), injected next frame. pub(crate) synthetic: Vec, /// True only while a frame is processing synthetic automation input. It @@ -660,6 +787,8 @@ pub struct PhotocraftApp { pub(crate) move_mods: move_mods::MoveDrag, /// Cached document for a modal text Style Options color preview. pub(crate) text_style_preview: Option, + /// The run of arrow-key nudges that share one History state (nudge_bundle). + pub(crate) nudge_bundle: nudge_bundle::NudgeBundle, /// Live Layer Style dialog preview: (key over revision + style fields, preview or validation error). pub(crate) style_preview: Option<(u64, Result, String>)>, pub(crate) solid_fill_preview: Option, @@ -730,6 +859,8 @@ pub struct PhotocraftApp { started: Option, /// Frame and canvas-upload timings (exposed via `ui.inspect`). pub perf: gpu_canvas::Perf, + /// Per-frame stroke timings for latency reports (`PHOTOCRAFT_STROKE_TRACE`, see `stroke_trace`). + pub stroke_trace: stroke_trace::StrokeTrace, /// Preferences, autosave and snapping runtime state (see `prefs_ui`, `snap_ui`). pub(crate) prefs_rt: prefs_ui::Runtime, /// Close, Revert or Exit parked behind the unsaved-changes prompt (see `discard_ui`). @@ -741,8 +872,13 @@ 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, + /// A save to a DDS texture parked behind the DDS Options prompt (see `dds_options_ui`). + pub(crate) dds_options: Option, /// Files read but not opened yet: the first waits on its profile prompt, the rest behind it /// (see `open_prompt`). pub(crate) held_opens: std::collections::VecDeque, @@ -754,6 +890,12 @@ pub struct PhotocraftApp { /// `jobs_ui`). The desktop app turns it on; off (the default), everything runs inline as /// before, which tests and scripts rely on. pub background_jobs: bool, + /// Shortest time between frames (ms), or `None` for no cap. A present mode that doesn't wait + /// for the display (Low-Latency Drawing) would otherwise redraw as fast as it can while + /// anything keeps requesting repaints (a drag hovering, autoscroll, Liquify held still). + /// After #4472 (astigmata). + pub min_frame_ms: Option, + last_frame_start_ms: f64, /// Background job bookkeeping: opening tabs, control replies waiting on a job. pub jobs: jobs_ui::JobsUi, #[cfg(all(debug_assertions, not(target_arch = "wasm32")))] @@ -771,6 +913,7 @@ impl PhotocraftApp { display_memo: HashMap::new(), navigator_gpu: HashMap::new(), navigator_plain: std::collections::HashSet::new(), + info_samplers: None, monitors: Default::default(), checker: None, drag: None, @@ -780,7 +923,7 @@ impl PhotocraftApp { committed_stroke: None, trail: None, remove_pending: Vec::new(), - remove_pending_doc: None, + remove_pending_target: None, remove_trail: None, menu_cache: None, move_preview: None, @@ -814,6 +957,7 @@ impl PhotocraftApp { last_window_title: String::new(), fonts_ready: false, last_canvas_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)), + arrange_start: 0, ppp: 1.0, drop_canvas_rect: None, tab_strip: None, @@ -822,6 +966,7 @@ impl PhotocraftApp { fps: 0.0, last_frame_time: 0.0, thumbs: HashMap::new(), + thumb_frames: HashMap::new(), snapshot_thumbs: HashMap::new(), thumb_documents: Vec::new(), bounds_cache: HashMap::new(), @@ -834,6 +979,7 @@ impl PhotocraftApp { redraws: Default::default(), color_range: None, adjust_preview: None, + live_adjust_preview: None, synthetic: Vec::new(), automation_input: false, channel_thumbs: None, @@ -856,6 +1002,7 @@ impl PhotocraftApp { clip_read_for_paste: false, transform_preview: None, move_mods: Default::default(), + nudge_bundle: Default::default(), style_preview: None, text_style_preview: None, solid_fill_preview: None, @@ -875,16 +1022,21 @@ impl PhotocraftApp { gpu: None, started: None, perf: Default::default(), + stroke_trace: stroke_trace::StrokeTrace::from_env(), prefs_rt: Default::default(), discard: None, file_dialog: None, tiff_options: None, save_dirs: HashMap::new(), + disk_stamps: HashMap::new(), targa_options: None, + dds_options: None, held_opens: Default::default(), allow_close: false, stylus: Default::default(), background_jobs: false, + min_frame_ms: None, + last_frame_start_ms: 0.0, jobs: Default::default(), #[cfg(all(debug_assertions, not(target_arch = "wasm32")))] live_tokens: theme::live::LiveTokens::from_env(), @@ -994,6 +1146,21 @@ impl PhotocraftApp { { authorize(id, ¶ms)?; } + // A new layer while Free Transform is open applies the transform first, as Photoshop does + // (#5667): History reads the transform (or Place Embedded), then New Layer. Only for a + // command that can run; a transform refused or still being applied keeps the box open and + // the command doesn't run. + if photocraft_engine::PRECHECKED.contains(&id) && self.ui.transform.is_some() { + // Bad params or a command that can't run leave the box as it is. + self.session.precheck(id, ¶ms).map_err(|e| e.to_string())?; + transform_tool::commit(self); + if transform_tool::committing(self).is_some() { + return Err(tl!("Free Transform is still being applied; try again when it has finished.").into()); + } + if self.ui.transform.is_some() { + return Err(if self.ui.status.is_empty() { tl!("The transform couldn't be applied.").into() } else { self.ui.status.clone() }); + } + } if let Some(result) = panel_docking::command(self, id, ¶ms) { return result; } @@ -1014,6 +1181,11 @@ impl PhotocraftApp { self.import_os_clipboard(); } if self.session.clipboard.is_none() && self.services.clipboard_get_image.is_some() { + if self.services.clipboard_unreadable.as_ref().is_some_and(|unreadable| unreadable()) { + // The clipboard may well hold an image that can't be reached: say why. + notices::clipboard_unreadable(self); + return Ok(serde_json::json!({"pasted": false})); + } // Enabled on the strength of the OS clipboard, which held no image: a quiet no-op. let message = "Nothing to paste: the clipboard holds no image"; self.ui.status = tl!(message).into(); @@ -1036,6 +1208,10 @@ impl PhotocraftApp { id, "layer.vectorMask.add" | "layer.vectorMask.fromPath" | "layer.vectorMask.revealAll" | "layer.vectorMask.hideAll" | "layer.vectorMask.currentPath" ) && on_active_layer; + // A draft Pen path belongs to the current document, not the one about to be created. + if id == "file.new" && self.ui.pen.is_some() { + vector_ui::pen_commit(self, false); + } let r = if id == "actions.play" { actions::play(self, ¶ms) } else if photocraft_engine::actions_cmds::shell_view_command(id) { @@ -1306,14 +1482,22 @@ impl PhotocraftApp { /// continues on a later frame, see `file_dialog`). Returns `{"path", "warnings"}` (the export /// warnings are also shown to the user). pub fn save_as(&mut self, path: Option) -> Result { - self.save_as_with(path, None) + self.save_as_with(path, FormatChoices::default()) } - /// [`Self::save_as`], writing a Targa with `tga_bits` per pixel when given (no Targa Options - /// prompt): `file.save` and `file.saveAs` with `tgaBits`. - pub fn save_as_with(&mut self, path: Option, tga_bits: Option) -> Result { + /// [`Self::save_as`], writing a Targa or DDS texture with the `choices` given instead of + /// asking for them (no Targa or DDS Options prompt): `file.save` and `file.saveAs` with + /// `tgaBits`, `ddsCompression` or `ddsMipmaps`. + pub fn save_as_with(&mut self, path: Option, choices: FormatChoices) -> Result { + self.save_dialog_flow(path, choices, false) + } + + /// [`Self::save_as_with`]. `plain_save` marks File › Save that had to ask for a file name + /// (a flat file): confirming the document's own file is recorded as a `file.save` step, + /// which writes whichever file the action is played on, not as a Save As to a fixed path. + pub(crate) fn save_dialog_flow(&mut self, path: Option, choices: FormatChoices, plain_save: bool) -> Result { if self.save_options_open() { - return Err("Answer TIFF or Targa Options before starting another save".into()); + return Err("Answer the TIFF, Targa or DDS Options before starting another save".into()); } // Edit Contents documents save back into their smart object. if path.is_none() && self.session.is_enabled("layer.smartObjects.saveContents") { @@ -1321,7 +1505,7 @@ impl PhotocraftApp { return Ok(serde_json::json!({"path": "smart object", "warnings": []})); } if let Some(path) = path { - return self.save_to(path, tga_bits); + return self.save_to(path, choices); } let st = self.session.active().ok_or("no document")?; // PDN imports default to our native format, which preserves Paint.NET's blend modes; @@ -1351,75 +1535,128 @@ impl PhotocraftApp { } }; let doc = st.doc.id; - self.pick_save(&suggested, move |app, path| app.with_document(doc, |app| app.save_to(path, tga_bits))) + 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(), choices)?; + if plain_save && own.as_deref() == Some(path.as_str()) { + app.journal_as_plain_save(&path); + } + Ok(r) + }) + }) } /// [`Self::save_as_with`] once the path is known. - 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. - if tga_bits.is_none() && targa_options_ui::wants_prompt(self, &path) { + pub(crate) fn save_to(&mut self, path: String, choices: FormatChoices) -> Result { + // An RGB Targa asks for its bits per pixel, and a DDS texture for its compression, 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. + if choices.tga_bits.is_none() && targa_options_ui::wants_prompt(self, &path) { let copy = self.saves_copy(&path, None)?; targa_options_ui::park(self, path.clone(), copy, "file.saveAs")?; return Ok(serde_json::json!({"path": path, "warnings": []})); } + if choices.dds.is_none() && dds_options_ui::wants_prompt(&path) { + let copy = self.saves_copy(&path, None)?; + dds_options_ui::park(self, path.clone(), copy, "file.saveAs")?; + return Ok(serde_json::json!({"path": path, "warnings": []})); + } // A layered TIFF asks about its layers first (Preferences › File Handling); the save // continues from the prompt, which records the step once answered. if tiff_options_ui::wants_prompt(self, &path) { tiff_options_ui::park(self, path.clone())?; return Ok(serde_json::json!({"path": path, "warnings": []})); } - self.save_as_step(path, None, tga_bits) + self.save_as_step(path, None, choices) } - /// File › Save back to the document's own layered file, recorded as a `file.save` step. - pub(crate) fn save_in_place(&mut self, path: String) -> Result { + /// File › Save back to the document's own file, recorded as a `file.save` step with the + /// Targa or DDS answer in `choices` (a replayed Save of a flat file brings its own). + pub(crate) fn save_in_place(&mut self, path: String, choices: FormatChoices) -> Result { if self.save_options_open() { - return Err("Answer TIFF or Targa Options before starting another save".into()); + return Err("Answer the TIFF, Targa or DDS Options before starting another save".into()); } - let r = self.write_save(path, None, None)?; - self.session.journal.push(("file.save".into(), Value::Object(Default::default()))); + let r = self.write_save(path, None, choices)?; + let mut step = Value::Object(Default::default()); + choices.record(&mut step); + self.session.journal.push(("file.save".into(), step)); Ok(r) } - /// Writes the Save As and journals it as a `file.saveAs` step with its path (and TIFF or - /// Targa answer), so an action being recorded keeps it (#2032). - fn save_as_step(&mut self, path: String, tiff_layers: Option, tga_bits: Option) -> Result { - let r = self.write_save(path.clone(), tiff_layers, tga_bits)?; + /// Writes the Save As and journals it as a `file.saveAs` step with its path (and TIFF, + /// Targa or DDS answer), so an action being recorded keeps it (#2032). + fn save_as_step(&mut self, path: String, tiff_layers: Option, choices: FormatChoices) -> Result { + let r = self.write_save(path.clone(), tiff_layers, choices)?; let mut step = serde_json::json!({"path": path}); if let Some(layers) = tiff_layers { step["tiffLayers"] = layers.into(); } - if let Some(bits) = tga_bits { - step["tgaBits"] = bits.bits().into(); - } + choices.record(&mut step); self.session.journal.push(("file.saveAs".into(), step)); Ok(r) } + /// Re-label the Save As just journaled for `path` as File › Save (see [`Self::save_dialog_flow`]), + /// keeping its Targa or DDS answer so playback writes the file the same way. A save parked + /// behind Targa or DDS Options hasn't been journaled yet: it is recorded as File › Save once + /// answered. + pub(crate) fn journal_as_plain_save(&mut self, path: &str) { + let parked = [self.targa_options.as_mut().map(|p| (&p.path, &mut p.record)), self.dds_options.as_mut().map(|p| (&p.path, &mut p.record))]; + if let Some((_, record)) = parked.into_iter().flatten().find(|(p, record)| p.as_str() == path && **record == "file.saveAs") { + *record = "file.save"; + return; + } + if let Some(last) = self.session.journal.last_mut() + && last.0 == "file.saveAs" + && last.1.get("path").and_then(Value::as_str) == Some(path) + && let Some(step) = last.1.as_object() + && step.keys().all(|k| matches!(k.as_str(), "path" | "tgaBits" | "ddsCompression" | "ddsMipmaps")) + { + let mut step = step.clone(); + step.remove("path"); + *last = ("file.save".into(), Value::Object(step)); + } + } + /// Replays a recorded `file.save` / `file.saveAs` / `file.saveACopy` step: no dialog or prompt, and the - /// recorded TIFF or Targa answer (if any) stands in for the TIFF or Targa Options prompt. + /// recorded TIFF, Targa or DDS answer (if any) stands in for the TIFF, Targa or DDS Options prompt. pub(crate) fn replay_save(&mut self, id: &str, params: &Value) -> Result { if self.save_options_open() { - return Err("Answer TIFF or Targa Options before starting another save".into()); + return Err("Answer the TIFF, Targa or DDS Options before starting another save".into()); } if id == "file.save" { + // The file the action is played on: one that holds the whole document, or a flat + // file a recorded Save confirmed in the dialog while the document is still a plain + // raster, so it is never flattened over (#3531, #3806). A flat file's recorded Targa + // or DDS answer stands in for the prompt. + let choices = FormatChoices::from_params(params, id)?; let st = self.session.active().ok_or("no document")?; - let path = - st.path.clone().filter(|p| photocraft_engine::file_cmds::holds_at(&st.doc, p)).ok_or( - "Save writes back only to the document's own PSD, PSB, .pcraft or OpenRaster file, and to OpenRaster only when it holds everything", - )?; - return self.save_in_place(path); + let flat_own = |p: &str| { + !photocraft_engine::file_cmds::saves_in_place(p) + && plain_raster(&st.doc).is_some() + && photocraft_engine::file_cmds::extension(p).is_some_and(|e| photocraft_codecs::from_extension(&e).is_some_and(|f| f.caps().write)) + }; + let path = st.path.clone().filter(|p| photocraft_engine::file_cmds::holds_at(&st.doc, p) || flat_own(p)).ok_or( + "Save writes back only to the document's own PSD, PSB, .pcraft or OpenRaster file (OpenRaster only when it holds everything), or to its own flat file while the document is a single plain layer", + )?; + return self.save_in_place(path, choices); } let path = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("Save As needs a `path`")?; - let tga_bits = targa_options_ui::bits_param(params, id)?; + let choices = FormatChoices::from_params(params, id)?; if id == "file.saveACopy" { // A copy: the document keeps its own file and unsaved state, as when recorded (#4373). let doc = self.session.active().ok_or("no document")?.doc.clone(); - return crate::view_cmds::write_copy(self, &doc, path, tga_bits); + return crate::view_cmds::write_copy(self, &doc, path, choices); } - self.save_as_step(path.to_string(), params.get("tiffLayers").and_then(Value::as_bool), tga_bits) + self.save_as_step(path.to_string(), params.get("tiffLayers").and_then(Value::as_bool), choices) } /// What a save writes by default under the preferences: File Handling › Image Previews @@ -1438,6 +1675,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 } @@ -1458,11 +1696,11 @@ impl PhotocraftApp { } /// Writes the active document to a known path. `tiff_layers` is the TIFF Options answer: - /// `Some(false)` discards the layers and saves a copy. `tga_bits` is the Targa bits per - /// pixel (`None`: 32 with an alpha channel or transparency, else 24). - fn write_save(&mut self, path: String, tiff_layers: Option, tga_bits: Option) -> Result { + /// `Some(false)` discards the layers and saves a copy. `choices` are the Targa bits per + /// pixel (`None`: 32 with an alpha channel or transparency, else 24) and the DDS options. + fn write_save(&mut self, path: String, tiff_layers: Option, choices: FormatChoices) -> Result { let copy = self.saves_copy(&path, tiff_layers)?; - let settings = ExportSettings { tiff_layers: tiff_layers.unwrap_or(ExportSettings::default().tiff_layers), tga_bits, ..self.save_settings() }; + let settings = choices.apply(ExportSettings { tiff_layers: tiff_layers.unwrap_or(ExportSettings::default().tiff_layers), ..self.save_settings() }); match self.write_document(path.clone(), &settings, copy)? { Some((path, warnings)) => Ok(serde_json::json!({"path": path, "warnings": warnings})), None => Ok(serde_json::json!({"path": path, "warnings": [], "pending": true, "job": self.jobs.last_started.map(|j| j.0)})), @@ -1481,11 +1719,12 @@ impl PhotocraftApp { return jobs_ui::start_save(self, path, settings.clone(), copy, save, false); } let st = self.session.active().ok_or("no document")?; - let (doc, revision) = (st.doc.id, st.revision); + let (doc, written, previous) = (st.doc.id, st.revision, st.saved_revision); let export = self.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(&st.doc, &path, settings)?; let write = self.services.write.as_mut().ok_or("no writer configured")?; write(&path, &bytes)?; + let revision = saved_revision_after_write(!self.services.write_unconfirmed, written, previous); self.saved(doc, revision, &path, &warnings, copy); Ok(Some((path, warnings))) } @@ -1522,6 +1761,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 @@ -1541,6 +1781,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())]); @@ -1548,9 +1791,24 @@ impl PhotocraftApp { self.sync_views(); } - /// TIFF Options or Targa Options is waiting on an answer before a save is written. + /// 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, Targa or DDS 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() + self.tiff_options.is_some() || self.targa_options.is_some() || self.dds_options.is_some() } /// A print worker may still be handing a job to the system queue. @@ -1567,12 +1825,13 @@ impl PhotocraftApp { /// picker or ambient writer is reachable from this path. Returns the path /// written and the export warnings (also shown to the user). pub fn save_automation(&mut self, path: Option) -> Result { - self.save_automation_with(path, None) + self.save_automation_with(path, FormatChoices::default()) } - /// [`Self::save_automation`] writing a Targa with `tga_bits` per pixel (`None`: 32 with an - /// alpha channel or transparency, else 24). Automation saves never prompt. - pub fn save_automation_with(&mut self, path: Option, tga_bits: Option) -> Result { + /// [`Self::save_automation`] writing a Targa or DDS texture with the `choices` given (a + /// Targa's `None`: 32 with an alpha channel or transparency, else 24; a DDS texture's: + /// uncompressed without mipmaps). Automation saves never prompt. + pub fn save_automation_with(&mut self, path: Option, choices: FormatChoices) -> Result { let target = { let state = self.session.active().ok_or("no document")?; // As File › Save: without `path` only a layered file is written back (#416). @@ -1585,7 +1844,7 @@ impl PhotocraftApp { if self.background_jobs && let Some(save) = self.services.automation_save.clone() { - return match jobs_ui::start_save(self, target.clone(), ExportSettings { tga_bits, ..self.save_settings() }, false, save, true)? { + return match jobs_ui::start_save(self, target.clone(), choices.apply(self.save_settings()), false, save, true)? { Some((path, warnings)) => Ok(reply(&path, &warnings)), None => { let job = self.jobs.last_started; @@ -1595,7 +1854,7 @@ impl PhotocraftApp { } let state = self.session.active().ok_or("no document")?; let (doc, revision) = (state.doc.id, state.revision); - let settings = ExportSettings { tga_bits, ..self.save_settings() }; + let settings = choices.apply(self.save_settings()); let export = self.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(&state.doc, &target, &settings)?; let write = self.services.automation_write.as_mut().ok_or("automation write authority is not configured")?; @@ -1671,12 +1930,34 @@ impl PhotocraftApp { } } +impl PhotocraftApp { + /// Wait out the rest of [`Self::min_frame_ms`] since the last frame started. + fn cap_frame_rate(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(min) = self.min_frame_ms.filter(|m| m.is_finite() && *m > 0.0) { + let wait = min.min(100.0) - (gpu_canvas::now_ms() - self.last_frame_start_ms); + if wait > 0.0 { + std::thread::sleep(std::time::Duration::from_secs_f64(wait / 1000.0)); + } + } + self.last_frame_start_ms = gpu_canvas::now_ms(); + } +} + impl eframe::App for PhotocraftApp { + fn on_exit(&mut self) { + if let Err(err) = tool_memory::save_on_exit(self) { + log::warn!("Preferences: could not save the final tool selection: {err}"); + } + } + fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + self.cap_frame_rate(); i18n::set_current(i18n::Lang::from_pref(&self.session.prefs().interface.language)); // Caps Lock state read from the OS once per frame (see `Services::caps_lock`): the canvas // cursor block needs it before the frame renders. `None` (Wayland/web) keeps `false`. self.caps_lock = self.services.caps_lock.as_mut().is_some_and(|f| f()); + tool_cursor::set_wayland(ctx, self.services.is_wayland); if !self.styled { Self::setup_context(ctx, self.ui.theme); self.styled = true; @@ -1734,6 +2015,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 @@ -1745,6 +2030,9 @@ impl eframe::App for PhotocraftApp { if !dropped.is_empty() { let at = self.services.cursor_pos.as_mut().and_then(|f| f(ctx)); self.open_dropped(ctx, dropped, at); + // Files dragged in from another app's window: PhotoCraft comes to the front and takes + // the keyboard, as Photoshop does, instead of staying behind the source (#5687). + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } self.place_next_dropped(ctx); // The control transport wakes the UI on arrival (ctx.request_repaint); only poll while a @@ -1756,6 +2044,7 @@ impl eframe::App for PhotocraftApp { } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + let frame_start = gpu_canvas::now_ms(); // An undecorated window gets its caption back when the toolkit changes its flags // (maximize, restore, …): take it off again before the frame is laid out (#2246). if let Some(window_frame) = self.services.window_frame.as_mut() { @@ -1779,6 +2068,11 @@ impl eframe::App for PhotocraftApp { if self.custom_titlebar { titlebar::release_after_os_resize(ctx, raw_input); } + if self.stroke_trace.enabled() { + // The OS's moves and the control channel's (injected just above). + let moves = raw_input.events.iter().filter(|e| matches!(e, egui::Event::PointerMoved(_))).count(); + self.stroke_trace.begin_frame(frame_start, moves); + } } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { @@ -1792,6 +2086,8 @@ impl eframe::App for PhotocraftApp { return; } let t0 = gpu_canvas::now_ms(); + self.perf.frame_refresh_ms = 0.0; + self.stroke_trace.cpu_prev(frame.info().cpu_usage); // A text field's right-click menu, before any field is drawn (text_field_menu.rs). text_field_menu::show(&ctx); // Each painting tool keeps its own brush (#218), so switch the active tool's brush in @@ -1858,6 +2154,7 @@ impl eframe::App for PhotocraftApp { discard_ui::show(self, &ctx); tiff_options_ui::show(self, &ctx); targa_options_ui::show(self, &ctx); + dds_options_ui::show(self, &ctx); distort_ui::show(self, &ctx); camera_raw_ui::show(self, &ctx); wide_angle_ui::show(self, &ctx); @@ -1865,7 +2162,7 @@ impl eframe::App for PhotocraftApp { notices::show(self, &ctx); gpu_status::show_fallback(self, &ctx); kys_import::show_offer(self, &ctx); - if chrome && self.ui.panels.menu_bar && self.custom_titlebar && !self.ui.dialogs.is_empty() { + if self.titlebar_modal_drag(chrome) { titlebar::modal_drag_overlay(&ctx); } if self.custom_titlebar { @@ -1893,7 +2190,28 @@ impl eframe::App for PhotocraftApp { egui::Modal::new(egui::Id::new("screen-color-wait")).frame(egui::Frame::NONE).backdrop_color(egui::Color32::TRANSPARENT).show(&ctx, |_| {}); ctx.set_cursor_icon(egui::CursorIcon::Wait); } - self.perf.frame(gpu_canvas::now_ms() - t0); + let ui_ms = gpu_canvas::now_ms() - t0; + // A GPU measurement lands a frame or two after the refresh it belongs to. Refreshes + // carry them out, but the last refresh before the app settles has none following it, so + // drain the timer here too or its number is never reported. Drain it either way: a + // measurement left sitting would surface later against an unrelated refresh. Report it + // only while the canvas is still on the GPU, since a CPU refresh clears `gpu_ms` + // precisely so a GPU number is never shown as that refresh's. + if let Some(gpu) = &self.gpu + && let Some(t) = gpu.take_gpu_timing() + && self.perf.last_refresh.starts_with("gpu") + { + self.perf.record_gpu(t); + } + self.perf.frame(ui_ms); + if self.stroke_trace.enabled() { + self.stroke_trace.canvas(self.perf.frame_refresh_ms); + let info = &self.perf.gpu_info; + self.stroke_trace.end_frame(ui_ms, self.live_stroke.is_some(), || { + let gpu = format!("{} {}", info.backend, info.adapter).trim().to_string(); + if info.present.is_empty() { gpu } else { format!("{gpu}, {}", info.present) } + }); + } // Synthetic input is injected one press/release step per frame: keep frames coming until // the queue is empty, then release control replies waiting on it. if !self.synthetic.is_empty() { @@ -1930,6 +2248,14 @@ fn read_dropped(_f: &dyn egui::DroppedFile) -> Result, String> { } impl PhotocraftApp { + /// Whether the title bar's free gap gets the drag overlay this frame: the app draws its title + /// strip (`chrome`: not hidden by the screen mode) and a dialog's modal layer covers it. Not + /// only with the custom title bar: on macOS the strip under the traffic lights is the window's + /// title bar too, and an open dialog (Embedded Profile Mismatch…) left it undraggable (#5826). + pub(crate) fn titlebar_modal_drag(&self, chrome: bool) -> bool { + chrome && self.ui.panels.menu_bar && !self.ui.dialogs.is_empty() + } + /// `params` aimed at the active layer's mask (`"target":"mask"`) when the Layers panel targets /// it and command `id` edits the target (adjustments, filters, fills) without naming one: /// ⌘I then inverts the mask, as in Photoshop (#780). A targeted alpha channel or Quick Mask @@ -2022,6 +2348,7 @@ impl PhotocraftApp { // Native files can restore IDs: retain the union across all open documents, rather // than deleting an ID just because one document containing it was closed. self.thumbs.retain(|key, _| live.contains(key)); + self.thumb_frames.retain(|id, _| live.contains(&(*id, mask_thumbs_ui::THUMB_LAYER))); } self.thumb_documents = documents.iter().map(|st| (st.doc.id, std::sync::Arc::downgrade(&st.doc))).collect(); } @@ -2047,25 +2374,31 @@ impl PhotocraftApp { self.checker = None; } - /// Cached 64px thumbnail of a pixel-ish layer, laid out in document space. - pub fn layer_thumb(&mut self, ctx: &egui::Context, doc: &Document, layer: &photocraft_doc::Layer) -> egui::TextureId { + /// Cached 64px thumbnail of a pixel-ish layer, letterboxed in a square, and the document + /// area it frames: the canvas, or with Clip Thumbnails to Layer Bounds the layer's pixels. + pub fn layer_thumb(&mut self, ctx: &egui::Context, doc: &Document, layer: &photocraft_doc::Layer) -> (egui::TextureId, photocraft_geom::Rect) { + let clip = self.session.prefs().interface.clip_thumbnails_to_layer_bounds; // Key by content, not document revision: COW tiles change pointer only when their pixels // change, so unrelated edits (e.g. painting another layer) don't rebuild this thumbnail. - let rev = layer.surface().map_or(0, surface_fingerprint) ^ (doc.size.width as u64) << 40; + let rev = layer.surface().map_or(0, surface_fingerprint) ^ (doc.size.width as u64) << 40 ^ u64::from(clip) << 63; let key = (layer.id, mask_thumbs_ui::THUMB_LAYER); if let Some((r, tex)) = self.thumbs.get(&key) && *r == rev + && let Some(frame) = self.thumb_frames.get(&layer.id) { - return tex.id(); + return (tex.id(), *frame); } + let canvas = doc.bounds(); + let frame = layer_thumb_frame(layer, canvas, clip); let mut px = [0.0f32; 8]; - let img = thumb_image(doc, 64, |x, y| { + let img = thumb_image(frame, 64, |x, y| { let Some(s) = layer.surface() else { return [0.0; 4] }; let n = s.channels(); s.read_pixel(x, y, &mut px[..n]); photocraft_raster::to_rgba(&s.format(), &px[..n]) }); - self.store_thumb(ctx, key, rev, img) + self.thumb_frames.insert(layer.id, frame); + (self.store_thumb(ctx, key, rev, img), frame) } /// The History panel thumbnail of snapshot `snap` of document `doc` (#2966): its composite, @@ -2100,7 +2433,7 @@ impl PhotocraftApp { return tex.id(); } let mut v = [0.0f32; 1]; - let img = thumb_image(doc, 64, |x, y| { + let img = thumb_image(doc.bounds(), 64, |x, y| { mask.surface.read_pixel(x, y, &mut v); [v[0], v[0], v[0], 1.0] }); @@ -2139,9 +2472,20 @@ pub fn surface_fingerprint(s: &photocraft_raster::Surface) -> u64 { h } -/// Square thumbnail of the canvas area, letterboxed, sampling `f(x, y)` in document space. -fn thumb_image(doc: &Document, side: usize, mut f: impl FnMut(i32, i32) -> [f32; 4]) -> egui::ColorImage { - let (w, h) = (doc.size.width.max(1) as f32, doc.size.height.max(1) as f32); +/// The document area a layer thumbnail frames: the canvas, or with `clip` the layer's pixels on +/// it (the canvas again when it has none there, so an empty layer still shows the page shape). +fn layer_thumb_frame(layer: &photocraft_doc::Layer, canvas: photocraft_geom::Rect, clip: bool) -> photocraft_geom::Rect { + if !clip { + return canvas; + } + let bounds = layer.surface().map_or(photocraft_geom::Rect::EMPTY, photocraft_compose::bounds::content_bounds).intersect(&canvas); + if bounds.is_empty() { canvas } else { bounds } +} + +/// Square thumbnail of the document area `frame`, letterboxed, sampling `f(x, y)` in document +/// space. +fn thumb_image(frame: photocraft_geom::Rect, side: usize, mut f: impl FnMut(i32, i32) -> [f32; 4]) -> egui::ColorImage { + let (w, h) = (frame.width().max(1) as f32, frame.height().max(1) as f32); let scale = w.max(h) / side as f32; let (ox, oy) = ((side as f32 - w / scale) / 2.0, (side as f32 - h / scale) / 2.0); let mut px = vec![egui::Color32::TRANSPARENT; side * side]; @@ -2152,7 +2496,7 @@ fn thumb_image(doc: &Document, side: usize, mut f: impl FnMut(i32, i32) -> [f32; if dx < 0.0 || dy < 0.0 || dx >= w || dy >= h { continue; } - let c = f(dx as i32, dy as i32); + let c = f(frame.x0.saturating_add(dx as i32), frame.y0.saturating_add(dy as i32)); let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; px[ty * side + tx] = egui::Color32::from_rgba_unmultiplied(q(c[0]), q(c[1]), q(c[2]), q(c[3])); } @@ -2479,6 +2823,9 @@ mod profile_prompt_tests; #[cfg(test)] mod input_tests; +#[cfg(test)] +mod empty_workspace_tests; + #[cfg(test)] mod pen_variants_tests; @@ -2497,6 +2844,10 @@ mod pixel_aspect_tests; #[cfg(test)] mod transform_fx_tests; +#[cfg(test)] +mod layer_props_ui_tests; +#[cfg(test)] +mod transform_switch_tests; #[cfg(test)] mod transform_undo_tests; @@ -2527,6 +2878,8 @@ mod blur_pins_tests; #[cfg(test)] mod move_outline_tests; +#[cfg(test)] +mod layer_thumb_bounds_tests; #[cfg(test)] mod mask_thumb_refresh_tests; @@ -2554,9 +2907,15 @@ mod dropdown_press_tests; #[cfg(test)] mod marquee_tests; +#[cfg(test)] +mod guide_cursor_tests; + #[cfg(test)] mod canvas_menu_tests; +#[cfg(test)] +mod nudge_bundle_tests; + #[cfg(test)] mod caps_lock_tests; @@ -2858,3 +3217,6 @@ mod channel_tint_tests { assert_eq!(channel_tint(Rgb, 0, 77, false), Color32::from_gray(77)); } } + +#[cfg(test)] +mod options_hit_area_tests; diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 07c1e5a7b6..9f46020b11 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -10,7 +10,7 @@ use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2}; use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, LiquifyTool, ProxyImage, auto_cell}; -use photocraft_doc::LayerId; +use photocraft_doc::{Document, LayerId}; use photocraft_geom::Rect; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -40,10 +40,32 @@ pub struct LiquifyOpts { pub mesh_size: usize, pub show_mask: bool, pub show_backdrop: bool, + /// Show Backdrop › Use: the layer shown as the backdrop (its id), `None` for All Layers. + pub backdrop_layer: Option, + pub backdrop_mode: BackdropMode, pub backdrop_opacity: f32, pub reconstruct_amount: f32, } +/// Show Backdrop › Mode, as Photoshop draws them (#7609): In Front lays the backdrop over the +/// image at the Opacity, Behind lays the image over the backdrop at the Opacity, and Blend fades +/// the image to 100 − Opacity under the backdrop at the Opacity. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BackdropMode { + #[default] + InFront, + Behind, + Blend, +} + +impl BackdropMode { + const ALL: [(BackdropMode, &'static str); 3] = [(BackdropMode::InFront, "inFront"), (BackdropMode::Behind, "behind"), (BackdropMode::Blend, "blend")]; + + fn key(self) -> &'static str { + Self::ALL.iter().find(|(m, _)| *m == self).map_or("inFront", |(_, k)| k) + } +} + impl Default for LiquifyOpts { fn default() -> Self { LiquifyOpts { @@ -56,6 +78,8 @@ impl Default for LiquifyOpts { mesh_size: 1, show_mask: true, show_backdrop: false, + backdrop_layer: None, + backdrop_mode: BackdropMode::InFront, backdrop_opacity: 50.0, reconstruct_amount: 100.0, } @@ -88,6 +112,13 @@ impl LiquifyOpts { *slot = b; } } + // `"all"` (or anything but a layer id) is All Layers. + if let Some(u) = v.get("backdropUse") { + self.backdrop_layer = u.as_u64(); + } + if let Some(m) = v.get("backdropMode").and_then(Value::as_str).and_then(|m| BackdropMode::ALL.iter().find(|(_, k)| *k == m)) { + self.backdrop_mode = m.0; + } if let Some(m) = v.get("meshSize").and_then(Value::as_str) { self.mesh_size = match m { "small" => 0, @@ -114,6 +145,8 @@ impl LiquifyOpts { "meshSize": mesh, "showMask": self.show_mask, "showBackdrop": self.show_backdrop, + "backdropUse": self.backdrop_layer.map_or(json!("all"), |id| json!(id)), + "backdropMode": self.backdrop_mode.key(), "backdropOpacity": self.backdrop_opacity, }) } @@ -143,7 +176,8 @@ pub struct LiquifyDialog { view_source_scratch: Vec<[f32; 4]>, view_tex: Option, view_dirty: Option, - backdrop: Option, + /// The backdrop and the Use it shows (see [`LiquifyOpts::backdrop_layer`]). + backdrop: Option<(Option, TextureHandle)>, mask_tex: Option, mask_dirty: bool, pub opts: LiquifyOpts, @@ -495,6 +529,8 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> if let Some(saved) = app.session.prefs().dialogs.get(REMEMBERED) { let _ = d.opts.apply(saved); } + // Layer ids belong to one document: a remembered backdrop Use starts at All Layers. + d.opts.backdrop_layer = None; d.upload(ctx); app.distort.liquify = Some(d); Ok(()) @@ -536,8 +572,16 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { cancel(app); return Ok(json!({"cancelled": true})); } + // A `backdropUse` that names no layer of the document is All Layers, as the Use list shows it. + let missing = |id: u64| app.session.active().is_none_or(|st| st.doc.layer(LayerId(id)).is_none()); + let unknown_use = ui.get("backdropUse").and_then(Value::as_u64).is_some_and(missing); let d = app.distort.liquify.as_mut().ok_or(tl!("Liquify is not open"))?; - d.opts.apply(ui)?; + // `apply` takes every setting it can before a bad one fails it, so normalize first. + let applied = d.opts.apply(ui); + if unknown_use { + d.opts.backdrop_layer = None; + } + applied?; let num = |k: &str| ui.get(k).and_then(Value::as_f64).map(|v| v as f32); let flag = |k: &str| ui.get(k).and_then(Value::as_bool); if flag("undo") == Some(true) { @@ -710,26 +754,85 @@ fn shortcut(t: LiquifyTool) -> &'static str { } } -fn load_backdrop(app: &PhotocraftApp, ctx: &egui::Context, layer: LayerId, canvas: Rect) -> Option { +/// The backdrop for Show Backdrop › Use `of`: the document as it is (All Layers, the liquified +/// layer's own pixels included, as in Photoshop) or that one layer alone. A layer that is gone +/// shows All Layers. +fn load_backdrop(app: &PhotocraftApp, ctx: &egui::Context, of: Option, canvas: Rect) -> Option { let st = app.session.active()?; - let doc = crate::distort_ui::without_layer(&st.doc, layer); - let buf = photocraft_compose::render(&doc, canvas); + let buf = photocraft_compose::render(&backdrop_doc(&st.doc, of), canvas); let mut s = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8); let flat: Vec = buf.px.iter().flat_map(|p| *p).collect(); s.write_region(canvas, &flat); Some(ctx.load_texture("liquify-backdrop", crate::distort_ui::surface_image(&s, canvas, PROXY_SIDE), egui::TextureOptions::LINEAR)) } +/// The document [`load_backdrop`] renders for Use `of`. +fn backdrop_doc(doc: &Document, of: Option) -> std::borrow::Cow<'_, Document> { + match of.map(LayerId).filter(|id| doc.layer(*id).is_some()) { + Some(id) => { + let mut d = doc.clone(); + crate::transform_tool::solo_layer(&mut d.layers, id); + // A hidden layer, or one in a hidden group, shows too: Use lists every layer. + reveal(&mut d.layers, id); + std::borrow::Cow::Owned(d) + } + None => std::borrow::Cow::Borrowed(doc), + } +} + +/// Makes layer `id` and the groups holding it visible; whether it was found. +fn reveal(layers: &mut [photocraft_doc::Layer], id: LayerId) -> bool { + layers.iter_mut().any(|l| { + let found = l.id == id + || match &mut l.content { + photocraft_doc::LayerContent::Group(g) => reveal(&mut g.children, id), + _ => false, + }; + if found { + l.visible = true; + } + found + }) +} + +/// How the preview layers the image and the backdrop texture `T`: the image's tint, and the +/// backdrop drawn behind it or in front of it, with its tint. +#[derive(Debug, PartialEq)] +struct Layering { + image: Color32, + behind: Option<(T, Color32)>, + front: Option<(T, Color32)>, +} + +/// The [`Layering`] for the backdrop `backdrop` (when shown) in `mode` at `opacity` (0..1): +/// Photoshop's modes, measured on its Liquify (#7609). +fn backdrop_layering(backdrop: Option, mode: BackdropMode, opacity: f32) -> Layering { + let alpha = |a: f32| Color32::from_white_alpha((a.clamp(0.0, 1.0) * 255.0).round() as u8); + let (image, behind, front) = match (backdrop, mode) { + (None, _) => (Color32::WHITE, None, None), + (Some(b), BackdropMode::InFront) => (Color32::WHITE, None, Some((b, alpha(opacity)))), + (Some(b), BackdropMode::Behind) => (alpha(opacity), Some((b, Color32::WHITE)), None), + (Some(b), BackdropMode::Blend) => (alpha(1.0 - opacity), None, Some((b, alpha(opacity)))), + }; + Layering { image, behind, front } +} + /// Draws the dialog (a full-window layer over the app). pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let screen = ctx.content_rect(); - if app.distort.liquify.as_ref().is_some_and(|d| d.opts.show_backdrop && d.backdrop.is_none()) { - let tex = app.distort.liquify.as_ref().map(|d| (d.layer, d.canvas)).and_then(|(layer, canvas)| load_backdrop(app, ctx, layer, canvas)); + // The backdrop is built when it's first shown and again when its Use changes. + let wanted = app.distort.liquify.as_ref().filter(|d| d.opts.show_backdrop && d.backdrop.as_ref().is_none_or(|(of, _)| *of != d.opts.backdrop_layer)); + if let Some((of, canvas)) = wanted.map(|d| (d.opts.backdrop_layer, d.canvas)) { + let tex = load_backdrop(app, ctx, of, canvas); if let Some(d) = app.distort.liquify.as_mut() { - d.backdrop = tex; + d.backdrop = tex.map(|t| (of, t)); } } + // Show Backdrop › Use: All Layers or one layer, bottom to top as Photoshop lists them. + let backdrop_layers: Vec<(Option, String)> = std::iter::once((None, tl!("All Layers").to_string())) + .chain(app.session.active().into_iter().flat_map(|st| st.doc.walk().into_iter().map(|(_, _, l)| (Some(l.id.0), l.name.clone())).collect::>())) + .collect(); let mut action: Option<&str> = None; let mut events: Vec = Vec::new(); let mut mods = egui::Modifiers::NONE; @@ -818,6 +921,16 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { widgets::checkbox(ui, &mut d.opts.show_mask, tl!("Show Mask")); widgets::checkbox(ui, &mut d.opts.show_backdrop, tl!("Show Backdrop")); if d.opts.show_backdrop { + ui.horizontal(|ui| { + ui.label(tl!("Use")); + let names: Vec<(Option, &str)> = backdrop_layers.iter().map(|(id, n)| (*id, n.as_str())).collect(); + widgets::dropdown_literal(ui, "liquify-backdrop-use", &mut d.opts.backdrop_layer, &names, 150.0); + }); + ui.horizontal(|ui| { + ui.label(tl!("Mode")); + let modes = [(BackdropMode::InFront, tl!("In Front")), (BackdropMode::Behind, tl!("Behind")), (BackdropMode::Blend, tl!("Blend"))]; + widgets::dropdown(ui, "liquify-backdrop-mode", &mut d.opts.backdrop_mode, &modes, 150.0); + }); widgets::slider_row(ui, tl!("Opacity"), &mut d.opts.backdrop_opacity, 0.0..=100.0, "", None); } widgets::hairline(ui); @@ -875,10 +988,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let clip = painter.with_clip_rect(area); widgets::checker(&clip, img.intersect(area), 8.0); let uv = ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); - if d.opts.show_backdrop - && let Some(b) = &d.backdrop - { - clip.image(b.id(), img, uv, Color32::from_white_alpha((d.opts.backdrop_opacity / 100.0 * 255.0) as u8)); + let backdrop = d.backdrop.as_ref().filter(|_| d.opts.show_backdrop).map(|(_, t)| t.id()); + let Layering { image: image_tint, behind, front } = backdrop_layering(backdrop, d.opts.backdrop_mode, d.opts.backdrop_opacity / 100.0); + if let Some((b, c)) = behind { + clip.image(b, img, uv, c); } if d.zoom > 1.0 && let (Some(bounds), Some(tex)) = (d.view_bounds, &d.view_tex) @@ -887,7 +1000,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { to_screen(d, area, [f64::from(bounds.x0), f64::from(bounds.y0)]), to_screen(d, area, [f64::from(bounds.x1), f64::from(bounds.y1)]), ); - clip.image(tex.id(), detail, uv, Color32::WHITE); + clip.image(tex.id(), detail, uv, image_tint); } else { // The proxy covers whole proxy pixels, a little past the canvas edge. let pr = ERect::from_min_max( @@ -895,9 +1008,12 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { img.min + vec2(d.proxy.w as f32 * d.proxy.scale as f32 * d.zoom, d.proxy.h as f32 * d.proxy.scale as f32 * d.zoom), ); if let Some(tex) = &d.tex { - clip.image(tex.id(), pr, uv, Color32::WHITE); + clip.image(tex.id(), pr, uv, image_tint); } } + if let Some((b, c)) = front { + clip.image(b, img, uv, c); + } if d.opts.show_mask && let Some(m) = &d.mask_tex { @@ -1111,6 +1227,55 @@ mod tests { assert!(d.preview_ms.is_finite()); } + /// #7609: Show Backdrop › Use and Mode, as in Photoshop: the settings, how each mode layers the + /// backdrop and the image, and what each Use shows. + #[test] + fn backdrop_use_and_mode() { + let mut o = LiquifyOpts::default(); + assert_eq!((o.backdrop_layer, o.backdrop_mode), (None, BackdropMode::InFront), "All Layers, In Front"); + o.apply(&json!({"backdropUse": 3, "backdropMode": "behind"})).unwrap(); + assert_eq!((o.backdrop_layer, o.backdrop_mode), (Some(3), BackdropMode::Behind)); + assert_eq!((&o.to_json()["backdropUse"], &o.to_json()["backdropMode"]), (&json!(3), &json!("behind"))); + o.apply(&json!({"backdropUse": "all", "backdropMode": "nope"})).unwrap(); + assert_eq!((o.backdrop_layer, o.backdrop_mode), (None, BackdropMode::Behind), "a bad mode is ignored"); + let (w, a) = (Color32::WHITE, |x: f32| Color32::from_white_alpha((x * 255.0).round() as u8)); + let l = |image, behind, front| Layering { image, behind, front }; + assert_eq!(backdrop_layering(Some(1), BackdropMode::InFront, 0.25), l(w, None, Some((1, a(0.25)))), "backdrop over the image"); + assert_eq!(backdrop_layering(Some(1), BackdropMode::Behind, 0.25), l(a(0.25), Some((1, w)), None), "image over the backdrop"); + assert_eq!(backdrop_layering(Some(1), BackdropMode::Blend, 0.25), l(a(0.75), None, Some((1, a(0.25)))), "faded together"); + assert_eq!(backdrop_layering::(None, BackdropMode::Blend, 0.25), l(w, None, None), "no backdrop: the image as is"); + // All Layers is the document with the liquified layer in it; one layer is that layer alone. + let app = app_with_layer(); + let st = app.session.active().unwrap(); + let (layer, background) = (st.active_layer.unwrap(), st.doc.layers[0].id); + let at = |doc: &Document| photocraft_compose::render(doc, Rect::new(10, 10, 11, 11)).px[0]; + let own = at(&st.doc); + assert_eq!(at(&backdrop_doc(&st.doc, None)), own, "All Layers"); + assert_eq!(at(&backdrop_doc(&st.doc, Some(layer.0))), own, "the liquified layer alone"); + assert_ne!(at(&backdrop_doc(&st.doc, Some(background.0))), own, "the Background alone"); + assert_eq!(at(&backdrop_doc(&st.doc, Some(9999))), own, "a layer that's gone: All Layers"); + // A hidden layer shows when it is the one used. + let mut hidden = (*st.doc).clone(); + hidden.layer_mut(layer).unwrap().visible = false; + assert_eq!(at(&backdrop_doc(&hidden, Some(layer.0))), own, "the hidden liquified layer, alone"); + // A remembered Use is a layer of another document: Liquify opens on All Layers. + let ctx = egui::Context::default(); + let mut app = app_with_layer(); + app.session.prefs.edit(|p| p.dialogs.insert(REMEMBERED.into(), json!({"backdropUse": 42, "backdropMode": "blend"}))); + open(&mut app, &ctx).unwrap(); + let o = &app.distort.liquify.as_ref().unwrap().opts; + assert_eq!((o.backdrop_layer, o.backdrop_mode), (None, BackdropMode::Blend), "the mode is kept, the layer is not"); + // Over the control channel, a layer of the document is kept and one that isn't is All Layers. + let layer = app.session.active().unwrap().active_layer.unwrap().0; + control(&mut app, &json!({"backdropUse": layer})).unwrap(); + assert_eq!(app.distort.liquify.as_ref().unwrap().opts.backdrop_layer, Some(layer)); + control(&mut app, &json!({"backdropUse": 4242})).unwrap(); + assert_eq!(app.distort.liquify.as_ref().unwrap().opts.backdrop_layer, None, "no such layer: All Layers"); + control(&mut app, &json!({"backdropUse": layer})).unwrap(); + assert!(control(&mut app, &json!({"backdropUse": 4242, "tool": "nope"})).is_err()); + assert_eq!(app.distort.liquify.as_ref().unwrap().opts.backdrop_layer, None, "also when another setting fails"); + } + /// #418: the brush settings survive Cancel, OK and Esc, and come back the next time Liquify /// opens (also after a restart: they live in the preferences). #[test] diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index cf4832904a..321a31e73a 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -147,6 +147,23 @@ fn shift_during_the_drag_draws_a_square_and_the_readout_follows() { assert!(h.query_by_label("W:").is_none() && h.query_by_label_contains(" px").is_none(), "the readout goes away with the drag"); } +/// #5472: a ⇧ square pressed between pixels read 101 × 102 and selected that, because each axis +/// snapped to the pixel grid on its own. The readout and the selection are square, and the +/// Elliptical Marquee's circle too. +#[test] +fn shift_square_pressed_between_pixels_has_equal_sides() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + let mut h = harness(tool); + press_at(&mut h, 50.3, 50.7, Modifiers::NONE); + mods(&mut h, Modifiers::SHIFT); + move_to(&mut h, 150.8, 90.0); + assert!(readout(&h, 102, 102), "{tool:?}: the square covers every pixel it touched, on both axes"); + release_at(&mut h, 150.8, 90.0, Modifiers::SHIFT); + mods(&mut h, Modifiers::NONE); + assert_eq!(selection(&h), Rect::new(50, 50, 152, 152), "{tool:?}"); + } +} + /// #204: Preferences › Tools › Show Transformation Values off hides the readout, not the marquee. #[test] fn show_transformation_values_off_hides_the_readout() { @@ -529,6 +546,38 @@ fn mouse_drag_inside_the_selection_moves_it() { assert_eq!(selection(&h), Rect::new(120, 90, 220, 170), "moved by (20, 10)"); } +/// #6290: with a fresh app's View › Snap To, a selection moved through the real canvas snaps its +/// edges to a guide and the document bounds. Layers' targets were clipped to the selection, so +/// it snapped back to its own starting edges and centre (shown as layer alignments) instead. +#[test] +fn a_moved_selection_snaps_to_guides_and_the_document_not_its_start() { + for (to, want) in [ + // Right edge 3 px left of the guide at x = 200; 3 px down stays put (not back to y = 52). + ([145.0, 103.0], Rect::new(100, 55, 200, 105)), + // 5 and 7 px from the document's top-left corner (its centre was 3 px off its start). + ([53.0, 55.0], Rect::new(0, 0, 100, 50)), + ] { + let mut h = harness(Tool::RectMarquee); + h.state_mut().run("view.newGuide", json!({"orientation": "vertical", "position": 200})).unwrap(); + h.state_mut().run("select.rect", json!({"x": 52, "y": 52, "width": 100, "height": 50})).unwrap(); + h.run_steps(2); + drag_to(&mut h, [100.0, 100.0], to); + let up = screen(&h, to[0], to[1]); + button(&mut h, up, false, Modifiers::NONE); + h.run_steps(2); + assert_eq!(selection(&h), want, "dragged to {to:?}"); + } +} + +/// #5646: the Move tool keeps Photoshop's arrow pointer instead of the 4-way move cursor (the +/// hover outline still marks the layer under the pointer). +#[test] +fn the_move_tool_keeps_the_arrow_pointer() { + let mut h = harness(Tool::Move); + move_to(&mut h, 200.0, 150.0); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default); +} + /// #1428: hovering inside the ants with a marquee shows the move cursor (a press there drags the /// outline); outside it is the marquee's own cursor. #[test] @@ -545,12 +594,14 @@ fn hovering_inside_the_selection_shows_the_move_cursor() { } /// #1428: with a marquee, the arrow keys nudge the selection outline 1 px, ⇧ 10 px (Photoshop); -/// each press is one undoable step. +/// each press is one undoable step with Bundle Arrow-Key Nudges off, a run of them one step with +/// it on (#2259, the default). #[test] fn arrow_keys_nudge_the_selection_outline() { use egui::Key; - for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + for (tool, bundle) in [(Tool::RectMarquee, false), (Tool::EllipseMarquee, false), (Tool::RectMarquee, true), (Tool::EllipseMarquee, true)] { let mut h = harness(tool); + h.state_mut().session.edit_prefs(|p| p.tools.bundle_nudges = bundle); press_at(&mut h, 100.0, 80.0, Modifiers::NONE); release_at(&mut h, 200.0, 160.0, Modifiers::NONE); let drawn = selection(&h); @@ -569,9 +620,15 @@ fn arrow_keys_nudge_the_selection_outline() { h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowLeft); h.run_steps(1); assert_eq!(selection(&h), shifted(-9, 9), "{tool:?} shift-left 10 px"); - assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 4, "one step per press"); + let nudges = h.state().session.active().unwrap().history.past_len() - steps; h.state_mut().session.undo(); - assert_eq!(selection(&h), shifted(1, 9), "{tool:?} undo takes back one nudge"); + if bundle { + assert_eq!(nudges, 1, "{tool:?} four quick presses, one step"); + assert_eq!(selection(&h), drawn, "{tool:?} undo takes back the run"); + } else { + assert_eq!(nudges, 4, "{tool:?} one step per press"); + assert_eq!(selection(&h), shifted(1, 9), "{tool:?} undo takes back one nudge"); + } } } diff --git a/crates/ui-egui/src/mask_props_ui.rs b/crates/ui-egui/src/mask_props_ui.rs index 295310bf97..26c3791d5a 100644 --- a/crates/ui-egui/src/mask_props_ui.rs +++ b/crates/ui-egui/src/mask_props_ui.rs @@ -66,8 +66,12 @@ fn sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: u64, document: u64 } g.0 }); - for (field, label, mut value, max, unit) in [("density", tl!("Density"), density * 100.0, 100.0, "%"), ("feather", tl!("Feather"), feather, 1000.0, "px")] { - let response = widgets::slider_row(ui, label, &mut value, 0.0..=max, unit, None); + // Feather's track is curved like Photoshop's, so a few px can be dragged to (#6545). + for (field, label, mut value, max, unit, curve) in + [("density", tl!("Density"), density * 100.0, 100.0, "%", None), ("feather", tl!("Feather"), feather, 1000.0, "px", Some(3.0))] + { + let gestures = widgets::RowGestures { curve, ..Default::default() }; + let response = widgets::slider_row_with(ui, label, &mut value, 0.0..=max, unit, None, gestures); response.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(value), label)); if response.changed() { let mut params = json!({"document": document, "layer": layer, diff --git a/crates/ui-egui/src/mask_props_ui_tests.rs b/crates/ui-egui/src/mask_props_ui_tests.rs index 1cde904682..660b94a155 100644 --- a/crates/ui-egui/src/mask_props_ui_tests.rs +++ b/crates/ui-egui/src/mask_props_ui_tests.rs @@ -94,8 +94,32 @@ fn mask_slider_drags_preview_live_and_undo_as_separate_gestures() { h.state_mut().run("edit.redo", json!({})).unwrap(); assert!((mask(&h).density - 0.5).abs() < 0.01); h.run_steps(3); - drag(&mut h, 1, &[0.0, 0.01, 0.02]); - assert!((mask(&h).feather - 20.0).abs() < 0.1); + // #6545: Feather's track is curved like Photoshop's, so a fifth of the way is 8 px, not 200. + drag(&mut h, 1, &[0.0, 0.1, 0.2]); + assert!((mask(&h).feather - 8.0).abs() < 0.3, "feather {}", mask(&h).feather); +} + +/// #6545: the curve is only the track's. A typed Feather and the arrow keys stay exact px, and +/// drawing the knob for them never writes the value back. +#[test] +fn typed_and_stepped_feather_stays_exact() { + let mut h = harness(false, crate::theme::ThemeKind::Pro); + h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(1).unwrap().click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("5".into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(mask(&h).feather, 5.0); + h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(1).unwrap().click(); + h.run_steps(1); + h.key_press(egui::Key::ArrowUp); + h.run_steps(3); + assert_eq!(mask(&h).feather, 6.0); + let before = h.state().session.active().unwrap().history.past_len(); + h.run_steps(5); + assert_eq!(mask(&h).feather, 6.0); + assert_eq!(h.state().session.active().unwrap().history.past_len(), before, "idle frames write nothing"); } #[test] diff --git a/crates/ui-egui/src/mask_thumbs_ui/tests.rs b/crates/ui-egui/src/mask_thumbs_ui/tests.rs index 50ff96f083..163c7cbae8 100644 --- a/crates/ui-egui/src/mask_thumbs_ui/tests.rs +++ b/crates/ui-egui/src/mask_thumbs_ui/tests.rs @@ -223,7 +223,6 @@ fn dragging_the_mask_thumbnail_to_trash_asks_to_apply_or_delete_it() { s.execute("layer.layerMask.delete", json!({"layer": masked})).unwrap(); s.execute("select.rect", json!({"x": 100, "y": 0, "width": 100, "height": 100})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({"layer": masked})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); let mut h = harness(s, 0, 1.0, 290.0); let drag_mask = |h: &mut Harness<'_, PhotocraftApp>| { let from = recorded(&h.ctx, masked).unwrap().0.into_iter().find(|(k, _)| *k == MaskKind::Pixel).unwrap().1.center(); @@ -595,7 +594,7 @@ fn mask_view_gestures_fail_gracefully_without_a_mask() { h.run_steps(2); // A stale view (layer gone) draws nothing instead of panicking. h.state_mut().session.active_mut().unwrap().channel_view.layer_mask = Some(photocraft_engine::mask_view_cmds::LayerMaskView { - layer: photocraft_doc::LayerId(987_654), + layer: photocraft_doc::LayerId(u64::MAX), mode: photocraft_engine::mask_view_cmds::MaskViewMode::Gray, }); assert!(canvas_px(&h).is_none()); diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs new file mode 100644 index 0000000000..c1536d8ee6 --- /dev/null +++ b/crates/ui-egui/src/menu_keys.rs @@ -0,0 +1,355 @@ +//! The menu bar from the keyboard (#4988), as Photoshop's on Windows: a tap on Alt (or F10) +//! puts the keyboard on the bar (File lit; ← / → move along it, ↓ / ↩ / Space open the lit menu, +//! Esc or a click leaves), Alt+letter opens the menu with that mnemonic straight away, and in an +//! open menu a letter runs the row with that mnemonic (or opens its submenu). +//! +//! Mnemonics are underlined while Alt is held, the bar is focused or a menu was opened from the +//! keyboard; a menu opened with the mouse shows none until Alt is pressed, as Windows does. +//! They are assigned per menu level from the labels as shown (so translated), unique within +//! the level: Photoshop's letters for the top menus where the label has them (F E I L Y S T V +//! W H), else the first unused letter, word starts first. Only ASCII letters and digits are keys +//! the keyboard reports as such, so a label without one (Japanese, Chinese…) has no mnemonic. +//! +//! The state is view state in egui memory, like [`crate::menu_nav::Nav`]. On macOS the native +//! menu bar replaces the in-window one and this never runs. + +use egui::{Color32, Event, Id, Key, Modifiers, Stroke, Ui}; + +use crate::menu_nav::Nav; + +/// Photoshop's mnemonics for the top menus (Windows), used where the shown label has the letter. +pub const TOP_PREFERRED: [(&str, char); 10] = [ + ("File", 'F'), + ("Edit", 'E'), + ("Image", 'I'), + ("Layer", 'L'), + ("Type", 'Y'), + ("Select", 'S'), + ("Filter", 'T'), + ("View", 'V'), + ("Window", 'W'), + ("Help", 'H'), +]; + +/// Keyboard state of the menu bar between frames. +#[derive(Clone, Debug, Default)] +pub struct Keys { + /// The title the keyboard is on with no menu open, in screen order (Alt tap, F10). + pub focus: Option, + /// Underline the mnemonics: the bar is focused or a menu was opened from the keyboard. + pub underline: bool, + /// Alt is down (from its key events, whatever the platform reports as modifiers). + alt_down: bool, + /// Alt went down and nothing else was pressed since: releasing it is a tap. + alt_tap: bool, + /// A menu was just opened from the keyboard: light its first row once it is drawn. + first_row: bool, +} + +fn keys_id() -> Id { + Id::new("pc-menu-bar-keys") +} + +fn focus_id() -> Id { + Id::new("pc-menu-bar-focus") +} + +/// Is the keyboard on the menu bar (no menu open yet)? The shortcuts leave the keys to it then, +/// as they do for an open menu: ← / → / ↓ / ↩ / Esc and the letters drive the bar. +pub fn is_focused(ctx: &egui::Context) -> bool { + let pass = ctx.cumulative_pass_nr(); + ctx.data(|d| d.get_temp::(focus_id())).is_some_and(|seen| seen + 2 >= pass) +} + +/// The mnemonic a key press stands for: the letter or digit of a plain key. +fn key_char(key: Key) -> Option { + let name = key.name(); + let mut chars = name.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) if c.is_ascii_alphanumeric() => Some(c.to_ascii_uppercase()), + _ => None, + } +} + +/// Mnemonics for `labels`, unique within the group: the preferred letter when the label has it, +/// else the first unused ASCII letter or digit, trying the first character of each word before +/// the others. Compared case-insensitively; the returned letter is upper case. +pub fn assign(labels: &[&str], preferred: &[Option]) -> Vec> { + let mut used: Vec = Vec::new(); + let mut out = Vec::with_capacity(labels.len()); + for (i, label) in labels.iter().enumerate() { + let upper: Vec = label.chars().map(|c| c.to_ascii_uppercase()).collect(); + let has = |c: char| upper.contains(&c.to_ascii_uppercase()); + let mut pick = preferred.get(i).copied().flatten().map(|c| c.to_ascii_uppercase()).filter(|&c| has(c) && !used.contains(&c)); + if pick.is_none() { + let starts = label.split(|c: char| !c.is_alphanumeric()).filter_map(|w| w.chars().next()); + let rest = label.chars(); + pick = starts.chain(rest).filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_uppercase()).find(|c| !used.contains(c)); + } + if let Some(c) = pick { + used.push(c); + } + out.push(pick); + } + out +} + +/// `prefix + label` as menu text with the mnemonic's first occurrence in `label` underlined +/// (none when `mnemonic` is None). `color` is the text colour; None takes the widget's. +pub fn mnemonic_text(ui: &Ui, prefix: &str, label: &str, mnemonic: Option, color: Option) -> egui::WidgetText { + let color = color.unwrap_or(Color32::PLACEHOLDER); + let font = egui::TextStyle::Button.resolve(ui.style()); + let plain = egui::TextFormat { font_id: font.clone(), color, ..Default::default() }; + let at = mnemonic.and_then(|m| label.char_indices().find(|(_, c)| c.to_ascii_uppercase() == m).map(|(i, c)| (i, c.len_utf8()))); + let Some((i, len)) = at else { + return egui::RichText::new(format!("{prefix}{label}")).color(color).into(); + }; + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = f32::INFINITY; + let underlined = egui::TextFormat { underline: Stroke::new(1.0, color), ..plain.clone() }; + let before = format!("{prefix}{}", &label[..i]); + if !before.is_empty() { + job.append(&before, 0.0, plain.clone()); + } + job.append(&label[i..i + len], 0.0, underlined); + if i + len < label.len() { + job.append(&label[i + len..], 0.0, plain); + } + job.into() +} + +/// The checkmark menu rows draw in their gutter. +pub const CHECK_MARK: &str = "✔ "; + +/// Width of the checkmark column every menu row reserves (#5705): the width of [`CHECK_MARK`] in +/// the button font, so checked, unchecked and plain rows (and submenu headers) all start their +/// label at the same x, whatever the script of the label. +pub fn check_gutter(ui: &Ui) -> f32 { + let font = egui::TextStyle::Button.resolve(ui.style()); + ui.painter().layout_no_wrap(CHECK_MARK.to_owned(), font, Color32::PLACEHOLDER).size().x +} + +/// A menu row's text (#5705): the label behind a fixed-width checkmark column ([`check_gutter`]), +/// with ✔ in the column when `checked`, and the mnemonic underlined when given (as +/// [`mnemonic_text`]). +pub fn menu_row_text(ui: &Ui, checked: bool, label: &str, mnemonic: Option) -> egui::WidgetText { + let font = egui::TextStyle::Button.resolve(ui.style()); + let plain = egui::TextFormat { font_id: font, color: Color32::PLACEHOLDER, ..Default::default() }; + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = f32::INFINITY; + // The label starts after the column: a checked row draws the mark in it, the others leave it + // empty (a leading space of the same width, not padding characters whose width varies). + let mut lead = check_gutter(ui); + if checked { + job.append(CHECK_MARK, 0.0, plain.clone()); + lead = 0.0; + } + let at = mnemonic.and_then(|m| label.char_indices().find(|(_, c)| c.to_ascii_uppercase() == m).map(|(i, c)| (i, c.len_utf8()))); + let parts = match at { + Some((i, len)) => [label.get(..i), label.get(i..i + len), label.get(i + len..)], + None => [Some(label), None, None], + }; + for (k, part) in parts.into_iter().enumerate() { + let Some(part) = part.filter(|p| !p.is_empty()) else { continue }; + let format = + if k == 1 && at.is_some() { egui::TextFormat { underline: Stroke::new(1.0, Color32::PLACEHOLDER), ..plain.clone() } } else { plain.clone() }; + job.append(part, lead, format); + lead = 0.0; + } + if job.sections.is_empty() { + // An empty label still keeps the row as wide as its column. + job.append("", lead, plain); + } + job.into() +} + +impl Keys { + /// Last frame's state. + pub fn load(ctx: &egui::Context) -> Keys { + ctx.data(|d| d.get_temp(keys_id())).unwrap_or_default() + } + + /// The state as the last frame left it (tests, inspection). + pub fn current(ctx: &egui::Context) -> Keys { + Self::load(ctx) + } + + pub fn store(self, ctx: &egui::Context) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| { + if self.focus.is_some() { + d.insert_temp(focus_id(), pass); + } else { + d.remove::(focus_id()); + } + d.insert_temp(keys_id(), self); + }); + } + + /// Underline the mnemonics this frame? + pub fn show_underlines(&self, ctx: &egui::Context) -> bool { + self.underline || self.focus.is_some() || self.alt_down || ctx.input(|i| i.modifiers.alt) + } + + /// A menu just opened from the keyboard: light its first row (once, when it is drawn). + pub fn take_first_row(&mut self, nav: &mut Nav) { + if self.first_row && nav.top.is_some() { + nav.highlight_first(0); + self.first_row = false; + } + } + + /// Handle this frame's keys, after the menus were drawn. `tops` are the titles' popup ids + /// and `mnemonics` their letters, both in screen order; `nav` is the open menu's state. A + /// command a mnemonic ran lands in `clicked`. + pub fn keys(&mut self, ctx: &egui::Context, tops: &[Id], mnemonics: &[Option], nav: &mut Nav, clicked: &mut Option) { + if tops.is_empty() { + return; + } + let events = ctx.input(|i| i.events.clone()); + let mut tap = false; + for e in &events { + match e { + Event::Key { key: Key::AltLeft | Key::AltRight, pressed: true, .. } => { + self.alt_down = true; + self.alt_tap = true; + } + Event::Key { key: Key::AltLeft | Key::AltRight, pressed: false, .. } => { + self.alt_down = false; + tap |= std::mem::take(&mut self.alt_tap); + } + // Alt+wheel (zoom, brush size…) or Alt going to the desktop is no tap either. + Event::Key { pressed: true, .. } + | Event::Text(_) + | Event::PointerButton { pressed: true, .. } + | Event::MouseWheel { .. } + | Event::WindowFocused(false) => self.alt_tap = false, + _ => {} + } + } + if events.iter().any(|e| matches!(e, Event::PointerButton { pressed: true, .. })) { + self.focus = None; + self.underline = false; + } + let open = nav.top.is_some(); + let n = tops.len(); + let open_top = |i: usize, keys: &mut Keys| { + if let Some(&id) = tops.get(i) { + egui::Popup::open_id(ctx, id); + keys.focus = None; + keys.underline = true; + keys.first_row = true; + ctx.request_repaint(); + } + }; + let consume = |m: Modifiers, k: Key| ctx.input_mut(|i| i.consume_key(m, k)); + // Alt+letter opens that menu, open or not (an open one switches), as on Windows. + let alt_letter = events.iter().find_map(|e| match e { + Event::Key { key, pressed: true, modifiers, .. } if modifiers.alt && !modifiers.command && !modifiers.ctrl => key_char(*key).map(|c| (*key, c)), + _ => None, + }); + if let Some((key, c)) = alt_letter + && let Some(i) = mnemonics.iter().position(|m| *m == Some(c)) + { + consume(Modifiers::ALT, key); + self.alt_tap = false; + open_top(i, self); + return; + } + if open { + // A letter runs the row with that mnemonic in the level the arrows are in, unless it is + // typed into a field in the menu: Help › Search took "S" for System Info (#5996). + let typing = ctx.text_edit_focused(); + let letter = events.iter().filter(|_| !typing).find_map(|e| match e { + Event::Key { key, pressed: true, modifiers, .. } if !modifiers.command && !modifiers.ctrl && !modifiers.alt => { + key_char(*key).map(|c| (*key, c)) + } + _ => None, + }); + if let Some((key, c)) = letter + && let Some(row) = nav.row_with_mnemonic(c) + { + consume(Modifiers::NONE, key); + self.underline = true; + if let Some(cmd) = row.command.clone() { + *clicked = Some(cmd); + egui::Popup::close_all(ctx); + } else { + nav.open_sub_row(ctx, row.widget); + } + ctx.request_repaint(); + } + if tap { + egui::Popup::close_all(ctx); + self.underline = false; + ctx.request_repaint(); + } + return; + } + // No menu open: Alt (tapped) or F10 puts the keyboard on the bar or takes it off again. + let f10 = consume(Modifiers::NONE, Key::F10); + if tap || f10 { + self.focus = if self.focus.is_some() { None } else { Some(0) }; + self.underline = false; + ctx.request_repaint(); + return; + } + let Some(focus) = self.focus else { return }; + let press = |k: Key| consume(Modifiers::NONE, k); + if press(Key::Escape) { + self.focus = None; + } else if press(Key::ArrowRight) { + self.focus = Some((focus + 1) % n); + } else if press(Key::ArrowLeft) { + self.focus = Some((focus + n - 1) % n); + } else if press(Key::ArrowDown) || press(Key::Enter) || press(Key::Space) { + open_top(focus, self); + } else if let Some((key, c)) = events.iter().find_map(|e| match e { + Event::Key { key, pressed: true, modifiers, .. } if modifiers.is_none() => key_char(*key).map(|c| (*key, c)), + _ => None, + }) { + if let Some(i) = mnemonics.iter().position(|m| *m == Some(c)) { + consume(Modifiers::NONE, key); + open_top(i, self); + } + } else { + return; + } + ctx.request_repaint(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn letters_are_unique_and_prefer_photoshop_s() { + let labels = ["File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"]; + let preferred: Vec> = TOP_PREFERRED.iter().map(|(_, c)| Some(*c)).collect(); + assert_eq!(assign(&labels, &preferred), "FEILYSTVWH".chars().map(Some).collect::>()); + // Items: first letters first, then the next free letter of the word starts, then any. + assert_eq!( + assign(&["New…", "Open…", "Open Recent", "Close", "Close All", "Save", "Save As…"], &[]), + [Some('N'), Some('O'), Some('R'), Some('C'), Some('A'), Some('S'), Some('V'),] + ); + // A preferred letter the label lacks (a translation) falls back; a taken one too. + assert_eq!(assign(&["Datei", "Bearbeiten"], &[Some('F'), Some('E')]), [Some('D'), Some('E')]); + assert_eq!(assign(&["Aa", "Ab", "Ba"], &[Some('A'), Some('A'), Some('A')]), [Some('A'), Some('B'), None]); + // Only ASCII letters and digits are keys: no mnemonic without one. + assert_eq!(assign(&["ファイル", "Zoom 100%", "✔ Rulers"], &[]), [None, Some('Z'), Some('R')]); + } + + #[test] + fn key_chars_are_plain_letters_and_digits() { + assert_eq!(key_char(Key::A), Some('A')); + assert_eq!(key_char(Key::Num7), Some('7')); + assert_eq!(key_char(Key::F10), None); + assert_eq!(key_char(Key::ArrowDown), None); + assert_eq!(key_char(Key::Space), None); + } +} + +#[cfg(test)] +#[path = "menu_keys_tests.rs"] +mod harness_tests; diff --git a/crates/ui-egui/src/menu_keys_tests.rs b/crates/ui-egui/src/menu_keys_tests.rs new file mode 100644 index 0000000000..0298cb470f --- /dev/null +++ b/crates/ui-egui/src/menu_keys_tests.rs @@ -0,0 +1,281 @@ +//! The menu bar from the keyboard (#4988): Alt / F10 focus the bar, Alt+letter opens a menu, +//! letters run rows, and the shortcuts wait while the bar has the keyboard. +use crate::PhotocraftApp; +use crate::menu_keys::Keys; +use crate::menu_nav::{Nav, is_open}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.sync_views(); + app +} + +/// The menu bar alone, as `menu_nav_tests` drives it. +fn bar() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1280.0, 720.0)).build_ui_state( + |ui, app| { + crate::menus::menu_bar(app, ui); + }, + app(), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + h +} + +fn key(h: &mut Harness<'static, PhotocraftApp>, k: egui::Key) { + h.key_press(k); + h.run_steps(3); +} + +fn alt_key(h: &mut Harness<'static, PhotocraftApp>, k: egui::Key) { + h.event_modifiers(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::ALT }, egui::Modifiers::ALT); + h.run_steps(3); +} + +/// A tap on Alt: down, then up with nothing else pressed between. +fn alt_tap(h: &mut Harness<'static, PhotocraftApp>) { + h.key_down(egui::Key::AltLeft); + h.run_steps(2); + h.key_up(egui::Key::AltLeft); + h.run_steps(3); +} + +fn rows(h: &Harness<'static, PhotocraftApp>, level: usize) -> Vec { + Nav::current(&h.ctx).rows.get(level).cloned().unwrap_or_default() +} + +#[test] +fn alt_focuses_the_bar_arrows_move_and_down_opens_the_lit_menu() { + let mut h = bar(); + assert_eq!(Keys::current(&h.ctx).focus, None); + alt_tap(&mut h); + assert_eq!(Keys::current(&h.ctx).focus, Some(0), "Alt puts the keyboard on File"); + assert!(crate::menu_keys::is_focused(&h.ctx), "the shortcuts wait for the bar"); + assert!(!is_open(&h.ctx), "no menu opens yet"); + key(&mut h, egui::Key::ArrowRight); + assert_eq!(Keys::current(&h.ctx).focus, Some(1), "→ moves to Edit"); + key(&mut h, egui::Key::ArrowLeft); + key(&mut h, egui::Key::ArrowLeft); + assert_eq!(Keys::current(&h.ctx).focus, Some(9), "← wraps round to Help"); + key(&mut h, egui::Key::ArrowRight); + key(&mut h, egui::Key::ArrowRight); + assert_eq!(Keys::current(&h.ctx).focus, Some(1)); + key(&mut h, egui::Key::ArrowDown); + h.run_steps(2); + assert!(is_open(&h.ctx), "↓ opens the lit menu"); + assert!(h.query_by_label_contains("Undo").is_some(), "Edit is open"); + assert_eq!(Keys::current(&h.ctx).focus, None, "the focus moves into the menu"); + let first = rows(&h, 0).iter().position(|r| r.enabled).unwrap(); + assert_eq!(Nav::current(&h.ctx).highlighted(0), Some(first), "its first row is lit, as on Windows"); + assert!(Keys::current(&h.ctx).underline, "a menu opened from the keyboard shows its mnemonics"); + key(&mut h, egui::Key::Escape); + assert!(!is_open(&h.ctx) && Keys::current(&h.ctx).focus.is_none(), "Esc leaves the menu and the bar"); +} + +/// Alt+wheel zooms; letting go of Alt after it is no tap, so Space (the hand tool) can't then +/// open File and run New…. +#[test] +fn alt_with_a_wheel_scroll_or_a_lost_focus_is_no_tap() { + let mut h = bar(); + h.key_down(egui::Key::AltLeft); + h.run_steps(2); + h.event_modifiers( + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::ALT, + }, + egui::Modifiers::ALT, + ); + h.run_steps(2); + h.key_up(egui::Key::AltLeft); + h.run_steps(3); + assert_eq!(Keys::current(&h.ctx).focus, None, "Alt+scroll leaves the bar alone"); + key(&mut h, egui::Key::Space); + assert!(!is_open(&h.ctx), "Space opens no menu"); + h.key_down(egui::Key::AltLeft); + h.run_steps(2); + h.event(egui::Event::WindowFocused(false)); + h.event(egui::Event::WindowFocused(true)); + h.key_up(egui::Key::AltLeft); + h.run_steps(3); + assert_eq!(Keys::current(&h.ctx).focus, None, "Alt that went to the desktop leaves the bar alone"); +} + +#[test] +fn a_second_alt_or_esc_or_a_click_takes_the_keyboard_off_the_bar() { + let mut h = bar(); + alt_tap(&mut h); + assert_eq!(Keys::current(&h.ctx).focus, Some(0)); + alt_tap(&mut h); + assert_eq!(Keys::current(&h.ctx).focus, None, "a second tap leaves"); + key(&mut h, egui::Key::F10); + assert_eq!(Keys::current(&h.ctx).focus, Some(0), "F10 focuses the bar too"); + key(&mut h, egui::Key::Escape); + assert_eq!(Keys::current(&h.ctx).focus, None); + assert!(!crate::menu_keys::is_focused(&h.ctx)); + key(&mut h, egui::Key::F10); + let at = egui::pos2(600.0, 300.0); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + assert_eq!(Keys::current(&h.ctx).focus, None, "a click anywhere leaves"); +} + +#[test] +fn alt_with_a_letter_opens_that_menu_and_enter_opens_the_lit_one() { + let mut h = bar(); + alt_key(&mut h, egui::Key::E); + h.run_steps(2); + assert!(is_open(&h.ctx) && h.query_by_label_contains("Undo").is_some(), "Alt+E opens Edit"); + alt_key(&mut h, egui::Key::F); + h.run_steps(2); + assert!(h.query_by_label_contains("Open…").is_some(), "Alt+F switches to File while a menu is open"); + key(&mut h, egui::Key::Escape); + assert!(!is_open(&h.ctx)); + key(&mut h, egui::Key::F10); + key(&mut h, egui::Key::Enter); + h.run_steps(2); + assert!(h.query_by_label_contains("Open…").is_some(), "Enter opens the lit File menu"); + key(&mut h, egui::Key::Escape); + key(&mut h, egui::Key::F10); + key(&mut h, egui::Key::V); + h.run_steps(2); + assert!(is_open(&h.ctx) && h.query_by_label_contains("Zoom In").is_some(), "a letter on the focused bar opens that menu (V = View)"); +} + +#[test] +fn a_letter_in_an_open_menu_runs_the_row_with_that_mnemonic_or_opens_its_submenu() { + let mut h = bar(); + alt_key(&mut h, egui::Key::V); + h.run_steps(2); + assert!(h.query_by_label_contains("Zoom In").is_some(), "View is open"); + let level = rows(&h, 0); + let zoom_in = level.iter().find(|r| r.command.as_deref() == Some("view.zoomIn")).expect("Zoom In row"); + let letter = zoom_in.mnemonic.expect("Zoom In has a mnemonic"); + assert!(level.iter().filter(|r| r.mnemonic == Some(letter)).count() == 1, "mnemonics are unique within a level"); + let zoom = h.state().ui.views[0].zoom; + key(&mut h, egui::Key::from_name(&letter.to_string()).unwrap()); + h.run_steps(3); + assert!(h.state().ui.views[0].zoom > zoom, "the letter ran Zoom In ({letter})"); + assert!(!is_open(&h.ctx), "and closed the menu"); + // A submenu's letter opens it, with its first row lit. + alt_key(&mut h, egui::Key::V); + h.run_steps(2); + let level = rows(&h, 0); + let sub = level.iter().find(|r| r.command.is_none() && r.enabled && r.mnemonic.is_some()).expect("an enabled submenu with a mnemonic"); + let letter = sub.mnemonic.unwrap(); + key(&mut h, egui::Key::from_name(&letter.to_string()).unwrap()); + h.run_steps(3); + assert!(!rows(&h, 1).is_empty(), "the submenu opened ({letter})"); + assert_eq!(Nav::current(&h.ctx).level, 1); +} + +#[test] +fn mnemonics_are_underlined_only_with_alt_or_a_keyboard_opened_menu() { + let mut h = bar(); + h.get_by_label("File").click(); + h.run_steps(4); + assert!(is_open(&h.ctx)); + assert!(!Nav::current(&h.ctx).underline, "a menu opened with the mouse shows no underlines"); + h.key_down(egui::Key::AltLeft); + h.run_steps(3); + assert!(Nav::current(&h.ctx).underline, "while Alt is held they show"); + h.key_up(egui::Key::AltLeft); + h.run_steps(3); + // The tap closed the menu (as on Windows); opening from the keyboard underlines. + assert!(!is_open(&h.ctx), "an Alt tap closes an open menu"); + alt_key(&mut h, egui::Key::F); + h.run_steps(2); + assert!(is_open(&h.ctx) && Nav::current(&h.ctx).underline); + assert!(rows(&h, 0).iter().any(|r| r.mnemonic.is_some()), "rows carry their letters"); +} + +#[test] +fn underlined_text_marks_the_mnemonic_once_and_keeps_the_prefix() { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(Default::default(), |ui| { + let t = crate::menu_keys::mnemonic_text(ui, "✔ ", "Save As…", Some('A'), None); + let egui::WidgetText::LayoutJob(job) = t else { panic!("a mnemonic makes a layout job") }; + assert_eq!(job.text, "✔ Save As…"); + let underlined: Vec<&str> = + job.sections.iter().filter(|s| s.format.underline != egui::Stroke::NONE).map(|s| &job.text[s.byte_range.start.0..s.byte_range.end.0]).collect(); + assert_eq!(underlined, ["a"], "the first occurrence, case-insensitively"); + let t = crate::menu_keys::mnemonic_text(ui, "", "Edit", None, None); + assert!(matches!(t, egui::WidgetText::RichText(_)), "without a mnemonic, plain text"); + let t = crate::menu_keys::mnemonic_text(ui, "", "Édition", Some('D'), None); + let egui::WidgetText::LayoutJob(job) = t else { panic!() }; + assert_eq!(job.sections.len(), 3, "multi-byte text before the letter is kept whole"); + }); + out.textures_delta.clear(); +} + +/// The whole app: with the bar focused a letter opens a menu instead of picking a tool, and the +/// arrows don't nudge the layer. +#[test] +fn the_shortcuts_wait_while_the_keyboard_is_on_the_bar() { + let mut h = Harness::builder().with_size(egui::vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = app(); + app.services.native_menu = None; + app + }); + h.run_steps(4); + let tool = h.state().ui.tool; + key(&mut h, egui::Key::F10); + assert_eq!(Keys::current(&h.ctx).focus, Some(0), "F10 focuses the bar"); + key(&mut h, egui::Key::E); + h.run_steps(2); + assert_eq!(h.state().ui.tool, tool, "E did not pick the Eraser"); + assert!(is_open(&h.ctx) && h.query_by_label_contains("Undo").is_some(), "E opened Edit"); + key(&mut h, egui::Key::Escape); + h.run_steps(2); + assert!(!is_open(&h.ctx)); + h.key_press(egui::Key::E); + h.run_steps(3); + assert_ne!(h.state().ui.tool, tool, "off the bar, E picks the Eraser again"); +} + +/// Checked, unchecked and plain rows (and submenu headers) start their label at the same x, in +/// any script: the checkmark column has a fixed width instead of padding characters (#5705). +#[test] +fn every_menu_row_starts_its_label_after_one_checkmark_column() { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(Default::default(), |ui| { + let start = |checked: bool, label: &str, mnemonic: Option| { + let galley = crate::menu_keys::menu_row_text(ui, checked, label, mnemonic).into_galley( + ui, + Some(egui::TextWrapMode::Extend), + f32::INFINITY, + egui::TextStyle::Button, + ); + let first = label.chars().next().unwrap_or(' '); + let row = galley.rows.first().expect("one row"); + let glyph = row.glyphs.iter().find(|g| g.chr == first).expect("the label's first glyph"); + assert!(galley.text().ends_with(label), "the label is shown whole: {:?}", galley.text()); + glyph.pos.x + }; + let gutter = crate::menu_keys::check_gutter(ui); + assert!(gutter > 0.0, "the column has room for the mark"); + for label in ["Lock Guides", "锁定参考线", "清除参考线"] { + let checked = start(true, label, None); + let unchecked = start(false, label, None); + assert!((checked - unchecked).abs() < 0.01, "{label}: checked {checked} vs unchecked {unchecked}"); + assert!((unchecked - gutter).abs() < 1.0, "{label}: the label starts right after the column ({unchecked} vs {gutter})"); + } + let underlined = start(false, "Clear Guides", Some('C')); + let plain = start(false, "Clear Guides", None); + assert!((underlined - plain).abs() < 0.01, "an underlined mnemonic doesn't move the label"); + let underlined_mid = start(true, "Lock Guides", Some('G')); + assert!((underlined_mid - plain).abs() < 0.01, "nor a mnemonic after the first letter"); + let t = crate::menu_keys::menu_row_text(ui, true, "Rulers", None); + assert_eq!(t.text(), "✔ Rulers", "a checked row keeps its mark (and its accessible label)"); + }); + out.textures_delta.clear(); +} diff --git a/crates/ui-egui/src/menu_nav.rs b/crates/ui-egui/src/menu_nav.rs index 7924fa8deb..c9aedd77d7 100644 --- a/crates/ui-egui/src/menu_nav.rs +++ b/crates/ui-egui/src/menu_nav.rs @@ -50,13 +50,17 @@ pub struct Row { pub command: Option, /// Where it was drawn (scrolled out of view when outside its level's `views` rect). pub rect: Rect, + /// The letter that runs it from the keyboard ([`crate::menu_keys`]), upper case. + pub mnemonic: Option, } /// Keyboard / highlight state of the open menu. #[derive(Clone, Debug, Default)] pub struct Nav { /// The open top-level menu (index into the menu bar). - top: Option, + pub(crate) top: Option, + /// Underline the rows' mnemonics this frame ([`crate::menu_keys`]). + pub underline: bool, /// Popup ids of the top-level menus, from the last frame. tops: Vec, /// The level the arrow keys move in (0 = the top-level menu). @@ -146,7 +150,15 @@ impl Nav { /// Draw a row of `level` with `add`, highlighted like a hovered row when the keyboard (or the /// pointer) is on it, and register it for the keys. `add` returns the row's response. A row /// without a `command` opens a submenu. - pub fn row(&mut self, ui: &mut Ui, level: usize, enabled: bool, command: Option<&str>, add: impl FnOnce(&mut Ui, &mut Nav) -> (egui::Response, R)) -> R { + pub fn row( + &mut self, + ui: &mut Ui, + level: usize, + enabled: bool, + command: Option<&str>, + mnemonic: Option, + add: impl FnOnce(&mut Ui, &mut Nav) -> (egui::Response, R), + ) -> R { let i = self.rows.get(level).map_or(usize::MAX, Vec::len); // While a submenu is kept open for the pointer heading to it, its row stays lit and the // rows crossed on the way don't light up. @@ -166,7 +178,7 @@ impl Nav { self.aim(level, i, root, open, &r); } if let Some(rows) = self.rows.get_mut(level) { - rows.push(Row { widget: r.id, enabled, command: command.map(str::to_string), rect: r.rect }); + rows.push(Row { widget: r.id, enabled, command: command.map(str::to_string), rect: r.rect, mnemonic }); if let Some(index) = held { self.set_hi(level, Some(index)); } else { @@ -278,6 +290,24 @@ impl Nav { } /// Open the highlighted submenu of `level` and move the keyboard into it. + /// Light the first enabled row of `level` once it has been drawn (a menu opened from the + /// keyboard, as on Windows). + pub(crate) fn highlight_first(&mut self, level: usize) { + self.first_at = Some(level); + } + + /// The enabled row of the level the arrows are in whose mnemonic is `c`. + pub(crate) fn row_with_mnemonic(&self, c: char) -> Option { + let level = self.level.min(self.rows.len().saturating_sub(1)); + self.rows.get(level)?.iter().find(|r| r.enabled && r.mnemonic == Some(c)).cloned() + } + + /// Open the submenu of row `widget` of the level the arrows are in. + pub(crate) fn open_sub_row(&mut self, ctx: &egui::Context, widget: Id) { + let level = self.level.min(self.rows.len().saturating_sub(1)); + self.open_sub(ctx, level, widget); + } + fn open_sub(&mut self, ctx: &egui::Context, level: usize, widget: Id) { let Some(&root) = self.roots.get(level) else { return }; let sub = SubMenu::id_from_widget_id(widget); @@ -306,7 +336,10 @@ impl Nav { let Some(top) = self.top.filter(|_| !tops.is_empty()) else { return }; let level = self.level.min(self.rows.len().saturating_sub(1)); self.level = level; - let press = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); + // A text field in the menu (Help › Search) keeps the keys that edit it: ← / → move its + // cursor and Space types a space. + let typing = ctx.text_edit_focused(); + let press = |k: Key| !(typing && matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::Space)) && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); let current = self.highlighted(level).and_then(|i| self.rows.get(level)?.get(i)).cloned(); let switch = |by: usize| { let n = tops.len(); diff --git a/crates/ui-egui/src/menu_nav_tests.rs b/crates/ui-egui/src/menu_nav_tests.rs index 1643e2765b..fcf06ed028 100644 --- a/crates/ui-egui/src/menu_nav_tests.rs +++ b/crates/ui-egui/src/menu_nav_tests.rs @@ -265,6 +265,44 @@ fn left_and_right_switch_menus_and_shortcuts_wait_while_one_is_open() { assert!(!is_open(&h.ctx), "shortcuts work again"); } +/// In Help › Search, ← / → move the field's cursor instead of switching to Window / File, and +/// Space types a space instead of running the lit row. +#[test] +fn help_search_keeps_the_arrows_and_space_that_edit_it() { + let mut h = harness((1280.0, 720.0, 1.0)); + open(&mut h, "Help"); + let field = egui::Id::new("help-menu-search").with("field"); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(field), "the search field has the keyboard"); + let text = |h: &Harness<'static, PhotocraftApp>| h.ctx.data(|d| d.get_temp::(egui::Id::new("help-menu-search"))).unwrap_or_default(); + let typing = |h: &mut Harness<'static, PhotocraftApp>, s: &str| { + h.event(egui::Event::Text(s.to_string())); + h.run_steps(3); + }; + typing(&mut h, "levls"); + key(&mut h, egui::Key::ArrowLeft); + assert!(h.query_by_label_contains("About PhotoCraft").is_some(), "← keeps Help open"); + key(&mut h, egui::Key::ArrowLeft); + typing(&mut h, "e"); + assert_eq!(text(&h), "levels", "← moved the cursor"); + // The cursor sits at `leve|ls`: two → take it to the end. + key(&mut h, egui::Key::ArrowRight); + assert!(h.query_by_label_contains("About PhotoCraft").is_some(), "→ keeps Help open"); + key(&mut h, egui::Key::ArrowRight); + // The pointer resting on a result lights it; a space typed then is text, not a click. + // "levels" finds both Image › Adjustments › Levels… and the Levels adjustment layer. + let levels = h.get_all_by_label_contains("› Levels").next().expect("a Levels result").rect().center(); + h.hover_at(levels); + h.run_steps(3); + assert!(Nav::current(&h.ctx).highlighted(0).is_some(), "a result is lit"); + h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::Text(" ".into())); + h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + assert!(is_open(&h.ctx) && h.query_by_label_contains("About PhotoCraft").is_some(), "Space keeps Help open"); + assert!(h.state().ui.dialogs.is_empty(), "Space ran no command: {:?}", h.state().ui.dialogs); + assert_eq!(text(&h), "levels "); +} + /// A level's visible height (its view) and the full height of its rows: equal means no scrolling. fn fits(h: &Harness<'static, PhotocraftApp>, level: usize) -> (f32, f32) { let nav = Nav::current(&h.ctx); @@ -844,3 +882,62 @@ fn heading_is_the_triangle_to_the_submenus_near_edge() { assert!(!heading(egui::pos2(150.0, 110.0), egui::pos2(170.0, 160.0), left)); assert!(!heading(from, from, egui::Rect::NOTHING)); } + +/// #5896, #6225: a menu scrolled down (to reach Edit › Preferences or Window › Theme) reopens +/// scrolled, with the rows above its view lying over the menu bar. egui opens a submenu when the +/// pointer is over its row even where the row is scrolled out of sight, so a hidden submenu row +/// under the title opened on the press and the click on the title then closed the whole menu. +/// Rows out of view must not open their submenus: the menu stays open. +#[test] +fn a_scrolled_menu_reopens_and_stays_open_with_a_hidden_submenu_row_under_its_title() { + use egui::{Modifiers, PointerButton}; + for top in ["Edit", "Window"] { + let mut h = harness((1280.0, 420.0, 1.0)); + open(&mut h, top); + let title = h.get_by_label(top).rect().center(); + let view = Nav::current(&h.ctx).views[0]; + assert!(Nav::current(&h.ctx).contents[0] > view.height() + 1.0, "{top} must overflow for this test to mean anything"); + // Scroll to the end, then back up until a submenu row is out of view right under the title. + h.hover_at(egui::pos2(view.center().x, view.bottom() - 2.0)); + let wheel = |h: &mut Harness<'static, PhotocraftApp>, dy: f32| { + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, dy), + modifiers: Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + h.run_steps(1); + }; + for _ in 0..60 { + wheel(&mut h, -60.0); + } + let under = |h: &Harness<'static, PhotocraftApp>| { + rows(h, 0).iter().any(|r| r.command.is_none() && r.enabled && r.rect.x_range().contains(title.x) && r.rect.y_range().contains(title.y)) + }; + for _ in 0..400 { + if under(&h) { + break; + } + wheel(&mut h, 8.0); + } + assert!(under(&h), "{top}: a submenu row scrolled under the title"); + key(&mut h, egui::Key::Escape); + assert!(!is_open(&h.ctx), "{top}: Esc closed it"); + // Click the title again: press, release, as a mouse does. + h.hover_at(title); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: title, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: title, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + // Per frame after the click: is the menu open, and how many levels are drawn? + let frames: Vec<(bool, usize)> = (0..6) + .map(|_| { + h.run_steps(1); + let nav = Nav::current(&h.ctx); + (nav.rows.first().is_some_and(|r| !r.is_empty()), nav.rows.len()) + }) + .collect(); + assert!(frames.iter().all(|f| f.0), "{top}: the menu closed again right after the click: {frames:?}"); + assert!(frames.iter().all(|f| f.1 == 1), "{top}: a submenu scrolled out of view opened under the title: {frames:?}"); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 403994ac10..79632791d5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -20,87 +20,9 @@ pub(crate) fn modal_allows(app: &PhotocraftApp, id: &str) -> bool { pub const TOP_MENUS: [&str; 10] = ["File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"]; /// UI-level commands (handled by the shell rather than the engine): id, label, menu, shortcut. -pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ - ("ui.symmetryTransform", "Transform Symmetry", &[], None), - // Right-click the options bar's tool icon (#2825); `tool.resetAll` asks first. - ("tool.reset", "Reset Tool", &[], None), - ("tool.resetAll", "Reset All Tools", &[], None), - ("window.panel.group", "Change Panel Group", &[], None), - ("window.panel.layout", "Change Panel Layout", &[], None), - ("window.panel.move", "Move Panel", &[], None), - ("window.panel.float", "Float Panel", &[], None), - ("window.panel.dock", "Dock Panel", &[], None), - ("window.panel.activate", "Activate Panel", &[], None), - ("window.panel.close", "Close Panel", &[], None), - ("file.open", "Open…", &["File"], Some("Cmd+O")), - ("file.save", "Save", &["File"], Some("Cmd+S")), - ("file.saveAs", "Save As…", &["File"], Some("Cmd+Shift+S")), - ("file.exit", "Exit", &["File"], Some("Cmd+Q")), - ("edit.freeTransform", "Free Transform", &["Edit"], Some("Cmd+T")), - ("file.export.exportAs", "Export As…", &["File", "Export"], Some("Cmd+Alt+Shift+W")), - ("file.export.quickExportAsPng", "Quick Export as PNG", &["File", "Export"], None), - ("edit.transform.scale", "Scale", &["Edit", "Transform"], None), - ("edit.transform.rotate", "Rotate", &["Edit", "Transform"], None), - ("edit.transform.skew", "Skew", &["Edit", "Transform"], None), - ("edit.transform.distort", "Distort", &["Edit", "Transform"], None), - ("edit.transform.perspective", "Perspective", &["Edit", "Transform"], None), - ("select.selectAndMask", "Select and Mask…", &["Select"], Some("Cmd+Alt+R")), - ("view.rulers", "Rulers", &["View"], Some("Cmd+R")), - ("view.show.grid", "Grid", &["View", "Show"], Some("Cmd+'")), - ("view.show.guides", "Guides", &["View", "Show"], Some("Cmd+;")), - ("view.snap", "Snap", &["View"], Some("Cmd+Shift+;")), - ("view.lockGuides", "Lock Guides", &["View"], Some("Cmd+Alt+;")), - ("view.extras", "Extras", &["View"], Some("Cmd+H")), - ("view.show.targetPath", "Target Path", &["View", "Show"], Some("Cmd+Shift+H")), - ("view.screenMode.cycle", "Cycle Screen Mode", &[], Some("F")), - ("edit.freeTransformCopy", "Free Transform a Copy", &[], Some("Cmd+Alt+T")), - ("type.editText", "Edit Type", &[], None), - ("view.zoomIn", "Zoom In", &["View"], Some("Cmd+=")), - ("view.zoomOut", "Zoom Out", &["View"], Some("Cmd+-")), - ("view.fitOnScreen", "Fit on Screen", &["View"], Some("Cmd+0")), - ("view.actualPixels", "100%", &["View"], Some("Cmd+1")), - ("view.rotateView", "Rotate View", &[], None), - ("view.resetView", "Reset View", &["View"], None), - ("window.newWindowForDocument", "New Window for Document", &["Window", "Arrange"], None), - ("window.toggle.layers", "Layers", &["Window"], Some("F7")), - ("window.toggle.history", "History", &["Window"], None), - ("window.toggle.properties", "Properties", &["Window"], None), - ("window.toggle.color", "Color", &["Window"], Some("F6")), - ("window.toggle.brushSettings", "Brush Settings", &["Window"], Some("F5")), - ("window.toggle.navigator", "Navigator", &["Window"], None), - ("window.toggle.toolbar", "Tools", &["Window"], None), - // Photoshop's Tab and ⇧Tab: all panels (Tools, options bar and the dock), or only the dock. - ("window.togglePanels", "Show/Hide All Panels", &[], Some("Tab")), - ("window.toggle.dock", "Show/Hide Panels", &[], Some("Shift+Tab")), - ("window.toggle.options", "Options", &["Window"], None), - // Photoshop's Ctrl+Tab and Ctrl+Shift+Tab, on macOS too (⌘Tab belongs to the system, #2340). - ("window.nextDocument", "Next Document", &[], Some("Ctrl+Tab")), - ("window.previousDocument", "Previous Document", &[], Some("Ctrl+Shift+Tab")), - ("window.theme.toggle", "Next Appearance Mode", &["Window"], None), - ("window.theme.pro", "Pro Theme", &["Window", "Theme"], None), - ("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None), - ("window.theme.proLightGray", "Pro Light Gray Theme", &["Window", "Theme"], None), - ("window.theme.proLight", "Pro Light Theme", &["Window", "Theme"], None), - ("window.theme.studio", "Studio Theme", &["Window", "Theme"], None), - ("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None), - ("window.theme.classic", "Classic Theme", &["Window", "Theme"], None), - ("window.theme.solarizedDark", "Solarized Dark Theme", &["Window", "Theme"], None), - ("window.theme.adwaita", "Adwaita Light Theme", &["Window", "Theme"], None), - ("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None), - ("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None), - ("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None), - ("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None), - ("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None), - ("window.theme.system", "Sync with system", &["Window", "Theme"], None), - ("edit.search", "Search…", &["Edit"], Some("Cmd+F")), - ("help.discord", "Join the ArtCraft Discord…", &["Help"], None), - ("help.website", "PhotoCraft Website", &["Help"], None), - ("help.artcraftWebsite", "ArtCraft Website", &["Help"], None), - ("help.github", "PhotoCraft on GitHub", &["Help"], None), - ("help.reportIssue", "Report an Issue…", &["Help"], None), - ("help.systemInfo", "System Info…", &["Help"], None), - ("help.about", "About PhotoCraft", &["Help"], None), -]; +/// The table is the engine's ([`photocraft_engine::shell_commands::SHELL_COMMANDS`]), so the +/// commands and their keys exist without this UI (headless shortcut sets, lists, conflicts). +pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = photocraft_engine::shell_commands::SHELL_COMMANDS; /// Window › : the open documents, listed at the end of the Window menu as in Photoshop. /// The id ends in the document's index, which is its tab's position. @@ -116,7 +38,8 @@ pub(crate) fn panel_alias(id: &str) -> Option<&'static str> { "navigator" | "info" | "histogram" => "window.toggle.navigator", "tools" => "window.toggle.toolbar", "options" => "window.toggle.options", - "brushSettings" | "brushes" => "window.toggle.brushSettings", + "brushSettings" => "window.toggle.brushSettings", + "brushes" => "window.toggle.brushes", _ => return None, }) } @@ -164,7 +87,7 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Va /// [`invoke`] without the unsaved-changes prompt, for once the user has already answered it. pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { if let Some(tool) = crate::tool_shortcuts::tool(id) { - app.ui.tool = tool; + crate::tool_memory::select(app, tool); return Ok(json!({"tool": tool})); } if id == "ui.symmetryTransform" { @@ -224,12 +147,6 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: if let Some(r) = crate::plugin_ui::menu(app, id, ¶ms) { return r; } - if id == "window.panel.brushes" { - // Window › Brushes opens the Brush Settings window on its presets tab. - app.ui.panels.brush_settings = true; - app.ui.brush_tab = 1; - return Ok(Value::Null); - } if id == "window.panel.brushSettings" { app.ui.brush_tab = 0; } @@ -238,6 +155,12 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: if let Some(r) = crate::prefs_ui::invoke(app, ctx, id, ¶ms) { return r; } + // Save for Web's dialog would export the committed document, not the open Free Transform (#6737). + if id == crate::transform_tool::SAVE_FOR_WEB + && let Some(r) = crate::transform_tool::intercept(app, id) + { + return r; + } // Save for Web, Print and the other File-menu dialogs added with slices. if let Some(r) = crate::file_ui::invoke(app, ctx, id, ¶ms) { return r; @@ -308,13 +231,18 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: "file.save" => { // Writes back only to a layered file that holds the whole document; anything else // goes through Save As, so the original is never overwritten with less (#3806). - let tga_bits = crate::targa_options_ui::bits_param(¶ms, id)?; + let choices = crate::FormatChoices::from_params(¶ms, id)?; match params.get("path").and_then(Value::as_str) { - Some(path) => app.save_as_with(Some(path.to_string()), tga_bits), + Some(path) => app.save_as_with(Some(path.to_string()), choices), 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, crate::FormatChoices::default())) + } 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); @@ -323,9 +251,11 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: let mut lines = losses; lines.push(tl!("Choose a format that keeps it, such as Photoshop (PSD), or save a copy.").to_string()); crate::notices::post(app, title, lines, false, None); - app.save_as_with(None, tga_bits) + app.save_as_with(None, choices) } - _ => app.save_as_with(None, tga_bits), + // A flat file asks for its name; confirming its own file records a + // plain Save that writes the file an action is played on (#3531). + _ => app.save_dialog_flow(None, choices, true), } } } @@ -359,8 +289,8 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } } "file.saveAs" => { - let tga_bits = crate::targa_options_ui::bits_param(¶ms, id)?; - app.save_as_with(params.get("path").and_then(Value::as_str).map(str::to_string), tga_bits) + let choices = crate::FormatChoices::from_params(¶ms, id)?; + app.save_as_with(params.get("path").and_then(Value::as_str).map(str::to_string), choices) } "view.zoomIn" | "view.zoomOut" | "view.fitOnScreen" | "view.actualPixels" => app.run(id, params), "window.newWindowForDocument" => { @@ -374,6 +304,10 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: crate::prefs_ui::cycle_appearance(app, ctx); Ok(Value::Null) } + "view.darkenInterface" | "view.lightenInterface" => { + crate::prefs_ui::step_brightness(app, ctx, id == "view.lightenInterface")?; + Ok(json!({"theme": app.ui.theme.id()})) + } "window.theme.system" => { app.run("prefs.set", json!({"path": "interface.appearanceMode", "value": "auto"}))?; crate::prefs_ui::sync_appearance(app, ctx); @@ -418,6 +352,23 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } "file.export.exportAs" => Ok(json!({"dialog": crate::export_dialog::open(app)?})), "file.export.quickExportAsPng" => crate::export_dialog::quick_export_png(app), + // Render Video exports a sequence of frames into a folder, not a named file. + // Keep explicit directory arguments from automation and the control channel unchanged. + "file.export.renderVideo" if params.get("dir").is_none() => { + #[cfg(target_arch = "wasm32")] + { + Err("Render Video to a local directory is unavailable on the web".into()) + } + #[cfg(not(target_arch = "wasm32"))] + { + let doc = app.session.active().ok_or("no document")?.doc.id; + let mut render_params = params.as_object().cloned().unwrap_or_default(); + app.ask_folder(move |app, dir| { + render_params.insert("dir".into(), json!(dir)); + app.with_document(doc, |app| app.run("file.export.renderVideo", Value::Object(render_params))) + }) + } + } // Layer › Export As… / Quick Export as PNG: the export pipeline on just the active layer. l if matches!(l, "layer.exportAs" | "layer.quickExportAsPng") && params.as_object().is_none_or(|o| o.is_empty()) => { let layer = app.session.active().and_then(|d| d.active_layer).ok_or("no active layer")?; @@ -465,6 +416,13 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } Ok(Value::Null) } + // Only interactive creation opens the Color Picker; it creates the layer on OK, + // so Cancel leaves neither an orphan layer nor an undo/redo history entry. + // Explicit parameters continue straight to the engine. + "layer.newFillLayer.solidColor" if params.as_object().is_none_or(|o| o.is_empty()) => { + 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" => { @@ -674,6 +632,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: "toolbar" => &mut p.toolbar, "options" => &mut p.options_bar, "brushSettings" => &mut p.brush_settings, + "brushes" => &mut p.brushes, "dock" => &mut p.dock, _ => return Err(format!("unknown panel in {t}")), }; @@ -750,6 +709,8 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { i if i.starts_with(DOCUMENT_ITEM) => true, i if crate::links::url_for(i).is_some() => true, i if i.starts_with("window.theme.") => true, + // Interface brightness works with or without a document, as in Photoshop. + "view.darkenInterface" | "view.lightenInterface" => true, "file.save" | "file.saveAs" | "file.export.exportAs" | "file.export.quickExportAsPng" => { app.session.active().is_some() && app.services.export.is_some() } @@ -881,6 +842,7 @@ fn checked(app: &PhotocraftApp, id: &str) -> Option { "window.togglePanels" => p.toolbar || p.options_bar || p.dock, "window.toggle.options" => p.options_bar, "window.toggle.brushSettings" => p.brush_settings, + "window.toggle.brushes" => p.brushes, _ => return None, }) } @@ -1111,7 +1073,7 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec { } } if !prefs.menus.hidden.is_empty() { - items.retain(|i| !prefs.menus.hidden.contains(&i.id)); + items.retain(|i| !prefs.menus.is_hidden(&i.id)); } if prefs.interface.show_menu_colors { for it in &mut items { @@ -1178,6 +1140,13 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { let mut clicked: Option = None; let t = crate::theme::Tokens::get(ui.ctx()); let mut nav = crate::menu_nav::Nav::load(ui.ctx()); + // The keyboard on the bar (#4988): Alt / F10 focus, Alt+letter, mnemonics. + let mut keys = crate::menu_keys::Keys::load(ui.ctx()); + keys.take_first_row(&mut nav); + nav.underline = keys.show_underlines(ui.ctx()); + let top_labels: Vec = TOP_MENUS.iter().map(|top| crate::i18n::tr(lang, top).to_string()).collect(); + let preferred: Vec> = TOP_MENUS.iter().map(|top| crate::menu_keys::TOP_PREFERRED.iter().find(|(t, _)| t == top).map(|(_, c)| *c)).collect(); + let top_mnemonics = crate::menu_keys::assign(&top_labels.iter().map(String::as_str).collect::>(), &preferred); ui.scope(|ui| { ui.spacing_mut().item_spacing.x = 0.0; let v = &mut ui.style_mut().visuals; @@ -1192,10 +1161,21 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { ui.spacing_mut().button_padding = egui::vec2(6.0, 3.0); nav.bar_bottom = Some(ui.max_rect().bottom()); let mut buttons = Vec::with_capacity(TOP_MENUS.len()); - for top in TOP_MENUS { + for (k, top) in TOP_MENUS.iter().enumerate() { // egui's menu_button toggles on release; these titles open on the press (one // gesture can press, drag to an item and release), so each drives its popup. - let title = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr(lang, top)).color(t.text_dim))); + // The title the keyboard is on (Alt / F10) is lit like a hovered one. + let focused = keys.focus == Some(if rtl { TOP_MENUS.len() - 1 - k } else { k }); + let label = if nav.underline { + crate::menu_keys::mnemonic_text(ui, "", &top_labels[k], top_mnemonics[k], Some(t.text_dim)) + } else { + egui::RichText::new(top_labels[k].as_str()).color(t.text_dim).into() + }; + let mut button = egui::Button::new(label); + if focused { + button = button.fill(ui.visuals().widgets.hovered.weak_bg_fill); + } + let title = ui.add(button); // The bar's close behaviour and style, as a menu (not bar) config so submenus inside // render as submenus. let bar = egui::containers::menu::MenuConfig::find(ui); @@ -1206,7 +1186,7 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { let opening = title.clicked() && ui.ctx().data(|d| d.get_temp::(press_gesture_id())).unwrap_or(false); let close = if opening { egui::PopupCloseBehavior::IgnoreClicks - } else if top == "Help" { + } else if *top == "Help" { // Menus close on any click, inside them too; a click in Help's search field // must keep it open. Its items close it themselves when chosen. egui::PopupCloseBehavior::CloseOnClickOutside @@ -1228,12 +1208,12 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { ItemCache { state, items } }) .items; - let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect(); + let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(*top)).collect(); ui.set_min_width(220.0); if mine.is_empty() { ui.weak(crate::i18n::tr(lang, "(coming soon)")); } - if top == "Help" { + if *top == "Help" { // The search field scrolls with the rows, as part of the menu's content. crate::menu_nav::level(ui, 1, &mut nav, |ui, nav| { help_search(ui, items, opening, &mut clicked, nav); @@ -1253,8 +1233,14 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { tops.reverse(); } nav.keys(ui.ctx(), &tops, &mut clicked); + let mut mnemonics = top_mnemonics.clone(); + if rtl { + mnemonics.reverse(); + } + keys.keys(ui.ctx(), &tops, &mnemonics, &mut nav, &mut clicked); }); }); + keys.store(ui.ctx()); nav.store(ui.ctx()); // No menu open this frame: nothing is kept. app.menu_cache = built.into_inner(); @@ -1409,7 +1395,7 @@ fn help_search(ui: &mut egui::Ui, items: &[MenuItem], opening: bool, clicked: &m if let Some(sc) = &it.shortcut { b = b.shortcut_text(crate::shortcuts::pretty(sc)); } - let hit = nav.row(ui, 0, it.enabled, Some(&it.id), |ui, _| { + let hit = nav.row(ui, 0, it.enabled, Some(&it.id), None, |ui, _| { let r = ui.add_enabled(it.enabled, b); let hit = r.clicked() || released_on(ui, &r); (r, hit) @@ -1444,6 +1430,27 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click crate::widgets::style_spectrum_popup_menu(ui); // Walk in Photoshop order: leaves and separators at this depth; a submenu appears at the position // of its first child. + // Each row's mnemonic (#4988), unique within this level, in display order: the leaves' labels + // and the submenus' names as shown. + let shown_labels: Vec = { + let mut subs: Vec<&str> = Vec::new(); + items + .iter() + .filter_map(|it| { + if it.path.len() == depth { + (it.label != "---").then(|| translated_menu_label(lang, it)) + } else { + let name = it.path.get(depth).map(String::as_str)?; + (!subs.contains(&name)).then(|| { + subs.push(name); + crate::i18n::tr(lang, name).to_string() + }) + } + }) + .collect() + }; + let mnemonics = crate::menu_keys::assign(&shown_labels.iter().map(String::as_str).collect::>(), &[]); + let mut next_row = 0usize; let mut shown_subs: Vec<&str> = Vec::new(); let mut last_was_sep = true; for (i, it) in items.iter().enumerate() { @@ -1455,10 +1462,11 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click } continue; } - let mut text = translated_menu_label(lang, it); - if let Some(c) = it.checked { - text = format!("{} {}", if c { "✔" } else { " " }, text); - } + let label = translated_menu_label(lang, it); + let mnemonic = mnemonics.get(next_row).copied().flatten(); + next_row += 1; + // Checked, unchecked and plain rows share one checkmark column (#5705). + let text = crate::menu_keys::menu_row_text(ui, it.checked == Some(true), &label, mnemonic.filter(|_| nav.underline)); let mut b = egui::Button::new(text); if let Some(tint) = it.color.as_deref().and_then(menu_tint) { b = b.fill(tint.gamma_multiply(0.55)); @@ -1466,7 +1474,7 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click if let Some(sc) = &it.shortcut { b = b.shortcut_text(crate::shortcuts::pretty(sc)); } - let hit = nav.row(ui, depth - 1, it.enabled, Some(&it.id), |ui, _| { + let hit = nav.row(ui, depth - 1, it.enabled, Some(&it.id), mnemonic, |ui, _| { let r = ui.add_enabled(it.enabled, b); let r = match it.id.as_str() { "image.mode.bits8" | "image.mode.bits16" => r.on_hover_text(crate::i18n::tr(lang, "Integer")), @@ -1489,14 +1497,17 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click shown_subs.push(name); let child: Vec<&MenuItem> = items.iter().copied().filter(|c| c.path.len() > depth && c.path[depth] == name).collect(); let enabled = submenu_enabled(&child); + let mnemonic = mnemonics.get(next_row).copied().flatten(); + next_row += 1; + let title = crate::menu_keys::menu_row_text(ui, false, crate::i18n::tr(lang, name), mnemonic.filter(|_| nav.underline)); ui.add_enabled_ui(enabled, |ui| { - nav.row(ui, depth - 1, enabled, None, |ui, nav| { - let r = ui.menu_button(crate::i18n::tr(lang, name), |ui| render_level(ui, &child, depth + 1, clicked, nav)); - if r.inner.is_some() { + nav.row(ui, depth - 1, enabled, None, mnemonic, |ui, nav| { + let (response, inner) = submenu_row(ui, title, |ui| render_level(ui, &child, depth + 1, clicked, nav)); + if inner.is_some() { // Where the submenu hangs from, to keep it below the menu bar (#319). - nav.set_anchor(depth, r.response.rect); + nav.set_anchor(depth, response.rect); } - (r.response, ()) + (response, ()) }); }); last_was_sep = false; @@ -1504,6 +1515,32 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click } } +/// A submenu row, drawn as `ui.menu_button` draws it, except that a row scrolled out of view +/// doesn't open its submenu. egui opens a submenu whenever the pointer is over its row, clipped +/// or not: a menu reopened scrolled down (after Edit › Preferences or Window › Theme) has rows +/// hidden above its view, over the menu bar, and the hidden one under the title opened on the +/// press; the release on the title then counted as a click outside that submenu and closed the +/// whole menu (#5896, #6225). An open submenu (e.g. opened from the keyboard) is drawn as usual. +fn submenu_row(ui: &mut egui::Ui, title: egui::WidgetText, content: impl FnOnce(&mut egui::Ui) -> R) -> (egui::Response, Option) { + use egui::containers::menu::{MenuState, SubMenu, SubMenuButton}; + let sub = SubMenu::id_from_widget_id(ui.next_auto_id()); + let open = MenuState::from_ui(ui, |state, _| state.open_item == Some(sub)); + let inactive = ui.style().visuals.widgets.inactive; + if open { + ui.style_mut().visuals.widgets.inactive = ui.style().visuals.widgets.open; + } + let response = ui.add(egui::Button::new(title).right_text(SubMenuButton::RIGHT_ARROW)); + ui.style_mut().visuals.widgets.inactive = inactive; + // The rect egui tests the pointer against (`SubMenu::show`), against the part that can be seen. + let reach = response.rect.expand2(ui.spacing().item_spacing / 2.0); + let hidden = ui.ctx().pointer_hover_pos().is_some_and(|p| reach.contains(p) && !ui.clip_rect().contains(p)); + if hidden && !open { + return (response, None); + } + let inner = SubMenu::new().show(ui, &response, content); + (response, inner.map(|r| r.inner)) +} + /// A submenu header is enabled only while some item under it (at any depth) can run, so a /// submenu of greyed items is greyed too instead of opening onto them (#1976). fn submenu_enabled(children: &[&MenuItem]) -> bool { @@ -1872,6 +1909,44 @@ mod tests { } } + /// #5996: a real key press comes as a key and its text. A letter typed into Help › Search is + /// text for the field, not a mnemonic: "S" ran System Info and "A" the ArtCraft website. + #[test] + fn help_search_letters_are_not_mnemonics() { + use egui::{Event, Key, Modifiers, PointerButton}; + use egui_kittest::{Harness, kittest::Queryable}; + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::ALL[0]); + PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()) + }); + h.run_steps(4); + let help = h.get_by_label("Help").rect().center(); + h.event(Event::PointerMoved(help)); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: help, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(3); + assert!(h.query_by_label_contains("About PhotoCraft").is_some(), "Help opened"); + for c in "DeSelect all".chars() { + let key = Key::from_name(&c.to_ascii_uppercase().to_string()); + if let Some(key) = key { + h.event(Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + } + h.event(Event::Text(c.to_string())); + h.run_steps(1); + if let Some(key) = key { + h.event(Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers: Modifiers::NONE }); + } + } + h.run_steps(3); + assert!(h.query_by_label_contains("About PhotoCraft").is_some(), "typing keeps Help open"); + let query: String = h.ctx.data(|d| d.get_temp(egui::Id::new("help-menu-search"))).unwrap_or_default(); + assert_eq!(query, "DeSelect all", "every letter reached the field"); + assert!(h.state().ui.dialogs.is_empty(), "no menu command ran: {:?}", h.state().ui.dialogs); + } + #[test] fn dropdown_items_have_room() { use egui_kittest::{Harness, kittest::Queryable}; @@ -2077,9 +2152,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); } } diff --git a/crates/ui-egui/src/monitor_status.rs b/crates/ui-egui/src/monitor_status.rs index 8e29b8c2f2..2350e6dc30 100644 --- a/crates/ui-egui/src/monitor_status.rs +++ b/crates/ui-egui/src/monitor_status.rs @@ -218,19 +218,24 @@ pub fn apply(app: &mut PhotocraftApp, r: Detection) { }) .collect() }; - for (id, name, reason) in fallbacks { - if app.monitors.reported.iter().any(|(i, r)| *i == id && *r == reason) { - continue; - } - app.monitors.reported.push((id, reason.clone())); - let template = if name.is_empty() { - "The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings." - } else { - "The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings." - }; - let lines = vec![reason, crate::i18n::fmt(tl!(template), &[("display", &name)])]; - crate::notices::post(app, FALLBACK_TITLE, lines, true, None); + let fresh: Vec<_> = fallbacks.into_iter().filter(|(id, _, reason)| !app.monitors.reported.iter().any(|(i, r)| i == id && r == reason)).collect(); + if fresh.is_empty() { + return; } + app.monitors.reported.extend(fresh.iter().map(|(id, _, reason)| (*id, reason.clone()))); + // One notice per reading, not one per display: two profile-less monitors (common on Linux + // desks) gave two cards saying the same thing at every launch. Each display keeps its own + // reason line; the names share the existing template, so no catalog change is needed. + let names: Vec<&str> = fresh.iter().map(|(_, name, _)| name.as_str()).filter(|n| !n.is_empty()).collect(); + let template = if names.is_empty() { + "The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings." + } else { + "The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings." + }; + let advice = crate::i18n::fmt(tl!(template), &[("display", &names.join(", "))]); + let mut lines: Vec = fresh.into_iter().map(|(_, _, reason)| reason).collect(); + lines.push(advice); + crate::notices::post(app, FALLBACK_TITLE, lines, true, None); } /// The frame of the window `ctx` draws, in OS points (as `Display::frame`). egui reports it in @@ -427,6 +432,29 @@ mod tests { assert!(lines[0].starts_with("Monitor profile on Built-in Retina Display: sRGB"), "{lines:?}"); } + #[test] + fn displays_without_profiles_share_one_notice() { + // Two profile-less monitors (a Linux desk: DP-4 and DP-5) gave two identical cards. + let mut app = app(); + let desk = |id, name: &str, x| Display { id, name: name.into(), frame: [x, 0.0, 1920.0, 1080.0], profile_name: None, icc: None }; + apply(&mut app, Ok(vec![desk(1, "DP-4", 0.0), desk(2, "DP-5", 1920.0)])); + assert_eq!(app.ui.notices.len(), 1); + let lines = &app.ui.notices[0].lines; + assert_eq!(lines[0], "DP-4 has no ICC profile"); + assert_eq!(lines[1], "DP-5 has no ICC profile"); + assert!(lines[2].starts_with("The canvas on DP-4, DP-5 is shown as sRGB."), "{lines:?}"); + // The same reading again: nothing new to say. + apply(&mut app, Ok(vec![desk(1, "DP-4", 0.0), desk(2, "DP-5", 1920.0)])); + assert_eq!(app.ui.notices.len(), 1); + // A third display plugged in later: a card for it alone. + apply(&mut app, Ok(vec![desk(1, "DP-4", 0.0), desk(2, "DP-5", 1920.0), desk(3, "HDMI-A-1", 3840.0)])); + assert_eq!(app.ui.notices.len(), 2); + let lines = &app.ui.notices[1].lines; + assert_eq!(lines.len(), 2); + assert_eq!(lines[0], "HDMI-A-1 has no ICC profile"); + assert!(lines[1].starts_with("The canvas on HDMI-A-1 is shown"), "{lines:?}"); + } + #[test] fn displays_are_read_again_when_they_may_have_changed() { let mut app = app(); diff --git a/crates/ui-egui/src/move_auto_select_tests.rs b/crates/ui-egui/src/move_auto_select_tests.rs index 0f01222a57..403ab28cd1 100644 --- a/crates/ui-egui/src/move_auto_select_tests.rs +++ b/crates/ui-egui/src/move_auto_select_tests.rs @@ -152,6 +152,32 @@ fn every_theme_shows_the_auto_select_toggle() { } } +#[test] +fn every_theme_shows_the_transform_controls_toggle() { + // #5740: outside the Pro themes the Move tool's options bar had no Show Transform Controls, so + // a click on a layer never showed its box and handles there. + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + for theme in crate::theme::ThemeKind::ALL { + let (app, _, _) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(4); + let before = h.state().ui.tool_options.move_show_transform; + h.get_by_label("Show Transform Controls").click(); + h.run_steps(2); + assert_ne!(h.state().ui.tool_options.move_show_transform, before, "{theme:?}: the toggle flips Show Transform Controls"); + } +} + #[test] fn every_theme_shows_the_type_options() { // #1387: outside the Pro themes the Type tool's options bar was only the hint "Click to add @@ -210,3 +236,24 @@ fn every_theme_shows_the_gradient_options() { assert!(combos.iter().any(|c| c.contains("Normal")), "{theme:?}: the blend mode picker shows; combo boxes: {combos:?}"); } } + +#[test] +fn shift_click_adds_shape_layers_one_after_another() { + // Shapes, as in Photoshop: a click selects one, each ⇧-click adds the one under it. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + for rect in [[10, 10, 40, 40], [80, 10, 40, 40], [10, 100, 40, 40]] { + app.session.execute("shape.create", json!({"kind": "rect", "rect": rect, "fill": "#ff0000"})).unwrap(); + } + app.sync_views(); + app.ui.tool = Tool::Move; + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + let ids: Vec = app.session.active().unwrap().doc.layers.iter().skip(1).map(|l| l.id).collect(); + drag(&mut app, [30.0, 30.0], [0.0, 0.0], egui::Modifiers::NONE); + drag(&mut app, [100.0, 30.0], [0.0, 0.0], egui::Modifiers::SHIFT); + drag(&mut app, [30.0, 120.0], [0.0, 0.0], egui::Modifiers::SHIFT); + let mut selected = app.session.active().unwrap().selected_layers(); + selected.sort_by_key(|l| l.0); + assert_eq!(selected, ids); +} diff --git a/crates/ui-egui/src/move_hover.rs b/crates/ui-egui/src/move_hover.rs index 0db1a5c80d..c3d07f313d 100644 --- a/crates/ui-egui/src/move_hover.rs +++ b/crates/ui-egui/src/move_hover.rs @@ -95,6 +95,11 @@ pub(crate) fn active_outline(app: &mut PhotocraftApp, at: Option<[f64; 2]>, comm app.canvas_hover_layer = None; return None; } + // A live rectangle's corner widget under the pointer (or being dragged) isn't a layer to pick. + if at.is_some_and(|p| crate::corner_radius::hovers(app, p)) { + app.canvas_hover_layer = None; + return None; + } // A layer hovered in the Layers panel takes precedence over canvas rollover. if let Some(id) = app.hover_layer && app.drag.is_none() @@ -180,7 +185,7 @@ mod tests { assert_eq!(active_outline(&mut app, None, false, false), Some(a)); // When a drag or transform is in progress, no outline: - app.guide_drag = Some(crate::rulers::GuideDrag { vertical: false, index: None, pos: 10.0 }); + app.guide_drag = Some(crate::rulers::GuideDrag { vertical: false, index: None, pos: 10.0, press: 10.0 }); assert_eq!(active_outline(&mut app, None, false, false), None); app.guide_drag = None; diff --git a/crates/ui-egui/src/move_mods.rs b/crates/ui-egui/src/move_mods.rs index a9e5eeb782..64b3a65603 100644 --- a/crates/ui-egui/src/move_mods.rs +++ b/crates/ui-egui/src/move_mods.rs @@ -8,7 +8,8 @@ //! pointer actually moves, and the drag then moves the copies. Duplicate and move land as one //! history step ("Duplicate + Move"). ⇧⌥ combines both. //! - Arrow keys nudge the selected layers 1 px (⇧: 10 px); ⌥ duplicates first. While Free -//! Transform is active they nudge the box instead. +//! Transform is active they nudge the box instead. A run of nudges is one History state +//! (`nudge_bundle`, #2259). //! - With a selection the drag and the arrow keys move the selected pixels (`move_ui`): ⇧ locks //! the drag to multiples of 45° and ⌥ copies the pixels instead of duplicating the layer. //! @@ -185,6 +186,7 @@ pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { for mods in [Modifiers::SHIFT | Modifiers::ALT, Modifiers::SHIFT, Modifiers::ALT, Modifiers::NONE] { if ctx.input_mut(|i| i.consume_key(base | mods, key)) { let k = if mods.shift { 10.0 } else { 1.0 }; + app.nudge_bundle.now = ctx.input(|i| i.time); nudge(app, ux * k, uy * k, mods.alt); return true; } @@ -218,13 +220,12 @@ fn brush_arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } /// Moves the selected layers (or the Free Transform box, or the selected pixels) by `(dx, dy)` -/// pixels, as the Move tool (also when ⌘ makes it the temporary Move tool). +/// pixels, as the Move tool (also when ⌘ makes it the temporary Move tool). Nudges that follow +/// each other are one History state, or one Undo step of the box (`nudge_bundle`); the selected +/// pixels float until dropped, which is one state already. pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) { - if let Some(t) = app.ui.transform.as_mut() { - if t.warp.is_none() { - t.quad = t.quad.map(|q| [q[0] + dx, q[1] + dy]); - t.pivot = [t.pivot[0] + dx, t.pivot[1] + dy]; - } + if app.ui.transform.is_some() { + crate::transform_tool::nudge(app, dx, dy); return; } if app.drag.is_some() { @@ -235,7 +236,14 @@ pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) { return; } let from = if duplicate_first { duplicate(app) } else { None }; - if let Err(e) = app.run("layer.translate", json!({"dx": dx, "dy": dy})) { + let moved = if duplicate_first { + // A copy is a state of its own, folded with its move below. + app.nudge_bundle.reset(); + app.run("layer.translate", json!({"dx": dx, "dy": dy})) + } else { + crate::nudge_bundle::run(app, "layers", "layer.translate", json!({"dx": dx, "dy": dy})) + }; + if let Err(e) = moved { app.ui.status = e; } if let Some(from) = from { @@ -244,9 +252,10 @@ pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) { } /// Arrow keys with a selection tool (#1428), as in Photoshop: nudge the selection outline 1 px -/// (⇧ 10 px) through `select.transformSelection`, one history step per press; the pixels stay -/// put. A floating piece (⌘-dragged pixels) moves instead, like a plain drag on it. Not while a -/// drag, a polygon or a Magnetic Lasso border is in progress. Returns true when a key was used. +/// (⇧ 10 px) through `select.transformSelection`, a run of presses being one history step +/// (`nudge_bundle`); the pixels stay put. A floating piece (⌘-dragged pixels) moves instead, like +/// a plain drag on it. Not while a drag, a polygon or a Magnetic Lasso border is in progress. +/// Returns true when a key was used. fn selection_arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if !crate::tool_feedback::is_selection_tool(app.ui.tool) || app.drag.is_some() || !app.ui.polygon.is_empty() || app.ui.magnetic.active() { return false; @@ -263,8 +272,14 @@ fn selection_arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if ctx.input_mut(|i| i.consume_key(mods, key)) { let k = if mods.shift { 10.0 } else { 1.0 }; let (dx, dy) = (ux * k, uy * k); - let id = if floating { "select.float" } else { "select.transformSelection" }; - if let Err(e) = app.run(id, json!({"dx": dx, "dy": dy})) { + app.nudge_bundle.now = ctx.input(|i| i.time); + // A floating piece is no state until dropped; the outline's nudges bundle. + let result = if floating { + app.run("select.float", json!({"dx": dx, "dy": dy})) + } else { + crate::nudge_bundle::run(app, "selection", "select.transformSelection", json!({"dx": dx, "dy": dy})) + }; + if let Err(e) = result { app.ui.status = e; app.ui.status_error = true; } @@ -420,8 +435,14 @@ mod tests { /// One key press through `arrow_keys`, as egui delivers it. fn press(app: &mut PhotocraftApp, key: egui::Key, mods: egui::Modifiers) -> bool { + press_at(app, key, mods, 0.0) + } + + /// [`press`] at egui time `time` (seconds). + fn press_at(app: &mut PhotocraftApp, key: egui::Key, mods: egui::Modifiers, time: f64) -> bool { let ctx = egui::Context::default(); ctx.input_mut(|i| { + i.time = time; i.modifiers = mods; i.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: mods }); }); @@ -540,11 +561,13 @@ mod tests { assert!(!press(&mut app, egui::Key::ArrowRight, CTRL)); } - /// #1428: with a selection tool the arrows nudge the selection outline (⇧ 10 px), one step - /// each; the layer stays put. Without a selection they are left alone. + /// #1428: with a selection tool the arrows nudge the selection outline (⇧ 10 px); the layer + /// stays put. Without a selection they are left alone. One step each with Bundle Arrow-Key + /// Nudges off (#2259). #[test] fn arrow_keys_nudge_the_selection_with_a_selection_tool() { let mut app = app_with_layer(); + app.session.edit_prefs(|p| p.tools.bundle_nudges = false); let id = app.session.active().unwrap().active_layer.unwrap(); app.ui.tool = Tool::Lasso; assert!(!press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE), "no selection: not used"); @@ -561,4 +584,197 @@ mod tests { app.ui.polygon = vec![[1.0, 1.0]]; assert!(!press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE)); } + fn history(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() + } + + fn left_edge(app: &PhotocraftApp, id: photocraft_doc::LayerId) -> i32 { + bounds(app, id).x0 + } + + /// #2259: tapping or holding an arrow key is one History state, so one Undo takes the whole + /// run back (Photoshop folds a run of nudges into one "Move"). + #[test] + fn a_run_of_arrow_nudges_is_one_history_step() { + let mut app = app_with_layer(); + let id = app.session.active().unwrap().active_layer.unwrap(); + let h0 = history(&app); + // Twelve taps, 0.2 s apart, then ⇧ for ten more px: still the same run. + for i in 0..12 { + assert!(press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 5.0 + 0.2 * f64::from(i))); + } + assert!(press_at(&mut app, egui::Key::ArrowDown, egui::Modifiers::SHIFT, 7.6)); + assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(20, 18, 36, 34)); + assert_eq!(history(&app), h0 + 1, "thirteen presses, one state"); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Move")); + assert!(app.session.undo()); + assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(8, 8, 24, 24), "one Undo takes back the run"); + assert!(app.session.redo()); + assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(20, 18, 36, 34), "and one Redo does it again"); + } + + /// A pause longer than Nudge Bundle Pause starts a new run; a longer pause setting keeps it. + #[test] + fn a_pause_ends_the_run() { + let mut app = app_with_layer(); + let id = app.session.active().unwrap().active_layer.unwrap(); + let h0 = history(&app); + press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 1.0); + press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 1.9); + assert_eq!(history(&app), h0 + 1, "0.9 s: the same run"); + press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 3.0); + assert_eq!(history(&app), h0 + 2, "1.1 s of quiet: a new run"); + app.session.edit_prefs(|p| p.tools.nudge_bundle_pause_ms = 5000); + press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 7.0); + assert_eq!(history(&app), h0 + 2, "4 s with a 5 s pause: the same run"); + assert_eq!(left_edge(&app, id), 12); + assert!(app.session.undo()); + assert_eq!(left_edge(&app, id), 10, "Undo takes back the second run only"); + } + + /// With Bundle Arrow-Key Nudges off every press is a state, as before (#2259). + #[test] + fn every_press_is_a_step_with_bundling_off() { + let mut app = app_with_layer(); + let id = app.session.active().unwrap().active_layer.unwrap(); + app.session.edit_prefs(|p| p.tools.bundle_nudges = false); + let h0 = history(&app); + for _ in 0..5 { + assert!(press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE)); + } + assert_eq!(history(&app), h0 + 5); + assert!(app.session.undo()); + assert_eq!(left_edge(&app, id), 12, "one pixel back"); + // Turning it back on bundles the next run, which doesn't swallow the earlier steps. + app.session.edit_prefs(|p| p.tools.bundle_nudges = true); + for _ in 0..3 { + press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE); + } + assert_eq!(history(&app), h0 + 5); + assert_eq!(left_edge(&app, id), 15); + } + + /// Any other change to the document ends the run: another edit, Undo, a different layer + /// selected, a save. + #[test] + fn other_changes_end_the_run() { + let mut app = app_with_layer(); + let a = app.session.active().unwrap().active_layer.unwrap(); + let tap = |app: &mut PhotocraftApp| assert!(press(app, egui::Key::ArrowRight, egui::Modifiers::NONE)); + let h0 = history(&app); + tap(&mut app); + tap(&mut app); + assert_eq!(history(&app), h0 + 1); + // An edit of another kind between two nudges. + app.session.execute("layer.setProps", json!({"opacity": 0.5})).unwrap(); + tap(&mut app); + tap(&mut app); + assert_eq!(history(&app), h0 + 3, "nudges, the edit, nudges"); + // Undo ends it too: the next nudge is a state of its own, not folded into the one before. + assert!(app.session.undo()); + tap(&mut app); + assert_eq!(history(&app), h0 + 3); + assert!(app.session.undo()); + assert_eq!(left_edge(&app, a), 10, "the new run was undone alone"); + // Saving keeps the saved state a step of its own. + tap(&mut app); + tap(&mut app); + let before = history(&app); + let st = app.session.active_mut().unwrap(); + st.saved_revision = st.revision; + tap(&mut app); + assert_eq!(history(&app), before + 1, "a save between two nudges ends the run"); + // Selecting another layer: the same keys now move that one, in a run of its own. + let b = { + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session.active().unwrap().active_layer.unwrap() + }; + assert_ne!(a, b); + let before = history(&app); + app.session + .edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 4, 4), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + tap(&mut app); + app.session.select_layer(a).unwrap(); + tap(&mut app); + assert_eq!(history(&app), before + 3, "paint, a nudge of the new layer, a nudge of the first"); + } + + /// ⌥ copies, so ⌥-nudges are never folded into a run (the copy is part of each step). + #[test] + fn alt_nudges_do_not_join_a_run() { + let mut app = app_with_layer(); + let h0 = history(&app); + press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE); + press(&mut app, egui::Key::ArrowRight, egui::Modifiers::ALT); + press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE); + assert_eq!(history(&app), h0 + 3); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Move"), "the run after the copy is a state of its own"); + } + + /// #2259: nudges of the selection outline bundle the same way. + #[test] + fn selection_outline_nudges_bundle_into_one_step() { + let mut app = app_with_layer(); + app.ui.tool = Tool::Lasso; + app.session.execute("select.rect", json!({"x": 4, "y": 4, "width": 10, "height": 10})).unwrap(); + let sel = |app: &PhotocraftApp| app.session.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(); + let h0 = history(&app); + for i in 0..5 { + assert!(press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 2.0 + 0.1 * f64::from(i))); + } + assert!(press_at(&mut app, egui::Key::ArrowDown, egui::Modifiers::SHIFT, 2.6)); + assert_eq!(sel(&app), photocraft_geom::Rect::new(9, 14, 19, 24)); + assert_eq!(history(&app), h0 + 1); + assert!(app.session.undo()); + assert_eq!(sel(&app), photocraft_geom::Rect::new(4, 4, 14, 14), "Undo takes the outline back where it was drawn"); + // The layer nudge and the outline nudge are different runs even back to back. + app.session.execute("select.deselect", json!({})).unwrap(); + app.ui.tool = Tool::Move; + let h1 = history(&app); + press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 3.0); + assert_eq!(history(&app), h1 + 1); + } + + /// Free Transform: nudges of the box that follow each other are one Undo step of the session + /// (it has its own history); a drag or a pause starts another. + #[test] + fn free_transform_nudges_bundle_into_one_undo_step() { + let mut app = app_with_layer(); + let ctx = egui::Context::default(); + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + let quad = |app: &PhotocraftApp| app.ui.transform.as_ref().unwrap().quad; + let q0 = quad(&app); + let moved = |dx: f64, dy: f64| q0.map(|q| [q[0] + dx, q[1] + dy]); + let settle = |app: &mut PhotocraftApp| crate::transform_tool::track_steps(app, &ctx); + for i in 0..6 { + assert!(press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 4.0 + 0.1 * f64::from(i))); + settle(&mut app); + } + assert_eq!(quad(&app), moved(6.0, 0.0)); + // A pause, and the next nudge is a step of its own. + assert!(press_at(&mut app, egui::Key::ArrowDown, egui::Modifiers::NONE, 9.0)); + settle(&mut app); + assert_eq!( + quad(&app), + [[q0[0][0] + 6.0, q0[0][1] + 1.0], [q0[1][0] + 6.0, q0[1][1] + 1.0], [q0[2][0] + 6.0, q0[2][1] + 1.0], [q0[3][0] + 6.0, q0[3][1] + 1.0]] + ); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(quad(&app), moved(6.0, 0.0), "Undo takes back the late nudge only"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(quad(&app), q0, "and the next one the six that were bundled"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(quad(&app), q0, "nothing is left of the session to undo"); + // With bundling off every nudge is a step. + app.session.edit_prefs(|p| p.tools.bundle_nudges = false); + for i in 0..3 { + press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 20.0 + 0.1 * f64::from(i)); + settle(&mut app); + } + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(quad(&app), moved(2.0, 0.0)); + } } 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/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index c9bf902c67..e615620d4c 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -633,7 +633,7 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = UNITS.iter().map(|u| (u.0.to_string(), u.1)).collect(); @@ -645,7 +645,7 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map)| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + body(app, ui, f); + } + }, + (app, f), + ); + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + let p = command_params(&h.state().1); + assert_eq!((p["width"].as_u64(), p["height"].as_u64()), (Some(1), Some(1))); + } + /// #1147: the Orientation buttons swap the typed values too: 841 x 1189 mm becomes 1189 x 841. #[test] fn orientation_swap_keeps_the_typed_values() { diff --git a/crates/ui-egui/src/new_guide_ui.rs b/crates/ui-egui/src/new_guide_ui.rs index 8b8a209281..07a8aef7b5 100644 --- a/crates/ui-egui/src/new_guide_ui.rs +++ b/crates/ui-egui/src/new_guide_ui.rs @@ -13,6 +13,11 @@ //! Color lists Photoshop's guide colours (Cyan by default) and Custom…, whose swatch opens the //! Color Picker. The guide stores the chosen colour itself, so changing the preference guide //! colour later doesn't recolour it. +//! +//! Edit Selected Guides (the guide menu, `guide_select`) opens the same dialog on the selected +//! guide: its orientation, its position in the ruler unit and its colour. OK runs `view.editGuide`, +//! sending the colour only when it was changed, so a guide that uses the preference colour keeps +//! doing so. use photocraft_engine::edit_cmds::GUIDE_COLORS; use photocraft_engine::prefs::Unit; @@ -23,6 +28,11 @@ use crate::state::DialogKind; const MARK: &str = "__newGuide"; const COMMAND: &str = "view.newGuide"; +const EDIT_COMMAND: &str = "view.editGuide"; +/// In an Edit Selected Guides dialog: the guide being edited, as `{"orientation", "index"}`, and +/// the colour it opened with. +const EDIT: &str = "__edit"; +const INITIAL_COLOR: &str = "__initialColor"; const CUSTOM: &str = "custom"; /// New Guide's colour when nothing was remembered, as in Photoshop. const DEFAULT_COLOR: &str = "cyan"; @@ -100,6 +110,44 @@ pub fn open(app: &mut PhotocraftApp) -> u64 { app.ui.open_dialog(DialogKind::Command, f) } +/// The list entry (and Custom… colour) showing `rgb`: a named colour when it is one, else Custom…. +fn color_choice(rgb: [u8; 3]) -> (String, String) { + let hex = format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]); + match GUIDE_COLORS.iter().find(|(.., c)| *c == rgb) { + Some((name, ..)) => (name.to_string(), hex), + None => (CUSTOM.to_string(), hex), + } +} + +/// Edit Selected Guides: the dialog on the selected guide, with its orientation, its position in +/// the current ruler unit and its colour (its own, or the preference colour it is drawn in). +pub fn open_edit(app: &mut PhotocraftApp) -> Result { + let (vertical, index) = crate::guide_select::selected(app).ok_or_else(|| "No guide is selected".to_string())?; + let (px, own) = app + .session + .active() + .and_then(|st| { + let g = &st.doc.guides; + let p = *if vertical { &g.vertical } else { &g.horizontal }.get(index)?; + Some((p, g.color(vertical, p))) + }) + .ok_or_else(|| "No guide is selected".to_string())?; + let pref = photocraft_engine::prefs::parse_hex(&app.session.prefs().guides_grid_and_slices.guide_color).unwrap_or([0x4a, 0xff, 0xff]); + let (color, custom) = color_choice(own.unwrap_or(pref)); + let orientation = if vertical { "vertical" } else { "horizontal" }; + let mut f = Map::new(); + f.insert(MARK.into(), json!(true)); + f.insert("__command".into(), json!(EDIT_COMMAND)); + f.insert("__label".into(), json!("Edit Selected Guides")); + f.insert(EDIT.into(), json!({"orientation": orientation, "index": index})); + f.insert("orientation".into(), json!(orientation)); + f.insert("position".into(), json!(in_ruler_unit(app, px, vertical))); + f.insert(INITIAL_COLOR.into(), json!(if color == CUSTOM { custom.clone() } else { color.clone() })); + f.insert("color".into(), json!(color)); + f.insert("customColor".into(), json!(custom)); + Ok(app.ui.open_dialog(DialogKind::Command, f)) +} + pub fn body(ui: &mut egui::Ui, f: &mut Map) { let mut orientation = f.get("orientation").and_then(Value::as_str).unwrap_or("horizontal").to_string(); let mut position = f.get("position").and_then(Value::as_str).unwrap_or_default().to_string(); @@ -152,11 +200,23 @@ pub fn params(app: &PhotocraftApp, f: &Map) -> Value { }; let color = f.get("color").and_then(Value::as_str).filter(|c| is_color_name(c)).unwrap_or(DEFAULT_COLOR); let color = if color == CUSTOM { custom_color(f) } else { color.to_string() }; - json!({"orientation": orientation, "position": position, "color": color}) + match f.get(EDIT) { + Some(edit) => { + let mut p = json!({"orientation": edit["orientation"], "index": edit["index"], "newOrientation": orientation, "position": position}); + if f.get(INITIAL_COLOR).and_then(Value::as_str) != Some(color.as_str()) { + p["color"] = json!(color); + } + p + } + None => json!({"orientation": orientation, "position": position, "color": color}), + } } pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { let params = params(app, f); + if f.contains_key(EDIT) { + return confirm_edit(app, f, ¶ms); + } match app.run(COMMAND, params.clone()) { Ok(result) => { // Remember where the guide landed (so it reopens in whatever the ruler unit is then), @@ -182,5 +242,30 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result, params: &Value) -> Result { + match app.run(EDIT_COMMAND, params.clone()) { + Ok(result) => { + let (from, to) = (params["orientation"] == "vertical", params["newOrientation"] == "vertical"); + let index = if from == to { + params["index"].as_u64().and_then(|i| usize::try_from(i).ok()) + } else { + app.session.active().map(|st| if to { st.doc.guides.vertical.len() } else { st.doc.guides.horizontal.len() }.saturating_sub(1)) + }; + if let Some(i) = index { + crate::guide_select::select(app, to, i); + } + Ok(result) + } + Err(e) => { + let mut again = f.clone(); + again.insert("__error".into(), json!(e)); + app.ui.open_dialog(DialogKind::Command, again); + Err(e) + } + } +} + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/notices.rs b/crates/ui-egui/src/notices.rs index 146b8b8180..76cdc7b9bb 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::PhotocraftApp; const WAYLAND_FILE_DROP_DISMISSED: &str = "ui.waylandFileDropGuidanceDismissed"; +const CLIPBOARD_UNREADABLE: &str = "Pasting from other apps is unavailable"; /// At most this many notices are kept; older ones drop off. pub const MAX_NOTICES: usize = 3; /// Lines shown per notice before "…and N more". @@ -91,6 +92,30 @@ pub fn wayland_file_drop_guidance(app: &mut PhotocraftApp) { } } +/// A paste found no clipboard to read (#5031). In a Flatpak on Wayland the sandbox hides the +/// data-control protocol that other apps' images are read through, and there is no X server to +/// fall back to, so the clipboard may hold an image all the same. Say so, and give the command +/// that starts this install under XWayland, where the X11 clipboard works, when there is one. +pub fn clipboard_unreadable(app: &mut PhotocraftApp) { + app.ui.status = tl!("Nothing to paste: PhotoCraft can't read the system clipboard here").into(); + app.ui.status_error = false; + if app.ui.notices.iter().any(|notice| notice.title == CLIPBOARD_UNREADABLE) { + return; + } + // English templates, translated when drawn, as in `wayland_file_drop_guidance`. + let mut lines = + vec!["PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through.".to_owned()]; + let mut args = Vec::new(); + if let Some(command) = &app.services.xwayland_command { + lines.push("To paste from other apps, start PhotoCraft under XWayland: `{command}`".to_owned()); + args.push(("command".to_owned(), command.clone())); + } + let id = post(app, CLIPBOARD_UNREADABLE, lines, false, None); + if let Some(notice) = app.ui.notices.iter_mut().find(|notice| notice.id == id) { + notice.args = args; + } +} + /// A full refresh of document `doc` fell back to the CPU compositor (`reason`, the GPU's) and took /// `ms`. The canvas doesn't respond while it runs, so once per document say why it was slow and /// which edits will do it again (#1015). @@ -105,8 +130,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, 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 { @@ -272,6 +303,37 @@ mod tests { assert_eq!(crate::i18n::tr(crate::i18n::Lang::EN, ¬ice.title), "Native file drag-and-drop is unavailable"); } + /// #5031: a paste that can't reach the clipboard (a Flatpak on Wayland) says why and how to + /// paste anyway, once while shown; a clipboard that simply holds no image stays a quiet no-op. + #[test] + fn unreadable_clipboard_paste_explains_the_sandbox() { + let command = "flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft"; + let services = |unreadable: bool| Services { + clipboard_get_image: Some(Box::new(|| None)), + clipboard_unreadable: Some(Box::new(move || unreadable)), + xwayland_command: Some(command.to_string()), + ..Default::default() + }; + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(Session::new(), services(true)); + for _ in 0..2 { + let r = crate::menus::invoke(&mut app, &ctx, "edit.paste", serde_json::json!({})).unwrap(); + assert_eq!(r["pasted"], serde_json::json!(false)); + } + assert_eq!(app.ui.status, "Nothing to paste: PhotoCraft can't read the system clipboard here"); + let shown: Vec<_> = app.ui.notices.iter().filter(|notice| notice.title == CLIPBOARD_UNREADABLE).collect(); + assert_eq!(shown.len(), 1, "a second paste doesn't stack another notice"); + let guidance = english(shown[0]); + assert!(guidance.contains(&format!("under XWayland: `{command}`")), "{guidance}"); + let es = crate::i18n::Lang::from_code("es").unwrap(); + assert!(shown[0].lines.iter().chain([&shown[0].title]).all(|line| crate::i18n::tr(es, line) != *line), "every line is a catalog template"); + + let mut empty = PhotocraftApp::new(Session::new(), services(false)); + crate::menus::invoke(&mut empty, &ctx, "edit.paste", serde_json::json!({})).unwrap(); + assert_eq!(empty.ui.status, "Nothing to paste: the clipboard holds no image"); + assert!(empty.ui.notices.iter().all(|notice| notice.title != CLIPBOARD_UNREADABLE)); + } + /// A notice's lines in English with their arguments filled in. fn english(notice: &Notice) -> String { let args: Vec<(&str, &str)> = notice.args.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); @@ -354,12 +416,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/nudge_bundle.rs b/crates/ui-egui/src/nudge_bundle.rs new file mode 100644 index 0000000000..87746c7165 --- /dev/null +++ b/crates/ui-egui/src/nudge_bundle.rs @@ -0,0 +1,167 @@ +//! Arrow-key nudges that follow each other are one History state (#2259). +//! +//! Tapping or holding an arrow key nudges the selected layers (or the selection outline) one +//! pixel at a time. One state per pixel buries the History panel and makes one Undo take back a +//! single pixel, so a run of nudges of the same thing shares one `coalesce` key (the engine folds +//! edits with the same key into one step, see `Session::execute`) and Undo takes back the run. +//! +//! A run lasts while each nudge comes within the Tools › Nudge Bundle Pause of the one before it +//! (longer than a key's auto-repeat delay, so a held key is one run) and nothing else happened +//! to the document in between: another edit, an Undo or Redo, a different layer selected or a +//! save each end it. Tools › Bundle Arrow-Key Nudges off gives every press its own state. +//! +//! [`NudgeBundle`] only decides which run a nudge belongs to; it reads the clock from the key +//! handlers ([`NudgeBundle::now`]) and the document from the caller, so it is tested without egui. + +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use photocraft_doc::DocId; +use photocraft_engine::DocState; + +/// What a run of nudges needs to find unchanged to go on: the document, and its revisions (every +/// edit, Undo, Redo and layer selection moves `revision`, a save moves `saved_revision`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Seen { + doc: DocId, + revision: u64, + saved_revision: u64, +} + +impl Seen { + pub(crate) fn of(st: &DocState) -> Self { + Self { doc: st.doc.id, revision: st.revision, saved_revision: st.saved_revision } + } +} + +/// The latest nudge: the run it belongs to, what it nudged, when, and the document it left. +#[derive(Clone, Debug)] +struct Last { + run: u64, + what: String, + at: f64, + left: Seen, +} + +/// The run the arrow-key nudges belong to (one per app; a nudge always goes to the active +/// document, whose state it checks). +#[derive(Clone, Debug, Default)] +pub struct NudgeBundle { + /// egui time (seconds) of the key press being handled; the arrow-key handlers set it. + pub(crate) now: f64, + /// Runs started so far; a run's number is part of its `coalesce` key. + started: u64, + last: Option, +} + +impl NudgeBundle { + /// The run a nudge of `what` now joins: the latest one when it was `what` too, came at most + /// `pause` seconds ago and left the document as `seen`; else a new run. + pub(crate) fn join(&mut self, what: &str, seen: Seen, pause: f64) -> u64 { + if let Some(l) = &self.last + && l.what == what + && l.left == seen + && self.now >= l.at + && self.now - l.at <= pause + { + return l.run; + } + self.started += 1; + self.started + } + + /// The nudge of `run` ran and left the document as `left`. + pub(crate) fn record(&mut self, run: u64, what: &str, left: Seen) { + self.last = Some(Last { run, what: what.to_string(), at: self.now, left }); + } + + /// The next nudge starts a new run. + pub(crate) fn reset(&mut self) { + self.last = None; + } +} + +/// The longest pause (seconds) between two nudges of one run, or `None` when nudges aren't +/// bundled (Tools › Bundle Arrow-Key Nudges). +pub(crate) fn pause(app: &PhotocraftApp) -> Option { + let tools = &app.session.prefs().tools; + tools.bundle_nudges.then(|| f64::from(tools.nudge_bundle_pause_ms) / 1000.0) +} + +/// Run the nudge command `id` (`what` names the thing it nudges: "layers", "selection") as part +/// of the current run of nudges, or on its own when nudges aren't bundled. +pub(crate) fn run(app: &mut PhotocraftApp, what: &str, id: &str, mut params: Value) -> Result { + let Some(pause) = pause(app) else { + app.nudge_bundle.reset(); + return app.run(id, params); + }; + let Some(st) = app.session.active() else { return app.run(id, params) }; + // The same keys nudge whatever is selected: another selection is another run. + let what = format!("{what}:{:?}:{:?}", st.active_layer, st.selected_layers); + let run = app.nudge_bundle.join(&what, Seen::of(st), pause); + if let Some(object) = params.as_object_mut() { + object.insert("coalesce".into(), json!(format!("nudge:{run}"))); + } + let result = app.run(id, params); + match (&result, app.session.active()) { + (Ok(_), Some(st)) => app.nudge_bundle.record(run, &what, Seen::of(st)), + _ => app.nudge_bundle.reset(), + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn seen(revision: u64) -> Seen { + Seen { doc: DocId(1), revision, saved_revision: 0 } + } + + fn at(b: &mut NudgeBundle, t: f64) { + b.now = t; + } + + #[test] + fn nudges_within_the_pause_share_a_run() { + let mut b = NudgeBundle::default(); + at(&mut b, 10.0); + let first = b.join("layers", seen(1), 1.0); + b.record(first, "layers", seen(2)); + at(&mut b, 10.9); + assert_eq!(b.join("layers", seen(2), 1.0), first, "0.9 s later is the same run"); + b.record(first, "layers", seen(3)); + // The pause counts from the latest nudge, so a long run goes on while the keys do. + at(&mut b, 11.8); + assert_eq!(b.join("layers", seen(3), 1.0), first); + b.record(first, "layers", seen(4)); + at(&mut b, 12.9); + assert_ne!(b.join("layers", seen(4), 1.0), first, "1.1 s of quiet ends the run"); + } + + #[test] + fn anything_else_ends_the_run() { + let mut b = NudgeBundle::default(); + at(&mut b, 1.0); + let run = b.join("layers", seen(1), 1.0); + b.record(run, "layers", seen(2)); + let new = |b: &mut NudgeBundle, what: &str, s: Seen| b.join(what, s, 1.0) != run; + assert!(new(&mut b, "layers", seen(3)), "an edit, Undo or Redo moved the revision"); + assert!(new(&mut b, "selection", seen(2)), "another thing is nudged"); + assert!(new(&mut b, "layers", Seen { doc: DocId(2), ..seen(2) }), "another document"); + assert!(new(&mut b, "layers", Seen { saved_revision: 2, ..seen(2) }), "a save keeps the saved state a step of its own"); + assert!(!new(&mut b, "layers", seen(2)), "nothing happened: the same run"); + // The clock never runs backwards in a real session; if it did, that is not a run. + at(&mut b, 0.5); + assert!(new(&mut b, "layers", seen(2))); + } + + #[test] + fn reset_ends_the_run() { + let mut b = NudgeBundle::default(); + let run = b.join("layers", seen(1), 1.0); + b.record(run, "layers", seen(2)); + b.reset(); + assert_ne!(b.join("layers", seen(2), 1.0), run); + } +} diff --git a/crates/ui-egui/src/nudge_bundle_tests.rs b/crates/ui-egui/src/nudge_bundle_tests.rs new file mode 100644 index 0000000000..d18bbddafa --- /dev/null +++ b/crates/ui-egui/src/nudge_bundle_tests.rs @@ -0,0 +1,123 @@ +//! #2259: arrow-key nudges through the whole app (panels, shortcuts, the real frame loop and its +//! clock) are one History state per run, and Undo takes the run back. + +use egui_kittest::Harness; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.ui.tool = Tool::Move; + app + }); + h.run_steps(6); + h +} + +fn bounds(h: &Harness<'_, PhotocraftApp>) -> Rect { + let st = h.state().session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds() +} + +fn steps(h: &Harness<'_, PhotocraftApp>) -> usize { + h.state().session.active().unwrap().history.past_len() +} + +fn nudge(h: &mut Harness<'_, PhotocraftApp>, key: egui::Key, frames: usize) { + h.key_press(key); + h.run_steps(frames); +} + +#[test] +fn tapping_an_arrow_key_ten_times_is_one_history_step() { + let mut h = harness(); + let (start, n0) = (bounds(&h), steps(&h)); + for _ in 0..10 { + nudge(&mut h, egui::Key::ArrowRight, 1); + } + assert_eq!(bounds(&h).x0, start.x0 + 10); + assert_eq!(steps(&h), n0 + 1, "ten taps, one state"); + let entries = h.state().session.active().unwrap().history.entries(); + assert_eq!(entries.last().map(String::as_str), Some("Move")); + assert_eq!(entries.iter().filter(|e| *e == "Move").count(), 1); + h.state_mut().session.undo(); + assert_eq!(bounds(&h), start, "one Undo takes all ten pixels back"); +} + +#[test] +fn a_held_key_is_one_history_step() { + let mut h = harness(); + let (start, n0) = (bounds(&h), steps(&h)); + nudge(&mut h, egui::Key::ArrowDown, 1); + // The key stays down: the OS repeats it after a pause of about half a second. + h.run_steps(2); + for _ in 0..15 { + h.event(egui::Event::Key { key: egui::Key::ArrowDown, physical_key: None, pressed: true, repeat: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + h.event(egui::Event::Key { key: egui::Key::ArrowDown, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + assert_eq!(bounds(&h).y0, start.y0 + 16); + assert_eq!(steps(&h), n0 + 1); + h.state_mut().session.undo(); + assert_eq!(bounds(&h), start); +} + +#[test] +fn a_pause_starts_a_new_step_and_the_preference_sets_how_long() { + let mut h = harness(); + let n0 = steps(&h); + nudge(&mut h, egui::Key::ArrowRight, 1); + nudge(&mut h, egui::Key::ArrowRight, 1); + // The test clock runs a quarter of a second per frame: eight frames is two seconds of quiet. + h.run_steps(8); + nudge(&mut h, egui::Key::ArrowRight, 1); + assert_eq!(steps(&h), n0 + 2, "two seconds of quiet end the run"); + h.state_mut().run("prefs.set", json!({"path": "tools.nudgeBundlePauseMs", "value": 5000})).unwrap(); + h.run_steps(8); + nudge(&mut h, egui::Key::ArrowRight, 1); + assert_eq!(steps(&h), n0 + 2, "with a five second pause, two seconds of quiet go on with the run"); + h.run_steps(24); + nudge(&mut h, egui::Key::ArrowRight, 1); + assert_eq!(steps(&h), n0 + 3, "six seconds of quiet end it"); +} + +#[test] +fn the_preference_turns_bundling_off_and_on_again() { + let mut h = harness(); + let (start, n0) = (bounds(&h), steps(&h)); + h.state_mut().run("prefs.set", json!({"path": "tools.bundleNudges", "value": false})).unwrap(); + for _ in 0..4 { + nudge(&mut h, egui::Key::ArrowRight, 1); + } + assert_eq!(steps(&h), n0 + 4, "a state per press"); + h.state_mut().session.undo(); + assert_eq!(bounds(&h).x0, start.x0 + 3); + h.state_mut().run("prefs.set", json!({"path": "tools.bundleNudges", "value": true})).unwrap(); + for _ in 0..4 { + nudge(&mut h, egui::Key::ArrowRight, 1); + } + assert_eq!(steps(&h), n0 + 4, "the next four bundle: 3 earlier states and one run"); + assert_eq!(bounds(&h).x0, start.x0 + 7); +} + +/// Bad values are rejected, not stored (the control channel and the Preferences dialog share the +/// same ranges). +#[test] +fn the_pause_preference_stays_within_its_range() { + let mut h = harness(); + for bad in [json!(0), json!(99), json!(10_001), json!("long"), json!(-5)] { + assert!(h.state_mut().run("prefs.set", json!({"path": "tools.nudgeBundlePauseMs", "value": bad.clone()})).is_err(), "{bad}"); + } + assert_eq!(h.state().session.prefs().tools.nudge_bundle_pause_ms, 1000); +} diff --git a/crates/ui-egui/src/numeric_expression.rs b/crates/ui-egui/src/numeric_expression.rs index a3232dd96f..6c7f96393c 100644 --- a/crates/ui-egui/src/numeric_expression.rs +++ b/crates/ui-egui/src/numeric_expression.rs @@ -19,6 +19,10 @@ pub struct Units<'s> { pub dpi: f64, /// Points per inch (72 PostScript, 72.27 traditional). pub ppi: f64, + /// The length in pixels that 100 % stands for, in a field that is both (Free Transform's W + /// and H, #6284): a length then converts into its percent and a percentage into its length. + /// `None` (most fields) keeps lengths and percentages apart. + pub extent: Option, } /// Unit names a number may carry, longest first so `pica` wins over `pc`. @@ -34,8 +38,9 @@ const UNIT_NAMES: [(&str, Unit); 8] = [ ]; /// [`parse`] where a number may carry a unit (`2in`, `5 mm`, `50%`), converted into the field's -/// unit. A unit that can't convert (a length in a percent field, any unit in a unitless one) makes -/// the whole text invalid; without `units`, unit names are invalid as before. +/// unit. A unit that can't convert (a length in a percent field without an [`Units::extent`], any +/// unit in a unitless one) makes the whole text invalid; without `units`, unit names are invalid +/// as before. pub(crate) fn parse_with_units(text: &str, units: Option) -> Option { if text.len() > 1024 { return None; @@ -168,7 +173,13 @@ impl Parser<'_> { Some(field) if field == typed => Some(value), // Lengths convert through pixels; a percentage has no length to convert against. Some(field) if field != Unit::Percent && typed != Unit::Percent => Some(field.from_px(typed.to_px(value, dpi, 1.0, ppi), dpi, 1.0, ppi)), - _ => None, + // A field whose 100 % is a length: convert through that length's pixels. + Some(field) => { + let extent = units.extent.map(f64::abs).filter(|e| e.is_finite() && *e > 1e-9)?; + let v = field.from_px(typed.to_px(value, dpi, extent, ppi), dpi, extent, ppi); + v.is_finite().then_some(v) + } + None => None, } } } @@ -206,10 +217,10 @@ mod tests { #[test] fn units_convert_into_the_field_unit_and_bad_input_is_rejected() { use super::{Unit, Units, parse_with_units}; - let px = Units { unit: Some(Unit::Pixels), suffix: "px", dpi: 300.0, ppi: 72.0 }; - let pt = Units { unit: Some(Unit::Points), suffix: "pt", dpi: 144.0, ppi: 72.0 }; - let pct = Units { unit: Some(Unit::Percent), suffix: "%", dpi: 72.0, ppi: 72.0 }; - let deg = Units { unit: None, suffix: "°", dpi: 72.0, ppi: 72.0 }; + let px = Units { unit: Some(Unit::Pixels), suffix: "px", dpi: 300.0, ppi: 72.0, extent: None }; + let pt = Units { unit: Some(Unit::Points), suffix: "pt", dpi: 144.0, ppi: 72.0, extent: None }; + let pct = Units { unit: Some(Unit::Percent), suffix: "%", dpi: 72.0, ppi: 72.0, extent: None }; + let deg = Units { unit: None, suffix: "°", dpi: 72.0, ppi: 72.0, extent: None }; for (text, units, want) in [ ("1in", px, 300.0), ("2.54cm", px, 300.0), @@ -255,6 +266,40 @@ mod tests { } assert_eq!(parse("1in"), None, "without a field unit, unit names stay invalid"); } + #[test] + fn a_field_with_an_extent_converts_between_lengths_and_percent() { + use super::{Unit, Units, parse_with_units}; + // Free Transform's W at 300 ppi for a 600 px side (#6284): 100 % is 600 px, 2 in. + let pct = Units { unit: Some(Unit::Percent), suffix: "%", dpi: 300.0, ppi: 72.0, extent: Some(600.0) }; + let px = Units { unit: Some(Unit::Pixels), suffix: "px", ..pct }; + let inches = Units { unit: Some(Unit::Inches), suffix: "in", ..pct }; + for (text, units, want) in [ + ("300px", pct, 50.0), + ("1in", pct, 50.0), + ("2in*2", pct, 200.0), + ("2.54cm", pct, 50.0), + ("-1in", pct, -50.0), + ("50%", pct, 50.0), + ("50", pct, 50.0), + ("50%", px, 300.0), + ("50%+10", px, 310.0), + ("150%", inches, 3.0), + ("10%3", px, 1.0), + ] { + let got = parse_with_units(text, Some(units)); + assert!(got.is_some_and(|v| (v - want).abs() < 1e-9), "{text}: {got:?}, want {want}"); + } + // A degenerate or hostile extent can't convert, and never divides by zero. + for extent in [Some(0.0), Some(f64::NAN), Some(f64::INFINITY), None] { + assert_eq!(parse_with_units("1in", Some(Units { extent, ..pct })), None, "{extent:?}"); + assert_eq!(parse_with_units("50%", Some(Units { extent, ..px })), None, "{extent:?}"); + } + assert_eq!(parse_with_units("1e308in", Some(Units { extent: Some(1e-6), ..pct })), None, "an overflow is invalid"); + // Other percent fields (opacity, fill …) still reject a length. + let opacity = Units { extent: None, ..pct }; + assert_eq!(parse_with_units("40px", Some(opacity)), None); + assert_eq!(parse_with_units("2in", Some(opacity)), None); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/object_finder.rs b/crates/ui-egui/src/object_finder.rs index e219b0ddff..01fe50e502 100644 --- a/crates/ui-egui/src/object_finder.rs +++ b/crates/ui-egui/src/object_finder.rs @@ -78,7 +78,7 @@ pub(crate) fn update(app: &mut PhotocraftApp, at: Option<[f64; 2]>, pressed: boo return Update::default(); }; let Some(st) = app.session.active() else { return Update::default() }; - let all_layers = app.ui.tool_options.sample_all_layers; + let all_layers = app.ui.tool_options.sample_all.object_selection; let target = sampled_target(&st.doc, st.active_layer, all_layers); let (doc, revision, layer) = (st.doc.id, st.revision, st.active_layer); let px = [at[0].floor() as i32, at[1].floor() as i32]; diff --git a/crates/ui-egui/src/open_prompt.rs b/crates/ui-egui/src/open_prompt.rs index cd04a4fc5f..a4d93ca057 100644 --- a/crates/ui-egui/src/open_prompt.rs +++ b/crates/ui-egui/src/open_prompt.rs @@ -75,19 +75,33 @@ fn show(app: &mut PhotocraftApp) { let name = front.name.as_str(); let working = crate::i18n::fmt(tl!("Working: {working}"), &[("working", q.get("working").and_then(Value::as_str).unwrap_or("?"))]); let missing = q.get("missing").and_then(Value::as_bool) == Some(true); + // A mismatch names the two profiles on the choices they belong to (`prefs_ui::mismatch_body`). let message = if missing { let what = crate::i18n::fmt(tl!("The document “{name}” does not have an embedded {mode} profile."), &[("mode", mode), ("name", name)]); format!("{what}\n{working}") } else { - let what = crate::i18n::fmt( + crate::i18n::fmt( tl!("The document “{name}” has an embedded color profile that does not match the current {mode} working space."), &[("mode", mode), ("name", name)], - ); - let embedded = crate::i18n::fmt(tl!("Embedded: {embedded}"), &[("embedded", q.get("embedded").and_then(Value::as_str).unwrap_or("?"))]); - format!("{what}\n{embedded}\n{working}") + ) }; let action = q.get("action").and_then(Value::as_str).unwrap_or("preserve").to_string(); - crate::prefs_ui::open_mismatch(app, &message, &action, missing, FIELD); + let names = json!({ + "embedded": q.get("embedded").and_then(Value::as_str).unwrap_or("?"), + "working": q.get("working").and_then(Value::as_str).unwrap_or("?"), + "policyKey": policy_key(front.doc.mode), + }); + crate::prefs_ui::open_mismatch(app, &message, &action, missing, FIELD, names); +} + +/// The Color Settings policy that answers this mode's files when Ask On Mismatch is off. +fn policy_key(mode: ColorMode) -> Option<&'static str> { + match photocraft_engine::color_cmds::mode_space(mode) { + photocraft_cms::ColorSpace::Rgb => Some("policyRgb"), + photocraft_cms::ColorSpace::Cmyk => Some("policyCmyk"), + photocraft_cms::ColorSpace::Gray => Some("policyGray"), + _ => None, + } } /// Whether `fields` are one of this module's prompts. @@ -115,6 +129,31 @@ pub fn answer(app: &mut PhotocraftApp, action: Option<&str>) -> Result Result { + let Some(key) = app.held_opens.front().and_then(|h| policy_key(h.doc.mode)) else { return answer(app, Some(action)) }; + let policy = match action { + "convert" => "convert", + "discard" => "off", + _ => "preserve", + }; + app.run("edit.colorSettings", json!({key: policy, "askOnMismatch": false}))?; + let same = |h: &Held| policy_key(h.doc.mode) == Some(key) && h.question.as_ref().is_some_and(|q| q.get("missing").and_then(Value::as_bool) != Some(true)); + let Some(held) = app.held_opens.pop_front() else { return Err("no file is waiting to open".into()) }; + let r = open(app, held, Some(action)); + while app.held_opens.front().is_some_and(same) { + if let Some(held) = app.held_opens.pop_front() { + // A failure is shown to the user (`open_failed`); the next file still opens. + let _ = open(app, held, Some(action)); + } + } + advance(app); + r +} + /// Opens a queued file, applying `action` (the prompt's answer) or else the Color Settings /// policy, then does what any open does afterwards. fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result { diff --git a/crates/ui-egui/src/options_hit_area_tests.rs b/crates/ui-egui/src/options_hit_area_tests.rs new file mode 100644 index 0000000000..d444f06473 --- /dev/null +++ b/crates/ui-egui/src/options_hit_area_tests.rs @@ -0,0 +1,199 @@ +//! #5052: the actual Brush options caption and field form one scrub target. +use crate::PhotocraftApp; +use egui::{Modifiers, Pos2, Rect, pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.tool = crate::state::Tool::Brush; + app.session.tools.brush.hardness = 0.5; + let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::panels::options_bar(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + h +} + +fn geometry(h: &Harness<'_, PhotocraftApp>) -> (Rect, Rect) { + let label = h.get_by_label("Hardness").rect(); + let field = h + .get_all_by_role(egui::accesskit::Role::SpinButton) + .map(|n| n.rect()) + .filter(|r| r.left() >= label.right()) + .min_by(|a, b| a.left().total_cmp(&b.left())) + .unwrap(); + (label, field) +} + +fn drag(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, dx: f32, mods: Modifiers) { + h.hover_at(at); + h.run(); + h.drag_at(at); + h.run(); + for i in 1..=4 { + h.event(egui::Event::ModifiersChanged(mods)); + h.hover_at(at + vec2(dx * i as f32 / 4.0, 0.0)); + h.run(); + } + h.drop_at(at + vec2(dx, 0.0)); + h.run_steps(2); +} + +#[test] +fn options_hardness_label_drag_cannot_copy_selected_caption() { + let mut h = harness(); + let (l, _) = geometry(&h); + drag(&mut h, pos2(l.left() + 2.0, l.center().y), 20.0, Modifiers::NONE); + assert!(!h.ctx.plugin::().lock().has_selection(), "scrubbing Hardness must not create label text selection"); + h.event(egui::Event::Copy); + h.step(); + assert!( + !h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(s) if s.contains("Hard"))), + "a scrub caption must not select text for copying" + ); + assert!(h.state().session.tools.brush.hardness > 0.5); +} + +#[test] +fn options_hardness_caption_upper_and_lower_margins_scrub() { + for dy in [-10.0, 10.0] { + let mut h = harness(); + let (l, _) = geometry(&h); + drag(&mut h, l.center() + vec2(0.0, dy), 12.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.62).abs() < 0.0001, "24pt caption margin dy={dy}: {}", h.state().session.tools.brush.hardness); + assert_eq!(h.state().session.journal.len(), 1, "one command for the whole gesture"); + } +} + +#[test] +fn options_hardness_gap_and_suffix_scrub() { + for suffix in [false, true] { + let mut h = harness(); + let (l, f) = geometry(&h); + let x = if suffix { f.right() + 8.0 } else { (l.right() + f.left() - 4.0) / 2.0 }; + drag(&mut h, pos2(x, l.center().y), 12.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.62).abs() < 0.0001, "new region suffix={suffix}: {}", h.state().session.tools.brush.hardness); + assert_eq!(h.state().session.journal.len(), 1); + } +} + +fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2) { + h.hover_at(at); + h.run(); + h.drag_at(at); + h.run(); + h.drop_at(at); + h.run_steps(2); +} + +#[test] +fn options_numeric_editing_and_focused_text_selection_keep_priority() { + let mut h = harness(); + let (_, f) = geometry(&h); + click(&mut h, f.center()); + let id = h.ctx.memory(|m| m.focused()).expect("click focuses the numeric editor"); + drag(&mut h, pos2(f.left() + 2.0, f.center().y), f.width() - 4.0, Modifiers::NONE); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(id), "text drag keeps numeric focus"); + assert_eq!(h.state().session.tools.brush.hardness, 0.5, "selecting digits must not scrub"); + let state = egui::TextEdit::load_state(&h.ctx, id).expect("focused text state"); + assert!(!state.cursor.char_range().unwrap().is_empty(), "digit selection still works"); + h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("25+5".into())); + h.run(); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert!((h.state().session.tools.brush.hardness - 0.3).abs() < 0.0001); +} + +#[test] +fn options_expanded_hit_click_does_not_edit_or_request_focus() { + let mut h = harness(); + let (_, f) = geometry(&h); + click(&mut h, f.center()); + let before_focus = h.ctx.memory(|m| m.focused()).expect("numeric editor focus"); + let before_journal = h.state().session.journal.len(); + let l = h.get_by_label("Hardness").rect(); + click(&mut h, l.center() + vec2(0.0, 10.0)); + assert_eq!(h.state().session.tools.brush.hardness, 0.5); + println!( + "focus_before={before_focus:?} focus_after={:?} value={} journal_before={before_journal} journal_after={}", + h.ctx.memory(|m| m.focused()), + h.state().session.tools.brush.hardness, + h.state().session.journal.len() + ); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(before_focus), "matches actual main: background retains numeric focus without acquiring it"); + assert_eq!(h.state().session.journal.len(), before_journal); +} + +#[test] +fn options_added_regions_preserve_modifiers_and_numeric_drag_rate() { + for (mods, dx, want) in [(Modifiers::SHIFT, 3.0, 0.8), (Modifiers::ALT, 20.0, 0.52)] { + let mut h = harness(); + let (l, f) = geometry(&h); + drag(&mut h, pos2(f.right() + 8.0, l.center().y), dx, mods); + assert!((h.state().session.tools.brush.hardness - want).abs() < 0.0001); + } + let mut h = harness(); + let (_, f) = geometry(&h); + drag(&mut h, f.center(), 20.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.6).abs() < 0.0001, "numeric drag retains its own rate"); +} + +#[test] +fn options_disabled_eraser_caption_and_added_regions_do_nothing() { + let mut h = harness(); + h.state_mut().ui.tool = crate::state::Tool::Eraser; + h.state_mut().ui.tool_options.eraser_mode = "block".into(); + h.run_steps(3); + let (l, f) = geometry(&h); + for at in [l.center(), pos2(f.right() + 8.0, l.center().y), l.center() + vec2(0.0, 10.0)] { + drag(&mut h, at, 12.0, Modifiers::NONE); + assert_eq!(h.state().session.tools.brush.hardness, 0.5); + assert!(h.state().session.journal.is_empty()); + } +} + +#[test] +fn options_field_outer_top_and_bottom_margins_scrub() { + for top in [true, false] { + let mut h = harness(); + let (_, f) = geometry(&h); + let y = if top { f.top() - 0.5 } else { f.bottom() + 0.5 }; + drag(&mut h, pos2(f.center().x, y), 12.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.62).abs() < 0.0001, "field margin top={top}"); + assert_eq!(h.state().session.journal.len(), 1); + } +} + +#[test] +fn options_gesture_keeps_its_start_owner_when_crossing_regions() { + let mut h = harness(); + let (l, _) = geometry(&h); + drag(&mut h, pos2(l.right() - 2.0, l.center().y), 24.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.74).abs() < 0.0001); + assert_eq!(h.state().session.journal.len(), 1); + let mut h = harness(); + let (_, f) = geometry(&h); + drag(&mut h, pos2(f.left() + 2.0, f.center().y), -30.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.35).abs() < 0.0001); + assert_eq!(h.state().session.journal.len(), 1); +} + +#[test] +fn options_tab_moves_from_hardness_to_opacity_without_a_caption_stop() { + let mut h = harness(); + let (_, f) = geometry(&h); + click(&mut h, f.center()); + h.key_press(egui::Key::Tab); + h.run_steps(2); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.hardness, 0.5); + assert!((h.state().session.tools.brush.opacity - 0.99).abs() < 0.0001); +} diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs index bdb5c41501..9e06a1ae68 100644 --- a/crates/ui-egui/src/outline.rs +++ b/crates/ui-egui/src/outline.rs @@ -10,6 +10,21 @@ use photocraft_raster::Surface; /// A boundary segment in document pixel coordinates (axis-aligned). pub type Segment = ([i32; 2], [i32; 2]); +/// Bound a whole-selection trace, including offscreen pixels carried into view by a transform. +/// Include grid alignment and the two padding cells in the budget; ordinary traces keep their step. +pub(crate) fn bounded_step(bounds: Rect, mut step: u32) -> u32 { + step = step.max(1); + let cells = |step: u32| { + let s = i64::from(step); + let axis = |lo: i32, hi: i32| ((i64::from(hi) + s - 1).div_euclid(s) - i64::from(lo).div_euclid(s) + 2).max(0) as u64; + axis(bounds.x0, bounds.x1).saturating_mul(axis(bounds.y0, bounds.y1)) + }; + while cells(step) > 1_048_576 { + step = step.saturating_mul(2); + } + step +} + /// Extract the boundary of `mask` (> 0.5 = selected) as merged horizontal and vertical segments. pub fn outline(mask: &Surface, bounds: Rect) -> Vec { outline_scaled(mask, bounds, 1) @@ -40,15 +55,22 @@ fn outline_scaled_impl(mask: &Surface, bounds: Rect, step: u32, parallel: bool) let mut grid = vec![false; w * h]; let half = step / 2; let sample_row = |gy: usize, row: &mut [bool]| { - let y = (gy0 + gy as i32) * step + half; - if y < bounds.y0 || y >= bounds.y1 { + let y0 = (gy0 + gy as i32) * step; + if y0 >= bounds.y1 || y0 + step <= bounds.y0 { return; } + // A coarse cell can overlap a one-pixel-wide selection even when its centre misses it. + // Sample the overlapping edge; padding cells still stay outside the mask. + let y = (y0 + half).clamp(bounds.y0, bounds.y1 - 1); let mut vals = vec![0.0f32; row.len()]; mask.sample_row_strided(y, gx0 * step + half, step, 0, &mut vals); for (gx, (cell, v)) in row.iter_mut().zip(vals).enumerate() { let x = (gx0 + gx as i32) * step + half; - *cell = x >= bounds.x0 && x < bounds.x1 && v > 0.5; + *cell = if x >= bounds.x0 && x < bounds.x1 { + v > 0.5 + } else { + x - half < bounds.x1 && x - half + step > bounds.x0 && mask.sample_channel(x.clamp(bounds.x0, bounds.x1 - 1), y, 0) > 0.5 + }; } }; #[cfg(not(target_arch = "wasm32"))] @@ -108,6 +130,33 @@ mod tests { use super::*; use photocraft_color::PixelFormat; + #[test] + fn whole_transform_trace_has_a_bounded_grid() { + assert_eq!(bounded_step(Rect::new(0, 0, 200, 150), 1), 1); + assert_eq!(bounded_step(Rect::new(0, 0, 6000, 6000), 1), 8); + for bounds in [Rect::new(-50_001, -50_003, 50_000, 50_000), Rect::new(-1_000_000, 0, 1_000_000, 1)] { + let step = i64::from(bounded_step(bounds, 1)); + let w = (i64::from(bounds.x1) + step - 1).div_euclid(step) - i64::from(bounds.x0).div_euclid(step) + 2; + let h = (i64::from(bounds.y1) + step - 1).div_euclid(step) - i64::from(bounds.y0).div_euclid(step) + 2; + assert!(w * h <= 1_048_576, "{bounds:?}: {w} × {h}"); + } + // Both distant pieces survive the coarse trace: it covers the whole selection, not the viewport. + let mut mask = Surface::new(PixelFormat::GRAY8); + mask.fill_rect(Rect::new(-50_000, -50_000, -49_488, -49_488), &[1.0]); + mask.fill_rect(Rect::new(49_488, 49_488, 50_000, 50_000), &[1.0]); + let bounds = mask.content_bounds(); + let lines = outline_scaled_serial(&mask, bounds, bounded_step(bounds, 1)); + assert!(lines.iter().any(|(a, _)| a[0] < -49_000)); + assert!(lines.iter().any(|(a, _)| a[0] > 49_000)); + let mut thin = Surface::new(PixelFormat::GRAY8); + thin.fill_rect(Rect::new(-1_000_000, 0, -999_488, 1), &[1.0]); + thin.fill_rect(Rect::new(999_488, 0, 1_000_000, 1), &[1.0]); + let bounds = thin.content_bounds(); + let lines = outline_scaled_serial(&thin, bounds, bounded_step(bounds, 1)); + assert!(lines.iter().any(|(a, _)| a[0] < -999_000), "thin left piece remains visible"); + assert!(lines.iter().any(|(a, _)| a[0] > 999_000), "thin right piece remains visible"); + } + #[test] fn rectangle_has_four_edges() { let mut m = Surface::new(PixelFormat::GRAY8); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index de56d6714c..f68c3e46e7 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -25,6 +25,7 @@ const PANELS: &[(&str, &str, Group)] = &[ ("layers", "Layers", Group::Layers), ("channels", "Channels", Group::Layers), ("paths", "Paths", Group::Layers), + ("brushes", "Brushes", Group::Brushes), ]; #[derive(Clone, Default)] struct LegacyDrag { @@ -80,6 +81,7 @@ fn legacy_layout(app: &PhotocraftApp, all: bool) -> Layout { Group::Navigator => app.ui.dock_tabs.navigator, Group::History => app.ui.dock_tabs.history, Group::Layers => app.ui.dock_tabs.layers, + Group::Brushes => app.ui.dock_tabs.brushes, }; let active = labels.get(selected).and_then(|label| normalize(label)).and_then(|id| panels.iter().position(|p| p == id)).unwrap_or(0); if group == Group::Properties && !pro { @@ -174,12 +176,13 @@ pub(crate) fn command(app: &mut PhotocraftApp, id: &str, params: &Value) -> Opti .and_then(|action| apply_action(app, action)), ); } - if let Some(layout) = &app.ui.docking - && let Some(panel) = id.strip_prefix("window.panel.").or_else(|| id.strip_prefix("window.toggle.")).and_then(normalize) - { - let showing = layout.contains(&panel.to_string()); + if let Some(panel) = id.strip_prefix("window.panel.").or_else(|| id.strip_prefix("window.toggle.")).and_then(normalize) { + if app.ui.docking.is_none() && panel != "brushes" { + return None; + } + let showing = app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&panel.to_string())); let show = match params.get("show").or_else(|| params.get("on")) { - None => !showing || !panel_active(layout, panel) || collapsed_members(app, layout).contains(panel), + None => !showing || app.ui.docking.as_ref().is_some_and(|layout| !panel_active(layout, panel) || collapsed_members(app, layout).contains(panel)), Some(value) => match value.as_bool() { Some(show) => show, None => return Some(Err("show must be a boolean".into())), @@ -544,6 +547,7 @@ fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: &str) { if group.scrolls_itself(tab) { crate::panels::dock_body(app, ui, *group, tab); } else { + crate::panels::offer_color_field_fill(ui, pro, *group, tab); egui::ScrollArea::vertical() .id_salt(("shared-panel-body", id)) .auto_shrink([false, false]) @@ -605,6 +609,7 @@ pub(crate) fn rail_on(app: &PhotocraftApp, group: Group) -> bool { pub(crate) fn rail_click(app: &mut PhotocraftApp, group: Group) { let Some(layout) = app.ui.docking.as_ref() else { return }; + let floating = PANELS.iter().filter(|p| p.2 == group).any(|p| layout.floating.iter().any(|g| g.panels.iter().any(|id| id == p.0))); let existing = PANELS.iter().find(|p| p.2 == group && layout.contains(&p.0.to_owned())).map(|p| p.0); let panel = existing.or_else(|| { group @@ -614,7 +619,11 @@ pub(crate) fn rail_click(app: &mut PhotocraftApp, group: Group) { }); if let Some(panel) = panel { let result = if existing.is_some() && rail_on(app, group) { - group_command(app, &json!({"panel": panel, "operation":"collapse"})) + if floating { + group_command(app, &json!({"panel": panel, "operation":"close"})) + } else { + group_command(app, &json!({"panel": panel, "operation":"collapse"})) + } } else { change(app, "activate", &json!({"panel":panel})) }; @@ -792,6 +801,7 @@ pub(crate) fn prepare_legacy_update( Group::Navigator => tabs.navigator, Group::History => tabs.history, Group::Layers => tabs.layers, + Group::Brushes => tabs.brushes, }; let id = group.tabs(pro).get(index).and_then(|label| normalize(label)).ok_or("dockTabs index is outside the panel group")?; if visibility == Some(false) { @@ -905,6 +915,10 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { self.commands.push(("window.panel.group", json!({"panel":active,"operation":if collapsed {"expand"} else {"collapse"}}))); ui.close(); } + if ui.add_enabled(!locked, egui::Button::new(if floating { tl!("Dock Panel") } else { tl!("Float Panel") })).clicked() { + self.commands.push((if floating { "window.panel.dock" } else { "window.panel.float" }, json!({"panel":active}))); + ui.close(); + } if !floating { let groups = self.app.ui.docking.as_ref().map(root_groups).unwrap_or_default(); let index = groups.iter().position(|g| g.contains(active)); diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 735274eb85..193ff2e6b8 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -30,6 +30,35 @@ fn panel_commands_group_split_reorder_float_and_restore_without_duplicate_owners assert_eq!(restored.docking_hidden, app.ui.docking_hidden); } +#[test] +fn floating_panel_rail_toggle_closes_the_float_window() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"properties","x":50,"y":70,"width":330,"height":410})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().floating.iter().any(|g| g.panels.contains(&"properties".into()))); + + rail_click(&mut app, Group::Properties); + + assert!(app.ui.docking.as_ref().unwrap().floating.iter().all(|g| !g.panels.contains(&"properties".into()))); + assert!(app.ui.docking.as_ref().unwrap().contains(&"adjustments".into())); + assert!(app.ui.panels.properties, "the docked Adjustments tab keeps the group visible"); +} + +#[test] +fn brushes_menu_action_uses_the_shared_docking_layout() { + let mut app = app(); + let before = app.ui.docking.clone(); + assert!(crate::menus::invoke(&mut app, &egui::Context::default(), "window.panel.brushes", json!({})).is_ok()); + let layout = app.ui.docking.as_ref().expect("brushes panel must be registered in docking"); + assert!(layout.contains(&"brushes".to_string())); + assert!(app.ui.panels.brushes); + assert_ne!(app.ui.docking, before); + + app.run("window.panel.float", json!({"panel":"brushes"})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().floating.iter().any(|group| group.panels.contains(&"brushes".into()))); + app.run("window.panel.dock", json!({"panel":"brushes"})).unwrap(); + assert!(!app.ui.docking.as_ref().unwrap().floating.iter().any(|group| group.panels.contains(&"brushes".into()))); +} + #[test] fn invalid_panel_requests_are_atomic_and_do_not_enter_a_custom_workspace() { let mut app = app(); @@ -95,6 +124,24 @@ fn ordinary_panel_tab_drag_enters_shared_docking_and_can_redock_into_another_tab assert!(valid(layout)); } +#[test] +fn brushes_tab_drag_floats_and_can_redock_into_another_tab_group() { + let mut app = app(); + crate::menus::invoke(&mut app, &egui::Context::default(), "window.panel.brushes", json!({})).unwrap(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let source = h.query_all_by_label("Brushes").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + drag(&mut h, source, egui::pos2(160.0, 140.0)); + let layout = h.state().ui.docking.as_ref().expect("brushes drag starts shared docking"); + assert!(layout.floating.iter().any(|group| group.panels.contains(&"brushes".into())), "{layout:?}"); + + let source = h.ctx.read_response(egui::Id::new("photocraft-panel-docking").with(("tab", &"brushes".to_string()))).unwrap().rect.center(); + let destination = h.ctx.read_response(egui::Id::new("photocraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); + drag(&mut h, source, destination); + let layout = h.state().ui.docking.as_ref().unwrap(); + assert!(!layout.floating.iter().any(|group| group.panels.contains(&"brushes".into()))); + assert!(valid(layout)); +} + #[test] fn saved_workspace_restores_the_custom_tree_and_hidden_panel_placement() { let mut app = app(); @@ -128,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 { @@ -164,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(); @@ -299,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(); @@ -535,6 +585,23 @@ fn explicit_reveal_activates_existing_tab_and_expands_custom_group() { assert_eq!(app.ui.docking, before); } +// #2455 in a custom workspace: the pro Color field fills a tall floating group, not 120 pt. +#[test] +fn pro_color_field_fills_a_tall_custom_group() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"color","x":40,"y":60,"width":320,"height":560})).unwrap(); + assert!(app.ui.docking.is_some()); + let h = harness(app, vec2(1280.0, 800.0)); + // The SV field and the 14 pt hue strip beside it are draggable, equally tall and 8 pt apart. + let drags: Vec = + h.ctx.viewport(|v| v.this_pass.widgets.layers().flat_map(|(_, w)| w).filter(|w| w.sense.senses_drag()).map(|w| w.rect).collect()); + let field = drags + .iter() + .find(|f| drags.iter().any(|s| s.width() == 14.0 && s.top() == f.top() && s.height() == f.height() && (s.left() - f.right() - 8.0).abs() < 0.5)) + .expect("Color field with its hue strip"); + assert!(field.height() > 300.0, "field stays short in a 560 pt group: {field:?}"); +} + // #4028: in a custom workspace too, a new adjustment layer brings Properties to the front. #[test] fn a_new_adjustment_layer_activates_properties_in_a_custom_workspace() { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 733f923c8a..04e775439e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -90,18 +90,16 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .exact_size(w) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(m, 8))) .show(ui, |ui| { - if t.pro { - // Photoshop's toolbar header chevrons switch between one and two columns. - let (cr, resp) = ui.allocate_exact_size(vec2(bx, 14.0), Sense::click()); - // The chevrons point the way the toolbar grows or shrinks: towards the canvas. - let (shrink, grow) = if crate::direction::is_rtl() { ("chevrons-right", "chevrons-left") } else { ("chevrons-left", "chevrons-right") }; - icons::paint(ui, cr, if double { shrink } else { grow }, 11.0, if resp.hovered() { t.text } else { t.text_faint }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Toolbar"))); - if resp.clicked() { - app.ui.panels.toolbar_double = !app.ui.panels.toolbar_double; - } - ui.add_space(4.0); + // Toolbar width is a user preference, not a Pro-only feature. + // Keep the same hit target in every theme (including RTL layouts). + let (cr, resp) = ui.allocate_exact_size(vec2(bx, 14.0), Sense::click()); + let (shrink, grow) = if crate::direction::is_rtl() { ("chevrons-right", "chevrons-left") } else { ("chevrons-left", "chevrons-right") }; + icons::paint(ui, cr, if double { shrink } else { grow }, 11.0, if resp.hovered() { t.text } else { t.text_faint }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Toolbar"))); + if resp.clicked() { + app.ui.panels.toolbar_double = !double; } + ui.add_space(4.0); // Subtle violet wash at the bottom of the toolbar. let full = ui.max_rect(); if !t.bevel && !t.pro && t.dark() { @@ -130,7 +128,8 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); let footer = (if t.pro { 8.0 } else { 14.0 }) + chips_height(t.pro, t.round_chips, t.round_chips && double) - + if t.pro { 8.0 + if double { bx } else { bx * 2.0 + 3.0 } } else { 0.0 } + + 8.0 + + if double { bx } else { bx * 2.0 + 3.0 } + 6.0; // As tall as the tools, so the colour chips sit right under them (Photoshop); a column // taller than the window scrolls and keeps the chips at the bottom. @@ -167,16 +166,16 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { paint_toolbar_flyout_indicator(ui, &t, &resp); } if resp.clicked() && ui.data(|d| d.get_temp::(held_id)) != Some(key) { - app.ui.tool = tool; + crate::tool_memory::select(app, tool); } // Double-clicking the Hand tool fits the image on screen (Photoshop). if resp.double_clicked() && tool == Tool::Hand { - app.ui.tool = tool; + crate::tool_memory::select(app, tool); // With no document open there is nothing to fit. let _ = app.run("view.fitOnScreen", json!({})); } if resp.double_clicked() && tool == Tool::Zoom { - app.ui.tool = tool; + crate::tool_memory::select(app, tool); let _ = app.run("view.actualPixels", json!({})); } // Right-click or long-press opens the flyout (Photoshop). @@ -206,7 +205,7 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } if slot.len() > 1 && long_press { // Holding a tool button selects the tool it shows, as a click would (#4453). - app.ui.tool = tool; + crate::tool_memory::select(app, tool); ui.ctx().request_repaint(); } if let Some((open_key, anchor)) = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)) @@ -268,7 +267,7 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ); } if ir.clicked() { - app.ui.tool = item; + crate::tool_memory::select(app, item); ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); } } @@ -289,81 +288,79 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } } - if t.pro && icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")).clicked() { + if icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")).clicked() { let _ = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})); } reveal_focused_control(ui); }); ui.add_space(if t.pro { 8.0 } else { 14.0 }); color_chips(app, ui, double); - if t.pro { - ui.add_space(8.0); + ui.add_space(8.0); - let layout = if double { egui::Layout::left_to_right(egui::Align::Min) } else { egui::Layout::top_down(egui::Align::Min) }; + let layout = if double { egui::Layout::left_to_right(egui::Align::Min) } else { egui::Layout::top_down(egui::Align::Min) }; - ui.with_layout(layout, |ui| { - let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some()); - if icons::button_with_icon_size_tip( - ui, - "square-dashed", - bx, - (bx * 0.52).round(), - quick_mask, - if quick_mask { tl!("Edit in Standard Mode (Q)") } else { tl!("Edit in Quick Mask Mode (Q)") }, - tool_tips, - ) - .clicked() - { - let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({})); - } - let sm = icons::button_with_icon_size_tip(ui, "app-window", bx, (bx * 0.52).round(), false, tl!("Change Screen Mode (F)"), tool_tips); - paint_toolbar_flyout_indicator(ui, &t, &sm); + ui.with_layout(layout, |ui| { + let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some()); + if icons::button_with_icon_size_tip( + ui, + "square-dashed", + bx, + (bx * 0.52).round(), + quick_mask, + if quick_mask { tl!("Edit in Standard Mode (Q)") } else { tl!("Edit in Quick Mask Mode (Q)") }, + tool_tips, + ) + .clicked() + { + let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({})); + } + let sm = icons::button_with_icon_size_tip(ui, "app-window", bx, (bx * 0.52).round(), false, tl!("Change Screen Mode (F)"), tool_tips); + paint_toolbar_flyout_indicator(ui, &t, &sm); - // Right-click, or click and hold like the tool flyouts (#4444), lists the modes, - // like Photoshop's flyout. The release that ends a hold doesn't cycle the mode. - let held_id = sm.id.with("held"); - let was_held = ui.data(|d| d.get_temp::(held_id)).unwrap_or(false); - let held_for = sm.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); - if let Some(seconds) = held_for - && seconds < 0.35 - { - ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); - } - let long_press = !was_held && held_for.is_some_and(|seconds| seconds >= 0.35); - if long_press { - ui.data_mut(|d| d.insert_temp(held_id, true)); - } else if was_held && !sm.is_pointer_button_down_on() { - ui.data_mut(|d| d.remove::(held_id)); - } - let cycle = sm.clicked() && !was_held; - if cycle { - let _ = crate::menus::invoke(app, &ctx, "view.screenMode.cycle", json!({})); - } - let open = if sm.secondary_clicked() || long_press { - Some(egui::SetOpenCommand::Bool(true)) - } else if cycle { - Some(egui::SetOpenCommand::Bool(false)) - } else { - None - }; - // The release ending the hold counts as a click; it mustn't close the list it opened. - let close = if was_held || long_press { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClick }; - egui::Popup::menu(&sm).open_memory(open).close_behavior(close).at_pointer_fixed().show(|ui| { - ui.set_min_width(220.0); - for (id, label) in [ - ("view.screenMode.standard", tl!("Standard Screen Mode")), - ("view.screenMode.fullScreenWithMenuBar", tl!("Full Screen Mode With Menu Bar")), - ("view.screenMode.fullScreen", tl!("Full Screen Mode")), - ] { - let on = crate::view_cmds::checked(app, id).unwrap_or(false); - if ui.add(egui::Button::selectable(on, label)).clicked() { - let _ = crate::menus::invoke(app, &ctx, id, json!({})); - ui.close(); - } + // Right-click, or click and hold like the tool flyouts (#4444), lists the modes, + // like Photoshop's flyout. The release that ends a hold doesn't cycle the mode. + let held_id = sm.id.with("held"); + let was_held = ui.data(|d| d.get_temp::(held_id)).unwrap_or(false); + let held_for = sm.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); + if let Some(seconds) = held_for + && seconds < 0.35 + { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); + } + let long_press = !was_held && held_for.is_some_and(|seconds| seconds >= 0.35); + if long_press { + ui.data_mut(|d| d.insert_temp(held_id, true)); + } else if was_held && !sm.is_pointer_button_down_on() { + ui.data_mut(|d| d.remove::(held_id)); + } + let cycle = sm.clicked() && !was_held; + if cycle { + let _ = crate::menus::invoke(app, &ctx, "view.screenMode.cycle", json!({})); + } + let open = if sm.secondary_clicked() || long_press { + Some(egui::SetOpenCommand::Bool(true)) + } else if cycle { + Some(egui::SetOpenCommand::Bool(false)) + } else { + None + }; + // The release ending the hold counts as a click; it mustn't close the list it opened. + let close = if was_held || long_press { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClick }; + egui::Popup::menu(&sm).open_memory(open).close_behavior(close).at_pointer_fixed().show(|ui| { + ui.set_min_width(220.0); + for (id, label) in [ + ("view.screenMode.standard", tl!("Standard Screen Mode")), + ("view.screenMode.fullScreenWithMenuBar", tl!("Full Screen Mode With Menu Bar")), + ("view.screenMode.fullScreen", tl!("Full Screen Mode")), + ] { + let on = crate::view_cmds::checked(app, id).unwrap_or(false); + if ui.add(egui::Button::selectable(on, label)).clicked() { + let _ = crate::menus::invoke(app, &ctx, id, json!({})); + ui.close(); } - }); + } }); - } + }); }); } @@ -548,10 +545,18 @@ fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_columns: bool) { } s.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Swap colours (X)"))); if s.on_hover_text(tl!("Swap colours (X)")).clicked() { - let _ = app.run("tools.swapColors", json!({})); + chip_command(app, "tools.swapColors"); } if d.is_some_and(|d| d.on_hover_text(tl!("Default colours (D)")).clicked()) { - let _ = app.run("tools.defaultColors", json!({})); + chip_command(app, "tools.defaultColors"); + } +} + +/// Run Switch Colors or Default Colors from the chips. As in Photoshop, characters highlighted +/// in the type layer being edited take the new foreground colour, like picking one (#5690). +fn chip_command(app: &mut PhotocraftApp, id: &str) { + if app.run(id, json!({})).is_ok() { + crate::type_tool::foreground_changed(app); } } @@ -596,7 +601,14 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); } - let title = app.session.active().map(|d| format!("{}{}", d.doc.name, if d.is_dirty() { " •" } else { "" })).unwrap_or_else(|| "PhotoCraft".into()); + // Preferences › Interface › Show File Name In Title: with it off the bar keeps the + // app name and the unsaved dot, since the tabs already name the file (#5037). + let show_name = app.session.prefs().interface.show_file_name_in_title; + let title = app + .session + .active() + .map(|d| format!("{}{}", if show_name { d.doc.name.as_str() } else { "PhotoCraft" }, if d.is_dirty() { " •" } else { "" })) + .unwrap_or_else(|| "PhotoCraft".into()); // The menus and the far-end controls are laid out first; the title is centred in // whatever room is left between them (`free`, left to right: menus then controls, or // controls then menus in a right-to-left UI), shortened or dropped rather than drawn @@ -646,9 +658,9 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ("Pixel Art".to_string(), tl!("Pixel Art")), ("Motion".to_string(), tl!("Motion")), ]; - // A narrow bar drops what is also in a menu, Discord first (below), then - // the theme toggle (Preferences), then search (Edit › Search), and narrows - // the switcher (Window › Workspace) before anything runs over. + // A narrow bar drops what is also in a menu, the theme toggle first + // (Preferences), then search (Edit › Search), and narrows the switcher + // (Window › Workspace) before anything runs over. let room = ui.available_width(); let icons_shown: u8 = if room >= WORKSPACE_MIN + 2.0 * ICON_SLOT { 2 @@ -680,25 +692,6 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } button.context_menu(|ui| crate::prefs_ui::appearance_context_menu(app, ui)); } - // The community Discord, one click away while the bar has room for it - // (narrow windows drop it first; it is also Help › Discord). - if ui.available_width() >= DISCORD_ROOM { - let discord = egui::Button::image_and_text( - icons::image("message-square", 14.0, t.text_dim), - egui::RichText::new(tl!("Discord")).color(t.text_dim).size(12.0), - ) - .frame(false); - if ui - .add(discord) - .on_hover_text({ - let template = "Join the ArtCraft Discord ({url})"; - crate::i18n::fmt(tl!(template), &[("url", crate::links::DISCORD)]) - }) - .clicked() - { - crate::links::open(app, ui.ctx(), crate::links::DISCORD); - } - } if rtl { ui.min_rect().right() } else { ui.min_rect().left() } }; free = if rtl { (controls_end, menus_end) } else { (menus_end, controls_end) }; @@ -707,6 +700,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) { @@ -738,8 +734,12 @@ pub fn system_title_bar(app: &PhotocraftApp) -> bool { /// The OS window title: the active document's name suffixed with the app name (a `*` prefix /// marks unsaved changes, like the `•` in the app-drawn title), or just the app name with no -/// document open. +/// document open. With Preferences › Interface › Show File Name In Title off, the file name +/// drops out and only the app name (and the `*`) remains (#5037). pub fn window_title(app: &PhotocraftApp) -> String { + if !app.session.prefs().interface.show_file_name_in_title { + return format!("{}PhotoCraft", if app.session.active().is_some_and(|d| d.is_dirty()) { "*" } else { "" }); + } app.session.active().map(|d| format!("{}{} \u{2014} PhotoCraft", if d.is_dirty() { "*" } else { "" }, d.doc.name)).unwrap_or_else(|| "PhotoCraft".into()) } @@ -755,8 +755,6 @@ pub fn sync_window_title(app: &mut PhotocraftApp, ctx: &egui::Context) { /// Minimum space kept between the window title and the menus or controls beside it. const TITLE_GAP: f32 = 16.0; -/// Free room the title bar needs after its other controls to show the Discord button. -const DISCORD_ROOM: f32 = 120.0; /// The workspace switcher's narrowest width, and one 28 pt title-bar icon with its spacing. const WORKSPACE_MIN: f32 = 90.0; const ICON_SLOT: f32 = 34.0; @@ -832,6 +830,40 @@ mod title_tests { assert_eq!(window_title(&app), "*foo.psd \u{2014} PhotoCraft"); } + #[test] + fn hiding_the_file_name_keeps_the_app_name_and_the_dirty_mark() { + let mut app = app(true); + open_named(&mut app, "foo.psd"); + app.session.prefs.edit(|p| p.interface.show_file_name_in_title = false); + assert_eq!(window_title(&app), "PhotoCraft"); + if let Some(st) = app.session.active_mut() { + st.revision += 1; + } + assert_eq!(window_title(&app), "*PhotoCraft"); + } + + #[test] + fn hidden_file_name_leaves_the_app_name_in_the_drawn_title() { + let mut app = app(true); + open_named(&mut app, "foo.psd"); + let texts = |app: &mut crate::PhotocraftApp| { + let ctx = egui::Context::default(); + crate::PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::title_bar(app, ui)); + out.textures_delta.clear(); + let mut texts = Vec::new(); + for shape in &out.shapes { + collect_text(&shape.shape, &mut texts); + } + texts + }; + assert!(texts(&mut app).iter().any(|t| t.contains("foo.psd")), "shown by default"); + app.session.prefs.edit(|p| p.interface.show_file_name_in_title = false); + let hidden = texts(&mut app); + assert!(!hidden.iter().any(|t| t.contains("foo.psd")), "file name hidden: {hidden:?}"); + assert!(hidden.iter().any(|t| t.contains("PhotoCraft")), "app name kept: {hidden:?}"); + } + #[test] fn system_mode_hides_the_mark_and_the_centred_title() { for custom in [true, false] { @@ -1065,7 +1097,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let block = crate::eraser_ui::block_mode(app, app.ui.tool); ui.add_enabled_ui(!block, |ui| { opt_label(ui, tl!("Size")); - widgets::value_field(ui, &mut b.size, 1.0..=2500.0, "px", 76.0); + widgets::value_field(ui, &mut b.size, 1.0..=photocraft_engine::paint::MAX_BRUSH_SIZE, "px", 76.0); }); widgets::vline(ui, 22.0); if app.ui.tool == Tool::Eraser { @@ -1208,7 +1240,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::value_field(ui, &mut app.ui.tool_options.tolerance, 0.0..=255.0, "", 58.0); widgets::checkbox(ui, &mut app.ui.tool_options.anti_alias, tl!("Anti-alias")); widgets::checkbox(ui, &mut app.ui.tool_options.contiguous, tl!("Contiguous")); - widgets::checkbox(ui, &mut app.ui.tool_options.sample_all_layers, tl!("Sample All Layers")); + widgets::checkbox(ui, &mut app.ui.tool_options.sample_all.magic_wand, tl!("Sample All Layers")); widgets::vline(ui, 22.0); if widgets::secondary_button(ui, tl!("Select Subject"), 0.0).clicked() { let r = app.run("select.subject", json!({})); @@ -1230,7 +1262,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::value_field(ui, &mut app.ui.tool_options.tolerance, 0.0..=255.0, "", 58.0); widgets::checkbox(ui, &mut app.ui.tool_options.anti_alias, tl!("Anti-alias")); widgets::checkbox(ui, &mut app.ui.tool_options.contiguous, tl!("Contiguous")); - widgets::checkbox(ui, &mut app.ui.tool_options.sample_all_layers, tl!("All Layers")); + widgets::checkbox(ui, &mut app.ui.tool_options.sample_all.paint_bucket, tl!("All Layers")); } Tool::Gradient => { // Live "Gradient" (a Gradient Fill layer) or "Classic gradient" (pixels). @@ -1294,9 +1326,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); } @@ -1316,6 +1349,16 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::crop_overlay::options_button(o, ui); let pick_shield_color = crate::crop_shield::options_button(&mut o.crop_shield, ui); widgets::checkbox(ui, &mut o.crop_delete, tl!("Delete Cropped Pixels")); + // Fill: what a turned or extending crop puts where the image doesn't reach + // (#3823). Generative Expand is not offered. + ui.label(tl!("Fill:")); + widgets::dropdown( + ui, + "crop-fill", + &mut o.crop_content_aware, + &[(false, tl!("Background (Default)")), (true, tl!("Content-Aware Fill"))], + 150.0, + ); if crate::crop_ui::preset_ratio(app) != before_ratio { crate::crop_ui::apply_ratio(app); } @@ -1404,6 +1447,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { Tool::Move => { // The Studio themes keep Photoshop's Auto-Select toggle too (#1275). widgets::checkbox(ui, &mut app.ui.tool_options.move_auto_select, tl!("Auto-Select:")); + // And Show Transform Controls (#5740): without it the box could not be + // turned on outside the Pro themes. + widgets::checkbox(ui, &mut app.ui.tool_options.move_show_transform, tl!("Show Transform Controls")); hint(ui, tl!("Drag to move the active layer")); } Tool::Eyedropper => crate::eyedropper_ui::options(app, ui), @@ -1520,8 +1566,9 @@ const LAYER_PCT_W: f32 = 66.0 + widgets::POPUP_ARROW_W; /// The `layer.setProps` edit of a Layers panel Opacity or Fill (`key`) field at `pct` percent. A /// drag of the pop-up slider (`drag`) coalesces into one history step, as in Photoshop. -fn pct_action(l: &Layer, key: &str, pct: f32, drag: Option) -> (String, Value) { - let mut p = json!({"layer": l.id.0, key: pct / 100.0}); +fn pct_action(target: &Value, key: &str, pct: f32, drag: Option) -> (String, Value) { + let mut p = target.clone(); + p[key] = json!(pct / 100.0); if let Some(n) = drag { p["coalesce"] = json!(format!("layer-{key}-slider:{n}")); } @@ -1574,8 +1621,8 @@ fn body_text_width(ui: &egui::Ui, s: &str) -> f32 { fn opt_label(ui: &mut egui::Ui, s: &str) { let t = Tokens::get(ui.ctx()); let text = if t.pro && !s.ends_with(':') { format!("{s}:") } else { s.to_string() }; - let label = ui.label(RichText::new(tl!(&text)).color(t.text_dim)); - widgets::scrub_label(ui, &label); + let label = ui.add(egui::Label::new(RichText::new(tl!(&text)).color(t.text_dim)).selectable(false)); + widgets::scrub_option_label(ui, &label); } fn percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive, width: f32) { @@ -1614,6 +1661,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; } @@ -1654,6 +1702,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); }); }); }); @@ -1683,12 +1732,13 @@ pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.painter().line_segment([pos2(x, r.top() - 8.0), pos2(x, r.bottom() + 8.0)], Stroke::new(1.0, t.separator)); ui.spacing_mut().item_spacing.y = 4.0; use crate::dock::Group; - let entries: [(&str, &str, Group); 5] = [ + let entries: [(&str, &str, Group); 6] = [ ("sliders-horizontal", tl!("Properties"), Group::Properties), ("navigation", tl!("Navigator"), Group::Navigator), ("palette", tl!("Color & Swatches"), Group::Color), ("layers", tl!("Layers"), Group::Layers), ("clock", tl!("History"), Group::History), + ("brush", tl!("Brushes"), Group::Brushes), ]; for (icon, name, g) in entries { // Studio floats Properties outside the dock. @@ -1733,6 +1783,7 @@ fn dock_panels(app: &mut PhotocraftApp, ui: &mut egui::Ui, p: &crate::state::Pan (Group::Navigator, p.navigator), (Group::History, p.history), (Group::Layers, p.layers), + (Group::Brushes, p.brushes), ] .into_iter() .filter_map(|(g, on)| on.then_some(g)) @@ -1774,6 +1825,7 @@ pub(crate) fn dock_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, group: crate (Group::Layers, 0) => layers(app, ui), (Group::Layers, 1) => channels(app, ui), (Group::Layers, _) => crate::vector_ui::paths_panel(app, ui), + (Group::Brushes, _) => crate::brushes_tab::show(app, ui), } } @@ -1796,6 +1848,13 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .hover_doc .map(|p| (p[0].floor() as i32, p[1].floor() as i32)) .filter(|(x, y)| *x >= 0 && *y >= 0 && *x < size.width as i32 && *y < size.height as i32); + // While a guide is dragged (#5728), the Info panel shows its position in one axis only, even + // when the pointer is outside the canvas (ruler drags do not update `hover_doc` there). + let (guide_x, guide_y) = match app.guide_drag { + Some(d) if d.vertical => (Some(d.pos), None), + Some(d) => (None, Some(d.pos)), + None => (None, None), + }; // The readout averages the Eyedropper's Sample Size, as in Photoshop (#1649). let sample_size = app.ui.tool_options.eyedropper_size; let rgba = pos.and_then(|(x, y)| { @@ -1834,8 +1893,8 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.columns(2, |cols| { let dash = || "—".to_string(); // Measured from the rulers' zero point (#2955). - row(&mut cols[0], "X:", pos.map_or_else(dash, |p| fx(p.0 as f64 - origin[0]))); - row(&mut cols[0], "Y:", pos.map_or_else(dash, |p| fy(p.1 as f64 - origin[1]))); + row(&mut cols[0], "X:", guide_x.map(|p| fx(p - origin[0])).or_else(|| pos.map(|p| fx(p.0 as f64 - origin[0]))).unwrap_or_else(dash)); + row(&mut cols[0], "Y:", guide_y.map(|p| fy(p - origin[1])).or_else(|| pos.map(|p| fy(p.1 as f64 - origin[1]))).unwrap_or_else(dash)); row(&mut cols[1], "W:", sel.map_or_else(dash, |r| fx(r.width() as f64))); row(&mut cols[1], "H:", sel.map_or_else(dash, |r| fy(r.height() as f64))); }); @@ -1847,14 +1906,13 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ); // Color Sampler points (#1046): each under its number, with its position in the current ruler // unit and the same RGB/CMYK readout as the pointer (one composite pixel each). - let samplers = app.session.execute("view.colorSamplers.list", json!({})).ok(); - let samplers = samplers.as_ref().and_then(|v| v["samplers"].as_array()).cloned().unwrap_or_default(); + let samplers = sampler_readouts(app); if !samplers.is_empty() { widgets::hairline(ui); let cell = |ui: &mut egui::Ui, s: String| { ui.label(RichText::new(s).font(mono.clone()).color(t.text)); }; - for s in &samplers { + for s in samplers.iter() { let n = s["number"].as_i64().unwrap_or(0); let at = s["position"] .as_array() @@ -1877,6 +1935,25 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// The Info panel's Color Sampler readouts and the (document, revision) they were composited for. +pub(crate) type SamplerReadouts = ((photocraft_doc::DocId, u64), std::sync::Arc>); + +/// The Color Sampler readouts of the active document, composited once per revision (#4528): +/// adding, moving or deleting a sampler and every pixel edit bump the revision, so between them +/// the Info panel reuses them instead of compositing each point through the stack every frame. +pub(crate) fn sampler_readouts(app: &mut PhotocraftApp) -> std::sync::Arc> { + let Some(key) = app.session.active().map(|s| (s.doc.id, s.revision)) else { return std::sync::Arc::default() }; + if let Some((k, v)) = &app.info_samplers + && *k == key + { + return v.clone(); + } + let list = app.session.execute("view.colorSamplers.list", json!({})).ok(); + let v = std::sync::Arc::new(list.as_ref().and_then(|v| v["samplers"].as_array()).cloned().unwrap_or_default()); + app.info_samplers = Some((key, v.clone())); + v +} + fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(idx) = app.session.active_index() else { @@ -1906,20 +1983,51 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.painter().image(tex, rect, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); } let s = rect.width() / size.width.max(1) as f32; - if (resp.clicked() || resp.dragged()) + // ⌘/Ctrl-drag over the preview draws a rectangle the view then zooms to, as in Photoshop + // (#6872); a plain click or drag moves the view. + let command = crate::workspace_ui::sticky_mods(app, ctx.input(|i| i.modifiers)).command; + if resp.drag_started() && command { + let start = ctx.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()); + ui.data_mut(|d| match start { + Some(p) => { + d.insert_temp(navigator_zoom_drag_id(), p); + } + None => d.remove::(navigator_zoom_drag_id()), + }); + } + let zoom_from = ui.data(|d| d.get_temp::(navigator_zoom_drag_id())); + if let Some(from) = zoom_from { + if let Some(to) = resp.interact_pointer_pos() { + let r = Rect::from_two_pos(from, to).intersect(rect); + crate::tool_feedback::draw_ants(ui.painter(), &[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()], true); + if resp.drag_stopped() { + let doc_rect = Rect::from_min_max(((r.min - rect.min) / s).to_pos2(), ((r.max - rect.min) / s).to_pos2()); + let (canvas, ppp, aspect) = (app.last_canvas_rect, app.canvas_ppp(), app.ui.view.display_aspect()); + navigator_zoom_to(&mut app.ui.views[idx], doc_rect, [size.width, size.height], canvas.size(), ppp, aspect); + } + } + if resp.drag_stopped() || !resp.dragged() { + ui.data_mut(|d| d.remove::(navigator_zoom_drag_id())); + } + } else if (resp.clicked() || resp.dragged()) && let Some(pp) = resp.interact_pointer_pos() { let d = (pp - rect.min) / s; app.ui.views[idx].center = [d.x.clamp(0.0, size.width as f32), d.y.clamp(0.0, size.height as f32)]; app.ui.views[idx].fit_pending = false; } + if command && resp.hovered() { + ctx.set_cursor_icon(egui::CursorIcon::ZoomIn); + } // The Navigator has its own zoom target: an unmodified wheel over the preview // should zoom even when the document canvas is configured to scroll on wheel. // Use the canvas wheel classifier to honour one ×1.1 step per notch without // applying egui's smoothed wheel tail a second time. let pinch_zooms = app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch; + let touch_gestures = app.session.prefs().enhanced_controls.touch_gestures; if resp.hovered() - && let Some(crate::wheel_nav::Wheel::Zoom(factor)) = crate::wheel_nav::read(&ctx, true, pinch_zooms) + && let Some(crate::wheel_nav::Wheel::Zoom(factor)) = + crate::wheel_nav::read_with(&ctx, true, pinch_zooms, egui::Id::new("pc-navigator-wheel"), true, true, touch_gestures) { let view = &mut app.ui.views[idx]; let next = crate::zoom_levels::clamp(view.zoom * factor, view.doc_size); @@ -1991,6 +2099,42 @@ fn navigator_fit(frame: Rect, w: u32, h: u32) -> Rect { Rect::from_center_size(frame.center(), vec2(w * s, h * s)) } +/// Where a ⌘/Ctrl-drag over the Navigator's preview started, while it lasts. +fn navigator_zoom_drag_id() -> egui::Id { + egui::Id::new("navigator-zoom-drag") +} + +/// Zoom `view` so the document rectangle `doc_rect` (pixels) fills a canvas of `canvas` points, +/// centred on it, as a Navigator ⌘/Ctrl-drag does (#6872). `doc` is the document's size in +/// pixels (the view's own copy is only set once the canvas has been drawn), `ppp` the canvas's +/// device pixels per point and `aspect` the pixel aspect ratio shown; the zoom stays within the +/// zoom range. A rectangle under 2 document pixels across zooms one step in on its centre. +fn navigator_zoom_to(view: &mut crate::state::View, doc_rect: Rect, doc: [u32; 2], canvas: egui::Vec2, ppp: f32, aspect: f32) { + // Checked before clipping: `min`/`max` would turn a NaN edge into a document edge. + if ![doc_rect.min.x, doc_rect.min.y, doc_rect.max.x, doc_rect.max.y].iter().all(|v| v.is_finite()) { + return; + } + let (w, h) = (doc[0] as f32, doc[1] as f32); + let r = doc_rect.intersect(Rect::from_min_size(Pos2::ZERO, vec2(w, h))); + if r.width() < 0.0 || r.height() < 0.0 { + return; + } + let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 }; + let zoom = if r.width() < 2.0 || r.height() < 2.0 || canvas.x <= 0.0 || canvas.y <= 0.0 { + crate::zoom_levels::step(view.zoom, 1, view.doc_size) + } else { + // Document columns shown across the canvas are `canvas.x / (point_zoom * aspect)`. + let point_zoom = (canvas.x / (r.width() * aspect)).min(canvas.y / r.height()); + point_zoom * ppp + }; + if !zoom.is_finite() { + return; + } + view.zoom = crate::zoom_levels::clamp(zoom, view.doc_size); + view.center = [r.center().x, r.center().y]; + view.fit_pending = false; +} + fn navigator_preview_id() -> egui::Id { egui::Id::new("navigator-preview-rect") } @@ -2053,6 +2197,101 @@ mod navigator_wheel_tests { assert!((app.ui.views[0].zoom - 1.0).abs() < 1e-4, "wheel outside preview leaves zoom unchanged"); } + /// #6872: the zoom a Navigator ⌘/Ctrl-drag sets: the rectangle fills the canvas, centred. + #[test] + fn navigator_zoom_to_fits_the_rectangle_in_the_canvas() { + let fresh = || crate::state::View { doc_size: [200, 100], zoom: 1.0, center: [100.0, 50.0], ..Default::default() }; + let canvas = vec2(800.0, 600.0); + let r = Rect::from_min_max(pos2(50.0, 25.0), pos2(100.0, 50.0)); + // 50 × 25 px on 800 × 600 pt: the width limits it, 16 points per pixel. + let mut v = fresh(); + navigator_zoom_to(&mut v, r, [200, 100], canvas, 1.0, 1.0); + assert_eq!((v.zoom, v.center, v.fit_pending), (crate::zoom_levels::clamp(16.0, [200, 100]), [75.0, 37.5], false)); + // Device pixels per point and a 2:1 pixel aspect scale it as the canvas shows pixels. + let mut v = fresh(); + navigator_zoom_to(&mut v, r, [200, 100], canvas, 2.0, 2.0); + assert_eq!(v.zoom, crate::zoom_levels::clamp(16.0, [200, 100]), "8 points per pixel across, at 2 device pixels per point"); + // Off the document: only the part on it counts. + let mut v = fresh(); + navigator_zoom_to(&mut v, Rect::from_min_max(pos2(150.0, 50.0), pos2(400.0, 300.0)), [200, 100], canvas, 1.0, 1.0); + assert_eq!(v.center, [175.0, 75.0]); + // Under 2 px across: one step in on its centre. + let mut v = fresh(); + navigator_zoom_to(&mut v, Rect::from_min_max(pos2(10.0, 10.0), pos2(11.0, 40.0)), [200, 100], canvas, 1.0, 1.0); + assert_eq!((v.zoom, v.center), (crate::zoom_levels::step(1.0, 1, [200, 100]), [10.5, 25.0])); + // Not a number, or wholly off the document: nothing changes. + for bad in [Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(10.0, 10.0)), Rect::from_min_max(pos2(300.0, 300.0), pos2(400.0, 400.0))] { + let mut v = fresh(); + navigator_zoom_to(&mut v, bad, [200, 100], canvas, 1.0, 1.0); + assert_eq!((v.zoom, v.center), (1.0, [100.0, 50.0]), "{bad:?}"); + } + } + + /// One frame of the Navigator with pointer `events` and `modifiers` held. + fn frame_with(app: &mut PhotocraftApp, ctx: &egui::Context, modifiers: Modifiers, mut events: Vec) { + events.insert(0, Event::ModifiersChanged(modifiers)); + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(430.0, 460.0))), events, ..Default::default() }; + ctx.run_ui(input, |ui| navigator(app, ui)).textures_delta.clear(); + } + + /// A drag over the Navigator's preview between document points `from` and `to`. + fn drag(app: &mut PhotocraftApp, ctx: &egui::Context, modifiers: Modifiers, from: [f32; 2], to: [f32; 2]) { + let preview = last_navigator_preview(ctx).expect("the preview"); + let s = preview.width() / 200.0; + let at = |p: [f32; 2]| preview.min + vec2(p[0], p[1]) * s; + let (a, b) = (at(from), at(to)); + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + frame_with(app, ctx, modifiers, vec![Event::PointerMoved(a), button(a, true)]); + frame_with(app, ctx, modifiers, vec![Event::PointerMoved(a.lerp(b, 0.5))]); + frame_with(app, ctx, modifiers, vec![Event::PointerMoved(b)]); + frame_with(app, ctx, modifiers, vec![button(b, false)]); + frame_with(app, ctx, Modifiers::NONE, vec![]); + } + + /// #6872: ⌘/Ctrl-dragging a rectangle over the preview zooms the view to it, as in + /// Photoshop; a plain drag still only moves the view. + #[test] + fn navigator_command_drag_zooms_to_the_rectangle() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + // The preview settles once the zoom row under it has been measured. + for _ in 0..3 { + frame(&mut app, &ctx, pos2(5.0, 5.0), 0.0); + } + // A plain drag pans and keeps the zoom. + drag(&mut app, &ctx, Modifiers::NONE, [50.0, 25.0], [100.0, 50.0]); + assert_eq!(app.ui.views[0].zoom, 1.0); + let c = app.ui.views[0].center; + assert!((c[0] - 100.0).abs() < 1.0 && (c[1] - 50.0).abs() < 1.0, "the view follows the pointer: {c:?}"); + // ⌘/Ctrl-drag: the rectangle fills the canvas (`last_canvas_rect`, 800 × 600 pt). + drag(&mut app, &ctx, Modifiers::COMMAND, [50.0, 25.0], [100.0, 50.0]); + let mut expected = app.ui.views[0].clone(); + navigator_zoom_to( + &mut expected, + Rect::from_min_max(pos2(50.0, 25.0), pos2(100.0, 50.0)), + [200, 100], + vec2(800.0, 600.0), + app.canvas_ppp(), + app.ui.view.display_aspect(), + ); + let v = &app.ui.views[0]; + assert!((v.zoom - expected.zoom).abs() / expected.zoom < 0.05, "{} vs {}", v.zoom, expected.zoom); + assert!( + (v.center[0] - 75.0).abs() < 1.0 && (v.center[1] - 37.5).abs() < 1.0, + "centred on the rectangle: {:?} (zoom {}, expected {:?})", + v.center, + v.zoom, + (expected.zoom, expected.center) + ); + assert!(v.zoom > 1.0 && !v.fit_pending); + assert!(ctx.data(|d| d.get_temp::(navigator_zoom_drag_id())).is_none(), "the drag is over"); + } + /// #4840: under the preview, one row as in Photoshop: the zoom in percent, Zoom Out, the /// slider and Zoom In. The buttons step the zoom like View › Zoom In / Out. #[test] @@ -2133,13 +2372,17 @@ fn hsb_sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui) { /// any is set, and locks all otherwise. Toggling only "lock all" would leave the Background's /// transparency lock (still showing as locked), and a second click would lock its pixels, /// shutting out the Eraser and every other paint tool (#76). -fn simple_lock_toggle(background: bool, l: &Layer) -> (String, Value) { +/// The Basic layout's lock toggle: the active layer's state decides, and every target follows it +/// (`targets`: `{"layer": id}` or `{"layers": [ids]}`). +fn simple_lock_toggle(background: bool, l: &Layer, targets: &Value) -> (String, Value) { if background { return ("layer.new.layerFromBackground".into(), json!({})); } let locks = if l.locks.any() { json!({"transparency": false, "pixels": false, "position": false, "artboard": false, "all": false}) } else { json!({"all": true}) }; - ("layer.setProps".into(), json!({"layer": l.id.0, "locks": locks})) + let mut p = targets.clone(); + p["locks"] = locks; + ("layer.setProps".into(), p) } /// The Layers panel's blend modes: Photoshop's, plus the layer's own Paint.NET mode when it has one @@ -2155,7 +2398,7 @@ fn blend_options(groups: bool, current: BlendMode) -> Vec<(BlendMode, &'static s /// list downward (reveals rows above) and negative upward (reveals rows below). /// The speed ramps with proximity to the edge and clamps to the maximum speed when /// dragged outside, using elapsed time instead of assuming a particular refresh rate. -fn layer_drag_edge_scroll(pointer: Option, viewport: Rect, dragging: bool, dt: f32) -> f32 { +pub(crate) fn layer_drag_edge_scroll(pointer: Option, viewport: Rect, dragging: bool, dt: f32) -> f32 { if !dragging || viewport.width() <= 0.0 || viewport.height() <= 0.0 || dt.is_nan() || dt <= 0.0 { return 0.0; } @@ -2201,36 +2444,25 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); if t.pro { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; - let (r, _) = ui.allocate_exact_size(vec2(64.0, 22.0), Sense::hover()); - ui.painter().rect_filled(r, t.radius_sm, t.field); - ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside); - icons::paint(ui, Rect::from_center_size(r.left_center() + vec2(11.0, 0.0), vec2(14.0, 14.0)), "search", 11.0, t.text_faint); - ui.painter().text(r.left_center() + vec2(22.0, 0.0), Align2::LEFT_CENTER, tl!("Kind"), egui::FontId::proportional(11.5), t.text_dim); - ui.add_space(4.0); - for (kind, icon, tip) in [ - ("pixel", "image", tl!("Filter for pixel layers")), - ("adjustment", "adjustment-layer", tl!("Filter for adjustment layers")), - ("type", "type", tl!("Filter for type layers")), - ("shape", "square", tl!("Filter for shape layers")), - ("smart", "app-window", tl!("Filter for smart objects")), - ] { - let on = app.ui.layer_filter.iter().any(|k| k == kind); - if icons::button(ui, icon, 22.0, on, tip).clicked() { - if on { - app.ui.layer_filter.retain(|k| k != kind); - } else { - app.ui.layer_filter.push(kind.to_string()); - } - } - } - }); + crate::layer_filter_ui::bar(app, ui); ui.add_space(4.0); } if let Some(l) = active_layer { - // Photoshop greys blend mode, Opacity and Fill for the Background layer. + // With several layers selected, blend mode, Opacity, Fill and the locks change every one + // of them, as in Photoshop; the controls show the active layer's values. + // The Background stays out of a several-layer change until Photoshop's behaviour is + // confirmed (#5856): Opacity, Fill or a blend mode would turn it into Layer 0. + let ids: Vec = if selection.len() > 1 && selection.contains(&l.id) { + selection.iter().copied().filter(|id| *id == l.id || !doc.layer(*id).is_some_and(|x| crate::doc_props_ui::is_background(&doc, x))).collect() + } else { + vec![l.id] + }; + let targets = if ids.len() > 1 { json!({"layers": ids.iter().map(|id| id.0).collect::>()}) } else { json!({"layer": l.id.0}) }; + // Photoshop greys blend mode, Opacity and Fill for the Background layer. (With the + // Background among several selected, what Photoshop does awaits a check: #5856.) let bg = crate::doc_props_ui::is_background(&doc, l); + // Pass Through is a group's mode: offered only when every target is a group. + let all_groups = ids.iter().all(|id| doc.layer(*id).is_some_and(|x| x.is_group())); ui.horizontal(|ui| { ui.add_enabled_ui(!bg, |ui| { let mut m = l.blend; @@ -2240,13 +2472,15 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let opacity_label = if t.pro { tl!("Opacity:") } else { tl!("Opacity") }; let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x); - let (chosen, hovered) = widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &blend_options(l.is_group(), l.blend), blend_width); + let (chosen, hovered) = widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &blend_options(all_groups, l.blend), blend_width); // One step per choice: a click, an arrow key or each wheel notch (#1747). for m in &chosen { - actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "blend": m.label()}))); + let mut p = targets.clone(); + p["blend"] = json!(m.label()); + actions.push(("layer.setProps".into(), p)); } // Hovering a mode previews it on the canvas (#970). - crate::blend_preview::hover(app, l.id, hovered.filter(|_| chosen.is_empty())); + crate::blend_preview::hover(app, &ids, hovered.filter(|_| chosen.is_empty())); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let mut o = l.opacity * 100.0; let field = widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W); @@ -2257,7 +2491,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::PopupFieldResponse::default() }; if field.changed || scrub.changed { - actions.push(pct_action(l, "opacity", o, scrub.drag.or(field.drag))); + actions.push(pct_action(&targets, "opacity", o, scrub.drag.or(field.drag))); } }); }); @@ -2293,7 +2527,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Lock Image Pixels includes Lock Transparent Pixels, which waits for it (#4892). let enabled = key != "transparency" || !lk.pixels; if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 20.0, on, tip)).inner.clicked() { - actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "locks": { key: !on }}))); + let mut p = targets.clone(); + p["locks"] = json!({ key: !on }); + actions.push(("layer.setProps".into(), p)); } } }); @@ -2301,7 +2537,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if !t.pro { let locked = l.locks.any(); if icons::button(ui, if locked { "lock" } else { "lock-open" }, 22.0, locked, tl!("Lock layer")).clicked() { - actions.push(simple_lock_toggle(bg, l)); + actions.push(simple_lock_toggle(crate::doc_props_ui::is_background(&doc, l), l, &targets)); } } ui.add_enabled_ui(!bg, |ui| { @@ -2310,7 +2546,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let field = widgets::popup_value_field(ui, fill_label, &mut f, 0.0..=100.0, "%", LAYER_PCT_W); let scrub = scrub_pct_label(ui, fill_label, &mut f, ui.available_width()); if field.changed || scrub.changed { - actions.push(pct_action(l, "fill", f, scrub.drag.or(field.drag))); + actions.push(pct_action(&targets, "fill", f, scrub.drag.or(field.drag))); } }) }); @@ -2325,7 +2561,8 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let fill = ui.available_height() > footer + 60.0; let rows_h = if fill { ui.available_height() - footer } else { f32::INFINITY }; egui::ScrollArea::vertical() - .id_salt("layer-rows") + // Keep each document's position independent, even when tab indices change (#5522). + .id_salt(("layer-rows", doc.id.0)) .max_height(rows_h) .min_scrolled_height(if fill { rows_h } else { 0.0 }) .auto_shrink([false, !fill]) @@ -2350,7 +2587,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.scroll_with_delta(vec2(0.0, delta)); ctx.request_repaint(); } - let filter = app.ui.layer_filter.clone(); + let filter = crate::layer_filter_ui::Filter::of(&app.ui); let fx_collapsed = app.session.active().map(|d| d.fx_collapsed.clone()).unwrap_or_default(); crate::layer_row_ui::begin(ui.ctx()); // The drawn rows, top to bottom (#1721): the space between them and below the last @@ -2361,18 +2598,8 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if !photocraft_engine::select_extra_cmds::isolation_shows(&doc, &isolated, l.id) { continue; } - if !filter.is_empty() { - let kind = match &l.content { - LayerContent::Raster(_) => "pixel", - LayerContent::Adjustment(_) | LayerContent::Fill(_) => "adjustment", - LayerContent::Text(_) => "type", - LayerContent::Shape(_) => "shape", - LayerContent::Smart(_) => "smart", - LayerContent::Group(_) => "", - }; - if !filter.iter().any(|k| k == kind) { - continue; - } + if !filter.shows(l) { + continue; } let row = RowSel { selected: selection.contains(&l.id), primary: active == Some(l.id), multi: selection.len() > 1 }; let top = ui.cursor().top(); @@ -2565,6 +2792,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } }); for (id, p) in actions { + // A row click commits even when it selects the layer already being edited (#5584). + // Finish before selection changes so the existing type session keeps its own target. + if id == "layer.select" && app.ui.text_edit.is_some() { + crate::type_tool::commit(app); + } if id == "ui.maskTarget" { app.ui.mask_target = p.as_bool().unwrap_or(false); app.ui.vector_mask_target = false; @@ -2584,10 +2816,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; } @@ -2879,7 +3113,7 @@ fn layer_row( if let Some(&(_, lock)) = indicators.iter().find(|(k, _)| *k == crate::layer_row_ui::Indicator::Lock) && ui.interact(lock, ui.id().with(("row-lock", l.id.0)), Sense::click()).clicked() { - actions.push(simple_lock_toggle(doc.is_background(l.id), l)); + actions.push(simple_lock_toggle(doc.is_background(l.id), l, &json!({"layer": l.id.0}))); } let name_color = if l.visible { t.text } else { t.text_faint }; let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) }; @@ -2909,7 +3143,9 @@ fn layer_row( if let Some(kind) = masks.hit(p) { return Some(crate::mask_thumbs_ui::load_params(l, kind, m)); } - thumb.expand(2.0).contains(p).then(|| json!({"channel": "transparency", "layer": l.id.0, "operation": crate::channels_panel::load_operation(m)})) + // The Background has no transparency to load: Photoshop treats the click as a plain one. + (!doc.is_background(l.id) && thumb.expand(2.0).contains(p)) + .then(|| json!({"channel": "transparency", "layer": l.id.0, "operation": crate::channels_panel::load_operation(m)})) }); // ⇧-click a mask thumbnail: disable / enable that mask; ⌥-click a layer mask: view it. let mask_toggle = resp.clicked().then(|| (ui.input(|i| i.modifiers), resp.interact_pointer_pos())).and_then(|(m, pos)| { @@ -2921,14 +3157,17 @@ fn layer_row( } else if let Some(cmd) = mask_toggle { actions.push(cmd); } else if resp.clicked() && !eye_resp.clicked() && !toggled && !fx_toggled && !masks.clicked { - let mode = select_mode(ui.input(|i| i.modifiers)); + let m = ui.input(|i| i.modifiers); + let pos = resp.interact_pointer_pos(); + let on_thumb = pos.is_some_and(|p| thumb.expand(2.0).contains(p)); + // ⌘-click on the Background thumbnail stands in for the plain click (there is no + // transparency to load): it selects the Background alone, not toggled into the set. + let mode = if m.command && on_thumb && doc.is_background(l.id) { "replace" } else { select_mode(m) }; actions.push(("layer.select".into(), json!({"layer": l.id.0, "mode": mode}))); crate::smart_ui::deselect(ctx, doc.id); // A thumbnail explicitly selects content or a mask. Selecting an adjustment/fill row // defaults to its mask, but clicking its content thumbnail must leave the mask target. - let pos = resp.interact_pointer_pos(); let on_mask = mask_rect.zip(pos).is_some_and(|(r, p)| r.expand(2.0).contains(p)); - let on_thumb = pos.is_some_and(|p| thumb.expand(2.0).contains(p)); let on_vector = pos.and_then(|p| masks.hit(p)) == Some(crate::mask_thumbs_ui::MaskKind::Vector); // Clicking the layer thumbnail leaves mask view (#196). let viewing = app.session.active().and_then(photocraft_engine::mask_view_cmds::current).is_some_and(|v| v.layer == l.id); @@ -2951,7 +3190,6 @@ fn layer_row( actions.push(("ui.maskTarget".into(), json!(false))); } // ⌥-click the layer thumbnail: fit the layer on screen, as Photoshop does. - let m = ui.input(|i| i.modifiers); if on_thumb && m.alt && !m.shift && !m.command { actions.push(("view.fitLayersOnScreen".into(), Value::Null)); } @@ -3059,10 +3297,11 @@ fn draw_layer_thumb(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &egui::Ui, _ => { // Keep row layout and outside thumbnail decorations when the image is clipped. if ui.is_rect_visible(rect) { - // The texture is a letterboxed square: draw only the part the document fills. - let (fitted, uv) = layer_thumb_fit(rect, doc.size.width, doc.size.height); + // The texture is a letterboxed square: draw only the part its frame (the document, + // or the layer's bounds) fills. + let (tex, frame) = app.layer_thumb(ctx, doc, l); + let (fitted, uv) = layer_thumb_fit(rect, frame.width(), frame.height()); widgets::checker(p, fitted, 5.0); - let tex = app.layer_thumb(ctx, doc, l); p.image(tex, fitted, uv, Color32::WHITE); } } @@ -3092,6 +3331,13 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let entries = st.history.entries(); let current = entries.len() - 1; let redo: Vec = st.history.redo_labels().map(str::to_string).collect(); + let selected_snap = selected_snapshot(ui.ctx(), st); + let ctx = ui.ctx().clone(); + // A row dragged onto the trash (#5324); a drag that never saw its release must not outlive it. + if !ctx.input(|i| i.pointer.primary_down() || i.pointer.any_released()) { + ctx.data_mut(|d| d.remove::(history_drag_key())); + } + let drag = ctx.data(|d| d.get_temp::(history_drag_key())); // Snapshot row (Photoshop shows the document's opening state with a thumbnail). { let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::hover()); @@ -3099,7 +3345,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); if let Some(layer) = doc.layers.first() { widgets::checker(ui.painter(), thumb, 4.0); - let tex = app.layer_thumb(&ctx, &doc, layer); + let (tex, _) = app.layer_thumb(&ctx, &doc, layer); ui.painter().image(tex, thumb, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); } ui.painter().rect_stroke(thumb, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Outside); @@ -3115,17 +3361,26 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { egui::ScrollArea::vertical().id_salt("history-rows").max_height(max_h).auto_shrink([false, false]).show(ui, |ui| { // Snapshots head the list and scroll with the states, so many of them never push the // footer out of a short dock. - snap_cmd = snapshot_rows(app, ui); + snap_cmd = snapshot_rows(app, ui, selected_snap); for (i, (e, is_redo)) in all.enumerate() { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, i == current, &e)); - if i == current { + // The current state shows selected unless a snapshot is (Photoshop highlights one row). + let selected = i == current && selected_snap.is_none(); + // Past states can be dragged onto the trash; the oldest one can't be deleted. + let sense = if is_redo || i == 0 { Sense::click() } else { Sense::click_and_drag() }; + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), sense); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &e)); + if resp.drag_started() { + ui.ctx().data_mut(|d| d.insert_temp(history_drag_key(), HistoryDrag::State(i))); + } + if selected { ui.painter().rect_filled(rect, if t.pro { 0.0 } else { t.radius_sm }, if t.pro { t.row_selected } else { t.accent_soft }); } else if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.5)); } let icon = match e.as_str() { s if s.contains("Brush") || s.contains("Eraser") => "brush", + // The Layers panel's controls (layer.setProps). + "Opacity Change" | "Fill Opacity Change" | "Blending Change" => "layers", s if s.contains("Layer") => "layers", s if s.contains("Select") || s.contains("Marquee") || s.contains("Deselect") || s.contains("Lasso") => "square-dashed", s if s.contains("Fill") => "paint-bucket", @@ -3143,35 +3398,73 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.painter().text(pos2(rect.left() + 58.0, rect.center().y), Align2::LEFT_CENTER, &e, egui::FontId::proportional(12.0), color); if resp.clicked() && i != current { target = Some(i); + } else if resp.clicked() && selected_snap.is_some() { + // The current state is already the document: clicking it only selects it. + ctx.data_mut(|d| d.remove::(selected_snapshot_key(doc.id))); } } }); - let mut footer_cmd: Option<&str> = None; + if let Some(drag) = drag + && let Some(p) = ctx.input(|i| i.pointer.interact_pos()) + { + let label = match drag { + HistoryDrag::Snapshot(id) => app.session.active().and_then(|st| st.snapshots.iter().find(|s| s.id == id)).map(|s| s.name.clone()), + HistoryDrag::State(i) => entries.get(i).cloned(), + }; + crate::layer_transfer::ghost(&ctx, p, &label.unwrap_or_default()); + } + let mut footer_cmd: Option<(&str, Value)> = None; if t.pro { - let can_delete = app.session.is_enabled("history.deleteState"); + // With a snapshot selected the trash deletes it, as in Photoshop (#5324). + let can_delete = selected_snap.is_some() || app.session.is_enabled("history.deleteState"); let can_new = app.session.is_enabled("history.newDocument"); widgets::panel_footer(ui, |ui| { - if ui.add_enabled_ui(can_delete, |ui| icons::button(ui, "trash", 26.0, false, tl!("Delete current state"))).inner.clicked() { - footer_cmd = Some("history.deleteState"); + let trash = ui.add_enabled_ui(can_delete, |ui| icons::button(ui, "trash", 26.0, false, tl!("Delete current state"))).inner; + if trash.clicked() { + footer_cmd = Some(match selected_snap { + Some(id) => ("history.deleteSnapshot", json!({"snapshot": id})), + None => ("history.deleteState", json!({})), + }); + } + // A snapshot or a past state dragged onto the trash is deleted (#5324). + if let Some(drag) = drag + && dropped_on(ui, &trash) + { + footer_cmd = Some(match drag { + HistoryDrag::Snapshot(id) => ("history.deleteSnapshot", json!({"snapshot": id})), + HistoryDrag::State(i) => ("history.deleteState", json!({"state": i})), + }); } let can_snap = app.session.is_enabled("history.newSnapshot"); if ui.add_enabled_ui(can_snap, |ui| icons::button(ui, "scan", 26.0, false, tl!("Create new snapshot"))).inner.clicked() { - footer_cmd = Some("history.newSnapshot"); + footer_cmd = Some(("history.newSnapshot", json!({}))); } if ui.add_enabled_ui(can_new, |ui| icons::button(ui, "file-plus", 26.0, false, tl!("Create new document from current state"))).inner.clicked() { - footer_cmd = Some("history.newDocument"); + footer_cmd = Some(("history.newDocument", json!({}))); } }); } + if ctx.input(|i| i.pointer.any_released()) { + ctx.data_mut(|d| d.remove::(history_drag_key())); + } // An open Free Transform owns Undo (transform_tool::intercept): stepping the document's history under // its box would leave it transforming pixels that changed. // Selecting a snapshot changes the document, so it waits for an open transform like the rows. let snap_cmd = snap_cmd.filter(|(cmd, _)| *cmd != "history.selectSnapshot" || app.ui.transform.is_none()); - if let Some((cmd, p)) = footer_cmd.map(|c| (c, json!({}))).filter(|_| app.ui.transform.is_none()).or(snap_cmd) - && let Err(e) = app.run(cmd, p) - { - app.ui.status = e; - app.ui.status_error = true; + if let Some((cmd, p)) = footer_cmd.filter(|_| app.ui.transform.is_none()).or(snap_cmd) { + let picked = (cmd == "history.selectSnapshot").then(|| p.get("snapshot").and_then(Value::as_u64)).flatten(); + match app.run(cmd, p) { + Err(e) => { + app.ui.status = e; + app.ui.status_error = true; + } + // The snapshot stays selected while the document is the state the click made. + Ok(_) => { + if let (Some(id), Some(st)) = (picked, app.session.active()) { + ctx.data_mut(|d| d.insert_temp::(selected_snapshot_key(st.doc.id), (id, history_spot(st)))); + } + } + } } // One command per click (#2966): stepping with Undo/Redo once per state made the canvas // recomposite the whole document. @@ -3183,11 +3476,49 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// A History panel row being dragged, for a drop on the trash (#5324). +#[derive(Clone, Copy, Debug)] +enum HistoryDrag { + Snapshot(u64), + State(usize), +} + +fn history_drag_key() -> egui::Id { + egui::Id::new("history-drag") +} + +/// The snapshot the History panel shows selected (#5324), and where the history stood after its +/// click: the number of past states, the current state's name and the number of redo states. +type SelectedSnapshot = (u64, HistorySpot); +type HistorySpot = (usize, Option, usize); + +fn history_spot(st: &photocraft_engine::DocState) -> HistorySpot { + (st.history.past_len(), st.history.entries().pop(), st.history.redo_labels().count()) +} + +fn selected_snapshot_key(doc: photocraft_doc::DocId) -> egui::Id { + egui::Id::new(("history-snapshot-selected", doc)) +} + +/// The selected snapshot: the one last clicked, while it still exists and the history is still at +/// the state that click made. Any other step (an edit, Undo, a state row) deselects it, as in +/// Photoshop. +fn selected_snapshot(ctx: &egui::Context, st: &photocraft_engine::DocState) -> Option { + let key = selected_snapshot_key(st.doc.id); + let (id, spot) = ctx.data(|d| d.get_temp::(key))?; + let live = spot == history_spot(st) && st.snapshots.iter().any(|s| s.id == id); + if !live { + ctx.data_mut(|d| d.remove::(key)); + } + live.then_some(id) +} + /// The History panel's snapshot rows (#2966), under the opening row, each with its thumbnail and -/// name: a click returns to the snapshot (a new state), a double-click or right-click › Rename… -/// edits the name in place (Enter or a click elsewhere keeps it, Escape cancels), and -/// right-click › Delete removes it. Returns the command a row asks for. -fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> Option<(&'static str, Value)> { +/// name: a click returns to the snapshot (a new state) and selects it, a double-click or +/// right-click › Rename… edits the name in place (Enter or a click elsewhere keeps it, Escape +/// cancels), right-click › Delete removes it, and a drag onto the trash too (#5324). Returns the +/// command a row asks for. +fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, selected: Option) -> Option<(&'static str, Value)> { let t = Tokens::get(ui.ctx()); let st = app.session.active()?; let (doc_id, snaps) = (st.doc.id, st.snapshots.clone()); @@ -3198,9 +3529,15 @@ fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> Option<(&'static let mut cmd = None; let ctx = ui.ctx().clone(); for snap in &snaps { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &snap.name)); - if resp.hovered() { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click_and_drag()); + let is_selected = selected == Some(snap.id); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, is_selected, &snap.name)); + if resp.drag_started() { + ctx.data_mut(|d| d.insert_temp(history_drag_key(), HistoryDrag::Snapshot(snap.id))); + } + if is_selected { + ui.painter().rect_filled(rect, if t.pro { 0.0 } else { t.radius_sm }, if t.pro { t.row_selected } else { t.accent_soft }); + } else if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.5)); } let thumb = Rect::from_min_size(pos2(rect.left() + 28.0, rect.center().y - 14.0), vec2(28.0, 28.0)); @@ -3507,10 +3844,19 @@ fn field_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, chips: Rect) { } /// Where the dock tells the Color field how tall its group body is. -pub(crate) fn color_field_fill_id() -> egui::Id { +fn color_field_fill_id() -> egui::Id { egui::Id::new("color-field-fill-height") } +/// The pro Color field stretches to its dock group's height instead of a fixed 120 pt: a dock +/// calls this with the group body's `ui` before drawing the tab. +pub(crate) fn offer_color_field_fill(ui: &egui::Ui, pro: bool, group: crate::dock::Group, tab: usize) { + if pro && group == crate::dock::Group::Color && tab == 0 { + let inner = ui.available_height().max(0.0); + ui.data_mut(|d| d.insert_temp(color_field_fill_id(), inner)); + } +} + /// Height of the Color field: the group body's height (when docked) less the hex/RGB readout, /// which folds onto a second line in a narrow dock. 120 pt when nothing sets a height. fn color_field_height(avail: Option, width: f32) -> f32 { @@ -4233,6 +4579,64 @@ mod color_tests { } } + /// While characters are highlighted in a type layer being edited, the Switch Colors and + /// Default Colors buttons recolour them with the new foreground (#5690); without a selection + /// they only change the tool colours, as before. + #[test] + fn swap_and_default_colors_recolour_selected_type() { + use egui_kittest::kittest::Queryable; + use photocraft_doc::LayerId; + let rgb = |app: &PhotocraftApp, id: LayerId, ci: usize| { + let doc = &app.session.active().unwrap().doc; + let photocraft_doc::LayerContent::Text(t) = &doc.layer(id).unwrap().content else { panic!("not a type layer") }; + let b = t.text.char_indices().nth(ci).map_or(t.text.len(), |(b, _)| b); + let mut at = 0; + for r in t.char_runs() { + if b < at + r.len { + return r.style.color.to_rgba8(); + } + at += r.len; + } + [0; 4] + }; + for selected in [true, false] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let made = app.run("type.create", json!({"text": "Hello world", "size": 40, "x": 50, "y": 150, "color": "#000000"})).unwrap(); + let id = LayerId(made["layer"].as_u64().unwrap()); + app.run("tools.setColors", json!({"foreground": "#ff0000", "background": "#0000ff"})).unwrap(); + let anchor = if selected { 6 } else { 11 }; + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id.0, + caret: 11, + anchor, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + let mut h = + egui_kittest::Harness::builder().with_size(vec2(80.0, 120.0)).build_ui_state(|ui, app: &mut PhotocraftApp| color_chips(app, ui, false), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(2); + h.get_by_label("Swap colours (X)").click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 1.0, 1.0]); + assert_eq!(h.state().session.tools.background, [1.0, 0.0, 0.0, 1.0]); + let blue = if selected { [0, 0, 255, 255] } else { [0, 0, 0, 255] }; + assert_eq!(rgb(h.state(), id, 0), [0, 0, 0, 255], "unselected characters keep their colour"); + assert_eq!((rgb(h.state(), id, 6), rgb(h.state(), id, 10)), (blue, blue), "selected: {selected}"); + // Default Colors: the selection takes black, the new foreground. + h.state_mut().run("tools.setColors", json!({"foreground": "#00ff00"})).unwrap(); + h.get_by_label("Default colours (D)").click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(rgb(h.state(), id, 6), [0, 0, 0, 255], "selected: {selected}"); + } + } + /// The Studio layout (chips stacked, icons below) fits the height the toolbar reserves for /// the chips, so the two-column check does not change with the theme. #[test] @@ -4604,7 +5008,7 @@ mod history_transform_tests { mod snapshot_row_tests { use egui::vec2; use egui_kittest::Harness; - use egui_kittest::kittest::Queryable; + use egui_kittest::kittest::{NodeT, Queryable}; use serde_json::json; use super::*; @@ -4675,6 +5079,110 @@ mod snapshot_row_tests { assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps + 2, "deleting leaves the history alone"); } + /// A History panel in the Photoshop theme (the footer with the trash) on red, Snapshot 1, blue. + fn pro_history() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("history.newSnapshot", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#0000ff"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.state_mut().ui.theme = crate::theme::ThemeKind::ProMedium; + h.run_steps(3); + h + } + + /// The footer's trash: the rightmost of its 26 pt buttons (the bar lays them out right to left). + fn trash(h: &Harness<'static, PhotocraftApp>) -> egui::Pos2 { + let buttons: Vec = h + .ctx + .viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(26.0, 26.0)).map(|w| w.rect).collect()); + buttons.into_iter().max_by(|a, b| a.center().x.total_cmp(&b.center().x)).unwrap().center() + } + + fn click(h: &mut Harness<'static, PhotocraftApp>, p: egui::Pos2) { + h.hover_at(p); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + } + + fn selected(h: &Harness<'static, PhotocraftApp>, label: &str) -> bool { + h.get_all_by_label(label).next().unwrap().accesskit_node().toggled() == Some(egui::accesskit::Toggled::True) + } + + /// Press on `from`, drag in small steps to `to` and release there. + fn drag(h: &mut Harness<'static, PhotocraftApp>, from: egui::Pos2, to: egui::Pos2) { + h.event(egui::Event::PointerMoved(from)); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + for k in 1..=10 { + h.event(egui::Event::PointerMoved(from + (to - from) * (k as f32 / 10.0))); + h.step(); + } + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + } + + /// Clicking a snapshot selects it, and the trash then deletes the snapshot instead of a state + /// (#5324). Any other step deselects it, and the trash deletes the current state again. + #[test] + fn the_trash_deletes_the_selected_snapshot() { + let mut h = pro_history(); + h.get_by_label("Snapshot 1").click(); + h.run_steps(2); + let steps = h.state().session.active().unwrap().history.entries().len(); + assert!(selected(&h, "Snapshot 1"), "the clicked snapshot shows selected"); + let p = trash(&h); + click(&mut h, p); + let st = h.state().session.active().unwrap(); + assert!(st.snapshots.is_empty(), "the trash deleted the snapshot"); + assert_eq!(st.history.entries().len(), steps, "and left the states alone"); + + // An edit after the click deselects the snapshot: the trash deletes the state. + let mut h = pro_history(); + h.get_by_label("Snapshot 1").click(); + h.run_steps(2); + h.state_mut().run("edit.fill", json!({"color": "#00ff00"})).unwrap(); + h.run_steps(2); + assert!(!selected(&h, "Snapshot 1"), "an edit deselects the snapshot"); + let steps = h.state().session.active().unwrap().history.entries().len(); + let p = trash(&h); + click(&mut h, p); + let st = h.state().session.active().unwrap(); + assert_eq!(st.snapshots.len(), 1, "the snapshot is kept"); + assert_eq!(st.history.entries().len(), steps - 1, "the current state is deleted"); + } + + /// A snapshot or a past state dragged onto the trash is deleted (#5324). + #[test] + fn rows_dragged_onto_the_trash_are_deleted() { + let mut h = pro_history(); + let steps = h.state().session.active().unwrap().history.entries().len(); + let row = h.get_by_label("Snapshot 1").rect().center(); + let to = trash(&h); + drag(&mut h, egui::pos2(row.x, row.y), to); + let st = h.state().session.active().unwrap(); + assert!(st.snapshots.is_empty(), "the dragged snapshot is deleted"); + assert_eq!(st.history.entries().len(), steps, "no state is"); + + // The second-to-last state: it and the state after it go, as with Delete. + let mut h = pro_history(); + // The states are Open (or New), New Layer, Fill, Fill: drag New Layer. + let entries = h.state().session.active().unwrap().history.entries(); + assert_eq!(entries.len(), 4, "{entries:?}"); + let row = h.get_by_label(&entries[1]).rect().center(); + let to = trash(&h); + drag(&mut h, egui::pos2(row.x, row.y), to); + let st = h.state().session.active().unwrap(); + assert_eq!(st.history.entries().len(), 1, "the dragged state and the ones after it are deleted"); + assert_eq!(st.snapshots.len(), 1, "the snapshot is kept"); + } + /// Snapshot rows scroll with the states: many snapshots in a short dock leave the footer and /// its camera button in view. #[test] @@ -4735,7 +5243,7 @@ mod lock_tests { fn click_lock(app: &mut PhotocraftApp) { let st = app.session.active().unwrap(); let l = st.active_layer.and_then(|id| st.doc.layer(id)).unwrap(); - let (cmd, p) = simple_lock_toggle(crate::doc_props_ui::is_background(&st.doc, l), l); + let (cmd, p) = simple_lock_toggle(crate::doc_props_ui::is_background(&st.doc, l), l, &json!({"layer": l.id.0})); app.run(&cmd, p).unwrap(); } @@ -5260,35 +5768,66 @@ mod toolbar_tests { use super::*; use egui_kittest::kittest::Queryable; - /// #1197: the header chevron switches the toolbar between one and two columns. + /// #1197 / #3469: every theme can toggle the toolbar without changing its selection. #[test] - fn header_chevron_toggles_two_columns() { - let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 1400.0)).build_ui_state( - |ui, app: &mut PhotocraftApp| { - if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { - toolbar(app, ui); - let left = ui.available_rect_before_wrap().left(); - ui.data_mut(|d| d.insert_temp(egui::Id::new("toolbar-test-left"), left)); - } - }, - app, - ); - PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro); - h.run_steps(2); - let left = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.ctx.data(|d| d.get_temp::(egui::Id::new("toolbar-test-left"))).unwrap_or(0.0); - let single = left(&h); - assert!(!h.state().ui.panels.toolbar_double); + fn header_chevron_toggles_two_columns_in_every_layout() { + for theme in [ + crate::theme::ThemeKind::Pro, + crate::theme::ThemeKind::Studio, + crate::theme::ThemeKind::StudioLight, + crate::theme::ThemeKind::Classic, + crate::theme::ThemeKind::AdwaitaDark, + crate::theme::ThemeKind::SolarizedDark, + crate::theme::ThemeKind::BreezeDark, + ] { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 1400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + toolbar(app, ui); + let left = ui.available_rect_before_wrap().left(); + ui.data_mut(|d| d.insert_temp(egui::Id::new("toolbar-test-left"), left)); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(2); + let left = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.ctx.data(|d| d.get_temp::(egui::Id::new("toolbar-test-left"))).unwrap_or(0.0); + let single = left(&h); + assert!(!h.state().ui.panels.toolbar_double, "{theme:?}"); - h.get_by_label("Toolbar").click(); - h.run_steps(2); - assert!(h.state().ui.panels.toolbar_double); - assert!(left(&h) > single + 20.0, "the toolbar did not widen: {single} -> {}", left(&h)); + h.get_by_label("Toolbar").click(); + h.run_steps(2); + assert!(h.state().ui.panels.toolbar_double, "{theme:?}"); + assert!(left(&h) > single + 20.0, "{theme:?}: the toolbar did not widen: {single} -> {}", left(&h)); - h.get_by_label("Toolbar").click(); - h.run_steps(2); - assert!(!h.state().ui.panels.toolbar_double); - assert_eq!(left(&h), single); + h.get_by_label("Toolbar").click(); + h.run_steps(2); + assert!(!h.state().ui.panels.toolbar_double, "{theme:?}"); + assert_eq!(left(&h), single, "{theme:?}"); + } + } + + /// #4653: every theme draws Edit Toolbar, Quick Mask and Screen Mode, not only Pro. + #[test] + fn every_theme_has_the_toolbar_controls() { + for kind in crate::theme::ThemeKind::ALL { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 1400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + toolbar(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(2); + for label in ["Edit Toolbar…", "Edit in Quick Mask Mode (Q)", "Change Screen Mode (F)"] { + assert!(h.query_by_label(label).is_some(), "{kind:?}: no {label:?} button"); + } + } } fn click(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, p: egui::Pos2) { @@ -5661,6 +6200,10 @@ mod compact_visual_regressions { phase: egui::TouchPhase::Move, }); harness.run_steps(30); + // Off the toolbar, so a tooltip of the tool scrolled under the pointer isn't a + // second "Zoom Tool" node. + harness.event(egui::Event::PointerMoved(pos2(300.0, 100.0))); + harness.run_steps(2); let node = harness.get_by_label("Zoom Tool (Z)"); let rect = node.rect(); assert!(rect.top() >= 0.0 && rect.bottom() <= 360.0, "{kind:?} at {scale}x: last tool remains outside the toolbar: {rect:?}"); @@ -5698,13 +6241,13 @@ mod compact_visual_regressions { } } - /// A window taller than the tools keeps the colour chips right under the last tool, as - /// Photoshop does, instead of at the bottom of the toolbar. + /// A window taller than the tools keeps the colour chips right under the tool column (its + /// last row is Edit Toolbar), as Photoshop does, instead of at the bottom of the toolbar. #[test] fn tall_toolbar_keeps_the_colour_chips_under_the_tools() { for kind in theme::ThemeKind::ALL { let harness = toolbar_harness_sized(kind, 1.0, 1600.0); - let last = harness.get_by_label("Zoom Tool (Z)").rect(); + let last = harness.get_by_label("Edit Toolbar…").rect(); let swap = harness.get_by_label("Swap colours (X)").rect(); assert!(swap.top() - last.bottom() < 80.0, "{kind:?}: the chips {swap:?} sit far below the last tool {last:?}"); } @@ -5712,6 +6255,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 { @@ -5923,6 +6467,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); @@ -6056,3 +6634,135 @@ mod properties_target_regressions { #[cfg(test)] #[path = "history_panel_select_tests.rs"] mod history_panel_select_tests; +#[cfg(test)] +mod layer_click_type_commit_tests { + use super::*; + use egui_kittest::{Harness, kittest::Queryable}; + + fn harness(kind: theme::ThemeKind) -> (Harness<'static, PhotocraftApp>, LayerId, LayerId) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let text = LayerId(app.run("type.create", json!({"text": "Before", "size": 24, "x": 20, "y": 80})).unwrap()["layer"].as_u64().unwrap()); + app.run("layer.setProps", json!({"layer": text.0, "name": "Edited type"})).unwrap(); + let other = LayerId(app.run("layer.new.layer", json!({"name": "Other pixels"})).unwrap()["layer"].as_u64().unwrap()); + app.run("layer.select", json!({"layer": text.0})).unwrap(); + crate::type_tool::edit_active(&mut app).unwrap(); + let session = app.ui.text_edit.as_ref().unwrap().session.clone(); + app.run("type.edit", json!({"layer": text.0, "text": "Preserved edit", "coalesce": session})).unwrap(); + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(360.0, 650.0)).with_step_dt(1.0 / 60.0).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), kind); + ready = true; + return; + } + layers(app, ui); + }, + app, + ); + h.run_steps(4); + (h, text, other) + } + + fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, modifiers: egui::Modifiers) { + h.event(egui::Event::ModifiersChanged(modifiers)); + h.hover_at(at); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers }); + h.run_steps(1); + } + } + + fn preserved(app: &PhotocraftApp, id: LayerId) { + let layer = app.session.active().unwrap().doc.layer(id).unwrap(); + let LayerContent::Text(text) = &layer.content else { panic!("type must remain editable") }; + assert_eq!(text.text, "Preserved edit"); + } + + #[test] + fn layer_name_and_thumbnail_clicks_commit_same_or_other_type_selection() { + for kind in theme::ThemeKind::ALL { + for same in [true, false] { + for thumbnail in [true, false] { + let (mut h, text, other) = harness(kind); + let label = if same { "Edited type" } else { "Other pixels" }; + let row = h.get_by_label(label).rect(); + let at = pos2(row.left() + if thumbnail { 43.0 } else { 120.0 }, row.center().y); + click(&mut h, at, egui::Modifiers::NONE); + assert!(h.state().ui.text_edit.is_none(), "{kind:?}, same={same}, thumbnail={thumbnail}"); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(if same { text } else { other })); + preserved(h.state(), text); + } + } + } + } + + #[test] + fn layer_click_commits_without_breaking_modifier_selection_or_double_click_editing() { + for kind in theme::ThemeKind::ALL { + let (mut h, text, other) = harness(kind); + let row = h.get_by_label("Other pixels").rect(); + click(&mut h, pos2(row.left() + 120.0, row.center().y), egui::Modifiers { command: true, ..Default::default() }); + assert!(h.state().ui.text_edit.is_none()); + let selected = h.state().session.active().unwrap().selected_layers(); + assert!(selected.contains(&text) && selected.contains(&other)); + preserved(h.state(), text); + + let (mut h, text, _) = harness(kind); + let row = h.get_by_label("Edited type").rect(); + let thumb = pos2(row.left() + 43.0, row.center().y); + click(&mut h, thumb, egui::Modifiers::NONE); + assert!(h.state().ui.text_edit.is_none()); + click(&mut h, thumb, egui::Modifiers::NONE); + assert_eq!(h.state().ui.text_edit.as_ref().map(|ed| ed.layer), Some(text.0)); + preserved(h.state(), text); + } + } + + #[test] + #[ignore = "offscreen visual evidence for #5584"] + fn layer_click_synthetic_visual_evidence() { + let (h, text, _) = harness(theme::ThemeKind::ProMedium); + let mut app = h.into_state(); + app.ui.tool = Tool::Type; + let mut h = Harness::builder().with_size(vec2(1280.0, 900.0)).with_step_dt(1.0 / 60.0).with_max_steps(64).wgpu().build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, theme::ThemeKind::ProMedium); + app + }); + h.run_steps(4); + assert!(h.state().ui.text_edit.is_some()); + h.render().unwrap().save("5584-before.png").unwrap(); + let row = h.get_by_label("Other pixels").rect(); + click(&mut h, pos2(row.left() + 120.0, row.center().y), egui::Modifiers::NONE); + assert!(h.state().ui.text_edit.is_none()); + preserved(h.state(), text); + h.run_steps(4); + h.render().unwrap().save("5584-after.png").unwrap(); + } +} + +#[cfg(test)] +mod sampler_readout_tests { + use super::*; + + /// Composited once per revision: repeated frames share the readouts; adding a sampler or an + /// edit recomputes them (#4528). + #[test] + fn sampler_readouts_are_reused_until_the_document_changes() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 4, "y": 4})).unwrap(); + let a = sampler_readouts(&mut app); + assert_eq!(a.len(), 1); + assert_eq!(a[0]["rgb"], json!([255, 255, 255])); + assert!(std::sync::Arc::ptr_eq(&a, &sampler_readouts(&mut app)), "the next frame reuses them"); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let b = sampler_readouts(&mut app); + assert!(!std::sync::Arc::ptr_eq(&a, &b)); + assert_eq!(b[0]["rgb"], json!([255, 0, 0]), "an edit shows"); + app.run("view.colorSamplers.add", json!({"x": 8, "y": 8})).unwrap(); + assert_eq!(sampler_readouts(&mut app).len(), 2, "so does a new sampler"); + } +} diff --git a/crates/ui-egui/src/path_selection_tests.rs b/crates/ui-egui/src/path_selection_tests.rs index 8f82e2ad68..b4aaa9e503 100644 --- a/crates/ui-egui/src/path_selection_tests.rs +++ b/crates/ui-egui/src/path_selection_tests.rs @@ -247,3 +247,87 @@ fn clicking_shape_with_path_selection_selects_its_layer() { crate::vector_ui::path_selection_finish(h.state_mut(), [150.0, 90.0], [150.0, 90.0]); assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, rect_layer); } + +/// Work paths remain visible (outline) regardless of the active tool. Before the fix, +/// `draw_overlay` was gated on `vector_tool` so paths disappeared when switching to Brush, Move, +/// etc. This test renders the canvas with a Brush tool active and a work path present, then +/// confirms the path's outline pixels are painted in the accent colour, proving the path is drawn. +#[test] +fn path_overlay_drawn_for_non_vector_tools() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + // Place a work path whose top edge runs exactly along y=10 in document space. + app.run("path.set", json!({"name": "work", "path": rect_path(50.0, 10.0, 100.0, 80.0)})).unwrap(); + // Switch to a non-vector tool — before the fix this hid the path outline. + app.ui.tool = Tool::Brush; + + let mut h = Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(6); + // Fit the view so document coordinates map predictably to screen pixels. + { + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 2.0; // 2 px/doc-px → the 200×200 doc fills 400×400 screen pixels + v.center = [100.0, 100.0]; + v.fit_pending = false; + } + h.run_steps(3); + + // Get the accent colour so we know what to look for. + let accent = crate::theme::Tokens::get(&h.ctx).accent; + + let xf = crate::canvas::ViewXform::active(h.state()).expect("active document view"); + let ppp = h.ctx.pixels_per_point(); + let img = h.render().unwrap(); + + // Sample the rendered outline at points derived from the same transform the canvas used. The + // top edge is doc-y=10; x=70..130 stays away from the rectangle's anchor squares. A 1 px + // egui stroke is anti-aliased over the white canvas, so accept visible blends of the accent. + let accent_r = accent.r(); + let accent_g = accent.g(); + let accent_b = accent.b(); + let is_accent_stroke = |pixel: [u8; 4]| { + let denom = f32::from(255u8.saturating_sub(accent_r)).max(1.0); + let coverage = f32::from(255u8.saturating_sub(pixel[0])) / denom; + if !(0.2..=1.05).contains(&coverage) { + return false; + } + let blended = |channel: u8| 255.0 + coverage * (f32::from(channel) - 255.0); + (f32::from(pixel[1]) - blended(accent_g)).abs() <= 12.0 && (f32::from(pixel[2]) - blended(accent_b)).abs() <= 12.0 + }; + + let has_accent_near = |at: egui::Pos2| { + let cx = (at.x * ppp).round() as i32; + let cy = (at.y * ppp).round() as i32; + let radius = (2.0 * ppp).ceil() as i32; + for y in (cy - radius)..=(cy + radius) { + for x in (cx - radius)..=(cx + radius) { + if x >= 0 && y >= 0 && (x as u32) < img.width() && (y as u32) < img.height() { + let pixel = img.get_pixel(x as u32, y as u32); + if is_accent_stroke([pixel[0], pixel[1], pixel[2], pixel[3]]) { + return true; + } + } + } + } + false + }; + let hits = (70..=130).step_by(5).filter(|x| has_accent_near(xf.to_screen(*x as f32, 10.0))).count(); + assert!( + hits >= 10, + "expected the work path outline to be visible with Brush tool active, \ + but found accent-coloured pixels at only {hits} sample point(s) along the transformed top edge \ + (accent = #{accent_r:02x}{accent_g:02x}{accent_b:02x}); \ + the path must be drawn regardless of the active tool", + ); +} diff --git a/crates/ui-egui/src/pen_draw_tests.rs b/crates/ui-egui/src/pen_draw_tests.rs index 74689a1070..57be940bd6 100644 --- a/crates/ui-egui/src/pen_draw_tests.rs +++ b/crates/ui-egui/src/pen_draw_tests.rs @@ -122,6 +122,23 @@ fn alt_breaks_the_handle_being_dragged_and_grabs_the_last_outgoing_handle() { assert!(near([k.out_ctrl.x, k.out_ctrl.y], [140.0, 20.0])); } +#[test] +fn opening_a_new_document_finishes_the_previous_pen_path() { + let mut h = harness(); + click(&mut h, [20.0, 20.0]); + click(&mut h, [120.0, 20.0]); + assert_eq!(h.state().ui.pen.as_ref().map(|p| p.knots.len()), Some(2)); + let original = h.state().session.active().unwrap().doc.id; + + h.state_mut().run("file.new", json!({"width": 300, "height": 200})).unwrap(); + assert!(h.state().ui.pen.is_none(), "the Pen draft must not overlay the new document"); + let docs = h.state().session.documents(); + assert_eq!(docs.len(), 2); + assert_eq!(docs[0].doc.id, original); + assert_eq!(docs[0].doc.work_path.as_ref().unwrap().subpaths[0].knots.len(), 2); + assert!(docs[1].doc.work_path.is_none(), "the new document starts with no path"); +} + #[test] fn dragging_on_the_first_anchor_closes_with_its_handles_in_one_step() { let mut h = harness(); @@ -154,3 +171,107 @@ fn dragging_on_the_first_anchor_closes_with_its_handles_in_one_step() { assert!(sp.closed && sp.knots.len() == 3); assert!(near([k.in_ctrl.x, k.in_ctrl.y], [0.0, 20.0]) && near([k.out_ctrl.x, k.out_ctrl.y], [40.0, 20.0]), "{k:?}"); } + +/// #5702: a finished work path showed again whenever the Pen came back, and ⌫ / Delete reached +/// Edit › Clear (the pixels under it) instead, so the path looked impossible to delete. With the +/// Pen the key deletes the path, in one undoable step, and leaves the pixels; other tools keep +/// Edit › Clear. +#[test] +fn delete_with_the_pen_deletes_the_finished_work_path_not_the_pixels() { + let mut h = harness(); + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + h.state_mut() + .session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 200, 150), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + for p in [[20.0, 20.0], [120.0, 20.0], [120.0, 100.0]] { + click(&mut h, p); + } + h.key_press(Key::Enter); + h.run_steps(2); + let work = |h: &Harness<'static, PhotocraftApp>| h.state().session.active().unwrap().doc.work_path.is_some(); + let red = |h: &Harness<'static, PhotocraftApp>| { + let st = h.state().session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(60, 60) == [1.0, 0.0, 0.0, 1.0] + }; + assert!(work(&h) && h.state().ui.pen.is_none(), "a finished work path"); + let steps = h.state().session.active().unwrap().history.past_len(); + + h.key_press(Key::Delete); + h.run_steps(2); + assert!(!work(&h), "Delete deletes the work path"); + assert!(red(&h), "and leaves the pixels under it"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "one History step"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert!(work(&h), "Undo brings it back"); + + h.state_mut().ui.tool = Tool::PathSelection; + h.key_press(Key::Backspace); + h.run_steps(2); + assert!(!work(&h), "Backspace with Path Selection deletes it too"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + + h.state_mut().ui.tool = Tool::Brush; + h.key_press(Key::Delete); + 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"); +} diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index db908ae23f..d808ae32f4 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -52,6 +52,7 @@ pub struct Runtime { show_rulers: Option, /// Tool options as last remembered in the preferences (see `tool_memory`). pub(crate) tool_options: Option, + pub(crate) selected_tool: Option, } /// The GPU canvas colours from Preferences › Transparency & Gamut. @@ -128,6 +129,26 @@ pub(crate) fn cycle_appearance(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// Photoshop's four interface brightnesses, darkest first. +const BRIGHTNESSES: [ThemeKind; 4] = [ThemeKind::Pro, ThemeKind::ProMedium, ThemeKind::ProLightGray, ThemeKind::ProLight]; + +/// Darken Interface (⇧⌘F1, `lighter` false) and Lighten Interface (⇧⌘F2): the next of +/// Photoshop's four brightnesses from the theme on screen, stopping at either end, as in +/// Photoshop. Another theme steps from the nearest brightness: Medium Gray for a dark one, Light +/// Gray for a light one. The choice is saved like picking it in Preferences › Interface. +pub(crate) fn step_brightness(app: &mut PhotocraftApp, ctx: &egui::Context, lighter: bool) -> Result<(), String> { + let current = app.ui.theme; + let at = BRIGHTNESSES.iter().position(|k| *k == current).unwrap_or(if crate::theme::Tokens::for_kind(current).dark() { 1 } else { 2 }); + let next = if lighter { (at + 1).min(BRIGHTNESSES.len() - 1) } else { at.saturating_sub(1) }; + let Some(&kind) = BRIGHTNESSES.get(next) else { return Ok(()) }; + if kind == current { + return Ok(()); + } + app.run("prefs.set", json!({"path": "interface.theme", "value": kind.id()}))?; + sync_appearance(app, ctx); + Ok(()) +} + const LIGHT_THEME_OPTIONS: &[(&str, ThemeKind)] = &[ ("proLight", ThemeKind::ProLight), ("proLightGray", ThemeKind::ProLightGray), @@ -212,11 +233,15 @@ pub fn load(app: &mut PhotocraftApp) { return; } app.prefs_rt.loaded = true; - if let Some(text) = app.services.load_prefs.as_mut().and_then(|f| f()) - && let Err(e) = app.session.load_prefs_json(&text) - { - let template = "Preferences were reset: {error}"; - app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e)]); + let mut invalid = Vec::new(); + if let Some(text) = app.services.load_prefs.as_mut().and_then(|f| f()) { + match app.session.load_prefs_json(&text) { + Ok(found) => invalid = found, + Err(e) => { + let template = "Preferences were reset: {error}"; + app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e)]); + } + } } crate::dock::restore(app); crate::brush_picker::restore(app); @@ -227,6 +252,37 @@ pub fn load(app: &mut PhotocraftApp) { app.sync_recent(); app.prefs_rt.saved_rev = app.session.prefs.rev(); app.prefs_rt.saved_value = Some(app.session.prefs_value()); + if !invalid.is_empty() { + repair_invalid(app, &invalid); + } +} + +/// Some saved values couldn't be read and took their defaults (#6611): say which, and write +/// the defaults over them on the next save so the next launch reads a clean file. The save +/// base holds the unreadable values, so the three-way merge sees them as changed here and +/// replaces (or removes) exactly those, keeping everything else in the stored file. +fn repair_invalid(app: &mut PhotocraftApp, invalid: &[prefs::InvalidPref]) { + if let Some(base) = app.prefs_rt.saved_value.as_mut() { + for bad in invalid { + let Some((key, parent)) = bad.keys.split_last() else { + continue; + }; + if let Some(obj) = parent.iter().try_fold(&mut *base, |cur, k| cur.get_mut(k.as_str())).and_then(Value::as_object_mut) { + obj.insert(key.clone(), bad.value.clone()); + } + } + } + app.session.prefs.edit(|_| ()); + let paths = invalid.iter().map(prefs::InvalidPref::path).collect::>().join(", "); + let (title, line) = ( + "Some preferences were reset", + "The preferences file held values PhotoCraft can't read for {paths}. Those settings are back to their defaults; the rest of your preferences loaded.", + ); + // Notices keep their English source and are translated when drawn. + let id = crate::notices::post(app, title, vec![line.into()], false, None); + if let Some(notice) = app.ui.notices.iter_mut().find(|n| n.id == id) { + notice.args.push(("paths".into(), paths)); + } } /// Discover recovery entries after construction, then decode one at a time without blocking @@ -783,6 +839,21 @@ pub fn shortcut_items(app: &PhotocraftApp) -> Vec<(String, String, Vec, out } +/// A fingerprint of the bindings a shortcut conflict describes. +fn conflict_state(overrides: &BTreeMap, alternates: &BTreeMap>, selected: &str, set: Option<&Value>) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (overrides, alternates, selected, set.map(Value::to_string)).hash(&mut h); + h.finish() +} + +/// Keys the system takes before the app sees them (switching apps, closing the window, force +/// quit): Edit › Keyboard Shortcuts won't assign them, as Photoshop won't. `sc` is in our +/// notation, where `Cmd` is Ctrl off the Mac. +pub fn reserved_shortcut(sc: &str) -> bool { + prefs::reserved_shortcut(sc) +} + /// Text of a key press for the shortcut editor (`Cmd+Shift+K`), `None` for bare modifiers. pub fn shortcut_text(key: egui::Key, m: egui::Modifiers) -> Option { if is_modifier_key(key) { @@ -871,26 +942,16 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &V } dialog(d) } - "edit.contentAwareFill" => { - if !app.session.is_enabled(id) { - return Some(Err("Content-Aware Fill needs a selection on a pixel layer".into())); - } - let d = crate::filter_dialog::open(app, id); - if let Some(dm) = d.and_then(|d| app.ui.dialog_mut(d)) { - dm.fields.insert("__preview".into(), json!(true)); - for k in ["margin", "seed", "channel"] { - dm.fields.remove(k); - } - } - dialog(d) - } + // Photoshop's Content-Aware Fill workspace (caf_ui); the engine command with params + // still runs directly. + "edit.contentAwareFill" => Some(crate::caf_ui::open(app).map(|_| json!({"contentAwareFill": app.distort.describe()["contentAwareFill"]}))), // Photoshop has no dialog: the menu opens the scale box (with params: the engine). crate::cas_transform::COMMAND => Some(crate::cas_transform::begin(app, ctx)), "edit.presets.presetManager" => { Some(Ok(json!({"dialog": open_kind(app, "presets", "Preset Manager", json!({"kind": "brushes", "selected": 0, "newName": ""}))}))) } "edit.presets.exportImportPresets" => Some(Ok( - json!({"dialog": open_kind(app, "presetsIO", "Export/Import Presets", json!({"action": "export", "brushes": true, "customShapes": true}))}), + json!({"dialog": open_kind(app, "presetsIO", "Export/Import Presets", json!({"action": "export", "brushes": true, "customShapes": true, "tools": true}))}), )), _ => None, } @@ -942,10 +1003,22 @@ pub fn apply(app: &mut PhotocraftApp, id: u64) -> Result { Ok(result) } -/// Max dialog width for our dialogs. +/// The size Preferences opens at: room for the widest section and about 30 settings rows. The +/// dialog's resize grip changes it, and its contents follow (#7420). +pub const PREFERENCES_SIZE: egui::Vec2 = egui::vec2(1040.0, 800.0); + +/// The height of the settings area (section list and fields) when `available` is left below the +/// search field: everything but the dialog's button row (`item_spacing` + 8 pt + the buttons) and +/// a little slack, never less than a few rows. +fn settings_height(available: f32, item_spacing: f32, button_height: f32) -> f32 { + (available - (item_spacing + 8.0 + button_height + 4.0)).max(160.0) +} + +/// Max dialog width for our dialogs. Preferences fills the width it is given (`available`, its +/// resizable area). pub fn width(fields: &Map, available: f32) -> Option { match fields.get("__prefsui").and_then(Value::as_str)? { - "prefs" => Some(available.clamp(380.0, 1040.0)), + "prefs" => Some(available.max(380.0)), "shortcuts" => Some(720.0), _ => Some(460.0), } @@ -984,6 +1057,9 @@ pub fn open_shortcuts(app: &mut PhotocraftApp, tab: u64) -> u64 { "tab": tab, "filter": "", "overrides": p.shortcuts, + "alternates": p.alternate_shortcuts, + "sets": p.shortcut_sets, + "activeSet": p.shortcut_set, "hidden": p.menus.hidden, "colors": p.menus.colors, "toolbarHidden": p.toolbar.hidden, @@ -997,8 +1073,13 @@ pub fn open_shortcuts(app: &mut PhotocraftApp, tab: u64) -> u64 { /// Open the Embedded Profile Mismatch prompt for a file waiting to open (`open_prompt`): its /// `message`, the answer selected first (`action`), and `marker`, the field that routes OK and /// Cancel back to `open_prompt`. -pub fn open_mismatch(app: &mut PhotocraftApp, message: &str, action: &str, missing: bool, marker: &str) -> u64 { - let mut fields = json!({"message": message, "action": action, "missing": missing}); +/// `names` carries the embedded and working profile names and the Color Settings policy a +/// remembered choice is saved to (`policyKey`, null for a mode without one). +pub fn open_mismatch(app: &mut PhotocraftApp, message: &str, action: &str, missing: bool, marker: &str, names: Value) -> u64 { + let mut fields = json!({"message": message, "action": action, "missing": missing, "remember": false}); + if let (Some(f), Value::Object(n)) = (fields.as_object_mut(), names) { + f.extend(n); + } fields[marker] = json!(true); open_kind(app, "mismatch", tl!("Embedded Profile Mismatch"), fields) } @@ -1009,7 +1090,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); } @@ -1021,9 +1104,31 @@ 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(), + "exrAcesCg" => return "Open color-less EXR as ACEScg".into(), "darkTheme" => return "Dark Theme".into(), "lightTheme" => return "Light Theme".into(), _ => {} @@ -1042,6 +1147,22 @@ fn humanize(key: &str) -> String { if key == "notificationDurationSeconds" { return "Notification Duration (seconds)".into(); } + // Tools: arrow-key nudges bundle into one History state (#2259). + if key == "bundleNudges" { + return "Bundle Arrow-Key Nudges".into(); + } + if key == "nudgeBundlePauseMs" { + return "Nudge Bundle Pause (ms)".into(); + } + // Performance: presentation without vsync (#1343); like the other graphics settings, it + // takes effect when the window next opens. + if key == "lowLatencyDrawing" { + return "Low-Latency Drawing (vsync off, applies at next launch)".into(); + } + // 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 { @@ -1172,7 +1293,10 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map, system: Option, key: &str) -> bool { } } +/// The pause that bundles arrow-key nudges means nothing with Bundle Arrow-Key Nudges off (#2259). +fn tools_field_visible(obj: &Map, key: &str) -> bool { + key != "nudgeBundlePauseMs" || obj.get("bundleNudges").and_then(Value::as_bool) != Some(false) +} + /// One theme as a Color Theme swatch: its panel colour under a chrome strip with an accent dot, /// outlined when chosen. The name is the tooltip and the accessible label. A star badge on its /// corner adds the theme to (or removes it from) the favourites listed by the appearance @@ -1472,8 +1601,11 @@ fn field_visible(section: &str, obj: &Map, k: &str, query: &str) let drawn_elsewhere = (section == "interface" && matches!(k, "theme" | "appearanceMode" | "darkTheme" | "lightTheme" | "favoriteThemes")) // View › Rulers (⌘R) sets it, as in Photoshop. || path == "unitsAndRulers.showRulers" - || (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "renderingMode")) - || (section == "export" && !export_field_visible(obj, k)); + || (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "renderingMode" | "memoryUsageMb")) + // The browser presents frames itself: Low-Latency Drawing does nothing there. + || (cfg!(target_arch = "wasm32") && path == "performance.lowLatencyDrawing") + || (section == "export" && !export_field_visible(obj, k)) + || (section == "tools" && !tools_field_visible(obj, k)); (query.is_empty() || preference_matches(section, k, query)) && !drawn_elsewhere && !prefs::is_hidden(&path) @@ -1501,10 +1633,37 @@ fn section_fields( if section == "performance" && (query.is_empty() || preference_matches(section, "renderingMode", &query)) { rendering_mode_row(ui, obj); } + if section == "performance" && (query.is_empty() || preference_matches(section, "memoryUsageMb", &query)) { + memory_usage_row(ui, obj); + } keys.retain(|k| field_visible(section, obj, k, &query)); egui::Grid::new(("prefs-grid", section)).num_columns(2).spacing([14.0, 7.0]).show(ui, |ui| field_rows(ui, section, obj, keys, lang)); } +/// Explain the per-document RAM limit separately from the graphics-memory preference. +fn memory_usage_row(ui: &mut egui::Ui, obj: &mut Map) { + let t = Tokens::get(ui.ctx()); + ui.group(|ui| { + ui.label(RichText::new(tl!("Memory Usage")).strong()); + let mut mb = obj.get("memoryUsageMb").and_then(Value::as_u64).unwrap_or(8192); + ui.horizontal(|ui| { + ui.label(tl!("Per-document RAM limit (MB)")); + if ui.add(egui::DragValue::new(&mut mb).range(256..=1_048_576).suffix(" MB")).changed() { + obj.insert("memoryUsageMb".into(), json!(mb)); + } + }); + if let Some(ram) = crate::gpu_canvas::physical_memory().filter(|n| *n > 0) { + let installed = ram / (1 << 20); + let percent = mb as f64 * 100.0 / (ram as f64 / (1 << 20) as f64); + ui.label(crate::i18n::fmt(tl!("Installed RAM: {ram} MB; this limit: {percent}%"), &[("ram", &installed.to_string()), ("percent", &format!("{percent:.1}"))])); + } + ui.label(RichText::new(tl!("Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded.")).color(t.text_dim)); + ui.label(RichText::new(tl!("Applies immediately when saved. Memory is not reserved in advance.")).color(t.text_dim)); + ui.label(RichText::new(tl!("With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget.")).color(t.text_dim)); + }); + ui.add_space(8.0); +} + /// One grid row per setting in `keys` (already filtered by `field_visible`). fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map, keys: Vec, lang: crate::i18n::Lang) { let t = Tokens::get(ui.ctx()); @@ -1634,17 +1793,19 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map>(v.clone())); let overrides_ok = parsed.as_ref().is_none_or(Result::is_ok); let mut overrides = parsed.and_then(Result::ok).unwrap_or_default(); + // A command's further keys (#3665): id → keys, replacing its default alternates. + let parsed_alts = f.get("alternates").map(|v| serde_json::from_value::>>(v.clone())); + let alternates_ok = parsed_alts.as_ref().is_none_or(Result::is_ok); + let mut alt_overrides = parsed_alts.and_then(Result::ok).unwrap_or_default(); + let mut capture_alt = f.get("captureAlt").and_then(Value::as_bool).unwrap_or(false); + // A key just assigned that another command holds: Photoshop's Accept and Go to Conflict / + // Undo Changes (an object: the key, the other command, and this one's keys before). + let mut conflict: Option = f.get("conflict").filter(|c| c.is_object()).cloned(); let mut hidden: Vec = f.get("hidden").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); let mut colors: BTreeMap = f.get("colors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); let mut selected = f.get("selected").and_then(Value::as_str).unwrap_or("").to_string(); @@ -1652,6 +1813,17 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map (Vec, String) { let lang = crate::i18n::current(); @@ -1664,7 +1836,15 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map def.clone(), } }; - // Key capture for the selected command. + // A command's further keys as the dialog stands: its edited list, else its defaults. + let eff_alts = |alts: &BTreeMap>, id: &str, main: Option<&str>| -> Vec { + let mut p = prefs::Preferences::default(); + if let Some(list) = alts.get(id) { + p.alternate_shortcuts.insert(id.to_string(), list.clone()); + } + p.alternates(id, main) + }; + // Key capture for the selected command: its main key, or (Add Shortcut) one more key. if capture && tab == 0 && !selected.is_empty() { let pressed = ui.input(|i| { i.events.iter().find_map(|e| match e { @@ -1678,21 +1858,64 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = items + .iter() + .filter(|(id, _, _, def)| { + let cur = eff(&overrides, id, def); + *id != selected + && (cur.as_deref().and_then(prefs::normalize_shortcut) == Some(sc.clone()) + || eff_alts(&alt_overrides, id, cur.as_deref()).contains(&sc)) + }) + .map(|(id, ..)| id.as_str()) + .collect(); let clash: Vec = items .iter() - .filter(|(id, _, _, def)| *id != selected && eff(&overrides, id, def).as_deref().and_then(prefs::normalize_shortcut) == Some(sc.clone())) + .filter(|(id, ..)| clash_ids.contains(&id.as_str())) .map(|(id, label, path, _)| { let (path, label) = shown(id, label, path); format!("{} › {}", path.join(" › "), label.trim_end_matches('…')) }) .collect(); - overrides.insert(selected.clone(), sc.clone()); + // What Undo Changes puts back: the command's main key and further keys as they were. + if let Some(first) = clash_ids.first() { + conflict = Some(json!({ + "shortcut": sc, + "with": first, + // Every command holding the key: Accept takes it from all of them. + "holders": clash_ids, + "command": selected, + "main": overrides.get(&selected), + "alternates": alt_overrides.get(&selected), + })); + } else { + conflict = None; + } + if capture_alt { + let def = items.iter().find(|i| i.0 == selected).and_then(|i| i.3.clone()); + let cur = eff(&overrides, &selected, &def); + let mut keys = eff_alts(&alt_overrides, &selected, cur.as_deref()); + if cur.as_deref().and_then(prefs::normalize_shortcut) != Some(sc.clone()) && !keys.contains(&sc) && keys.len() < prefs::MAX_ALTERNATES { + keys.push(sc.clone()); + } + alt_overrides.insert(selected.clone(), keys); + } else { + overrides.insert(selected.clone(), sc.clone()); + } message_warning = !clash.is_empty(); message = if clash.is_empty() { let template = "{shortcut} assigned."; @@ -1714,13 +1937,16 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = serde_json::from_value(c["holders"].clone()).unwrap_or_else(|_| vec![with.to_string()]); + for holder in &holders { + let def = items.iter().find(|i| i.0 == *holder).and_then(|i| i.3.clone()); + let cur = eff(&overrides, holder, &def); + if cur.as_deref().and_then(prefs::normalize_shortcut).as_deref() == Some(key) { + overrides.insert(holder.clone(), String::new()); + } + let keys = eff_alts(&alt_overrides, holder, eff(&overrides, holder, &def).as_deref()); + if keys.iter().any(|k| k == key) { + alt_overrides.insert(holder.clone(), keys.into_iter().filter(|k| k != key).collect()); + } + } + selected = with.to_string(); + filter.clear(); + message = tl!("Choose a new shortcut for this command, or leave it without one.").into(); + message_warning = false; + conflict = None; + } + if ui.button(tl!("Undo Changes")).on_hover_text(tl!("Put this command's shortcuts back as they were")).clicked() { + match c["main"].as_str() { + Some(m) => overrides.insert(command.to_string(), m.to_string()), + None => overrides.remove(command), + }; + match serde_json::from_value::>(c["alternates"].clone()) { + Ok(list) => alt_overrides.insert(command.to_string(), list), + Err(_) => alt_overrides.remove(command), + }; + selected = command.to_string(); + message = tl!("Changes undone.").into(); + message_warning = false; + conflict = None; + } + }); + } if overrides_ok { f.insert("overrides".into(), json!(overrides)); } + if alternates_ok { + f.insert("alternates".into(), json!(alt_overrides)); + } + f.insert("captureAlt".into(), json!(capture && capture_alt)); + // A conflict just found is stamped with the bindings it describes. + if let Some(c) = conflict.as_mut().filter(|c| c.get("state").is_none()) { + c["state"] = json!(conflict_state(&overrides, &alt_overrides, &selected, f.get("activeSet"))); + } + f.insert("conflict".into(), conflict.unwrap_or(Value::Null)); f.insert("hidden".into(), json!(hidden)); f.insert("colors".into(), json!(colors)); f.insert("selected".into(), json!(selected)); @@ -1820,6 +2135,167 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, alternates: &BTreeMap>, id: &str, default: Option<&str>) -> Vec { + let main = match overrides.get(id) { + Some(s) if s.is_empty() => None, + Some(s) => Some(s.as_str()), + None => default, + }; + let mut p = prefs::Preferences::default(); + if let Some(list) = alternates.get(id) { + p.alternate_shortcuts.insert(id.to_string(), list.clone()); + } + let mut keys: Vec = main.and_then(prefs::normalize_shortcut).into_iter().collect(); + keys.extend(p.alternates(id, main)); + keys +} + +/// Edit › Keyboard Shortcuts › Set, as in Photoshop: choose a saved set (its bindings replace +/// the dialog's), save the bindings as a new set or over the one in use, delete it, export it as +/// a `.kys` file or summarize it as an HTML page. Everything but Export and Summarize waits for +/// OK, like the rest of the dialog. +#[allow(clippy::too_many_arguments)] +fn set_row( + app: &mut PhotocraftApp, + ui: &mut egui::Ui, + f: &mut Map, + overrides: &mut BTreeMap, + alternates: &mut BTreeMap>, + items: &[(String, String, Vec, Option)], + message: &mut String, + warning: &mut bool, +) { + use photocraft_engine::shortcut_sets::{self, ShortcutSet}; + let mut sets: BTreeMap = f.get("sets").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let mut active = f.get("activeSet").and_then(Value::as_str).unwrap_or("").to_string(); + if !active.is_empty() && !sets.contains_key(&active) { + active.clear(); + } + let now = ShortcutSet { shortcuts: overrides.clone(), alternate_shortcuts: alternates.clone() }; + let modified = match sets.get(&active) { + Some(s) => *s != now, + None => now != ShortcutSet::default(), + }; + let defaults = tl!("PhotoCraft Defaults").to_string(); + let shown = |name: &str| -> String { + let base = if name.is_empty() { defaults.clone() } else { name.to_string() }; + if name == active && modified { crate::i18n::fmt(tl!("{set} (modified)"), &[("set", &base)]) } else { base } + }; + let names: Vec<(String, String)> = std::iter::once(String::new()).chain(sets.keys().cloned()).map(|n| (n.clone(), shown(&n))).collect(); + let mut new_name: Option = f.get("newSetName").and_then(Value::as_str).map(str::to_string); + let set_label = if active.is_empty() { defaults.clone() } else { active.clone() }; + let mut export = false; + let mut summarize = false; + ui.horizontal(|ui| { + ui.label(tl!("Set:")); + let mut chosen = active.clone(); + let pairs: Vec<(String, &str)> = names.iter().map(|(n, l)| (n.clone(), l.as_str())).collect(); + if crate::widgets::dropdown(ui, "shortcut-set", &mut chosen, &pairs, 220.0) && chosen != active { + let set = sets.get(&chosen).cloned().unwrap_or_default(); + *overrides = set.shortcuts; + *alternates = set.alternate_shortcuts; + active = chosen; + message.clear(); + } + if ui.button(tl!("New Set…")).on_hover_text(tl!("Save these shortcuts as a new set")).clicked() { + new_name = Some(String::new()); + } + if ui.add_enabled(!active.is_empty() && modified, egui::Button::new(tl!("Save"))).on_hover_text(tl!("Save the changes to this set")).clicked() { + sets.insert(active.clone(), now.clone()); + *message = tl!("Set saved.").into(); + *warning = false; + } + if ui.add_enabled(!active.is_empty(), egui::Button::new(tl!("Delete"))).on_hover_text(tl!("Delete this set")).clicked() { + sets.remove(&active); + active.clear(); + *message = tl!("Set deleted.").into(); + *warning = false; + } + export = ui.button(tl!("Export…")).on_hover_text(tl!("Save these shortcuts as a .kys file")).clicked(); + summarize = ui.button(tl!("Summarize…")).on_hover_text(tl!("Save a list of these shortcuts as a web page")).clicked(); + }); + if let Some(name) = new_name.as_mut() { + let mut create = false; + let mut cancel = false; + ui.horizontal(|ui| { + ui.label(tl!("Name:")); + let r = ui.add(egui::TextEdit::singleline(name).desired_width(220.0).hint_text(tl!("set name"))); + create = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + create |= ui.button(tl!("Create")).clicked(); + cancel = ui.button(tl!("Cancel")).clicked(); + }); + if create { + match shortcut_sets::set_name(name) { + Ok(n) if !sets.contains_key(&n) && sets.len() >= shortcut_sets::MAX_SETS => { + *message = tl!("There are too many sets: delete one first.").into(); + *warning = true; + } + Ok(n) => { + sets.insert(n.clone(), now.clone()); + active = n; + new_name = None; + *message = tl!("Set saved.").into(); + *warning = false; + } + Err(e) => { + *message = e; + *warning = true; + } + } + } else if cancel { + new_name = None; + } + } + if export || summarize { + let result = if export { + let entries: Vec = items + .iter() + .map(|(id, label, _, def)| photocraft_engine::kys::Entry { + id: id.clone(), + label: label.clone(), + tool: id.starts_with("tools.select."), + keys: dialog_keys(overrides, alternates, id, def.as_deref()), + }) + .collect(); + let text = photocraft_engine::kys::write(&set_label, &entries); + save_text(app, &format!("{set_label}.kys"), text) + } else { + let mut sections: Vec = Vec::new(); + for (id, label, path, def) in items { + let section = path.first().cloned().unwrap_or_else(|| tl!("Other").to_string()); + let name = path.iter().skip(1).chain(std::iter::once(label)).cloned().collect::>().join(" › "); + let row = (name, dialog_keys(overrides, alternates, id, def.as_deref())); + match sections.iter_mut().find(|(s, _)| *s == section) { + Some((_, rows)) => rows.push(row), + None => sections.push((section, vec![row])), + } + } + let html = shortcut_sets::summarize(&set_label, §ions, cfg!(target_os = "macos")); + save_text(app, &format!("{set_label}.html"), html) + }; + if let Err(e) = result { + *message = e; + *warning = true; + } + } + f.insert("sets".into(), json!(sets)); + f.insert("activeSet".into(), json!(active)); + f.insert("newSetName".into(), new_name.map_or(Value::Null, Value::String)); +} + +/// Save `text` through the save dialog under the suggested file name. +fn save_text(app: &mut PhotocraftApp, suggested: &str, text: String) -> Result { + // A set name can hold characters a file name can't. + let name: String = suggested.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect(); + app.pick_save(&name, move |app, path| { + let write = app.services.write.as_mut().ok_or("no writer configured")?; + write(&path, text.as_bytes())?; + Ok(json!({"path": path})) + }) +} + fn toolbar_tab(ui: &mut egui::Ui, f: &mut Map) { let mut hidden: Vec = f.get("toolbarHidden").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); egui::ScrollArea::vertical().max_height(380.0).id_salt("toolbar-scroll").show(ui, |ui| { @@ -1904,7 +2380,7 @@ fn presets_io_body(ui: &mut egui::Ui, f: &mut Map) { 180.0, ); }); - for (k, label) in [("brushes", tl!("Brushes")), ("customShapes", tl!("Custom Shapes"))] { + for (k, label) in [("brushes", tl!("Brushes")), ("customShapes", tl!("Custom Shapes")), ("tools", tl!("Tool Presets"))] { let mut on = f.get(k).and_then(Value::as_bool).unwrap_or(true); crate::widgets::checkbox(ui, &mut on, label); f.insert(k.into(), json!(on)); @@ -1918,19 +2394,28 @@ fn mismatch_body(ui: &mut egui::Ui, f: &mut Map) { ui.add_space(6.0); let mut action = f.get("action").and_then(Value::as_str).unwrap_or("preserve").to_string(); let missing = f.get("missing").and_then(Value::as_bool).unwrap_or(false); - let opts: &[(&str, &str)] = if missing { - &[("preserve", tl!("Leave as is (don't color manage)")), ("assignWorking", tl!("Assign working space"))] + let name = |k: &str| f.get(k).and_then(Value::as_str).unwrap_or("?").to_string(); + let opts: Vec<(&str, String)> = if missing { + vec![("preserve", tl!("Leave as is (don't color manage)").into()), ("assignWorking", tl!("Assign working space").into())] } else { - &[ - ("preserve", tl!("Use the embedded profile (instead of the working space)")), - ("convert", tl!("Convert document's colors to the working space")), - ("discard", tl!("Discard the embedded profile (don't color manage)")), + vec![ + ("preserve", crate::i18n::fmt(tl!("Use the embedded profile ({profile})"), &[("profile", &name("embedded"))])), + ("convert", crate::i18n::fmt(tl!("Convert to the working space ({profile})"), &[("profile", &name("working"))])), + ("discard", tl!("Discard the embedded profile (don't color manage)").into()), ] }; for (v, label) in opts { - ui.radio_value(&mut action, v.to_string(), *label); + ui.radio_value(&mut action, v.to_string(), label); } f.insert("action".into(), json!(action)); + // Remembering sets the Color Settings policy and stops asking, so it needs a mode with a policy. + if !missing && f.get("policyKey").is_some_and(Value::is_string) { + ui.add_space(8.0); + let mut remember = f.get("remember").and_then(Value::as_bool).unwrap_or(false); + crate::widgets::checkbox(ui, &mut remember, tl!("Remember this choice for future images")) + .on_hover_text(tl!("Turn the prompt back on with Ask On Mismatch in Edit › Color Settings.")); + f.insert("remember".into(), json!(remember)); + } } // ------------------------------------------------------------------ confirm @@ -1949,38 +2434,77 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result = serde_json::from_value(overrides).map_err(|e| format!("overrides must map command IDs to shortcut strings: {e}"))?; + let alternates = f.get("alternates").cloned().unwrap_or(json!({})); + let mut alt: BTreeMap> = + serde_json::from_value(alternates).map_err(|e| format!("alternates must map command IDs to lists of shortcuts: {e}"))?; let items = shortcut_items(app); // Newly assigned shortcuts are checked first and take theirs from any holder, custom - // overrides included; unchanged overrides only take theirs from defaults. + // overrides included; unchanged overrides only take theirs from defaults. A + // command's further keys (#3665) take and lose keys the same way. let before = &app.session.prefs().shortcuts; - let mut todo: Vec<(String, String, bool)> = - ov.iter().filter(|(_, s)| !s.is_empty()).map(|(k, s)| (k.clone(), s.clone(), before.get(k) != Some(s))).collect(); - todo.sort_by_key(|(_, _, new)| !new); - for (id, sc, new) in todo { - if ov.get(&id).is_some_and(String::is_empty) { - continue; // taken by a newer assignment + let before_alt = &app.session.prefs().alternate_shortcuts; + let mut todo: Vec<(String, String, bool, bool)> = + ov.iter().filter(|(_, s)| !s.is_empty()).map(|(k, s)| (k.clone(), s.clone(), before.get(k) != Some(s), false)).collect(); + for (k, keys) in &alt { + let new = before_alt.get(k) != Some(keys); + todo.extend(keys.iter().map(|s| (k.clone(), s.clone(), new, true))); + } + todo.sort_by_key(|(_, _, new, _)| !new); + let main_of = |ov: &BTreeMap, id: &str, def: Option<&str>| -> Option { + match ov.get(id) { + Some(s) if s.is_empty() => None, + Some(s) => Some(s.clone()), + None => def.map(str::to_string), } + }; + for (id, sc, new, is_alt) in todo { let norm = prefs::normalize_shortcut(&sc); + let Some(norm) = norm else { continue }; + // Taken by a newer assignment? + if (!is_alt && ov.get(&id).is_some_and(String::is_empty)) || (is_alt && alt.get(&id).is_some_and(|k| !k.contains(&norm))) { + continue; + } for (other, _, _, def) in &items { + if *other == id { + continue; + } let held = match ov.get(other) { Some(s) if new => Some(s.as_str()), Some(_) => None, None => def.as_deref(), }; - if *other != id && held.and_then(prefs::normalize_shortcut) == norm { + if held.and_then(prefs::normalize_shortcut).as_deref() == Some(norm.as_str()) { ov.insert(other.clone(), String::new()); } + // Its further keys: the edited list when this assignment is new, else only + // the defaults. + if alt.contains_key(other) && !new { + continue; + } + let mut p = prefs::Preferences::default(); + if let Some(list) = alt.get(other) { + p.alternate_shortcuts.insert(other.clone(), list.clone()); + } + let keys = p.alternates(other, main_of(&ov, other, def.as_deref()).as_deref()); + if keys.contains(&norm) { + alt.insert(other.clone(), keys.into_iter().filter(|k| *k != norm).collect()); + } } } let hidden = f.get("hidden").cloned().unwrap_or(json!([])); let colors = f.get("colors").cloned().unwrap_or(json!({})); let toolbar = f.get("toolbarHidden").cloned().unwrap_or(json!([])); - app.run("edit.keyboardShortcuts", json!({"reset": true, "set": ov, "allowUnknown": true, "removeConflicts": false}))?; + let sets = f.get("sets").cloned().unwrap_or(json!({})); + let active = f.get("activeSet").cloned().unwrap_or(json!("")); + app.run( + "edit.keyboardShortcuts", + json!({"reset": true, "set": ov, "alternates": alt, "allowUnknown": true, "removeConflicts": false, "sets": sets, "activeSet": active}), + )?; app.run("prefs.set", json!({"values": {"menus": {"hidden": hidden, "colors": colors}, "toolbar": {"hidden": toolbar}}})) } "presets" => Ok(Value::Null), "presetsIO" => { - let kinds: Vec<&str> = ["brushes", "customShapes"].into_iter().filter(|k| f.get(*k).and_then(Value::as_bool).unwrap_or(true)).collect(); + let kinds: Vec<&str> = ["brushes", "customShapes", "tools"].into_iter().filter(|k| f.get(*k).and_then(Value::as_bool).unwrap_or(true)).collect(); if f.get("action").and_then(Value::as_str) == Some("import") { app.pick_file_bytes(move |app, name, bytes| { let text = String::from_utf8(bytes).map_err(|_| format!("{name} is not a preset file"))?; @@ -2001,6 +2525,9 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { let action = f.get("action").and_then(Value::as_str).unwrap_or("preserve"); + if f.get("remember").and_then(Value::as_bool) == Some(true) { + return crate::open_prompt::answer_remembering(app, action); + } crate::open_prompt::answer(app, Some(action)) } _ => Err("unknown dialog".into()), @@ -2015,9 +2542,28 @@ mod recovery_tests; #[cfg(test)] #[path = "shortcut_capture_tests.rs"] mod shortcut_capture_tests; +#[cfg(test)] +#[path = "shortcut_set_tests.rs"] +mod shortcut_set_tests; #[cfg(test)] mod tests { + #[test] + fn memory_usage_has_a_dedicated_explanation_and_keeps_its_value() { + use egui_kittest::{Harness, kittest::Queryable}; + let values = json!({"memoryUsageMb":50000}).as_object().unwrap().clone(); + assert!(!field_visible("performance", &values, "memoryUsageMb", "")); + let mut h = Harness::builder() + .with_size(vec2(680.0, 350.0)) + .build_ui_state(|ui, values| section_fields(ui, "performance", values, &["memoryUsageMb".into()], crate::i18n::Lang::EN, None, ""), values); + h.run_steps(2); + h.get_by_label("Memory Usage"); + h.get_by_label("Per-document RAM limit (MB)"); + h.get_by_label("Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded."); + h.get_by_label("Applies immediately when saved. Memory is not reserved in advance."); + assert_eq!(h.state()["memoryUsageMb"], 50000); + } + #[test] fn colour_preferences_take_a_typed_hex_value() { // #668: the canvas colour (and every colour preference) showed its hex but couldn't take one. @@ -2067,6 +2613,21 @@ mod tests { assert_eq!(humanize("webpQuality"), "Quality"); } + #[test] + fn the_nudge_pause_is_hidden_while_nudges_are_not_bundled() { + let mut tools = Map::new(); + tools.insert("bundleNudges".into(), json!(true)); + assert!(tools_field_visible(&tools, "nudgeBundlePauseMs")); + assert!(tools_field_visible(&tools, "bundleNudges")); + tools.insert("bundleNudges".into(), json!(false)); + assert!(!tools_field_visible(&tools, "nudgeBundlePauseMs")); + assert!(tools_field_visible(&tools, "bundleNudges") && tools_field_visible(&tools, "showTooltips")); + // A section without the flag (an older preferences object) shows the field. + assert!(tools_field_visible(&Map::new(), "nudgeBundlePauseMs")); + assert_eq!(humanize("bundleNudges"), "Bundle Arrow-Key Nudges"); + assert_eq!(humanize("nudgeBundlePauseMs"), "Nudge Bundle Pause (ms)"); + } + #[test] fn rendering_mode_display_respects_explicit_mode_and_legacy_disable() { let legacy = serde_json::json!({"useGpu": false, "gpuBackend": "auto"}); @@ -2090,15 +2651,30 @@ mod tests { } #[test] - fn preferences_dialog_width_follows_the_window() { + fn preferences_dialog_width_follows_its_resizable_area() { let prefs = serde_json::json!({"__prefsui": "prefs"}); let prefs = prefs.as_object().unwrap(); assert_eq!(super::width(prefs, 200.0), Some(380.0)); assert_eq!(super::width(prefs, 800.0), Some(800.0)); - assert_eq!(super::width(prefs, 3000.0), Some(1040.0)); + // A dialog resized wider than it opens is filled, not left with empty space (#7420). + assert_eq!(super::width(prefs, 3000.0), Some(3000.0)); let shortcuts = serde_json::json!({"__prefsui": "shortcuts"}); assert_eq!(super::width(shortcuts.as_object().unwrap(), 3000.0), Some(720.0)); } + + #[test] + fn preferences_settings_fill_the_dialog_down_to_the_buttons() { + // The settings area plus the button row (spacing, 8 pt gap, buttons) stays inside what the + // resizable area gives it, at any size: the area grows to fit its content, so asking for + // more would make the dialog grow every frame. The area follows the dialog's size (#7420). + for available in [300.0, 600.0, 1400.0] { + let h = super::settings_height(available, 4.0, 30.0); + assert!(h + 4.0 + 8.0 + 30.0 <= available, "{available}"); + assert!(h + 4.0 + 8.0 + 30.0 + 8.0 >= available, "{available}"); + } + assert_eq!(super::settings_height(1400.0, 4.0, 30.0) - super::settings_height(600.0, 4.0, 30.0), 800.0); + assert_eq!(super::settings_height(50.0, 4.0, 30.0), 160.0); + } use super::*; use std::sync::{Arc, Mutex}; @@ -2166,6 +2742,65 @@ mod tests { } } + #[test] + fn darken_and_lighten_interface_step_through_photoshops_brightnesses_and_stop_at_the_ends() { + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + sync_appearance(&mut app, &ctx); + assert_eq!(app.ui.theme, ThemeKind::ProMedium, "Photoshop's default brightness"); + let press = |app: &mut PhotocraftApp, id: &str| crate::menus::invoke(app, &ctx, id, json!({})).unwrap(); + for expected in [ThemeKind::ProLightGray, ThemeKind::ProLight, ThemeKind::ProLight] { + press(&mut app, "view.lightenInterface"); + assert_eq!(app.ui.theme, expected); + } + // Saved like a choice in Preferences › Interface: the light slot and the Light appearance. + let i = &app.session.prefs().interface; + assert_eq!((i.appearance_mode, i.light_theme), (AppearanceMode::Light, LightTheme::ProLight)); + for expected in [ThemeKind::ProLightGray, ThemeKind::ProMedium, ThemeKind::Pro, ThemeKind::Pro] { + press(&mut app, "view.darkenInterface"); + assert_eq!(app.ui.theme, expected); + } + let i = &app.session.prefs().interface; + assert_eq!((i.appearance_mode, i.dark_theme), (AppearanceMode::Dark, DarkTheme::Pro)); + // Another theme steps from the nearest brightness. + for (theme, id, expected) in [ + ("studio", "view.darkenInterface", ThemeKind::Pro), + ("studio", "view.lightenInterface", ThemeKind::ProLightGray), + ("classic", "view.darkenInterface", ThemeKind::ProMedium), + ("classic", "view.lightenInterface", ThemeKind::ProLight), + ] { + app.run("prefs.set", json!({"path": "interface.theme", "value": theme})).unwrap(); + sync_appearance(&mut app, &ctx); + press(&mut app, id); + assert_eq!(app.ui.theme, expected, "{theme} {id}"); + } + } + + #[test] + fn ctrl_shift_f1_and_f2_darken_and_lighten_the_interface() { + for (id, shortcut) in [("view.darkenInterface", "Cmd+Shift+F1"), ("view.lightenInterface", "Cmd+Shift+F2")] { + assert_eq!(crate::shortcuts::default_shortcut(id).as_deref(), Some(shortcut), "{id}"); + } + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + sync_appearance(&mut app, &ctx); + let press = |app: &mut PhotocraftApp, key| { + let modifiers = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); + }; + press(&mut app, egui::Key::F1); + assert_eq!(app.ui.theme, ThemeKind::Pro); + press(&mut app, egui::Key::F2); + press(&mut app, egui::Key::F2); + assert_eq!(app.ui.theme, ThemeKind::ProLightGray); + } + fn appearance_frame(app: &mut PhotocraftApp, ctx: &egui::Context, appearance: Option) { ctx.run_ui(egui::RawInput { system_theme: appearance, ..Default::default() }, |ui| tick(app, ui.ctx())).textures_delta.clear(); } @@ -2496,6 +3131,39 @@ mod tests { serde_json::from_str(store.lock().unwrap().as_deref().unwrap_or("null")).unwrap() } + #[test] + fn a_wrongly_typed_value_resets_only_itself_and_is_repaired_on_disk() { + // #6611: one bad value used to reset every preference, on every launch. + let saved = json!({ + "fileHandling": {"recentFileCount": "ten", "autosaveMinutes": 5}, + "performance": {"historyStates": 12}, + "shortcuts": {"edit.fill": "Cmd+Shift+F", "file.open": 7}, + "futureSection": {"kept": true}, + }); + let (mut app, store) = app_with_saved(Some(saved.to_string())); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + let p = app.session.prefs(); + assert_eq!((p.file_handling.recent_file_count, p.file_handling.autosave_minutes, p.performance.history_states), (20, 5, 12)); + assert!(!app.ui.status.contains("reset"), "{}", app.ui.status); + let notice = app.ui.notices.iter().find(|n| n.title == "Some preferences were reset").expect("a notice names the bad values"); + let text = notice.text(¬ice.lines[0]); + assert!(text.contains("fileHandling.recentFileCount, shortcuts.file.open"), "{text}"); + // The file now holds the default in its place and keeps everything else. + let v = stored(&store); + assert_eq!(v["fileHandling"]["recentFileCount"], 20); + assert_eq!(v["fileHandling"]["autosaveMinutes"], 5); + assert_eq!(v["performance"]["historyStates"], 12); + assert_eq!(v["shortcuts"]["edit.fill"], "Cmd+Shift+F"); + assert!(v["shortcuts"].get("file.open").is_none()); + assert_eq!(v["futureSection"]["kept"], true, "keys this version doesn't know survive"); + // So the next launch loads cleanly. + let (mut next, _) = app_with_saved(store.lock().unwrap().clone()); + tick(&mut next, &ctx); + assert!(next.ui.notices.iter().all(|n| n.title != "Some preferences were reset")); + assert_eq!(next.session.prefs().file_handling.autosave_minutes, 5); + } + #[test] fn merge_keeps_changes_made_elsewhere() { let base = json!({"a": {"x": 1, "y": 1}, "list": [1], "gone": 1, "kept": 1}); @@ -2729,6 +3397,110 @@ mod tests { assert!(app3.ui.brush_picker_list.show_name && app3.ui.brush_picker_list.show_stroke && app3.ui.brush_picker_list.show_tip); } + #[test] + fn selected_tool_survives_a_restart_without_option_changes() { + use crate::state::Tool; + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + assert_eq!(app.ui.tool, Tool::Brush); + // Every tool, including flyout-only tools, can be restored. Return to Brush last to + // prove selecting the default overwrites a previously remembered nondefault tool. + for tool in Tool::ALL.into_iter().chain([Tool::Brush]) { + app.ui.tool = tool; + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + assert_eq!(stored(&store)["selectedTool"], serde_json::to_value(tool).unwrap()); + let (app2, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(app2.ui.tool, tool, "restored before the first frame"); + assert_eq!(app2.ui.group_tool(tool.key()), Some(tool)); + assert_eq!(app2.ui.tool_options, crate::state::ToolOptions::default()); + } + } + + #[test] + fn selected_tool_is_flushed_on_exit_without_another_frame() { + let (mut app, store) = app_with_store(); + app.ui.tool = crate::state::Tool::Move; + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (app2, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(app2.ui.tool, crate::state::Tool::Move); + } + + #[test] + fn selected_tool_explicit_eraser_survives_exit_during_temporary_switches() { + use crate::state::Tool; + for spring in [false, true] { + for menu in [false, true] { + let (mut app, store) = app_with_store(); + app.stylus.tool_before_eraser = Some(Tool::Zoom); + app.ui.tool = Tool::Eraser; + if spring { + crate::spring_tools::pressed(&mut app, 'E', Tool::Zoom, true, 0.0); + } + if menu { + crate::menus::invoke(&mut app, &egui::Context::default(), "tools.select.eraser", json!({})).unwrap(); + } else { + crate::tool_memory::select(&mut app, Tool::Eraser); + } + assert_eq!(app.stylus.tool_before_eraser, None); + assert!(app.ui.spring.is_none()); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.ui.tool, Tool::Eraser); + } + } + } + + #[test] + fn invalid_selected_tool_preserves_other_preferences() { + for bad in [json!(null), json!("FutureTool"), json!(42), json!({}), json!([])] { + let (app, _) = app_with_saved(Some( + json!({ + "selectedTool": bad, "toolOptions": {"feather": 12.0} + }) + .to_string(), + )); + assert_eq!(app.ui.tool, crate::state::Tool::Brush, "{bad}"); + assert_eq!(app.ui.tool_options.feather, 12.0); + } + } + + #[test] + fn selected_tool_excludes_temporary_switches() { + use crate::state::Tool; + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + app.ui.tool = Tool::Move; + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + app.tool_override = Some(Tool::Hand); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + assert_eq!(stored(&store)["selectedTool"], json!("Move")); + app.tool_override = None; + app.ui.tool = Tool::Zoom; + crate::spring_tools::pressed(&mut app, 'Z', Tool::Move, true, 0.0); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + assert_eq!(stored(&store)["selectedTool"], json!("Move")); + // A quick tap commits the choice after spring_tools clears the pending switch. + crate::spring_tools::tick(&mut app, &ctx); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + assert_eq!(stored(&store)["selectedTool"], json!("Zoom")); + app.stylus.tool_before_eraser = Some(Tool::Zoom); + app.ui.tool = Tool::Eraser; + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + assert_eq!(stored(&store)["selectedTool"], json!("Zoom")); + // An explicit selection during a temporary switch takes precedence. + app.ui.tool = Tool::PolygonLasso; + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + assert_eq!(stored(&store)["selectedTool"], json!("PolygonLasso")); + } + #[test] fn tool_options_survive_a_restart() { let (mut app, store) = app_with_store(); @@ -2845,11 +3617,13 @@ mod tests { assert!(!prefs::is_hidden("type.useEscToCommit")); assert!(!prefs::is_hidden("type.smartQuotes")); assert!(!prefs::is_hidden("type.showFontNamesInEnglish")); - // Rotate View with Trackpad and the pinch zoom are live; the other Enhanced Controls rows - // stay hidden. + // Rotate View with Trackpad, the pinch zoom and Enable Gestures are live; the other + // Enhanced Controls rows stay hidden. assert!(has_visible_fields(&values, "enhancedControls")); assert!(!prefs::is_hidden("enhancedControls.rotateViewWithTrackpad")); assert!(!prefs::is_hidden("enhancedControls.zoomWithTrackpadPinch")); + assert!(!prefs::is_hidden("enhancedControls.touchGestures")); + assert!(prefs::is_hidden("enhancedControls.scrubbySliderAcceleration")); // Camera Raw Defaults shows only "Open in Camera Raw" so far. assert!(has_visible_fields(&values, "rawDefaults")); assert!(!prefs::is_hidden("rawDefaults.openInCameraRaw")); @@ -2888,6 +3662,23 @@ mod tests { assert!(store2.lock().unwrap().as_ref().unwrap().contains("classic")); } + #[test] + fn preset_export_dialog_offers_tool_presets() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let opened = crate::menus::invoke(&mut app, &ctx, "edit.presets.exportImportPresets", json!({})).unwrap(); + let fields = app.ui.dialog_mut(opened["dialog"].as_u64().unwrap()).unwrap().fields.clone(); + assert_eq!(fields["tools"], true); + let mut h = Harness::builder().with_size(vec2(420.0, 260.0)).build_ui_state(presets_io_body, fields); + h.run_steps(2); + h.get_by_label("Tool Presets").click(); + h.run_steps(2); + assert_eq!(h.state()["tools"], false); + assert_eq!(h.state()["brushes"], true); + assert_eq!(h.state()["customShapes"], true); + } + #[test] fn brush_preset_store_attaches_when_loaded_and_persists() { use photocraft_engine::preset_store::{MemBackend, open}; @@ -3147,6 +3938,52 @@ mod tests { assert_eq!(effective_shortcut(&app, "file.open", Some("Cmd+O")), None, "File › Open stays cleared"); } + /// #3665: the dialog keeps a command's further keys: they move like main keys, an override + /// of the main key leaves them, and menus show only the main key. + #[test] + fn shortcut_dialog_moves_and_keeps_alternate_keys() { + let (mut app, _) = app_with_store(); + let ctx = egui::Context::default(); + let open = |app: &mut PhotocraftApp| crate::menus::invoke(app, &ctx, "edit.keyboardShortcuts", json!({})).unwrap()["dialog"].as_u64().unwrap(); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.copy"), ["F3"]); + assert_eq!(crate::shortcuts::effective_alternates(&app, "view.zoomIn"), ["Cmd++"]); + // F2 (Cut's alternate) given to Rename Layer is taken from Cut, which keeps ⌘X. + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("overrides".into(), json!({"layer.renameLayer": "F2"})); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(effective_shortcut(&app, "layer.renameLayer", None).as_deref(), Some("F2")); + assert!(crate::shortcuts::effective_alternates(&app, "edit.cut").is_empty()); + assert_eq!(effective_shortcut(&app, "edit.cut", Some("Cmd+X")).as_deref(), Some("Cmd+X")); + // Copy's main key moved to ⌘K keeps F3; the menu shows ⌘K. + let id = open(&mut app); + let mut ov = app.session.prefs().shortcuts.clone(); + ov.insert("edit.copy".into(), "Cmd+K".into()); + app.ui.dialog_mut(id).unwrap().fields.insert("overrides".into(), json!(ov)); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.copy"), ["F3"]); + assert!(crate::menus::menu_items(&app).iter().any(|i| i.id == "edit.copy" && i.shortcut.as_deref() == Some("Cmd+K"))); + // An added alternate takes its key from the main key that held it. + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("alternates".into(), json!({"edit.paste": ["F4", "Cmd+K"]})); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.paste"), ["F4", "Cmd+K"]); + assert_eq!(effective_shortcut(&app, "edit.copy", Some("Cmd+C")), None, "taken from Copy"); + // Use Default for everything restores the defaults; a malformed list is an error. + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("alternates".into(), json!({"edit.paste": "F4"})); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("alternates".into(), json!({"edit.paste": ["Cmd+Shift"]})); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.paste"), ["F4", "Cmd+K"], "nothing changed"); + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("alternates".into(), json!({})); + app.ui.dialog_mut(id).unwrap().fields.insert("overrides".into(), json!({})); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert!(app.session.prefs().alternate_shortcuts.is_empty() && app.session.prefs().shortcuts.is_empty()); + assert_eq!(crate::shortcuts::effective_alternates(&app, "edit.cut"), ["F2"]); + } + #[test] fn shortcut_dialog_rejects_malformed_overrides() { // #956: a non-map `overrides` was read as "no overrides" and wiped every custom shortcut. @@ -3183,6 +4020,16 @@ mod tests { assert!(!crate::menus::menu_items(&app).iter().any(|i| i.id == "edit.fade")); } + /// #5096: preferences that list Edit › Menus or File › Exit as hidden still show them. + #[test] + fn essential_menu_items_stay_visible() { + let (mut app, _) = app_with_store(); + app.run("prefs.set", json!({"values": {"menus": {"hidden": ["edit.menus", "file.exit", "edit.fade"], "colors": {}}}})).unwrap(); + let items = crate::menus::menu_items(&app); + assert!(items.iter().any(|i| i.id == "edit.menus") && items.iter().any(|i| i.id == "file.exit")); + assert!(!items.iter().any(|i| i.id == "edit.fade")); + } + #[test] fn autosave_runs_for_dirty_documents() { type Saved = Arc>>; @@ -3288,9 +4135,11 @@ mod tests { assert!(note.starts_with("Monitor profile in use: sRGB") && note.contains("fallback"), "{note}"); assert!(crate::menus::invoke(&mut app, &ctx, "edit.fade", json!({})).is_err()); app.run("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 8})).unwrap(); - let d = crate::menus::invoke(&mut app, &ctx, "edit.contentAwareFill", json!({})).unwrap()["dialog"].as_u64().unwrap(); - assert_eq!(app.ui.dialogs.iter().find(|x| x.id == d).unwrap().fields["__preview"], true); - crate::dialogs::confirm(&mut app, d).unwrap(); + // Content-Aware Fill opens its workspace (caf_ui); OK fills. + let r = crate::menus::invoke(&mut app, &ctx, "edit.contentAwareFill", json!({})).unwrap(); + assert!(r["contentAwareFill"]["window"].is_array(), "{r}"); + crate::caf_ui::commit(&mut app); + assert!(app.distort.caf.is_none()); let r = crate::menus::invoke(&mut app, &ctx, "edit.contentAwareScale", json!({})).unwrap(); assert_eq!(r["transform"]["rect"], json!([4.0, 4.0, 12.0, 12.0])); crate::transform_tool::cancel(&mut app); diff --git a/crates/ui-egui/src/preset_files_ui.rs b/crates/ui-egui/src/preset_files_ui.rs index 6138c08844..1ea0725838 100644 --- a/crates/ui-egui/src/preset_files_ui.rs +++ b/crates/ui-egui/src/preset_files_ui.rs @@ -4,7 +4,8 @@ //! `gradient.presets.importGrd`, swatches (`.aco`, `.ase`) to the Swatches panel through //! `swatches.import`, patterns (`.pat`) to the Patterns panel through `pattern.import`, keyboard //! shortcut sets (`.kys`) to Edit › Keyboard Shortcuts through -//! [`crate::kys_import`], instead of opening as documents. +//! [`crate::kys_import`], instead of opening as documents. Import Brushes… also takes an image +//! file, which becomes a brush tip through `brush.presets.importImage` (TOOL-213-12). use serde_json::{Value, json}; @@ -25,6 +26,36 @@ pub fn is_preset_file(name: &str) -> bool { PRESET_EXTS.contains(&ext(name).as_str()) } +/// Image files Import Brushes… offers besides `.abr`: each becomes a sampled brush tip. +pub const BRUSH_IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "psd", "psb", "bmp", "gif", "webp", "tga"]; + +/// Brushes panel / brush preset picker › Import Brushes…: an `.abr` joins the brush library as a +/// group (as File › Open does), an image file becomes a new brush preset with that image as its tip. +pub fn import_brushes(app: &mut PhotocraftApp) -> Result { + let exts: Vec<&str> = std::iter::once("abr").chain(BRUSH_IMAGE_EXTS.iter().copied()).collect(); + app.pick_file_bytes_filtered(&exts, |app, name, bytes| match open(app, &name, &bytes) { + Some(r) => r.map(|()| Value::Null), + None => import_brush_image(app, &name, &bytes), + }) +} + +/// An image file as a brush tip: a new preset named after the file, made the current brush. +pub fn import_brush_image(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Result { + let file = std::path::Path::new(name).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string()); + let mut p = source(name, bytes); + if p.get("data").is_some() { + p["fileName"] = json!(file); + } + let r = app.run("brush.presets.importImage", p)?; + let preset = r.get("name").and_then(Value::as_str).unwrap_or_default(); + app.ui.status_error = false; + app.ui.status = format!("Imported brush {preset} from {file}"); + // Show it: Brush Settings › Brushes tab. + app.ui.panels.brush_settings = true; + app.ui.brush_tab = 1; + Ok(r) +} + /// Import from a gradient picker through the same preset command as File › Open. pub(crate) fn import_gradients_dialog(app: &mut PhotocraftApp) { let result = app.pick_file_bytes_filtered(&["grd"], |app, name, bytes| { @@ -114,4 +145,33 @@ mod tests { assert!(!is_preset_file("photo.psd")); assert!(!is_preset_file("abr")); } + + /// Import Brushes… with a PNG: the image becomes the current brush's tip (TOOL-213-12). + #[test] + fn import_brushes_turns_an_image_into_a_brush_tip() { + use photocraft_engine::paint::TipShape; + let mut px = vec![255u8; 16 * 16]; + for y in 4..12 { + for x in 6..9 { + px[y * 16 + x] = 0; + } + } + let img = photocraft_codecs::Image::from_u8(16, 16, photocraft_codecs::ChannelLayout::Gray, px).unwrap(); + let png = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let (show, asked) = crate::file_dialog::fake(vec![Some(crate::file_dialog::FileDialogAnswer::Contents("Leaf.png".into(), png))]); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services { file_dialog: Some(show), ..Default::default() }); + import_brushes(&mut app).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + match asked.borrow().first() { + Some(crate::file_dialog::FileDialogRequest::Open { extensions: Some(e), .. }) => { + assert!(e.iter().any(|x| x == "abr") && e.iter().any(|x| x == "png"), "{e:?}") + } + other => panic!("expected a filtered open dialog, got {other:?}"), + } + assert!(!app.ui.status_error, "{}", app.ui.status); + assert_eq!(app.session.tools.current_preset.as_deref(), Some("Leaf")); + let TipShape::Sampled(tip) = &app.session.tools.brush.tip else { panic!("sampled tip expected") }; + assert_eq!((tip.width, tip.height), (3, 8)); + assert!(app.ui.panels.brush_settings && app.ui.brush_tab == 1, "the Brushes tab shows the new brush"); + } } diff --git a/crates/ui-egui/src/preset_panels.rs b/crates/ui-egui/src/preset_panels.rs index 7e9e9b6727..b52f1400d7 100644 --- a/crates/ui-egui/src/preset_panels.rs +++ b/crates/ui-egui/src/preset_panels.rs @@ -334,12 +334,20 @@ const FOOTER_GUESS: f32 = 32.0; /// Draws the folders and footer; `thumb` paints item `(group, item)` into a rect. /// Where a browser sits: the canvas (drop target), its height cap and the New button's tooltip. +/// `flush` lays the grid out as Photoshop's Swatches panel: the cells touch, square-cornered and +/// without a border of their own, so no screen is spent on gaps (#5103); gradients, patterns and +/// the other preset grids keep their [`GRID_GAP`] and card borders. pub(crate) struct Place<'a> { pub(crate) canvas: Rect, pub(crate) max_h: f32, pub(crate) new_tip: &'a str, + pub(crate) flush: bool, } +/// Space between the cells of a preset grid, and the grid's inset from the panel's left edge. +const GRID_GAP: f32 = 4.0; +const GRID_INSET: f32 = 8.0; + pub(crate) fn browser( ui: &mut egui::Ui, st: &mut PresetUi, @@ -349,7 +357,7 @@ pub(crate) fn browser( thumb: &mut dyn FnMut(&egui::Ui, Rect, usize, usize), ) -> Vec { let t = Tokens::get(ui.ctx()); - let Place { canvas, max_h, new_tip } = place; + let Place { canvas, max_h, new_tip, flush } = place; let mut ev = Vec::new(); let size = *st.sizes.get(panel).unwrap_or(&40.0); let list = size <= MIN_THUMB + 0.5; @@ -439,15 +447,15 @@ pub(crate) fn browser( continue; } let w = ui.available_width(); + let pad = if list { 0.0 } else { GRID_INSET }; + let gap = if list || flush { 0.0 } else { GRID_GAP }; let (cell, cols) = if list { (vec2(w, 24.0), 1) } else { - let cols = ((w - 8.0 + 4.0) / (size + 4.0)).floor().max(1.0) as usize; + let cols = ((w - pad + gap) / (size + gap)).floor().max(1.0) as usize; (vec2(size, size), cols) }; let rows = g.items.len().div_ceil(cols); - let pad = if list { 0.0 } else { 8.0 }; - let gap = if list { 0.0 } else { 4.0 }; let (area, _) = ui.allocate_exact_size(vec2(w, rows as f32 * (cell.y + gap) + 2.0), Sense::hover()); for (ii, it) in g.items.iter().enumerate() { let (cx, cy) = ((ii % cols) as f32, (ii / cols) as f32); @@ -466,14 +474,20 @@ pub(crate) fn browser( ui.painter().text(pos2(tr.right() + 8.0, r.center().y), Align2::LEFT_CENTER, &it.name, FontId::proportional(11.5), t.text); } else { thumb(ui, r, gi, ii); - let (stroke, kind) = if is_sel { - (Stroke::new(2.0, t.accent), egui::StrokeKind::Outside) + // Touching cells draw no border of their own: only the selected and the + // hovered one are outlined, over their neighbours, as in Photoshop. + let stroke = if is_sel { + Some((Stroke::new(2.0, t.accent), egui::StrokeKind::Outside)) } else if resp.hovered() { - (Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside) + Some((Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside)) + } else if flush { + None } else { - (Stroke::new(1.0, t.card_border), egui::StrokeKind::Inside) + Some((Stroke::new(1.0, t.card_border), egui::StrokeKind::Inside)) }; - ui.painter().rect_stroke(r, CornerRadius::same(2), stroke, kind); + if let Some((stroke, kind)) = stroke { + ui.painter().rect_stroke(r, CornerRadius::same(if flush { 0 } else { 2 }), stroke, kind); + } } let resp = if list { resp } else { resp.on_hover_text(&it.name) }; if resp.double_clicked() { @@ -667,10 +681,11 @@ pub fn gradients_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let canvas = canvas_drop_rect(app); let mut st = std::mem::take(&mut app.ui.presets_ui); let max_h = (ui.available_height() - 40.0).clamp(90.0, 260.0); - let events = browser(ui, &mut st, "gradients", &groups, Place { canvas, max_h, new_tip: tl!("Create new gradient") }, &mut |ui, r, gi, ii| { - let stops = groups_src[gi].items[ii].resolve(fg, bg); - paint_gradient(ui, r, &stops); - }); + let events = + browser(ui, &mut st, "gradients", &groups, Place { canvas, max_h, new_tip: tl!("Create new gradient"), flush: false }, &mut |ui, r, gi, ii| { + let stops = groups_src[gi].items[ii].resolve(fg, bg); + paint_gradient(ui, r, &stops); + }); app.ui.presets_ui = st; for e in events { match &e { @@ -715,11 +730,17 @@ pub fn patterns_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut st = std::mem::take(&mut app.ui.presets_ui); let max_h = (ui.available_height() - 40.0).clamp(90.0, 280.0); let ctx = ui.ctx().clone(); - let events = - browser(ui, &mut st, "patterns", &groups, Place { canvas, max_h, new_tip: tl!("Create new pattern from the selection") }, &mut |ui, r, gi, ii| { + let events = browser( + ui, + &mut st, + "patterns", + &groups, + Place { canvas, max_h, new_tip: tl!("Create new pattern from the selection"), flush: false }, + &mut |ui, r, gi, ii| { let tex = pattern_texture(&ctx, &pats[gi][ii]); ui.painter().image(tex.id(), r, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); - }); + }, + ); app.ui.presets_ui = st; for e in events { match &e { @@ -820,7 +841,7 @@ pub fn styles_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { &mut st, "styles", &groups, - Place { canvas, max_h: 300.0, new_tip: tl!("Create new style from the selected layer") }, + Place { canvas, max_h: 300.0, new_tip: tl!("Create new style from the selected layer"), flush: false }, &mut |ui, r, gi, ii| { ui.painter().rect_filled(r, 2.0, t.field); ui.painter().image(texes[gi][ii].id(), r, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); @@ -864,7 +885,7 @@ pub fn shapes_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { &mut st, "shapes", &groups, - Place { canvas, max_h: 300.0, new_tip: tl!("Create new shape from the current path") }, + Place { canvas, max_h: 300.0, new_tip: tl!("Create new shape from the current path"), flush: false }, &mut |ui, r, gi, ii| { let tex = shape_texture(&ctx, &src[gi].items[ii]); ui.painter().image(tex.id(), r.shrink(2.0), Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), t.text); @@ -981,9 +1002,10 @@ fn tool_id(t: Tool) -> String { /// Apply a tool preset: the engine sets the brush/colour, the shell switches tool and options. pub fn select_tool_preset(app: &mut PhotocraftApp, name: &str) { + let before_ratio = crate::crop_ui::preset_ratio(app); let Some(r) = run(app, "tool.presets.select", json!({"preset": name})) else { return }; if let Some(tool) = r["tool"].as_str().and_then(Tool::from_name) { - app.ui.tool = tool; + crate::tool_memory::select(app, tool); // Each tool keeps its own brush (#218), so switch it in before the preset's brush lands // on it, and re-apply the brush the command just set for the old tool. crate::paint_mouse::sync_tool_brush(app); @@ -995,13 +1017,39 @@ pub fn select_tool_preset(app: &mut PhotocraftApp, name: &str) { let mut cur = serde_json::to_value(&app.ui.tool_options).unwrap_or_default(); if let Some(c) = cur.as_object_mut() { for (k, v) in o { - c.insert(k.clone(), v.clone()); + // An object option (`sample_all`, one per tool) takes only the entries the + // preset stored, so the other tools' keep theirs (#5524). + match (c.get_mut(k), v) { + (Some(Value::Object(have)), Value::Object(set)) => have.extend(set.clone()), + _ => { + c.insert(k.clone(), v.clone()); + } + } } } if let Ok(opts) = serde_json::from_value(cur) { app.ui.tool_options = opts; } } + // As the options bar does: a crop preset's ratio (or W x H) fits the frame at once. + if crate::crop_ui::preset_ratio(app) != before_ratio { + crate::crop_ui::apply_ratio(app); + } +} + +/// The `tool.presets.new` params for the + button: `tool`'s own options only, so choosing the +/// preset later leaves the other tools' settings alone. +fn new_tool_preset_params(app: &PhotocraftApp, tool: Tool) -> Value { + let all = serde_json::to_value(&app.ui.tool_options).unwrap_or_default(); + let mut own: serde_json::Map = + crate::tool_reset::option_keys(tool).iter().filter_map(|k| Some(((*k).to_string(), all.get(*k)?.clone()))).collect(); + // Sample All Layers is each tool's own (#5524): store `tool`'s alone. + if let Some(k) = crate::tool_reset::sample_all_key(tool) + && let Some(v) = all.get("sample_all").and_then(|m| m.get(&k)) + { + own.insert("sample_all".into(), json!({ k: v })); + } + json!({"name": tool.label(), "tool": tool_id(tool), "options": {"toolOptions": own}}) } pub fn tool_presets_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { @@ -1072,9 +1120,7 @@ pub fn tool_presets_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { action = Some(("tool.presets.edit".into(), json!({"action": "delete", "preset": n}))); } if crate::icons::button(ui, "plus", 24.0, false, tl!("Create new tool preset")).clicked() { - let label = cur.label().to_string(); - let opts = json!({"toolOptions": serde_json::to_value(&app.ui.tool_options).unwrap_or_default()}); - action = Some(("tool.presets.new".into(), json!({"name": label, "tool": tool_id(cur), "options": opts}))); + action = Some(("tool.presets.new".into(), new_tool_preset_params(app, cur))); } }); }); @@ -1241,6 +1287,7 @@ pub fn clone_sample_point(app: &PhotocraftApp, at: Option<[f64; 2]>) -> Option<[ mod tests { use super::*; use crate::menus::invoke; + use crate::state::ToolOptions; fn app() -> (PhotocraftApp, egui::Context) { let ctx = egui::Context::default(); @@ -1290,6 +1337,59 @@ mod tests { assert_eq!(Tool::from_name(&tool_id(Tool::CustomShape)), Some(Tool::CustomShape)); } + /// A crop preset picked in Tool Presets fits the frame to its ratio at once, as the options + /// bar's dropdown does. + #[test] + fn a_crop_tool_preset_fits_the_frame_to_its_ratio() { + let (mut app, _) = app(); + app.ui.tool = Tool::Crop; + crate::crop_ui::ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 64.0, 48.0])); + app.run("tool.presets.new", json!({"name": "Square", "tool": "crop", "options": {"toolOptions": {"crop_ratio": "1:1"}}})).unwrap(); + select_tool_preset(&mut app, "Square"); + assert_eq!(app.ui.tool_options.crop_ratio, "1:1"); + assert_eq!(app.ui.crop_rect, Some([8.0, 0.0, 56.0, 48.0])); + } + + /// #7356: a preset made with + keeps only its own tool's options, so choosing it doesn't + /// reset the Crop tool's Delete Cropped Pixels or the Move tool's Auto-Select. + #[test] + fn new_tool_preset_stores_only_its_tools_options() { + let (mut app, _) = app(); + app.ui.tool = Tool::RectMarquee; + app.ui.tool_options.feather = 5.0; + let p = new_tool_preset_params(&app, Tool::RectMarquee); + let r = app.run("tool.presets.new", p).unwrap(); + let name = r["name"].as_str().unwrap().to_string(); + let stored = &app.session.presets.tool_presets.iter().find(|t| t.name == name).unwrap().options["toolOptions"]; + assert_eq!(stored["feather"], json!(5.0)); + assert!(stored.as_object().unwrap().keys().all(|k| !k.starts_with("crop_") && !k.starts_with("move_")), "{stored}"); + app.ui.tool_options.feather = 0.0; + app.ui.tool_options.crop_delete = !ToolOptions::default().crop_delete; + app.ui.tool_options.move_auto_select = !ToolOptions::default().move_auto_select; + select_tool_preset(&mut app, &name); + assert_eq!(app.ui.tool_options.feather, 5.0); + assert_eq!(app.ui.tool_options.crop_delete, !ToolOptions::default().crop_delete); + assert_eq!(app.ui.tool_options.move_auto_select, !ToolOptions::default().move_auto_select); + } + + /// #5524 with #7356: a Blur preset keeps Blur's Sample All Layers and leaves the Magic Wand's + /// alone, both when it is made and when it is chosen. + #[test] + fn new_tool_preset_stores_only_its_tools_sample_all_layers() { + let (mut app, _) = app(); + app.ui.tool = Tool::Blur; + app.ui.tool_options.sample_all.blur = true; + let p = new_tool_preset_params(&app, Tool::Blur); + assert_eq!(p["options"]["toolOptions"]["sample_all"], json!({"blur": true})); + let name = app.run("tool.presets.new", p).unwrap()["name"].as_str().unwrap().to_string(); + app.ui.tool_options.sample_all.blur = false; + app.ui.tool_options.sample_all.magic_wand = true; + select_tool_preset(&mut app, &name); + assert!(app.ui.tool_options.sample_all.blur); + assert!(app.ui.tool_options.sample_all.magic_wand, "the Magic Wand's Sample All Layers is not Blur's"); + } + #[test] fn custom_shape_tool_places_the_picked_shape() { let (mut app, _) = app(); diff --git a/crates/ui-egui/src/pressure_curve_ui.rs b/crates/ui-egui/src/pressure_curve_ui.rs index cb9880733f..0c8c5b7c4d 100644 --- a/crates/ui-egui/src/pressure_curve_ui.rs +++ b/crates/ui-egui/src/pressure_curve_ui.rs @@ -1,5 +1,6 @@ //! Preferences › Tools › Pressure Curve: the pen's pressure response, edited as points on a curve //! (`tools.pressureCurve`, read by the stylus through `photocraft_engine::prefs::PressureCurve`). +//! Brush Settings uses the same editor for each dynamic's own curve ([`edit_points`]). //! Drag a point to move it, click empty space to add one, right-click a point to remove it. use egui::{Pos2, Sense, Stroke, Vec2, pos2}; @@ -46,17 +47,25 @@ fn round2(x: f32) -> f64 { /// The editor. Returns the new value when the curve changed. pub(crate) fn editor(ui: &mut egui::Ui, value: &Value) -> Option { - let t = Tokens::get(ui.ctx()); let mut points = points_of(value); + let changed = edit_points(ui, egui::Id::new(RECT_KEY), &mut points); + changed.then(|| value_of(&points)) +} + +/// The editor over a list of points (fewer than two reads as linear). `rect_key` is the egui +/// memory key the editing square's rectangle is stored under, for tests and automation. Returns +/// whether the points changed; they are rounded to two decimals then. +pub(crate) fn edit_points(ui: &mut egui::Ui, rect_key: egui::Id, points: &mut Vec<[f32; 2]>) -> bool { + let t = Tokens::get(ui.ctx()); if points.len() < 2 { - points = vec![[0.0, 0.0], [1.0, 1.0]]; + *points = vec![[0.0, 0.0], [1.0, 1.0]]; } let mut changed = false; // The square with the presets beside it, so the control fits the preferences page's height. ui.horizontal_top(|ui| { let (rect, resp) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click_and_drag()); // Where the square is this frame (tests and automation point at curve coordinates). - ui.data_mut(|d| d.insert_temp(egui::Id::new(RECT_KEY), rect)); + ui.data_mut(|d| d.insert_temp(rect_key, rect)); let to_screen = |p: [f32; 2]| pos2(rect.left() + p[0] * rect.width(), rect.bottom() - p[1] * rect.height()); let to_curve = |s: Pos2| [((s.x - rect.left()) / rect.width()).clamp(0.0, 1.0), ((rect.bottom() - s.y) / rect.height()).clamp(0.0, 1.0)]; let painter = ui.painter_at(rect.expand(4.0)); @@ -80,7 +89,7 @@ pub(crate) fn editor(ui: &mut egui::Ui, value: &Value) -> Option { if resp.drag_started() && let Some(at) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) { - let grabbed = nearest(at, &points); + let grabbed = nearest(at, points); ui.data_mut(|d| d.insert_temp(drag_id, grabbed)); } let dragging: Option = ui.data(|d| d.get_temp(drag_id)).flatten(); @@ -95,7 +104,7 @@ pub(crate) fn editor(ui: &mut egui::Ui, value: &Value) -> Option { } if resp.clicked() && let Some(at) = resp.interact_pointer_pos() - && nearest(at, &points).is_none() + && nearest(at, points).is_none() && points.len() < PressureCurve::MAX_POINTS { points.push(to_curve(at)); @@ -103,14 +112,14 @@ pub(crate) fn editor(ui: &mut egui::Ui, value: &Value) -> Option { } if resp.secondary_clicked() && let Some(at) = resp.interact_pointer_pos() - && let Some(i) = nearest(at, &points) + && let Some(i) = nearest(at, points) && points.len() > 2 { points.remove(i); changed = true; } - let curve = PressureCurve::new(&points); + let curve = PressureCurve::new(points); let line: Vec = (0..=64).map(|i| i as f32 / 64.0).map(|x| to_screen([x, curve.eval(x)])).collect(); painter.add(egui::Shape::line(line, Stroke::new(2.0, t.accent))); for (i, p) in points.iter().enumerate() { @@ -122,13 +131,19 @@ pub(crate) fn editor(ui: &mut egui::Ui, value: &Value) -> Option { ui.vertical(|ui| { for (name, preset) in PRESETS { if ui.small_button(tl!(name)).clicked() { - points = preset.to_vec(); + *points = preset.to_vec(); changed = true; } } }); }); - changed.then(|| value_of(&points)) + if changed { + // Two decimals: the stored value stays readable and a drag doesn't write float noise. + for p in points.iter_mut() { + *p = [round2(p[0]) as f32, round2(p[1]) as f32]; + } + } + changed } #[cfg(test)] diff --git a/crates/ui-egui/src/profile_open_tests.rs b/crates/ui-egui/src/profile_open_tests.rs index 6828da6f70..3eeb06b003 100644 --- a/crates/ui-egui/src/profile_open_tests.rs +++ b/crates/ui-egui/src/profile_open_tests.rs @@ -13,8 +13,8 @@ use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; use photocraft_engine::Session; use serde_json::Value; -const USE: &str = "Use the embedded profile (instead of the working space)"; -const CONVERT: &str = "Convert document's colors to the working space"; +const USE: &str = "Use the embedded profile (Adobe RGB (1998)-compatible (PhotoCraft))"; +const CONVERT: &str = "Convert to the working space (sRGB IEC61966-2.1)"; const DISCARD: &str = "Discard the embedded profile (don't color manage)"; /// A PNG tagged with Adobe RGB, which doesn't match the sRGB working space. @@ -135,35 +135,36 @@ fn files_opened_together_ask_one_at_a_time_before_each_opens() { } } -/// The prompt reads in the interface language: its sentence, the Embedded and Working lines and -/// the mode's name (French "RVB"). The file's name goes in as it is, braces and all. +/// The prompt reads in the interface language: its sentence, the mode's name (French "RVB") and +/// the choices, which name the embedded and working profiles. The file's name goes in as it is, +/// braces and all. #[test] fn the_prompt_is_translated_and_still_names_its_file() { let png = adobe_png(); - for (code, sentence, embedded, working) in [ + for (code, sentence, use_embedded, convert) in [ ( "en", "The document “shot {mode}.png” has an embedded color profile that does not match the current RGB working space.", - "Embedded: Adobe RGB", - "Working: sRGB", + "Use the embedded profile (Adobe RGB", + "Convert to the working space (sRGB", ), ( "de", "Das Dokument „shot {mode}.png“ hat ein eingebettetes Farbprofil, das nicht zum aktuellen RGB-Arbeitsfarbraum passt.", - "Eingebettet: Adobe RGB", - "Arbeitsfarbraum: sRGB", + "Eingebettetes Profil verwenden (Adobe RGB", + "In den Arbeitsfarbraum konvertieren (sRGB", ), ( "fr", "Le document «NBSPshot {mode}.pngNBSP» a un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail RVB actuel.", - "IncorporéNBSP: Adobe RGB", - "Espace de travailNBSP: sRGB", + "Utiliser le profil incorporé (Adobe RGB", + "Convertir vers l'espace de travail (sRGB", ), ( "ja", "ドキュメント「shot {mode}.png」の埋め込みカラープロファイルは、現在の RGB 作業用スペースと一致しません。", - "埋め込み: Adobe RGB", - "作業用スペース: sRGB", + "埋め込みプロファイルを使用 (Adobe RGB", + "作業用スペースに変換 (sRGB", ), ] { let services = Services { @@ -177,12 +178,18 @@ fn the_prompt_is_translated_and_still_names_its_file() { let lang = crate::i18n::Lang::from_code(code).unwrap(); crate::i18n::with_language(lang, || app.open_file("/pics/shot {mode}.png", &png)).unwrap(); assert!(app.session.documents().is_empty(), "{code}: held until answered"); - let message = app.ui.dialogs[0].fields.get("message").and_then(Value::as_str).unwrap().to_string(); - let lines: Vec<&str> = message.lines().collect(); - assert_eq!(lines.len(), 3, "{code}: {message}"); - assert_eq!(lines[0], sentence.replace("NBSP", "\u{a0}"), "{code}"); - assert!(lines[1].starts_with(&embedded.replace("NBSP", "\u{a0}")), "{code}: {}", lines[1]); - assert!(lines[2].starts_with(&working.replace("NBSP", "\u{a0}")), "{code}: {}", lines[2]); + let fields = app.ui.dialogs[0].fields.clone(); + let message = fields.get("message").and_then(Value::as_str).unwrap().to_string(); + assert_eq!(message, sentence.replace("NBSP", "\u{a0}"), "{code}: one line, the profiles are on the choices"); + let name = |k: &str| fields.get(k).and_then(Value::as_str).unwrap().to_string(); + let (use_label, convert_label) = crate::i18n::with_language(lang, || { + ( + crate::i18n::fmt(tl!("Use the embedded profile ({profile})"), &[("profile", &name("embedded"))]), + crate::i18n::fmt(tl!("Convert to the working space ({profile})"), &[("profile", &name("working"))]), + ) + }); + assert!(use_label.starts_with(use_embedded), "{code}: {use_label}"); + assert!(convert_label.starts_with(convert), "{code}: {convert_label}"); } } @@ -253,3 +260,40 @@ fn cancel_opens_neither_the_file_nor_the_ones_still_to_come() { assert_eq!(asked(&h), "d.png"); } } + +/// "Remember this choice": one answer opens the files still waiting with it, saves it as the +/// Color Settings policy and stops asking, so the next mismatched file opens without a prompt. +#[test] +fn remember_answers_the_waiting_files_and_stops_asking() { + for background in [false, true] { + let mut h = harness(background); + open_three(&mut h); + click(&mut h, CONVERT); + click(&mut h, "Remember this choice for future images"); + click(&mut h, "OK"); + assert!(h.state().ui.dialogs.is_empty(), "background {background}: no prompt is left"); + let mut opened = docs(&h); + opened.sort(); + assert_eq!(opened, ["a.png", "b.png", "c.png"], "background {background}"); + for name in ["a.png", "b.png", "c.png"] { + assert_eq!(profile(&h, name).as_deref(), Some("sRGB IEC61966-2.1"), "background {background}: {name} converted"); + } + let settings = &h.state().session.color.settings; + assert_eq!(settings.policy(photocraft_doc::ColorMode::Rgb), photocraft_engine::color_cmds::Policy::Convert); + assert!(!settings.ask_on_mismatch, "background {background}"); + h.state_mut().open_file("/pics/d.png", &adobe_png()).unwrap(); + settle(&mut h); + assert!(h.state().ui.dialogs.is_empty(), "background {background}: the next file doesn't ask"); + assert_eq!(profile(&h, "d.png").as_deref(), Some("sRGB IEC61966-2.1"), "background {background}"); + } +} + +/// Without Remember, OK answers only the file asked about. +#[test] +fn without_remember_the_next_file_still_asks() { + let mut h = harness(false); + open_three(&mut h); + answer(&mut h, CONVERT); + assert_eq!(h.state().ui.dialogs.len(), 1); + assert!(h.state().session.color.settings.ask_on_mismatch); +} diff --git a/crates/ui-egui/src/profile_prompt_tests.rs b/crates/ui-egui/src/profile_prompt_tests.rs index cec2a8c289..e055cdd546 100644 --- a/crates/ui-egui/src/profile_prompt_tests.rs +++ b/crates/ui-egui/src/profile_prompt_tests.rs @@ -34,7 +34,7 @@ fn harness(background: bool) -> Harness<'static, PhotocraftApp> { fn open_together(h: &mut Harness<'static, PhotocraftApp>, n: usize) { let message = "The document “shot.png” has an embedded color profile that does not match the current RGB working space."; for _ in 0..n { - crate::prefs_ui::open_mismatch(h.state_mut(), message, "preserve", false, "__stacked"); + crate::prefs_ui::open_mismatch(h.state_mut(), message, "preserve", false, "__stacked", serde_json::json!({})); } for _ in 0..100 { h.run_steps(2); diff --git a/crates/ui-egui/src/props_layout.rs b/crates/ui-egui/src/props_layout.rs index 674ff4dba2..ea67b3714d 100644 --- a/crates/ui-egui/src/props_layout.rs +++ b/crates/ui-egui/src/props_layout.rs @@ -17,6 +17,17 @@ pub const LABEL_GAP: f32 = 4.0; /// Horizontal gap between two label+field columns. pub const COL_GAP: f32 = 8.0; +/// A one-letter field label ("W", "H", "X", "Y") in a `w`-wide column, ending `pad` before its field. The letters +/// stacked in one column share a centre (#7314): the widest of `column` stays flush right, and the narrower ones +/// are centred on it rather than pushed against its right edge. +pub fn stacked_label(ui: &mut egui::Ui, s: &str, column: &[&str], w: f32, pad: f32) { + let t = Tokens::get(ui.ctx()); + let font = egui::FontId::proportional(12.0); + let widest = column.iter().map(|c| ui.painter().layout_no_wrap((*c).to_owned(), font.clone(), t.text_dim).size().x).fold(0.0, f32::max); + let (r, _) = ui.allocate_exact_size(vec2(w, 22.0), Sense::hover()); + ui.painter().text(pos2(r.right() - pad - widest / 2.0, r.center().y), egui::Align2::CENTER_CENTER, s, font, t.text_dim); +} + /// Icon for a layer kind (the same glyphs as the Layers panel's kind filter). pub fn kind_icon(content: &LayerContent) -> &'static str { match content { diff --git a/crates/ui-egui/src/proxy.rs b/crates/ui-egui/src/proxy.rs index dc30cc3148..a3a73c7542 100644 --- a/crates/ui-egui/src/proxy.rs +++ b/crates/ui-egui/src/proxy.rs @@ -27,6 +27,13 @@ pub fn preview_factor(doc: &Document, reduced: bool) -> u32 { if reduced { factor(doc) } else { 1 } } +/// A live preview's factor `k` capped for a view at `zoom` (physical screen pixels per document +/// pixel): never coarser than the screen ([`crate::adjust_preview::proxy_factor`]), so a view at +/// 100 % shows full resolution instead of k-pixel blocks while a control moves (#5764). +pub fn view_factor(k: u32, zoom: f32) -> u32 { + k.min(crate::adjust_preview::proxy_factor(zoom)).max(1) +} + pub use photocraft_compose::proxy::{downsample, proxy_document}; #[cfg(test)] @@ -48,6 +55,23 @@ mod tests { assert_eq!(preview_factor(&small, true), 1); } + /// #5764: dragging a Curves point on a large document showed the canvas in k-pixel blocks at + /// any zoom. The proxy may only be as coarse as the screen. + #[test] + fn view_factor_never_coarser_than_the_screen() { + let big = Document::new("b", Size::new(6016, 6016), ColorMode::Rgb, SampleType::U8); + let k = preview_factor(&big, true); + assert!(k > 2, "{k}"); + assert_eq!(view_factor(k, 1.0), 1, "100 %: full resolution"); + assert_eq!(view_factor(k, 2.0), 1, "zoomed in: full resolution"); + assert_eq!(view_factor(k, 0.6), 1); + assert_eq!(view_factor(k, 0.5), 2); + assert_eq!(view_factor(k, 0.1), k, "far out: the document's proxy"); + assert_eq!(view_factor(1, 0.1), 1, "small documents stay full size"); + assert_eq!(view_factor(k, f32::NAN), 1); + assert_eq!(view_factor(0, 0.1), 1); + } + #[test] fn proxy_keeps_structure_and_ids() { let doc = Document::with_background("d", Size::new(4000, 3000), ColorMode::Rgb, SampleType::U8, Color::WHITE); diff --git a/crates/ui-egui/src/puppet_ui.rs b/crates/ui-egui/src/puppet_ui.rs index b08546a51d..e233396cfd 100644 --- a/crates/ui-egui/src/puppet_ui.rs +++ b/crates/ui-egui/src/puppet_ui.rs @@ -454,6 +454,38 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + /// #5092: a Puppet Warp session left open does not follow the user to a new document. + #[test] + fn session_is_dropped_when_another_document_becomes_active() { + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": 100, "height": 80, "depth": 8})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(20, 30, 80, 50), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.sync_views(); + crate::distort_ui::menu(&mut app, &ctx, "edit.puppetWarp", &json!({})).unwrap().unwrap(); + crate::distort_ui::drop_foreign(&mut app); + assert!(app.distort.puppet.is_some()); + app.session.execute("file.new", json!({"width": 32, "height": 32, "depth": 8})).unwrap(); + assert!(crate::distort_ui::display_doc(&app, 1).is_some()); + crate::distort_ui::drop_foreign(&mut app); + assert!(app.distort.puppet.is_none()); + assert!(crate::distort_ui::display_doc(&app, 1).is_none()); + // Closing the document it was started on drops it too. + assert!(app.session.set_active(0)); + crate::distort_ui::menu(&mut app, &ctx, "edit.puppetWarp", &json!({})).unwrap().unwrap(); + crate::distort_ui::drop_foreign(&mut app); + assert!(app.distort.puppet.is_some()); + assert!(app.session.close(0).is_some()); + crate::distort_ui::drop_foreign(&mut app); + assert!(app.distort.puppet.is_none()); + } + /// #4440: ⌘Z / ⌘⇧Z undo and redo pin edits while Puppet Warp is open: placing a pin, /// dragging one and removing one are a step each; a click that moves nothing isn't one. #[test] diff --git a/crates/ui-egui/src/rasterize_prompt_tests.rs b/crates/ui-egui/src/rasterize_prompt_tests.rs index 510723beb6..9e6a10583c 100644 --- a/crates/ui-egui/src/rasterize_prompt_tests.rs +++ b/crates/ui-egui/src/rasterize_prompt_tests.rs @@ -137,7 +137,7 @@ fn a_stale_or_bad_prompt_fails_gracefully() { app.run("layer.rasterize.type", json!({})).unwrap(); assert!(crate::dialogs::confirm(&mut app, id).is_err()); // Garbage fields. - for f in [json!({"__rasterize": "plaid", "layer": 1}), json!({"__rasterize": "type"}), json!({"__rasterize": "type", "layer": 999_999})] { + for f in [json!({"__rasterize": "plaid", "layer": 1}), json!({"__rasterize": "type"}), json!({"__rasterize": "type", "layer": u64::MAX})] { let id = app.ui.open_dialog(crate::state::DialogKind::Command, f.as_object().unwrap().clone()); assert!(crate::dialogs::confirm(&mut app, id).is_err(), "{f}"); } diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index adf3c8cf69..20efb9bc0b 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -50,9 +50,9 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static str, Value)> { let o = &app.ui.tool_options; Some(match tool { - Tool::Blur => ("paint.blur", json!({"strength": o.strength, "sampleAllLayers": o.sample_all_layers})), - Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail, "sampleAllLayers": o.sample_all_layers})), - Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting, "sampleAllLayers": o.sample_all_layers})), + Tool::Blur => ("paint.blur", json!({"strength": o.strength, "sampleAllLayers": o.sample_all.blur})), + Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail, "sampleAllLayers": o.sample_all.sharpen})), + Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting, "sampleAllLayers": o.sample_all.smudge})), Tool::Dodge | Tool::Burn => ( if tool == Tool::Dodge { "paint.dodge" } else { "paint.burn" }, json!({"range": o.tone_range, "exposure": o.exposure, "protectTones": o.protect_tones}), @@ -84,7 +84,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m let o = app.ui.tool_options.clone(); let pts = json!(points); let (cmd, mut p): (&str, Value) = match tool { - Tool::SpotHealing => ("paint.spotHealing", json!({"type": o.spot_type, "sampleAllLayers": o.sample_all_layers})), + Tool::SpotHealing => ("paint.spotHealing", json!({"type": o.spot_type, "sampleAllLayers": o.sample_all.spot_healing})), Tool::Remove => { if !o.remove_after_each_stroke { remove_pending_add(app, points); @@ -134,7 +134,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m let size = app.session.tools.brush.size; let mode = if mods.alt { "subtract" } else { "add" }; let xy: Vec<[f64; 2]> = points.iter().map(|q| [q[0], q[1]]).collect(); - let (sample_all, enhance) = (o.sample_all_layers, o.enhance_edge); + let (sample_all, enhance) = (o.sample_all.quick_selection, o.enhance_edge); let r = app.run("select.quick", json!({"points": xy, "size": size, "mode": mode, "enhanceEdge": enhance, "sampleAllLayers": sample_all})); report_smart_result(app, r, tl!("Quick Selection found nothing there")); return true; @@ -163,21 +163,37 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m /// `paint.remove` params from the options bar (without the strokes). fn remove_params(o: &crate::state::ToolOptions) -> Value { - json!({"sampleAllLayers": o.sample_all_layers, "newLayer": o.remove_new_layer}) + json!({"sampleAllLayers": o.sample_all.remove, "newLayer": o.remove_new_layer}) +} + +/// The selection a queued Remove stroke was drawn against. A later selection must not +/// redirect the queued strokes to a different layer or colour component. +#[derive(Clone, PartialEq)] +pub(crate) struct RemoveTarget { + doc: photocraft_doc::DocId, + layer: Option, + channel: photocraft_engine::channel_cmds::ChannelTarget, + paint: Value, +} + +fn remove_target(app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + Some(RemoveTarget { doc: st.doc.id, layer: st.active_layer, channel: st.channel_view.target, paint: crate::canvas::paint_target(app) }) } /// Remove after each stroke off: a finished stroke waits with the others, tinted on the canvas, /// until ↩ or the options bar's ✓ applies them all as one removal (Esc or ⊘ drops them). pub fn remove_pending_add(app: &mut PhotocraftApp, points: &[[f64; 3]]) { + let Some(target) = remove_target(app) else { return }; let Some(st) = app.session.active() else { return }; - let (doc, size) = (st.doc.id, [st.doc.size.width, st.doc.size.height]); - if app.remove_pending_doc != Some(doc) { + let size = [st.doc.size.width, st.doc.size.height]; + if app.remove_pending_target.as_ref() != Some(&target) { app.remove_pending.clear(); app.remove_trail = None; - app.remove_pending_doc = Some(doc); + app.remove_pending_target = Some(target); } let diameter = app.session.tools.brush.size; - app.remove_trail.get_or_insert_with(|| crate::stroke_trail::Trail::new(size)).feed(points, diameter); + app.remove_trail.get_or_insert_with(|| crate::stroke_trail::Trail::new(size)).feed_stroke(points, diameter); app.remove_pending.push(points.to_vec()); app.ui.status = crate::i18n::fmt(tl!("Strokes to remove: {n} (Enter applies, Esc cancels)"), &[("n", &app.remove_pending.len().to_string())]); app.ui.status_error = false; @@ -185,12 +201,13 @@ pub fn remove_pending_add(app: &mut PhotocraftApp, points: &[[f64; 3]]) { /// Apply the waiting strokes as one removal (one job, one undo step). False when none wait. pub fn remove_pending_commit(app: &mut PhotocraftApp) -> bool { - if app.remove_pending.is_empty() { + // Enter can arrive before the next canvas frame checks pending-stroke lifetime. + if !remove_pending_live(app) { return false; } let strokes = std::mem::take(&mut app.remove_pending); app.remove_trail = None; - app.remove_pending_doc = None; + app.remove_pending_target = None; let mut p = remove_params(&app.ui.tool_options); p["strokes"] = json!(strokes); p["target"] = crate::canvas::paint_target(app); @@ -209,7 +226,7 @@ pub fn remove_pending_cancel(app: &mut PhotocraftApp) -> bool { } app.remove_pending.clear(); app.remove_trail = None; - app.remove_pending_doc = None; + app.remove_pending_target = None; app.ui.status.clear(); app.ui.status_error = false; true @@ -221,8 +238,7 @@ pub fn remove_pending_live(app: &mut PhotocraftApp) -> bool { if app.remove_pending.is_empty() { return false; } - let doc = app.session.active().map(|st| st.doc.id); - if app.ui.tool == Tool::Remove && doc.is_some() && app.remove_pending_doc == doc { + if app.ui.tool == Tool::Remove && remove_target(app).as_ref() == app.remove_pending_target.as_ref() { return true; } remove_pending_cancel(app); @@ -291,7 +307,7 @@ pub fn finish_patch(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { "contentAware": true, "structure": o.patch_structure.round().clamp(1.0, 7.0), "color": o.patch_color.round().clamp(0.0, 10.0), - "sampleAllLayers": o.sample_all_layers, + "sampleAllLayers": o.sample_all.patch, "target": crate::canvas::paint_target(app), }); if let Err(e) = app.run("paint.patch", p) { @@ -337,7 +353,7 @@ pub fn content_aware_move_params(app: &PhotocraftApp, offset: [i64; 2]) -> Value "mode": o.cam_mode, "structure": o.cam_structure.round().clamp(1.0, 7.0), "color": o.cam_color.round().clamp(0.0, 10.0), - "sampleAllLayers": o.sample_all_layers, + "sampleAllLayers": o.sample_all.content_aware_move, "target": crate::canvas::paint_target(app), }) } @@ -351,7 +367,7 @@ pub fn content_aware_move_params(app: &PhotocraftApp, offset: [i64; 2]) -> Value pub fn finish_object_selection(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) { let (x, y) = (start[0].min(end[0]), start[1].min(end[1])); let (w, h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs()); - let sample_all = app.ui.tool_options.sample_all_layers; + let sample_all = app.ui.tool_options.sample_all.object_selection; if (w < 2.0 || h < 2.0) && app.ui.tool_options.object_finder { // Object Finder (#4087): the object under the click, as hovering highlighted it. let mode = match (mods.shift, mods.alt) { @@ -456,12 +472,12 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } } crate::widgets::vline(ui, 22.0); - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.sample_all.spot_healing, tl!("Sample All Layers")); } Tool::Remove => { // Photoshop's options bar: Sample All Layers, Create a new layer, Remove after each // stroke; with strokes waiting, ✓ applies them and ⊘ drops them. - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.sample_all.remove, tl!("Sample All Layers")); crate::widgets::checkbox(ui, &mut o.remove_new_layer, tl!("Create a new layer")); crate::widgets::checkbox(ui, &mut o.remove_after_each_stroke, tl!("Remove after each stroke")); if !app.remove_pending.is_empty() { @@ -490,7 +506,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::value_field(ui, &mut o.patch_structure, 1.0..=7.0, "", 40.0); opt(ui, tl!("Color:")); crate::widgets::value_field(ui, &mut o.patch_color, 0.0..=10.0, "", 40.0); - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.sample_all.patch, tl!("Sample All Layers")); } else { for (k, l) in [("source", tl!("Source")), ("destination", tl!("Destination"))] { let mut on = o.patch_mode == k; @@ -510,7 +526,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::value_field(ui, &mut o.cam_structure, 1.0..=7.0, "", 40.0); opt(ui, tl!("Color:")); crate::widgets::value_field(ui, &mut o.cam_color, 0.0..=10.0, "", 40.0); - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.sample_all.content_aware_move, tl!("Sample All Layers")); crate::widgets::checkbox(ui, &mut o.cam_transform_on_drop, tl!("Transform On Drop")); crate::widgets::vline(ui, 22.0); if crate::cam_transform::active(app) { @@ -562,7 +578,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } Tool::Blur | Tool::Sharpen | Tool::Smudge => { pct(ui, tl!("Strength:"), &mut o.strength); - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + if let Some(all) = o.sample_all.slot(tool) { + crate::widgets::checkbox(ui, all, tl!("Sample All Layers")); + } if tool == Tool::Sharpen { crate::widgets::checkbox(ui, &mut o.protect_detail, tl!("Protect Detail")); } @@ -606,7 +624,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } Tool::HistoryBrush => opt(ui, "Paints from the document's opening state"), Tool::QuickSelection => { - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.sample_all.quick_selection, tl!("Sample All Layers")); crate::widgets::checkbox(ui, &mut o.enhance_edge, tl!("Enhance Edge")); opt(ui, &crate::i18n::fmt(tl!("{key} to subtract"), &[("key", &crate::shortcuts::pretty("Alt"))])); crate::widgets::vline(ui, 22.0); @@ -617,7 +635,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } Tool::ObjectSelection => { crate::widgets::checkbox(ui, &mut o.object_finder, tl!("Object Finder")); - crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.sample_all.object_selection, tl!("Sample All Layers")); opt(ui, tl!("Drag a rectangle around the object")); crate::widgets::vline(ui, 22.0); if crate::widgets::secondary_button(ui, tl!("Select Subject"), 0.0).clicked() { @@ -804,7 +822,7 @@ mod tests { let mut app = app(); stripes(&mut app, 4, "pixels"); app.run("layer.new.layer", json!({})).unwrap(); - app.ui.tool_options.sample_all_layers = all; + *app.ui.tool_options.sample_all.slot(tool).unwrap() = all; drag(&mut app, tool); let a = active(&app).surface().unwrap().rgba(30, 30)[3]; assert_eq!(a > 0.0, all, "{tool:?} sampleAllLayers={all}: alpha {a}"); @@ -812,6 +830,42 @@ mod tests { } } + #[test] + fn sample_all_layers_is_each_tools_own() { + // #5524: Sample All Layers turned on for another tool (the Magic Wand, say) left Blur and + // Smudge copying the composite onto an annotation layer, freezing a sharp patch above the + // picture. Photoshop keeps the option per tool, so those strokes leave the empty layer empty. + for tool in [Tool::Blur, Tool::Sharpen, Tool::Smudge] { + let mut app = app(); + stripes(&mut app, 4, "pixels"); + app.run("layer.new.layer", json!({})).unwrap(); + let mut others = crate::state::SampleAllLayers::default(); + for t in [ + Tool::MagicWand, + Tool::QuickSelection, + Tool::ObjectSelection, + Tool::PaintBucket, + Tool::MagicEraser, + Tool::SpotHealing, + Tool::Patch, + Tool::ContentAwareMove, + Tool::Remove, + Tool::Blur, + Tool::Sharpen, + Tool::Smudge, + ] { + if let Some(v) = others.slot(t).filter(|_| t != tool) { + *v = true; + } + } + app.ui.tool_options.sample_all = others; + assert!(!app.ui.tool_options.sample_all.get(tool)); + drag(&mut app, tool); + let a = active(&app).surface().unwrap().rgba(30, 30)[3]; + assert_eq!(a, 0.0, "{tool:?} sampled all layers through another tool's option"); + } + } + #[test] fn object_selection_click_falls_back_to_quick_selection() { // A click cannot be a rectangle: it selects at the cursor instead of looking dead. @@ -1141,6 +1195,95 @@ mod tests { assert!(!remove_pending_commit(&mut app), "nothing left to apply"); } + #[test] + fn every_waiting_remove_stroke_is_tinted_in_full() { + // #6555: each stroke used to be stamped only past the previous stroke's point count. + let mut app = app(); + app.ui.tool = Tool::Remove; + app.ui.tool_options.remove_after_each_stroke = false; + let strokes: [&[[f64; 3]]; 3] = [ + &[[10.0, 10.0, 1.0], [40.0, 10.0, 1.0], [70.0, 10.0, 1.0]], + &[[10.0, 30.0, 1.0], [40.0, 30.0, 1.0]], + &[[10.0, 50.0, 1.0], [20.0, 50.0, 1.0], [30.0, 50.0, 1.0], [50.0, 50.0, 1.0], [90.0, 50.0, 1.0]], + ]; + for s in strokes { + remove_pending_add(&mut app, s); + } + assert_eq!(app.remove_pending.len(), 3); + let t = app.remove_trail.as_ref().expect("the waiting strokes are tinted"); + let k = f64::from(t.scale()); + for s in strokes { + for w in s.windows(2) { + for i in 0..=8 { + let f = f64::from(i) / 8.0; + let (x, y) = (w[0][0] + (w[1][0] - w[0][0]) * f, w[0][1] + (w[1][1] - w[0][1]) * f); + assert_eq!(t.coverage((x / k) as usize, (y / k) as usize), 255, "({x}, {y}) on a waiting stroke is tinted"); + } + } + } + // Enter, Esc, another tool and another document all drop the tint with the strokes. + assert!(remove_pending_commit(&mut app)); + assert!(app.remove_trail.is_none()); + remove_pending_add(&mut app, strokes[0]); + assert!(remove_pending_cancel(&mut app) && app.remove_trail.is_none()); + remove_pending_add(&mut app, strokes[1]); + app.ui.tool = Tool::SpotHealing; + assert!(!remove_pending_live(&mut app) && app.remove_trail.is_none()); + app.ui.tool = Tool::Remove; + remove_pending_add(&mut app, strokes[2]); + app.run("file.new", json!({"width": 100, "height": 60})).unwrap(); + assert!(!remove_pending_live(&mut app) && app.remove_trail.is_none()); + } + + #[test] + fn queued_remove_cannot_commit_after_layer_or_channel_target_changes() { + for change in ["layer", "color", "mask"] { + let mut app = app(); + app.run("layer.new.layer", json!({"name": "Original"})).unwrap(); + if change == "color" { + app.run("channel.target", json!({"channel": "red"})).unwrap(); + } + app.ui.tool = Tool::Remove; + app.ui.tool_options.remove_after_each_stroke = false; + remove_pending_add(&mut app, &[[12.0, 10.0, 1.0], [16.0, 12.0, 1.0]]); + assert_eq!(app.remove_pending.len(), 1); + assert!(remove_pending_live(&mut app)); + + match change { + "layer" => { + app.run("layer.new.layer", json!({"name": "Other"})).unwrap(); + } + "color" => { + app.run("channel.target", json!({"channel": "blue"})).unwrap(); + } + _ => { + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.ui.mask_target = true; + } + } + + let journal = app.session.journal.len(); + assert!(!remove_pending_commit(&mut app), "{change}: Enter must not retarget a queued stroke"); + assert_eq!(app.session.journal.len(), journal, "{change}: no Remove command ran"); + assert!(app.remove_pending.is_empty() && app.remove_trail.is_none(), "{change}: queue cleared"); + assert!(app.remove_pending_target.is_none()); + } + } + + #[test] + fn queued_remove_starts_a_new_queue_after_target_switch() { + let mut app = app(); + app.run("layer.new.layer", json!({"name": "Original"})).unwrap(); + app.ui.tool = Tool::Remove; + app.ui.tool_options.remove_after_each_stroke = false; + remove_pending_add(&mut app, &[[12.0, 10.0, 1.0], [16.0, 12.0, 1.0]]); + app.run("layer.new.layer", json!({"name": "Other"})).unwrap(); + remove_pending_add(&mut app, &[[40.0, 10.0, 1.0], [44.0, 12.0, 1.0]]); + assert_eq!(app.remove_pending.len(), 1, "strokes from different layers must not be combined"); + assert_eq!(app.remove_pending[0][0][0], 40.0); + assert!(remove_pending_live(&mut app)); + } + #[test] fn remove_strokes_waiting_are_dropped_when_the_tool_changes() { let mut app = app(); diff --git a/crates/ui-egui/src/rgb_histogram.rs b/crates/ui-egui/src/rgb_histogram.rs index f8787ad66b..efae4cce41 100644 --- a/crates/ui-egui/src/rgb_histogram.rs +++ b/crates/ui-egui/src/rgb_histogram.rs @@ -5,7 +5,7 @@ use photocraft_algo::histogram::{BINS, RgbHistogram}; /// A five-bin binomial display kernel softens quantization combs. It cannot bridge /// a wide empty range. Clipping bins are pinned and excluded from filtering adjacent bins. -fn curves(h: &RgbHistogram) -> [[f32; BINS]; 3] { +fn curves(h: &RgbHistogram, spikes: Spikes) -> [[f32; BINS]; 3] { let mut curves = [[0.0; BINS]; 3]; for (out, bins) in curves.iter_mut().zip(&h.channels) { for (i, value) in out.iter_mut().enumerate() { @@ -22,7 +22,18 @@ fn curves(h: &RgbHistogram) -> [[f32; BINS]; 3] { // preserve them at the edges, capped at the top. A flat black/white image uses its edge peak. let interior = curves.iter().flat_map(|c| c.iter().skip(1).take(BINS - 2)).copied().fold(0.0f32, f32::max); let peak = if interior > 0.0 { interior } else { curves.iter().flatten().copied().fold(0.0f32, f32::max) }; - let scale = if peak > 0.0 { peak * 1.1 } else { 1.0 }; + let mut scale = if peak > 0.0 { peak * 1.1 } else { 1.0 }; + if spikes == Spikes::Clip { + // Camera Raw scales linearly with the 95th-percentile column at two thirds of the height + // and lets taller spikes run off the top (measured on Photoshop 25.4's Camera Raw 16.1.1: + // 49 px per 10 000 pixels in a 106 px plot, 95th percentile 14 546, full 21 600). + let mut columns: Vec = (1..BINS - 1).map(|i| curves.iter().map(|c| c[i]).fold(0.0f32, f32::max)).collect(); + columns.sort_unstable_by(f32::total_cmp); + let p95 = columns.get(columns.len() * 95 / 100).copied().unwrap_or(0.0); + if p95 > 0.0 { + scale = scale.min(p95 * 1.5); + } + } for value in curves.iter_mut().flatten() { *value = (*value / scale).min(1.0); } @@ -101,11 +112,24 @@ fn fill(rect: Rect, curves: &[[f32; BINS]; 3], t: &Tokens) -> Mesh { mesh } +/// How a histogram treats columns far above the rest. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Spikes { + /// Fit the tallest interior column (edge columns excluded). + Fit, + /// Camera Raw's: scale by the typical columns, spikes run off the top. + Clip, +} + pub(crate) fn paint(painter: &egui::Painter, rect: Rect, h: &RgbHistogram, t: &Tokens) { + paint_with(painter, rect, h, t, Spikes::Fit); +} + +pub(crate) fn paint_with(painter: &egui::Painter, rect: Rect, h: &RgbHistogram, t: &Tokens, spikes: Spikes) { if !rect.is_finite() || !rect.is_positive() || !rect.size().is_finite() { return; } - let curves = curves(h); + let curves = curves(h, spikes); painter.add(fill(rect, &curves, t)); for (channel, values) in curves.iter().enumerate() { if !values.iter().any(|&v| v > 0.0) { @@ -139,7 +163,7 @@ pub(crate) fn paint_sample(painter: &egui::Painter, rect: Rect, rgb: [f32; 3], t #[cfg(test)] fn mesh(rect: Rect, h: &RgbHistogram, t: &Tokens) -> Mesh { - fill(rect, &curves(h), t) + fill(rect, &curves(h, Spikes::Fit), t) } #[cfg(test)] @@ -161,6 +185,28 @@ mod tests { }) } + #[test] + fn camera_raw_scale_lets_a_shadow_spike_run_off_the_top() { + // A dark-sky photo: a tall spike in the first columns over a hill in the midtones. + let mut h = RgbHistogram::default(); + for c in &mut h.channels { + for (i, v) in c.iter_mut().enumerate() { + let x = i as f32 / 255.0; + *v = (14_000.0 * (-((x - 0.5) / 0.18).powi(2)).exp()) as u64 + 3_000; + } + c[1] = 88_000; + c[2] = 42_000; + } + let fit = curves(&h, Spikes::Fit); + let clip = curves(&h, Spikes::Clip); + assert!(fit[1][128] < 0.25, "fitting the spike flattens the hill: {}", fit[1][128]); + assert!((0.5..0.75).contains(&clip[1][128]), "Camera Raw keeps the hill at about two thirds: {}", clip[1][128]); + assert_eq!(clip[1][1], 1.0, "the spike is clipped at the top"); + // Without spikes both scales agree. + let flat = RgbHistogram { channels: [[3; BINS], [2; BINS], [1; BINS]], ..Default::default() }; + assert_eq!(curves(&flat, Spikes::Fit), curves(&flat, Spikes::Clip)); + } + #[test] fn shared_scale_and_overlaps_are_exact_for_every_theme() { let h = RgbHistogram { channels: [[3; BINS], [2; BINS], [1; BINS]], ..Default::default() }; @@ -217,15 +263,15 @@ mod tests { let mut h = RgbHistogram { channels: [[10; BINS]; 3], ..Default::default() }; h.channels[0][0] = 1_000_000; h.channels[1][255] = 1_000_000; - let p = curves(&h); + let p = curves(&h, Spikes::Fit); assert_eq!(p[0][0], 1.0); assert_eq!(p[1][255], 1.0); assert!((p[0][1] - 1.0 / 1.1).abs() < 0.0001); assert!((p[1][254] - 1.0 / 1.1).abs() < 0.0001); let mut h = RgbHistogram::default(); h.channels[0][255] = 100; - assert!(curves(&h)[0][255] > 0.9); - assert_eq!(curves(&h)[0][254], 0.0); + assert!(curves(&h, Spikes::Fit)[0][255] > 0.9); + assert_eq!(curves(&h, Spikes::Fit)[0][254], 0.0); } #[test] @@ -247,7 +293,7 @@ mod tests { channel[i] = 100; } } - let p = curves(&h); + let p = curves(&h, Spikes::Fit); let low = p[0][40..210].iter().copied().fold(f32::INFINITY, f32::min); let high = p[0][40..210].iter().copied().fold(0.0f32, f32::max); assert!(high - low < 0.2, "quantization comb still dominates the contour: {}", high - low); diff --git a/crates/ui-egui/src/rotate_view.rs b/crates/ui-egui/src/rotate_view.rs index 5e173da5dc..5137b9d312 100644 --- a/crates/ui-egui/src/rotate_view.rs +++ b/crates/ui-egui/src/rotate_view.rs @@ -4,7 +4,7 @@ //! decreases it in a flipped view). Shift snaps to 15°. Reset View, //! Escape, a double-click, or the on-canvas compass put the angle back to 0. History is not //! recorded. Trackpad rotate is gated by Preferences › Enhanced Controls › Rotate View with -//! Trackpad. +//! Trackpad; a two-finger twist on a touch screen also needs Enable Gestures (#204). use egui::{Align2, Pos2, Rect, Stroke, pos2, vec2}; use serde_json::{Value, json}; @@ -137,7 +137,9 @@ pub fn apply_trackpad(current: f32, enabled: bool, delta_rad: f32) -> Option Option { +/// The trackpad's own `Rotate` events always count; a touch screen's two-finger twist (egui's +/// multi-touch rotation) only while `touch_gestures` (Enhanced Controls › Enable Gestures) is on. +pub fn trackpad_delta(ctx: &egui::Context, enabled: bool, touch_gestures: bool) -> Option { if !enabled { return None; } @@ -150,7 +152,13 @@ pub fn trackpad_delta(ctx: &egui::Context, enabled: bool) -> Option { _ => None, }) .sum(); - if from_events != 0.0 { from_events } else { i.rotation_delta() } + if from_events != 0.0 { + from_events + } else if touch_gestures { + i.rotation_delta() + } else { + 0.0 + } }); apply_trackpad(0.0, true, rad).filter(|_| rad != 0.0) } @@ -324,8 +332,8 @@ mod tests { let mut disabled = None; let raw = egui::RawInput { events: vec![Event::Rotate(std::f32::consts::FRAC_PI_6)], ..Default::default() }; let mut output = ctx.run_ui(raw, |ui| { - enabled = trackpad_delta(ui.ctx(), true); - disabled = trackpad_delta(ui.ctx(), false); + enabled = trackpad_delta(ui.ctx(), true, true); + disabled = trackpad_delta(ui.ctx(), false, true); }); output.textures_delta.clear(); @@ -333,6 +341,36 @@ mod tests { assert_eq!(disabled, None); } + /// Preferences › Enhanced Controls › Enable Gestures (#204): off, a two-finger twist on a + /// touch screen doesn't turn the view; the trackpad's `Rotate` event still does. + #[test] + fn enable_gestures_switches_touch_screen_rotation() { + use egui::TouchPhase::{Move, Start}; + let touch = |id: u64, phase, pos| Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(id), phase, pos, force: None }; + let twist = |touch_gestures: bool, extra: Vec| { + let ctx = egui::Context::default(); + let frames = [ + // egui only starts a multi-touch gesture once it knows where the pointer is. + vec![Event::PointerMoved(pos2(50.0, 50.0))], + vec![touch(1, Start, pos2(40.0, 50.0)), touch(2, Start, pos2(60.0, 50.0))], + // A quarter turn about the centre between the fingers. + [vec![touch(1, Move, pos2(50.0, 40.0)), touch(2, Move, pos2(50.0, 60.0))], extra].concat(), + ]; + let mut last = None; + for (n, events) in (0u32..).zip(frames) { + let raw = egui::RawInput { time: Some(f64::from(n) / 60.0), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| last = trackpad_delta(ui.ctx(), true, touch_gestures)); + out.textures_delta.clear(); + } + last + }; + let on = twist(true, Vec::new()); + assert!(on.is_some_and(|d| (d.abs() - 90.0).abs() < 1e-3), "on: the fingers turn the view: {on:?}"); + assert_eq!(twist(false, Vec::new()), None, "off: the fingers don't"); + let trackpad = twist(false, vec![Event::Rotate(std::f32::consts::FRAC_PI_6)]); + assert!(trackpad.is_some_and(|d| (d - 30.0).abs() < 1e-3), "off: a trackpad rotate still turns: {trackpad:?}"); + } + #[test] fn trackpad_pref_gates_rotation() { assert_eq!(apply_trackpad(10.0, false, 0.5), None); diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index ff5f106bde..a5f4bb0fd1 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -93,6 +93,17 @@ pub struct GuideDrag { pub vertical: bool, pub index: Option, pub pos: f64, + /// Where the pointer went down, on the guide's axis: released there, an existing guide is + /// clicked (selected), not moved. + pub press: f64, +} + +impl GuideDrag { + /// An existing guide taken at document point `p`. + pub fn existing(vertical: bool, index: usize, p: [f64; 2]) -> Self { + let at = if vertical { p[0] } else { p[1] }; + Self { vertical, index: Some(index), pos: at, press: at } + } } /// Canvas area left after the rulers. @@ -159,6 +170,12 @@ fn guide_segment(xf: &ViewXform, clip: Rect, vertical: bool, pos: f64) -> [Pos2; if vertical { [xf.to_screen(pos, lo), xf.to_screen(pos, hi)] } else { [xf.to_screen(lo, pos), xf.to_screen(hi, pos)] } } +/// The cursor over a guide: the arrow across it as the view shows it. A vertical guide moves left +/// and right in the document; a rotated view turns that, as it turns the guide (#3277). +pub fn guide_cursor(xf: &ViewXform, vertical: bool) -> egui::CursorIcon { + crate::tool_cursor::resize(if vertical { [1.0, 0.0] } else { [0.0, 1.0] }, xf) +} + pub fn draw_guides(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, doc: &Document) { let clip = painter.clip_rect(); let drag = app.guide_drag; @@ -172,8 +189,13 @@ pub fn draw_guides(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, styled_line(painter, a, b, Stroke::new(1.0, color), style); }; // A guide given a colour in New Guide keeps it; the rest use the preference colour (or, while - // New Guide Layout previews, the colour it will set). - let own = |vertical: bool, p: f64| guides.color(vertical, p).map_or(guide, |[r, g, b]| Color32::from_rgb(r, g, b)); + // New Guide Layout previews, the colour it will set). The selected guide is Photoshop's blue. + let own = |vertical: bool, p: f64| { + if crate::guide_select::is_selected(app, vertical, p) { + return crate::guide_select::SELECTED_COLOR; + } + guides.color(vertical, p).map_or(guide, |[r, g, b]| Color32::from_rgb(r, g, b)) + }; if app.ui.extras.guides || preview.is_some() { for (vertical, list) in [(true, &guides.vertical), (false, &guides.horizontal)] { for (i, p) in list.iter().enumerate() { @@ -213,16 +235,26 @@ pub fn guide_at(app: &PhotocraftApp, x: f64, y: f64) -> Option<(bool, usize)> { None } -/// Finish a guide drag: create, move, or delete (dropped outside the canvas). +/// Finish a guide drag: create, move, or delete (dropped outside the canvas). An existing guide +/// released where it was pressed is a click: it's selected and stays put, as in Photoshop; a +/// moved one stays selected. pub fn finish_drag(app: &mut PhotocraftApp, d: GuideDrag) { let Some(size) = app.session.active().map(|s| s.doc.size) else { return }; + if let Some(i) = d.index + && (d.pos - d.press).abs() * f64::from(app.point_zoom().max(0.01)) < 0.5 + { + crate::guide_select::select(app, d.vertical, i); + return; + } let extent = if d.vertical { size.width } else { size.height } as f64; let orientation = if d.vertical { "vertical" } else { "horizontal" }; let inside = d.pos >= 0.0 && d.pos <= extent; let pos = (d.pos * 1000.0).round() / 1000.0; let _ = match (d.index, inside) { (None, true) => app.run("view.newGuide", json!({"orientation": orientation, "position": pos})), - (Some(i), true) => app.run("view.moveGuide", json!({"orientation": orientation, "index": i, "position": pos})), + (Some(i), true) => app + .run("view.moveGuide", json!({"orientation": orientation, "index": i, "position": pos})) + .inspect(|_| crate::guide_select::select(app, d.vertical, i)), (Some(i), false) => app.run("view.deleteGuide", json!({"orientation": orientation, "index": i})), (None, false) => Ok(serde_json::Value::Null), }; @@ -297,10 +329,14 @@ fn ruler_pos(xf: &ViewXform, vertical: bool, u: f64, px_per_unit: f64, zero: [f6 /// (`y_axis` false) or the left one, from Preferences › Units & Rulers and the zoom. Minor ticks /// sit at tenths of the step. fn ruler_scale(app: &PhotocraftApp, xf: &ViewXform, y_axis: bool) -> (f64, f64) { + ruler_scale_of(app, app.session.active().map(|d| &*d.doc), xf, y_axis) +} + +/// [`ruler_scale`] for the view of `doc`. +fn ruler_scale_of(app: &PhotocraftApp, doc: Option<&photocraft_doc::Document>, xf: &ViewXform, y_axis: bool) -> (f64, f64) { let ur = &app.session.prefs().units_and_rulers; let (unit, ppi) = (ur.rulers, ur.point_size.per_inch()); - let (dpi, size) = - app.session.active().map_or((72.0, [1.0, 1.0]), |d| (d.doc.resolution_dpi.max(1.0) as f64, [d.doc.size.width as f64, d.doc.size.height as f64])); + let (dpi, size) = doc.map_or((72.0, [1.0, 1.0]), |d| (d.resolution_dpi.max(1.0) as f64, [d.size.width as f64, d.size.height as f64])); let px_per_unit = unit.to_px(1.0, dpi, if y_axis { size[1] } else { size[0] }, ppi).max(1e-9); // The top ruler runs along document x, which pixel aspect correction stretches. let zoom = if y_axis { xf.zoom } else { xf.zoom_x() }; @@ -324,9 +360,16 @@ fn ruler_label(v: f64, step: f64, whole: bool) -> String { if step >= 1.0 || whole { format!("{}", v.round() as i64) } else { crate::widgets::fmt_num2(v) } } -/// Rulers along the top and left of `full` (the canvas rect before `content_rect`), with the -/// pointer position marked; dragging out of a ruler creates a guide. -pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: &ViewXform) { +/// The rulers of `doc`'s view in `full`: background, ticks and labels from its zero point. +/// Returns the top, left and corner rects, the painter, the zero point and the minor tick sizes. +#[allow(clippy::type_complexity)] +fn paint( + app: &PhotocraftApp, + ui: &egui::Ui, + full: Rect, + xf: &ViewXform, + doc: &photocraft_doc::Document, +) -> (Rect, Rect, Rect, egui::Painter, [f64; 2], [f64; 2]) { let t = Tokens::get(ui.ctx()); let top = Rect::from_min_max(pos2(full.left() + RULER, full.top()), pos2(full.right(), full.top() + RULER)); let left = Rect::from_min_max(pos2(full.left(), full.top() + RULER), pos2(full.left() + RULER, full.bottom())); @@ -344,11 +387,11 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & let unit = app.session.prefs().units_and_rulers.rulers; let whole = unit == Unit::Pixels; // The zero point (#2955): rulers read 0 there and count the distance from it. - let origin = app.session.active().map_or([0.0, 0.0], |d| d.doc.ruler_origin); + let origin = doc.ruler_origin; let mut ticks = [1.0; 2]; for vertical in [false, true] { let p = p.with_clip_rect(if vertical { left } else { top }); - let (px_per_unit, step) = ruler_scale(app, xf, vertical); + let (px_per_unit, step) = ruler_scale_of(app, Some(doc), xf, vertical); ticks[usize::from(vertical)] = step * px_per_unit / 10.0; let (u0, u1) = if vertical { ruler_range(xf, left.left_top(), left.left_bottom(), 1, px_per_unit, origin) @@ -361,9 +404,10 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & let c = at(v); if vertical { p.line_segment([pos2(left.right() - RULER, c), pos2(left.right(), c)], tick); - // Labels stacked, as in Photoshop. + // Labels stacked, as in Photoshop, each character centred in the ruler's width + // (a narrow "1" used to hug the left edge while a "0" filled the strip). for (i, ch) in ruler_label(v, step, whole).chars().enumerate() { - p.text(pos2(left.left() + 3.0, c + 2.0 + i as f32 * 8.5), Align2::LEFT_TOP, ch, font.clone(), t.text_dim); + p.text(pos2(left.left() + RULER * 0.5, c + 2.0 + i as f32 * 8.5), Align2::CENTER_TOP, ch, font.clone(), t.text_dim); } } else { p.line_segment([pos2(c, top.bottom() - RULER), pos2(c, top.bottom())], tick); @@ -381,6 +425,23 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & v += step; } } + (top, left, corner, p, origin, ticks) +} + +/// Rulers for a document view that isn't the active one (another tile of Window › Arrange, a +/// floating window): shown as the active view's are, but guides and the zero point are dragged +/// in the active view (#6017). +pub fn paint_rulers(app: &PhotocraftApp, ui: &egui::Ui, full: Rect, xf: &ViewXform, doc: &photocraft_doc::Document) { + paint(app, ui, full, xf, doc); +} + +/// Rulers along the top and left of `full` (the canvas rect before `content_rect`), with the +/// pointer position marked; dragging out of a ruler creates a guide. +pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: &ViewXform) { + let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + let (top, left, corner, p, origin, ticks) = paint(app, ui, full, xf, &doc); + let t = Tokens::get(ui.ctx()); + let unit = app.session.prefs().units_and_rulers.rulers; // Pointer position markers. if let Some(h) = ui.ctx().pointer_hover_pos().filter(|h| full.contains(*h)) { let m = Stroke::new(1.0, t.text); @@ -399,10 +460,13 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); } if let Some(pos) = resp.interact_pointer_pos().filter(|_| salt != "ruler-corner" && (resp.dragged() || resp.drag_started())) { + // ⌥/Alt turns the guide the other way, as in Photoshop: a vertical guide out of the top + // ruler, a horizontal one out of the left (#5698), switching as Alt goes up and down. + let vertical = vertical != ui.input(|i| i.modifiers.alt); let d = xf.to_doc(pos); let along = if vertical { d[0] } else { d[1] }; let pos = if ui.input(|i| i.modifiers.shift) { snap_to_tick(app, xf, vertical, along) } else { crate::snap_ui::snap_guide(app, vertical, along) }; - app.guide_drag = Some(GuideDrag { vertical, index: None, pos }); + app.guide_drag = Some(GuideDrag { vertical, index: None, pos, press: pos }); } if resp.drag_stopped() && let Some(d) = app.guide_drag.take() @@ -499,6 +563,28 @@ mod tests { assert!(failures.is_empty(), "ruler drawing overlaps the origin corner: {failures:?}"); } + /// #7314: the stacked characters of the left ruler's labels are centred on the strip, so a + /// narrow "1" lines up with a wide "0". + #[test] + fn left_ruler_label_characters_are_centred() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 256, "height": 128})).unwrap(); + let (mut h, full, _) = ruler_harness(app); + h.run_steps(2); + let centres: Vec = h + .output() + .shapes + .iter() + .filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t) } else { None }) + .filter(|t| t.pos.x < full.min.x + RULER && t.pos.y > full.min.y + RULER) + .map(|t| t.pos.x + t.galley.size().x * 0.5) + .collect(); + assert!(centres.len() > 2, "the left ruler draws labels: {centres:?}"); + for c in ¢res { + assert!((c - (full.min.x + RULER * 0.5)).abs() < 0.01, "character centre {c} in {centres:?}"); + } + } + #[test] fn shift_snaps_the_zero_point_to_ruler_ticks_from_the_current_origin() { assert_eq!(snap_px_to_tick(47.0, 0.0, 10.0), 50.0); @@ -633,6 +719,24 @@ mod tests { assert!(h.query_by_label("50").is_none(), "not the raw image x"); } + /// #5728: dragging a guide shows its coordinate in the Info panel on one axis only. + #[test] + fn info_panel_shows_guide_drag_position_outside_canvas() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.hover_doc = None; + app.guide_drag = Some(GuideDrag { vertical: true, index: None, pos: 42.0, press: 42.0 }); + let mut h = egui_kittest::Harness::builder() + .with_size(vec2(260.0, 240.0)) + .build_ui_state(|ui, app| crate::panels::dock_body(app, ui, crate::dock::Group::Navigator, 2), app); + h.run_steps(2); + assert!(h.query_by_label("42").is_some(), "vertical guide: X readout"); + h.state_mut().guide_drag = Some(GuideDrag { vertical: false, index: None, pos: 17.0, press: 17.0 }); + h.run_steps(2); + assert!(h.query_by_label("17").is_some(), "horizontal guide: Y readout"); + } + #[test] fn percentage_grid_spacing_uses_both_canvas_dimensions() { let mut g = GuidesGridAndSlices { grid_unit: Unit::Percent, gridline_every: 20.0, subdivisions: 2, ..Default::default() }; @@ -757,14 +861,14 @@ mod tests { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.session.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); app.sync_views(); - finish_drag(&mut app, GuideDrag { vertical: true, index: None, pos: 50.0 }); - finish_drag(&mut app, GuideDrag { vertical: false, index: None, pos: 500.0 }); // off canvas: ignored + finish_drag(&mut app, GuideDrag { vertical: true, index: None, pos: 50.0, press: 50.0 }); + finish_drag(&mut app, GuideDrag { vertical: false, index: None, pos: 500.0, press: 500.0 }); // off canvas: ignored assert_eq!(app.session.active().unwrap().doc.guides.vertical, vec![50.0]); assert!(app.session.active().unwrap().doc.guides.horizontal.is_empty()); assert_eq!(guide_at(&app, 51.0, 10.0), Some((true, 0))); - finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: 120.0 }); + finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: 120.0, press: 50.0 }); assert_eq!(app.session.active().unwrap().doc.guides.vertical, vec![120.0]); - finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: -30.0 }); + finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: -30.0, press: 120.0 }); assert!(app.session.active().unwrap().doc.guides.vertical.is_empty()); app.ui.extras.lock_guides = true; assert_eq!(guide_at(&app, 0.0, 0.0), None); @@ -776,7 +880,17 @@ mod tests { fn command_drag_over_a_guide_moves_the_guide_with_any_tool() { use crate::canvas::{ToolEvent, tool_event}; use crate::state::Tool; - for (tool, locked) in [(Tool::Brush, false), (Tool::RectMarquee, false), (Tool::Lasso, false), (Tool::MagicWand, false), (Tool::RectMarquee, true)] { + for (tool, locked) in [ + (Tool::Brush, false), + (Tool::Eraser, false), + (Tool::Gradient, false), + (Tool::Frame, false), + (Tool::Artboard, false), + (Tool::RectMarquee, false), + (Tool::Lasso, false), + (Tool::MagicWand, false), + (Tool::RectMarquee, true), + ] { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 80, "height": 60, "background": "transparent"})).unwrap(); app.sync_views(); @@ -890,6 +1004,20 @@ mod tests { } } + /// The cursor over a guide turns with the view as the guide does: on a view turned 90°, a + /// vertical guide shows as a horizontal line and moves up and down. + #[test] + fn guide_cursors_turn_with_a_rotated_view() { + use egui::CursorIcon::{ResizeHorizontal as H, ResizeNeSw as NeSw, ResizeNwSe as NwSe, ResizeVertical as V}; + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let level = ViewXform { rect, zoom: 1.0, center: [400.0, 300.0], flip: false, rotation: 0.0, aspect: 1.0 }; + // View rotation, flip → the cursor over a vertical guide, over a horizontal guide. + for (rotation, flip, want) in [(0.0, false, [H, V]), (90.0, false, [V, H]), (45.0, false, [NwSe, NeSw]), (0.0, true, [H, V])] { + let xf = ViewXform { rotation, flip, ..level }; + assert_eq!([guide_cursor(&xf, true), guide_cursor(&xf, false)], want, "view turned {rotation}°, flip {flip}"); + } + } + #[test] fn ruler_range_is_normalised_when_flipped() { // #1814: with View > Flip Horizontal, `to_doc` negates x, so the raw @@ -905,6 +1033,35 @@ mod tests { } } + /// #5698: as in Photoshop, an Alt-drag out of the top ruler makes a vertical guide and one out + /// of the left ruler a horizontal guide; without Alt each ruler makes its own kind. + #[test] + fn alt_drags_a_guide_of_the_other_orientation_out_of_a_ruler() { + let full = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)); + let rect = Rect::from_min_max(full.min + vec2(RULER, RULER), full.max); + let xf = ViewXform { rect, zoom: 1.0, center: [128.0, 64.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let drag = |from: Pos2, modifiers: egui::Modifiers| { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 256, "height": 128})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 300.0)).build_ui_state(move |ui, app| draw_rulers(app, ui, full, &xf), app); + let to = xf.to_screen(37.0, 90.0); + h.event_modifiers(egui::Event::PointerMoved(from), modifiers); + h.event_modifiers(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }, modifiers); + h.run_steps(1); + h.event_modifiers(egui::Event::PointerMoved(to), modifiers); + h.run_steps(1); + h.event_modifiers(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }, modifiers); + h.run_steps(2); + let g = &h.state().session.active().unwrap().doc.guides; + (g.vertical.clone(), g.horizontal.clone()) + }; + let (top, left) = (pos2(200.0, RULER / 2.0), pos2(RULER / 2.0, 150.0)); + assert_eq!(drag(top, egui::Modifiers::NONE), (vec![], vec![90.0]), "top ruler: horizontal"); + assert_eq!(drag(left, egui::Modifiers::NONE), (vec![37.0], vec![]), "left ruler: vertical"); + assert_eq!(drag(top, egui::Modifiers::ALT), (vec![37.0], vec![]), "Alt from the top ruler: vertical"); + assert_eq!(drag(left, egui::Modifiers::ALT), (vec![], vec![90.0]), "Alt from the left ruler: horizontal"); + } + /// #3770: a guide Shift-dragged out of a ruler lands on the nearest tick the ruler draws /// (tenths of the labelled step, in the ruler unit); without Shift it lands where it's dropped. #[test] @@ -932,6 +1089,8 @@ mod tests { h.state().session.active().unwrap().doc.guides.vertical.clone() }; assert_eq!(drag_guide(egui::Modifiers::NONE), vec![37.0]); + // #5698: Alt turns it into a horizontal guide (at the drop's y, the document's centre). + assert_eq!(drag_guide(egui::Modifiers::ALT), Vec::::new(), "no vertical guide with Alt"); // Pixels at 100%: labels every 100 px, ticks every 10 px. assert_eq!(drag_guide(egui::Modifiers::SHIFT), vec![40.0]); diff --git a/crates/ui-egui/src/save_identity_tests.rs b/crates/ui-egui/src/save_identity_tests.rs index c947afc9b6..3396dfcdd5 100644 --- a/crates/ui-egui/src/save_identity_tests.rs +++ b/crates/ui-egui/src/save_identity_tests.rs @@ -254,3 +254,21 @@ fn save_settings_start_at_the_opened_files_encoding() { app.open_bytes("art.webp", &lossless).unwrap(); assert!(app.save_settings().webp_lossless); } + +/// A browser download is only requested: the document takes the name but keeps its unsaved +/// changes, so the leave-page guard stays armed (#4962). A confirmed write still saves it. +#[test] +fn unconfirmed_download_names_the_document_but_keeps_it_unsaved() { + assert_eq!(crate::saved_revision_after_write(true, 7, 3), 7); + assert_eq!(crate::saved_revision_after_write(false, 7, 3), 3); + let mut app = app(); + app.services.write_unconfirmed = true; + let (_, _, revision, saved) = identity(&app); + app.save_as(Some("download.psd".into())).unwrap(); + assert_eq!(identity(&app), ("download.psd".into(), Some("download.psd".into()), revision, saved)); + assert!(app.session.active().unwrap().is_dirty()); + assert!(app.has_unsaved_work()); + app.services.write_unconfirmed = false; + app.save_as(Some("download.psd".into())).unwrap(); + assert!(!app.has_unsaved_work()); +} diff --git a/crates/ui-egui/src/shortcut_capture_tests.rs b/crates/ui-egui/src/shortcut_capture_tests.rs index d68ae57d92..f5db001707 100644 --- a/crates/ui-egui/src/shortcut_capture_tests.rs +++ b/crates/ui-egui/src/shortcut_capture_tests.rs @@ -230,3 +230,119 @@ fn a_user_shortcut_wins_over_a_default_it_was_not_moved_from() { let bound: Vec = crate::shortcut_dispatch::bindings(&app).into_iter().filter(|(_, sc)| *sc == f7).map(|(id, _)| id).collect(); assert_eq!(bound, ["select.modify.feather"]); } + +/// Capture `k` with `m` for the selected command. +fn capture_key(h: &mut Harness<'_, PhotocraftApp>, id: u64, k: Key, m: Modifiers) { + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("capture".into(), json!(true)); + h.run_steps(2); + key(h, k, true, m); + h.run_steps(2); + key(h, k, false, m); + h.run_steps(2); +} + +fn click(h: &mut Harness<'_, PhotocraftApp>, label: &str) { + h.get_by_role_and_label(egui::accesskit::Role::Button, label).click(); + h.run_steps(3); +} + +#[test] +fn a_conflict_offers_accept_and_go_to_conflict() { + // Photoshop: the key is taken from Copy now, and Copy is selected to get a new one. + let (mut h, id) = dialog(1.0, "edit.cut", ""); + capture_key(&mut h, id, Key::C, Modifiers::COMMAND); + assert_eq!(field(&h, id, "overrides")["edit.cut"], json!("Cmd+C")); + assert_eq!(field(&h, id, "conflict")["with"], json!("edit.copy")); + click(&mut h, "Accept and Go to Conflict"); + assert_eq!(field(&h, id, "selected"), json!("edit.copy")); + assert_eq!(field(&h, id, "overrides")["edit.copy"], json!(""), "Copy no longer holds Cmd+C"); + assert!(field(&h, id, "conflict").is_null()); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.cut", Some("Cmd+X")).as_deref(), Some("Cmd+C")); + assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.copy", Some("Cmd+C")), None); +} + +#[test] +fn undo_changes_puts_the_command_back() { + let (mut h, id) = dialog(1.0, "edit.cut", ""); + capture_key(&mut h, id, Key::C, Modifiers::COMMAND); + click(&mut h, "Undo Changes"); + assert!(field(&h, id, "overrides").get("edit.cut").is_none(), "{}", field(&h, id, "overrides")); + assert!(field(&h, id, "conflict").is_null()); + // An alternate added with Add Shortcut is undone the same way. + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("captureAlt".into(), json!(true)); + capture_key(&mut h, id, Key::C, Modifiers::COMMAND); + assert_eq!(field(&h, id, "alternates")["edit.cut"], json!(["F2", "Cmd+C"]), "beside Photoshop's F2"); + click(&mut h, "Undo Changes"); + assert!(field(&h, id, "alternates").get("edit.cut").is_none(), "{}", field(&h, id, "alternates")); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.copy", Some("Cmd+C")).as_deref(), Some("Cmd+C")); +} + +#[test] +fn a_system_key_is_refused() { + let (k, m) = if cfg!(target_os = "macos") { (Key::Tab, Modifiers::MAC_CMD | Modifiers::COMMAND) } else { (Key::F4, Modifiers::ALT) }; + let (mut h, id) = dialog(1.0, "edit.cut", ""); + capture_key(&mut h, id, k, m); + assert!(field(&h, id, "overrides").get("edit.cut").is_none(), "{}", field(&h, id, "overrides")); + assert!(field(&h, id, "message").as_str().is_some_and(|s| s.contains("reserved")), "{}", field(&h, id, "message")); + assert_eq!(field(&h, id, "messageWarning"), json!(true)); + assert!(crate::prefs_ui::reserved_shortcut(if cfg!(target_os = "macos") { "Cmd+Tab" } else { "Alt+F4" })); + assert!(!crate::prefs_ui::reserved_shortcut("Cmd+Shift+F4")); + assert!(!crate::prefs_ui::reserved_shortcut("garbage+")); +} + +/// The conflict describes the bindings as the capture left them: Reset All, Use Default or +/// Escape after it makes it stale, so Accept can't take a key nothing holds any more. +#[test] +fn a_conflict_goes_stale_when_the_bindings_change() { + for undo in ["Reset All to Defaults", "Use Default", "escape"] { + let (mut h, id) = dialog(1.0, "edit.cut", ""); + capture_key(&mut h, id, Key::C, Modifiers::COMMAND); + assert!(field(&h, id, "conflict").is_object(), "{undo}"); + if undo == "escape" { + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("capture".into(), json!(true)); + h.run_steps(2); + key(&mut h, Key::Escape, true, Modifiers::NONE); + h.run_steps(2); + key(&mut h, Key::Escape, false, Modifiers::NONE); + h.run_steps(2); + } else { + click(&mut h, undo); + } + assert!(field(&h, id, "conflict").is_null(), "{undo}: {}", field(&h, id, "conflict")); + assert!(h.query_by_label("Accept and Go to Conflict").is_none(), "{undo}"); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + if undo != "escape" { + assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.copy", Some("Cmd+C")).as_deref(), Some("Cmd+C"), "{undo}: Copy keeps ⌘C"); + } + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +#[test] +fn linux_reserves_its_own_system_keys() { + assert!(crate::prefs_ui::reserved_shortcut("Cmd+Alt+F2")); + assert!(!crate::prefs_ui::reserved_shortcut("Cmd+Escape")); +} + +#[cfg(target_os = "windows")] +#[test] +fn windows_reserves_its_own_system_keys() { + assert!(crate::prefs_ui::reserved_shortcut("Cmd+Escape")); + assert!(!crate::prefs_ui::reserved_shortcut("Cmd+Alt+F2")); +} + +/// A key two commands hold (Cut's ⌘X, given to Copy too): Accept takes it from both. +#[test] +fn accept_takes_the_key_from_every_command_holding_it() { + let (mut h, id) = dialog(1.0, "edit.paste", ""); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("overrides".into(), json!({"edit.copy": "Cmd+X"})); + h.run_steps(2); + capture_key(&mut h, id, Key::X, Modifiers::COMMAND); + let holders = field(&h, id, "conflict")["holders"].clone(); + assert!(holders.as_array().is_some_and(|a| a.len() == 2), "{holders}"); + click(&mut h, "Accept and Go to Conflict"); + let o = field(&h, id, "overrides"); + assert_eq!((o["edit.cut"].clone(), o["edit.copy"].clone(), o["edit.paste"].clone()), (json!(""), json!(""), json!("Cmd+X")), "{o}"); +} diff --git a/crates/ui-egui/src/shortcut_dispatch.rs b/crates/ui-egui/src/shortcut_dispatch.rs index 0a64437214..ffc1a7f6e5 100644 --- a/crates/ui-egui/src/shortcut_dispatch.rs +++ b/crates/ui-egui/src/shortcut_dispatch.rs @@ -39,19 +39,17 @@ pub fn take_log(ctx: &egui::Context) -> Vec<(String, Outcome)> { ctx.data_mut(|d| d.remove_temp::>(log_id())).unwrap_or_default() } -/// Alternative default shortcuts Photoshop gives some commands besides the one its menu shows: -/// (command, shortcut). Edit › Fill… is Shift+F5 and also Shift+Backspace (Shift+Delete on a Mac). -pub const SECONDARY: &[(&str, &str)] = &[("edit.fill", "Shift+Backspace")]; - /// Every key binding, most modifiers first (so ⇧⌘Z wins over ⌘Z), each key owned by one command: /// command and shell shortcuts, then the menu catalogue's for live items without their own, -/// then Edit › Keyboard Shortcuts assignments to any other menu item. D and X +/// then Edit › Keyboard Shortcuts assignments to any other menu item, then the commands' +/// alternate keys (#3665: F3 is Copy as well as ⌘C, see +/// [`photocraft_engine::prefs::ALTERNATE_SHORTCUTS`]), which a main key of another command wins. D and X /// (`tools.defaultColors` / `tools.swapColors`) and the fill keys are commands like any other, so /// their overrides apply. Held temporary tools (Space…) are not here: see [`crate::hold_keys`]. pub fn bindings(app: &PhotocraftApp) -> Vec<(String, KeyboardShortcut)> { let prefs = app.session.prefs(); let action_ids: Vec = app.session.actions.list.iter().map(|a| crate::actions::shortcut_id(&a.name)).collect(); - let actions = action_ids.iter().map(|id| (id.as_str(), prefs.shortcut(id, None))); + let actions = action_ids.iter().map(|id| (id.as_str(), prefs.shortcut(id, None).map(str::to_string))); // The Keyboard Shortcuts dialog lists Photoshop's Window › item (`window.panel.*`) // in place of the shell command it runs (`window.toggle.*`, see `menus::panel_alias`), so an // edit to that row reassigns or removes the shell command's default too (#1272). @@ -61,8 +59,8 @@ pub fn bindings(app: &PhotocraftApp) -> Vec<(String, KeyboardShortcut)> { crate::menus::panel_alias(k) == Some(id) && crate::menu_catalog::CATALOG.iter().any(|c| c.3 == k.as_str() && c.2.is_some() && c.2 == sc) }) }; - let ui = crate::menus::UI_COMMANDS.iter().map(|(id, _, _, sc)| (*id, if aliased(id, *sc) { None } else { prefs.shortcut(id, *sc) })); - let engine = photocraft_engine::command_specs().iter().map(|c| (c.id, prefs.shortcut(c.id, c.shortcut))); + let ui = crate::menus::UI_COMMANDS.iter().map(|(id, _, _, sc)| (*id, if aliased(id, *sc) { None } else { prefs.shortcut(id, *sc).map(str::to_string) })); + let engine = photocraft_engine::command_specs().iter().map(|c| (c.id, prefs.shortcut(c.id, c.shortcut).map(str::to_string))); let own: std::collections::HashSet<&str> = crate::menus::UI_COMMANDS .iter() .map(|c| c.0) @@ -71,7 +69,7 @@ pub fn bindings(app: &PhotocraftApp) -> Vec<(String, KeyboardShortcut)> { let catalog = crate::menu_catalog::CATALOG .iter() .filter(|(_, _, sc, id)| sc.is_some() && !own.contains(id) && crate::menus::is_live(id)) - .map(|&(_, _, sc, id)| (id, prefs.shortcut(id, sc))); + .map(|&(_, _, sc, id)| (id, prefs.shortcut(id, sc).map(str::to_string))); let overrides = prefs .shortcuts .iter() @@ -82,17 +80,32 @@ pub fn bindings(app: &PhotocraftApp) -> Vec<(String, KeyboardShortcut)> { && !crate::menus::UI_COMMANDS.iter().any(|c| c.0 == id.as_str()) && !crate::hold_keys::is_temporary(id) }) - .map(|(id, sc)| (id.as_str(), Some(sc.as_str()))); - // Photoshop's second shortcuts, kept while the command's main one is the default. - let secondary = SECONDARY.iter().filter(|(id, _)| !prefs.shortcuts.contains_key(*id)).map(|&(id, sc)| (id, Some(sc))); + .map(|(id, sc)| (id.as_str(), Some(sc.clone()))); // A key the user assigned in Edit › Keyboard Shortcuts belongs to that command, ahead of any // default that still names it (#1272): OK clears the old owner's row, but that row can be // another id for the same item, or a default the dialog doesn't list. - let mut candidates: Vec<(&str, Option<&str>)> = actions.chain(ui).chain(engine).chain(catalog).chain(overrides).chain(secondary).collect(); - candidates.sort_by_key(|(id, sc)| !(sc.is_some() && prefs.shortcuts.get(*id).map(String::as_str) == *sc)); + let mut candidates: Vec<(&str, Option, bool)> = actions + .chain(ui) + .chain(engine) + .chain(catalog) + .chain(overrides) + .map(|(id, sc)| { + let user = sc.is_some() && prefs.shortcuts.get(id) == sc.as_ref(); + (id, sc, user) + }) + .collect(); + // Each command's alternates after every main key, the user's ahead of the defaults. + let alternate_ids: std::collections::BTreeSet<&str> = + photocraft_engine::prefs::ALTERNATE_SHORTCUTS.iter().map(|(id, _)| *id).chain(prefs.alternate_shortcuts.keys().map(String::as_str)).collect(); + for id in alternate_ids { + let main = prefs.shortcut(id, crate::shortcuts::default_shortcut(id).as_deref()).map(str::to_string); + let user = prefs.alternate_shortcuts.contains_key(id); + candidates.extend(prefs.alternates(id, main.as_deref()).into_iter().map(|k| (id, Some(k), user))); + } + candidates.sort_by_key(|(_, _, user)| !user); let mut all: Vec<(String, KeyboardShortcut)> = Vec::new(); - for (id, sc) in candidates { - let Some(sc) = sc.and_then(parse) else { continue }; + for (id, sc, _) in candidates { + let Some(sc) = sc.as_deref().and_then(parse) else { continue }; if !all.iter().any(|(_, b)| *b == sc) { all.push((id.to_string(), sc)); } @@ -202,7 +215,9 @@ fn key_owner(app: &PhotocraftApp, ctx: &egui::Context) -> (bool, usize, bool, bo } /// Keys whose shortcuts follow the key's position, as in Photoshop: ⇧⌘. is ⇧⌘> on a US layout, -/// but types `:` on Nordic and German ones, so its character would never match. +/// but types `:` on Nordic and German ones, so its character would never match. Only ⌘ shortcuts +/// follow the position: a plain `,` or `;` binding (brush hardness on AZERTY, where `;` sits on the +/// US comma key) must match the character typed. const POSITIONAL: [Key; 2] = [Key::Period, Key::Comma]; /// Dispatch this frame's shortcut presses in the order they arrived (⌘Z then ⌘S undoes, then @@ -223,7 +238,7 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc egui::Event::Key { key, physical_key, pressed: true, modifiers, .. } => { let by_position = physical_key .filter(|p| POSITIONAL.contains(p)) - .and_then(|p| table.iter().find(|(_, sc)| sc.logical_key == p && key_matches(sc, p, *modifiers))); + .and_then(|p| table.iter().find(|(_, sc)| sc.modifiers.command && sc.logical_key == p && key_matches(sc, p, *modifiers))); by_position.or_else(|| table.iter().find(|(_, sc)| key_matches(sc, *key, *modifiers))).map(|(id, _)| (id.clone(), *key == Key::Tab)) } _ => None, diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index 458f22c050..00f1a120a3 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -315,6 +315,47 @@ fn a_disabled_shortcut_uses_the_command_id_translation() { }); } +/// #3665: Photoshop's second keys reach their command beside its main key: F2/F3/F4 Cut, Copy +/// and Paste, Shift+F7 Inverse, ⌘+ Zoom In, Shift+Backspace Fill. +#[test] +fn alternate_keys_run_their_command() { + let mut h = harness(); + put_focus(&mut h, Place::Canvas); + set_dry_run(&h.ctx, true); + for (key, want) in [ + ("F2", "edit.cut"), + ("F3", "edit.copy"), + ("Cmd+C", "edit.copy"), + ("F4", "edit.paste"), + ("Shift+F7", "select.inverse"), + ("Cmd+Shift+I", "select.inverse"), + ("Cmd++", "view.zoomIn"), + ("Shift+Backspace", "edit.fill"), + ] { + press(&mut h, key); + let log = take_log(&h.ctx); + assert!(matches!(log.as_slice(), [(id, _)] if id == want), "{key}: {log:?}"); + } + // Overriding Copy's main key keeps F3; ⌘C no longer copies. + h.state_mut().session.edit_prefs(|p| { + p.shortcuts.insert("edit.copy".into(), "Cmd+K".into()); + }); + press(&mut h, "F3"); + assert!(matches!(take_log(&h.ctx).as_slice(), [(id, _)] if id == "edit.copy")); + // A key a user gives another command wins over a default alternate. + h.state_mut().session.edit_prefs(|p| { + p.shortcuts.insert("layer.renameLayer".into(), "F2".into()); + }); + assert!(bindings(h.state()).iter().any(|(id, sc)| id == "layer.renameLayer" && *sc == parse("F2").unwrap())); + assert!(!bindings(h.state()).iter().any(|(id, sc)| id == "edit.cut" && *sc == parse("F2").unwrap())); + // A removed alternate no longer fires. + h.state_mut().session.edit_prefs(|p| { + p.alternate_shortcuts.insert("edit.paste".into(), Vec::new()); + }); + press(&mut h, "F4"); + assert!(take_log(&h.ctx).is_empty()); +} + /// #352: ⌥[ ⌥] ⌥, ⌥. and ⇧⌥[ walk the Layers panel's rows from the keyboard. #[test] fn alt_brackets_walk_the_layers_panel() { @@ -763,3 +804,23 @@ fn type_size_keys_follow_key_position_and_the_typed_selection() { assert_eq!(logged(&h), ["type.decreaseSize"]); assert_eq!(sizes(&h), [(2, base), (3, base + 2.0)], "only the selected \"He\" shrinks"); } + +/// AZERTY: `;` sits on the US comma key and `,` on the US M key. Plain `,` / `;` bindings (brush +/// hardness) must match the character typed, not the key's position, or `;` fires `,`'s command. +#[test] +fn plain_comma_and_semicolon_bindings_follow_the_typed_character_on_azerty() { + let mut h = harness(); + h.state_mut().session.edit_prefs(|p| { + p.shortcuts.insert("tools.decreaseBrushHardness".into(), ",".into()); + p.shortcuts.insert("tools.increaseBrushHardness".into(), ";".into()); + }); + let tap = |h: &mut Harness<'_, PhotocraftApp>, key, physical| { + h.event(egui::Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.event(egui::Event::Key { key, physical_key: Some(physical), pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + }; + tap(&mut h, egui::Key::Semicolon, egui::Key::Comma); + assert_eq!(logged(&h), ["tools.increaseBrushHardness"]); + tap(&mut h, egui::Key::Comma, egui::Key::M); + assert_eq!(logged(&h), ["tools.decreaseBrushHardness"]); +} diff --git a/crates/ui-egui/src/shortcut_set_tests.rs b/crates/ui-egui/src/shortcut_set_tests.rs new file mode 100644 index 0000000000..b00f3839fc --- /dev/null +++ b/crates/ui-egui/src/shortcut_set_tests.rs @@ -0,0 +1,128 @@ +//! Edit › Keyboard Shortcuts › Set in the dialog: a new set saves the dialog's bindings, the +//! dropdown switches sets, Save and Delete wait for OK, and Export / Summarize write the bindings +//! as the dialog shows them. + +use std::cell::RefCell; +use std::rc::Rc; + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::file_dialog::FileDialogAnswer; + +type Written = Rc)>>>; + +/// The dialog, with the save picker answering `answers` and writes recorded. +fn dialog(answers: Vec<&str>) -> (Harness<'static, PhotocraftApp>, u64, Written) { + let written: Written = Rc::default(); + let log = written.clone(); + let mut h = Harness::builder().with_size(egui::vec2(1100.0, 760.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut services = crate::Services::default(); + let (picker, _) = crate::file_dialog::fake(answers.iter().map(|a| Some(FileDialogAnswer::SaveTo((*a).into()))).collect()); + services.file_dialog = Some(picker); + services.write = Some(Box::new(move |path: &str, bytes: &[u8]| { + log.borrow_mut().push((path.to_string(), bytes.to_vec())); + Ok(()) + })); + PhotocraftApp::new(photocraft_engine::Session::new(), services) + }); + let ctx = h.ctx.clone(); + let id = crate::menus::invoke(h.state_mut(), &ctx, "edit.keyboardShortcuts", json!({})).unwrap()["dialog"].as_u64().unwrap(); + h.run_steps(4); + (h, id, written) +} + +fn field(h: &Harness<'_, PhotocraftApp>, id: u64, name: &str) -> Value { + h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.get(name).cloned().unwrap_or(Value::Null) +} + +fn set_field(h: &mut Harness<'_, PhotocraftApp>, id: u64, name: &str, v: Value) { + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert(name.into(), v); + h.run_steps(2); +} + +fn click(h: &mut Harness<'_, PhotocraftApp>, label: &str) { + // The first match: the set row's buttons come before the dialog's own (its Cancel). + h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).next().expect("the button").click(); + h.run_steps(3); +} + +#[test] +fn a_new_set_saves_the_dialogs_bindings_and_ok_keeps_it() { + let (mut h, id, _) = dialog(vec![]); + // An edit in the dialog, then New Set… with a name. + set_field(&mut h, id, "overrides", json!({"edit.copy": "F9"})); + click(&mut h, "New Set…"); + set_field(&mut h, id, "newSetName", json!(" Painting ")); + click(&mut h, "Create"); + assert_eq!(field(&h, id, "activeSet"), json!("Painting")); + assert_eq!(field(&h, id, "sets")["Painting"]["shortcuts"], json!({"edit.copy": "F9"})); + assert!(field(&h, id, "newSetName").is_null()); + // A bad name is refused with a message. + click(&mut h, "New Set…"); + set_field(&mut h, id, "newSetName", json!("PhotoCraft Defaults")); + click(&mut h, "Create"); + assert!(field(&h, id, "message").as_str().unwrap_or_default().contains("built-in"), "{:?}", field(&h, id, "message")); + click(&mut h, "Cancel"); + // Nothing is saved before OK; OK saves the set and keeps it in use. + assert!(h.state().session.prefs().shortcut_sets.is_empty()); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let p = h.state().session.prefs(); + assert_eq!((p.shortcut_set.as_str(), p.shortcuts.get("edit.copy").map(String::as_str)), ("Painting", Some("F9"))); + assert_eq!(p.shortcut_sets["Painting"].shortcuts.get("edit.copy").map(String::as_str), Some("F9")); +} + +#[test] +fn choosing_a_set_loads_it_and_save_and_delete_wait_for_ok() { + let (mut h, first, _) = dialog(vec![]); + h.state_mut().ui.dialogs.retain(|d| d.id != first); + h.state_mut().run("edit.keyboardShortcuts", json!({"set": {"edit.copy": "F9"}, "saveSet": "A"})).unwrap(); + h.state_mut().run("edit.keyboardShortcuts", json!({"useSet": ""})).unwrap(); + let ctx = h.ctx.clone(); + let id = crate::menus::invoke(h.state_mut(), &ctx, "edit.keyboardShortcuts", json!({})).unwrap()["dialog"].as_u64().unwrap(); + h.run_steps(4); + assert_eq!(field(&h, id, "overrides"), json!({})); + // Choosing set A in the dropdown (as its row sets it) loads its bindings. + set_field(&mut h, id, "activeSet", json!("A")); + let set = field(&h, id, "sets")["A"].clone(); + set_field(&mut h, id, "overrides", set["shortcuts"].clone()); + // An edit marks it modified; Save stores it in the dialog's sets. + set_field(&mut h, id, "overrides", json!({"edit.copy": "F8"})); + click(&mut h, "Save"); + assert_eq!(field(&h, id, "sets")["A"]["shortcuts"]["edit.copy"], "F8"); + assert_eq!(h.state().session.prefs().shortcut_sets["A"].shortcuts["edit.copy"], "F9", "not before OK"); + click(&mut h, "Delete"); + assert_eq!((field(&h, id, "sets"), field(&h, id, "activeSet")), (json!({}), json!(""))); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let p = h.state().session.prefs(); + assert!(p.shortcut_sets.is_empty() && p.shortcut_set.is_empty()); + assert_eq!(p.shortcuts.get("edit.copy").map(String::as_str), Some("F8"), "the bindings stay"); +} + +#[test] +fn export_and_summarize_write_the_dialogs_bindings() { + let (mut h, id, written) = dialog(vec!["Mine.kys", "Mine.html"]); + set_field(&mut h, id, "overrides", json!({"edit.copy": "F9", "tools.select.pencil": "F6"})); + set_field(&mut h, id, "alternates", json!({"edit.copy": []})); + let ctx = h.ctx.clone(); + click(&mut h, "Export…"); + h.state_mut().poll_file_dialog(&ctx, None); + click(&mut h, "Summarize…"); + h.state_mut().poll_file_dialog(&ctx, None); + let written = written.borrow(); + assert_eq!(written.len(), 2, "{:?}", written.iter().map(|w| &w.0).collect::>()); + let kys = String::from_utf8(written[0].1.clone()).unwrap(); + assert!(kys.contains("pc-id=\"edit.copy\">F9
"), "{kys}"); + assert!(kys.contains("pc-id=\"tools.select.pencil\">F6"), "tools as rows: {kys}"); + assert!(kys.contains("KeyboardCustomization/PhotoCraft Defaults="), "{kys}"); + // The file reads back with the same bindings. + let set = photocraft_engine::kys::parse(&kys).unwrap(); + assert!(set.commands.iter().any(|c| c.id.as_deref() == Some("edit.copy") && c.shortcuts == ["F9"])); + let html = String::from_utf8(written[1].1.clone()).unwrap(); + assert!(html.contains("CopyF9") && html.contains("

Edit

"), "{html}"); + // Nothing changed the preferences. + assert!(h.state().session.prefs().shortcuts.is_empty()); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index bd3b1fe00c..1ad8d6d92a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -98,6 +98,13 @@ pub fn effective_shortcut(app: &PhotocraftApp, id: &str, default: Option<&str>) app.session.prefs().shortcut(id, default).map(str::to_string) } +/// A command's further keys in effect besides its main one (#3665): the user's, else +/// Photoshop's (F3 for Copy, Shift+F7 for Inverse…). Menus show only the main key. +pub fn effective_alternates(app: &PhotocraftApp, id: &str) -> Vec { + let main = effective_shortcut(app, id, default_shortcut(id).as_deref()); + app.session.prefs().alternates(id, main.as_deref()) +} + /// Default (unmodified) shortcut of a command id. pub fn default_shortcut(id: &str) -> Option { crate::menus::UI_COMMANDS @@ -189,10 +196,10 @@ fn new_layer_without_dialog(app: &mut PhotocraftApp, ctx: &egui::Context) -> boo fn nav_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { for id in NAV_COMMANDS { - let sc = crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| parse(app.session.prefs().shortcut(id, c.3)?)); - if let Some(sc) = sc - && consume(ctx, &sc) - { + let main = crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| app.session.prefs().shortcut(id, c.3)); + let keys: Vec = + main.into_iter().map(str::to_string).chain(app.session.prefs().alternates(id, main)).filter_map(|k| parse(&k)).collect(); + if keys.iter().any(|sc| consume(ctx, sc)) { if crate::menus::is_enabled(app, id) { let _ = crate::menus::invoke(app, ctx, id, json!({})); } @@ -226,6 +233,10 @@ fn clipboard_press(e: &egui::Event, mods: Modifiers, is_windows: bool) -> Option /// (the key-down's default action) arrives as `Event::Paste` in the same frame. A clipboard event /// right after its own ⌘ key press is that press, not a second one (#2638). /// +/// With an image on the clipboard the V release is all that reaches us, and ⌘ is often let go +/// first: a V release whose press never arrived, after ⌘ was held and before any other key, is +/// that swallowed ⌘V too (#6329). Releasing in either order then pastes. +/// /// Ctrl+Insert copies and Shift+Insert pastes: egui-winit maps them on Windows (a Shift+Insert /// release without a paste event pastes the image, like ⌘V), and we map them here on Linux. pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInput) { @@ -272,18 +283,31 @@ pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInpu return; } let seen = egui::Id::new("pc-paste-key-seen"); + // A V press without ⌘ that reached us (the Move tool's key): its release is not a paste. + let plain_v = egui::Id::new("pc-plain-v-down"); + // ⌘ was held since the last key press without it: a swallowed ⌘V can be under way. + let command_lately = egui::Id::new("pc-paste-command-lately"); // The modifiers held at each event: last frame's, updated as the events report changes. let mut mods = ctx.input(|i| i.modifiers); + if mods.command { + ctx.data_mut(|d| d.insert_temp(command_lately, true)); + } let mut out = Vec::with_capacity(raw.events.len()); // The last ⌘C/⌘X/⌘V press this frame whose clipboard event hasn't arrived (yet). let mut unpaired: Option = None; for e in raw.events.drain(..) { if let egui::Event::ModifiersChanged(m) | egui::Event::Key { modifiers: m, .. } = &e { mods = *m; + if mods.command { + ctx.data_mut(|d| d.insert_temp(command_lately, true)); + } } if let Some((key, held)) = clipboard_press(&e, mods, cfg!(target_os = "windows")) { if key == Key::V { - ctx.data_mut(|d| d.insert_temp(seen, true)); + ctx.data_mut(|d| { + d.insert_temp(seen, true); + d.remove_temp::(plain_v); + }); } if unpaired.take_if(|k| *k == key).is_none() { out.push(press(key, held)); @@ -297,15 +321,32 @@ pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInpu egui::Event::Key { key: key @ (Key::C | Key::X | Key::V), pressed: true, modifiers, .. } if modifiers.command => { unpaired = Some(*key); if *key == Key::V { - ctx.data_mut(|d| d.insert_temp(seen, true)); + ctx.data_mut(|d| { + d.insert_temp(seen, true); + d.remove_temp::(plain_v); + }); } } + egui::Event::Key { key, pressed: true, modifiers, .. } if !modifiers.command => { + ctx.data_mut(|d| { + d.remove_temp::(command_lately); + if *key == Key::V { + d.insert_temp(plain_v, true); + } + }); + } // A paste key's release ends that press, with or without ⌘ still held (else a stale // mark would swallow the next image paste); without a press it is the paste itself. egui::Event::Key { key: key @ (Key::V | Key::Insert), pressed: false, modifiers, .. } => { let had_press = ctx.data_mut(|d| d.remove_temp::(seen)).unwrap_or(false); + let had_plain_press = *key == Key::V && ctx.data_mut(|d| d.remove_temp::(plain_v)).unwrap_or(false); + // Taken: one swallowed ⌘V is one paste. + let lately = *key == Key::V && ctx.data_mut(|d| d.remove_temp::(command_lately)).unwrap_or(false); let held = match key { Key::V if modifiers.command => Some(*modifiers), + // ⌘ let go before V: the press egui-winit swallowed was ⌘V (#6329). The web + // always forwards the key-down, so there a lone release is no paste. + Key::V if lately && !had_plain_press && !cfg!(target_arch = "wasm32") => Some(Modifiers::COMMAND), Key::Insert if cfg!(target_os = "windows") && modifiers.shift && !modifiers.ctrl => Some(Modifiers::COMMAND), _ => None, }; @@ -334,9 +375,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.camera_raw.is_some() { return; } - // An open menu owns the keyboard (arrows, ↩, Esc), like a native menu; so does an open - // dropdown list, whose letters pick an option rather than a tool (#1481). - if crate::menu_nav::is_open(ctx) || crate::widgets::dropdown_list_open(ctx) { + // An open menu owns the keyboard (arrows, ↩, Esc), like a native menu; so does the menu bar + // once Alt or F10 put the keyboard on it (#4988), and an open dropdown list, whose letters + // pick an option rather than a tool (#1481). + if crate::menu_nav::is_open(ctx) || crate::menu_keys::is_focused(ctx) || crate::widgets::dropdown_list_open(ctx) { return; } // ⌘Z in a filter or adjustment dialog steps the dialog's settings, not the document (#4697). @@ -374,11 +416,15 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { crate::liquify_ui::keys(app, ctx); } } + // The Content-Aware Fill workspace likewise. + if app.distort.caf.is_some() && !ctx.text_edit_focused() { + crate::caf_ui::keys(app, ctx); + } use crate::shortcut_dispatch::{Focus, dispatch_pressed}; let focus = Focus::of(ctx); // Dialogs (and Liquify's panel while one of its controls has focus) keep canvas zoom; a // focused text field keeps its keys. - let liquify_focus = app.distort.liquify.is_some() && focus != Focus::None; + let liquify_focus = (app.distort.liquify.is_some() || app.distort.caf.is_some()) && focus != Focus::None; if liquify_focus || !app.ui.dialogs.is_empty() || app.discard.is_some() { if focus != Focus::Text { nav_keys(app, ctx); @@ -469,6 +515,19 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { crate::vector_ui::pen_commit(app, false); return; } + // A finished work path shown by the Pen or Path Selection: ⌫/Delete deletes it, as in + // Photoshop (#5702), never Edit › Clear on the pixels under it. + if focus != Focus::DeletingWidget && crate::vector_ui::delete_key_deletes_work_path(app) { + let mods = ctx.input(|i| i.modifiers); + if !mods.command && !mods.ctrl && !mods.shift && !mods.alt && ctx.input_mut(|i| i.consume_key(mods, Key::Backspace) || i.consume_key(mods, Key::Delete)) + { + if let Err(e) = app.run("path.delete", json!({"name": "work"})) { + app.ui.status = e; + app.ui.status_error = true; + } + return; + } + } // Magnetic Lasso: ↩ closes, Esc cancels, ⌫ removes a fastening point (not Edit › Clear), // [ ] change the detection width (not the brush size). if crate::magnetic_lasso_ui::keys(app, ctx) { @@ -492,6 +551,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } } + // A selected guide: ⌫/Delete removes it (`guide_select`), never Edit › Clear's pixels. + if crate::guide_select::keys(app, ctx) { + return; + } // Space+F (⇧Space+F backwards) cycles the pasteboard colour, as in Photoshop: F while the // Hand key is held, before F's own Cycle Screen Mode. if !matches!(focus, Focus::Text) && app.ui.text_edit.is_none() && crate::hold_keys::reposition_held(app, ctx) { @@ -555,7 +618,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } } - if !app.ui.polygon.is_empty() || app.ui.crop_rect.is_some() { + // An open options bar popup (the Crop Ratio list, the overlay and gear menus) takes the keys + // first: Esc closes it, and neither key reaches the crop beneath (#7386). Its id is kept in + // memory from the last frame; `ctx.any_popup_open` only sees this frame's, drawn later. + if (!app.ui.polygon.is_empty() || app.ui.crop_rect.is_some()) && !egui::Popup::is_any_open(ctx) { if pressed(Key::Enter) { if !app.ui.polygon.is_empty() { crate::canvas::commit_polygon(app); @@ -564,7 +630,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { } return; } - if pressed(Key::Escape) { + // In a full screen mode, the Crop tool's untouched default frame (Straighten off) has nothing to cancel: cancelling only + // makes the next one, so Esc goes on to leave full screen (#7315). + let untouched = app.ui.view.screen_mode != "standard" && app.ui.polygon.is_empty() && app.crop.default_frame && !crate::crop_straighten::mode(app); + if !untouched && pressed(Key::Escape) { app.ui.polygon.clear(); app.ui.polygon_mode.clear(); crate::crop_ui::cancel(app); @@ -628,6 +697,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { // `consume_key` ignores extra ⇧ and ⌥: a ⇧/⌥ switch is never spring-loaded. let (plain, now) = ctx.input(|i| (!cycle_shift && !i.modifiers.shift && !i.modifiers.alt, i.time)); crate::spring_tools::pressed(app, t.key(), before, plain, now); + if app.ui.spring.is_none() { + // Permanent key choices own the tool; held-key switches keep the pen marker. + app.stylus.tool_before_eraser = None; + } return; } } @@ -748,7 +821,7 @@ mod tests { app.run("shape.create", serde_json::json!({"kind": "rect", "rect": [10, 10, 40, 40], "fill": "#ff0000"})).unwrap(); let history = app.session.active().unwrap().history.past_len(); for (x, y) in [(10.0, 10.0), (50.0, 10.0), (50.0, 50.0)] { - crate::vector_ui::pen_down(&mut app, x, y); + crate::vector_ui::pen_down(&mut app, x, y, false); crate::vector_ui::pen_up(&mut app); } let ctx = egui::Context::default(); @@ -781,7 +854,7 @@ mod tests { app.run("file.new", serde_json::json!({"width": 200, "height": 200})).unwrap(); app.ui.tool = crate::state::Tool::Pen; for (x, y) in [(10.0, 10.0), (50.0, 10.0)] { - crate::vector_ui::pen_down(&mut app, x, y); + crate::vector_ui::pen_down(&mut app, x, y, false); crate::vector_ui::pen_up(&mut app); } app.session.prefs.edit(|p| p.shortcuts.insert("edit.undo".into(), "Cmd+Shift+Y".into())); @@ -978,6 +1051,37 @@ mod tests { assert_eq!(presses(vec![egui::Event::ModifiersChanged(Modifiers::COMMAND), key(Key::V, false, Modifiers::COMMAND)]), 1, "image paste"); } + /// #6329: with an image on the clipboard egui-winit swallows the ⌘V press and sends only the V + /// release, which carries no ⌘ when ⌘ was let go first: that release still pastes, once. A V + /// pressed and released on its own (the Move tool) never pastes. + #[test] + fn an_image_paste_released_after_command_still_pastes() { + let key = |key, pressed, modifiers| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + let pastes = |ctx: &egui::Context, events: Vec| { + let mut r = egui::RawInput { events, ..Default::default() }; + clipboard_keys(ctx, false, &mut r); + r.events.iter().filter(|e| matches!(e, egui::Event::Key { key: Key::V, pressed: true, modifiers, .. } if modifiers.command)).count() + }; + let none = Modifiers::NONE; + // ⌘ down, (the swallowed V press,) ⌘ up, V up: in one frame or over several. + let ctx = egui::Context::default(); + assert_eq!(pastes(&ctx, vec![egui::Event::ModifiersChanged(Modifiers::COMMAND), egui::Event::ModifiersChanged(none), key(Key::V, false, none)]), 1); + assert_eq!(pastes(&ctx, vec![egui::Event::ModifiersChanged(Modifiers::COMMAND)]), 0); + assert_eq!(pastes(&ctx, vec![egui::Event::ModifiersChanged(none)]), 0); + assert_eq!(pastes(&ctx, vec![key(Key::V, false, none)]), 1, "released after ⌘"); + // Once: another lone release without ⌘ held again doesn't paste again. + assert_eq!(pastes(&ctx, vec![key(Key::V, false, none)]), 0); + // A plain V tap after ⌘ was used is the Move tool, not a paste. + let ctx = egui::Context::default(); + assert_eq!(pastes(&ctx, vec![egui::Event::ModifiersChanged(Modifiers::COMMAND), egui::Event::ModifiersChanged(none)]), 0); + assert_eq!(pastes(&ctx, vec![key(Key::V, true, none), key(Key::V, false, none)]), 0); + // Without ⌘ at all, a lone V release is nothing. + let ctx = egui::Context::default(); + assert_eq!(pastes(&ctx, vec![key(Key::V, false, none)]), 0); + // Released with ⌘ still held it pastes as before. + assert_eq!(pastes(&ctx, vec![egui::Event::ModifiersChanged(Modifiers::COMMAND), key(Key::V, false, Modifiers::COMMAND)]), 1); + } + /// #2638: on the web one ⌘V brings its key press and the browser's paste event; that is one paste. #[test] fn web_paste_key_and_paste_event_are_one_press() { @@ -1077,8 +1181,11 @@ mod tests { let all = photocraft_engine::command_specs() .iter() .filter_map(|c| c.shortcut.map(|s| (c.id, s))) - .chain(crate::menus::UI_COMMANDS.iter().filter_map(|(id, _, _, s)| s.map(|s| (*id, s)))); + .chain(crate::menus::UI_COMMANDS.iter().filter_map(|(id, _, _, s)| s.map(|s| (*id, s)))) + // Photoshop's second keys (#3665) are taken by no other command either. + .chain(photocraft_engine::prefs::ALTERNATE_SHORTCUTS.iter().copied()); for (id, s) in all { + assert!(parse(s).is_some(), "{id}: {s}"); if let Some(prev) = seen.insert(s, id) { panic!("shortcut {s} bound to both {prev} and {id}"); } diff --git a/crates/ui-egui/src/sizing.rs b/crates/ui-egui/src/sizing.rs index 6cce7e0a5c..d0e0cfdec4 100644 --- a/crates/ui-egui/src/sizing.rs +++ b/crates/ui-egui/src/sizing.rs @@ -70,7 +70,8 @@ pub fn reset(f: &mut Map) { pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { let unit = pref_unit(app.session.prefs().units_and_rulers.rulers); - let resample = pref_resample(app.session.prefs().general.image_interpolation); + let mut resample = pref_resample(app.session.prefs().general.image_interpolation); + let pt_per_in = app.session.prefs().units_and_rulers.point_size.per_inch(); let d = &app.session.active()?.doc; let (w, h, res) = (d.size.width as f64, d.size.height as f64, d.resolution_dpi as f64); let label = photocraft_engine::commands::find(command).map(|c| c.label).unwrap_or("Image Size…"); @@ -82,10 +83,17 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { f.insert("__origH".into(), json!(h)); f.insert("__origRes".into(), json!(res)); f.insert("__unit".into(), json!(unit)); + f.insert("__ptPerIn".into(), json!(pt_per_in)); f.insert("__bytesPerPixel".into(), json!(d.layers.first().and_then(|l| l.surface()).map_or(4, |s| s.format().bytes_per_pixel()))); f.insert("width".into(), json!(w)); f.insert("height".into(), json!(h)); if command == "image.imageSize" { + // Indexed Color resamples with Nearest Neighbor only, as the command does (#6019). + let indexed = d.mode == photocraft_color::ColorMode::Indexed; + if indexed && resample != "automatic" { + resample = "nearest"; + } + f.insert("__indexed".into(), json!(indexed)); f.insert("__constrain".into(), json!(true)); f.insert("resolution".into(), json!(res)); f.insert("resample".into(), json!(resample)); @@ -102,27 +110,33 @@ fn num(f: &Map, k: &str) -> f64 { f.get(k).and_then(Value::as_f64).unwrap_or(0.0) } -/// Pixels → display unit. -fn to_unit(px: f64, unit: &str, orig: f64, res: f64) -> f64 { +/// Points per inch from Preferences › Units & Rulers › Point/Pica Size, stored when the dialog +/// opens: 72 (PostScript) when missing or not a positive finite number. +fn pt_per_in(f: &Map) -> f64 { + f.get("__ptPerIn").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(72.0) +} + +/// Pixels → display unit; `ppi` is points per inch (a pica is 12 points). +fn to_unit(px: f64, unit: &str, orig: f64, res: f64, ppi: f64) -> f64 { match unit { "percent" => px / orig.max(1.0) * 100.0, "in" => px / res.max(1.0), "cm" => px / res.max(1.0) * 2.54, "mm" => px / res.max(1.0) * 25.4, - "pt" => px / res.max(1.0) * 72.0, - "pica" => px / res.max(1.0) * 6.0, + "pt" => px / res.max(1.0) * ppi, + "pica" => px / res.max(1.0) * ppi / 12.0, _ => px, } } -fn from_unit(v: f64, unit: &str, orig: f64, res: f64) -> f64 { +fn from_unit(v: f64, unit: &str, orig: f64, res: f64, ppi: f64) -> f64 { match unit { "percent" => v * orig / 100.0, "in" => v * res, "cm" => v / 2.54 * res, "mm" => v / 25.4 * res, - "pt" => v / 72.0 * res, - "pica" => v / 6.0 * res, + "pt" => v / ppi * res, + "pica" => v / ppi * 12.0 * res, _ => v, } } @@ -165,18 +179,19 @@ fn dim_field(ui: &mut egui::Ui, f: &mut Map, key: &str, orig_key: let res = f.get("resolution").and_then(Value::as_f64).unwrap_or(num(f, "__origRes")); let orig = num(f, orig_key); let px = num(f, key); - let mut v = to_unit(px, &unit, orig, res) as f32; + let ppi = pt_per_in(f); + let mut v = to_unit(px, &unit, orig, res, ppi) as f32; // The engine's 300000 px limit in this unit (never below 30000), so a large document's size // isn't clamped when the field is drawn: egui clamps the shown value to the range, and the // field reports that as an edit (#4162). - let lim = if unit == "px" { 300000.0 } else { to_unit(300000.0, &unit, orig, res).max(30000.0) as f32 }; + let lim = if unit == "px" { 300000.0 } else { to_unit(300000.0, &unit, orig, res, ppi).max(30000.0) as f32 }; let range = -lim..=lim; // Each unit keeps its own precision: whole pixels, 3 places for inches, 2 for cm/mm/picas (#2434). // A typed unit (`2in` while in pixels) converts at the dialog's resolution. - let units = crate::widgets::FieldUnits { unit: crate::widgets::unit_of_key(&unit), dpi: res, ppi: 72.0 }; + let units = crate::widgets::FieldUnits { unit: crate::widgets::unit_of_key(&unit), dpi: res, ppi }; let changed = crate::widgets::value_field_units(ui, &mut v, range, 90.0, crate::widgets::unit_decimals(&unit), units).changed(); if changed { - let px = from_unit(v as f64, &unit, orig, res); + let px = from_unit(v as f64, &unit, orig, res, ppi); f.insert(key.into(), json!(if unit == "px" { px.round() } else { px })); } let _ = id; @@ -312,6 +327,10 @@ fn image_size(ui: &mut egui::Ui, f: &mut Map) { ("bilinear".to_string(), tl!("Bilinear")), ("lanczos".to_string(), tl!("Lanczos (sharp)")), ]; + // Indexed Color: every other method blends colours the colour table doesn't have, + // which the command doesn't use and a save quantises away (#6019). + let indexed = f.get("__indexed").and_then(Value::as_bool) == Some(true); + let opts: Vec<_> = opts.into_iter().filter(|(k, _)| !indexed || k == "automatic" || k == "nearest").collect(); if crate::widgets::dropdown(ui, "is-resample", &mut cur, &opts, 200.0) { f.insert("resample".into(), json!(cur)); } @@ -442,15 +461,36 @@ mod tests { #[test] fn unit_conversions_round_trip() { - for unit in ["px", "percent", "in", "cm", "mm", "pt", "pica"] { - let v = to_unit(1500.0, unit, 3000.0, 300.0); - assert!((from_unit(v, unit, 3000.0, 300.0) - 1500.0).abs() < 1e-9, "{unit}"); + for ppi in [72.0, 72.27] { + for unit in ["px", "percent", "in", "cm", "mm", "pt", "pica"] { + let v = to_unit(1500.0, unit, 3000.0, 300.0, ppi); + assert!((from_unit(v, unit, 3000.0, 300.0, ppi) - 1500.0).abs() < 1e-9, "{unit} at {ppi} pt/in"); + } } - assert_eq!(to_unit(1500.0, "percent", 3000.0, 300.0), 50.0); - assert_eq!(to_unit(600.0, "in", 3000.0, 300.0), 2.0); + assert_eq!(to_unit(1500.0, "percent", 3000.0, 300.0, 72.0), 50.0); + assert_eq!(to_unit(600.0, "in", 3000.0, 300.0, 72.0), 2.0); assert_eq!(human_bytes(6016.0 * 6016.0 * 4.0), "138.1M"); } + /// #5756: points and picas use the Point/Pica Size preference (Traditional is 72.27 pt/in), + /// and a dialog without the field (or with a bad one) falls back to PostScript's 72. + #[test] + fn points_and_picas_use_the_points_per_inch() { + assert!((to_unit(72.0, "pt", 72.0, 72.0, 72.27) - 72.27).abs() < 1e-9); + assert!((to_unit(72.0, "pica", 72.0, 72.0, 72.27) - 72.27 / 12.0).abs() < 1e-9); + assert_eq!(to_unit(72.0, "pt", 72.0, 72.0, 72.0), 72.0); + assert_eq!(to_unit(72.0, "pica", 72.0, 72.0, 72.0), 6.0); + assert!((from_unit(72.27, "pt", 72.0, 72.0, 72.27) - 72.0).abs() < 1e-9); + let mut f = Map::new(); + assert_eq!(pt_per_in(&f), 72.0); + for bad in [json!(0.0), json!(-3.0), json!("x")] { + f.insert("__ptPerIn".into(), bad); + assert_eq!(pt_per_in(&f), 72.0); + } + f.insert("__ptPerIn".into(), json!(72.27)); + assert_eq!(pt_per_in(&f), 72.27); + } + #[test] fn dialog_maps_to_engine_command() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -485,6 +525,35 @@ mod tests { assert!(app.ui.dialogs.iter().any(|x| x.id == id), "reset leaves the dialog open"); } + /// #6019: on an Indexed Color document the dialog opens on, and offers only, Automatic and Nearest Neighbor. + #[test] + fn indexed_color_resamples_with_nearest_neighbor_only() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.session.execute("image.mode.indexedColor", json!({})).unwrap(); + for (pref, method) in [("bicubic", "nearest"), ("bicubicAutomatic", "automatic"), ("nearestNeighbor", "nearest")] { + app.session.execute("prefs.set", json!({"path": "general.imageInterpolation", "value": pref})).unwrap(); + let id = open(&mut app, "image.imageSize").unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f.get("resample"), Some(&json!(method)), "{pref}"); + assert_eq!(f.get("__indexed"), Some(&json!(true))); + app.ui.close_dialog(id); + } + // The open list offers only Automatic and Nearest Neighbor. + use egui_kittest::{Harness, kittest::Queryable}; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + open(h.state_mut(), "image.imageSize").unwrap(); + h.run_steps(3); + let combo = h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Nearest Neighbor (hard edges)")); + combo.expect("the Resample list shows Nearest Neighbor").click(); + h.run_steps(3); + assert!(h.query_by_label_contains("Automatic").is_some(), "Automatic is offered"); + for gone in ["Bicubic", "Bilinear", "Lanczos", "Preserve Details"] { + assert!(h.query_by_label_contains(gone).is_none(), "{gone} is not offered for Indexed Color"); + } + } + /// The dialog starts on Preferences › General › Image Interpolation: Bicubic Automatic opens /// as Automatic and the others as themselves (Bicubic Sharper used to open as Lanczos). #[test] @@ -547,6 +616,33 @@ mod tests { (h, id) } + /// #5756: points and picas follow Preferences › Units & Rulers › Point/Pica Size. With + /// Traditional (72.27 pt/in) and Resample off, 100 px typed as 72.27 pt wide is 100 ppi. + #[test] + fn traditional_point_size_drives_points_and_picas() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 100, "height": 50, "resolution": 72})).unwrap(); + app.session.execute("prefs.set", json!({"path": "unitsAndRulers.pointSize", "value": "traditional"})).unwrap(); + for command in ["image.imageSize", "image.canvasSize"] { + let id = open(&mut app, command).unwrap(); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields.get("__ptPerIn"), Some(&json!(72.27)), "{command}"); + app.ui.close_dialog(id); + } + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(1400.0, 900.0)) + .build_ui_state(|ui, app: &mut PhotocraftApp| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(h.state_mut(), "image.imageSize").unwrap(); + let d = h.state_mut().ui.dialog_mut(id).unwrap(); + d.fields.insert("resample".into(), json!("none")); + d.fields.insert("__unit".into(), json!("pt")); + h.run_steps(3); + type_width(&mut h, "72.27"); + let f = fields(&h); + assert_eq!((num(&f, "width"), num(&f, "height")), (100.0, 50.0), "{f:?}"); + assert!((num(&f, "resolution") - 100.0).abs() < 1e-2, "{f:?}"); + } + /// Types `text` into the Width field and leaves it with Tab. fn type_width(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, text: &str) { use egui::accesskit::Role; diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 48b2f0a72f..2acc77ceaf 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -287,15 +287,41 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: } return; } + // So has the Filter Gallery. + if f.command == "filter.filterGallery" { + if let Err(e) = crate::gallery_ui::open_smart_filter(app, photocraft_doc::LayerId(layer), index) { + app.ui.status = e; + app.ui.status_error = true; + } + return; + } + // An adjustment recorded on the smart object (Brightness/Contrast, Levels…) re-opens its own + // Image › Adjustments editor, like the filters above. + if crate::adjust_dialog::has_dialog(&f.command) { + if let Some(id) = crate::adjust_dialog::open(app, &f.command) { + edit_filter(app, id, layer, index, &f.params); + } + return; + } if !crate::filter_dialog::has_dialog(&f.command) { return; } let Some(id) = crate::filter_dialog::open(app, &f.command) else { return }; + edit_filter(app, id, layer, index, &f.params); +} + +/// Hands the open dialog `id` the smart filter's recorded `params` and makes OK update that +/// filter (`layer.smartFilter.setParams`, see [`confirm_command`]) instead of adding another. +fn edit_filter(app: &mut PhotocraftApp, id: u64, layer: u64, index: usize, params: &Value) { if let Some(d) = app.ui.dialog_mut(id) { - // The live preview would stack a second copy of the filter on the smart object. - d.fields.remove("__preview"); + // A filter's live preview runs `layer.smartFilter.setParams` on the proxy, as OK does + // (`canvas::ensure_filter_preview`, #6324). An adjustment's previews on the layer and + // would stack a second copy of it on the smart object. + if d.fields.contains_key("__adjust") { + d.fields.remove("__preview"); + } d.fields.insert("__smartFilter".into(), json!({"layer": layer, "index": index})); - if let Value::Object(p) = &f.params { + if let Value::Object(p) = params { for (k, v) in p { if d.fields.contains_key(k) { d.fields.insert(k.clone(), v.clone()); @@ -305,6 +331,12 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: } } +/// The command a filter dialog's live preview runs ([`confirm_command`]'s): `layer.smartFilter.setParams` +/// while it edits a smart filter (#6324), else `command`. +pub fn preview_command<'a>(fields: &serde_json::Map, command: &'a str) -> &'a str { + if fields.contains_key("__smartFilter") { "layer.smartFilter.setParams" } else { command } +} + /// The command a confirmed filter dialog runs: smart-filter edits update the recorded filter. pub fn confirm_command(fields: &serde_json::Map, command: String, params: Value) -> (String, Value) { match fields.get("__smartFilter") { @@ -397,4 +429,81 @@ mod tests { assert!(drawn.contains(&(frame, t.field))); assert!(drawn.iter().all(|(_, c)| *c != t.icon)); } + + /// #5689: an Image › Adjustments recorded on a smart object (Brightness/Contrast…) opens its + /// own editor from the Smart Filters row, with the recorded settings, and OK updates that + /// filter instead of stacking another. + #[test] + fn an_adjustment_smart_filter_opens_its_editor_and_updates_it() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + let id = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("image.adjustments.brightnessContrast", json!({"brightness": 40, "contrast": -10})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + let sm = match &app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + LayerContent::Smart(sm) => sm.clone(), + other => panic!("expected a smart object, got {other:?}"), + }; + assert_eq!(sm.smart_filters.len(), 1); + open_editor(&mut app, &egui::Context::default(), id, &sm, 0); + let d = app.ui.dialogs.last().expect("the adjustment editor opened"); + assert_eq!(d.fields.get("__adjust").and_then(Value::as_str), Some("brightnessContrast")); + assert_eq!(d.fields.get("brightness").and_then(Value::as_f64), Some(40.0), "the recorded settings"); + assert_eq!(d.fields.get("contrast").and_then(Value::as_f64), Some(-10.0)); + let dialog = d.id; + // OK updates the recorded filter (one history step), it does not add a second filter. + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let LayerContent::Smart(sm) = &app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content else { + panic!("the layer stays a smart object"); + }; + assert_eq!(sm.smart_filters.len(), 1, "no filter was stacked"); + assert_eq!(sm.smart_filters[0].params.get("brightness").and_then(Value::as_f64), Some(40.0)); + assert_eq!(sm.smart_filters[0].params.get("contrast").and_then(Value::as_f64), Some(-10.0)); + } + + /// #6324: a smart filter opened for editing previews, and its preview replaces the filter in + /// the stack (as OK does) instead of stacking a second one. + #[test] + fn an_edited_smart_filter_previews_what_ok_gives() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "background": "#ffffff"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + s.execute("edit.stroke", json!({"width": 4, "color": "#000000", "location": "inside"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let id = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 1.0})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.background_jobs = false; + let smart = |app: &PhotocraftApp, doc: &Document| match &doc.layer(LayerId(id)).unwrap().content { + LayerContent::Smart(sm) => sm.clone(), + other => panic!("expected a smart object, got {other:?} ({})", app.ui.status), + }; + let sm = smart(&app, &app.session.active().unwrap().doc); + open_editor(&mut app, &egui::Context::default(), id, &sm, 0); + let d = app.ui.dialogs.last_mut().expect("the filter's dialog opened"); + assert_eq!(d.fields.get("__preview"), Some(&json!(true)), "the dialog previews"); + d.fields.insert("radius".into(), json!(6.0)); + let dialog = d.id; + + crate::canvas::ensure_filter_preview(&mut app, 0, &egui::Context::default()).expect("a preview"); + let preview = app.filter_preview.as_ref().unwrap(); + assert_eq!(preview.key.command, "layer.smartFilter.setParams"); + // The dialog's own preview window matches it to the dialog by that command. + let fields = &app.ui.dialogs.last().unwrap().fields; + assert_eq!(preview_command(fields, "filter.blur.gaussianBlur"), preview.key.command); + assert_eq!(preview_command(&serde_json::Map::new(), "filter.blur.gaussianBlur"), "filter.blur.gaussianBlur"); + let shown = preview.result.clone().expect("the preview's document"); + let filters = smart(&app, &shown).smart_filters; + assert_eq!(filters.len(), 1, "the edited filter is replaced, not stacked"); + assert_eq!(filters[0].params.get("radius").and_then(Value::as_f64), Some(6.0)); + assert_eq!(smart(&app, &app.session.active().unwrap().doc).smart_filters[0].params["radius"], json!(1.0), "the document is untouched"); + + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert_eq!(smart(&app, doc).smart_filters.len(), 1); + assert!(photocraft_compose::flatten(&shown).px == photocraft_compose::flatten(doc).px, "OK gives what the preview showed"); + } } diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index 56fecd01eb..e0b122c373 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -2,8 +2,8 @@ //! document, snaps tool gestures (Move tool, marquees, crop, shapes, pen anchors, Free Transform //! handles and guides), and draws smart guides (magenta alignment lines) while layers move. //! -//! Snapping is on when View › Snap (⇧⌘;) is; holding Ctrl while dragging turns it off for that -//! drag, as in Photoshop. Smart guides follow View › Show › Smart Guides and also snap the Move +//! Snapping is on when View › Snap (⇧⌘;) is; holding Ctrl while dragging turns it off, except when +//! Ctrl is the corner-distortion modifier. Smart guides follow View › Show › Smart Guides and also snap the Move //! tool to other layers' edges and centres. The threshold is 8 screen pixels at the current zoom. use egui::{Color32, Stroke, pos2}; @@ -22,6 +22,8 @@ pub const SNAP_PX: f64 = 8.0; pub enum Gesture { /// A point (marquee corner, crop or shape corner, pen anchor, transform handle). Point, + /// A transform corner: distortion snaps the corner itself, keeping the handle grab offset. + TransformCorner { index: usize, point: [f64; 2] }, /// Layers being moved: their bounds at the start of the drag. Move { rect: [f64; 4] }, /// Free Transform box dragged from inside: its bounds at the start of the drag. @@ -29,6 +31,9 @@ pub enum Gesture { /// A selection outline (or its floating piece) dragged with a selection tool: its bounds at /// the start of the drag. SelectionMove { rect: [f64; 4] }, + /// A marquee, Object Selection box or shape being drawn, repositioned with Space held + /// (`hold_keys`): its bounds when Space went down. + DrawMove { rect: [f64; 4] }, /// A guide being moved with the Move tool. Guide, } @@ -54,7 +59,14 @@ pub fn options(app: &PhotocraftApp) -> SnapOptions { crate::rulers::grid_major_px(g, d.doc.resolution_dpi.max(1.0) as f64, [d.doc.size.width as f64, d.doc.size.height as f64], ppi) .map(|step| step / g.subdivisions.max(1) as f64) }); - SnapOptions { guides: st.guides && e.guides, grid: st.grid && e.grid, layers: st.layers, document: st.document_bounds, selection: true, grid_step } + SnapOptions { + guides: st.guides && e.guides && app.ui.view.shows(app.ui.view.show.canvas_guides), + grid: st.grid && e.grid, + layers: st.layers, + document: st.document_bounds, + selection: true, + grid_step, + } } /// Snap tolerance in document pixels at the current zoom. @@ -154,6 +166,14 @@ fn override_held(mods: egui::Modifiers) -> bool { mods.ctrl && !mods.mac_cmd } +fn corner_distorts(app: &PhotocraftApp, gesture: &Gesture, mods: egui::Modifiers) -> bool { + matches!(gesture, Gesture::TransformCorner { index, .. } if crate::transform_tool::corner_distorts(app, *index, mods)) +} + +fn override_for_gesture(app: &PhotocraftApp, mods: egui::Modifiers) -> bool { + override_held(mods) && !(mods.command && app.prefs_rt.snap.as_ref().is_some_and(|s| corner_distorts(app, &s.gesture, mods))) +} + /// The Move tool's press at `p` picked other layers (Auto-Select): the drag moves those, so its /// box, what it snaps and what it leaves out of the targets are theirs, not the ones selected /// before (a group or an empty layer selected before had no box at all). @@ -174,12 +194,14 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { let moves_layer = crate::canvas::command_moves_layer(app, tool, p, mods); let crop_tol = crate::crop_ui::tolerance(app); let gesture = if let Some(t) = &app.ui.transform { + let corner = match crate::transform_tool::hit(t, p, crate::transform_tool::handle_tolerance(app)) { + crate::transform_tool::Hit::Corner(index) => t.quad.get(index).map(|&point| (Gesture::TransformCorner { index, point }, vec![LayerId(t.layer)])), + _ => None, + }; if t.mode == crate::state::TransformMode::Distort { - // Distort is freehand corner placement (fitting an image onto a screen, say): guides - // and edges pulling the corners about only get in the way. - None + corner } else if near_handle(&t.quad, p, crate::transform_tool::handle_tolerance(app)) { - Some((Gesture::Point, vec![LayerId(t.layer)])) + corner.or_else(|| Some((Gesture::Point, vec![LayerId(t.layer)]))) } else if in_quad(&t.quad, p) { Some((Gesture::TransformMove { rect: quad_rect(&t.quad) }, vec![LayerId(t.layer)])) } else { @@ -220,6 +242,10 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { }; let Some((gesture, exclude)) = gesture else { return }; let mut targets = if snap_on(app) { build(app, &exclude, false) } else { SnapTargets::default() }; + if app.ui.transform.as_ref().is_some_and(|t| t.mode == crate::state::TransformMode::Distort) { + // Explicit Distort fits corners to guides; preserve free placement near layer edges. + targets = targets.filtered(|k| k == SnapKind::Guide); + } if gesture == Gesture::Guide { // A guide never snaps to itself (or other guides). targets = targets.filtered(|k| k != SnapKind::Guide); @@ -262,8 +288,14 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2 } let (out, lines) = match &snap.gesture { Gesture::Point => crop_handle(app, p, mods, &snap.targets, tol).unwrap_or_else(|| snap.targets.snap_point(p, tol)), + Gesture::TransformCorner { point, .. } if corner_distorts(app, &snap.gesture, mods) => { + let proposed = [point[0] + p[0] - snap.start[0], point[1] + p[1] - snap.start[1]]; + let (corner, lines) = snap.targets.snap_point(proposed, tol); + ([p[0] + corner[0] - proposed[0], p[1] + corner[1] - proposed[1]], lines) + } + Gesture::TransformCorner { .. } => snap.targets.snap_point(p, tol), Gesture::Guide => snap.targets.snap_point(p, tol), - Gesture::Move { rect } | Gesture::TransformMove { rect } | Gesture::SelectionMove { rect } => { + Gesture::Move { rect } | Gesture::TransformMove { rect } | Gesture::SelectionMove { rect } | Gesture::DrawMove { rect } => { let d = [p[0] - snap.start[0], p[1] - snap.start[1]]; let moved = [rect[0] + d[0], rect[1] + d[1], rect[2] + d[0], rect[3] + d[1]]; let set = if snap.targets.is_empty() { &snap.smart } else { &snap.targets }; @@ -275,6 +307,18 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2 out } +/// A corner stopped by the transform's convexity constraint is not aligned to the proposed +/// target. Keep only the lines the final corner actually reaches, after the transform applies. +pub(crate) fn retain_transform_lines(app: &mut PhotocraftApp, mods: egui::Modifiers) { + let Some(snap) = app.prefs_rt.snap.as_ref() else { return }; + let Gesture::TransformCorner { index, .. } = snap.gesture else { return }; + if !corner_distorts(app, &snap.gesture, mods) { + return; + } + let Some(point) = app.ui.transform.as_ref().and_then(|t| t.quad.get(index)).copied() else { return }; + app.prefs_rt.snap_lines.retain(|line| (if line.vertical { point[0] } else { point[1] } - line.pos).abs() < 1e-6); +} + /// Snap a crop handle being dragged: the handle itself (the frame's edge or corner), not the /// pointer, which grabbed it up to the grab radius away (#1919: snapping the pointer left a strip /// as wide as that offset between the frame and the edge it showed as snapped to). With Auto @@ -347,6 +391,40 @@ fn crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, targets: Some((view.map_or(q, |view| crate::crop_mode::from_press(app, view, q)), lines)) } +/// The bounds `[x0, y0, x1, y1]` of what drag `d` draws so far, for the tools whose drag Space +/// repositions and that snap (`None` for the rest, or a shape still too small to draw). +fn drawn_rect(app: &PhotocraftApp, d: &crate::canvas::Drag) -> Option<[f64; 4]> { + let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); + let (a, b) = match d.tool { + Tool::RectMarquee | Tool::EllipseMarquee => crate::canvas::marquee_corners(&app.ui.tool_options, d, last), + Tool::ObjectSelection => (d.start, last), + t if crate::vector_ui::is_shape_tool(t) => return crate::vector_ui::shape_bounds(&app.ui.tool_options, t, d.start, last, d.live), + _ => return None, + }; + Some([a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]) +} + +/// Space held while a marquee, Object Selection box or shape is drawn moves the whole of it +/// (`hold_keys`): its edges and centre snap, as a moved selection's do, not just the corner under +/// the pointer. Releasing Space goes back to snapping that corner. +fn follow_reposition(app: &mut PhotocraftApp) { + let Some(d) = app.drag.as_ref() else { return }; + let Some(snap) = app.prefs_rt.snap.as_ref() else { return }; + let gesture = match (&snap.gesture, d.reposition) { + (Gesture::Point, true) => match drawn_rect(app, d) { + Some(rect) => Gesture::DrawMove { rect }, + None => return, + }, + (Gesture::DrawMove { .. }, false) => Gesture::Point, + _ => return, + }; + let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); + if let Some(snap) = app.prefs_rt.snap.as_mut() { + snap.gesture = gesture; + snap.start = last; + } +} + /// Snap a tool event's coordinates (called by `canvas::tool_event` before the tools see it). pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> ToolEvent { match ev { @@ -374,8 +452,10 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier if app.prefs_rt.snap.is_none() { return ev; } + follow_reposition(app); + let disabled = override_for_gesture(app, mods); if let Some(s) = app.prefs_rt.snap.as_mut() { - s.disabled = override_held(mods); + s.disabled = disabled; } let q = apply(app, [x, y], mods); let p = pixel_round(app, q); @@ -385,8 +465,10 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier if app.prefs_rt.snap.is_none() { return ev; } + follow_reposition(app); + let disabled = override_for_gesture(app, mods); if let Some(s) = app.prefs_rt.snap.as_mut() { - s.disabled = override_held(mods); + s.disabled = disabled; } let q = apply(app, [x, y], mods); let p = pixel_round(app, q); @@ -618,6 +700,37 @@ mod tests { assert_eq!(sel.x1, 116); } + /// Space while drawing a marquee, Object Selection box or shape moves it: its edges snap, not + /// only the corner under the pointer; with Space released the corner snaps again. + #[test] + fn drawing_repositioned_with_space_snaps_its_edges() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee, Tool::ObjectSelection, Tool::Rectangle, Tool::Line] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + app.run("view.newGuide", json!({"orientation": "vertical", "position": 220})).unwrap(); + app.ui.tool = tool; + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 150.0, y: 140.0, pressure: 1.0 }, m); + // Space down: the 50 × 40 box moves so its left edge lands 4 px short of the guide; + // the pointer corner (x = 266) is near nothing. + app.drag.as_mut().unwrap().reposition = true; + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 266.0, y: 140.0, pressure: 1.0 }, m); + assert_eq!(app.drag.as_ref().unwrap().start, [220.0, 100.0], "{tool:?}"); + if tool != Tool::RectMarquee { + continue; + } + // Space up: sizing resumes and the corner snaps to the document's right edge. + app.drag.as_mut().unwrap().reposition = false; + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 397.0, y: 140.0 }, m); + let sel = app.session.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(); + assert_eq!((sel.x0, sel.y0, sel.x1, sel.y1), (220, 100, 400, 140)); + } + } + /// Dragging inside a selection with a marquee or lasso moves the outline (⌘ cuts the pixels /// into a floating piece): its bounds snap, not the pointer. #[test] @@ -645,7 +758,302 @@ mod tests { } #[test] - fn distort_mode_never_snaps() { + fn distort_corner_snaps_from_positive_grab_offset() { + distort_corner_snaps_from_grab_offset([4.0, 3.0]); + } + + #[test] + fn distort_corner_snaps_from_negative_grab_offset() { + distort_corner_snaps_from_grab_offset([-4.0, -3.0]); + } + + fn distort_corner_snaps_from_grab_offset(offset: [f64; 2]) { + let mut app = app_with_box(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 110})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 30})).unwrap(); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "edit.transform.distort", json!({})).unwrap(); + let before = app.ui.transform.as_ref().unwrap().quad; + let press = [before[1][0] + offset[0], before[1][1] + offset[1]]; + // The proposed corner is (108, 32); the pointer keeps its original grab offset. + let release = [108.0 + offset[0], 32.0 + offset[1]]; + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: press[0], y: press[1], pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: release[0], y: release[1], pressure: 1.0 }, m); + let moved = app.ui.transform.as_ref().unwrap().quad; + assert_eq!(moved[1], [110.0, 30.0], "grab offset {offset:?}"); + assert_eq!([moved[0], moved[2], moved[3]], [before[0], before[2], before[3]]); + assert!(app.prefs_rt.snap_lines.iter().any(|l| l.vertical && l.pos == 110.0 && l.kind == SnapKind::Guide)); + assert!(app.prefs_rt.snap_lines.iter().any(|l| !l.vertical && l.pos == 30.0 && l.kind == SnapKind::Guide)); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: release[0], y: release[1] }, m); + assert_eq!(app.ui.transform.as_ref().unwrap().quad, moved); + assert!(app.prefs_rt.snap.is_none() && app.prefs_rt.snap_lines.is_empty()); + } + + fn app_with_corner_guides(menu: &str) -> PhotocraftApp { + let mut app = app_with_box(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 110})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 30})).unwrap(); + crate::menus::invoke(&mut app, &egui::Context::default(), menu, json!({})).unwrap(); + app + } + + fn move_corner(app: &mut PhotocraftApp, index: usize, offset: [f64; 2], end: [f64; 2], down: egui::Modifiers, moving: egui::Modifiers) { + let corner = app.ui.transform.as_ref().unwrap().quad[index]; + crate::canvas::tool_event(app, ToolEvent::Down { x: corner[0] + offset[0], y: corner[1] + offset[1], pressure: 1.0 }, down); + crate::canvas::tool_event(app, ToolEvent::Move { x: end[0] + offset[0], y: end[1] + offset[1], pressure: 1.0 }, moving); + } + + #[test] + fn distort_corner_respects_guide_axes_tolerance_and_toggles() { + for (snap, shown, guides, end, expected) in [ + (true, true, true, [108.0, 20.0], [110.0, 20.0]), + (true, true, true, [100.0, 32.0], [100.0, 30.0]), + (true, true, true, [100.0, 20.0], [100.0, 20.0]), + (false, true, true, [108.0, 32.0], [108.0, 32.0]), + (true, false, true, [108.0, 32.0], [108.0, 32.0]), + (true, true, false, [108.0, 32.0], [108.0, 32.0]), + ] { + let mut app = app_with_corner_guides("edit.transform.distort"); + app.ui.extras.snap = snap; + app.ui.extras.guides = shown; + app.ui.view.snap_to.guides = guides; + move_corner(&mut app, 1, [4.0, 3.0], end, egui::Modifiers::NONE, egui::Modifiers::NONE); + assert_eq!(app.ui.transform.as_ref().unwrap().quad[1], expected, "Snap: {snap}, shown: {shown}, guides: {guides}, proposed: {end:?}"); + assert!(app.prefs_rt.snap_lines.iter().all(|line| (if line.vertical { expected[0] } else { expected[1] } - line.pos).abs() < 1e-6)); + } + } + + #[test] + fn free_transform_command_corner_snaps_with_the_real_grab_offset() { + let windows = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE }; + let mac = egui::Modifiers { command: true, mac_cmd: true, ..egui::Modifiers::NONE }; + for m in [windows, mac] { + for offset in [[4.0, 3.0], [-4.0, -3.0], [0.0, 0.0]] { + for down in [m, egui::Modifiers::NONE] { + let mut app = app_with_corner_guides("edit.freeTransform"); + let before = app.ui.transform.as_ref().unwrap().quad; + move_corner(&mut app, 1, offset, [108.0, 32.0], down, m); + let moved = app.ui.transform.as_ref().unwrap().quad; + assert_eq!(moved[1], [110.0, 30.0], "{m:?}, press {down:?}, offset {offset:?}"); + assert_eq!([moved[0], moved[2], moved[3]], [before[0], before[2], before[3]]); + assert_eq!(app.prefs_rt.snap_lines.len(), 2); + assert!(!app.prefs_rt.snap.as_ref().unwrap().disabled); + } + } + } + } + + #[test] + fn control_alone_still_disables_snapping_in_menu_distort() { + let mut app = app_with_corner_guides("edit.transform.distort"); + let ctrl = egui::Modifiers { ctrl: true, ..egui::Modifiers::NONE }; + move_corner(&mut app, 1, [4.0, 3.0], [108.0, 32.0], ctrl, ctrl); + assert_eq!(app.ui.transform.as_ref().unwrap().quad[1], [108.0, 32.0]); + assert!(app.prefs_rt.snap.as_ref().unwrap().disabled); + assert!(app.prefs_rt.snap_lines.is_empty()); + } + + #[test] + fn skew_control_corner_keeps_its_axis_instead_of_snapping_as_distort() { + let ctrl = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE }; + for legacy in [false, true] { + for (menu, expected, snapping) in [ + ("edit.transform.skew", [[40.0, 40.0], [108.0, 40.0], [90.0, 80.0], [40.0, 80.0]], false), + ("edit.transform.distort", [[40.0, 40.0], [110.0, 30.0], [90.0, 80.0], [40.0, 80.0]], true), + ("edit.transform.perspective", [[22.0, 40.0], [108.0, 40.0], [90.0, 80.0], [40.0, 80.0]], false), + ] { + let mut app = app_with_corner_guides(menu); + app.run("prefs.set", json!({"path": "general.useLegacyFreeTransform", "value": legacy})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let past = app.session.active().unwrap().history.past_len(); + let prefs = serde_json::to_value(app.session.prefs()).unwrap(); + // Ctrl arrives after the raw, off-centre press, as on the actual canvas path. + move_corner(&mut app, 1, [4.0, 3.0], [108.0, 32.0], egui::Modifiers::NONE, ctrl); + assert_eq!(app.ui.transform.as_ref().unwrap().quad, expected, "{menu}, legacy {legacy}"); + assert_eq!(!app.prefs_rt.snap.as_ref().unwrap().disabled, snapping, "{menu}"); + assert_eq!(app.prefs_rt.snap_lines.len(), if snapping { 2 } else { 0 }, "{menu}"); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 112.0, y: 35.0 }, ctrl); + assert_eq!(app.ui.transform.as_ref().unwrap().quad, expected, "release preserves {menu}"); + crate::transform_tool::cancel(&mut app); + assert!(app.ui.transform.is_none() && app.prefs_rt.snap.is_none() && app.prefs_rt.snap_lines.is_empty()); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc), "cancel preserves {menu}'s document"); + assert_eq!(app.session.active().unwrap().history.past_len(), past, "cancel preserves {menu}'s history"); + assert_eq!(serde_json::to_value(app.session.prefs()).unwrap(), prefs, "{menu} does not change preferences"); + } + } + } + + #[test] + fn skew_control_corner_on_editable_type_slides_the_whole_edge() { + let ctrl = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE }; + for legacy in [false, true] { + let mut app = app_with_box(); + app.run("type.create", json!({"x": 40, "y": 60, "text": "Hello"})).unwrap(); + app.run("prefs.set", json!({"path": "general.useLegacyFreeTransform", "value": legacy})).unwrap(); + crate::menus::invoke(&mut app, &egui::Context::default(), "edit.transform.skew", json!({})).unwrap(); + let before = app.ui.transform.as_ref().unwrap().quad; + let end = [before[1][0] + 18.0, before[1][1] - 8.0]; + let doc = app.session.active().unwrap().doc.clone(); + let past = app.session.active().unwrap().history.past_len(); + move_corner(&mut app, 1, [2.0, 1.0], end, egui::Modifiers::NONE, ctrl); + let mut expected = before; + expected[0][0] += 18.0; + expected[1][0] += 18.0; + assert_eq!(app.ui.transform.as_ref().unwrap().quad, expected, "type remains affine, legacy {legacy}"); + assert!(app.prefs_rt.snap.as_ref().unwrap().disabled && app.prefs_rt.snap_lines.is_empty()); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: end[0] + 2.0, y: end[1] + 1.0 }, ctrl); + assert_eq!(app.ui.transform.as_ref().unwrap().quad, expected, "release preserves the type's edge"); + crate::transform_tool::cancel(&mut app); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc)); + assert_eq!(app.session.active().unwrap().history.past_len(), past); + } + } + + #[test] + fn transform_non_distortion_gestures_keep_control_snap_override() { + let ctrl = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE }; + let perspective = egui::Modifiers { alt: true, shift: true, ..ctrl }; + for (menu, press, m) in [ + ("edit.freeTransform", [65.0, 40.0], ctrl), // edge + ("edit.freeTransform", [65.0, 60.0], ctrl), // pivot + ("edit.freeTransform", [55.0, 52.0], ctrl), // inside + ("edit.freeTransform", [90.0, 40.0], perspective), + ("edit.transform.perspective", [90.0, 40.0], ctrl), + ("edit.transform.warp", [90.0, 40.0], ctrl), + ("edit.contentAwareScale", [90.0, 40.0], ctrl), + ] { + let mut app = app_with_corner_guides(menu); + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: press[0], y: press[1], pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 108.0, y: 32.0, pressure: 1.0 }, m); + assert!(app.prefs_rt.snap.as_ref().unwrap().disabled, "{menu}, {press:?}, {m:?}"); + assert!(app.prefs_rt.snap_lines.is_empty(), "{menu}, {press:?}"); + } + } + + #[test] + fn editable_type_refusal_keeps_control_snap_override() { + let mut app = app_with_box(); + app.run("type.create", json!({"x": 40, "y": 60, "text": "Hello"})).unwrap(); + crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap(); + let corner = app.ui.transform.as_ref().unwrap().quad[1]; + app.run("view.newGuide", json!({"orientation": "vertical", "position": corner[0] + 20.0})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": corner[1] - 10.0})).unwrap(); + let ctrl = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE }; + move_corner(&mut app, 1, [0.0, 0.0], [corner[0] + 18.0, corner[1] - 8.0], ctrl, ctrl); + assert!(app.prefs_rt.snap.as_ref().unwrap().disabled); + assert!(app.prefs_rt.snap_lines.is_empty()); + let q = app.ui.transform.as_ref().unwrap().quad; + assert_eq!(q[0][1], q[1][1], "editable type still scales, rather than distorting"); + } + + #[test] + fn a_foldover_constraint_never_claims_a_guide_alignment() { + let mut app = app_with_corner_guides("edit.transform.distort"); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 100})).unwrap(); + let before = app.ui.transform.as_ref().unwrap().quad; + move_corner(&mut app, 0, [4.0, 3.0], [108.0, 98.0], egui::Modifiers::NONE, egui::Modifiers::NONE); + let q = app.ui.transform.as_ref().unwrap().quad; + assert_ne!(q[0], [110.0, 100.0], "the guide intersection would fold the quad"); + assert_eq!([q[1], q[2], q[3]], [before[1], before[2], before[3]]); + for i in 0..4 { + let (a, b, c) = (q[i], q[(i + 1) % 4], q[(i + 2) % 4]); + assert!((b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0]) > 0.0, "still convex: {q:?}"); + } + assert!(app.prefs_rt.snap_lines.is_empty(), "{:?}", app.prefs_rt.snap_lines); + } + + #[test] + fn a_snapped_distort_commits_one_undoable_transform() { + let mut app = app_with_corner_guides("edit.transform.distort"); + let before_doc = app.session.active().unwrap().doc.clone(); + let before_bounds = mover_bounds(&app); + let past = app.session.active().unwrap().history.past_len(); + move_corner(&mut app, 1, [4.0, 3.0], [108.0, 32.0], egui::Modifiers::NONE, egui::Modifiers::NONE); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &before_doc), "preview does not edit the document"); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 112.0, y: 35.0 }, egui::Modifiers::NONE); + crate::transform_tool::commit(&mut app); + assert!(!app.ui.status_error, "{}", app.ui.status); + let after_bounds = mover_bounds(&app); + assert_ne!(after_bounds, before_bounds); + assert_eq!(app.session.active().unwrap().history.past_len(), past + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(mover_bounds(&app), before_bounds); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(mover_bounds(&app), after_bounds); + } + + #[test] + fn transform_corner_press_is_not_adjusted_near_an_original_guide() { + for menu in ["edit.transform.distort", "edit.freeTransform"] { + let mut app = app_with_corner_guides(menu); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 95})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 45})).unwrap(); + let raw = ToolEvent::Down { x: 94.0, y: 43.0, pressure: 1.0 }; + let ToolEvent::Down { x, y, .. } = filter_event(&mut app, raw, egui::Modifiers::NONE) else { panic!("still a press") }; + assert_eq!([x, y], [94.0, 43.0], "{menu}: filtering must preserve the handle press"); + } + } + + #[test] + fn corner_near_original_guides_keeps_the_raw_press_in_the_canvas_path() { + for (menu, m) in + [("edit.transform.distort", egui::Modifiers::NONE), ("edit.freeTransform", egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE })] + { + let mut app = app_with_corner_guides(menu); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 95})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 45})).unwrap(); + let before = app.ui.transform.as_ref().unwrap().quad; + // The command modifier is added after Down, including in Free Transform. + move_corner(&mut app, 1, [4.0, 3.0], [93.0, 43.0], egui::Modifiers::NONE, m); + let q = app.ui.transform.as_ref().unwrap().quad; + assert_eq!(q[1], [95.0, 45.0], "{menu}"); + assert_eq!([q[0], q[2], q[3]], [before[0], before[2], before[3]]); + } + } + + #[test] + fn corner_distortion_does_not_snap_to_guides_hidden_by_view_flags() { + for menu in ["edit.transform.distort", "edit.freeTransform"] { + for hide_extras in [false, true] { + let mut app = app_with_corner_guides(menu); + if hide_extras { + app.ui.view.extras = false; + } else { + app.ui.view.show.canvas_guides = false; + } + let m = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE }; + move_corner(&mut app, 1, [4.0, 3.0], [108.0, 32.0], m, m); + assert_eq!(app.ui.transform.as_ref().unwrap().quad[1], [108.0, 32.0], "{menu}, hide Extras: {hide_extras}"); + assert!(app.prefs_rt.snap_lines.is_empty()); + } + } + } + + #[test] + fn marquee_and_corner_snapping_share_the_canvas_guide_visibility() { + for (extras, canvas_guides, shown) in [(true, true, true), (false, true, false), (true, false, false)] { + let mut app = app_with_box(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 110})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 30})).unwrap(); + app.ui.tool = Tool::RectMarquee; + // Isolate guides: the mover's y=40 edge would otherwise be a valid fallback target. + app.ui.view.snap_to.layers = false; + app.ui.view.snap_to.document_bounds = false; + app.ui.view.snap_to.grid = false; + app.ui.view.extras = extras; + app.ui.view.show.canvas_guides = canvas_guides; + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 12.0, y: 12.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 108.0, y: 32.0 }, m); + let rect = app.session.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(); + assert_eq!((rect.x0, rect.y0), (12, 12)); + assert_eq!((rect.x1, rect.y1), if shown { (110, 30) } else { (108, 32) }, "Extras: {extras}, Canvas Guides: {canvas_guides}"); + } + } + + #[test] + fn distort_corner_does_not_snap_to_layer_edges() { let mut app = app_with_box(); let ctx = egui::Context::default(); crate::menus::invoke(&mut app, &ctx, "edit.transform.distort", json!({})).unwrap(); @@ -681,6 +1089,52 @@ mod tests { assert_eq!(mover_bounds(&app).x0, 300); } + /// #5751: a Rectangle drawn on a 1080 px canvas, then resized with Free Transform under the + /// Move tool. With Snap Vector Tools and Transforms to Pixel Grid on (the default) the box's + /// edges stay on whole pixels: the grab offset and a proportional scale used to leave them at + /// 20.6 and 1040.4. Off, the box follows the pointer exactly. + #[test] + fn free_transform_of_a_shape_keeps_whole_pixels() { + for grid in [true, false] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1080, "height": 1080, "resolution": 72})).unwrap(); + app.run("prefs.set", json!({"path": "tools.snapVectorToolsAndTransformsToPixelGrid", "value": grid})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = 0.6; + app.ui.views[0].fit_pending = false; + app.ui.tool = Tool::Rectangle; + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 40.0, y: 31.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 300.0, y: 700.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 481.0, y: 1000.0 }, m); + app.ui.tool = Tool::Move; + crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap(); + let whole = |q: [[f64; 2]; 4]| q.iter().flatten().all(|v| v.fract() == 0.0); + // The left edge handle, grabbed 1.3 px inside, dropped away from any snap target. + let q = app.ui.transform.as_ref().unwrap().quad; + let mid_y = (q[0][1] + q[3][1]) / 2.0; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: q[0][0] + 1.3, y: mid_y, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 60.0, y: mid_y, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 80.6, y: mid_y }, m); + let q = app.ui.transform.as_ref().unwrap().quad; + assert_eq!(whole(q), grid, "grid {grid}: left edge {q:?}"); + // The bottom-right corner scaled proportionally (the default), off any snap target. + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: q[2][0], y: q[2][1], pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 450.4, y: 900.1 }, m); + let q = app.ui.transform.as_ref().unwrap().quad; + assert_eq!(whole(q), grid, "grid {grid}: corner {q:?}"); + crate::transform_tool::commit(&mut app); + let st = app.session.active().unwrap(); + let photocraft_doc::LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("a shape layer") }; + let (x0, y0, x1, y1) = sh.path.control_bounds().unwrap(); + if grid { + for v in [x0, y0, x1, y1] { + assert!((v - v.round()).abs() < 1e-6, "committed shape {:?}", (x0, y0, x1, y1)); + } + } + } + } + #[test] fn guides_snap_to_layer_edges() { let mut app = app_with_box(); 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] { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 42997fe62c..974d3fde35 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -389,6 +389,9 @@ pub struct Panels { /// Window › Brush Settings (F5): floating, like Photoshop's. #[serde(default)] pub brush_settings: bool, + /// Window › Brushes: dockable panel, like Photoshop's. + #[serde(default)] + pub brushes: bool, /// Window › Character / Paragraph: the Character | Paragraph dock group (#150). #[serde(default)] pub character: bool, @@ -427,6 +430,7 @@ impl Default for Panels { options_bar: true, status_bar: true, brush_settings: false, + brushes: false, character: false, toolbar_double: false, dock: true, @@ -507,7 +511,9 @@ pub struct ToolOptions { pub tolerance: f32, pub contiguous: bool, pub anti_alias: bool, - pub sample_all_layers: bool, + /// Sample All Layers, kept per tool as in Photoshop (#5524). Stored under a new key, so the + /// single shared `sample_all_layers` of older preferences is ignored on load. + pub sample_all: SampleAllLayers, /// Object Selection › Object Finder: hovering highlights the object a click selects (#4087). pub object_finder: bool, /// Remove tool: the fill goes to a new layer above the target (Photoshop's Create a new layer). @@ -620,6 +626,10 @@ pub struct ToolOptions { pub crop_custom_ratio_height: f32, #[serde(default = "yes")] pub crop_delete: bool, + /// Crop options bar › Fill: false = Background (Default), true = Content-Aware Fill (the area a + /// rotated or extending crop uncovers is filled from its surroundings, #3823). + #[serde(default)] + pub crop_content_aware: bool, /// Crop W x H x Resolution (`crop_ratio` = "whr", #2443): width and height as typed lengths /// with their unit ("4 in", "1024 px"; empty = unset), the resolution ("" = the document's) /// and its unit ("px/in" or "px/cm"). See `crop_size`. @@ -645,8 +655,8 @@ pub struct ToolOptions { /// Crop gear menu (#1919): Show Cropped Area and the crop shield. See `crop_shield`. #[serde(default)] pub crop_shield: crate::crop_shield::CropShield, - /// Magic Eraser opacity % (tolerance, anti-alias, contiguous and sample-all are shared with the - /// Magic Wand and Paint Bucket). + /// Magic Eraser opacity % (tolerance, anti-alias and contiguous are shared with the Magic Wand + /// and Paint Bucket). pub magic_eraser_opacity: f32, /// Background Eraser: sampling (continuous | once | backgroundSwatch), limits (discontiguous | /// contiguous | findEdges), tolerance % and Protect Foreground Color. @@ -741,6 +751,10 @@ fn default_brush_tool() -> Tool { Tool::Brush } +fn default_layer_filter_mode() -> photocraft_color::BlendMode { + photocraft_color::BlendMode::Normal +} + fn one_u32() -> u32 { 1 } @@ -749,13 +763,60 @@ fn default_eyedropper_sample() -> String { "all".into() } +/// Each tool's Sample All Layers (the Paint Bucket calls it All Layers). Photoshop keeps one per +/// tool, all off by default: turning it on for the Magic Wand must not leave Blur or Smudge +/// copying the composite onto the active layer (#5524). The Mixer Brush keeps its own with the +/// brush (`MixerSettings`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SampleAllLayers { + pub magic_wand: bool, + pub quick_selection: bool, + pub object_selection: bool, + pub paint_bucket: bool, + pub magic_eraser: bool, + pub spot_healing: bool, + pub patch: bool, + pub content_aware_move: bool, + pub remove: bool, + pub blur: bool, + pub sharpen: bool, + pub smudge: bool, +} + +impl SampleAllLayers { + /// `tool`'s setting; false for a tool without the option. + pub fn get(mut self, tool: Tool) -> bool { + self.slot(tool).is_some_and(|v| *v) + } + + /// `tool`'s setting, or `None` for a tool without the option. + pub fn slot(&mut self, tool: Tool) -> Option<&mut bool> { + Some(match tool { + Tool::MagicWand => &mut self.magic_wand, + Tool::QuickSelection => &mut self.quick_selection, + Tool::ObjectSelection => &mut self.object_selection, + Tool::PaintBucket => &mut self.paint_bucket, + Tool::MagicEraser => &mut self.magic_eraser, + Tool::SpotHealing => &mut self.spot_healing, + Tool::Patch => &mut self.patch, + Tool::ContentAwareMove => &mut self.content_aware_move, + Tool::Remove => &mut self.remove, + Tool::Blur => &mut self.blur, + Tool::Sharpen => &mut self.sharpen, + Tool::Smudge => &mut self.smudge, + _ => return None, + }) + } +} + impl Default for ToolOptions { fn default() -> Self { Self { tolerance: 32.0, contiguous: true, anti_alias: true, - sample_all_layers: false, + sample_all: SampleAllLayers::default(), object_finder: true, remove_new_layer: false, remove_after_each_stroke: true, @@ -815,6 +876,7 @@ impl Default for ToolOptions { crop_custom_ratio_width: 0.0, crop_custom_ratio_height: 0.0, crop_delete: true, + crop_content_aware: false, crop_width: String::new(), crop_height: String::new(), crop_resolution: String::new(), @@ -1031,6 +1093,8 @@ pub struct DockTabs { pub history: usize, /// Character | Paragraph. pub character: usize, + /// Brushes presets. + pub brushes: usize, } /// Color panel state. @@ -1039,6 +1103,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)] @@ -1058,6 +1125,12 @@ 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, /// Ctrl/Cmd Type-tool gesture, exposed to automation but never restored as a held pointer. #[serde(default, skip_deserializing)] pub type_transform: Option, @@ -1119,6 +1192,9 @@ pub struct UiState { /// Selection-tool context menu opened by a plain canvas right-click. #[serde(default)] pub canvas_tool_menu: Option, + /// The guide a Move-tool click selected (`guide_select`): drawn blue, Delete removes it. + #[serde(default)] + pub selected_guide: Option, /// The brush is a per-tool option (the Brush, the Eraser and every retouching tool each keep /// their own size, hardness, tip and dynamics, as in Photoshop): the tool whose brush the /// session brush holds, and the other tools' saved brushes. @@ -1134,12 +1210,24 @@ pub struct UiState { /// Direct Selection tool: selected anchors and the drag in progress (#790). #[serde(default)] pub direct_selection: crate::direct_select::DirectSelection, + /// A live rectangle's corner radius widget being dragged (TOOL-214-12). + #[serde(skip)] + pub corner_radius: Option, /// Selected row in the Paths panel ("work" or a saved path name). #[serde(default)] pub selected_path: Option, /// Layers panel kind filter ("pixel", "adjustment", "type", "shape", "smart"); empty = all. #[serde(default)] pub layer_filter: Vec, + /// Layers panel filter type (#5048): kind (above), name or blend mode. + #[serde(default)] + pub layer_filter_by: crate::layer_filter_ui::FilterBy, + /// Layers panel name filter: part of a layer name, ignoring case; empty = all. + #[serde(default)] + pub layer_filter_name: String, + /// Layers panel blend-mode filter. + #[serde(default = "default_layer_filter_mode")] + pub layer_filter_mode: photocraft_color::BlendMode, /// Actions panel: which row is selected and which are expanded. The list lives on the session. #[serde(default)] pub actions: crate::actions::ActionsUi, @@ -1274,6 +1362,8 @@ 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, cam_transform: None, @@ -1289,6 +1379,7 @@ impl Default for UiState { brush_picker_size: None, layer_menu: None, canvas_tool_menu: None, + selected_guide: None, brush_tool: Tool::Brush, tool_brushes: Vec::new(), clone_source: None, @@ -1307,9 +1398,13 @@ impl Default for UiState { artboard_tool: Default::default(), shell: Default::default(), layer_filter: Vec::new(), + layer_filter_by: Default::default(), + layer_filter_name: String::new(), + layer_filter_mode: default_layer_filter_mode(), pen: None, stroke_editor: None, direct_selection: Default::default(), + corner_radius: None, selected_path: None, panels: Panels::default(), views: Vec::new(), @@ -1398,6 +1493,25 @@ impl UiState { mod tests { use super::*; + #[test] + fn sample_all_layers_is_per_tool_and_old_prefs_load() { + // The single shared flag of older preferences is dropped: every tool starts off (#5524). + let o: ToolOptions = serde_json::from_str(r#"{"sample_all_layers": true, "feather": 3.0}"#).unwrap(); + assert_eq!((o.sample_all, o.feather), (SampleAllLayers::default(), 3.0)); + let mut o = ToolOptions::default(); + o.sample_all.magic_wand = true; + let back: ToolOptions = serde_json::from_value(serde_json::to_value(&o).unwrap()).unwrap(); + assert!(back.sample_all.magic_wand && !back.sample_all.get(Tool::Blur)); + assert!(!back.sample_all.get(Tool::Brush), "a tool without the option reads false"); + let mut s = SampleAllLayers::default(); + assert!(s.slot(Tool::Brush).is_none()); + for t in [Tool::MagicWand, Tool::PaintBucket, Tool::Blur, Tool::Sharpen, Tool::Smudge, Tool::Remove] { + *s.slot(t).unwrap() = true; + assert!(s.get(t), "{t:?}"); + } + assert!(!s.get(Tool::QuickSelection)); + } + #[test] fn tool_names_parse() { assert_eq!(Tool::from_name("brush"), Some(Tool::Brush)); diff --git a/crates/ui-egui/src/stroke_timing_tests.rs b/crates/ui-egui/src/stroke_timing_tests.rs index fc6d0c7e78..88f218c3ba 100644 --- a/crates/ui-egui/src/stroke_timing_tests.rs +++ b/crates/ui-egui/src/stroke_timing_tests.rs @@ -338,6 +338,80 @@ fn a_committed_stroke_refreshes_only_what_each_cache_had_not_shown() { assert_eq!(c.since(99), None, "not a key of this stroke"); } +/// A stroke that grows a layer with a Gradient Overlay lays the one gradient out again over the +/// whole layer (#4053): the canvas, which already showed the live stroke, still repaints where +/// the earlier strokes are, not just the new stroke's own pixels (it repainted nothing, leaving +/// one gradient per stroke). +#[test] +fn a_committed_stroke_repaints_a_gradient_overlay_over_the_whole_layer() { + let mut h = harness(); + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + h.state_mut().run("layer.layerStyle.gradientOverlay", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})).unwrap(); + h.state_mut().run("tools.setBrush", json!({"brush": {"size": 10, "hardness": 1.0}})).unwrap(); + let drag = |h: &mut Harness<'static, PhotocraftApp>, pts: &[(f32, f32)]| { + move_to(h, pts[0].0, pts[0].1); + button(h, pts[0].0, pts[0].1, true); + for (x, y) in &pts[1..] { + move_to(h, *x, *y); + } + let (x, y) = pts[pts.len() - 1]; + button(h, x, y, false); + h.run_steps(2); + }; + drag(&mut h, &[(20.0, 20.0), (30.0, 25.0), (40.0, 30.0)]); + drag(&mut h, &[(150.0, 60.0), (160.0, 70.0), (170.0, 80.0)]); + let app = h.state(); + let idx = app.session.active_index().unwrap(); + let st = app.session.active().unwrap(); + let c = app.committed_stroke.as_ref().expect("the commit took the live stroke's pixels"); + assert_eq!(c.revision, st.revision); + // A cache that showed the live stroke's last step. + let missed = crate::canvas::damage_since(app, idx, (st.revision - 1, c.last_key()), (st.revision, 0), 0, st.last_damage).unwrap(); + assert!(missed.contains(25, 25), "the gradient around the first stroke is repainted: {missed:?}"); +} + +#[test] +fn the_stroke_trace_times_each_frame_of_a_stroke_and_ends_on_release() { + // The app's hooks around the canvas (`raw_input_hook` starts a frame, `ui` ends it); the + // canvas times the live stroke and the release's commit itself. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100, "background": "white"})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::Brush; + app.stroke_trace.output = crate::stroke_trace::Output::Stderr; + let mut h = Harness::builder().with_size(vec2(900.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + let t0 = crate::gpu_canvas::now_ms(); + let moves = ctx.input(|i| i.raw.events.iter().filter(|e| matches!(e, egui::Event::PointerMoved(_))).count()); + app.stroke_trace.begin_frame(t0, moves); + app.stroke_trace.cpu_prev(Some(0.004)); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + let stroking = app.live_stroke.is_some(); + app.stroke_trace.end_frame(crate::gpu_canvas::now_ms() - t0, stroking, || "test".into()); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + move_to(&mut h, 20.0, 50.0); + button(&mut h, 20.0, 50.0, true); + for x in [40.0, 60.0, 80.0, 100.0, 120.0] { + move_to(&mut h, x, 50.0); + } + assert!(h.state().stroke_trace.last.is_none(), "the stroke is still being drawn"); + button(&mut h, 120.0, 50.0, false); + h.run_steps(2); + let s = h.state().stroke_trace.last.clone().expect("releasing ends the stroke's trace"); + assert!(s.frames >= 6, "{s:?}"); + assert!(s.stroke[1] > 0.0 && s.ui[0] >= s.stroke[0], "{s:?}"); + assert_eq!(s.moves[1], 1.0, "one move per test frame: {s:?}"); + assert_eq!(s.backend, "test"); +} + /// A Clone Stamp stroke through the canvas renders the points its commit gets (position and /// pressure, no tilt or times), so the commit can take the live pixels; they are what replaying /// the journaled command paints. diff --git a/crates/ui-egui/src/stroke_trace.rs b/crates/ui-egui/src/stroke_trace.rs new file mode 100644 index 0000000000..809414ed49 --- /dev/null +++ b/crates/ui-egui/src/stroke_trace.rs @@ -0,0 +1,329 @@ +//! Stroke latency trace (#1343): where each frame's time goes while a brush stroke is drawn, so +//! a tester on any platform can report a per-stage breakdown instead of one input-to-pixel number. +//! +//! Set `PHOTOCRAFT_STROKE_TRACE` before launching: `1` prints a summary to stderr when each stroke +//! ends; a file path also appends every frame of every stroke to that CSV. The last summary is in +//! the control channel's `perf` output (`strokeTrace`). +//! +//! Per frame of a stroke it records the interval since the previous frame started, the pointer +//! moves the frame delivered, the live stroke's render time, the canvas refresh (composite and +//! upload), the UI build, and eframe's CPU time for the frame before (UI, tessellation and GPU +//! submission, without the vsync wait). What the app can't see (the OS input queue before a frame +//! starts, frames queued for the display, the compositor) is the rest of the input-to-photon time; +//! the summary names the GPU backend because GL ignores the frame-latency setting. + +use std::fmt::Write as _; + +/// One frame of a stroke. Times in ms. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Row { + /// Frame start, from [`crate::gpu_canvas::now_ms`]. + pub t: f64, + /// Since the previous frame started (0 for the first frame the trace saw). + pub interval: f64, + /// Pointer moves the frame delivered. + pub moves: usize, + /// Rendering the stroke's new points (the live stroke, or the commit on release). + pub stroke: f64, + /// Canvas refresh: composite and upload. + pub canvas: f64, + /// Building the UI (`App::ui`), the two above included. + pub ui: f64, + /// eframe's CPU time for the previous frame: UI, tessellation and GPU submission. + pub cpu_prev: Option, +} + +/// Percentiles of a stroke's frames, for the summary line and the control channel. +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Summary { + pub frames: usize, + /// GPU backend and adapter ("vulkan NVIDIA GeForce …"). + pub backend: String, + /// `[p50, p95]` of each stage, in ms. + pub interval: [f64; 2], + pub moves: [f64; 2], + pub stroke: [f64; 2], + pub canvas: [f64; 2], + pub ui: [f64; 2], + /// eframe's CPU time per frame (UI, tessellation, submission). + pub cpu: [f64; 2], + /// Frame interval minus that CPU time: waiting on vsync / the swap chain, or idle. + pub wait: [f64; 2], +} + +impl Summary { + /// One line for stderr. + pub fn line(&self) -> String { + let f = |name: &str, [p50, p95]: [f64; 2]| format!("{name} {p50:.2}/{p95:.2}"); + [ + format!("stroke trace: {} frames on {} (p50/p95 ms):", self.frames, self.backend), + f("interval", self.interval), + f("moves/frame", self.moves), + f("stroke", self.stroke), + f("canvas", self.canvas), + f("ui", self.ui), + f("cpu", self.cpu), + f("wait", self.wait), + ] + .join(" ") + } +} + +/// Where the trace goes ([`StrokeTrace::from_env`]). +// The web build has no clock, so it never traces. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) enum Output { + #[default] + Off, + /// A summary per stroke on stderr. + Stderr, + /// The summary on stderr, and every frame appended to this CSV. + Csv(std::path::PathBuf), +} + +#[derive(Debug, Default)] +pub struct StrokeTrace { + pub(crate) output: Output, + /// The frame being built (started by `begin_frame`). + frame: Option, + last_start: Option, + /// The current stroke's frames. + rows: Vec, + /// Strokes finished so far (numbers the CSV rows). + strokes: usize, + /// The last finished stroke's summary. + pub last: Option, +} + +/// `[p50, p95]` of `v` (nearest rank; zeros when empty). +fn percentiles(mut v: Vec) -> [f64; 2] { + v.retain(|x| x.is_finite()); + if v.is_empty() { + return [0.0, 0.0]; + } + v.sort_by(f64::total_cmp); + let at = |p: f64| v.get(((p * (v.len() - 1) as f64).round() as usize).min(v.len() - 1)).copied().unwrap_or(0.0); + let r = |x: f64| (x * 100.0).round() / 100.0; + [r(at(0.5)), r(at(0.95))] +} + +impl StrokeTrace { + /// Reads `PHOTOCRAFT_STROKE_TRACE` (off on the web, where there is no clock). + pub fn from_env() -> Self { + #[cfg(not(target_arch = "wasm32"))] + if let Some(v) = std::env::var_os("PHOTOCRAFT_STROKE_TRACE") { + return Self::from_value(&v.to_string_lossy()); + } + Self::default() + } + + /// A trace set by value: `""`, `"0"` or `"off"` is off, `"1"` or `"stderr"` prints + /// summaries, anything else is a CSV path (`--stroke-trace`, `PHOTOCRAFT_STROKE_TRACE`; the + /// control channel's `ui.set {"strokeTrace"}` passes only on or off, never a path). + pub fn from_value(v: &str) -> Self { + let v = v.trim(); + let output = match v { + _ if cfg!(target_arch = "wasm32") => Output::Off, + "" | "0" | "off" | "false" => Output::Off, + "1" | "stderr" | "true" => Output::Stderr, + path => Output::Csv(path.into()), + }; + Self { output, ..Default::default() } + } + + /// Switch the output, keeping the last summary. + pub fn set(&mut self, v: &str) { + let last = self.last.take(); + *self = Self { last, ..Self::from_value(v) }; + } + + pub fn enabled(&self) -> bool { + self.output != Output::Off + } + + /// A frame starts (the raw input hook) with `moves` pointer moves. + pub fn begin_frame(&mut self, now: f64, moves: usize) { + if !self.enabled() { + return; + } + let interval = self.last_start.map_or(0.0, |t| now - t); + self.last_start = Some(now); + self.frame = Some(Row { t: now, interval, moves, ..Default::default() }); + } + + /// eframe's CPU time for the previous frame, in seconds (`Frame::info().cpu_usage`). + pub fn cpu_prev(&mut self, secs: Option) { + if let Some(f) = self.frame.as_mut() { + f.cpu_prev = secs.map(|s| f64::from(s) * 1000.0); + } + } + + /// Time spent rendering stroke points this frame. + pub fn stroke(&mut self, ms: f64) { + if let Some(f) = self.frame.as_mut() { + f.stroke += ms; + } + } + + /// Time spent refreshing the canvas this frame. + pub fn canvas(&mut self, ms: f64) { + if let Some(f) = self.frame.as_mut() { + f.canvas += ms; + } + } + + /// The frame's UI is built (`ui` ms). `stroking`: a stroke is being drawn (a frame that + /// rendered stroke points counts too, such as the release's commit). A stroke's frames are + /// kept until the first frame without one, which ends it. + pub fn end_frame(&mut self, ui: f64, stroking: bool, backend: impl FnOnce() -> String) { + let Some(mut f) = self.frame.take() else { return }; + if stroking || f.stroke > 0.0 { + f.ui = ui; + // The first frame of a stroke doesn't follow a stroke frame: its interval is idle time. + if self.rows.is_empty() { + f.interval = 0.0; + } + self.rows.push(f); + } else if !self.rows.is_empty() { + self.finish(backend()); + } + } + + fn finish(&mut self, backend: String) { + let rows = std::mem::take(&mut self.rows); + self.strokes += 1; + let col = |g: fn(&Row) -> Option| percentiles(rows.iter().filter_map(g).collect()); + let summary = Summary { + frames: rows.len(), + backend, + interval: col(|r| (r.interval > 0.0).then_some(r.interval)), + moves: col(|r| Some(r.moves as f64)), + stroke: col(|r| Some(r.stroke)), + canvas: col(|r| Some(r.canvas)), + ui: col(|r| Some(r.ui)), + // A frame's cpu_prev belongs to the frame before it, so pair it with that interval. + cpu: percentiles(rows.iter().skip(1).filter_map(|r| r.cpu_prev).collect()), + wait: percentiles(rows.windows(2).filter_map(|w| Some((w[1].interval - w[1].cpu_prev?).max(0.0))).collect()), + }; + if self.enabled() { + eprintln!("{}", summary.line()); + } + #[cfg(not(target_arch = "wasm32"))] + if let Output::Csv(path) = &self.output + && let Err(e) = append_csv(path, self.strokes, &rows) + { + eprintln!("stroke trace: can't write {}: {e}", path.display()); + } + self.last = Some(summary); + } +} + +/// The CSV text for `rows` of stroke `stroke` (with the header when `header`). +// The web build has no clock, so it never traces. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) fn csv(stroke: usize, rows: &[Row], header: bool) -> String { + let mut s = String::new(); + if header { + s.push_str("stroke,t_ms,interval_ms,moves,stroke_ms,canvas_ms,ui_ms,cpu_prev_ms\n"); + } + for r in rows { + let cpu = r.cpu_prev.map_or(String::new(), |c| format!("{c:.3}")); + let _ = writeln!(s, "{stroke},{:.3},{:.3},{},{:.3},{:.3},{:.3},{cpu}", r.t, r.interval, r.moves, r.stroke, r.canvas, r.ui); + } + s +} + +#[cfg(not(target_arch = "wasm32"))] +fn append_csv(path: &std::path::Path, stroke: usize, rows: &[Row]) -> std::io::Result<()> { + use std::io::Write as _; + let header = std::fs::metadata(path).map_or(true, |m| m.len() == 0); + let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?; + f.write_all(csv(stroke, rows, header).as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn on() -> StrokeTrace { + StrokeTrace { output: Output::Stderr, ..Default::default() } + } + + fn frame(t: &mut StrokeTrace, now: f64, cpu: f32, stroking: bool) { + t.begin_frame(now, 3); + t.cpu_prev(Some(cpu / 1000.0)); + if stroking { + t.stroke(1.0); + t.canvas(2.0); + } + t.end_frame(4.0, stroking, || "vulkan Test GPU".into()); + } + + #[test] + fn a_stroke_is_its_frames_from_the_first_stroke_frame_to_the_first_without() { + let mut t = on(); + frame(&mut t, 0.0, 5.0, false); + frame(&mut t, 100.0, 5.0, true); + for k in 1..=10 { + frame(&mut t, 100.0 + 16.0 * f64::from(k), 6.0, true); + } + assert!(t.last.is_none(), "still drawing"); + frame(&mut t, 276.0, 6.0, false); + let s = t.last.clone().expect("the stroke ended"); + assert_eq!(s.frames, 11); + assert_eq!(s.interval, [16.0, 16.0], "the idle gap before the stroke isn't a frame interval"); + assert_eq!((s.stroke, s.canvas, s.ui, s.moves), ([1.0, 1.0], [2.0, 2.0], [4.0, 4.0], [3.0, 3.0])); + assert_eq!((s.cpu, s.wait), ([6.0, 6.0], [10.0, 10.0])); + assert!(s.line().contains("11 frames on vulkan Test GPU") && s.line().contains("wait 10.00/10.00"), "{}", s.line()); + // Off: nothing is kept. + let mut off = StrokeTrace::default(); + frame(&mut off, 0.0, 1.0, true); + frame(&mut off, 16.0, 1.0, false); + assert!(off.last.is_none() && off.rows.is_empty()); + } + + #[test] + fn a_trace_is_set_by_value() { + assert_eq!(StrokeTrace::from_value("off").output, Output::Off); + assert_eq!(StrokeTrace::from_value(" 0 ").output, Output::Off); + assert_eq!(StrokeTrace::from_value("stderr").output, Output::Stderr); + assert_eq!(StrokeTrace::from_value("true").output, Output::Stderr); + assert_eq!(StrokeTrace::from_value("t.csv").output, Output::Csv("t.csv".into())); + let mut t = on(); + t.last = Some(Summary::default()); + t.set("off"); + assert!(!t.enabled() && t.last.is_some(), "turning it off keeps the last summary"); + } + + #[test] + fn csv_rows_and_odd_numbers() { + let rows = [Row { t: 1.0, interval: 16.5, moves: 2, stroke: 0.25, canvas: 1.0, ui: 3.0, cpu_prev: None }]; + assert_eq!(csv(1, &rows, true), "stroke,t_ms,interval_ms,moves,stroke_ms,canvas_ms,ui_ms,cpu_prev_ms\n1,1.000,16.500,2,0.250,1.000,3.000,\n"); + assert_eq!(percentiles(vec![]), [0.0, 0.0]); + assert_eq!(percentiles(vec![f64::NAN, 2.0, f64::INFINITY]), [2.0, 2.0]); + assert_eq!(percentiles((1..=100).map(f64::from).collect()), [51.0, 95.0]); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn the_csv_gets_one_header_and_every_strokes_frames() { + let dir = std::env::temp_dir().join(format!("photocraft-stroke-trace-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("trace.csv"); + let _ = std::fs::remove_file(&path); + let mut t = StrokeTrace { output: Output::Csv(path.clone()), ..Default::default() }; + for start in [0.0, 1000.0] { + for k in 0..3 { + frame(&mut t, start + 16.0 * f64::from(k), 5.0, true); + } + frame(&mut t, start + 48.0, 5.0, false); + } + let text = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 7, "{text}"); + assert!(lines[0].starts_with("stroke,") && lines[1].starts_with("1,") && lines[6].starts_with("2,")); + } +} diff --git a/crates/ui-egui/src/stroke_trail.rs b/crates/ui-egui/src/stroke_trail.rs index 6a5465ca7f..af09fca1c4 100644 --- a/crates/ui-egui/src/stroke_trail.rs +++ b/crates/ui-egui/src/stroke_trail.rs @@ -74,6 +74,13 @@ impl Trail { self.fed = points.len(); } + /// Stamp a whole new drag into the same mask (the Remove tool's waiting strokes, #6555): its + /// points are counted from its own start, not from where the previous drag ended. + pub fn feed_stroke(&mut self, points: &[[f64; 3]], diameter: f32) { + self.fed = 0; + self.feed(points, diameter); + } + /// Show the coverage the brush's own dabs leave along the drag, as the stroke commit renders /// it (`photocraft_paint::retouch::stroke_coverage`): soft edges for a soft brush, the size and /// shape each dab takes from pen pressure and dynamics. `brush` is asked for once, on the first @@ -224,6 +231,24 @@ mod tests { } } + #[test] + fn each_stroke_fed_whole_is_stamped_in_full() { + let mut t = Trail::new([300, 300]); + let strokes: [&[[f64; 3]]; 3] = [ + &[[50.0, 50.0, 1.0], [120.0, 50.0, 1.0], [200.0, 50.0, 1.0]], + &[[50.0, 120.0, 1.0], [120.0, 120.0, 1.0]], + &[[50.0, 200.0, 1.0], [80.0, 200.0, 1.0], [120.0, 200.0, 1.0], [160.0, 200.0, 1.0], [200.0, 200.0, 1.0], [260.0, 200.0, 1.0]], + ]; + for s in strokes { + t.feed_stroke(s, 10.0); + } + for (x, y) in [(50, 50), (85, 50), (199, 50), (50, 120), (85, 120), (119, 120), (50, 200), (65, 200), (100, 200), (259, 200)] { + assert_eq!(t.coverage(x, y), 255, "({x}, {y}) lies on a stroke"); + } + assert_eq!(t.coverage(50, 85), 0, "no capsule joins one stroke's end to the next one's start"); + assert_eq!(t.coverage(150, 160), 0); + } + #[test] fn hostile_points_and_huge_documents_are_safe() { let mut t = Trail::new([300_000, 2]); 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/swatches_ui.rs b/crates/ui-egui/src/swatches_ui.rs index 749ab4a059..a18ddee640 100644 --- a/crates/ui-egui/src/swatches_ui.rs +++ b/crates/ui-egui/src/swatches_ui.rs @@ -68,11 +68,10 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut st = std::mem::take(&mut app.ui.presets_ui); st.sizes.entry(PANEL.to_string()).or_insert(DEFAULT_SIZE); let max_h = (ui.available_height() - 30.0).max(60.0); - let card_border = crate::theme::Tokens::get(ui.ctx()).card_border; - let events = browser(ui, &mut st, PANEL, &groups, Place { canvas, max_h, new_tip: tl!("Create new swatch") }, &mut |ui, r: Rect, gi, ii| { + // Flat, touching squares, as in Photoshop's Swatches panel (#5103). + let events = browser(ui, &mut st, PANEL, &groups, Place { canvas, max_h, new_tip: tl!("Create new swatch"), flush: true }, &mut |ui, r: Rect, gi, ii| { let c = table.get(gi).and_then(|g| g.get(ii)).copied().unwrap_or(Color32::GRAY); - ui.painter().rect_filled(r, CornerRadius::same(2), c); - ui.painter().rect_stroke(r, CornerRadius::same(2), egui::Stroke::new(1.0, card_border), egui::StrokeKind::Inside); + ui.painter().rect_filled(r, CornerRadius::ZERO, c); }); app.ui.presets_ui = st; let mods = ui.input(|i| i.modifiers); @@ -305,6 +304,20 @@ mod tests { h.ctx.input(|i| i.viewport_rect()).min + vec2(8.0 + 12.0 + 8.0, 22.0 + 2.0 + 12.0 + 8.0) } + /// #5103: the swatches touch, as in Photoshop's panel. A click just past the first cell's + /// right edge lands on the second swatch; with the preset grids' 4 px gap it hit nothing. + #[test] + fn swatches_touch_without_a_gap() { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let second = app.session.presets.swatches.first().and_then(|g| g.items.get(1)).map(|s| s.name.clone()).unwrap(); + let mut h = Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel(app, ui), app); + h.run_steps(2); + assert_eq!(h.state().ui.presets_ui.selected.get(PANEL), None); + let p = first_cell(&h); + click(&mut h, p + vec2(DEFAULT_SIZE / 2.0 + 1.0, 0.0), Modifiers::NONE); + assert_eq!(h.state().ui.presets_ui.selected.get(PANEL), Some(&second), "the second swatch, not a gap"); + } + /// Clicking a swatch while characters are selected recolours them, not just the foreground. #[test] fn clicking_a_swatch_recolours_selected_type() { diff --git a/crates/ui-egui/src/tab_strip.rs b/crates/ui-egui/src/tab_strip.rs index f7c9043821..1419471f1f 100644 --- a/crates/ui-egui/src/tab_strip.rs +++ b/crates/ui-egui/src/tab_strip.rs @@ -54,8 +54,10 @@ pub struct StripOut { pub const CHEVRON_W: f32 = 18.0; /// Draw the » overflow button at `r`: hover tip `tip`, listing the tabs named by `labels` at the -/// indices in `overflow`. A picked index is left in `picked`. -pub fn overflow_button(ui: &mut Ui, id: egui::Id, r: Rect, tip: &str, labels: &[&str], overflow: &[usize], picked: &mut Option) { +/// indices in `overflow`, the `checked` one with a check mark as in the menu bar's menus. A picked +/// index is left in `picked`. +#[allow(clippy::too_many_arguments)] +pub fn overflow_button(ui: &mut Ui, id: egui::Id, r: Rect, tip: &str, labels: &[&str], overflow: &[usize], checked: Option, picked: &mut Option) { let t = Tokens::get(ui.ctx()); let resp = ui.interact(r, id, Sense::click()); let mut paint_ui = ui.new_child(egui::UiBuilder::new().max_rect(r)); @@ -69,9 +71,9 @@ pub fn overflow_button(ui: &mut Ui, id: egui::Id, r: Rect, tip: &str, labels: &[ egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(140.0); for &i in overflow { - if let Some(name) = labels.get(i) - && ui.button(*name).clicked() - { + let Some(name) = labels.get(i) else { continue }; + let text = if checked.is_some() { format!("{} {name}", if checked == Some(i) { "✔" } else { " " }) } else { (*name).to_owned() }; + if ui.button(text).clicked() { *picked = Some(i); ui.close(); } @@ -86,20 +88,20 @@ fn tabs_in( ui: &mut Ui, id: egui::Id, area: Rect, - tabs: &[&str], + panel_ids: &[&str], selected: &mut usize, font: egui::FontId, pad: f32, min_w: f32, reorderable: bool, active: impl Fn(usize) -> bool, - mut paint_tab: impl FnMut(&Ui, Rect, usize, std::sync::Arc, &Response, bool), + mut paint_tab: impl FnMut(&Ui, Rect, usize, &str, std::sync::Arc, &Response, bool), ) -> StripOut { let t = Tokens::get(ui.ctx()); // Panel names are English keys; draw them in the UI language. - let names: Vec<&str> = tabs.iter().map(|n| tl!(n)).collect(); - let tabs = names.as_slice(); - let natural: Vec = tabs.iter().map(|n| ui.painter().layout_no_wrap((*n).to_owned(), font.clone(), t.text).size().x + pad).collect(); + let names: Vec<&str> = panel_ids.iter().map(|n| tl!(n)).collect(); + let translated_tabs = names.as_slice(); + let natural: Vec = translated_tabs.iter().map(|n| ui.painter().layout_no_wrap((*n).to_owned(), font.clone(), t.text).size().x + pad).collect(); let f = fit(&natural, *selected, area.width(), min_w, CHEVRON_W); let mut x = area.left(); let mut out = StripOut { @@ -117,7 +119,8 @@ fn tabs_in( let sense = if reorderable { Sense::click_and_drag() } else { Sense::click() }; let mut drag: Option<(usize, bool)> = None; for &(i, w) in &f.shown { - let Some(name) = tabs.get(i) else { continue }; + let Some(name) = translated_tabs.get(i) else { continue }; + let panel_id = panel_ids[i]; let r = Rect::from_min_size(pos2(x, area.top()), vec2(w, area.height())); // The padding gives way (down to a third) before the label is cut. let galley = elided(ui, name, font.clone(), t.text, (w - pad / 3.0).max(1.0)); @@ -125,7 +128,7 @@ fn tabs_in( let resp = craft_ui::tabs::Tab::new(id.with(("tab", i)), name, i == *selected).sense(sense).focus_stroke(Stroke::new(1.0, t.accent)).show_at( ui, r, - |ui, resp| paint_tab(ui, r, i, galley, resp, active(i)), + |ui, resp| paint_tab(ui, r, i, panel_id, galley, resp, active(i)), ); let resp = if cut { resp.on_hover_text(*name) } else { resp }; if resp.drag_started() { @@ -160,7 +163,7 @@ fn tabs_in( if !f.overflow.is_empty() && f.overflow_width > 0.0 { let r = Rect::from_min_size(pos2(x, area.top()), vec2(f.overflow_width, area.height())); let mut picked = None; - overflow_button(ui, id.with("tab-overflow"), r, tl!("More panels"), tabs, &f.overflow, &mut picked); + overflow_button(ui, id.with("tab-overflow"), r, tl!("More panels"), translated_tabs, &f.overflow, None, &mut picked); if let Some(i) = picked { out.clicked = true; *selected = i; @@ -176,7 +179,7 @@ fn tabs_in( let before = match out.tabs.get(slot) { Some((b, _)) => Some(*b), // After the last tab on the strip: before the first overflowing one, if any. - None => out.tabs.last().map(|(l, _)| l + 1).filter(|b| *b < tabs.len()), + None => out.tabs.last().map(|(l, _)| l + 1).filter(|b| *b < panel_ids.len()), }; if released { out.reorder = Some((tab, before)); @@ -218,7 +221,7 @@ pub fn pro_tabs(ui: &mut Ui, id: egui::Id, strip: Rect, menu_left: f32, tabs: &[ 40.0, reorderable, |i| i == sel && !collapsed, - |ui, r, i, galley, resp, active| { + |ui, r, i, _, galley, resp, active| { if active { ui.painter().rect_filled(r, CornerRadius { nw: if i == 0 { 3 } else { 0 }, ne: 0, sw: 0, se: 0 }, t.card); } else if resp.hovered() { @@ -251,7 +254,7 @@ pub fn pill_tabs(ui: &mut Ui, id: egui::Id, area: Rect, tabs: &[&str], selected: 44.0, reorderable, |i| i == sel, - |ui, r, _, galley, resp, active| { + |ui, r, _, _, galley, resp, active| { // 2 pt between pills, as the old horizontal layout had. let r = Rect::from_min_max(r.min, pos2((r.right() - 2.0).max(r.left()), r.bottom())); if active { @@ -357,7 +360,7 @@ mod tests { let mut picked = None; let input = egui::RawInput { events: vec![egui::Event::PointerMoved(rect.center())], ..Default::default() }; let mut output = ctx.run_ui(input, |ui| { - overflow_button(ui, ui.id().with("tiny-overflow"), rect, "More panels", &["Layers"], &[0], &mut picked); + overflow_button(ui, ui.id().with("tiny-overflow"), rect, "More panels", &["Layers"], &[0], None, &mut picked); }); output.textures_delta.clear(); let mut icons = 0; diff --git a/crates/ui-egui/src/targa_options_ui.rs b/crates/ui-egui/src/targa_options_ui.rs index c2161a0c9b..5a8b47d589 100644 --- a/crates/ui-egui/src/targa_options_ui.rs +++ b/crates/ui-egui/src/targa_options_ui.rs @@ -21,8 +21,9 @@ pub struct Prompt { /// Save a Copy: the document keeps its path and unsaved changes. pub copy: bool, /// The step the answered save is recorded as, so an action being recorded keeps it: - /// `file.saveAs` for Save As (#2032, even when a flat Targa is written as a copy) and - /// `file.saveACopy` for Save a Copy (#4373). + /// `file.saveAs` for Save As (#2032, even when a flat Targa is written as a copy), + /// `file.saveACopy` for Save a Copy (#4373), and `file.save` when File › Save confirmed the + /// document's own Targa (#3531), which plays back on the file the action is played on. pub record: &'static str, } @@ -46,7 +47,7 @@ pub fn bits_param(params: &serde_json::Value, cmd: &str) -> Result Result<(), String> { if app.save_options_open() { - return Err("Answer TIFF or Targa Options before starting another save".into()); + return Err("Answer the TIFF, Targa or DDS Options before starting another save".into()); } let doc = app.active_doc_id()?; let bits = app.session.active().map_or(TgaBits::Bits24, |st| photocraft_io::tga_default_bits(&st.doc)); @@ -76,10 +77,12 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { }); cancel |= modal.should_close(); p.bits = bits; - if ok || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) { + // A focused button takes Enter as its click without consuming the key: Enter on Cancel cancels. + if ok || (!cancel && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter))) { let Some(p) = app.targa_options.take() else { return }; let settings = ExportSettings { tga_bits: Some(p.bits), ..app.save_settings() }; - let step = serde_json::json!({"path": p.path, "tgaBits": p.bits.bits()}); + let mut step = if p.record == "file.save" { serde_json::json!({}) } else { serde_json::json!({"path": p.path}) }; + step["tgaBits"] = p.bits.bits().into(); let written = app.with_document(p.doc, |app| app.write_document(p.path, &settings, p.copy)); if written.is_ok() { // An action being recorded keeps the Save As or Save a Copy with this answer (#2032). diff --git a/crates/ui-egui/src/tiff_options_ui.rs b/crates/ui-egui/src/tiff_options_ui.rs index febd5964cb..eed78830a1 100644 --- a/crates/ui-egui/src/tiff_options_ui.rs +++ b/crates/ui-egui/src/tiff_options_ui.rs @@ -65,7 +65,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { cancel |= modal.should_close(); p.discard_layers = discard; p.dont_ask_again = dont_ask; - if ok || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) { + // A focused button takes Enter as its click without consuming the key: Enter on Cancel cancels. + if ok || (!cancel && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter))) { let Some(p) = app.tiff_options.take() else { return }; if p.dont_ask_again { app.session.edit_prefs(|prefs| prefs.file_handling.ask_before_saving_layered_tiff = false); diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index c45baa2f0d..25b23ee0f6 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -538,6 +538,23 @@ mod tests { use egui::{Event, Pos2, RawInput, ViewportId}; use photocraft_engine::Session; + /// #5826: the drag overlay under a dialog doesn't depend on the custom title bar. On macOS + /// (OS traffic lights over the app's own title strip) a dialog left the window undraggable. + #[test] + fn a_dialog_keeps_the_title_strip_draggable_with_either_title_bar() { + for custom in [false, true] { + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + app.custom_titlebar = custom; + app.run("file.new", serde_json::json!({"width": 8, "height": 8})).unwrap(); + assert!(!app.titlebar_modal_drag(true), "no dialog, no overlay"); + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + assert!(app.titlebar_modal_drag(true), "custom title bar: {custom}"); + assert!(!app.titlebar_modal_drag(false), "chrome hidden (full screen): no title strip"); + app.ui.panels.menu_bar = false; + assert!(!app.titlebar_modal_drag(true), "no menu bar, no title strip"); + } + } + /// A headless window `width` pt wide showing the title bar and the resize zones. struct Win { ctx: egui::Context, @@ -646,7 +663,7 @@ mod tests { for width in [760.0, 1100.0, 1440.0] { let mut w = Win::new(width); w.frame(vec![]); - let out = w.frame(vec![]); + w.frame(vec![]); let [min, max, close] = CAPTIONS.map(|c| w.caption(c)); assert_eq!((close.right(), close.top()), (width, 0.0), "Close sits in the window's corner at {width}"); // The bar's bottom point is the panel's separator line, which the buttons leave visible. @@ -657,9 +674,18 @@ mod tests { for a in &widgets { assert!(a.right() <= min.left(), "{a:?} runs into the caption buttons at {width}"); } - // Wide windows show the whole group; the narrowest drops Discord (also Help › Discord). - let has_discord = texts(&out).iter().any(|(t, _)| t == "Discord"); - assert_eq!(has_discord, width > 1000.0, "Discord at {width}"); + } + } + + #[test] + fn the_title_bar_promotes_no_community_chat() { + // Issue #1726: only the always-visible title-bar button is removed. The start-screen + // and About buttons and Help › Discord remain available. + for width in [760.0, 1100.0, 1440.0] { + let mut w = Win::new(width); + w.frame(vec![]); + let out = w.frame(vec![]); + assert!(!texts(&out).iter().any(|(t, _)| t.contains("Discord")), "no Discord in the bar at {width}"); } } diff --git a/crates/ui-egui/src/tone.rs b/crates/ui-egui/src/tone.rs index 0eacc731ae..f02c108dbb 100644 --- a/crates/ui-egui/src/tone.rs +++ b/crates/ui-egui/src/tone.rs @@ -24,6 +24,9 @@ static EMPTY: [u32; 256] = [0; 256]; pub struct Histograms { pub tag: u64, pub ch: Vec<[u32; 256]>, + /// Photoshop's histogram cache level of a composite histogram: 1 when every document pixel + /// was counted, `n` when a proxy 2^(n-1) times smaller stood in; 0 for a gray plane's. + pub level: u32, } impl std::ops::Index for Histograms { @@ -40,12 +43,20 @@ pub enum HistSource { BelowLayer(LayerId), /// One layer on its own (the target of a destructive Image › Adjustments command). Layer(LayerId), + /// The gray plane a Curves target edits: a layer mask, a saved channel or the Quick Mask. + /// The dialog shows this plane's own levels, not the colour layers beneath (#5335). + Channel { layer: LayerId, surface: photocraft_engine::channel_cmds::GraySurface }, } fn tag(source: HistSource, space: ToneSpace) -> u64 { let (k, id) = match source { HistSource::BelowLayer(l) => (1u64, l.0), HistSource::Layer(l) => (2, l.0), + HistSource::Channel { layer, surface } => match surface { + photocraft_engine::channel_cmds::GraySurface::LayerMask => (3, layer.0), + photocraft_engine::channel_cmds::GraySurface::Alpha(i) => (4, i as u64), + photocraft_engine::channel_cmds::GraySurface::QuickMask => (5, layer.0), + }, }; let s = match space { ToneSpace::Rgb => 0u64, @@ -68,6 +79,48 @@ fn isolate(layers: &mut [Layer], id: LayerId) -> bool { /// Histograms of `doc` as seen by `source`, in the channels of `space`. pub fn compute_histograms(doc: &Document, source: HistSource, space: ToneSpace) -> Histograms { + let mut level = 0; + let mut h = vec![ + [0u32; 256]; + match space { + ToneSpace::Rgb => 4, + ToneSpace::Cmyk => 5, + ToneSpace::Lab => 3, + } + ]; + match source { + // The gray plane itself: its levels are what the curve edits, whatever the document's + // colour mode (#5335). + HistSource::Channel { layer, surface } => { + let surf = match surface { + photocraft_engine::channel_cmds::GraySurface::LayerMask => doc.layer(layer).and_then(|l| l.mask.as_ref()).map(|m| &m.surface), + photocraft_engine::channel_cmds::GraySurface::Alpha(i) => doc.channels.get(i).map(|c| &c.surface), + photocraft_engine::channel_cmds::GraySurface::QuickMask => doc.quick_mask.as_ref().map(|c| &c.surface), + }; + if let Some(s) = surf + && let Some(bin) = h.first_mut() + { + *bin = gray_bins(s, doc.bounds()); + } + } + HistSource::BelowLayer(_) | HistSource::Layer(_) => { + level = composite_bins(doc, source, space, &mut h); + } + } + Histograms { tag: tag(source, space), ch: h, level } +} + +/// Bins of the composited image below (`BelowLayer`) or of one layer alone (`Layer`), in `space`. +/// Returns the cache level counted: 1 when every document pixel was counted, higher when a +/// downsampled proxy stood in (like Photoshop's histogram cache levels). +/// +/// The pixels are counted as they are, never blended: an area-averaged thumbnail invented greys +/// that aren't in the image wherever an edge fell inside one of its cells (#6538). Documents up to +/// [`photocraft_compose::PROXY_THUMBNAIL_PIXELS`] (and those a proxy can't reduce faithfully) are +/// composited in bands and counted exactly, which costs what the thumbnail did (it reduced the +/// same full composite). Larger ones count a nearest-neighbour proxy composite of about the old +/// thumbnail's work: it only holds values the layers have. +fn composite_bins(doc: &Document, source: HistSource, space: ToneSpace, h: &mut [[u32; 256]]) -> u32 { let mut d = doc.clone(); match source { HistSource::BelowLayer(hide) => { @@ -78,44 +131,112 @@ pub fn compute_histograms(doc: &Document, source: HistSource, space: ToneSpace) HistSource::Layer(id) => { isolate(&mut d.layers, id); } + HistSource::Channel { .. } => {} } - let img = photocraft_compose::thumbnail(&d, 384); - let n = match space { - ToneSpace::Rgb => 4, - ToneSpace::Cmyk => 5, - ToneSpace::Lab => 3, + let longest = d.size.width.max(d.size.height); + let k = longest / 768; + let (d, level) = if k >= 2 && d.size.area() > photocraft_compose::PROXY_THUMBNAIL_PIXELS && photocraft_compose::proxy::proxy_faithful(&d) { + (photocraft_compose::proxy::proxy_document(&d, k), k.next_power_of_two().ilog2() + 1) + } else { + (d, 1) }; - let mut h = vec![[0u32; 256]; n]; + let _ = photocraft_compose::render_bands(&d, d.bounds(), 0, |band| -> Result<(), ()> { + #[cfg(not(target_arch = "wasm32"))] + let part = { + use rayon::prelude::*; + band.px + .par_chunks(64 * 1024) + .map(|px| { + let mut part = vec![[0u32; 256]; h.len()]; + count_pixels(px, space, &mut part); + part + }) + .reduce(|| vec![[0u32; 256]; h.len()], add_bins) + }; + #[cfg(target_arch = "wasm32")] + let part = { + let mut part = vec![[0u32; 256]; h.len()]; + count_pixels(&band.px, space, &mut part); + part + }; + for (dst, src) in h.iter_mut().zip(&part) { + for (a, b) in dst.iter_mut().zip(src) { + *a = a.saturating_add(*b); + } + } + Ok(()) + }); + level +} + +/// Bin-wise sum of two sets of histograms (saturating: a hostile canvas can exceed `u32`). +#[cfg(not(target_arch = "wasm32"))] +fn add_bins(mut a: Vec<[u32; 256]>, b: Vec<[u32; 256]>) -> Vec<[u32; 256]> { + for (dst, src) in a.iter_mut().zip(&b) { + for (x, y) in dst.iter_mut().zip(src) { + *x = x.saturating_add(*y); + } + } + a +} + +/// Counts straight-alpha composite pixels into `h` (one histogram per channel of `space`); +/// transparent pixels are skipped. +fn count_pixels(px: &[[f32; 4]], space: ToneSpace, h: &mut [[u32; 256]]) { let bin = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as usize; - for p in img.pixels.as_chunks::<4>().0 { - if p[3] == 0 { + let mut add = |c: usize, b: usize| { + if let Some(n) = h.get_mut(c).and_then(|ch| ch.get_mut(b)) { + *n = n.saturating_add(1); + } + }; + for p in px { + // Skips NaN alpha too. + if p[3].is_nan() || p[3] <= 0.0 { continue; } match space { ToneSpace::Rgb => { - for c in 0..3 { - h[c + 1][p[c] as usize] += 1; + let q = [p[0], p[1], p[2]].map(bin); + for (c, v) in q.iter().enumerate() { + add(c + 1, *v); } - h[0][((p[0] as u32 + p[1] as u32 + p[2] as u32) / 3) as usize] += 1; + add(0, (q[0] + q[1] + q[2]) / 3); } ToneSpace::Cmyk => { - let rgb = [p[0], p[1], p[2]].map(|v| f32::from(v) / 255.0); - let ink = photocraft_color::convert::rgb_to_cmyk(rgb); + let ink = photocraft_color::convert::rgb_to_cmyk([p[0], p[1], p[2]].map(|v| v.clamp(0.0, 1.0))); for (c, v) in ink.iter().enumerate() { - h[c + 1][bin(1.0 - v)] += 1; + add(c + 1, bin(1.0 - v)); } - h[0][bin(1.0 - ink.iter().sum::() / 4.0)] += 1; + add(0, bin(1.0 - ink.iter().sum::() / 4.0)); } ToneSpace::Lab => { - let rgb = [p[0], p[1], p[2]].map(|v| f32::from(v) / 255.0); - let l = photocraft_color::convert::srgb_to_lab(rgb); - h[0][bin(l[0] / 100.0)] += 1; - h[1][bin((l[1] + 128.0) / 255.0)] += 1; - h[2][bin((l[2] + 128.0) / 255.0)] += 1; + let l = photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]].map(|v| v.clamp(0.0, 1.0))); + add(0, bin(l[0] / 100.0)); + add(1, bin((l[1] + 128.0) / 255.0)); + add(2, bin((l[2] + 128.0) / 255.0)); } } } - Histograms { tag: tag(source, space), ch: h } +} + +/// 256-bin histogram of a gray plane: its levels in 0–255, sampled like the engine's auto-tone +/// histograms so a big canvas costs no more than about a megapixel here (#5335). +fn gray_bins(surf: &photocraft_raster::Surface, area: photocraft_geom::Rect) -> [u32; 256] { + let mut h = [0u32; 256]; + let step = ((u64::from(area.width()) * u64::from(area.height())) / 1_000_000).max(1).isqrt().max(1) as i32; + let mut y = area.y0; + while y < area.y1 { + let mut x = area.x0; + while x < area.x1 { + let v = (surf.sample_channel(x, y, 0).clamp(0.0, 1.0) * 255.0).round() as usize; + if let Some(bin) = h.get_mut(v) { + *bin += 1; + } + x += step; + } + y += step; + } + h } /// Cached histograms for a tone editor; recomputed only when something other than the editor's @@ -126,6 +247,7 @@ pub fn histograms(app: &mut PhotocraftApp, source: HistSource, space: ToneSpace) let want = tag(source, space); let layer = match source { HistSource::BelowLayer(l) | HistSource::Layer(l) => l, + HistSource::Channel { layer, .. } => layer, }; let pixels_kept = view_only(st); if let Some((d, l, r, h)) = app.tone_hist.as_mut() @@ -162,11 +284,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(); @@ -290,9 +418,8 @@ pub fn histogram_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } let Some((_, _, _, h)) = app.doc_hist.clone() else { return }; - let size = app.session.active().map_or(photocraft_doc::Size::new(0, 0), |s| s.doc.size); - // Statistics come from a downsampled cache (like Photoshop's cache levels). - let level = (size.width.max(size.height) as f32 / 384.0).max(1.0).log2().ceil() as u32 + 1; + // 1 when the statistics count every pixel; higher when a downsampled proxy stood in. + let level = h.level.max(1); let ch = channel_picker(ui, egui::Id::new("histogram-panel-ch"), "Channel:"); let w = ui.available_width(); let (r, _) = ui.allocate_exact_size(vec2(w, 100.0), Sense::hover()); @@ -322,7 +449,7 @@ pub fn histogram_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { (tl!("Mean:"), format!("{mean:.2}")), (tl!("Std Dev:"), format!("{:.2}", var.sqrt())), (tl!("Median:"), median.unwrap_or(0).to_string()), - (tl!("Pixels:"), (size.width as u64 * size.height as u64).to_string()), + (tl!("Pixels:"), n.to_string()), (tl!("Cache Level:"), level.to_string()), ] { ui.label(egui::RichText::new(k).color(t.text_dim).size(11.5)); @@ -362,6 +489,145 @@ mod tests { assert_eq!(compute_histograms(&st.doc, HistSource::Layer(bg), ToneSpace::Cmyk).ch.len(), 5); assert_eq!(compute_histograms(&st.doc, HistSource::Layer(bg), ToneSpace::Lab).ch.len(), 3); } + + /// #5335: the Curves histogram of a targeted gray plane describes that plane, not the + /// colour layers beneath it: a channel filled with 25% grey over a black layer shows a + /// spike at 25% grey, not at black. + #[test] + fn channel_histogram_describes_the_gray_plane() { + use photocraft_engine::channel_cmds::GraySurface; + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); // white background + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 32, "height": 32})).unwrap(); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + s.execute("channel.new", json!({"name": "Sky sel", "fill": "white"})).unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + let b = photocraft_geom::Rect::from_xywh(0, 0, 32, 32); + s.edit("fill-planes", |doc, active| { + doc.channels[0].surface.fill_rect(b, &[0.25]); + doc.layer_mut(active.unwrap()).unwrap().mask.as_mut().unwrap().surface.fill_rect(b, &[0.0]); + Ok(()) + }) + .unwrap(); + let st = s.active().unwrap(); + let layer = st.active_layer.unwrap(); + let h = compute_histograms(&st.doc, HistSource::Channel { layer, surface: GraySurface::Alpha(0) }, ToneSpace::Rgb); + assert!(h[0][64] > 0, "a spike at 25% grey, got {:?}", &h[0][0..80]); + assert_eq!(h[0][0], 0, "not the black layer beneath"); + let m = compute_histograms(&st.doc, HistSource::Channel { layer, surface: GraySurface::LayerMask }, ToneSpace::Rgb); + assert!(m[0][0] > 0, "the hidden mask is black"); + // Each plane keeps its own cache entry: switching targets recomputes. + assert_ne!( + tag(HistSource::Channel { layer, surface: GraySurface::Alpha(0) }, ToneSpace::Rgb), + tag(HistSource::Channel { layer, surface: GraySurface::LayerMask }, ToneSpace::Rgb) + ); + 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}"); + } + + /// A white document with black filled over columns `0..split` (a layer above the background). + fn split_doc(w: u32, h: u32, split: u32, depth: Option) -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + let mut p = json!({"width": w, "height": h}); + if let Some(bits) = depth { + p["depth"] = json!(bits); + } + s.execute("file.new", p).unwrap(); // white + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": split, "height": h})).unwrap(); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s + } + + fn panel_histograms(s: &photocraft_engine::Session) -> Histograms { + compute_histograms(&s.active().unwrap().doc, HistSource::BelowLayer(LayerId(u64::MAX)), ToneSpace::Rgb) + } + + fn mean(h: &[u32; 256]) -> f64 { + let n: u64 = h.iter().map(|v| u64::from(*v)).sum(); + h.iter().enumerate().map(|(i, v)| i as f64 * f64::from(*v)).sum::() / n as f64 + } + + /// #6538: a black/white split counts only black and white, wherever the edge falls (the old + /// 384 px area-averaged thumbnail turned the cell holding the edge mid-grey), and counts + /// every pixel: cache level 1. + #[test] + fn split_image_histogram_counts_only_black_and_white() { + for (w, h, split) in [(1000, 1000, 334), (1000, 1000, 500), (1000, 600, 1), (777, 333, 389), (2000, 50, 1999), (64, 64, 32)] { + let s = split_doc(w, h, split, None); + let hist = panel_histograms(&s); + assert_eq!(hist.level, 1, "{w}x{h}: every pixel is counted"); + for c in 0..4 { + let stray: Vec<(usize, u32)> = hist[c].iter().copied().enumerate().filter(|&(i, v)| v > 0 && i != 0 && i != 255).collect(); + assert!(stray.is_empty(), "{w}x{h} split {split}, channel {c}: invented values {stray:?}"); + assert_eq!(u64::from(hist[c][0]), u64::from(split) * u64::from(h), "{w}x{h} split {split}: black pixels"); + assert_eq!(u64::from(hist[c][255]), u64::from(w - split) * u64::from(h), "{w}x{h} split {split}: white pixels"); + } + } + let half = panel_histograms(&split_doc(1000, 1000, 500, None)); + assert!((mean(&half[0]) - 127.5).abs() < 1e-9, "a 50/50 split has mean 127.5, got {}", mean(&half[0])); + } + + /// The same at 16 and 32 bits per channel: the count does not go through an 8-bit thumbnail. + #[test] + fn split_image_histogram_is_exact_at_high_bit_depths() { + for bits in [16, 32] { + let s = split_doc(1000, 400, 334, Some(bits)); + assert_eq!(s.active().unwrap().doc.depth.bits(), bits, "the document is {bits}-bit"); + let hist = panel_histograms(&s); + for c in 0..4 { + let stray: Vec<(usize, u32)> = hist[c].iter().copied().enumerate().filter(|&(i, v)| v > 0 && i != 0 && i != 255).collect(); + assert!(stray.is_empty(), "{bits}-bit channel {c}: invented values {stray:?}"); + assert_eq!(hist[c][0], 334 * 400); + assert_eq!(hist[c][255], 666 * 400); + } + } + } + + /// Above the exact-count size a nearest-neighbour proxy stands in (cache level > 1): it is + /// smaller, yet still only holds black and white. + #[test] + fn large_split_image_counts_a_nearest_proxy_without_inventing_greys() { + let s = split_doc(6000, 3000, 2001, None); + let hist = panel_histograms(&s); + assert!(hist.level > 1, "a proxy stood in, level {}", hist.level); + let n: u64 = hist[0].iter().map(|v| u64::from(*v)).sum(); + assert!(n > 0 && n < 6000 * 3000, "counted {n} proxy pixels"); + for c in 0..4 { + let stray: Vec<(usize, u32)> = hist[c].iter().copied().enumerate().filter(|&(i, v)| v > 0 && i != 0 && i != 255).collect(); + assert!(stray.is_empty(), "channel {c}: invented values {stray:?}"); + } + assert!((mean(&hist[0]) - 255.0 * 3999.0 / 6000.0).abs() < 1.0, "mean {}", mean(&hist[0])); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/tool_cursor.rs b/crates/ui-egui/src/tool_cursor.rs index 6539ddb860..8b67b96d60 100644 --- a/crates/ui-egui/src/tool_cursor.rs +++ b/crates/ui-egui/src/tool_cursor.rs @@ -9,6 +9,8 @@ use std::sync::Arc; use egui::{CursorIcon, CustomCursorImage, Painter, Pos2, Stroke, Vec2, vec2}; +use crate::canvas::ViewXform; + mod hand; mod zoom; @@ -91,9 +93,25 @@ fn close_ring() -> Vec { } /// Can this pass show an OS cursor bitmap? eframe's immediate viewports and its web integration -/// don't upload cursor images; every desktop root viewport does (winit custom cursors). +/// don't upload cursor images; every desktop root viewport does (winit custom cursors). Wayland at +/// any scale but 1 doesn't either: winit sizes the bitmap there with a `wp_viewport` destination +/// that some compositors ignore on cursor surfaces (Hyprland 0.56; Mutter before GNOME 48). They +/// show it one bitmap pixel per logical pixel, so at 1.25 a brush outline was 1.25× too large and +/// its centre sat 0.2 × its half-width right of and below the pointer, which is where the brush +/// painted. Nothing tells the app which compositor does what, so the outline is painted instead. fn native(ctx: &egui::Context) -> bool { - cfg!(not(target_arch = "wasm32")) && ctx.viewport_id() == egui::ViewportId::ROOT + cfg!(not(target_arch = "wasm32")) && ctx.viewport_id() == egui::ViewportId::ROOT && !scaled_wayland(ctx) +} + +/// Mark this window as a Wayland surface (or not), for [`native`]; the shell calls it every pass. +pub(crate) fn set_wayland(ctx: &egui::Context, wayland: bool) { + ctx.data_mut(|d| d.insert_temp(egui::Id::new("photocraft-cursor-wayland"), wayland)); +} + +/// A Wayland window whose scale is not 1, where winit's cursor bitmaps are unreliable. +fn scaled_wayland(ctx: &egui::Context) -> bool { + let wayland = cfg!(target_os = "linux") && ctx.data(|d| d.get_temp::(egui::Id::new("photocraft-cursor-wayland"))).unwrap_or(false); + wayland && ctx.native_pixels_per_point().unwrap_or_else(|| ctx.pixels_per_point() / ctx.zoom_factor()) != 1.0 } /// Bitmap pixels per egui point. winit takes an `NSCursor` image's dimensions as logical points on @@ -104,6 +122,21 @@ pub(crate) fn native_scale(ctx: &egui::Context) -> f32 { if cfg!(target_os = "macos") { ctx.zoom_factor() } else { ctx.pixels_per_point() } } +/// The resize arrow for a handle that resizes along document direction `d`, as the view shows it +/// (its rotation, flip and pixel aspect): the nearest of the four arrows. Free Transform (handles +/// and Warp splits), the type box, Content-Aware Move's box, the Artboard tool and guides pick +/// their arrows here. +pub(crate) fn resize(d: [f64; 2], xf: &ViewXform) -> CursorIcon { + let s = xf.map_vec(vec2(d[0] as f32, d[1] as f32)); + let angle = f64::from(s.y).atan2(f64::from(s.x)).rem_euclid(std::f64::consts::PI); + match (angle / std::f64::consts::FRAC_PI_4).round() as u8 % 4 { + 0 => CursorIcon::ResizeHorizontal, + 1 => CursorIcon::ResizeNwSe, + 2 => CursorIcon::ResizeVertical, + _ => CursorIcon::ResizeNeSw, + } +} + /// The Hand tool's pointer: an open hand, a fist while it drags (`closed`). winit maps `Grab` and /// `Grabbing` to the four-arrow move cursor on Windows, so there it is a bitmap; macOS and the /// Linux themes already draw hands, and the OS moves those without waiting for a frame. diff --git a/crates/ui-egui/src/tool_cursor/tests.rs b/crates/ui-egui/src/tool_cursor/tests.rs index 8a240fa70c..d68be241f5 100644 --- a/crates/ui-egui/src/tool_cursor/tests.rs +++ b/crates/ui-egui/src/tool_cursor/tests.rs @@ -272,6 +272,40 @@ fn ordinary_brush_cursor_is_not_part_of_the_canvas_draw_list() { assert!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Circle(_))), "the full tip is not silently shrunk"); } +#[test] +#[cfg(target_os = "linux")] +fn scaled_wayland_paints_the_brush_outline_instead_of_an_os_bitmap() { + // Hyprland ignores the viewport winit sizes cursor bitmaps with: at 1.25 the outline was + // 1.25× too large and its centre 0.2 × its half-width right of and below where the brush + // painted. Off scale 1 on Wayland the outline is painted at the pointer, at its true size. + let run = |ctx: &egui::Context, native_ppp: f32| { + let mut input = egui::RawInput::default(); + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(native_ppp); + let mut output = ctx.run_ui(input, |ui| { + let icon = circle(ui.painter(), pos2(100.0, 100.0), 20.0, false); + ui.ctx().set_cursor_icon(icon); + }); + output.textures_delta.clear(); + let circles: Vec<_> = + output.shapes.iter().filter_map(|s| if let egui::Shape::Circle(c) = &s.shape { Some((c.center, c.radius)) } else { None }).collect(); + (output.platform_output.cursor_image.is_some(), output.platform_output.cursor_icon, circles) + }; + let ctx = egui::Context::default(); + ctx.add_plugin(CursorLifecycle); + set_wayland(&ctx, true); + for ppp in [1.25, 1.5, 2.0] { + let (bitmap, icon, circles) = run(&ctx, ppp); + assert!(!bitmap, "{ppp}: no OS bitmap"); + assert_eq!(icon, CursorIcon::None, "{ppp}: the painted outline is the pointer"); + assert!(!circles.is_empty() && circles.iter().all(|&(c, r)| c == pos2(100.0, 100.0) && r == 20.0), "{ppp}: {circles:?}"); + } + let (bitmap, _, circles) = run(&ctx, 1.0); + assert!(bitmap && circles.is_empty(), "Wayland at scale 1 keeps the low-latency bitmap"); + set_wayland(&ctx, false); + let (bitmap, _, circles) = run(&ctx, 1.25); + assert!(bitmap && circles.is_empty(), "X11 at 1.25 keeps the bitmap"); +} + /// Run explicitly for visual QA: native cursors are absent from GPU screenshots. #[test] #[ignore = "writes target/tool-cursor-preview.png for visual inspection"] @@ -458,3 +492,26 @@ fn circle_bitmaps_evaluate_only_the_ring_and_match_every_pixel() { } } } + +/// The arrow is the handle's direction as the view shows it: turned with a rotated view and +/// mirrored with a flipped one. +#[test] +fn resize_follows_the_view() { + use CursorIcon::{ResizeHorizontal as H, ResizeNeSw as NeSw, ResizeNwSe as NwSe, ResizeVertical as V}; + let view = |rotation: f32, flip: bool| ViewXform { + rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), + zoom: 1.0, + center: [0.0, 0.0], + flip, + rotation, + aspect: 1.0, + }; + // Right, down-right, down and down-left in the document. + let directions = [[1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [-1.0, 1.0]]; + for (rotation, flip, want) in + [(0.0, false, [H, NwSe, V, NeSw]), (90.0, false, [V, NeSw, H, NwSe]), (45.0, false, [NwSe, V, NeSw, H]), (0.0, true, [H, NeSw, V, NwSe])] + { + let got = directions.map(|d| resize(d, &view(rotation, flip))); + assert_eq!(got, want, "view turned {rotation}°, flip {flip}"); + } +} diff --git a/crates/ui-egui/src/tool_feedback.rs b/crates/ui-egui/src/tool_feedback.rs index eda03b305e..2e66ae07c1 100644 --- a/crates/ui-egui/src/tool_feedback.rs +++ b/crates/ui-egui/src/tool_feedback.rs @@ -17,6 +17,8 @@ pub enum Badge { Add, Subtract, Intersect, + /// Four arrows: a press drags the point under the pointer (the Curvature Pen over an anchor). + Move, } impl Badge { @@ -105,6 +107,12 @@ pub fn badge(app: &PhotocraftApp, tool: Tool, m: egui::Modifiers) -> Option Vec<[Pos2; 2]> { match b { Badge::Add => vec![[c - vec2(h, 0.0), c + vec2(h, 0.0)], [c - vec2(0.0, h), c + vec2(0.0, h)]], Badge::Subtract => vec![[c - vec2(h, 0.0), c + vec2(h, 0.0)]], + Badge::Move => { + let a = h * 0.45; + let mut v = vec![[c - vec2(h, 0.0), c + vec2(h, 0.0)], [c - vec2(0.0, h), c + vec2(0.0, h)]]; + for d in [vec2(1.0, 0.0), vec2(-1.0, 0.0), vec2(0.0, 1.0), vec2(0.0, -1.0)] { + let tip = c + d * h; + let side = vec2(-d.y, d.x) * a; + v.push([tip, tip - d * a + side]); + v.push([tip, tip - d * a - side]); + } + v + } Badge::Intersect => { let d = h * 0.8; vec![[c + vec2(-d, -d), c + vec2(d, d)], [c + vec2(-d, d), c + vec2(d, -d)]] diff --git a/crates/ui-egui/src/tool_memory.rs b/crates/ui-egui/src/tool_memory.rs index 8ea78a4d52..4c0f61b817 100644 --- a/crates/ui-egui/src/tool_memory.rs +++ b/crates/ui-egui/src/tool_memory.rs @@ -1,5 +1,4 @@ -//! Tool options remembered between launches (#2814), as Photoshop keeps each tool's options-bar -//! settings across sessions. +//! Selected tool and tool options remembered between launches, as in Photoshop (#2814). //! //! `Preferences::tool_options` holds the [`ToolOptions`] fields that differ from their defaults, //! so a default changed in a later release still reaches users who never touched that option. @@ -10,7 +9,28 @@ use serde_json::{Map, Value}; use crate::PhotocraftApp; -use crate::state::ToolOptions; +use crate::state::{Tool, ToolOptions}; + +/// Explicit choices override temporary pen and held-key tools, even when selecting the same tool. +pub(crate) fn select(app: &mut PhotocraftApp, tool: Tool) { + app.stylus.tool_before_eraser = None; + app.ui.spring = None; + app.ui.tool = tool; +} + +/// Temporary canvas overrides do not change `ui.tool`. Spring-loaded letters and the pen's +/// eraser end do, so remember the tool they will return to instead. A different explicit choice +/// (or beginning a type edit) owns the tool and stands, just as in `spring_tools::tick`. +fn selected(app: &PhotocraftApp) -> Tool { + let tool = if app.ui.tool == Tool::Eraser { app.stylus.tool_before_eraser.unwrap_or(app.ui.tool) } else { app.ui.tool }; + if let Some(s) = &app.ui.spring + && tool == s.tool + && app.ui.text_edit.is_none() + { + return s.previous; + } + tool +} /// The options that differ from the defaults, as JSON (`Null` when none do). fn changed(o: &ToolOptions) -> Value { @@ -21,11 +41,24 @@ fn changed(o: &ToolOptions) -> Value { if diff.is_empty() { Value::Null } else { Value::Object(diff) } } -/// Remember the tool options in the preferences once the user lets go of the pointer. Cheap -/// when nothing changed: one struct compare per frame. Run every frame, so options set by the -/// control channel or a tool preset are remembered too. +/// Remember the selected tool and its options once the user lets go of the pointer. Run every +/// frame, so choices made through the control channel or a tool preset are remembered too. pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { - if ctx.input(|i| i.pointer.any_down()) || app.prefs_rt.tool_options.as_ref() == Some(&app.ui.tool_options) { + if ctx.input(|i| i.pointer.any_down()) { + return; + } + let tool = selected(app); + if app.prefs_rt.selected_tool != Some(tool) { + app.prefs_rt.selected_tool = Some(tool); + if let Ok(now) = serde_json::to_value(tool) + && app.session.prefs().selected_tool != now + { + app.session.prefs.edit(|p| p.selected_tool = now); + // prefs_ui::tick ran before the panels; ensure it can flush this new revision. + ctx.request_repaint(); + } + } + if app.prefs_rt.tool_options.as_ref() == Some(&app.ui.tool_options) { return; } app.prefs_rt.tool_options = Some(app.ui.tool_options.clone()); @@ -35,8 +68,25 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { } } -/// Restore the remembered tool options at launch, field by field over the current ones. +/// Flush the final choice on normal exit, even if no further frame ran after the tool changed. +pub(crate) fn save_on_exit(app: &mut PhotocraftApp) -> Result<(), String> { + persist(app, &egui::Context::default()); + crate::prefs_ui::save_preferences(app) +} + +/// Restore the selected tool before the first frame and its options field by field. pub fn restore(app: &mut PhotocraftApp) { + let saved = app.session.prefs().selected_tool.clone(); + if !saved.is_null() { + match serde_json::from_value::(saved) { + Ok(tool) => { + app.ui.tool = tool; + app.ui.remember_group_tool(); + } + Err(err) => log::warn!("Preferences: ignoring the remembered selected tool: {err}"), + } + } + app.prefs_rt.selected_tool = Some(app.ui.tool); let saved = app.session.prefs().tool_options.clone(); if let Some(saved) = saved.as_object() && let Ok(Value::Object(mut cur)) = serde_json::to_value(&app.ui.tool_options) diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 8de3fe4ec7..3c154d3a45 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -8,9 +8,10 @@ //! `ui.dialog.cancel` answer it); `{"confirmed": true}` skips it, for agents that already asked. //! //! What a tool's options are: [`ToolOptions`] is one struct, not one per tool, and a few fields -//! are shared by the tools that show them (Tolerance, Anti-alias, Contiguous and Sample All Layers -//! by the Magic Wand, Paint Bucket and Magic Eraser; Feather by the marquees and lassos; Strength -//! by Blur, Sharpen and Smudge). Resetting one of those tools resets the shared field too. +//! are shared by the tools that show them (Tolerance, Anti-alias and Contiguous by the Magic Wand, +//! Paint Bucket and Magic Eraser; Feather by the marquees and lassos; Strength by Blur, Sharpen and +//! Smudge). Resetting one of those tools resets the shared field too. Sample All Layers is kept per +//! tool, as in Photoshop, so resetting a tool resets only its own. //! [`reset_options`] lists the fields of each tool. The selection tools also go back to New //! Selection, and the painting tools (the ones with a brush in the options bar) get the default //! brush, keeping the colours. Tools without options (Hand, Rotate View, Ruler, Note, …) have @@ -113,9 +114,46 @@ fn is_selection_tool(tool: Tool) -> bool { /// Put `tool`'s fields of `o` back to their defaults. pub fn reset_options(o: &mut ToolOptions, tool: Tool) { + let keys = option_keys(tool); + if keys.is_empty() { + return; + } + let (Ok(Value::Object(mut cur)), Ok(Value::Object(d))) = (serde_json::to_value(&*o), serde_json::to_value(ToolOptions::default())) else { + return; + }; + for k in keys { + if let Some(v) = d.get(*k) { + cur.insert((*k).to_string(), v.clone()); + } + } + if let Ok(n) = serde_json::from_value(Value::Object(cur)) { + *o = n; + } + // Sample All Layers is each tool's own (#5524): only `tool`'s goes back. let d = ToolOptions::default(); + if let Some(all) = o.sample_all.slot(tool) { + *all = d.sample_all.get(tool); + } +} + +/// The serialized name of `tool`'s own Sample All Layers inside `sample_all`, if it has one: a +/// tool preset stores that one alone, not the other tools' (#5524, #7356). +pub fn sample_all_key(tool: Tool) -> Option { + let mut probe = crate::state::SampleAllLayers::default(); + *probe.slot(tool)? = true; + let Ok(Value::Object(m)) = serde_json::to_value(probe) else { return None }; + m.into_iter().find(|(_, v)| v.as_bool() == Some(true)).map(|(k, _)| k) +} + +/// The [`ToolOptions`] fields `tool` shows, by their serialized names: what Reset Tool resets and +/// what a tool preset made with + stores (#7356). +pub fn option_keys(tool: Tool) -> &'static [&'static str] { + // `reset!` names each field once; one that is misspelt or gone fails to compile. macro_rules! reset { - ($($f:ident),* $(,)?) => {{ $( o.$f = d.$f.clone(); )* }}; + ($($f:ident),* $(,)?) => {{ + let _ = |o: &ToolOptions| { $(let _ = &o.$f;)* }; + &[$(stringify!($f)),*] + }}; } match tool { Tool::Move => reset!(move_auto_select, move_target, move_show_transform), @@ -124,12 +162,13 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { Tool::SingleRowMarquee | Tool::SingleColumnMarquee => reset!(feather), Tool::Lasso | Tool::PolygonLasso => reset!(feather, anti_alias), Tool::MagneticLasso => reset!(feather, anti_alias, magnetic_width, magnetic_contrast, magnetic_frequency, magnetic_pressure), - Tool::MagicWand => reset!(tolerance, anti_alias, contiguous, sample_all_layers), - Tool::QuickSelection => reset!(sample_all_layers, enhance_edge), - Tool::ObjectSelection => reset!(sample_all_layers, enhance_edge, object_finder), + Tool::MagicWand => reset!(tolerance, anti_alias, contiguous), + Tool::QuickSelection => reset!(enhance_edge), + Tool::ObjectSelection => reset!(enhance_edge, object_finder), Tool::Crop => reset!( crop_ratio, crop_delete, + crop_content_aware, crop_width, crop_height, crop_resolution, @@ -143,23 +182,26 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { ), Tool::PerspectiveCrop => reset!(crop_width, crop_height, crop_resolution, crop_resolution_unit, perspective_crop_grid), Tool::Eyedropper => reset!(eyedropper_size, eyedropper_sample, eyedropper_ring), - Tool::Pencil => reset!(pencil_auto_erase), + Tool::Brush => reset!(brush_clear), + Tool::Pencil => reset!(pencil_auto_erase, pencil_clear), + Tool::Eraser => reset!(eraser_mode), + Tool::Remove => reset!(remove_new_layer, remove_after_each_stroke), Tool::BackgroundEraser => reset!(bg_sampling, bg_limits, bg_tolerance, bg_protect_fg), Tool::ColorReplacement => reset!(cr_mode, cr_sampling, cr_limits, cr_tolerance, cr_anti_alias), - Tool::MagicEraser => reset!(tolerance, anti_alias, contiguous, sample_all_layers, magic_eraser_opacity), + Tool::MagicEraser => reset!(tolerance, anti_alias, contiguous, magic_eraser_opacity), Tool::Gradient => reset!(gradient_style, gradient_reverse, gradient_dither, gradient_classic, gradient_blend_mode, fill_opacity), - Tool::PaintBucket => reset!(bucket_fill_pattern, fill_opacity, tolerance, anti_alias, contiguous, sample_all_layers), + Tool::PaintBucket => reset!(bucket_fill_pattern, fill_opacity, tolerance, anti_alias, contiguous), Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => reset!(type_font, type_style, type_size, type_aa, type_align), Tool::Zoom => reset!(zoom_scrubby), - Tool::SpotHealing => reset!(spot_type, sample_all_layers), + Tool::SpotHealing => reset!(spot_type), Tool::Healing => reset!(clone_aligned, clone_sample), - Tool::Patch => reset!(patch_mode, patch_content_aware, patch_structure, patch_color, sample_all_layers), - Tool::ContentAwareMove => reset!(cam_mode, cam_structure, cam_color, sample_all_layers), + Tool::Patch => reset!(patch_mode, patch_content_aware, patch_structure, patch_color), + Tool::ContentAwareMove => reset!(cam_mode, cam_structure, cam_color, cam_transform_on_drop), Tool::RedEye => reset!(red_eye_pupil_size, red_eye_darken), Tool::CloneStamp => reset!(clone_aligned, clone_sample), Tool::PatternStamp => reset!(pattern_stamp_aligned, pattern_stamp_impressionist, pattern_stamp_scale, pattern_stamp_angle), - Tool::Blur | Tool::Sharpen => reset!(strength, sample_all_layers, protect_detail), - Tool::Smudge => reset!(strength, sample_all_layers, finger_painting), + Tool::Blur | Tool::Sharpen => reset!(strength, protect_detail), + Tool::Smudge => reset!(strength, finger_painting), Tool::Dodge | Tool::Burn => reset!(tone_range, exposure, protect_tones), Tool::Sponge => reset!(sponge_mode, vibrance), Tool::Pen => reset!(vector_mode, pen_auto_add_delete), @@ -172,12 +214,9 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { | Tool::Ruler | Tool::Note | Tool::Count - | Tool::Brush | Tool::MixerBrush - | Tool::Eraser | Tool::Hand | Tool::RotateView - | Tool::Remove | Tool::HistoryBrush | Tool::PathSelection | Tool::DirectSelection @@ -187,7 +226,7 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { | Tool::DeleteAnchorPoint | Tool::ConvertPoint | Tool::Artboard - | Tool::Frame => {} + | Tool::Frame => &[], } } @@ -201,6 +240,22 @@ mod tests { PhotocraftApp::new(s, crate::Services::default()) } + /// Every option belongs to a tool, so Reset Tool and tool presets (#7356) miss none, and every + /// listed name is a real serialized field. `sample_all` holds one Sample All Layers per tool, + /// which `reset_options` and `sample_all_key` handle tool by tool (#5524). + #[test] + fn every_option_belongs_to_a_tool() { + let all = serde_json::to_value(ToolOptions::default()).unwrap(); + let all = all.as_object().unwrap(); + let listed: std::collections::BTreeSet<&str> = Tool::ALL.iter().flat_map(|t| option_keys(*t).iter().copied()).collect(); + for k in all.keys().filter(|k| *k != "sample_all") { + assert!(listed.contains(k.as_str()), "{k} is no tool's option"); + } + for k in listed { + assert!(all.contains_key(k), "{k} is not a ToolOptions field"); + } + } + /// Reset Tool resets only the current tool's options; Reset All Tools asks, then resets all. #[test] fn reset_tool_and_reset_all_tools() { @@ -225,4 +280,30 @@ mod tests { assert_eq!(app.ui.tool_options, ToolOptions::default()); assert!(app.ui.dialogs.is_empty()); } + + /// Sample All Layers is per tool (#5524): resetting Blur turns off only Blur's. + #[test] + fn reset_tool_resets_only_its_own_sample_all_layers() { + let mut o = ToolOptions::default(); + o.sample_all.blur = true; + o.sample_all.magic_wand = true; + reset_options(&mut o, Tool::Blur); + assert!(!o.sample_all.blur); + assert!(o.sample_all.magic_wand, "the Magic Wand's Sample All Layers is not Blur's"); + reset_options(&mut o, Tool::Remove); + assert!(o.sample_all.magic_wand); + reset_options(&mut o, Tool::MagicWand); + assert_eq!(o.sample_all, Default::default()); + } + + /// Every tool with a Sample All Layers names its own key, and a tool without one names none. + #[test] + fn sample_all_key_names_the_tools_own() { + assert_eq!(sample_all_key(Tool::MagicWand).as_deref(), Some("magic_wand")); + assert_eq!(sample_all_key(Tool::Blur).as_deref(), Some("blur")); + assert_eq!(sample_all_key(Tool::Brush), None); + let keys: Vec = Tool::ALL.iter().filter_map(|t| sample_all_key(*t)).collect(); + let distinct: std::collections::BTreeSet<&String> = keys.iter().collect(); + assert_eq!((keys.len(), distinct.len()), (12, 12), "one key per tool with the option"); + } } diff --git a/crates/ui-egui/src/tool_shortcuts.rs b/crates/ui-egui/src/tool_shortcuts.rs index 345b7f57b3..91f66a0f07 100644 --- a/crates/ui-egui/src/tool_shortcuts.rs +++ b/crates/ui-egui/src/tool_shortcuts.rs @@ -94,3 +94,17 @@ mod tests { assert_eq!(h.state().ui.status, "q"); } } + +#[cfg(test)] +mod engine_table_tests { + use super::*; + + /// The engine knows the tools as data (headless shortcut sets); it must match the toolbar. + #[test] + fn the_engines_tool_table_matches_the_tools() { + let ours: Vec<(String, &str, Option)> = Tool::ALL.into_iter().map(|t| (id(t), t.label(), default(t))).collect(); + let engine: Vec<(String, &str, Option)> = + photocraft_engine::shell_commands::TOOLS.iter().map(|(i, n, k)| (i.to_string(), *n, k.map(str::to_string))).collect(); + assert_eq!(ours, engine, "update photocraft_engine::shell_commands::TOOLS"); + } +} diff --git a/crates/ui-egui/src/transform_switch_tests.rs b/crates/ui-egui/src/transform_switch_tests.rs new file mode 100644 index 0000000000..f5be113d2a --- /dev/null +++ b/crates/ui-egui/src/transform_switch_tests.rs @@ -0,0 +1,223 @@ +//! Free Transform with the whole app: picking another tool or layer, or New Layer, while the box +//! is open applies the transform as it stands, as Photoshop does (#5441, #5665, #5667). + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::PhotocraftApp; + +/// The app with a 400 × 300 document: a red 100 × 60 rectangle at (100, 100) on its own layer +/// above the Background. +fn app() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app + }); + h.run_steps(6); + h +} + +/// Open Free Transform and move the box 50 px right, 40 px down. +fn transform_and_move(h: &mut Harness<'static, PhotocraftApp>) { + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "edit.freeTransform", json!({})).unwrap(); + h.run_steps(2); + let t = h.state_mut().ui.transform.as_mut().expect("Free Transform is open"); + for p in &mut t.quad { + p[0] += 50.0; + p[1] += 40.0; + } + h.run_steps(2); +} + +fn red_bounds(h: &Harness<'static, PhotocraftApp>) -> photocraft_geom::Rect { + let st = h.state().session.active().unwrap(); + let top = st.doc.layers.last().unwrap(); + top.surface().unwrap().content_bounds() +} + +#[test] +fn picking_another_tool_in_the_toolbar_applies_the_transform() { + let mut h = app(); + transform_and_move(&mut h); + let hand = h.query_by_label_contains("Hand Tool").expect("the toolbar's Hand Tool"); + hand.click(); + h.run_steps(4); + assert!(h.state().ui.transform.is_none(), "the box closed"); + assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200), "the move was applied, not cancelled"); +} + +/// The app with the red rectangle on a layer the last step made (Layer via Copy, which deselects in +/// the same step), as Place Embedded leaves it for the box `begin_placed` opens: cancelling the +/// place takes that step back. +fn placed_app() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("layer.new.layerViaCopy", json!({})).unwrap(); + app + }); + h.run_steps(6); + assert!(h.state().session.active().unwrap().doc.selection.is_none(), "Layer via Copy deselects in its own step"); + h +} + +fn history(h: &Harness<'static, PhotocraftApp>) -> Vec { + h.state().session.active().unwrap().history.entries().to_vec() +} + +/// The box on a just-placed layer (a file dropped on the canvas), moved. +fn placed_and_moved(h: &mut Harness<'static, PhotocraftApp>) { + let ctx = h.ctx.clone(); + crate::transform_tool::begin_placed(h.state_mut(), &ctx).unwrap(); + h.run_steps(2); + let t = h.state_mut().ui.transform.as_mut().expect("Free Transform is open"); + for p in &mut t.quad { + p[0] += 50.0; + p[1] += 40.0; + } + h.run_steps(2); +} + +#[test] +fn a_placed_image_is_applied_when_another_tool_is_picked() { + let mut h = placed_app(); + placed_and_moved(&mut h); + h.query_by_label_contains("Hand Tool").expect("the toolbar's Hand Tool").click(); + h.run_steps(4); + assert!(h.state().ui.transform.is_none()); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays"); + assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200)); +} + +#[test] +fn picking_another_layer_in_the_panel_applies_the_transform() { + for placed in [false, true] { + let mut h = if placed { placed_app() } else { app() }; + if placed { + placed_and_moved(&mut h); + } else { + transform_and_move(&mut h); + } + h.get_by_label("Background").click(); + h.run_steps(4); + assert!(h.state().ui.transform.is_none(), "placed {placed}"); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "placed {placed}"); + assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200), "placed {placed}"); + } +} + +#[test] +fn new_layer_applies_the_transform_first() { + for placed in [false, true] { + let mut h = if placed { placed_app() } else { app() }; + let before = history(&h); + if placed { + placed_and_moved(&mut h); + } else { + transform_and_move(&mut h); + } + // The Layers panel's New Layer button (the menu item opens the New Layer dialog). + h.get_by_label("Create a new layer").click(); + h.run_steps(4); + assert!(h.state().ui.transform.is_none(), "placed {placed}"); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.layers.len(), 3, "placed {placed}"); + let red = st.doc.layers[1].surface().unwrap().content_bounds(); + assert_eq!(red, photocraft_geom::Rect::new(150, 140, 250, 200), "placed {placed}: the moved image, not the original"); + // History: the transform (folded into Place Embedded for a placed layer), then New Layer. + let after = history(&h); + let added: Vec<&str> = after.iter().skip(before.len().saturating_sub(usize::from(placed))).map(String::as_str).collect(); + let first = if placed { photocraft_engine::file_cmds::PLACE_EMBEDDED } else { "Free Transform" }; + assert_eq!(added, [first, "New Layer"], "placed {placed}: {after:?}"); + } +} + +#[test] +fn an_untouched_placed_image_stays_when_another_tool_or_layer_is_picked() { + for via_layer in [false, true] { + let mut h = placed_app(); + let ctx = h.ctx.clone(); + crate::transform_tool::begin_placed(h.state_mut(), &ctx).unwrap(); + h.run_steps(2); + if via_layer { + let bg = h.state().session.active().unwrap().doc.layers[0].id; + h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap(); + } else { + h.query_by_label_contains("Hand Tool").expect("Hand Tool").click(); + } + h.run_steps(4); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.layers.len(), 2, "via layer {via_layer}: the placed image stays"); + assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(100, 100, 200, 160)); + } +} + +/// The placed fixture is a real place: cancelling the box takes the layer back, so the tests +/// above would see a switch that cancelled it. +#[test] +fn cancelling_the_placed_fixture_removes_its_layer() { + let mut h = placed_app(); + placed_and_moved(&mut h); + crate::transform_tool::cancel(h.state_mut()); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 1); +} + +/// A new-layer command that can't run (Artboard from Group with a pixel layer active) leaves the box and the +/// placed layer as they were. +#[test] +fn a_new_layer_command_that_cant_run_leaves_the_box_open() { + let mut h = placed_app(); + placed_and_moved(&mut h); + let before = history(&h); + assert!(h.state_mut().run("layer.new.artboardFromGroup", json!({})).is_err()); + h.run_steps(2); + assert!(h.state().ui.transform.is_some(), "the box stays open"); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays"); + assert_eq!(history(&h), before); +} + +/// A transform the engine refuses (a result far over the pixel budget, #3773) keeps the box open +/// with the placed layer, and the new layer isn't made: the refusal comes back. +#[test] +fn a_refused_transform_keeps_the_placed_layer_and_refuses_the_new_layer() { + let mut h = placed_app(); + placed_and_moved(&mut h); + let t = h.state_mut().ui.transform.as_mut().unwrap(); + t.quad = [[0.0, 0.0], [4.0e6, 0.0], [4.0e6, 4.0e6], [0.0, 4.0e6]]; + h.run_steps(2); + let before = history(&h); + let r = h.state_mut().run("layer.new.layer", json!({})); + h.run_steps(2); + assert!(r.is_err(), "{r:?}"); + assert!(h.state().ui.transform.is_some(), "the box stays open"); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays, no new layer"); + assert_eq!(history(&h), before); +} + +/// New Layer with bad params, while a moved box is open, is refused before the transform is +/// applied: the box, the layer and History stay as they were. +#[test] +fn new_layer_with_bad_params_leaves_the_box_open() { + for bad in [json!({"mode": "Bogus"}), json!({"fillNeutral": true}), json!({"position": "x"}), json!([1])] { + let mut h = app(); + transform_and_move(&mut h); + let before = history(&h); + assert!(h.state_mut().run("layer.new.layer", bad.clone()).is_err(), "{bad}"); + h.run_steps(2); + assert!(h.state().ui.transform.is_some(), "{bad}: the box stays open"); + assert_eq!(history(&h), before, "{bad}: nothing applied"); + assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(100, 100, 200, 160), "{bad}"); + } +} diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 81fc0fadcf..7ae223029d 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -145,14 +145,32 @@ struct Steps { settled: Option, undo: Vec, redo: Vec, + /// When the latest arrow-key nudge came (egui time) and the box it left. + nudged: Option<(f64, Step)>, + /// The nudge that moved the box since it last came to rest follows another one closely: it + /// joins that one's step instead of adding its own (#2259). + fold: bool, } impl Steps { + /// An arrow-key nudge at `now` moved the box from `before` to `after`. With `pause` (seconds, + /// `None`: nudges aren't bundled) it folds into the previous nudge's step when it came within + /// that time of it and found the box as that nudge left it, so a drag, an option edit, Undo + /// or Redo in between start a step of their own. + fn nudged(&mut self, before: &Step, after: Step, now: f64, pause: Option) { + self.fold = pause.is_some_and(|w| self.nudged.as_ref().is_some_and(|(at, left)| left == before && now >= *at && now - at <= w)); + self.nudged = pause.map(|_| (now, after)); + } + /// Record a step if the box changed since it last came to rest. fn settle(&mut self, now: Step) { if self.settled.as_ref() == Some(&now) { return; } + if std::mem::take(&mut self.fold) && self.settled.is_some() { + self.settled = Some(now); + return; + } if let Some(prev) = self.settled.replace(now) { self.undo.push(prev); self.redo.clear(); @@ -195,7 +213,8 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> if matches!(layer.content, LayerContent::Adjustment(_)) && layer.mask.is_none() { return Err("Adjustment layers have nothing to transform".into()); } - let b = photocraft_engine::transform_cmds::transform_bounds(&doc, layer); + // With a selection the box is the selection's (#5297). + let b = photocraft_engine::transform_cmds::transform_frame(&doc, layer); if b.is_empty() { return Err("Could not transform: the layer is empty".into()); } @@ -410,7 +429,7 @@ fn fold_made(app: &mut PhotocraftApp, document: DocId, made: MadeLayer) { } /// Free Transform of a targeted unlinked layer mask, alpha channel or Quick Mask by itself: the -/// box frames its content (within the selection) and previews the moving values in grey over the +/// box frames its content (or the selection) and previews the moving values in grey over the /// document with them vacated. fn begin_lone( app: &mut PhotocraftApp, @@ -420,7 +439,7 @@ fn begin_lone( target: serde_json::Value, ) -> Result<(), String> { use photocraft_engine::transform_cmds as tc; - let b = tc::target_bounds(&doc, &surf); + let b = tc::target_frame(&doc, &surf); if b.is_empty() { return Err("Could not transform: nothing is selected".into()); } @@ -773,9 +792,13 @@ fn preview_image( (None, Some(s)) if layer.is_some_and(|l| matches!(l.content, LayerContent::Raster(_))) => Some(s), _ => { let mut solo = doc.clone(); - for l in solo.layers.iter_mut() { - if l.id != id && doc.layer(id).is_some_and(|_| !contains(l, id)) { - l.visible = false; + if layer.is_some() { + solo_layer(&mut solo.layers, id); + } else { + // Keep the previous fallback for an invalid id: render the document, with only + // top-level clipping disabled. Production callers get ids from this document. + for l in &mut solo.layers { + l.clipped = false; } } if let Some(l) = solo.layer_mut(id).filter(|_| bare) { @@ -966,6 +989,26 @@ fn show_only(layers: &mut [photocraft_doc::Layer], keep: &[LayerId]) { } } +/// Leave only `id`, its descendants and the groups holding it visible. Every layer on that path +/// is detached from its clipping base because the base is hidden in this isolated render (#5239). +/// Hide every layer but `id` and the groups holding it, and unclip those, so a render shows that +/// layer alone (the Free Transform preview, Liquify's one-layer backdrop). +pub(crate) fn solo_layer(layers: &mut [photocraft_doc::Layer], id: LayerId) { + for l in layers { + if l.id == id { + l.clipped = false; + continue; + } + match &mut l.content { + LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id)) => { + l.clipped = false; + solo_layer(&mut g.children, id); + } + _ => l.visible = false, + } + } +} + fn contains(l: &photocraft_doc::Layer, id: LayerId) -> bool { l.id == id || matches!(&l.content, LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id))) } @@ -1187,11 +1230,29 @@ fn start_steps(app: &mut PhotocraftApp) { pub fn track_steps(app: &mut PhotocraftApp, ctx: &egui::Context) { let (Some(t), Some(pv)) = (&app.ui.transform, app.transform_preview.as_mut()) else { return }; if pv.gesture.is_some() || pv.warp_drag.is_some() || ctx.input(|i| i.pointer.any_down()) || ctx.text_edit_focused() { + // Whatever comes to rest after this is more than the nudge. + pv.steps.fold = false; return; } pv.steps.settle(Step::of(t)); } +/// Arrow keys while Free Transform is active: move the box (and its pivot) by `(dx, dy)`. +/// Nudges that follow each other closely are one Undo step of the session (`nudge_bundle`). +pub(crate) fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64) { + let (pause, now) = (crate::nudge_bundle::pause(app), app.nudge_bundle.now); + let Some(t) = app.ui.transform.as_mut().filter(|t| t.warp.is_none()) else { return }; + let before = Step::of(t); + t.quad = t.quad.map(|q| [q[0] + dx, q[1] + dy]); + t.pivot = [t.pivot[0] + dx, t.pivot[1] + dy]; + if let Some(pv) = app.transform_preview.as_mut() { + pv.steps.nudged(&before, Step::of(t), now, pause); + } +} + +/// Save for Web (Legacy): refused under an open box, as it would export without the preview (#6737). +pub(crate) const SAVE_FOR_WEB: &str = "file.export.saveForWebLegacy"; + /// While transforming, Undo and Redo apply to the box: `Some(enabled)` for those commands (and /// Toggle Last State, which would change the document under the box), `None` for the rest. pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { @@ -1208,6 +1269,8 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { "edit.redo" => Some(steps.is_some_and(|s| !s.redo.is_empty())), // No Toggle Last State or history jump under the box, and one box at a time. "edit.toggleLastState" | "history.selectState" | "edit.contentAwareScale" | "edit.puppetWarp" => Some(false), + // Save for Web exports the committed document and would ignore the pending box (#6737). + SAVE_FOR_WEB => Some(false), // Rotate and Flip turn the box (not while warping, where the mesh is the box). _ if box_preset(id).is_some() => Some(t.warp.is_none()), // Editable type can't be distorted (#2630, as #2701 rules for the menus outside a box). @@ -1247,7 +1310,7 @@ fn box_preset(id: &str) -> Option<(f64, f64, f64)> { /// `None` for other commands or when not transforming. /// The commands the open box answers itself ([`is_enabled`], [`intercept`]). fn box_command(id: &str) -> bool { - matches!(id, "edit.undo" | "edit.redo" | "edit.toggleLastState" | "edit.contentAwareScale" | "edit.puppetWarp") || box_preset(id).is_some() + matches!(id, "edit.undo" | "edit.redo" | "edit.toggleLastState" | "edit.contentAwareScale" | "edit.puppetWarp" | SAVE_FOR_WEB) || box_preset(id).is_some() } pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option> { @@ -1258,7 +1321,7 @@ pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option Some(Ok(step(app, false))), "edit.redo" => Some(Ok(step(app, true))), - "edit.toggleLastState" | "history.selectState" | "edit.contentAwareScale" | "edit.puppetWarp" => { + "edit.toggleLastState" | "history.selectState" | "edit.contentAwareScale" | "edit.puppetWarp" | SAVE_FOR_WEB => { Some(Err("Commit or cancel the transform first".into())) } _ => { @@ -1354,7 +1417,6 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> warp_pointer(app, ev, tol, mods); return true; } - let no_distort = distort_refused(app, &t); let Some(pv) = app.transform_preview.as_mut() else { return false }; match ev { ToolEvent::Down { x, y, .. } => { @@ -1385,28 +1447,82 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> commit(app); return true; } - let legacy = app.session.prefs().general.use_legacy_free_transform; - let unlinked = app.ui.transform_unlinked; let type_layer = moves_type(app, &t); - if let (Some(g), Some(s)) = (g, app.ui.transform.as_mut()) + let adjusted = g.map(|g| drag_mods(app, &t, g.hit, mods)); + if let (Some(g), Some((mode, mods)), Some(s)) = (g, adjusted, app.ui.transform.as_mut()) && mode_allows(t.mode, g.hit) && (t.content_aware.is_none() || g.hit != Hit::Outside) { - let (mode, mods) = if no_distort { scale_not_distort(t.mode, g.hit, mods) } else { (t.mode, mods) }; if t.content_aware.is_some() { - let mut mods = mode_mods(mode, g.hit, legacy_mods(legacy, g.hit, unlinked_mods(unlinked, legacy, g.hit, mods))); + let mut mods = mode_mods(mode, g.hit, mods); // Content-Aware Scale: ⌘ neither skews nor distorts. mods.command = false; apply_drag(s, g, [x, y], mods); } else { - apply_mode_drag(s, g, [x, y], mode, type_layer, legacy_mods(legacy, g.hit, unlinked_mods(unlinked, legacy, g.hit, mods))); + apply_mode_drag(s, g, [x, y], mode, type_layer, mods); } + round_to_pixel_grid(app); } } } + crate::snap_ui::retain_transform_lines(app, mods); true } +/// Preferences › Tools › Snap Vector Tools and Transforms to Pixel Grid (#5751): while a shape +/// layer or a path is moved or scaled (the box still an upright rectangle), its edges stay on +/// whole pixels, whatever the tool. Rounding the pointer alone left the edges off by the grab +/// offset, and a proportional scale off by its ratio: a shape dragged to 540 px came out 539.6. +/// Rotated, skewed and distorted boxes are left as dragged. +fn round_to_pixel_grid(app: &mut PhotocraftApp) { + if !app.session.prefs().tools.snap_vector_tools_and_transforms_to_pixel_grid { + return; + } + let Some(t) = app.ui.transform.as_ref() else { return }; + if t.selection || t.warp.is_some() || t.content_aware.is_some() || t.target.is_some() { + return; + } + let vector = + t.path.is_some() || app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Shape(_))); + if !vector || !upright(&t.quad) { + return; + } + let q = t.quad.map(|p| p.map(f64::round)); + // A box narrower than a pixel keeps its size rather than collapsing. + if (q[1][0] - q[0][0]).abs() < 1.0 || (q[3][1] - q[0][1]).abs() < 1.0 || q.iter().flatten().any(|v| !v.is_finite()) { + return; + } + if let Some(s) = app.ui.transform.as_mut() { + s.quad = q; + } +} + +/// Whether corners clockwise from top-left form an axis-aligned rectangle (flips included). +fn upright(q: &[[f64; 2]; 4]) -> bool { + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + near(q[0][1], q[1][1]) && near(q[2][1], q[3][1]) && near(q[0][0], q[3][0]) && near(q[1][0], q[2][0]) +} + +/// The same effective modifiers drive the geometry and decide whether Ctrl is a distortion +/// gesture rather than the ordinary snap override. Keep type and Content-Aware Scale refusals. +fn drag_mods(app: &PhotocraftApp, t: &TransformSession, hit: Hit, mods: egui::Modifiers) -> (TransformMode, egui::Modifiers) { + let (mode, mods) = if distort_refused(app, t) { scale_not_distort(t.mode, hit, mods) } else { (t.mode, mods) }; + let legacy = app.session.prefs().general.use_legacy_free_transform; + (mode, legacy_mods(legacy, hit, unlinked_mods(app.ui.transform_unlinked, legacy, hit, mods))) +} + +/// Only a freely distorted corner can snap its proposed position. Perspective moves paired +/// corners on one axis; Skew, Warp and Content-Aware Scale have their own constrained geometry. +pub(crate) fn corner_distorts(app: &PhotocraftApp, index: usize, mods: egui::Modifiers) -> bool { + let Some(t) = app.ui.transform.as_ref() else { return false }; + if t.quad.get(index).is_none() || t.warp.is_some() || t.content_aware.is_some() || t.mode == TransformMode::Skew { + return false; + } + let (mode, mods) = drag_mods(app, t, Hit::Corner(index), mods); + let mods = mode_mods(mode, Hit::Corner(index), mods); + mods.command && !(mods.alt && mods.shift) +} + /// Editable type can be scaled, rotated and skewed but not distorted (#2630): the rule that greys /// Distort and Perspective in `enable_rules::disabled`, for the box's own layer. A box on a path, /// on the selection outline or on a lone mask or channel moves no type. @@ -1662,8 +1778,7 @@ pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods let Some(inv) = h.inverse() else { return }; let (u, v) = inv.apply(p[0], p[1]); let (pu, pv) = inv.apply(g.pivot0[0], g.pivot0[1]); - // Moving edges/corner in unit space: [u0, v0, u1, v1] starting as [0, 0, 1, 1]. - let mut r = [0.0f64, 0.0, 1.0, 1.0]; + // Which sides of the unit box move: (u = 0, u = 1) and (v = 0, v = 1). let (mu, mv) = match g.hit { Hit::Corner(0) => ((true, false), (true, false)), Hit::Corner(1) => ((false, true), (true, false)), @@ -1674,33 +1789,21 @@ pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods Hit::Edge(2) => ((false, false), (false, true)), _ => ((true, false), (false, false)), }; - if mu.0 { - r[0] = u; - } - if mu.1 { - r[2] = u; - } - if mv.0 { - r[1] = v; - } - if mv.1 { - r[3] = v; - } - // ⌥: symmetric about the reference point. - if mods.alt { - if mu.0 { - r[2] = 2.0 * pu - r[0]; + // Each axis scales by a factor `k` about an anchor: the opposite side, or with ⌥ the + // reference point, wherever it was set (#1329). The dragged handle follows the cursor. + let axis = |moving: (bool, bool), at: f64, pivot: f64| -> (f64, f64) { + if !moving.0 && !moving.1 { + return (0.5, 1.0); } - if mu.1 { - r[0] = 2.0 * pu - r[2]; - } - if mv.0 { - r[3] = 2.0 * pv - r[1]; - } - if mv.1 { - r[1] = 2.0 * pv - r[3]; - } - } + let handle = if moving.0 { 0.0 } else { 1.0 }; + let anchor = if mods.alt { pivot } else { 1.0 - handle }; + let d = handle - anchor; + // A reference point on the dragged side leaves nothing to scale against. + let k = if d.abs() > 1e-9 { (at - anchor) / d } else { 1.0 }; + (anchor, if k.is_finite() { k } else { 1.0 }) + }; + let (mut au, mut ku) = axis(mu, u, pu); + let (mut av, mut kv) = axis(mv, v, pv); // Corners and edges scale proportionally by default; ⇧ frees them (the legacy // preference swaps the two for corners and always stretches edges, `legacy_mods`). let corner = matches!(g.hit, Hit::Corner(_)); @@ -1710,41 +1813,16 @@ pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods // corners), so the opposite edge's handle stays put. Past the opposite edge only // the dragged axis flips. if mu.0 || mu.1 { - let k = (r[2] - r[0]).abs(); - let c = if mods.alt { pv } else { 0.5 }; - r[1] = c - k / 2.0; - r[3] = c + k / 2.0; + (av, kv) = (if mods.alt { pv } else { 0.5 }, ku.abs()); } else { - let k = (r[3] - r[1]).abs(); - let c = if mods.alt { pu } else { 0.5 }; - r[0] = c - k / 2.0; - r[2] = c + k / 2.0; + (au, ku) = (if mods.alt { pu } else { 0.5 }, kv.abs()); } } if corner && !mods.shift { - let (sx, sy) = (r[2] - r[0], r[3] - r[1]); - let k = if sx.abs() > sy.abs() { sx.abs() } else { sy.abs() }; - let (nx, ny) = (k * sx.signum(), k * sy.signum()); - let (ax, ay) = if mods.alt { (pu, pv) } else { (if mu.0 { r[2] } else { r[0] }, if mv.0 { r[3] } else { r[1] }) }; - let fx = if mods.alt { - 0.5 - } else if mu.0 { - 1.0 - } else { - 0.0 - }; - let fy = if mods.alt { - 0.5 - } else if mv.0 { - 1.0 - } else { - 0.0 - }; - r[0] = ax - nx * fx; - r[2] = r[0] + nx; - r[1] = ay - ny * fy; - r[3] = r[1] + ny; + let k = ku.abs().max(kv.abs()); + (ku, kv) = (k * ku.signum(), k * kv.signum()); } + let r = [au - au * ku, av - av * kv, au + (1.0 - au) * ku, av + (1.0 - av) * kv]; let map = |a: f64, b: f64| { let (x, y) = h.apply(a, b); [x, y] @@ -1802,9 +1880,8 @@ fn surface_at(mesh: &BezierMesh, p: [f64; 2]) -> Option<(f64, f64)> { } /// Ctrl-click (or Option-click) picks the split: crosswise in the open, and the perpendicular -/// split when the pointer is close to an existing grid line. -fn quick_split_kind(mesh: &BezierMesh, bounds: [f64; 4], p: [f64; 2]) -> SplitTool { - let (s, t) = mesh.param_at(p); +/// split when the pointer's grid position `(s, t)` is close to an existing grid line. +fn quick_split_kind(mesh: &BezierMesh, bounds: [f64; 4], (s, t): (f64, f64)) -> SplitTool { let w = (bounds[2] - bounds[0]).abs().max(1.0); let h = (bounds[3] - bounds[1]).abs().max(1.0); let su = (8.0 / w).clamp(0.02, 0.12); @@ -1820,12 +1897,18 @@ fn quick_split_kind(mesh: &BezierMesh, bounds: [f64; 4], p: [f64; 2]) -> SplitTo } } -fn split_cursor(tool: SplitTool) -> CursorIcon { - match tool { - SplitTool::Vertical => CursorIcon::ResizeHorizontal, - SplitTool::Horizontal => CursorIcon::ResizeVertical, - SplitTool::Cross => CursorIcon::Crosshair, - } +/// The cursor for a split armed at grid position `(s, t)` of warp grid `m`: the arrow across the +/// split line as it looks on screen. A vertical split line runs along the grid's columns and is +/// placed by moving across them, in the grid's own direction there, so the arrow turns with a +/// turned or bent grid and with the view. +fn split_cursor(m: &BezierMesh, tool: SplitTool, (s, t): (f64, f64), xf: &ViewXform) -> CursorIcon { + let e = 1e-3; + let (a, b) = match tool { + SplitTool::Vertical => (m.eval((s - e).max(0.0), t), m.eval((s + e).min(1.0), t)), + SplitTool::Horizontal => (m.eval(s, (t - e).max(0.0)), m.eval(s, (t + e).min(1.0))), + SplitTool::Cross => return CursorIcon::Crosshair, + }; + crate::tool_cursor::resize([b[0] - a[0], b[1] - a[1]], xf) } /// Armed split tool, or Option held: the guide tracks the pointer and the split lands on release. @@ -1858,7 +1941,10 @@ fn split_gesture(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) let kind = if let Some(tool) = armed { Some(tool) } else if quick { - app.ui.transform.as_ref().and_then(|t| t.warp.as_ref()).map(|w| quick_split_kind(&w.to_mesh(1, 1), w.bounds, [x, y])) + app.ui.transform.as_ref().and_then(|t| t.warp.as_ref()).map(|w| { + let m = w.to_mesh(1, 1); + quick_split_kind(&m, w.bounds, m.param_at([x, y])) + }) } else { None }; @@ -1997,20 +2083,22 @@ fn rotate_dir(t: &TransformSession, pv: Option<&TransformPreview>, p: [f64; 2], (l.is_finite() && l > 1e-9).then(|| [s[0] / l, s[1] / l]) } -/// Cursor for hovering a document point while transforming. `quick_split` is Ctrl or Option, -/// which arms a quick split while a warp is active. -pub fn cursor(app: &PhotocraftApp, p: [f64; 2], quick_split: bool) -> Option { +/// Cursor for hovering a document point while transforming. `xf` is the canvas's view; +/// `quick_split` is Ctrl or Option, which arms a quick split while a warp is active. +pub fn cursor(app: &PhotocraftApp, xf: &ViewXform, p: [f64; 2], quick_split: bool) -> Option { let t = app.ui.transform.as_ref()?; let tol = handle_tolerance(app); if let Some(w) = &t.warp { if warps_type(app) { return Some(CursorIcon::Default); } - if let Some(tool) = app.transform_preview.as_ref().and_then(|pv| pv.split_tool) { - return Some(split_cursor(tool)); - } - if quick_split { - return Some(split_cursor(quick_split_kind(&w.to_mesh(1, 1), w.bounds, p))); + let armed = app.transform_preview.as_ref().and_then(|pv| pv.split_tool); + if armed.is_some() || quick_split { + // One search for the grid position under the pointer, for both the kind and the arrow. + let m = w.to_mesh(1, 1); + let at = m.param_at(p); + let tool = armed.unwrap_or_else(|| quick_split_kind(&m, w.bounds, at)); + return Some(split_cursor(&m, tool, at, xf)); } if w.style != WarpStyle::Custom || warp_hit(w, p, tol).is_some() { return Some(CursorIcon::Crosshair); @@ -2025,11 +2113,17 @@ pub fn cursor(app: &PhotocraftApp, p: [f64; 2], quick_split: bool) -> Option CursorIcon::ResizeNwSe, - Hit::Corner(_) => CursorIcon::ResizeNeSw, - Hit::Edge(0 | 2) => CursorIcon::ResizeVertical, - Hit::Edge(_) => CursorIcon::ResizeHorizontal, + Hit::Corner(i) => resize(corner(i + 2), corner(i)), + Hit::Edge(i) => resize(edge_mid(i + 2), edge_mid(i)), Hit::Pivot => CursorIcon::Crosshair, Hit::Inside => CursorIcon::Move, Hit::Outside => CursorIcon::Alias, @@ -2045,19 +2139,14 @@ fn front_vertices(h: &Homography, n: usize) -> Vec { (0..=n).flat_map(|j| (0..=n).map(move |i| (i as f64 / n as f64, j as f64 / n as f64))).map(|(u, v)| front * h.w(u, v) > 1e-9).collect() } -/// Warped preview of the moving pixels plus the box, handles and reference point. -pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { +/// The moving pixels as the box shows them (warped, skewed or styled), plus the layers above +/// them. This is image content: the canvas draws it right after the document, under the grid +/// and guides, so a guide stays visible across a layer being transformed (#5086), as in +/// Photoshop. The box, handles and reference point are [`draw_overlay`], drawn over the guides. +pub fn draw_content(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let (Some(t), Some(pv)) = (&app.ui.transform, &app.transform_preview) else { return }; - let scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); if let Some(w) = &t.warp { - let over_canvas = painter.ctx().input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)).map(|p| xf.to_doc(p)); - let at = pv.split_pointer.or(over_canvas); - let alt = painter.ctx().input(|i| i.modifiers.alt || i.modifiers.command); - let guide = at.filter(|_| !warps_type(app)).and_then(|p| { - let tool = pv.split_tool.or_else(|| (alt || pv.split_quick).then(|| quick_split_kind(&w.to_mesh(1, 1), w.bounds, p))); - tool.map(|tool| (tool, p)) - }); - draw_warp(painter, xf, t, w, pv, guide); + draw_warp_content(painter, xf, t, w, pv); return; } let ctx = painter.ctx(); @@ -2105,6 +2194,29 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if let Some(a) = &pv.above { a.draw(painter, xf); } +} + +/// The box, handles, reference point and path outline over the preview that [`draw_content`] +/// drew; the warp grid and its split guides for a warp. +pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { + let (Some(t), Some(pv)) = (&app.ui.transform, &app.transform_preview) else { return }; + let scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); + if let Some(w) = &t.warp { + let over_canvas = painter.ctx().input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)).map(|p| xf.to_doc(p)); + let at = pv.split_pointer.or(over_canvas); + let alt = painter.ctx().input(|i| i.modifiers.alt || i.modifiers.command); + let guide = at.filter(|_| !warps_type(app)).and_then(|p| { + let tool = pv.split_tool.or_else(|| { + (alt || pv.split_quick).then(|| { + let m = w.to_mesh(1, 1); + quick_split_kind(&m, w.bounds, m.param_at(p)) + }) + }); + tool.map(|tool| (tool, p)) + }); + draw_warp_overlay(painter, xf, w, pv, guide); + return; + } if pv.committing.is_some() { return; } @@ -2150,6 +2262,72 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } } +/// Applying the open box moves the selection outline with it: `edit.transform` and +/// `edit.transform.warp` map it like the pixels. Not Transform Selection (its ants are hidden), a +/// path, Content-Aware Scale or a type layer's Warp Text, which leave the selection alone. +fn carries_selection(app: &PhotocraftApp, t: &TransformSession) -> bool { + !t.selection && t.path.is_none() && t.content_aware.is_none() && !(t.warp.is_some() && warps_type(app)) +} + +/// A transform of `document` is open that carries its selection along (#5297): the canvas traces +/// the whole outline, not only the part in view where it started, for [`draw_selection`]. +pub(crate) fn carries_selection_of(app: &PhotocraftApp, document: DocId) -> bool { + matches!((&app.ui.transform, &app.transform_preview), (Some(t), Some(pv)) if pv.doc.id == document && carries_selection(app, t)) +} + +/// The marching ants where the open box puts them (#5297): the selection follows a move, scale, +/// rotation, skew, distort, perspective or warp while it is made, to where applying it leaves it. +/// `segs` is the outline of `document`'s selection at rest. False when no open transform carries +/// that selection: the caller draws it where it is. +pub(crate) fn draw_selection( + app: &PhotocraftApp, + painter: &egui::Painter, + xf: &ViewXform, + document: DocId, + segs: &[crate::outline::Segment], + time: f64, +) -> bool { + let Some(t) = app.ui.transform.as_ref().filter(|_| carries_selection_of(app, document)) else { return false }; + let scr = |(x, y): (f64, f64)| xf.to_screen(x as f32, y as f32); + let doc = |p: [i32; 2]| [f64::from(p[0]), f64::from(p[1])]; + let along = |a: [f64; 2], b: [f64; 2], k: f64| [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k]; + if let Some(w) = &t.warp { + // A warp bends the outline's straight runs: each is drawn in pieces about 8 points long. + let piece = (8.0 / f64::from(xf.zoom.max(1e-3))).max(1.0); + let lines = segs.iter().flat_map(|(a, b)| { + let (a, b) = (doc(*a), doc(*b)); + let n = (((b[0] - a[0]).abs() + (b[1] - a[1]).abs()) / piece).ceil().clamp(1.0, 256.0) as usize; + let at = move |i: usize| { + let p = along(a, b, i as f64 / n as f64); + scr(w.map(p[0], p[1])) + }; + (0..n).map(move |i| (at(i), at(i + 1))) + }); + crate::canvas::marching_ants_lines(painter, lines, time); + } else if let Some(h) = Homography::rect_to_quad(t.rect, t.quad) { + // Past a Distort's horizon the outline has no place, like the pixels (#4863): a run that + // crosses it stops short of it, where its points are still a finite way off. + let front = h.front_side(t.rect); + let depth = |p: [f64; 2]| front * h.w(p[0], p[1]); + let near = corners(t.rect).iter().map(|c| depth(*c)).fold(0.0, f64::max) * 1e-3; + let lines = segs.iter().filter_map(|(a, b)| { + let (mut a, mut b) = (doc(*a), doc(*b)); + let (da, db) = (depth(a), depth(b)); + if da < near && db < near { + return None; + } + if da < near { + a = along(a, b, (near - da) / (db - da)); + } else if db < near { + b = along(b, a, (near - db) / (da - db)); + } + Some((scr(h.apply(a[0], a[1])), scr(h.apply(b[0], b[1])))) + }); + crate::canvas::marching_ants_lines(painter, lines, time); + } + true +} + /// What the styled preview of the moving layer is drawn from (transform_fx.rs), at `zoom` screen /// pixels per document pixel. `None` for a layer without a style (also once it is switched off /// while the box is open) or a hidden one: the bare pixels are drawn instead. The style, opacity @@ -2335,8 +2513,8 @@ fn draw_warp_points(painter: &egui::Painter, m: &BezierMesh, scr: impl Fn([f64; } } -fn draw_warp(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: &Warp, pv: &TransformPreview, guide: Option<(SplitTool, [f64; 2])>) { - let scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); +/// The warped pixels and the layers above them ([`draw_content`] for a warp). +fn draw_warp_content(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: &Warp, pv: &TransformPreview) { let r = t.rect; let m = w.to_mesh(1, 1); let (us, vs) = crate::warp_preview::samples(&m, xf.zoom as f64); @@ -2356,6 +2534,12 @@ fn draw_warp(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: & if let Some(a) = &pv.above { a.draw(painter, xf); } +} + +/// The warp grid, its control points and split guides ([`draw_overlay`] for a warp). +fn draw_warp_overlay(painter: &egui::Painter, xf: &ViewXform, w: &Warp, pv: &TransformPreview, guide: Option<(SplitTool, [f64; 2])>) { + let scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); + let m = w.to_mesh(1, 1); let accent = crate::theme::Tokens::get(painter.ctx()).accent; // Transform Path › Warp: the path drawn through the warp. if let Some(path) = &pv.path { @@ -2437,9 +2621,23 @@ fn quad_scale(t: &TransformSession) -> f32 { } /// W and H (%), the angle (°) and the horizontal skew (°) the options bar shows for the box. +/// A mirrored box reads as a negative W or H, whichever keeps the angle nearer 0 (Photoshop: +/// Flip Horizontal is W -100 % at 0°, Flip Vertical H -100 % at 0°). Both are the same box: +/// negating W and H and turning by a half turn cancel out. fn readout(t: &TransformSession) -> (f64, f64, f64, f64) { let p = Parts::of(t); - (p.sx * 100.0, p.sy.abs() * 100.0, p.angle.to_degrees(), p.skew_h.to_degrees()) + let wrap = |a: f64| { + let a = a.rem_euclid(std::f64::consts::TAU); + if a > std::f64::consts::PI { a - std::f64::consts::TAU } else { a } + }; + let turned = wrap(p.angle + std::f64::consts::PI); + let (sx, sy, angle) = if p.sy < 0.0 && turned.abs() < wrap(p.angle).abs() { (-p.sx, -p.sy, turned) } else { (p.sx, p.sy, p.angle) }; + (sx * 100.0, sy * 100.0, angle.to_degrees(), p.skew_h.to_degrees()) +} + +/// `v`, kept off zero with its sign: a divisor for a W or H scale that may be negative. +fn nonzero(v: f64) -> f64 { + if v.abs() < 1e-9 { 1e-9f64.copysign(v) } else { v } } /// The box's affine part read as Photoshop's options bar does: the frame scaled (W, H), skewed @@ -2682,14 +2880,17 @@ pub(crate) fn position_size_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t let (suffix, places) = (u.unit.suffix(), u.unit.decimals() as u32); // Lengths need more room than a percentage ("2116.67 mm"). let size_w = if u.unit == photocraft_engine::prefs::Unit::Percent { 66.0 } else { 84.0 }; + // Whatever their unit, W and H take any other typed in (`500 px` or `2 in` in percent, `50%` + // in a length), converted at the document's resolution against the box's original side (#6284). + let units = crate::widgets::FieldUnits { unit: u.unit, dpi: u.dpi, ppi: u.ppi }; lbl(ui, "W:"); - let rw = crate::widgets::value_field_prec(ui, &mut w, -wlim_u..=wlim_u, suffix, size_w, places); + let rw = crate::widgets::value_field_extent(ui, &mut w, -wlim_u..=wlim_u, suffix, size_w, places, units, side_w); let link = !app.ui.transform_unlinked; if crate::icons::button(ui, if link { "link" } else { "unlink" }, 22.0, link, tl!("Maintain aspect ratio")).clicked() { app.ui.transform_unlinked = link; } lbl(ui, "H:"); - let rh = crate::widgets::value_field_prec(ui, &mut h, -hlim_u..=hlim_u, suffix, size_w, places); + let rh = crate::widgets::value_field_extent(ui, &mut h, -hlim_u..=hlim_u, suffix, size_w, places, units, side_h); for r in [&rw, &rh] { r.context_menu(|ui| { if let Some(name) = crate::rulers::unit_menu(ui, u.unit) { @@ -2700,12 +2901,12 @@ pub(crate) fn position_size_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t let (w, h) = (size_pct(&u, w, side_w) as f32, size_pct(&u, h, side_h) as f32); if rw.changed() || rh.changed() { let (kx, ky) = if link { - let k = if rw.changed() { w as f64 / sx.max(1e-9) } else { h as f64 / sy.max(1e-9) }; + let k = if rw.changed() { w as f64 / nonzero(sx) } else { h as f64 / nonzero(sy) }; // Linked, the other side follows: keep it within its own limit too. let k = k.min(f64::from(wlim) / sx.abs().max(1e-9)).min(f64::from(hlim) / sy.abs().max(1e-9)); (k, k) } else { - (w as f64 / sx.max(1e-9), h as f64 / sy.max(1e-9)) + (w as f64 / nonzero(sx), h as f64 / nonzero(sy)) }; scale_about_pivot(app, kx, ky); } @@ -3165,6 +3366,43 @@ mod tests { assert!(eq6(skew, 20.0), "{skew}"); } + /// The test box flipped horizontally and vertically, as Edit › Transform › Flip leaves it. + const FLIPPED_H: [[f64; 2]; 4] = [[100.0, 0.0], [0.0, 0.0], [0.0, 50.0], [100.0, 50.0]]; + const FLIPPED_V: [[f64; 2]; 4] = [[0.0, 50.0], [100.0, 50.0], [100.0, 0.0], [0.0, 0.0]]; + + /// Photoshop puts a flip's minus on the flipped axis and keeps the angle at 0: Flip Horizontal + /// reads W -100 %, Flip Vertical H -100 % (it read 180° and nothing at all). + #[test] + fn a_flipped_box_reads_as_a_negative_scale_on_that_axis() { + let mut t = session(); + for (quad, want) in [(FLIPPED_H, (-100.0, 100.0)), (FLIPPED_V, (100.0, -100.0))] { + t.quad = quad; + let (w, h, angle, skew) = readout(&t); + assert!(eq6(w, want.0) && eq6(h, want.1) && eq6(angle, 0.0) && eq6(skew, 0.0), "{quad:?}: {w} {h} {angle} {skew}"); + } + // Flipped both ways is a half turn, not a mirror. + t.quad = [[100.0, 50.0], [0.0, 50.0], [0.0, 0.0], [100.0, 0.0]]; + let (w, h, angle, _) = readout(&t); + assert!(eq6(w, 100.0) && eq6(h, 100.0) && eq6(angle.abs(), 180.0), "{w} {h} {angle}"); + } + + /// The bar's fields act on a flipped box without undoing the flip: angle 0 keeps it as it is, + /// and a typed W scales it and keeps it mirrored. + #[test] + fn bar_fields_keep_a_flipped_box_flipped() { + let flip_h = |app: &mut PhotocraftApp| app.ui.transform.as_mut().unwrap().quad = FLIPPED_H; + let t = type_into_bar_field_with(flip_h, 4, "0"); + assert!(t.quad.iter().flatten().zip(FLIPPED_H.iter().flatten()).all(|(a, b)| eq6(*a, *b)), "{:?}", t.quad); + let t = type_into_bar_field_with(flip_h, 2, "-50"); + let (w, h, angle, _) = readout(&t); + assert!(eq6(w, -50.0) && eq6(h, 50.0) && eq6(angle, 0.0), "{w} {h} {angle}"); + assert!(eq6(t.quad[0][0] - t.quad[1][0], 50.0), "50 px wide, still mirrored: {:?}", t.quad); + let flip_v = |app: &mut PhotocraftApp| app.ui.transform.as_mut().unwrap().quad = FLIPPED_V; + let t = type_into_bar_field_with(flip_v, 3, "-200"); + let (w, h, _, _) = readout(&t); + assert!(eq6(w, 200.0) && eq6(h, -200.0), "{w} {h}"); + } + /// Typing H and V skews leans the box about the reference point; the angle, W and H fields keep /// their values, and V stays as typed. #[test] @@ -3279,6 +3517,93 @@ mod tests { assert_eq!(shown_size(&bar_harness(|_| {})), ["100", "100"]); } + /// Opens a document at `dpi` so the W/H fields convert typed lengths at its resolution. + fn at_dpi(app: &mut PhotocraftApp, dpi: f32) { + app.session.execute("file.new", json!({"width": 400, "height": 400})).unwrap(); + app.session + .edit("resolution", |doc, _| { + doc.resolution_dpi = dpi; + Ok(()) + }) + .unwrap(); + } + + /// The box's width and height in px after a W/H edit. + fn box_size(t: &TransformSession) -> (f64, f64) { + (t.quad[1][0] - t.quad[0][0], t.quad[3][1] - t.quad[0][1]) + } + + #[test] + fn w_and_h_in_percent_take_a_typed_length() { + use photocraft_engine::prefs::Unit; + let near = |a: f64, b: f64| (a - b).abs() < 0.01; + // The 100 × 50 px box with W/H showing percent (#6284): a typed length is the box's new side. + for (text, px) in + [("40px", 40.0), ("40 px", 40.0), ("2in", 600.0), ("2 in", 600.0), ("5cm", 5.0 / 2.54 * 300.0), ("25.4mm", 300.0), ("72pt", 300.0), ("1pica", 50.0)] + { + // Linked: the height follows the width. + let t = type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, text); + let (w, h) = box_size(&t); + assert!(near(w, px) && near(h, px / 2.0), "{text} linked: {w} × {h}"); + // Unlinked: only the width changes. + let t = type_into_bar_field_with( + |app| { + at_dpi(app, 300.0); + app.ui.transform_unlinked = true; + }, + 2, + text, + ); + let (w, h) = box_size(&t); + assert!(near(w, px) && near(h, 50.0), "{text} unlinked: {w} × {h}"); + } + // H takes a length too; linked, the width follows. + let t = type_into_bar_field_with(|app| at_dpi(app, 300.0), 3, "1in"); + let (w, h) = box_size(&t); + assert!(near(w, 600.0) && near(h, 300.0), "H 1in: {w} × {h}"); + // A plain number is still the percentage. + let (w, _) = box_size(&type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, "40")); + assert!(near(w, 40.0), "40 is 40 % of 100 px: {w}"); + // A percentage typed into a length field is that share of the box's original side. + let px = |app: &mut PhotocraftApp| { + at_dpi(app, 300.0); + set_size_unit(app, Unit::Pixels.name()); + }; + let (w, h) = box_size(&type_into_bar_field_with(px, 2, "50%")); + assert!(near(w, 50.0) && near(h, 25.0), "50% in px: {w} × {h}"); + let inches = |app: &mut PhotocraftApp| { + at_dpi(app, 300.0); + set_size_unit(app, Unit::Inches.name()); + }; + let (w, h) = box_size(&type_into_bar_field_with(inches, 3, "300%")); + assert!(near(w, 300.0) && near(h, 150.0), "300% in inches: {w} × {h}"); + } + + #[test] + fn w_and_h_reject_or_clamp_hostile_typed_lengths() { + let near = |a: f64, b: f64| (a - b).abs() < 0.01; + // Text that is no number leaves the box alone. + for text in ["px", "in", "NaN", "%"] { + let (w, h) = box_size(&type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, text)); + assert!(near(w, 100.0) && near(h, 50.0), "{text}: {w} × {h}"); + } + // Invalid text after a number keeps only what applied while typing (a plain number applies + // per keystroke), never a NaN or a box past the largest document. + for text in ["1in/0", "1e400px", "2 inches", "1e308in"] { + let (w, h) = box_size(&type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, text)); + assert!(w.is_finite() && h.is_finite() && w.abs() <= f64::from(POSITION_LIMIT_PX) + 0.01, "{text}: {w} × {h}"); + } + // A huge length stops at the field's limit: the box stays within the largest document. + let (w, h) = box_size(&type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, "1e9in")); + assert!(w.is_finite() && w <= f64::from(POSITION_LIMIT_PX) + 0.01 && h <= f64::from(POSITION_LIMIT_PX), "{w} × {h}"); + // A negative length flips, as a negative percentage does. + let (w, _) = box_size(&type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, "-40px")); + assert!(near(w, -40.0), "{w}"); + // Zero collapses the side without dividing by zero. + let (w, h) = box_size(&type_into_bar_field_with(|app| at_dpi(app, 300.0), 2, "0px")); + assert!(w.is_finite() && h.is_finite(), "{w} × {h}"); + } + #[test] fn right_clicking_w_or_h_picks_their_unit_and_remembers_it() { use egui_kittest::kittest::Queryable; @@ -3594,6 +3919,37 @@ mod tests { assert!(close(s.quad, corners([-50.0, 0.0, 150.0, 50.0])), "{:?}", s.quad); } + /// A reference point moved off the centre (⌥-click) is the centre of ⌥-scaling too, not just + /// of rotation: it stays put and the box scales away from it. + #[test] + fn alt_scales_about_a_moved_reference_point() { + let alt = egui::Modifiers::ALT; + // Reference point on the top-left corner: the bottom-right corner to (200, 100) doubles + // the box, top-left fixed (not centred on the reference point). + let mut s = session(); + s.pivot = [0.0, 0.0]; + drag(&mut s, [100.0, 50.0], [200.0, 100.0], alt); + assert!(close(s.quad, corners([0.0, 0.0, 200.0, 100.0])), "{:?}", s.quad); + assert!(near(s.pivot, [0.0, 0.0]), "{:?}", s.pivot); + // Reference point at (25, 25): the right edge to x = 175 is 2× about x = 25, and + // proportionally 2× about y = 25 too. + let mut s = session(); + s.pivot = [25.0, 25.0]; + drag(&mut s, [100.0, 25.0], [175.0, 25.0], alt); + assert!(close(s.quad, corners([-25.0, -25.0, 175.0, 75.0])), "{:?}", s.quad); + // ⌥⇧: that edge's axis only. + let mut s = session(); + s.pivot = [25.0, 25.0]; + drag(&mut s, [100.0, 25.0], [175.0, 25.0], egui::Modifiers { alt: true, shift: true, ..Default::default() }); + assert!(close(s.quad, corners([-25.0, 0.0, 175.0, 50.0])), "{:?}", s.quad); + // Reference point in line with the dragged corner horizontally: that axis has nothing to + // scale against and stays, the other doubles about it. + let mut s = session(); + s.pivot = [100.0, 50.0]; + drag(&mut s, [100.0, 0.0], [150.0, -50.0], egui::Modifiers { alt: true, shift: true, ..Default::default() }); + assert!(close(s.quad, corners([0.0, -50.0, 100.0, 50.0])), "{:?}", s.quad); + } + fn near(a: [f64; 2], b: [f64; 2]) -> bool { (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6 } @@ -3714,6 +4070,23 @@ mod tests { assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "another tool ends it"); } + /// #6737: Save for Web exports the committed document, so it is off while a box is pending. + #[test] + fn save_for_web_is_off_under_a_pending_transform() { + let ctx = egui::Context::default(); + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + assert!(crate::menus::is_enabled(&app, SAVE_FOR_WEB), "enabled with no box"); + crate::menus::invoke(&mut app, &ctx, "edit.freeTransform", json!({})).unwrap(); + assert!(!crate::menus::is_enabled(&app, SAVE_FOR_WEB), "off under the box"); + assert!(crate::menus::invoke(&mut app, &ctx, SAVE_FOR_WEB, json!({})).is_err(), "the dialog doesn't open"); + let path = std::env::temp_dir().join(format!("pc-web6737-{}.png", std::process::id())).to_string_lossy().into_owned(); + let r = crate::menus::invoke(&mut app, &ctx, SAVE_FOR_WEB, json!({"path": path, "format": "png24"})); + assert!(r.is_err() && !std::path::Path::new(&path).exists(), "nor does the export run"); + assert!(app.ui.transform.is_some(), "the box stays open"); + cancel(&mut app); + assert!(crate::menus::is_enabled(&app, SAVE_FOR_WEB), "back after the box closes"); + } + /// #352: ⌥⌘T transforms a copy. OK leaves the original and a moved copy as one history step; /// Cancel leaves no copy and nothing to redo; with a selection the copy holds the selected /// pixels only. @@ -3973,6 +4346,123 @@ mod tests { assert_eq!((t.layer, t.rect), (second.0, [40.0, 40.0, 56.0, 60.0]), "the next box is on the selected layer"); } + /// The screen rectangle the marching ants cover as [`draw_selection`] paints them over the + /// active document's selection; `None` when it leaves them to the canvas. + fn ants_bounds(app: &PhotocraftApp, xf: &ViewXform) -> Option { + let st = app.session.active().unwrap(); + let sel = st.doc.selection.as_ref().unwrap(); + let segs = crate::outline::outline(sel, sel.content_bounds()); + let ctx = egui::Context::default(); + let mut drawn = false; + let mut output = ctx.run_ui(Default::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("ants-test"))); + drawn = draw_selection(app, &painter, xf, st.doc.id, &segs, 0.0); + }); + output.textures_delta.clear(); + // The ants are a mesh of straight dashes and line shapes for the slanted ones. + let ants = output.shapes.iter().filter(|s| matches!(s.shape, egui::Shape::Mesh(_) | egui::Shape::LineSegment { .. })); + drawn.then(|| ants.map(|s| s.shape.visual_bounding_rect()).fold(egui::Rect::NOTHING, egui::Rect::union)) + } + + /// The screen rectangle around document points. + fn screen_bounds(xf: &ViewXform, points: &[[f64; 2]]) -> egui::Rect { + egui::Rect::from_points(&points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect::>()) + } + + fn rects_match(a: egui::Rect, b: egui::Rect) -> bool { + (a.min - b.min).abs().max_elem() <= 2.0 && (a.max - b.max).abs().max_elem() <= 2.0 + } + + /// #5297: with a selection, Free Transform's box is the selection's own rectangle (wider here + /// than the pixels it holds), and the marching ants stay on the box through a move, a scale, a + /// rotation and a distort, before anything is applied. Applying leaves them there. + #[test] + fn the_box_is_the_selection_and_the_ants_follow_it() { + let ctx = gpu_ctx(); + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)), + zoom: 2.0, + center: [32.0, 32.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + app.run("select.rect", json!({"x": 4, "y": 4, "width": 30, "height": 26})).unwrap(); + assert_eq!(ants_bounds(&app, &xf), None, "no box open: the canvas draws them where they are"); + begin(&mut app, &ctx).unwrap(); + let rest = corners([4.0, 4.0, 34.0, 30.0]); + assert_eq!(app.ui.transform.as_ref().unwrap().quad, rest, "the box is the selection, not the 16 px square in it"); + assert!(rects_match(ants_bounds(&app, &xf).unwrap(), screen_bounds(&xf, &rest)), "untouched, the ants are where they were"); + + let on_the_box = |app: &PhotocraftApp, what: &str| { + let quad = app.ui.transform.as_ref().unwrap().quad; + let (ants, boxed) = (ants_bounds(app, &xf).unwrap(), screen_bounds(&xf, &quad)); + assert!(rects_match(ants, boxed), "{what}: ants {ants:?}, box {boxed:?}"); + }; + if let Some(t) = app.ui.transform.as_mut() { + t.quad = t.quad.map(|[x, y]| [x + 20.0, y + 10.0]); + t.pivot = [t.pivot[0] + 20.0, t.pivot[1] + 10.0]; + } + on_the_box(&app, "moved"); + let moved = app.ui.transform.as_ref().unwrap().quad; + scale_about_pivot(&mut app, 0.5, 1.5); + on_the_box(&app, "scaled"); + rotate_about_pivot(&mut app, 0.6); + on_the_box(&app, "rotated"); + if let Some(t) = app.ui.transform.as_mut() { + t.quad[2] = [t.quad[2][0] + 9.0, t.quad[2][1] + 13.0]; + } + on_the_box(&app, "distorted"); + + // Applied as moved only: the outline is where the ants showed it. + if let Some(t) = app.ui.transform.as_mut() { + t.quad = moved; + } + commit(&mut app); + assert!(app.ui.transform.is_none()); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.selection.as_ref().unwrap().content_bounds(), photocraft_geom::Rect::new(24, 14, 54, 40)); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(), photocraft_geom::Rect::new(28, 18, 44, 34)); + } + + /// #5297: in Warp mode the ants bend with the mesh, and past a Distort's horizon they stop + /// rather than run off to infinity. + #[test] + fn the_ants_follow_a_warp_and_stop_at_a_distorts_horizon() { + let ctx = gpu_ctx(); + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 56, 56)); + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)), + zoom: 2.0, + center: [32.0, 32.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + app.run("select.rect", json!({"x": 12, "y": 12, "width": 40, "height": 40})).unwrap(); + begin(&mut app, &ctx).unwrap(); + let rest = corners([12.0, 12.0, 52.0, 52.0]); + + // Two corners dragged across each other put half the box past the horizon. + if let Some(t) = app.ui.transform.as_mut() { + t.quad = [[12.0, 12.0], [52.0, 12.0], [20.0, 40.0], [44.0, 40.0]]; + } + let ants = ants_bounds(&app, &xf).unwrap(); + assert!(ants == egui::Rect::NOTHING || (ants.is_finite() && ants.width() < 1e5 && ants.height() < 1e5), "{ants:?}"); + + if let Some(t) = app.ui.transform.as_mut() { + t.quad = rest; + } + enter_warp(&mut app, &ctx); + let arc = Warp::preset(WarpStyle::Arc, 50.0, [12.0, 12.0, 52.0, 52.0]); + app.ui.transform.as_mut().unwrap().warp = Some(arc.clone()); + let ants = ants_bounds(&app, &xf).unwrap(); + assert!(!rects_match(ants, screen_bounds(&xf, &rest)), "the outline left its place: {ants:?}"); + let warped: Vec<[f64; 2]> = rest.iter().map(|p| arc.map(p[0], p[1])).map(|(x, y)| [x, y]).collect(); + assert!(ants.expand(2.0).contains_rect(screen_bounds(&xf, &warped)), "the warped corners are on it: {ants:?}"); + } + /// Transform Selection moves the selection outline, not a layer: selecting a layer keeps it open. #[test] fn selecting_another_layer_keeps_a_transform_selection_open() { @@ -4083,15 +4573,69 @@ mod tests { let tol = handle_tolerance(&app); let h = std::f64::consts::FRAC_1_SQRT_2; let (tl, br) = ([32.0 - tol * 1.5, 32.0 - tol * 1.5], [160.0 + tol * 1.5, 160.0 + tol * 1.5]); - assert_eq!(cursor(&app, tl, false), Some(CursorIcon::None), "the arrow is drawn instead"); + let xf = ViewXform::active(&app).unwrap(); + assert_eq!(cursor(&app, &xf, tl, false), Some(CursorIcon::None), "the arrow is drawn instead"); assert!(near2(rotate_cursor_dir(&app, tl), [h, h]), "{:?}", rotate_cursor_dir(&app, tl)); assert!(near2(rotate_cursor_dir(&app, br), [-h, -h]), "bends round the bottom-right corner"); - assert_eq!(cursor(&app, [60.0, 60.0], false), Some(CursorIcon::Move)); + assert_eq!(cursor(&app, &xf, [60.0, 60.0], false), Some(CursorIcon::Move)); assert_eq!(rotate_cursor_dir(&app, [60.0, 60.0]), None, "inside moves"); assert_eq!(rotate_cursor_dir(&app, [32.0, 32.0]), None, "the corner handle scales"); assert_eq!(rotate_cursor_dir(&app, [f64::NAN, 32.0]), None); } + /// The resize arrows follow the box's turn and the view's rotation and flip: each handle + /// shows the arrow it resizes along on screen, not the one it had on the upright box. + #[test] + fn resize_cursors_turn_with_the_box_and_the_view() { + use CursorIcon::{ResizeHorizontal as H, ResizeNeSw as NeSw, ResizeNwSe as NwSe, ResizeVertical as V}; + // Box turned (degrees), view rotation, flip → corner 0, corner 1, edge 0, edge 1 (corner + // and edge i + 2 sit opposite, on the same axis). + let cases = [ + (0.0, 0.0, false, [NwSe, NeSw, V, H]), + (90.0, 0.0, false, [NeSw, NwSe, H, V]), + (45.0, 0.0, false, [V, H, NeSw, NwSe]), + (0.0, 0.0, true, [NeSw, NwSe, V, H]), + (0.0, 90.0, false, [NeSw, NwSe, H, V]), + ]; + for (deg, view, flip, want) in cases { + let mut app = app_with_square(256, photocraft_geom::Rect::new(64, 96, 192, 160)); + crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap(); + let view_index = app.session.active_index().unwrap(); + app.ui.views[view_index].rotation = view; + app.ui.view.flip_horizontal = flip; + let t = app.ui.transform.as_mut().unwrap(); + let (s, c) = f64::to_radians(deg).sin_cos(); + let o = [128.0, 128.0]; + t.quad = t.quad.map(|[x, y]| [o[0] + (x - o[0]) * c - (y - o[1]) * s, o[1] + (x - o[0]) * s + (y - o[1]) * c]); + let t = app.ui.transform.clone().unwrap(); + let xf = ViewXform::active(&app).unwrap(); + let mid = |i: usize| [(t.quad[i][0] + t.quad[(i + 1) % 4][0]) / 2.0, (t.quad[i][1] + t.quad[(i + 1) % 4][1]) / 2.0]; + let got: Vec<_> = (0..4).map(|i| cursor(&app, &xf, t.quad[i], false)).chain((0..4).map(|i| cursor(&app, &xf, mid(i), false))).collect(); + let want = [want[0], want[1], want[0], want[1], want[2], want[3], want[2], want[3]].map(Some); + assert_eq!(got, want, "box {deg}°, view {view}°, flip {flip}"); + } + } + + /// A warp split's arrow crosses the split line as it looks on screen: it turns with a turned + /// grid (Warp entered on a rotated box) and with a rotated view. + #[test] + fn warp_split_cursors_turn_with_the_grid_and_the_view() { + use CursorIcon::{ResizeHorizontal as H, ResizeNeSw as NeSw, ResizeNwSe as NwSe, ResizeVertical as V}; + let rect = [0.0, 0.0, 200.0, 100.0]; + let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let level = ViewXform { rect: screen, zoom: 1.0, center: [100.0, 50.0], flip: false, rotation: 0.0, aspect: 1.0 }; + // Grid turned (degrees, about its centre), view rotation → Split Vertically, Split Horizontally. + for (grid, view, want) in [(0.0, 0.0, [H, V]), (0.0, 90.0, [V, H]), (90.0, 0.0, [V, H]), (45.0, 0.0, [NwSe, NeSw])] { + let (s, c) = f64::to_radians(grid).sin_cos(); + let m = BezierMesh::identity(rect, 1, 1).map_points(|[x, y]| [100.0 + (x - 100.0) * c - (y - 50.0) * s, 50.0 + (x - 100.0) * s + (y - 50.0) * c]); + let xf = ViewXform { rotation: view, ..level }; + let got = [SplitTool::Vertical, SplitTool::Horizontal].map(|tool| split_cursor(&m, tool, m.param_at([100.0, 50.0]), &xf)); + assert_eq!(got, want, "grid turned {grid}°, view {view}°"); + } + let m = BezierMesh::identity(rect, 1, 1); + assert_eq!(split_cursor(&m, SplitTool::Cross, (0.5, 0.5), &level), CursorIcon::Crosshair); + } + /// #1767: the readout beside the pointer during a drag (Photoshop's transformation values): /// the angle while rotating, W/H while scaling, ΔX/ΔY while moving; nothing otherwise. #[test] @@ -4387,12 +4931,66 @@ mod tests { assert!(pv.texture.size()[0] as f64 >= t.rect[2] - t.rect[0]); } - /// The textured meshes `draw_overlay` paints for the moving pixels (not the box or handles). + #[test] + fn nested_group_preview_excludes_its_siblings() { + use photocraft_color::PixelFormat; + use photocraft_doc::Layer; + + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let target_pixels = doc.layers.pop().unwrap(); + let mut sibling = Layer::raster("sibling", PixelFormat::RGBA8); + sibling.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(12, 12, 20, 20), &[0.0, 0.0, 1.0, 1.0]); + let target = Layer::group("target", vec![target_pixels]); + let target_id = target.id; + doc.layers.push(Layer::group("parent", vec![target, sibling])); + + let (image, _) = preview_image(&st.doc, target_id, None, photocraft_geom::Rect::new(8, 8, 24, 24), 4096, false); + assert_eq!( + image.pixels[8 * image.size[0] + 8], + Color32::from_rgb(255, 0, 0), + "a sibling in the parent group must not be baked into the moving texture" + ); + } + + #[test] + fn nested_group_preview_detaches_every_clipped_ancestor() { + use photocraft_color::PixelFormat; + use photocraft_doc::Layer; + + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let target_pixels = doc.layers.pop().unwrap(); + let target = Layer::group("target", vec![target_pixels]); + let target_id = target.id; + let mut middle = Layer::group("middle clipped ancestor", vec![target]); + middle.clipped = true; + let mut inner_base = Layer::raster("hidden inner clipping base", PixelFormat::RGBA8); + inner_base.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 64, 64), &[0.0, 0.0, 1.0, 1.0]); + let mut outer = Layer::group("outer clipped ancestor", vec![inner_base, middle]); + outer.clipped = true; + doc.layers.push(outer); + + let (image, _) = preview_image(&st.doc, target_id, None, photocraft_geom::Rect::new(8, 8, 24, 24), 4096, false); + assert_eq!(image.pixels[8 * image.size[0] + 8], Color32::from_rgb(255, 0, 0), "hidden clipping bases must not make a nested target disappear"); + } + + #[test] + fn unknown_preview_target_keeps_the_previous_document_fallback() { + let app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let st = app.session.active().unwrap(); + let (image, _) = preview_image(&st.doc, LayerId::fresh(), None, photocraft_geom::Rect::new(8, 8, 24, 24), 4096, false); + assert_eq!(image.pixels[8 * image.size[0] + 8], Color32::from_rgb(255, 0, 0)); + } + + /// The textured meshes `draw_content` paints for the moving pixels (not the box or handles). fn preview_meshes(app: &PhotocraftApp, xf: &ViewXform) -> Vec { let ctx = gpu_ctx(); let mut out = ctx.run_ui(egui::RawInput { max_texture_side: Some(16384), ..Default::default() }, |ui| { let painter = ui.ctx().layer_painter(egui::LayerId::background()); - draw_overlay(app, &painter, xf); + draw_content(app, &painter, xf); }); out.textures_delta.clear(); out.shapes @@ -4520,6 +5118,7 @@ mod tests { rotation: 0.0, aspect: 1.0, }; + draw_content(&app, &painter, &xf); draw_overlay(&app, &painter, &xf); }); t0.elapsed().as_secs_f64() * 1e3 @@ -4591,6 +5190,32 @@ mod tests { assert!(app.ui.transform.as_ref().unwrap().warp.is_none()); } + #[test] + fn a_thin_layer_scaled_then_warped_commits_the_curve() { + // #5793: a 3 px column stretched wide, switched to Warp and pulled down in the middle. + // The commit came out straight: the warp's grid was one cell across the 3 source pixels. + let mut app = app_with_square(128, photocraft_geom::Rect::new(40, 20, 43, 60)); + let ctx = egui::Context::default(); + begin(&mut app, &ctx).unwrap(); + scale_about_pivot(&mut app, 30.0, 1.0); + enter_warp(&mut app, &ctx); + warp_pointer(&mut app, ToolEvent::Down { x: 41.5, y: 40.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE); + warp_pointer(&mut app, ToolEvent::Move { x: 41.5, y: 70.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE); + warp_pointer(&mut app, ToolEvent::Up { x: 41.5, y: 70.0 }, 2.0, egui::Modifiers::NONE); + commit(&mut app); + assert!(app.ui.transform.is_none(), "{}", app.ui.status); + let st = app.session.active().unwrap(); + let s = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let alpha = |x: i32, y: i32| { + let mut p = [0.0f32; 4]; + s.read_pixel(x, y, &mut p); + p[3] + }; + // The middle sags below the band's old bottom edge (y 60); the ends stay put. + assert!(alpha(41, 70) > 0.99, "middle {}", alpha(41, 70)); + assert!(alpha(15, 70) < 0.01 && alpha(15, 40) > 0.99, "end {} {}", alpha(15, 70), alpha(15, 40)); + } + fn warping_square() -> PhotocraftApp { let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 32, 32)); begin_warp(&mut app, &egui::Context::default()).unwrap(); @@ -4794,6 +5419,27 @@ mod tests { } } + /// #5239: a smart object clipped to the layer under it previewed as nothing while the box + /// was open (its flattened render hid the clipping base too). + #[test] + fn preview_of_a_clipped_smart_object_is_not_empty() { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 64, 64), &[0.0, 0.0, 1.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + app.session.execute("layer.createClippingMask", json!({})).unwrap(); + let st = app.session.active().unwrap(); + let id = st.active_layer.unwrap(); + assert!(st.doc.layer(id).unwrap().clipped); + let (img, _) = preview_image(&st.doc, id, None, photocraft_geom::Rect::new(0, 0, 64, 64), 4096, false); + assert_eq!(texel_alpha(&img, 32, 32), 255, "the moving object shows"); + } + #[test] fn targeted_unlinked_mask_transforms_alone_through_the_box() { let mut app = masked_app(false); diff --git a/crates/ui-egui/src/transform_type_tests.rs b/crates/ui-egui/src/transform_type_tests.rs index dcbbb70b3c..457c03c1cb 100644 --- a/crates/ui-egui/src/transform_type_tests.rs +++ b/crates/ui-egui/src/transform_type_tests.rs @@ -52,6 +52,8 @@ fn type_layers_offer_free_transform_but_not_distort_or_perspective() { pasteboard: false, anchor: None, text: false, + type_layer: false, + guide: false, spelling: None, }; assert!(crate::canvas_tool_menu::available(&app, &menu, "edit.freeTransform")); diff --git a/crates/ui-egui/src/transform_undo_tests.rs b/crates/ui-egui/src/transform_undo_tests.rs index 832b221900..b863eb8fff 100644 --- a/crates/ui-egui/src/transform_undo_tests.rs +++ b/crates/ui-egui/src/transform_undo_tests.rs @@ -343,3 +343,46 @@ fn the_first_drag_from_the_move_tools_controls_undoes() { key(&mut h, Modifiers::COMMAND, Key::Z); assert!(close(quad(&h), [[100.0, 100.0], [200.0, 100.0], [200.0, 160.0], [100.0, 160.0]]), "{:?}", quad(&h)); } + +/// #3402's layer-switch case (#5297): selected pixels moved with the Move tool, then moved again +/// in the Free Transform box, land where the box left them when another layer is picked. +#[test] +fn picking_another_layer_applies_the_transform_of_moved_selected_pixels() { + let mut h = harness(); + let layer = h.state().session.active().unwrap().active_layer.unwrap(); + h.state_mut().run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap(); + drag(&mut h, [150.0, 130.0], [250.0, 180.0]); + begin(&mut h); + // Away from the centre, where a press would take the reference point instead. + drag(&mut h, [220.0, 165.0], [240.0, 175.0]); + assert_eq!(quad(&h), [[220.0, 160.0], [320.0, 160.0], [320.0, 220.0], [220.0, 220.0]]); + let other = h.state().session.active().unwrap().doc.layers.iter().map(|l| l.id).find(|id| *id != layer).unwrap(); + h.state_mut().run("layer.select", json!({"layer": other.0})).unwrap(); + crate::transform_tool::end_if_left(h.state_mut()); + h.run_steps(3); + assert!(h.state().ui.transform.is_none(), "picking another layer applies the transform"); + let st = h.state().session.active().unwrap(); + let landed = photocraft_geom::Rect::new(220, 160, 320, 220); + let (pixels, selection) = (st.doc.layer(layer).unwrap().surface().unwrap().content_bounds(), st.doc.selection.as_ref().map(|s| s.content_bounds())); + assert_eq!((pixels, selection), (landed, Some(landed))); +} + +/// #5297 through the real canvas: ⌘T with a selection wider than the layer opens the box on the +/// selection, and dragging it moves the selected pixels and the selection together. +#[test] +fn free_transform_opens_on_the_selection_and_moves_it_with_the_pixels() { + let mut h = harness(); + let layer = h.state().session.active().unwrap().active_layer.unwrap(); + // The left 60 px of the red rectangle, and empty layer to its left, above and below. + h.state_mut().run("select.rect", json!({"x": 60, "y": 80, "width": 100, "height": 120})).unwrap(); + begin(&mut h); + assert_eq!(quad(&h), [[60.0, 80.0], [160.0, 80.0], [160.0, 200.0], [60.0, 200.0]]); + drag(&mut h, [80.0, 100.0], [130.0, 130.0]); + assert_eq!(quad(&h), [[110.0, 110.0], [210.0, 110.0], [210.0, 230.0], [110.0, 230.0]]); + key(&mut h, Modifiers::NONE, Key::Enter); + assert!(h.state().ui.transform.is_none()); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.selection.as_ref().unwrap().content_bounds(), photocraft_geom::Rect::new(110, 110, 210, 230)); + // What stayed (160..200 × 100..160) and what moved (to 150..210 × 130..190). + assert_eq!(st.doc.layer(layer).unwrap().surface().unwrap().content_bounds(), photocraft_geom::Rect::new(150, 100, 210, 190)); +} diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index f17032523d..6f05ed61a5 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -10,6 +10,7 @@ use photocraft_engine::prefs::FontPreview; struct State { current: String, families: Vec, + button: egui::Id, popup: egui::Id, preview: FontPreview, } @@ -22,10 +23,11 @@ fn harness_with(preview: FontPreview) -> Harness<'static, State> { // Enough families that the unfiltered menu hits its maximum height and scrolls. let mut families: Vec = (0..60).map(|i| format!("Family {i:02}")).collect(); families.push("Zeta Sans".into()); - let state = State { current: "Family 00".into(), families, popup: egui::Id::NULL, preview }; + let state = State { current: "Family 00".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview }; let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state( |ui, s: &mut State| { - s.popup = ui.make_persistent_id(egui::IdSalt::new("type-font")).with("popup"); + s.button = ui.make_persistent_id(egui::IdSalt::new("type-font")); + s.popup = s.button.with("popup"); let fams = s.families.clone(); font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview); }, @@ -46,6 +48,16 @@ fn open(h: &mut Harness<'static, State>) { h.run(); } +/// A native click can leave the opening button held across several rendered frames. +fn hold_open(h: &mut Harness<'_, S>, button: egui::Pos2) { + h.hover_at(button); + h.run(); + h.event(egui::Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(4); + h.event(egui::Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run(); +} + #[test] fn hovering_a_font_row_sets_preview_and_leaving_clears_it() { let mut h = harness(); @@ -71,6 +83,35 @@ fn hovering_a_font_row_sets_preview_and_leaving_clears_it() { assert!(h.ctx.data(|d| d.get_temp::(hover_id)).is_none()); } +/// #7406: hovering a font highlights its row without moving anything: the other rows keep +/// their place, the menu its width, and every row (the selected one too) takes one height. +#[test] +fn hovering_a_font_row_moves_no_other_row() { + let mut h = harness(); + // The app's theme: its hovered and selected buttons draw a stroke (egui's default style + // would hide the jump). + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run(); + open(&mut h); + let rows = |h: &Harness<'static, State>| -> Vec { (0..8).map(|i| h.get_by_label(&format!("Family {i:02}")).rect()).collect() }; + let before = rows(&h); + let width = popup_rect(&h).expect("font menu open").width(); + let tops: Vec = before.windows(2).map(|w| w[1].top() - w[0].top()).collect(); + assert!(tops.windows(2).all(|t| (t[0] - t[1]).abs() < 0.01), "rows are evenly spaced, the selected Family 00 included: {tops:?}"); + for hovered in [2, 5] { + h.hover_at(before[hovered].center()); + h.run_steps(2); + let after = rows(&h); + for (i, (b, a)) in before.iter().zip(&after).enumerate() { + if i != hovered { + assert_eq!(b, a, "hovering Family {hovered:02} moved Family {i:02}"); + } + } + assert_eq!(after[hovered].center(), before[hovered].center(), "the hovered row stays centred where it was"); + assert_eq!(popup_rect(&h).map(|r| r.width()), Some(width), "the menu keeps its width"); + } +} + #[test] fn clearing_the_query_restores_the_menu_height() { let mut h = harness(); @@ -93,15 +134,18 @@ fn clearing_the_query_restores_the_menu_height() { #[test] fn reopening_focuses_the_search_field_and_clicking_it_keeps_the_menu_open() { let mut h = harness(); - open(&mut h); + let button = h.query_all_by_role(Role::ComboBox).next().unwrap().rect().center(); + hold_open(&mut h, button); assert!(h.get_by_role(Role::TextInput).is_focused(), "search is focused on first open"); - h.get_by_role(Role::TextInput).type_text("zeta"); - h.run(); - // Close without choosing, then reopen: the query stays and the field is focused again. - h.key_press(Key::Escape); + h.event(egui::Event::Text("zeta".into())); h.run(); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("zeta"), "typing goes straight to the search field"); + // Close and reopen on consecutive passes: the query stays and the field is focused again. + h.input_mut().events.push(egui::Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); assert!(popup_rect(&h).is_none(), "Escape closes the menu"); - open(&mut h); + h.query_all_by_role(Role::ComboBox).next().unwrap().click_accesskit(); + h.run(); let search = h.get_by_role(Role::TextInput); assert_eq!(search.value().as_deref(), Some("zeta")); assert!(search.is_focused(), "search is focused on reopen with a query"); @@ -120,6 +164,64 @@ fn reopening_focuses_the_search_field_and_clicking_it_keeps_the_menu_open() { assert!(popup_rect(&h).is_none(), "choosing a font closes the menu"); } +#[test] +fn opening_click_release_returns_focus_to_search_before_typing() { + let mut h = harness(); + let button = h.query_all_by_role(Role::ComboBox).next().unwrap().rect().center(); + h.hover_at(button); + h.run(); + h.event(egui::Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(4); + assert!(h.get_by_role(Role::TextInput).is_focused()); + // A native opening click can hand focus back to its button when it ends, after the + // search has already been focused for several passes. Model that platform handoff. + let id = h.state().button; + h.ctx.memory_mut(|m| m.request_focus(id)); + h.event(egui::Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run(); + assert!(h.get_by_role(Role::TextInput).is_focused(), "the opening release leaves the search focused"); + h.event(egui::Event::Text("zeta".into())); + h.run(); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("zeta")); + assert!(h.query_by_label("Family 01").is_none()); +} + +#[test] +fn first_options_bar_open_searches_without_changing_the_tool_or_edited_text() { + use crate::{PhotocraftApp, Services}; + use photocraft_doc::LayerContent; + use serde_json::json; + + for editing in [false, true] { + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("type.create", json!({"text": "Sample", "font": "Inter", "size": 18})).unwrap(); + app.ui.tool = crate::state::Tool::Type; + if editing { + super::edit_active(&mut app).unwrap(); + } + app.sync_views(); + app + }); + h.run_steps(8); + let button = h.query_all_by_role(Role::ComboBox).find(|n| n.value().as_deref() == Some("Inter")).unwrap().rect().center(); + hold_open(&mut h, button); + // Native typing carries key events as well as text; an unfocused search would let + // the first key switch tools, or replace the selected inline text instead. + h.event(egui::Event::Key { key: Key::J, physical_key: Some(Key::J), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.event(egui::Event::Text("JetBrains".into())); + h.run(); + let search = h.query_all_by_role(Role::TextInput).find(|n| n.is_focused()).expect("font search focused on first open"); + assert_eq!(search.value().as_deref(), Some("JetBrains")); + assert_eq!(h.state().ui.tool, crate::state::Tool::Type); + let st = h.state().session.active().unwrap(); + let LayerContent::Text(text) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("type layer") }; + assert_eq!(text.text, "Sample"); + } +} + #[test] fn arrows_apply_fonts_without_closing_and_enter_accepts() { let mut h = harness(); @@ -137,6 +239,88 @@ fn arrows_apply_fonts_without_closing_and_enter_accepts() { assert!(popup_rect(&h).is_none()); } +/// #7407: as in Photoshop, the field keeps the focus once a font is picked (click or Enter), +/// and ↑ / ↓ on it step through the families with the menu closed. Escape ends it. +#[test] +fn arrows_on_the_field_step_through_the_families_after_a_pick() { + let mut h = harness(); + open(&mut h); + h.get_by_label("Family 03").click(); + h.run(); + assert_eq!(h.state().current, "Family 03"); + assert!(popup_rect(&h).is_none(), "picking closes the menu"); + let button = h.state().button; + assert!(h.ctx.memory(|m| m.has_focus(button)), "the field keeps the focus"); + let press = |h: &mut Harness<'static, State>, key: Key| { + h.key_press(key); + h.run(); + assert!(popup_rect(h).is_none(), "{key:?} leaves the menu closed"); + assert!(h.ctx.memory(|m| m.has_focus(button)), "{key:?} keeps the focus on the field"); + h.state().current.clone() + }; + assert_eq!(press(&mut h, Key::ArrowDown), "Family 04"); + assert_eq!(press(&mut h, Key::ArrowDown), "Family 05"); + assert_eq!(press(&mut h, Key::ArrowUp), "Family 04"); + // Enter in the open menu is a pick too. + open(&mut h); + h.key_press(Key::ArrowDown); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().current, "Family 05"); + assert_eq!(press(&mut h, Key::ArrowDown), "Family 06"); + // Escape takes the focus away; the arrows then leave the font alone. + h.key_press(Key::Escape); + h.run(); + assert!(!h.ctx.memory(|m| m.has_focus(button))); + h.key_press(Key::ArrowDown); + h.run(); + assert_eq!(h.state().current, "Family 06"); +} + +/// The first arrow right after a pick (in the very next frame) steps the font and keeps the +/// focus on the field, also between focusable widgets: the field's lock filter only holds from +/// the frame after it got the focus, and egui would read that arrow as a move to "Below". +#[test] +fn the_first_arrow_after_a_pick_keeps_the_focus_between_neighbours() { + for (enter, picked, stepped) in [(false, "Family 05", "Family 06"), (true, "Family 04", "Family 05")] { + let families: Vec = (0..20).map(|i| format!("Family {i:02}")).collect(); + let state = State { current: "Family 03".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview: FontPreview::Off }; + let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state( + |ui, s: &mut State| { + let _ = ui.button("Above"); + s.button = ui.make_persistent_id(egui::IdSalt::new("type-font")); + s.popup = s.button.with("popup"); + let fams = s.families.clone(); + font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview); + let _ = ui.button("Below"); + }, + state, + ); + h.run(); + open(&mut h); + if enter { + h.key_press(Key::ArrowDown); + h.key_press(Key::Enter); + } else { + h.get_by_label("Family 05").click(); + } + // Frame by frame up to the pick, then the arrow in the next frame. + for _ in 0..8 { + if h.state().current == picked && popup_rect(&h).is_none() { + break; + } + h.step(); + } + assert_eq!(h.state().current, picked, "enter: {enter}"); + h.key_press(Key::ArrowDown); + h.step(); + h.run(); + assert_eq!(h.state().current, stepped, "enter: {enter}"); + let button = h.state().button; + assert!(h.ctx.memory(|m| m.has_focus(button)), "enter: {enter}: the arrow moved the focus off the field"); + } +} + #[test] fn arrows_follow_the_filtered_list_and_handle_no_matches() { let mut h = harness(); @@ -411,6 +595,7 @@ fn localized_font_names_label_the_menu_but_pick_the_family() { let state = State { current: "Arial".into(), families: vec!["Arial".into(), "MS Gothic".into(), "Zeta Sans".into()], + button: egui::Id::NULL, popup: egui::Id::NULL, preview: FontPreview::Medium, }; @@ -490,3 +675,43 @@ fn wheel_family_index_clamps_to_the_ends() { assert_eq!(super::wheel_family_index(1, 3, 0.0), None); assert_eq!(super::wheel_family_index(0, 0, -1.0), None); } + +/// #5152: in the full app the Properties panel's Character section draws a second font picker +/// after the options bar; hovering a font in the options bar still previews it on the canvas. +#[test] +fn options_bar_hover_previews_with_the_character_picker_also_shown() { + use crate::{PhotocraftApp, Services}; + use photocraft_doc::LayerContent; + use serde_json::json; + + let theme = crate::theme::ThemeKind::Pro; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.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(), Services::default()); + app.ui.theme = theme; + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("type.create", json!({"text": "Sample", "font": "Inter", "size": 18})).unwrap(); + app.ui.tool = crate::state::Tool::Type; + app.sync_views(); + app + }); + h.run_steps(8); + let layer = h.state().session.active().unwrap().active_layer.unwrap(); + let font = |h: &mut Harness<'static, PhotocraftApp>| { + let (shown, _) = crate::canvas::display_doc(h.state_mut(), 0); + let LayerContent::Text(t) = &shown.layer(layer).unwrap().content else { panic!("type layer") }; + t.char_runs()[0].style.font_family.clone() + }; + let pickers: Vec<_> = h.query_all_by_role(Role::ComboBox).filter(|n| n.value().as_deref() == Some("Inter")).collect(); + assert_eq!(pickers.len(), 2, "options bar and Character section both show the font picker"); + let button = pickers[0].rect().center(); + hold_open(&mut h, button); + h.query_all_by_role(Role::TextInput).find(|n| n.is_focused()).expect("search field focused").type_text("JetBrains Mono"); + h.run(); + h.get_by_label("JetBrains Mono").hover(); + h.run(); + assert_eq!(font(&mut h), "JetBrains Mono"); + h.query_all_by_role(Role::TextInput).find(|n| n.is_focused()).expect("search field focused").hover(); + h.run(); + assert_eq!(font(&mut h), "Inter", "leaving the row restores the committed font"); +} diff --git a/crates/ui-egui/src/type_panels_ui.rs b/crates/ui-egui/src/type_panels_ui.rs index 499e8eac44..86ef663209 100644 --- a/crates/ui-egui/src/type_panels_ui.rs +++ b/crates/ui-egui/src/type_panels_ui.rs @@ -443,7 +443,7 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s (tl!("Indent Right"), "end_indent_pt", "pt"), (tl!("Space Before"), "space_before_pt", "pt"), (tl!("Space After"), "space_after_pt", "pt"), - (tl!("Hyphenate"), "hyphenate", "bool"), + // Hyphenate is stored and round-trips to PSD, but layout never hyphenates (#6710). ]; for &(label, key, kind) in rows { let mut on = defined(¶_attrs, key); @@ -768,7 +768,17 @@ fn spelling_window(app: &mut PhotocraftApp, ctx: &egui::Context) { .anchor(Align2::CENTER_CENTER, vec2(0.0, -40.0)) .show(ctx, |ui| { ui.set_width(380.0); - ui.label(RichText::new(tl!("Check Spelling")).color(t.text).size(13.0).strong()); + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Check Spelling")).color(t.text).size(13.0).strong()); + // The close box (#5496) is Done, as in Photoshop: every change is already made. + if crate::dialogs::CLOSE_BOX { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::icons::button(ui, "x", 20.0, false, tl!("Close")).clicked() { + act = Some("done"); + } + }); + } + }); ui.add_space(6.0); ui.horizontal_top(|ui| { ui.vertical(|ui| { @@ -930,4 +940,54 @@ mod tests { // Headless calls with an action go to the engine. assert!(menu(&mut app, "edit.checkSpelling", &json!({"action": "list"})).is_none()); } + + /// #5496: Check Spelling's close box is Done, as in Photoshop on Windows: the window closes + /// and the text keeps what it had. None on macOS. + #[test] + fn check_spellings_close_box_is_done() { + use egui_kittest::kittest::Queryable; + let mut app = app_with_text("Teh cat"); + menu(&mut app, "edit.checkSpelling", &json!({})).unwrap().unwrap(); + let revision = app.session.active().unwrap().revision; + // The fonts are set up after the harness's first frame: the window appears after it. + let spell = app.ui.type_panels.spell.take(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(|ui, app| windows(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.state_mut().ui.type_panels.spell = spell; + h.run_steps(3); + if !crate::dialogs::CLOSE_BOX { + assert_eq!(h.query_all_by_label("Close").count(), 0, "no close box on macOS"); + return; + } + let x = h.get_by_label("Close").rect(); + let done = h.get_by_label("Done").rect(); + assert!(x.bottom() <= done.top() && x.right() >= done.right(), "the × ends the title row: {x:?} over {done:?}"); + h.hover_at(x.center()); + h.drag_at(x.center()); + h.drop_at(x.center()); + h.run_steps(2); + assert!(h.state().ui.type_panels.spell.is_none(), "the × closed Check Spelling"); + assert_eq!(h.state().session.active().unwrap().revision, revision, "and changed nothing"); + } + + #[test] + fn hyphenate_is_not_shown_in_paragraph_style_options() { + // #6710: layout never hyphenates; the style editor must not advertise it. + use egui_kittest::kittest::Queryable; + let mut app = app_with_text("Hi"); + app.ui.type_panels.styles = true; + app.ui.type_panels.styles_tab = 1; + app.ui.type_panels.options = Some((true, 0)); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + windows(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + assert!(h.query_by_label("Hyphenate").is_none()); + } } 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 (f32, f32) { /// Topmost shown type layer (it and every group around it visible, #4320) whose laid-out text /// contains the document point. -fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option { +pub(crate) fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option { let doc = app.session.active()?.doc.clone(); let slop = 6.0 / app.point_zoom().max(0.01); // A Type Mask session's text is a hidden layer; it is still the text being edited. @@ -121,6 +121,7 @@ pub fn edit_active(app: &mut PhotocraftApp) -> Result<(), String> { ed.caret = n; } let vertical = app.session.active().and_then(|st| text_layer(&st.doc, id)).is_some_and(|t| t.orientation == photocraft_doc::text::Orientation::Vertical); + app.ui.text_edit_return_tool = None; app.ui.tool = if vertical { crate::state::Tool::VerticalType } else { crate::state::Tool::Type }; app.ui.mask_target = false; app.ui.vector_mask_target = false; @@ -132,6 +133,7 @@ pub fn edit_active(app: &mut PhotocraftApp) -> Result<(), String> { /// type is there. pub fn edit_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { let Some(id) = hit_layer(app, x, y) else { return false }; + let previous_tool = app.ui.tool; commit(app); if app.session.select_layer(id).is_err() { return false; @@ -144,6 +146,9 @@ pub fn edit_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { app.ui.text_edit = Some(TextEdit { layer: id.0, caret: off, anchor: off, session: key, created: false, mask: false, dragging: false, resize: None, preedit: None }); let vertical = app.session.active().and_then(|st| text_layer(&st.doc, id)).is_some_and(|t| t.orientation == photocraft_doc::text::Orientation::Vertical); + if !previous_tool.is_type() { + app.ui.text_edit_return_tool = Some(previous_tool); + } app.ui.tool = if vertical { crate::state::Tool::VerticalType } else { crate::state::Tool::Type }; app.ui.mask_target = false; app.ui.vector_mask_target = false; @@ -232,6 +237,15 @@ fn resize_box(app: &mut PhotocraftApp, ed: &TextEdit, i: u8, x: f64, y: f64) { let _ = app.run("type.edit", json!({"layer": ed.layer, "box": [o.x, o.y, x1 - x0, y1 - y0], "coalesce": ed.session})); } +/// True when document point (`x`, `y`) lies inside the paragraph box of type layer `id` (false +/// for point text). +fn inside_box(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> bool { + let Some((bx, by, w, h)) = box_shape(app, id) else { return false }; + let Some((_, aff, _)) = layout(app, id) else { return false }; + let (tx, ty) = to_text(&aff, x, y); + tx >= bx && tx <= bx + w && ty >= by && ty <= by + h +} + /// Pointer down with the Type tool. Returns true when the press was consumed (no box drag). pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) -> bool { if let Some(ed) = app.ui.text_edit.clone() { @@ -242,7 +256,9 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie } return true; } - if hit_layer(app, x, y) == Some(id) { + // Photoshop: anywhere inside the paragraph box places the caret, even off the glyphs + // (left of a line's start, below the last line); only a click outside it commits. + if hit_layer(app, x, y) == Some(id) || inside_box(app, id, x, y) { let off = hit_offset(app, id, x, y); if let Some(e) = app.ui.text_edit.as_mut() { e.caret = off; @@ -271,6 +287,7 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie let off = hit_offset(app, id, x, y); app.ui.text_edit = Some(TextEdit { layer: id.0, caret: off, anchor: off, session: key, created: false, mask: false, dragging: true, resize: None, preedit: None }); + app.ui.text_edit_return_tool = None; return true; } false @@ -295,6 +312,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()); @@ -334,7 +352,9 @@ 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; } } @@ -773,6 +793,7 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { let id = LayerId(ed.layer); let Some(text) = current_text(app, id) else { app.ui.text_edit = None; + app.ui.text_edit_return_tool = None; return false; }; // Undo/redo can shorten the text under us. @@ -781,7 +802,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()]; @@ -934,6 +959,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(|_| { @@ -960,6 +986,11 @@ pub fn commit_with(app: &mut PhotocraftApp, mods: egui::Modifiers) { } crate::type_transform::reset(app); let Some(ed) = app.ui.text_edit.take() else { return }; + if let Some(tool) = app.ui.text_edit_return_tool.take() + && app.ui.tool.is_type() + { + app.ui.tool = tool; + } let Some(text) = current_text(app, LayerId(ed.layer)) else { return }; if ed.mask { // Type Mask: the text's glyphs become (or combine with) the selection and the working @@ -987,6 +1018,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 { @@ -1014,20 +1048,20 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX } // Frame: paragraph text shows its box with handles; point text an underline per line. let mods = crate::workspace_ui::sticky_mods(app, painter.ctx().input(|i| i.modifiers)); - if crate::type_transform::draw(app, painter, xf, mods) { - return; - } + let has_transform = crate::type_transform::draw(app, painter, xf, mods); let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone())); let frame = Stroke::new(1.0, t.accent); match shape { Some(photocraft_doc::text::TextShape::Box { x, y, width, height }) => { - let c = [scr_t(x, y), scr_t(x + width, y), scr_t(x + width, y + height), scr_t(x, y + height)]; - painter.add(egui::Shape::closed_line(c.to_vec(), frame)); - let mids = [c[0].lerp(c[1], 0.5), c[1].lerp(c[2], 0.5), c[2].lerp(c[3], 0.5), c[3].lerp(c[0], 0.5)]; - for p in c.iter().chain(mids.iter()) { - let r = egui::Rect::from_center_size(*p, egui::vec2(7.0, 7.0)); - painter.rect_filled(r, 0.0, Color32::WHITE); - painter.rect_stroke(r, 0.0, frame, egui::StrokeKind::Inside); + if !has_transform { + let c = [scr_t(x, y), scr_t(x + width, y), scr_t(x + width, y + height), scr_t(x, y + height)]; + painter.add(egui::Shape::closed_line(c.to_vec(), frame)); + let mids = [c[0].lerp(c[1], 0.5), c[1].lerp(c[2], 0.5), c[2].lerp(c[3], 0.5), c[3].lerp(c[0], 0.5)]; + for p in c.iter().chain(mids.iter()) { + let r = egui::Rect::from_center_size(*p, egui::vec2(7.0, 7.0)); + painter.rect_filled(r, 0.0, Color32::WHITE); + painter.rect_stroke(r, 0.0, frame, egui::StrokeKind::Inside); + } } } _ => { @@ -1070,18 +1104,24 @@ 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 }; - painter.line_segment([scr(x, top), scr(x, bot)], Stroke::new(1.5, c)); - painter.line_segment([scr(x, top), scr(x, bot)], Stroke::new(0.75, Color32::BLACK)); + for stroke in CARET_STROKES { + painter.line_segment([scr(x, top), scr(x, bot)], stroke); + } } } } +/// The text caret, drawn in order: a white halo, then a solid black line inside it, so it reads on +/// any background (#6085). The black line used to be a 0.75 pt hairline inside a white one, which +/// on a white background left only a faint grey line. +const CARET_STROKES: [Stroke; 2] = [Stroke { width: 3.0, color: Color32::WHITE }, Stroke { width: 1.0, color: Color32::BLACK }]; + /// The Type Mask tools while typing: Quick Mask's colour over the canvas with the glyphs clear, /// as Photoshop shows the selection-to-be. The glyphs come from the hidden working layer's pixels. fn draw_mask_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, id: LayerId) { @@ -1422,7 +1462,10 @@ pub(crate) fn family_picker_in( preview: photocraft_engine::prefs::FontPreview, ) -> bool { let mut changed = false; + // A font picked from the open menu (click or Enter): the field keeps the focus afterwards. + let mut picked = false; let search_id = ui.id().with(("font-search", salt)); + let focus_id = search_id.with("focus"); // The list opens on the press, so a press-drag-release picks a font too (#2735). let (press, close) = crate::press_menu::PressCombo::before(ui, salt); let labels: Option>> = ui.ctx().data(|d| d.get_temp(font_labels_id())); @@ -1438,26 +1481,25 @@ pub(crate) fn family_picker_in( .close_behavior(if matches!(close, egui::PopupCloseBehavior::IgnoreClicks) { close } else { egui::PopupCloseBehavior::CloseOnClickOutside }); let response = combo.show_ui(ui, |ui| { - let (pass_id, focus_id, height_id) = (search_id.with("pass"), search_id.with("focus"), search_id.with("height")); - let pass = ui.ctx().cumulative_pass_nr(); - let last_pass: Option = ui.data(|d| d.get_temp(pass_id)); - // Freshly opened (not drawn last pass): focus the search field whatever the query, and keep - // asking until it has focus (the first pass is egui's invisible sizing pass) (#1369). - let opened = last_pass.is_none_or(|p| p.saturating_add(1) < pass); - let mut focus = opened || ui.data(|d| d.get_temp(focus_id)).unwrap_or(false); + let height_id = search_id.with("height"); + // Each opening focuses the search, including a reopen on the very next pass. Keep + // asking through the invisible sizing pass, until the field receives focus. + let mut focus = ui.data(|d| d.get_temp(focus_id)).unwrap_or(true); let mut q: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); // Consume these before TextEdit and the canvas can interpret them as caret movement. let down = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)); let up = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)); let enter = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); - let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search fonts")).desired_width(200.0)); - if r.has_focus() { - focus = false; - } else if focus { - r.request_focus(); + if focus { + ui.memory_mut(|m| m.request_focus(search_id.with("input"))); + } + let r = ui.add(egui::TextEdit::singleline(&mut q).id(search_id.with("input")).hint_text(tl!("Search fonts")).desired_width(200.0)); + if focus { + // The opening click can release after several focused frames. Keep the request + // pending through that release, which can hand focus back to the combo button. + focus = !r.has_focus() || ui.input(|i| i.pointer.primary_down() || i.pointer.primary_released()); } ui.data_mut(|d| { - d.insert_temp(pass_id, pass); d.insert_temp(focus_id, focus); d.insert_temp(search_id, q.clone()); }); @@ -1479,6 +1521,7 @@ pub(crate) fn family_picker_in( } } let accept = enter && rows.iter().any(|(f, _)| *f == current); + picked |= accept; if accept { ui.data_mut(|d| d.remove::(search_id)); ui.close(); @@ -1488,7 +1531,11 @@ pub(crate) fn family_picker_in( let mut hovered: Option = None; for (f, label) in rows { - let mut button = egui::Button::selectable(f == current, label).truncate(); + // Highlighted by its fill alone, as Photoshop's font menu is. A selectable button lays + // out its frame's stroke only once hovered or selected, so with the theme's stroke the + // row under the pointer (and the selected one) grew and pushed the rows below down: + // the list jumped as the pointer moved (#7406). + let mut button = egui::Button::selectable(f == current, label).truncate().stroke(egui::Stroke::NONE); if let Some((size, [_, h])) = preview { button = button.right_text(egui::Atom::custom(ui.id().with(("font-sample", f)), egui::vec2(crate::font_preview::slot_width(ui.ctx(), f, size), h))); @@ -1510,6 +1557,7 @@ pub(crate) fn family_picker_in( // A served family not fetched yet: start now, before any text needs it. photocraft_text::served::request(f); changed = true; + picked = true; ui.data_mut(|d| d.remove::(search_id)); hovered = None; ui.close(); @@ -1527,8 +1575,43 @@ pub(crate) fn family_picker_in( if accept { None } else { hovered } }); press.after(&response.response); + if !egui::ComboBox::is_open(ui.ctx(), response.response.id) { + ui.data_mut(|d| d.remove::(focus_id)); + } let hovered_font = response.inner.unwrap_or_default(); + // As in Photoshop, the field keeps the keyboard focus once a font is picked, and ↑ / ↓ on the + // focused, closed field step to the previous / next family, applying each (#7407). The lock + // filter keeps egui from using those arrows to move the focus to another widget. + let field = response.response.id; + if picked { + ui.memory_mut(|m| m.request_focus(field)); + } + let open = egui::ComboBox::is_open(ui.ctx(), field); + if !open && ui.is_enabled() && ui.memory(|m| m.has_focus(field)) { + ui.memory_mut(|m| m.set_focus_lock_filter(field, egui::EventFilter { vertical_arrows: true, ..Default::default() })); + let down = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)); + let up = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)); + if down || up { + // The lock filter only holds from the frame after the field got the focus: an arrow + // right after a pick was already read by egui as a focus move, applied at the end of + // the frame. Cancel it, so the arrow only steps the font. + ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + // The wheel's step: a negative notch is the next family. + let notches = f32::from(u8::from(up)) - f32::from(u8::from(down)); + let rows = font_menu_rows(families, labels, ""); + if let Some(i) = rows.iter().position(|(f, _)| *f == current) + && let Some(n) = wheel_family_index(i, rows.len(), notches) + && let Some((f, _)) = rows.get(n) + && **f != *current + { + current.clone_from(f); + photocraft_text::served::request(f); + changed = true; + } + } + // The wheel over the closed button steps through the families, a notch each, like Photoshop's // options-bar font menu: no need to open the list (#3350). if !egui::ComboBox::is_open(ui.ctx(), response.response.id) && ui.is_enabled() { @@ -1553,7 +1636,9 @@ pub(crate) fn family_picker_in( let hover_id = egui::Id::new("type-font-hover"); if let Some(f) = hovered_font { ui.ctx().data_mut(|d| d.insert_temp(hover_id, f)); - } else { + } else if egui::ComboBox::is_open(ui.ctx(), response.response.id) { + // Only the open menu clears it: the options bar and the Character section both draw + // this picker, and the closed one must not wipe the other's hover (#5152). ui.ctx().data_mut(|d| d.remove::(hover_id)); } } @@ -2115,6 +2200,8 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa use crate::props_layout::{COL_GAP, LABEL_GAP, LABEL_W, field_width, section}; use crate::theme::ROW_GAP; let Some((c, para)) = styles_at(app) else { return }; + // Preferences ▸ Units & Rulers ▸ Type, as in the options bar: the model keeps points. + let (u, unit) = type_unit(app); let t = crate::theme::Tokens::get(ui.ctx()); // Row helper: `ui.horizontal` with the column gap, then the shared row gap. let row = |ui: &mut egui::Ui, add: &mut dyn FnMut(&mut egui::Ui)| { @@ -2152,13 +2239,13 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa let k = shown_scale(app); // Hover tips stay English until wired through `tl!` in a multi-locale change // (`every_tl_literal_is_translated` requires every complete catalog). - if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt * k, 0.1..=1296.0, "pt", w) { - app.ui.tool_options.type_size = v; - apply(app, ui.ctx(), json!({"size": v, "metricsAsShown": true})); + if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt * k * u, 0.1 * u..=1296.0 * u, unit, w) { + app.ui.tool_options.type_size = v / u; + apply(app, ui.ctx(), json!({"size": v / u, "metricsAsShown": true})); } let lead = c.leading_pt.unwrap_or(c.size_pt * para.auto_leading.max(0.01)) * k; - if let Some(v) = num_field(ui, "A↕", "Leading (set to the font size × auto-leading when Auto)", lead, 0.1..=5000.0, "pt", w) { - apply(app, ui.ctx(), json!({"leading": v, "metricsAsShown": true})); + if let Some(v) = num_field(ui, "A↕", "Leading (set to the font size × auto-leading when Auto)", lead * u, 0.1 * u..=5000.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"leading": v / u, "metricsAsShown": true})); } }); row(ui, &mut |ui| { @@ -2183,8 +2270,8 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa } }); row(ui, &mut |ui| { - if let Some(v) = num_field(ui, "Aª", "Baseline shift", c.baseline_shift_pt, -1296.0..=1296.0, "pt", w) { - apply(app, ui.ctx(), json!({"baselineShift": v})); + if let Some(v) = num_field(ui, "Aª", "Baseline shift", c.baseline_shift_pt * u, -1296.0 * u..=1296.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"baselineShift": v / u})); } // Colour: label + a swatch filling the rest of the second column. ui.horizontal(|ui| { @@ -2291,30 +2378,27 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa }); ui.add_space(ROW_GAP); row(ui, &mut |ui| { - if let Some(v) = num_field(ui, "→|", "Indent left margin", para.start_indent_pt, -1296.0..=1296.0, "pt", w) { - apply(app, ui.ctx(), json!({"startIndent": v})); + if let Some(v) = num_field(ui, "→|", "Indent left margin", para.start_indent_pt * u, -1296.0 * u..=1296.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"startIndent": v / u})); } - if let Some(v) = num_field(ui, "|←", "Indent right margin", para.end_indent_pt, -1296.0..=1296.0, "pt", w) { - apply(app, ui.ctx(), json!({"endIndent": v})); + if let Some(v) = num_field(ui, "|←", "Indent right margin", para.end_indent_pt * u, -1296.0 * u..=1296.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"endIndent": v / u})); } }); row(ui, &mut |ui| { - if let Some(v) = num_field(ui, "¶→", "Indent first line", para.first_line_indent_pt, -1296.0..=1296.0, "pt", w) { - apply(app, ui.ctx(), json!({"firstLineIndent": v})); + if let Some(v) = num_field(ui, "¶→", "Indent first line", para.first_line_indent_pt * u, -1296.0 * u..=1296.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"firstLineIndent": v / u})); } }); row(ui, &mut |ui| { - if let Some(v) = num_field(ui, "↑¶", "Add space before paragraph", para.space_before_pt, 0.0..=1296.0, "pt", w) { - apply(app, ui.ctx(), json!({"spaceBefore": v})); + if let Some(v) = num_field(ui, "↑¶", "Add space before paragraph", para.space_before_pt * u, 0.0 * u..=1296.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"spaceBefore": v / u})); } - if let Some(v) = num_field(ui, "¶↓", "Add space after paragraph", para.space_after_pt, 0.0..=1296.0, "pt", w) { - apply(app, ui.ctx(), json!({"spaceAfter": v})); + if let Some(v) = num_field(ui, "¶↓", "Add space after paragraph", para.space_after_pt * u, 0.0 * u..=1296.0 * u, unit, w) { + apply(app, ui.ctx(), json!({"spaceAfter": v / u})); } }); - let mut hy = para.hyphenate; - if crate::widgets::checkbox(ui, &mut hy, "Hyphenate").changed() { - apply(app, ui.ctx(), json!({"hyphenate": hy})); - } + // Hyphenate is stored (⌥⇧⌘H / PSD AutoHyphenate) but layout never breaks words (#6710). ui.add_space(ROW_GAP); } } @@ -2390,6 +2474,11 @@ fn type_options(app: &mut PhotocraftApp, ui: &mut egui::Ui) { pub fn cancel(app: &mut PhotocraftApp) { crate::type_transform::reset(app); let Some(ed) = app.ui.text_edit.take() else { return }; + if let Some(tool) = app.ui.text_edit_return_tool.take() + && app.ui.tool.is_type() + { + app.ui.tool = tool; + } undo_session(app, &ed); } @@ -2680,4 +2769,86 @@ 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 CARET_STROKES.contains(stroke))) + }; + + // 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(); + app.ui.tool = crate::state::Tool::Move; + assert!(edit_at(&mut app, ON_TEXT.0, ON_TEXT.1)); + assert_eq!(app.ui.tool, crate::state::Tool::Type); + assert_eq!(app.ui.text_edit_return_tool, Some(crate::state::Tool::Move)); + commit(&mut app); + assert_eq!(app.ui.tool, crate::state::Tool::Move, "commit restores the Move tool"); + assert_eq!(app.ui.text_edit_return_tool, None); + + // Also test cancel + app.ui.tool = crate::state::Tool::Move; + assert!(edit_at(&mut app, ON_TEXT.0, ON_TEXT.1)); + assert_eq!(app.ui.tool, crate::state::Tool::Type); + cancel(&mut app); + assert_eq!(app.ui.tool, crate::state::Tool::Move, "cancel restores the Move tool"); + assert_eq!(app.ui.text_edit_return_tool, None); + + // Switching tools mid-edit commits without overriding the newly chosen tool + app.ui.tool = crate::state::Tool::Move; + assert!(edit_at(&mut app, ON_TEXT.0, ON_TEXT.1)); + assert_eq!(app.ui.tool, crate::state::Tool::Type); + assert_eq!(app.ui.text_edit_return_tool, Some(crate::state::Tool::Move)); + app.ui.tool = crate::state::Tool::Brush; + commit(&mut app); + assert_eq!(app.ui.tool, crate::state::Tool::Brush, "switching tools keeps the chosen tool"); + assert_eq!(app.ui.text_edit_return_tool, None); + + // Layers-thumbnail edit stays on Type tool (matches Photoshop) + app.ui.tool = crate::state::Tool::Move; + edit_active(&mut app).unwrap(); + assert_eq!(app.ui.tool, crate::state::Tool::Type); + assert_eq!(app.ui.text_edit_return_tool, None); + commit(&mut app); + assert_eq!(app.ui.tool, crate::state::Tool::Type, "Layers-thumbnail edit stays on Type tool"); + } } diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 0f5be9e2fa..5cbca3e2b4 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -115,6 +115,49 @@ fn new_app() -> PhotocraftApp { app } +/// #6085: the text caret is a solid black line inside a white halo, so it shows on a white +/// background (the black used to be a 0.75 pt hairline inside a white line) and on a dark one. +#[test] +fn the_caret_reads_on_white_and_on_black() { + use crate::canvas::{ToolEvent, tool_event}; + let mut app = new_app(); + app.ui.tool = crate::state::Tool::Type; + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, Modifiers::NONE); + // A caret, not the placeholder's selection; solid, whatever the blink (as while dragging). + let ed = app.ui.text_edit.as_mut().expect("editing"); + ed.anchor = ed.caret; + ed.dragging = true; + let xf = ViewXform { + rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), + zoom: 1.0, + center: [100.0, 100.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let mut h = + Harness::builder().with_size(vec2(400.0, 300.0)).build_ui_state(move |ui, app: &mut PhotocraftApp| super::draw_overlay(app, ui.painter(), &xf), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.run(); + // Vertical line segments are the caret (the frame and underline run sideways). + let caret: Vec<(f32, egui::Color32)> = h + .output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::LineSegment { points: [a, b], stroke } if (a.x - b.x).abs() < 0.01 && (a.y - b.y).abs() > 1.0 => Some((stroke.width, stroke.color)), + _ => None, + }) + .collect(); + assert_eq!(caret.len(), 2, "a halo and a line: {caret:?}"); + let ((halo_w, halo), (line_w, line)) = (caret[0], caret[1]); + assert_eq!(line, egui::Color32::BLACK); + assert!(line_w >= 1.0, "a solid black line, not a hairline: {line_w}"); + assert_eq!(halo, egui::Color32::WHITE); + assert!(halo_w >= line_w + 2.0, "at least 1 pt of white on each side: {halo_w}"); +} + fn xf(app: &PhotocraftApp) -> ViewXform { let v = &app.ui.views[0]; ViewXform { @@ -779,6 +822,23 @@ fn temporary_type_transform_visibility_and_cursors() { assert!(crate::type_transform::visible(h.state(), sticky)); } +#[test] +fn type_selection_highlight_visible_with_command_held() { + let (app, _id) = transform_app(false, false, Affine::IDENTITY); + let mut h = harness(1.0, app); + if let Some(ed) = h.state_mut().ui.text_edit.as_mut() { + ed.anchor = 0; + ed.caret = 5; + } + hold(&mut h, type_command()); + h.run(); + let accent = crate::theme::Tokens::get(&h.ctx).accent; + let expected_fill = egui::Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 110); + let has_selection_highlight = + h.output().shapes.iter().any(|clipped| if let egui::Shape::Path(path) = &clipped.shape { path.fill == expected_fill && path.closed } else { false }); + assert!(has_selection_highlight, "selection highlight must remain visible while Ctrl/Cmd is held"); +} + /// Real canvas input: layer-local scaling stays correct across orientation, zoom, HiDPI and flip. #[test] fn temporary_type_transform_scales_without_reflow_or_leaving_type() { @@ -904,6 +964,33 @@ fn temporary_type_transform_free_scale_center_scale_move_skew_and_rotation() { } } +/// Editing paragraph text, a click inside the box but off the glyphs moves the caret to the +/// nearest offset, as in Photoshop; a click outside the box still commits. +#[test] +fn click_inside_the_paragraph_box_off_the_glyphs_moves_the_caret() { + use crate::canvas::{ToolEvent, tool_event}; + // The box is 240 × 180; the two 32 pt lines only fill its top. + let (mut app, id) = transform_app(true, false, Affine::translate(400.0, 350.0)); + let (bx, by, _, h) = super::box_shape(&app, id).unwrap(); + let (l, aff, _) = layout(&mut app, id).unwrap(); + let click = |app: &mut PhotocraftApp, tx: f32, ty: f32| { + let p = aff.apply(Point::new(f64::from(tx), f64::from(ty))); + tool_event(app, ToolEvent::Down { x: p.x, y: p.y, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Up { x: p.x, y: p.y }, Modifiers::NONE); + }; + let layers = app.session.active().unwrap().doc.layers.len(); + // Near the bottom of the box, just right of its left edge. + assert!(!photocraft_text::text_point_inside(&l, bx + 8.0, by + h - 8.0, 6.0), "the point is off the glyphs"); + click(&mut app, bx + 8.0, by + h - 8.0); + let ed = app.ui.text_edit.clone().expect("still editing"); + // "Hello é世界\n" is 10 characters: the start of the second line. + assert_eq!((ed.layer, ed.caret, ed.anchor), (id.0, 10, 10)); + // Outside the box the edit commits, without starting new text. + click(&mut app, bx - 40.0, by + h - 8.0); + assert!(app.ui.text_edit.is_none()); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers); +} + #[test] fn temporary_type_transform_noop_cancel_and_stale_capture() { use crate::canvas::{ToolEvent, tool_event}; @@ -1113,6 +1200,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() { @@ -1271,6 +1359,9 @@ fn font_styles_keep_metadata_names_and_numeric_labels() { #[test] fn postscript_only_style_shows_actual_subfamily() { + // selected_style reads the engine only once the font scan is done (try_shared). Under nextest + // this test runs in a process of its own, where nothing else has started the scan, so start it. + let _ = photocraft_text::shared(); let style = photocraft_doc::text::CharStyle { font_family: "Inter".into(), postscript_name: Some("Inter-SemiBold".into()), ..Default::default() }; assert_eq!(super::selected_style(&style), "SemiBold"); } @@ -1487,6 +1578,14 @@ mod multi_layer_formatting { h.run(); } + #[test] + fn hyphenate_is_not_shown_while_layout_does_not_hyphenate() { + // #6710: layout never hyphenates; the control must stay hidden until it does. + let (app, _) = fixture(); + let h = controls(app, true); + assert!(h.query_by_label("Hyphenate").is_none()); + } + #[test] fn properties_edit_shown_metrics_and_paragraphs_with_a_non_type_primary() { let (mut app, ids) = fixture(); @@ -1504,8 +1603,6 @@ mod multi_layer_formatting { h.run(); h.get_by_label("Center text").click(); h.run(); - h.get_by_label("Hyphenate").click(); - h.run(); number(&mut h, "Indent left margin", "9"); for (id, scale) in [(ids[0], 2.0), (ids[1], 4.0)] { let c = style(h.state(), id, 0); @@ -1514,7 +1611,6 @@ mod multi_layer_formatting { assert!(c.faux_bold); let p = text(h.state(), id).paragraph_runs()[0].style.clone(); assert_eq!(p.align, TextAlign::Center); - assert!(p.hyphenate); assert_eq!(p.start_indent_pt, 9.0); } assert_eq!(style(h.state(), ids[2], 0), untouched); @@ -1528,6 +1624,47 @@ mod multi_layer_formatting { assert_eq!(text(h.state(), ids[1]).char_runs(), peer); } + /// #6491: with Units & Rulers ▸ Type set to pixels, the Character and Paragraph fields show + /// and take pixels, like the options bar. 144 ppi so a pixel is not a point. + #[test] + fn character_and_paragraph_fields_follow_the_type_units_preference() { + use egui_kittest::kittest::NodeT; + let (mut app, ids) = fixture(); + app.run("prefs.set", json!({"path": "unitsAndRulers.typeUnits", "value": "pixels"})).unwrap(); + app.run("image.imageSize", json!({"resolution": 144, "resample": "none"})).unwrap(); + selected(&mut app, &[ids[0]]); + let mut h = controls(app, true); + let shown = h.get_by_role_and_label(Role::SpinButton, "Font size").accesskit_node().numeric_value(); + assert_eq!(shown, Some(24.0), "12 pt at 144 ppi"); + number(&mut h, "Font size", "60"); + number(&mut h, "Leading (set to the font size × auto-leading when Auto)", "84"); + number(&mut h, "Baseline shift", "10"); + number(&mut h, "Indent left margin", "9"); + let c = style(h.state(), ids[0], 0); + assert_eq!((c.size_pt, c.leading_pt, c.baseline_shift_pt), (30.0, Some(42.0), 5.0)); + assert_eq!(text(h.state(), ids[0]).paragraph_runs()[0].style.start_indent_pt, 4.5); + } + + /// #5717: a layer scaled so its shown size or leading is past a field's range (2400 pt, or + /// 0.08 pt) keeps it while the Character panel and the options bar only show it. + #[test] + fn showing_a_scaled_layer_past_the_size_range_does_not_change_it() { + for (layer, scale) in [(0, 200.0), (1, 0.005)] { + for properties in [true, false] { + let (mut app, ids) = fixture(); + let id = ids[layer]; + app.run("type.edit", json!({"layer": id.0, "transform": [scale, 0.0, 0.0, scale, 0.0, 0.0]})).unwrap(); + selected(&mut app, &[id]); + let before = text(&app, id).char_runs(); + let steps = app.session.active().unwrap().history.entries().len(); + let mut h = controls(app, properties); + h.run_steps(3); + assert_eq!(text(h.state(), id).char_runs(), before, "scale {scale}, properties {properties}"); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps, "no history step"); + } + } + } + #[test] fn formatting_menus_isolate_the_edit_layer_and_range_but_honor_explicit_targets() { let (mut app, ids) = fixture(); diff --git a/crates/ui-egui/src/type_transform.rs b/crates/ui-egui/src/type_transform.rs index 2a68366d6e..0c1b4837ed 100644 --- a/crates/ui-egui/src/type_transform.rs +++ b/crates/ui-egui/src/type_transform.rs @@ -52,6 +52,7 @@ pub(crate) fn cancel_stale(app: &mut PhotocraftApp) { cancel_drag(app); if lost_layer { app.ui.text_edit = None; + app.ui.text_edit_return_tool = None; } } } @@ -349,16 +350,6 @@ pub(crate) fn draw_box(painter: &egui::Painter, xf: &ViewXform, t: &TransformSes } } -pub(crate) fn resize_cursor(d: [f64; 2], flip: bool) -> CursorIcon { - let angle = d[1].atan2(if flip { -d[0] } else { d[0] }).rem_euclid(std::f64::consts::PI); - match (angle / std::f64::consts::FRAC_PI_4).round() as u8 % 4 { - 0 => CursorIcon::ResizeHorizontal, - 1 => CursorIcon::ResizeNwSe, - 2 => CursorIcon::ResizeVertical, - _ => CursorIcon::ResizeNeSw, - } -} - pub(crate) fn rotation_cursor(ctx: &egui::Context, p: Pos2) { let tokens = crate::theme::Tokens::get(ctx); let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("type-rotate-cursor"))); @@ -399,7 +390,7 @@ pub(crate) fn cursor(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXfor let b = if handle.is_multiple_of(2) { t.quad[3] } else { t.quad[1] }; [b[0] - t.quad[0][0], b[1] - t.quad[0][1]] }; - return Some(resize_cursor(direction, xf.flip)); + return Some(crate::tool_cursor::resize(direction, xf)); } let Some(t) = frame(app) else { return Some(CursorIcon::Default) }; let h = app.ui.type_transform.as_ref().map(|t| t.gesture.hit).or_else(|| hit(app, &t, xf.to_doc(p))); @@ -407,7 +398,7 @@ pub(crate) fn cursor(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXfor Some(Hit::Corner(i)) => { let q = *t.quad.get(i)?; let opposite = *t.quad.get((i + 2) % 4)?; - resize_cursor([q[0] - opposite[0], q[1] - opposite[1]], xf.flip) + crate::tool_cursor::resize([q[0] - opposite[0], q[1] - opposite[1]], xf) } Some(Hit::Edge(_)) => { // Original arrowhead indicator, above the canvas, without a proprietary cursor asset. diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index e9d998b046..8b5e914af2 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -104,6 +104,19 @@ fn shape_geometry(o: &ToolOptions, tool: Tool, start: [f64; 2], end: [f64; 2], m }) } +/// The box `[x0, y0, x1, y1]` a Shape-tool drag draws (a line's: its ends'), which snaps while +/// Space repositions it (`snap_ui`). None when too small to draw. +pub fn shape_bounds(o: &ToolOptions, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option<[f64; 4]> { + let g = shape_geometry(o, tool, start, end, mods)?; + if tool == Tool::Line { + let a: [f64; 2] = serde_json::from_value(g["from"].clone()).ok()?; + let b: [f64; 2] = serde_json::from_value(g["to"].clone()).ok()?; + return Some([a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]); + } + let [x, y, w, h]: [f64; 4] = serde_json::from_value(g["rect"].clone()).ok()?; + Some([x, y, x + w, y + h]) +} + /// Finish a Shape-tool drag: ⇧ constrains proportions, ⌥ draws from the centre. pub fn finish_shape(app: &mut PhotocraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) { let Some(mut p) = shape_geometry(&app.ui.tool_options, tool, start, end, mods) else { return }; @@ -206,8 +219,9 @@ pub fn closes_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { } /// Pen press: close on the first anchor, reshape the last anchor's outgoing handle, -/// or add a new anchor (dragging a new anchor pulls symmetrical handles). -pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64) { +/// or add a new anchor (dragging a new anchor pulls symmetrical handles). With ⇧ a new anchor +/// lands on the nearest 45° ray from the previous one, at the pointer's projection onto it. +pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { let tol = 6.0 / app.point_zoom().max(0.01) as f64; let pen = app.ui.pen.get_or_insert_with(PenPath::default); if let Some(first) = pen.knots.first().map(|k| k[0]) @@ -234,18 +248,25 @@ pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64) { pen.adjusting_last = true; return; } - pen.knots.push([[x, y]; 3]); + let p = match pen.knots.last() { + Some(prev) => shift_45(prev[0], [x, y], shift), + None => [x, y], + }; + pen.knots.push([p; 3]); pen.dragging = true; pen.adjusting_last = false; } -pub fn pen_move(app: &mut PhotocraftApp, x: f64, y: f64) { +/// Pen drag: pull out the handles of the anchor just placed. ⇧ (read live, as it is held) +/// turns the handle to the nearest 45° direction from its anchor. +pub fn pen_move(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { if let Some(pen) = app.ui.pen.as_mut() && pen.dragging { let adjusting_last = pen.adjusting_last; if let Some(k) = pen.knots.last_mut() { let a = k[0]; + let [x, y] = shift_45(a, [x, y], shift); k[2] = [x, y]; if !adjusting_last { k[1] = [2.0 * a[0] - x, 2.0 * a[1] - y]; @@ -254,6 +275,11 @@ pub fn pen_move(app: &mut PhotocraftApp, x: f64, y: f64) { } } +/// `p`, or with ⇧ `p` projected onto the nearest 45° ray from `origin` (Pen tool constraint). +fn shift_45(origin: [f64; 2], p: [f64; 2], shift: bool) -> [f64; 2] { + if shift { crate::stroke_constraint::snap45(origin, p) } else { p } +} + /// Undo the most recently placed Pen anchor while its path is still in progress. /// These knots are editor-only gesture state, not document history until Enter or /// clicking the first anchor commits the path. Therefore Cmd/Ctrl+Z must consume one @@ -545,6 +571,11 @@ fn curvature_refresh(pen: &mut PenPath, closed: bool) { pen.knots = photocraft_vector::edit::curvature_knots(&anchors, &corners, closed); } +/// The anchor of the Curvature Pen path being drawn under (x, y), for the cursor. +pub(crate) fn curvature_anchor_at(app: &PhotocraftApp, x: f64, y: f64) -> Option { + curvature_hit(app, x, y) +} + /// The Curvature Pen's anchor within a few screen pixels of (x, y). fn curvature_hit(app: &PhotocraftApp, x: f64, y: f64) -> Option { let tol = 6.0 / app.point_zoom().max(0.01) as f64; @@ -593,8 +624,14 @@ pub fn curvature_up(app: &mut PhotocraftApp) { } /// Curvature Pen double-click: toggle the anchor under the pointer between smooth and corner -/// (double-clicking empty canvas therefore places a corner point, as in Photoshop). +/// (double-clicking empty canvas therefore places a corner point, as in Photoshop). With no path +/// being drawn, an anchor of the active path toggles the same way and its subpath reflows +/// (`path.convertPoint` with `curvature`), so a Pen-drawn corner rounds off. pub fn curvature_toggle(app: &mut PhotocraftApp, x: f64, y: f64) { + if app.ui.pen.as_ref().is_none_or(|p| p.knots.is_empty()) { + crate::direct_select::curvature_toggle_active(app, [x, y]); + return; + } let Some(i) = curvature_hit(app, x, y) else { return }; let Some(pen) = app.ui.pen.as_mut() else { return }; if let Some(at) = pen.unlinked.iter().position(|&c| c == i) { @@ -701,6 +738,19 @@ pub(crate) fn active_shape_path(app: &PhotocraftApp) -> Option<(u64, Path)> { } } +/// Does ⌫ / Delete delete the work path? With the Pen, Freeform Pen or Path Selection tool, no +/// path being drawn and the work path the one these tools show and edit (no shape layer or +/// targeted vector mask in front of it), as in Photoshop (#5702). It was drawn whenever one of +/// these tools was active, but the key reached Edit › Clear, so it looked impossible to delete. +/// Direct Selection and the Curvature Pen delete selected anchors in Photoshop, not the path. +pub(crate) fn delete_key_deletes_work_path(app: &PhotocraftApp) -> bool { + matches!(app.ui.tool, Tool::Pen | Tool::FreeformPen | Tool::PathSelection) + && app.ui.pen.is_none() + && app.ui.transform.is_none() + && app.ui.view.shows(app.ui.view.show.target_path) + && matches!(target_path(app), Some((PathTarget::Work, _))) +} + /// The path Path Selection edits: the targeted vector mask, the active shape layer's path, else /// the work path. fn target_path(app: &PhotocraftApp) -> Option<(PathTarget, Path)> { @@ -931,7 +981,6 @@ pub(crate) fn draw_handle(painter: &egui::Painter, anchor: Pos2, handle: Pos2, a /// Work path / active shape path outlines, anchors, and the pen path in progress. pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, doc: &Document) { let tool = app.ui.tool; - let vector_tool = matches!(tool, Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::PathSelection) || is_shape_tool(tool); let accent = Tokens::get(painter.ctx()).accent; let to_scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); let draw_path = |p: &Path, anchors: bool| { @@ -952,23 +1001,22 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform crate::anchor_tools::draw_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); } } else if paths { - if vector_tool { - if let Some(wp) = &doc.work_path { - draw_path(wp, tool == Tool::PathSelection); - } - let st = app.session.active(); - if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))) - && let LayerContent::Shape(sh) = &l.content - { - draw_path(&sh.path, tool == Tool::PathSelection); - } + // Paths remain visible as outlines with any tool. Path Selection adds anchor squares; Direct + // Selection has its own overlay above. + if let Some(wp) = &doc.work_path { + draw_path(wp, tool == Tool::PathSelection); + } + let st = app.session.active(); + if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))) + && let LayerContent::Shape(sh) = &l.content + { + draw_path(&sh.path, tool == Tool::PathSelection); } // A targeted vector mask shows its path with any tool (#196). if let Some((_, p)) = targeted_vector_mask(app) { draw_path(&p, tool == Tool::PathSelection); } - // The segment the Pen's Auto Add/Delete would add an anchor to, or the anchor it would delete. - crate::anchor_tools::draw_pen_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); + crate::corner_radius::draw(app, painter, &to_scr, accent); } // Pen path in progress, with handles of the last knot and a rubber band to the pointer. While // ⌘/Ctrl drags it, Direct Selection draws its preview instead. @@ -988,9 +1036,12 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform pts.extend(curve_points(w[0][0], w[0][2], w[1][1], w[1][0], &to_scr)); } painter.add(egui::Shape::line(pts, Stroke::new(1.5, accent))); + // No rubber band where a click adds or deletes an anchor of this path instead (#6140). + let auto = app.hover_doc.is_some_and(|h| crate::anchor_tools::pen_auto(app, tool, painter.ctx().input(|i| i.modifiers), h).is_some()); if let (Some(last), Some(h)) = (pen.knots.last(), app.hover_doc) && !pen.dragging && pen.moving.is_none() + && !auto { if pen.curvature && pen.knots.len() >= 2 { // The Curvature Pen previews the curve the next click makes through the pointer. @@ -1002,6 +1053,9 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, accent), 4.0, 3.0)); } } else { + // The rubber band ends where a ⇧-click would put the next anchor. + let shift = painter.ctx().input(|i| i.modifiers.shift); + let h = if pen.curvature { h } else { shift_45(last[0], h, shift) }; painter.add(egui::Shape::dashed_line(&[to_scr(last[0]), to_scr(h)], Stroke::new(1.0, accent), 4.0, 3.0)); } } @@ -1026,6 +1080,11 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform painter.circle_stroke(c + vec2(11.0, 11.0), 3.5, Stroke::new(1.5, accent)); } } + // The segment the Pen's Auto Add/Delete would add an anchor to, or the anchor it would delete, + // over the path being drawn. While drawing, the other paths can be hidden: it edits none of them. + if paths || app.ui.pen.is_some() { + crate::anchor_tools::draw_pen_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); + } } // --------------------------------------------------------------------------------------------- @@ -2076,11 +2135,11 @@ mod tests { fn pen_shows_the_close_cue_only_over_the_first_anchor_of_a_real_segment() { let mut app = app(); app.ui.tool = Tool::Pen; - pen_down(&mut app, 20.0, 20.0); + pen_down(&mut app, 20.0, 20.0, false); pen_up(&mut app); let one = app.ui.pen.clone().unwrap(); assert!(!over_first_anchor(&one, [20.0, 20.0], 6.0), "a single anchor has nothing to close"); - pen_down(&mut app, 120.0, 20.0); + pen_down(&mut app, 120.0, 20.0, false); pen_up(&mut app); let two = app.ui.pen.clone().unwrap(); assert!(over_first_anchor(&two, [22.0, 21.0], 6.0)); @@ -2092,13 +2151,13 @@ mod tests { let mut app = app(); app.ui.tool = Tool::Pen; for (x, y) in [(20.0, 20.0), (120.0, 20.0)] { - pen_down(&mut app, x, y); + pen_down(&mut app, x, y, false); pen_up(&mut app); } - pen_down(&mut app, 120.0, 120.0); - pen_move(&mut app, 140.0, 140.0); // smooth knot + pen_down(&mut app, 120.0, 120.0, false); + pen_move(&mut app, 140.0, 140.0, false); // smooth knot pen_up(&mut app); - pen_down(&mut app, 20.5, 20.5); // click the first anchor: close + pen_down(&mut app, 20.5, 20.5, false); // click the first anchor: close pen_up(&mut app); assert!(app.ui.pen.is_none()); let wp = app.session.active().unwrap().doc.work_path.clone().unwrap(); @@ -2110,6 +2169,75 @@ mod tests { assert_eq!((wp.subpaths[0].knots[0].anchor.x, wp.subpaths[0].knots[0].anchor.y), (30.0, 25.0)); } + fn close(a: [f64; 2], b: [f64; 2]) -> bool { + (a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9 + } + + #[test] + fn pen_shift_click_puts_the_next_anchor_on_a_45_degree_ray() { + let mut app = app(); + app.ui.tool = Tool::Pen; + // The first anchor has nothing to constrain against: it lands where clicked. + pen_down(&mut app, 20.0, 20.0, true); + pen_up(&mut app); + assert_eq!(app.ui.pen.as_ref().unwrap().knots[0][0], [20.0, 20.0]); + // ~30° below horizontal snaps to the 45° diagonal at the pointer's projection. + let (dx, dy) = (100.0 * 30f64.to_radians().cos(), 100.0 * 30f64.to_radians().sin()); + pen_down(&mut app, 20.0 + dx, 20.0 + dy, true); + pen_up(&mut app); + let k = app.ui.pen.as_ref().unwrap().knots[1]; + let t = 100.0 * 15f64.to_radians().cos() / std::f64::consts::SQRT_2; + assert!(close(k[0], [20.0 + t, 20.0 + t]), "{:?}", k[0]); + assert!(k[1] == k[0] && k[2] == k[0], "a click stays a corner"); + // ~10° off vertical snaps to straight down from the previous anchor. + let prev = k[0]; + pen_down(&mut app, prev[0] + 8.0, prev[1] + 50.0, true); + pen_up(&mut app); + assert!(close(app.ui.pen.as_ref().unwrap().knots[2][0], [prev[0], prev[1] + 50.0])); + // Without ⇧ the anchor stays at the pointer. + pen_down(&mut app, 200.0, 190.0, false); + pen_up(&mut app); + assert_eq!(app.ui.pen.as_ref().unwrap().knots[3][0], [200.0, 190.0]); + // ⇧ does not stop a click on the first anchor from closing the path. + pen_down(&mut app, 20.5, 20.5, true); + pen_up(&mut app); + assert!(app.ui.pen.is_none()); + assert!(app.session.active().unwrap().doc.work_path.as_ref().unwrap().subpaths[0].closed); + } + + #[test] + fn pen_shift_drag_turns_handles_to_45_degrees_live() { + let mut app = app(); + app.ui.tool = Tool::Pen; + pen_down(&mut app, 20.0, 20.0, false); + pen_up(&mut app); + pen_down(&mut app, 100.0, 100.0, false); + // Without ⇧ the handle follows the pointer. + pen_move(&mut app, 130.0, 110.0, false); + assert_eq!(app.ui.pen.as_ref().unwrap().knots[1][2], [130.0, 110.0]); + // Pressing ⇧ mid-drag snaps the handle (~18°) to horizontal; the opposite handle mirrors it. + pen_move(&mut app, 130.0, 110.0, true); + let k = app.ui.pen.as_ref().unwrap().knots[1]; + assert!(close(k[2], [130.0, 100.0]) && close(k[1], [70.0, 100.0]), "{k:?}"); + assert_eq!(k[0], [100.0, 100.0], "the anchor does not move"); + // Releasing ⇧ frees it again. + pen_move(&mut app, 120.0, 140.0, false); + assert_eq!(app.ui.pen.as_ref().unwrap().knots[1][2], [120.0, 140.0]); + // ⌥ breaks the pair; ⇧ then constrains the outgoing handle alone (~63° → 45°). + pen_break_handle(&mut app); + pen_move(&mut app, 120.0, 140.0, true); + let k = app.ui.pen.as_ref().unwrap().knots[1]; + assert!(close(k[2], [130.0, 130.0]), "{k:?}"); + assert_eq!(k[1], [80.0, 60.0], "the incoming handle stays where it was"); + pen_up(&mut app); + // Degenerate: ⇧ with the pointer on the anchor leaves the handle on it. + pen_down(&mut app, 200.0, 100.0, false); + pen_move(&mut app, 200.0, 100.0, true); + let k = app.ui.pen.as_ref().unwrap().knots[2]; + assert!(k.iter().all(|p| *p == [200.0, 100.0]), "{k:?}"); + pen_up(&mut app); + } + /// #4721: a second Pen path joins the Work Path it was drawn after (still selected), as a new /// subpath, instead of replacing it; a saved path that is selected takes it instead; with no /// path selected, the new path replaces the Work Path, as in Photoshop. @@ -2119,10 +2247,10 @@ mod tests { app.ui.tool = Tool::Pen; let draw = |app: &mut PhotocraftApp, x: f64| { for (dx, dy) in [(0.0, 0.0), (40.0, 0.0), (40.0, 40.0)] { - pen_down(app, x + dx, 20.0 + dy); + pen_down(app, x + dx, 20.0 + dy, false); pen_up(app); } - pen_down(app, x + 0.5, 20.5); // close + pen_down(app, x + 0.5, 20.5, false); // close pen_up(app); }; let work = |app: &PhotocraftApp| app.session.active().unwrap().doc.work_path.clone().unwrap(); @@ -2163,16 +2291,16 @@ mod tests { let mut app = app(); app.ui.tool = Tool::Pen; assert!(!closes_path_at(&app, Tool::Pen, 20.0, 20.0), "no path yet"); - pen_down(&mut app, 20.0, 20.0); + pen_down(&mut app, 20.0, 20.0, false); pen_up(&mut app); assert!(!closes_path_at(&app, Tool::Pen, 20.0, 20.0), "one anchor: a click there doesn't close"); - pen_down(&mut app, 120.0, 20.0); + pen_down(&mut app, 120.0, 20.0, false); assert!(!closes_path_at(&app, Tool::Pen, 20.0, 20.0), "not while dragging the new anchor"); pen_up(&mut app); assert!(closes_path_at(&app, Tool::Pen, 21.0, 22.0), "over the first anchor"); assert!(!closes_path_at(&app, Tool::Pen, 120.0, 20.0) && !closes_path_at(&app, Tool::Pen, 60.0, 60.0), "the last anchor and empty canvas"); assert!(!closes_path_at(&app, Tool::CurvaturePen, 20.0, 20.0), "a Pen path isn't the Curvature Pen's"); - pen_down(&mut app, 21.0, 22.0); + pen_down(&mut app, 21.0, 22.0, false); pen_up(&mut app); assert!(app.ui.pen.is_none() && !closes_path_at(&app, Tool::Pen, 20.0, 20.0), "the click closed it"); @@ -2195,12 +2323,12 @@ mod tests { app.run("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 40], "fill": "#ff0000"})).unwrap(); let old_history = app.session.active().unwrap().history.past_len(); - pen_down(&mut app, 20.0, 20.0); + pen_down(&mut app, 20.0, 20.0, false); pen_up(&mut app); - pen_down(&mut app, 120.0, 20.0); - pen_move(&mut app, 120.0, 60.0); + pen_down(&mut app, 120.0, 20.0, false); + pen_move(&mut app, 120.0, 60.0, false); pen_up(&mut app); - pen_down(&mut app, 120.0, 120.0); + pen_down(&mut app, 120.0, 120.0, false); pen_up(&mut app); let original_first_two = app.ui.pen.as_ref().unwrap().knots[..2].to_vec(); @@ -2221,7 +2349,7 @@ mod tests { fn pen_undo_of_first_anchor_cancels_empty_draft() { let mut app = app(); assert!(!pen_undo_last_point(&mut app)); - pen_down(&mut app, 15.0, 17.0); + pen_down(&mut app, 15.0, 17.0, false); pen_up(&mut app); assert!(pen_undo_last_point(&mut app)); assert!(app.ui.pen.is_none()); @@ -2243,10 +2371,10 @@ mod tests { // Drawing a work path with the Pen selects it. app.ui.tool = Tool::Pen; for (x, y) in [(20.0, 20.0), (120.0, 20.0), (120.0, 120.0)] { - pen_down(&mut app, x, y); + pen_down(&mut app, x, y, false); pen_up(&mut app); } - pen_down(&mut app, 20.5, 20.5); + pen_down(&mut app, 20.5, 20.5, false); pen_up(&mut app); assert_eq!(app.ui.selected_path.as_deref(), Some("work")); let wp = app.session.active().unwrap().doc.work_path.clone().unwrap(); @@ -2336,13 +2464,13 @@ mod tests { for zoom in [0.25, 1.0, 4.0] { let mut app = app(); app.ui.views[0].zoom = zoom; - pen_down(&mut app, 20.0, 20.0); + pen_down(&mut app, 20.0, 20.0, false); pen_up(&mut app); - pen_down(&mut app, 100.0, 100.0); - pen_move(&mut app, 110.0, 120.0); + pen_down(&mut app, 100.0, 100.0, false); + pen_move(&mut app, 110.0, 120.0, false); pen_up(&mut app); let incoming = app.ui.pen.as_ref().unwrap().knots[1][1]; - pen_down(&mut app, 100.0 + 5.0 / f64::from(zoom), 100.0); + pen_down(&mut app, 100.0 + 5.0 / f64::from(zoom), 100.0, false); pen_up(&mut app); let pen = app.ui.pen.as_ref().unwrap(); assert_eq!(pen.knots.len(), 2); @@ -2352,7 +2480,7 @@ mod tests { let restored: PenPath = serde_json::from_value(saved).unwrap(); assert_eq!(restored, *pen); assert_eq!(pen_to_json(&restored, false)["subpaths"][0]["knots"][1]["smooth"], false); - pen_down(&mut app, 100.0 + 7.0 / f64::from(zoom), 100.0); + pen_down(&mut app, 100.0 + 7.0 / f64::from(zoom), 100.0, false); assert_eq!(app.ui.pen.as_ref().unwrap().knots.len(), 3); } let legacy: PenPath = serde_json::from_value(json!({"knots": [[[10, 10], [10, 10], [10, 10]]]})).unwrap(); diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 793257be8b..ae19bfa6a3 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, }) } @@ -612,7 +620,7 @@ fn no_params(p: &Value) -> bool { /// `id` isn't ours. pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { if wraps(id) { - return front(app, id, params); + return front(app, ctx, id, params); } if !handles(id) { return None; @@ -985,6 +993,11 @@ fn form(app: &mut PhotocraftApp, command: &str, label: &str, fields: Value, choi app.ui.open_dialog(DialogKind::Command, f) } +/// The Batch and Image Processor dialogs' "Include All Subfolders" checkbox reads as Photoshop's. +fn subfolders_label() -> Value { + json!({"includeSubfolders": tl!("Include All Subfolders")}) +} + /// Alt+Cancel (Reset): restore the `__form` params captured at open. pub fn reset_form(f: &mut Map) { if !f.contains_key("__form") { @@ -1154,7 +1167,7 @@ fn default_dir(app: &PhotocraftApp) -> String { app.session.active().and_then(|d| d.path.as_deref()).and_then(|p| p.rfind(['/', '\\']).map(|i| p[..i].to_string())).unwrap_or_else(|| ".".into()) } -fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option> { +fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { if id == "file.closeAll" || id == "file.closeOthers" { // Keep the remaining document's view (views are index-aligned with documents). let keep = app.session.active_index().and_then(|i| app.ui.views.get(i).cloned()); @@ -1192,15 +1205,24 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option open_exr_parts_dialog(app), "file.saveACopy" => Some(save_a_copy(app)), "file.placeEmbedded" | "file.placeLinked" => { + if app.ui.transform.is_some() { + return Some(Err("Commit or cancel the transform first".into())); + } let doc = match app.active_doc_id() { Ok(doc) => doc, Err(e) => return Some(Err(e)), }; let linked = id == "file.placeLinked"; + let ctx = ctx.clone(); Some(app.pick_file_bytes(move |app, name, bytes| { + if app.ui.transform.is_some() { + return Err("Commit or cancel the transform first".into()); + } app.refocus(doc)?; let linked = linked.then(|| name.clone()); - app.place_bytes(&name, bytes, linked) + let result = app.place_bytes(&name, bytes, linked)?; + crate::transform_tool::begin_placed(app, &ctx)?; + Ok(result) })) } "layer.smartObjects.replaceContents" => { @@ -1313,7 +1335,7 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option dialog( app, - json!({"input": dir, "output": format!("{dir}/processed"), "format": "jpg", "quality": 8, "width": 0, "height": 0, "convertToSrgb": true}), + json!({"input": dir, "includeSubfolders": false, "__labels": subfolders_label(), "output": format!("{dir}/processed"), "format": "jpg", "quality": 8, "width": 0, "height": 0, "convertToSrgb": true}), json!({"format": ["jpg", "png", "psd", "tiff", "webp"]}), ), "file.automate.batch" => { @@ -1324,7 +1346,7 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option Result { let suggested = format!("{stem} copy.psd"); // The copy is of the document as it was when asked. app.pick_save(&suggested, move |app, path| { - // An RGB Targa asks for its bits per pixel first; the copy is written from the prompt. + // An RGB Targa asks for its bits per pixel first, and a DDS texture for its compression; + // the copy is written from the prompt. let parked = app.with_document(doc.id, |app| { - let ask = crate::targa_options_ui::wants_prompt(app, &path); - if ask { + if crate::targa_options_ui::wants_prompt(app, &path) { crate::targa_options_ui::park(app, path.clone(), true, "file.saveACopy")?; + return Ok(true); } - Ok(ask) + if crate::dds_options_ui::wants_prompt(&path) { + crate::dds_options_ui::park(app, path.clone(), true, "file.saveACopy")?; + return Ok(true); + } + Ok(false) })?; if parked { return Ok(json!({"path": path, "warnings": []})); } - let r = write_copy(app, &doc, &path, None)?; + let r = write_copy(app, &doc, &path, crate::FormatChoices::default())?; app.session.journal.push(("file.saveACopy".into(), json!({"path": path}))); Ok(r) }) } /// Writes `doc` to `path` as a copy (Save a Copy, and its replayed action step): the document's -/// path and saved state are untouched. `tga_bits` is the Targa answer, when there is one. -pub(crate) fn write_copy( - app: &mut PhotocraftApp, - doc: &photocraft_doc::Document, - path: &str, - tga_bits: Option, -) -> Result { - let settings = crate::ExportSettings { tga_bits, ..app.save_settings() }; +/// path and saved state are untouched. `choices` are the Targa and DDS answers, when there are any. +pub(crate) fn write_copy(app: &mut PhotocraftApp, doc: &photocraft_doc::Document, path: &str, choices: crate::FormatChoices) -> Result { + let settings = choices.apply(app.save_settings()); let export = app.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(doc, path, &settings)?; let write = app.services.write.as_mut().ok_or("no writer configured")?; diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 7ef3100d9e..407d8e6824 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); @@ -327,8 +353,8 @@ fn new_guide_layout_remembers_successful_values_not_cancelled_edits() { #[test] fn new_guide_layout_does_not_remember_a_failed_command() { - let (mut app, _) = app_with(0); - let id = front(&mut app, "view.newGuideLayout", &json!({})).unwrap().unwrap()["dialog"].as_u64().unwrap(); + let (mut app, ctx) = app_with(0); + let id = front(&mut app, &ctx, "view.newGuideLayout", &json!({})).unwrap().unwrap()["dialog"].as_u64().unwrap(); app.ui.dialog_mut(id).unwrap().fields.insert("columns".into(), json!(3)); assert!(crate::dialogs::confirm(&mut app, id).is_err()); assert_eq!(app.ui.view.guide_layout["columns"], 8); @@ -336,10 +362,10 @@ fn new_guide_layout_does_not_remember_a_failed_command() { #[test] fn lookup_export_dialog_uses_the_current_adjustment_selection() { - let (mut app, _ctx) = app_with(1); + let (mut app, ctx) = app_with(1); app.run("layer.newAdjustmentLayer.invert", json!({})).unwrap(); let current = app.session.active().unwrap().active_layer.unwrap(); - let opened = front(&mut app, "file.export.colorLookupTables", &json!({})).unwrap().unwrap(); + let opened = front(&mut app, &ctx, "file.export.colorLookupTables", &json!({})).unwrap().unwrap(); let id = opened["dialog"].as_u64().unwrap(); let dialog = &app.ui.dialogs.last().unwrap().fields; assert_eq!(dialog["scope"], "selected"); @@ -348,7 +374,7 @@ fn lookup_export_dialog_uses_the_current_adjustment_selection() { app.ui.close_dialog(id); app.run("layer.new.layer", json!({"name": "Unrelated pixels"})).unwrap(); - let opened = front(&mut app, "file.export.colorLookupTables", &json!({})).unwrap().unwrap(); + let opened = front(&mut app, &ctx, "file.export.colorLookupTables", &json!({})).unwrap().unwrap(); let id = opened["dialog"].as_u64().unwrap(); assert_eq!(app.ui.dialogs.last().unwrap().fields["scope"], "all"); app.ui.close_dialog(id); @@ -359,6 +385,32 @@ fn lookup_export_dialog_uses_the_current_adjustment_selection() { assert!(direct["cube"].as_str().unwrap().contains("LUT_3D_SIZE 3")); } +/// Batch and Image Processor carry Photoshop's "Include All Subfolders" checkbox (AUTO-219-5, +/// #3753): off by default, and confirming it on processes a subfolder's images too. +#[test] +fn batch_dialogs_include_all_subfolders() { + let (mut app, ctx) = app_with(1); + let dir = std::env::temp_dir().join(format!("photocraft-ui-batch-subfolders-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("in/sub")).unwrap(); + let input = dir.join("in").to_string_lossy().into_owned(); + app.run("file.saveACopy", json!({"path": dir.join("in/sub/a.png").to_string_lossy()})).unwrap(); + app.session.actions.list.push(photocraft_engine::actions_cmds::Action::new("Invert", vec![("image.adjustments.invert".into(), json!({}))])); + for (id, out) in [("file.automate.batch", "batch"), ("file.scripts.imageProcessor", "processed")] { + let d = menu(&mut app, &ctx, id, json!({})).unwrap()["dialog"].as_u64().unwrap(); + let fields = &mut app.ui.dialog_mut(d).unwrap().fields; + assert_eq!(fields["includeSubfolders"], json!(false), "{id}"); + assert_eq!(fields["__labels"]["includeSubfolders"], json!("Include All Subfolders"), "{id}"); + fields.insert("input".into(), json!(input)); + fields.insert("output".into(), json!(dir.join(out).to_string_lossy())); + fields.insert("includeSubfolders".into(), json!(true)); + let r = crate::dialogs::confirm(&mut app, d).unwrap(); + assert_eq!(r["files"].as_array().map(Vec::len), Some(1), "{id}: {r}"); + assert!(dir.join(out).join("sub").is_dir(), "{id} keeps the subfolder: {r}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// Float in Window takes the document and the screen position a dragged-off tab was dropped at /// (#3733); bad params are errors, not panics. #[test] @@ -448,6 +500,29 @@ fn press_esc(app: &mut PhotocraftApp, ctx: &egui::Context) { ctx.run_ui(input, |ui| crate::shortcuts::handle(app, ui.ctx())).textures_delta.clear(); } +#[test] +fn esc_leaves_full_screen_with_the_crop_tool_showing_its_default_frame() { + let (mut app, ctx) = app_with(1); + app.ui.tool = crate::Tool::Crop; + crate::crop_ui::ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame); + for mode in ["fullScreen", "fullScreenWithMenuBar"] { + menu(&mut app, &ctx, &format!("view.screenMode.{mode}"), json!({})).unwrap(); + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "standard", "{mode}"); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame, "{mode}: the default frame stays"); + } + // With Straighten armed, Esc drops it first and the mode stays. + menu(&mut app, &ctx, "view.screenMode.fullScreen", json!({})).unwrap(); + app.crop.straighten.armed = true; + press_esc(&mut app, &ctx); + assert!(!app.crop.straighten.armed); + assert_eq!(app.ui.view.screen_mode, "fullScreen"); + crate::crop_ui::ensure_frame(&mut app); + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "standard"); +} + #[test] fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { let (mut app, ctx) = app_with(1); @@ -480,3 +555,66 @@ fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { assert_eq!(app.ui.view.screen_mode, "standard"); assert!(app.session.active().unwrap().doc.selection.is_some()); } + +/// #6017: a click into another tile activated that document and moved it to the first tile, so +/// the two side-by-side documents swapped places. Each document keeps its tile; with more +/// documents than tiles, the shown run slides just enough to include the active one. +#[test] +fn arranged_documents_keep_their_tiles() { + let rect = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(900.0, 600.0)); + let (mut app, _) = app_with(3); + app.ui.view.arrange = "tileAllVertically".into(); + for active in 0..3 { + app.session.set_active(active); + let shown = crate::canvas::arranged_cells(&app, rect).unwrap(); + let order: Vec = shown.iter().map(|(d, _)| *d).collect(); + assert_eq!(order, vec![0, 1, 2], "active {active}"); + assert!(shown.windows(2).all(|w| w[0].1.left() < w[1].1.left()), "document 0 on the left"); + } + app.ui.view.arrange = "twoUpVertical".into(); + // As `documents` draws them: the shown run is kept from frame to frame. + let docs = |app: &mut PhotocraftApp, active: usize| { + app.session.set_active(active); + let shown: Vec = crate::canvas::arranged_cells(app, rect).unwrap().iter().map(|(d, _)| *d).collect(); + app.arrange_start = shown[0]; + shown + }; + assert_eq!(docs(&mut app, 0), vec![0, 1]); + assert_eq!(docs(&mut app, 1), vec![0, 1], "the second tile's document stays on the right"); + assert_eq!(docs(&mut app, 2), vec![1, 2], "the third document slides in next to the second"); + assert_eq!(docs(&mut app, 1), vec![1, 2], "a click into the visible document 1 leaves the tiles as they are"); + assert_eq!(docs(&mut app, 0), vec![0, 1], "a hidden document slides in from the left"); + assert_eq!(docs(&mut app, 1), vec![0, 1]); +} + +/// #6017: rulers showed in the active tile only; the other tile had none and no room for them. +#[test] +fn every_arranged_tile_has_rulers() { + use egui_kittest::Harness; + let (mut app, _) = app_with(2); + app.ui.view.arrange = "tileAllVertically".into(); + app.ui.extras.rulers = true; + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let right = h.state().last_canvas_rect.center().x; + // Ruler labels are 9 pt text: some lie in each half of the document area. + let mut labels = [0, 0]; + for s in &h.output().shapes { + if let egui::Shape::Text(t) = &s.shape + && t.galley.job.sections.first().is_some_and(|sec| sec.format.font_id.size == 9.0) + && t.galley.text().chars().all(|c| c.is_ascii_digit()) + { + labels[usize::from(t.pos.x > right)] += 1; + } + } + assert!(labels[0] > 0 && labels[1] > 0, "ruler labels per tile: {labels:?}"); +} diff --git a/crates/ui-egui/src/wheel_nav.rs b/crates/ui-egui/src/wheel_nav.rs index c65ad1fe2c..68a2321c44 100644 --- a/crates/ui-egui/src/wheel_nav.rs +++ b/crates/ui-egui/src/wheel_nav.rs @@ -2,11 +2,15 @@ //! //! - The wheel scrolls up and down; ⇧ or ⌘/Ctrl + wheel scrolls sideways. //! - ⌥/Alt + wheel zooms around the pointer, 10% per wheel notch, applied the frame it arrives. +//! ⇧/Shift + ⌥/Alt + wheel snaps to the next zoom ladder level instead. //! - Preferences › General › Zoom with Scroll Wheel swaps the two: the wheel zooms and -//! ⌥/Alt + wheel scrolls. +//! ⌥/Alt + wheel scrolls; ⇧/Shift + wheel snaps to the next zoom ladder level. //! - A trackpad pinch zooms around the pointer, unless Preferences › Enhanced Controls › //! Zoom with Trackpad Pinch is off (then a pinch does nothing, and Windows' Ctrl + wheel //! fractions scroll sideways like the wheel, because the fold below is skipped). +//! - A two-finger pinch on a touch screen zooms the same way, unless Preferences › Enhanced +//! Controls › Enable Gestures is off (#204): then the fingers leave the view alone, and a +//! trackpad pinch still zooms ([`pinch_factor`]). //! //! [`configure`] makes egui fold ⌘/Ctrl + wheel into a sideways scroll as it does ⇧ + wheel, so //! only a real pinch (egui's `Event::Zoom`, or a touch screen) reaches `zoom_delta`. macOS and @@ -22,7 +26,7 @@ //! keeps the smoothed delta. The Alt state is taken from the wheel events and kept until the next //! wheel event: releasing Alt while the tail is still settling must not turn it into a pan. -use egui::{Context, Event, Id, Modifiers, MouseWheelUnit, RawInput, Vec2}; +use egui::{Context, Event, Id, InputState, Modifiers, MouseWheelUnit, RawInput, Vec2}; /// Zoom factor of one wheel notch: every step is exactly ×1.1. pub const NOTCH: f32 = 1.1; @@ -36,6 +40,8 @@ const PINCH_GAP: f64 = 0.25; pub enum Wheel { /// Multiply the zoom by this factor around the pointer. Zoom(f32), + /// Step N rungs on the zoom ladder (positive = in, negative = out). + SnapZoom(i32), /// Move the image by this many screen points (egui's content direction). Pan(Vec2), } @@ -52,6 +58,8 @@ pub struct Input { pub zoom_delta: f32, /// ⌥/Alt was held for the wheel gesture in progress. pub alt: bool, + /// ⇧/Shift was held for the wheel gesture in progress (snap zoom). + pub shift: bool, /// Preferences › General › Zoom with Scroll Wheel. pub zoom_with_wheel: bool, /// Preferences › Enhanced Controls › Zoom with Trackpad Pinch. @@ -71,10 +79,27 @@ pub fn classify(i: Input) -> Option { let f = NOTCH.powf(dy / notch); (f.is_finite() && f > 0.0 && f != 1.0).then_some(Wheel::Zoom(f)) }; + let snap = |dy: f32| { + if dy == 0.0 { + return None; + } + let notch = if i.notch.is_finite() && i.notch > 0.0 { i.notch } else { 40.0 }; + let n = dy / notch; + if !n.is_finite() { + return None; + } + let steps = n.clamp(-10.0, 10.0).round() as i32; + if steps == 0 { None } else { Some(Wheel::SnapZoom(steps)) } + }; match (i.alt, i.zoom_with_wheel) { - // egui folds a scroll with Alt held into y. The smoothed tail of a notch is ignored. + // ⇧ + ⌥ + scroll: snap to the zoom ladder instead of continuous ×1.1. + (true, false) if i.shift => snap(i.raw.y + i.raw.x), + // ⌥ + scroll: continuous ×1.1 zoom. The smoothed tail of a notch is ignored. (true, false) => step(i.raw.y + i.raw.x), - // ⇧ and ⌘/Ctrl fold the wheel into x: those still scroll sideways. + // ⇧ + scroll wheel with the preference on: snap to the zoom ladder. + // Some systems deliver Shift+wheel as horizontal (delta in x, y = 0). + (false, true) if i.shift => snap(i.raw.y + i.raw.x), + // ⌘/Ctrl folds the wheel into x: those still scroll sideways. (false, true) if i.raw.y != 0.0 || i.scroll.y != 0.0 => step(i.raw.y), _ if !(i.scroll.x.is_finite() && i.scroll.y.is_finite()) || i.scroll == Vec2::ZERO => None, _ => Some(Wheel::Pan(i.scroll)), @@ -149,15 +174,54 @@ fn alt_id() -> Id { Id::new("pc-wheel-alt") } -/// Read this frame's wheel input. Call it every frame (hovered or not) so the Alt state of the -/// gesture follows the latest wheel event. -pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool) -> Option { +fn shift_id() -> Id { + Id::new("pc-wheel-shift") +} + +fn snap_residual_id(owner: Id) -> Id { + Id::new("pc-wheel-snap-residual").with(owner) +} + +fn snap_time_id(owner: Id) -> Id { + Id::new("pc-wheel-snap-time").with(owner) +} + +fn snap_frame_result_id(owner: Id) -> Id { + Id::new("pc-wheel-snap-frame-result").with(owner) +} + +fn active_snap_owner_id() -> Id { + Id::new("pc-wheel-active-snap-owner") +} + +const SNAP_IDLE_TIMEOUT: f64 = 0.5; + +/// Read this frame's wheel input. Call it every frame (hovered or not) so the Alt and Shift +/// states of the gesture follow the latest wheel event. `owner` scopes the snap-zoom +/// accumulator per viewport. `hovered` gates accumulation: only the viewport receiving +/// navigation may add raw input to the snap residual. +pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool) -> Option { + read_with(ctx, zoom_with_wheel, pinch_zooms, owner, hovered, true, true) +} + +/// [`read`], choosing how a plain wheel scroll pans: `smooth` glides it over a few frames (egui's +/// smoothed delta); off, as with Interface › Enable Animations off, the view moves the whole notch +/// in the frame the wheel turns and stops there (#5721), as scroll areas already do. +/// `touch_gestures` is Preferences › Enhanced Controls › Enable Gestures: off, a touch screen's +/// two-finger pinch doesn't zoom ([`pinch_factor`]). +pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool, smooth: bool, touch_gestures: bool) -> Option { let notch = ctx.options(|o| o.input_options.line_scroll_speed); - let (scroll, zoom_delta, latest, wheel, page) = ctx.input(|i| { - let latest = i.events.iter().rev().find_map(|e| match e { + + // --- Modifier tracking (always runs for all callers) --- + let (scroll, zoom_delta, latest_alt, latest_shift, wheel, page) = ctx.input(|i| { + let latest_alt = i.events.iter().rev().find_map(|e| match e { Event::MouseWheel { modifiers, .. } => Some(modifiers.alt && !modifiers.command && !modifiers.ctrl), _ => None, }); + let latest_shift = i.events.iter().rev().find_map(|e| match e { + Event::MouseWheel { modifiers, .. } => Some(modifiers.shift && !modifiers.command && !modifiers.ctrl), + _ => None, + }); let wheel: Vec<(MouseWheelUnit, Vec2, Modifiers)> = i .events .iter() @@ -166,29 +230,126 @@ pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool) -> Option None, }) .collect(); - (i.smooth_scroll_delta, i.zoom_delta(), latest, wheel, i.content_rect().height()) + (i.smooth_scroll_delta, pinch_factor(i, touch_gestures), latest_alt, latest_shift, wheel, i.content_rect().height()) }); - let alt = match latest { + let alt = match latest_alt { Some(a) => { ctx.data_mut(|d| d.insert_temp(alt_id(), a)); a } None => ctx.data(|d| d.get_temp::(alt_id())).unwrap_or(false), }; - // The wheel events that belong to the gesture in progress: Alt ones, or plain ones (⇧ and - // ⌘/Ctrl wheel scroll sideways and never zoom). - let raw = wheel.iter().filter(|(_, _, m)| if alt { m.alt && !m.command && !m.ctrl } else { !m.alt && !m.shift && !m.command && !m.ctrl }).fold( - Vec2::ZERO, - |sum, (unit, delta, _)| { + let shift = match latest_shift { + Some(s) => { + ctx.data_mut(|d| d.insert_temp(shift_id(), s)); + s + } + None => ctx.data(|d| d.get_temp::(shift_id())).unwrap_or(false), + }; + + if !hovered { + return None; + } + + // --- Input classification (hovered viewport only) --- + // The wheel events that belong to the gesture in progress: Shift+Alt (snap zoom), Alt-only + // (continuous zoom), Shift-only (snap zoom with zoom_with_wheel), or plain (scroll/zoom). + let raw = wheel + .iter() + .filter(|(_, _, m)| { + if alt && shift { + m.alt && m.shift && !m.command && !m.ctrl + } else if alt { + m.alt && !m.shift && !m.command && !m.ctrl + } else if shift { + m.shift && !m.alt && !m.command && !m.ctrl + } else { + !m.alt && !m.shift && !m.command && !m.ctrl + } + }) + .fold(Vec2::ZERO, |sum, (unit, delta, _)| { sum + *delta * match unit { MouseWheelUnit::Point => 1.0, MouseWheelUnit::Line => notch, MouseWheelUnit::Page => page, } - }, - ); - classify(Input { scroll, raw, zoom_delta, alt, zoom_with_wheel, pinch_zooms, notch }) + }); + let snap_mode = shift && ((alt && !zoom_with_wheel) || (!alt && zoom_with_wheel)); + let has_pinch = pinch_zooms && zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0; + + if snap_mode && !has_pinch { + let dy = raw.y + raw.x; + if dy == 0.0 { + return None; + } + + // Per-frame idempotency: a second read in the same frame returns the cached result. + let frame_nr = ctx.cumulative_pass_nr(); + if let Some((cached_frame, cached_steps)) = ctx.data(|d| d.get_temp::<(u64, i32)>(snap_frame_result_id(owner))) + && cached_frame == frame_nr + { + return if cached_steps == 0 { None } else { Some(Wheel::SnapZoom(cached_steps)) }; + } + + // When the receiving viewport changes, clear the old viewport's residual so a + // partial gesture doesn't reappear when returning to it. + let prev_active = ctx.data(|d| d.get_temp::(active_snap_owner_id())); + if let Some(old) = prev_active.filter(|&p| p != owner) { + ctx.data_mut(|d| { + d.remove::(snap_residual_id(old)); + d.remove::(snap_time_id(old)); + }); + } + ctx.data_mut(|d| d.insert_temp(active_snap_owner_id(), owner)); + + let now = ctx.input(|i| i.time); + let last_time = ctx.data(|d| d.get_temp::(snap_time_id(owner))); + let prev = if last_time.is_some_and(|t| now - t < SNAP_IDLE_TIMEOUT && (now - t).is_finite()) { + ctx.data(|d| d.get_temp::(snap_residual_id(owner))).unwrap_or(0.0) + } else { + 0.0 + }; + let notch_val = if notch.is_finite() && notch > 0.0 { notch } else { 40.0 }; + let total = prev + dy; + let n = if total.is_finite() { (total / notch_val).trunc() } else { 0.0 }; + let remainder = total - n * notch_val; + let steps = n.clamp(-10.0, 10.0) as i32; + ctx.data_mut(|d| { + d.insert_temp(snap_residual_id(owner), if remainder.is_finite() { remainder } else { 0.0 }); + d.insert_temp(snap_time_id(owner), now); + d.insert_temp(snap_frame_result_id(owner), (frame_nr, steps)); + }); + if steps == 0 { None } else { Some(Wheel::SnapZoom(steps)) } + } else { + if !snap_mode { + ctx.data_mut(|d| { + d.remove::(snap_residual_id(owner)); + d.remove::(snap_time_id(owner)); + }); + } + // Unsmoothed, a plain scroll pans by this frame's own wheel events (none: no tail). + let scroll = if smooth || alt || shift { scroll } else { raw }; + classify(Input { scroll, raw, zoom_delta, alt, shift, zoom_with_wheel, pinch_zooms, notch }) + } +} + +/// This frame's pinch zoom factor (1 = none). While two fingers are down on a touch screen, egui's +/// `zoom_delta` reports their pinch in place of the trackpad's `Event::Zoom`. With Preferences › +/// Enhanced Controls › Enable Gestures off (`touch_gestures` false) the fingers are ignored and only +/// the trackpad's own pinch events count (Windows' Ctrl + wheel pinch included: [`fold_legacy_pinch`] +/// has already turned it into `Event::Zoom`). +pub fn pinch_factor(i: &InputState, touch_gestures: bool) -> f32 { + if touch_gestures || i.multi_touch().is_none() { + return i.zoom_delta(); + } + i.events + .iter() + .filter_map(|e| match e { + Event::Zoom(f) if f.is_finite() && *f > 0.0 => Some(*f), + _ => None, + }) + .product() } #[cfg(test)] @@ -198,7 +359,7 @@ mod tests { use super::*; fn input(scroll: Vec2, alt: bool) -> Input { - Input { scroll, raw: scroll, zoom_delta: 1.0, alt, zoom_with_wheel: false, pinch_zooms: true, notch: 40.0 } + Input { scroll, raw: scroll, zoom_delta: 1.0, alt, shift: false, zoom_with_wheel: false, pinch_zooms: true, notch: 40.0 } } fn wheel(delta: Vec2, modifiers: Modifiers) -> Event { @@ -210,10 +371,11 @@ mod tests { /// Feed `events` through an egui context set up like the app's (`legacy`: the Windows pinch /// fold on) and add up what the wheel did to the view over the following second. - fn gesture(frames: Vec>, zoom_with_wheel: bool, legacy: bool) -> (Vec2, f32) { + fn gesture(frames: Vec>, zoom_with_wheel: bool, legacy: bool) -> (Vec2, f32, i32) { + let owner = Id::new("test-gesture"); let ctx = Context::default(); configure(&ctx); - let (mut pan, mut zoom) = (Vec2::ZERO, 1.0); + let (mut pan, mut zoom, mut snaps) = (Vec2::ZERO, 1.0, 0i32); let mut frames = frames.into_iter(); for n in 0..60 { let mut raw = @@ -221,20 +383,61 @@ mod tests { if legacy { fold_legacy_pinch(&ctx, &mut raw); } - let mut out = ctx.run_ui(raw, |ui| match read(ui.ctx(), zoom_with_wheel, true) { + let mut out = ctx.run_ui(raw, |ui| match read(ui.ctx(), zoom_with_wheel, true, owner, true) { Some(Wheel::Pan(d)) => pan += d, Some(Wheel::Zoom(f)) => zoom *= f, + Some(Wheel::SnapZoom(n)) => snaps += n, None => {} }); out.textures_delta.clear(); } - (pan, zoom) + (pan, zoom, snaps) + } + + fn wheel_point(delta: Vec2, modifiers: Modifiers) -> Event { + Event::MouseWheel { unit: MouseWheelUnit::Point, delta, phase: TouchPhase::Move, modifiers } } fn near(a: Vec2, b: Vec2) -> bool { (a - b).length() < 0.01 } + /// #5721: with smoothing off a wheel notch pans the whole notch in the frame it arrives and + /// nothing after; smoothed (the default) it glides, reaching the same total over later frames. + #[test] + fn unsmoothed_wheel_pans_at_once_without_a_glide() { + let pans = |smooth: bool| -> Vec { + let owner = Id::new("test-unsmoothed"); + let ctx = Context::default(); + configure(&ctx); + (0..30) + .map(|n| { + let events = if n == 0 { vec![wheel(Vec2::new(0.0, -1.0), Modifiers::NONE)] } else { Vec::new() }; + let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; + let mut pan = Vec2::ZERO; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::Pan(d)) = read_with(ui.ctx(), false, true, owner, true, smooth, true) { + pan = d; + } + }); + out.textures_delta.clear(); + pan + }) + .collect() + }; + let notch = { + let ctx = Context::default(); + configure(&ctx); + ctx.options(|o| o.input_options.line_scroll_speed) + }; + let direct = pans(false); + assert!(near(direct[0], Vec2::new(0.0, -notch)), "the whole notch at once: {:?}", direct[0]); + assert!(direct[1..].iter().all(|p| *p == Vec2::ZERO), "no glide after it"); + let smooth = pans(true); + assert!(smooth[0].y.abs() < notch - 0.5, "smoothed, the first frame moves only part of it: {:?}", smooth[0]); + assert!(smooth[1..].iter().any(|p| p.y != 0.0), "and the rest follows"); + } + #[test] fn plain_scroll_pans() { assert_eq!(classify(input(Vec2::new(3.0, -40.0), false)), Some(Wheel::Pan(Vec2::new(3.0, -40.0)))); @@ -321,37 +524,94 @@ mod tests { assert_eq!(classify(pref(Vec2::new(40.0, 0.0), false)), Some(Wheel::Pan(Vec2::new(40.0, 0.0))), "sideways still scrolls"); } + #[test] + fn shift_scroll_snaps_to_zoom_ladder_with_preference() { + let snap = |dy: f32| Input { shift: true, zoom_with_wheel: true, ..input(Vec2::new(0.0, dy), false) }; + assert_eq!(classify(snap(40.0)), Some(Wheel::SnapZoom(1)), "one notch up → +1 step"); + assert_eq!(classify(snap(-40.0)), Some(Wheel::SnapZoom(-1)), "one notch down → −1 step"); + assert_eq!(classify(snap(80.0)), Some(Wheel::SnapZoom(2)), "two notches → +2 steps"); + assert_eq!(classify(snap(0.0)), None, "no scroll → nothing"); + // Some systems deliver Shift+wheel as horizontal (delta in x, y = 0). + let snap_h = |dx: f32| Input { shift: true, zoom_with_wheel: true, raw: Vec2::new(dx, 0.0), ..input(Vec2::new(dx, 0.0), false) }; + assert_eq!(classify(snap_h(40.0)), Some(Wheel::SnapZoom(1)), "horizontal notch → +1 step"); + assert_eq!(classify(snap_h(-40.0)), Some(Wheel::SnapZoom(-1)), "horizontal notch down → −1 step"); + } + + #[test] + fn shift_scroll_tail_does_not_snap_or_pan() { + let tail = Input { shift: true, zoom_with_wheel: true, raw: Vec2::ZERO, ..input(Vec2::new(12.0, 0.0), false) }; + assert_eq!(classify(tail), None, "smoothed tail of ⇧ + scroll produces nothing"); + } + + #[test] + fn shift_alt_scroll_snaps_without_preference() { + let snap = |dy: f32| Input { shift: true, ..input(Vec2::new(0.0, dy), true) }; + assert_eq!(classify(snap(40.0)), Some(Wheel::SnapZoom(1)), "⇧ + ⌥ + notch up → +1 step"); + assert_eq!(classify(snap(-40.0)), Some(Wheel::SnapZoom(-1)), "⇧ + ⌥ + notch down → −1 step"); + assert_eq!(classify(snap(0.0)), None, "no scroll → nothing"); + } + + #[test] + fn shift_only_without_zoom_with_wheel_does_not_snap() { + let i = Input { shift: true, zoom_with_wheel: false, ..input(Vec2::new(0.0, 40.0), false) }; + assert_eq!(classify(i), Some(Wheel::Pan(Vec2::new(0.0, 40.0))), "⇧ alone without the preference: no zoom"); + } + + #[test] + fn shift_scroll_snaps_through_egui_context() { + let notch = Vec2::new(0.0, 1.0); + // Shift + wheel with zoom_with_wheel on. + let (pan, zoom, snaps) = gesture(vec![vec![wheel(notch, Modifiers::SHIFT)]], true, false); + assert_eq!(snaps, 1, "⇧ + wheel with zoom_with_wheel → snap"); + assert!(pan == Vec2::ZERO, "no pan: {pan:?}"); + assert_eq!(zoom, 1.0, "no continuous zoom: {zoom}"); + let (_, _, snaps) = gesture(vec![vec![wheel(-notch, Modifiers::SHIFT)]], true, false); + assert_eq!(snaps, -1, "⇧ + wheel down → snap out"); + // Some systems deliver Shift+wheel as horizontal (delta in x, y = 0). + let h_notch = Vec2::new(1.0, 0.0); + let (pan, zoom, snaps) = gesture(vec![vec![wheel(h_notch, Modifiers::SHIFT)]], true, false); + assert_eq!(snaps, 1, "⇧ + horizontal wheel with zoom_with_wheel → snap"); + assert!(pan == Vec2::ZERO, "no pan: {pan:?}"); + assert_eq!(zoom, 1.0, "no continuous zoom: {zoom}"); + // Shift + Alt + wheel without the preference. + let shift_alt = Modifiers { shift: true, alt: true, ..Modifiers::NONE }; + let (pan, zoom, snaps) = gesture(vec![vec![wheel(notch, shift_alt)]], false, false); + assert_eq!(snaps, 1, "⇧ + ⌥ + wheel → snap"); + assert!(pan == Vec2::ZERO, "no pan: {pan:?}"); + assert_eq!(zoom, 1.0, "no continuous zoom: {zoom}"); + } + #[test] fn each_modifier_maps_like_photoshop() { let notch = Vec2::new(0.0, 1.0); let one = |m: Modifiers| gesture(vec![vec![wheel(notch, m)]], false, false); - let (pan, zoom) = one(Modifiers::NONE); + let (pan, zoom, _) = one(Modifiers::NONE); assert!(near(pan, Vec2::new(0.0, 40.0)) && zoom == 1.0, "wheel scrolls vertically: {pan:?} {zoom}"); for m in [Modifiers::SHIFT, CTRL, CMD, Modifiers::CTRL] { - let (pan, zoom) = one(m); + let (pan, zoom, _) = one(m); assert!(near(pan, Vec2::new(40.0, 0.0)) && zoom == 1.0, "{m:?} + wheel scrolls sideways: {pan:?} {zoom}"); } - let (pan, zoom) = one(Modifiers::ALT); + let (pan, zoom, _) = one(Modifiers::ALT); assert!(pan == Vec2::ZERO && (zoom - 1.1).abs() < 1e-4, "⌥ + wheel zooms 10%, at once: {pan:?} {zoom}"); // Zoom with Scroll Wheel on: swapped. - let (pan, zoom) = gesture(vec![vec![wheel(notch, Modifiers::ALT)]], true, false); + let (pan, zoom, _) = gesture(vec![vec![wheel(notch, Modifiers::ALT)]], true, false); assert!(near(pan, Vec2::new(0.0, 40.0)) && zoom == 1.0, "{pan:?} {zoom}"); - let (pan, zoom) = gesture(vec![vec![wheel(notch, Modifiers::NONE)]], true, false); + let (pan, zoom, _) = gesture(vec![vec![wheel(notch, Modifiers::NONE)]], true, false); assert!(pan == Vec2::ZERO && (zoom - 1.1).abs() < 1e-4, "{pan:?} {zoom}"); - let (pan, zoom) = gesture(vec![vec![wheel(notch, CTRL)]], true, false); + let (pan, zoom, _) = gesture(vec![vec![wheel(notch, CTRL)]], true, false); assert!(near(pan, Vec2::new(40.0, 0.0)) && zoom == 1.0, "⌘/Ctrl + wheel still scrolls sideways: {pan:?} {zoom}"); } #[test] fn pinch_zooms_and_ctrl_wheel_pans() { // macOS trackpad and browsers: the pinch is its own event. - let (pan, zoom) = gesture(vec![vec![Event::Zoom(1.1)], vec![Event::Zoom(1.1)]], false, false); + let (pan, zoom, _) = gesture(vec![vec![Event::Zoom(1.1)], vec![Event::Zoom(1.1)]], false, false); assert!(pan == Vec2::ZERO && (zoom - 1.21).abs() < 1e-4, "{pan:?} {zoom}"); // Windows: a touchpad pinch is Ctrl + wheel in fractions of a notch, a mouse wheel whole notches. - let (pan, zoom) = gesture(vec![vec![wheel(Vec2::new(0.0, 0.25), CTRL)], vec![wheel(Vec2::new(0.0, 0.5), CTRL)]], false, true); + let (pan, zoom, _) = gesture(vec![vec![wheel(Vec2::new(0.0, 0.25), CTRL)], vec![wheel(Vec2::new(0.0, 0.5), CTRL)]], false, true); let speed = egui::InputOptions::default().scroll_zoom_speed; assert!(pan == Vec2::ZERO && (zoom - (speed * 40.0 * 0.75).exp()).abs() < 1e-4, "the pinch zooms: {pan:?} {zoom}"); - let (pan, zoom) = gesture(vec![vec![wheel(Vec2::new(0.0, 1.0), CTRL)]], false, true); + let (pan, zoom, _) = gesture(vec![vec![wheel(Vec2::new(0.0, 1.0), CTRL)]], false, true); assert!(near(pan, Vec2::new(40.0, 0.0)) && zoom == 1.0, "Ctrl + mouse wheel scrolls sideways: {pan:?} {zoom}"); } @@ -369,7 +629,7 @@ mod tests { #[test] fn native_pinch_is_not_zoomed_twice_when_ctrl_wheel_arrives_in_the_same_frame() { - let (_, zoom) = gesture(vec![vec![Event::Zoom(1.2), wheel(Vec2::new(0.0, 0.25), CTRL)]], false, true); + let (_, zoom, _) = gesture(vec![vec![Event::Zoom(1.2), wheel(Vec2::new(0.0, 0.25), CTRL)]], false, true); assert!((zoom - 1.2).abs() < 1e-4, "native zoom must win over fallback: {zoom}"); } @@ -418,11 +678,36 @@ mod tests { for alt in [false, true] { for notch in [0.0, -1.0, f32::NAN, 40.0] { for zd in [f32::NAN, 0.0, -1.0, f32::INFINITY, 1.0] { - let i = - Input { scroll: Vec2::new(0.0, s), raw: Vec2::new(0.0, s), zoom_delta: zd, alt, zoom_with_wheel: !alt, pinch_zooms: true, notch }; + let i = Input { + scroll: Vec2::new(0.0, s), + raw: Vec2::new(0.0, s), + zoom_delta: zd, + alt, + shift: false, + zoom_with_wheel: !alt, + pinch_zooms: true, + notch, + }; if let Some(Wheel::Zoom(f)) = classify(i) { assert!(f.is_finite() && f > 0.0, "{f} from {i:?}"); } + let si = Input { + shift: true, + zoom_with_wheel: true, + ..Input { + scroll: Vec2::new(0.0, s), + raw: Vec2::new(0.0, s), + zoom_delta: zd, + alt: false, + shift: false, + zoom_with_wheel: true, + pinch_zooms: true, + notch, + } + }; + if let Some(Wheel::SnapZoom(n)) = classify(si) { + assert!(n.abs() <= 10, "snap steps clamped: {n} from {si:?}"); + } } let mut ev = [wheel(Vec2::new(s, 0.5), CTRL)]; let _ = legacy_pinch_to_zoom(&mut ev, f64::NAN, Some(f64::NAN), notch, 0.005); @@ -434,4 +719,288 @@ mod tests { } } } + + /// A gesture helper that runs each frame at 1/60 s spacing with Point-unit events, + /// collecting snaps, and using a specific owner ID. + fn snap_gesture(frames: Vec>, zoom_with_wheel: bool, owner: Id) -> i32 { + let ctx = Context::default(); + configure(&ctx); + let mut snaps = 0i32; + let mut frames = frames.into_iter(); + for n in 0..60 { + let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events: frames.next().unwrap_or_default(), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), zoom_with_wheel, true, owner, true) { + snaps += s; + } + }); + out.textures_delta.clear(); + } + snaps + } + + #[test] + fn fractional_point_events_accumulate_across_frames() { + let quarter = Vec2::new(0.0, 10.0); + let batched = snap_gesture(vec![vec![wheel_point(Vec2::new(0.0, 40.0), Modifiers::SHIFT)]], true, Id::new("accum-batched")); + let split = snap_gesture( + vec![ + vec![wheel_point(quarter, Modifiers::SHIFT)], + vec![wheel_point(quarter, Modifiers::SHIFT)], + vec![wheel_point(quarter, Modifiers::SHIFT)], + vec![wheel_point(quarter, Modifiers::SHIFT)], + ], + true, + Id::new("accum-split"), + ); + assert_eq!(batched, 1, "40 points batched → 1 step"); + assert_eq!(split, 1, "4 × 10 points split → 1 step"); + } + + #[test] + fn fractional_accumulation_negative_and_horizontal() { + let neg_quarter = Vec2::new(0.0, -10.0); + let neg_split = snap_gesture( + vec![ + vec![wheel_point(neg_quarter, Modifiers::SHIFT)], + vec![wheel_point(neg_quarter, Modifiers::SHIFT)], + vec![wheel_point(neg_quarter, Modifiers::SHIFT)], + vec![wheel_point(neg_quarter, Modifiers::SHIFT)], + ], + true, + Id::new("accum-neg"), + ); + assert_eq!(neg_split, -1, "4 × −10 points → −1 step"); + let h_quarter = Vec2::new(10.0, 0.0); + let h_split = snap_gesture( + vec![ + vec![wheel_point(h_quarter, Modifiers::SHIFT)], + vec![wheel_point(h_quarter, Modifiers::SHIFT)], + vec![wheel_point(h_quarter, Modifiers::SHIFT)], + vec![wheel_point(h_quarter, Modifiers::SHIFT)], + ], + true, + Id::new("accum-horiz"), + ); + assert_eq!(h_split, 1, "horizontal 4 × 10 → 1 step"); + } + + #[test] + fn accumulation_handles_empty_frames_and_direction_reversal() { + let snaps = snap_gesture( + vec![ + vec![wheel_point(Vec2::new(0.0, 10.0), Modifiers::SHIFT)], + vec![], // empty frame + vec![wheel_point(Vec2::new(0.0, 10.0), Modifiers::SHIFT)], + vec![wheel_point(Vec2::new(0.0, 10.0), Modifiers::SHIFT)], + vec![wheel_point(Vec2::new(0.0, 10.0), Modifiers::SHIFT)], + ], + true, + Id::new("accum-empty"), + ); + assert_eq!(snaps, 1, "empty frame preserves residual"); + let reversal = snap_gesture( + vec![vec![wheel_point(Vec2::new(0.0, 30.0), Modifiers::SHIFT)], vec![wheel_point(Vec2::new(0.0, -15.0), Modifiers::SHIFT)]], + true, + Id::new("accum-reversal"), + ); + assert_eq!(reversal, 0, "30 − 15 = 15 points, below 1 notch → 0 steps"); + } + + #[test] + fn accumulation_shift_alt_without_preference() { + let shift_alt = Modifiers { shift: true, alt: true, ..Modifiers::NONE }; + let split = snap_gesture( + vec![ + vec![wheel_point(Vec2::new(0.0, 10.0), shift_alt)], + vec![wheel_point(Vec2::new(0.0, 10.0), shift_alt)], + vec![wheel_point(Vec2::new(0.0, 10.0), shift_alt)], + vec![wheel_point(Vec2::new(0.0, 10.0), shift_alt)], + ], + false, + Id::new("accum-shift-alt"), + ); + assert_eq!(split, 1, "Shift+Alt 4 × 10 → 1 step without preference"); + } + + #[test] + fn cross_viewport_does_not_leak_residual() { + let owner_a = Id::new("owner-a"); + let owner_b = Id::new("owner-b"); + let ctx = Context::default(); + configure(&ctx); + let mut snaps_a = 0i32; + let mut snaps_b = 0i32; + // Frame 0: 30 points over A (hovered), B not hovered. + let raw = + RawInput { time: Some(0.0), predicted_dt: 1.0 / 60.0, events: vec![wheel_point(Vec2::new(0.0, 30.0), Modifiers::SHIFT)], ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner_a, true) { + snaps_a += s; + } + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner_b, false) { + snaps_b += s; + } + }); + out.textures_delta.clear(); + assert_eq!(snaps_a, 0, "A: 30 points, below 1 notch"); + assert_eq!(snaps_b, 0, "B: not hovered, nothing"); + // Frame 1: 10 points, pointer moved to B (B hovered, A not). + let raw = RawInput { + time: Some(1.0 / 60.0), + predicted_dt: 1.0 / 60.0, + events: vec![wheel_point(Vec2::new(0.0, 10.0), Modifiers::SHIFT)], + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner_a, false) { + snaps_a += s; + } + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner_b, true) { + snaps_b += s; + } + }); + out.textures_delta.clear(); + assert_eq!(snaps_b, 0, "B: only 10 points of its own, no snap"); + // Frame 2–4: 30 more points over B. + for n in 2..5 { + let raw = RawInput { + time: Some(f64::from(n) / 60.0), + predicted_dt: 1.0 / 60.0, + events: vec![wheel_point(Vec2::new(0.0, 10.0), Modifiers::SHIFT)], + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + let _ = read(ui.ctx(), true, true, owner_a, false); + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner_b, true) { + snaps_b += s; + } + }); + out.textures_delta.clear(); + } + assert_eq!(snaps_b, 1, "B: 10+10+10+10 = 40 → 1 step"); + // Frame 5: switch back to A — A's residual was cleared on the viewport switch. + let raw = RawInput { + time: Some(5.0 / 60.0), + predicted_dt: 1.0 / 60.0, + events: vec![wheel_point(Vec2::new(0.0, 30.0), Modifiers::SHIFT)], + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner_a, true) { + snaps_a += s; + } + let _ = read(ui.ctx(), true, true, owner_b, false); + }); + out.textures_delta.clear(); + assert_eq!(snaps_a, 0, "A: fresh start after switch, 30 points, no snap yet"); + } + + #[test] + fn two_reads_same_owner_same_frame_do_not_double_count() { + let owner = Id::new("same-owner"); + let ctx = Context::default(); + configure(&ctx); + let mut total_snaps = 0i32; + let raw = + RawInput { time: Some(0.0), predicted_dt: 1.0 / 60.0, events: vec![wheel_point(Vec2::new(0.0, 20.0), Modifiers::SHIFT)], ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner, true) { + total_snaps += s; + } + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner, true) { + total_snaps += s; + } + }); + out.textures_delta.clear(); + assert_eq!(total_snaps, 0, "20 points read twice must not become 40"); + // Now add another 20 in the next frame — should snap since accumulated 20+20=40. + let raw = RawInput { + time: Some(1.0 / 60.0), + predicted_dt: 1.0 / 60.0, + events: vec![wheel_point(Vec2::new(0.0, 20.0), Modifiers::SHIFT)], + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::SnapZoom(s)) = read(ui.ctx(), true, true, owner, true) { + total_snaps += s; + } + }); + out.textures_delta.clear(); + assert_eq!(total_snaps, 1, "20 + 20 across two frames → 1 step"); + } + + #[test] + fn accumulation_hostile_numbers() { + for dy in [1e30, -1e30, 1e-10, -1e-10] { + let snaps = snap_gesture(vec![vec![wheel_point(Vec2::new(0.0, dy), Modifiers::SHIFT)]], true, Id::new("accum-hostile")); + assert!(snaps.abs() <= 10, "hostile {dy} clamped: {snaps}"); + } + } + + #[test] + fn line_unit_snap_still_works_with_accumulation() { + let (_, _, snaps) = gesture(vec![vec![wheel(Vec2::new(0.0, 1.0), Modifiers::SHIFT)]], true, false); + assert_eq!(snaps, 1, "Line-unit notch produces 1 step"); + let (_, _, snaps) = gesture(vec![vec![wheel(Vec2::new(0.0, -1.0), Modifiers::SHIFT)]], true, false); + assert_eq!(snaps, -1, "Line-unit notch down produces −1 step"); + } + + /// Two fingers on a touch screen: down 20 points apart, spread to 40 (a ×2 pinch), lifted. + fn touch_pinch_frames() -> Vec> { + let touch = |id: u64, phase: TouchPhase, x: f32| Event::Touch { + device_id: egui::TouchDeviceId(1), + id: egui::TouchId(id), + phase, + pos: egui::pos2(x, 50.0), + force: None, + }; + vec![ + // egui only starts a multi-touch gesture once it knows where the pointer is. + vec![Event::PointerMoved(egui::pos2(40.0, 50.0))], + vec![touch(1, TouchPhase::Start, 40.0), touch(2, TouchPhase::Start, 60.0)], + vec![touch(1, TouchPhase::Move, 30.0), touch(2, TouchPhase::Move, 70.0)], + vec![touch(1, TouchPhase::End, 30.0), touch(2, TouchPhase::End, 70.0)], + ] + } + + /// The total zoom [`read_with`] reports over `frames`, Enable Gestures set to `touch_gestures`. + fn touch_zoom(frames: Vec>, touch_gestures: bool) -> f32 { + let owner = Id::new("test-touch"); + let ctx = Context::default(); + configure(&ctx); + let mut zoom = 1.0; + for (n, events) in (0u32..).zip(frames) { + let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::Zoom(f)) = read_with(ui.ctx(), false, true, owner, true, true, touch_gestures) { + zoom *= f; + } + }); + out.textures_delta.clear(); + } + zoom + } + + /// Preferences › Enhanced Controls › Enable Gestures (#204): on (the default) a two-finger + /// pinch on a touch screen zooms; off, it leaves the view alone. + #[test] + fn enable_gestures_switches_touch_screen_pinch_zoom() { + let on = touch_zoom(touch_pinch_frames(), true); + assert!((on - 2.0).abs() < 1e-4, "on: the fingers zoom ×2: {on}"); + let off = touch_zoom(touch_pinch_frames(), false); + assert_eq!(off, 1.0, "off: the fingers don't zoom"); + } + + /// Enable Gestures off only silences the touch screen: a trackpad pinch still zooms, also in a + /// frame where two fingers rest on the screen (egui's `zoom_delta` would report theirs instead). + #[test] + fn enable_gestures_off_keeps_the_trackpad_pinch() { + let zoom = touch_zoom(vec![vec![Event::Zoom(1.25)]], false); + assert!((zoom - 1.25).abs() < 1e-4, "no fingers down: {zoom}"); + let mut frames = touch_pinch_frames(); + frames[2] = vec![Event::Zoom(1.25)]; + let zoom = touch_zoom(frames, false); + assert!((zoom - 1.25).abs() < 1e-4, "fingers resting: {zoom}"); + } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9e56022cef..0a91058387 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}; @@ -246,8 +246,9 @@ pub fn panel_menu_button(ui: &mut Ui) -> Response { pub fn pill_tab(ui: &mut Ui, label: &str, selected: bool) -> Response { let t = Tokens::get(ui.ctx()); + let translated = tl!(label); let font = theme::medium(12.5); - let galley = ui.painter().layout_no_wrap(tl!(label).to_owned(), font, t.text); + let galley = ui.painter().layout_no_wrap(translated.to_string(), font, t.text); let size = vec2(galley.size().x + 20.0, 24.0); let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); if selected { @@ -260,6 +261,7 @@ pub fn pill_tab(ui: &mut Ui, label: &str, selected: bool) -> Response { } let color = if selected { t.text } else { t.text_dim }; ui.painter().galley_with_override_text_color(rect.center() - galley.size() / 2.0, galley, color); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, translated)); resp } @@ -348,6 +350,25 @@ pub fn value_field_units(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInc value_field_in(ui, value, range, "", width, 0.0, FieldOpts { decimals: Some(decimals), units: Some(units), ..Default::default() }).0 } +/// A [`value_field_prec`] in `units` whose 100 % is a length of `extent` px (Free Transform's W +/// and H): a length typed while it shows percent becomes that share of `extent` (`40px`, `2in`), +/// and a percentage typed while it shows a length becomes that share of `extent` (`50%`), at +/// `units`' resolution (#6284). The field keeps its own unit. +#[allow(clippy::too_many_arguments)] +pub fn value_field_extent( + ui: &mut Ui, + value: &mut f32, + range: std::ops::RangeInclusive, + suffix: &str, + width: f32, + decimals: u32, + units: FieldUnits, + extent: f64, +) -> Response { + let opts = FieldOpts { decimals: Some(decimals), units: Some(units), extent: Some(extent), ..Default::default() }; + value_field_in(ui, value, range, suffix, width, 0.0, opts).0 +} + fn field_resolution_id() -> egui::Id { egui::Id::new("pc-field-resolution") } @@ -382,6 +403,8 @@ struct LabelScrub { rect: Rect, /// How far right of the label the field may start (the whole row for a slider row). max_gap: f32, + /// Options captions defer interaction until the adjacent field bounds are known. + option_area: bool, /// Whole steps to move the value (left negative; Shift ×10, Alt a tenth as fast). steps: f32, started: bool, @@ -400,9 +423,35 @@ pub fn scrub_label(ui: &Ui, label: &Response) -> Response { scrub_label_within(ui, label, 24.0) } +/// A tool-options caption whose scrub target includes the adjacent field's outer box. +/// Keep the original label bounds for linking; register the hit area before the numeric editor. +pub fn scrub_option_label(ui: &Ui, label: &Response) { + let scrub = LabelScrub { + pass: ui.ctx().cumulative_pass_nr(), + label: label.id.with("pc-option-scrub"), + rect: label.rect, + max_gap: 24.0, + option_area: true, + steps: 0.0, + started: false, + dragging: false, + stopped: false, + }; + ui.data_mut(|d| d.insert_temp(label_scrub_id(), scrub)); +} + fn scrub_label_within(ui: &Ui, label: &Response, max_gap: f32) -> Response { // Drag only, not focusable: Tab goes to the value field, not to its label as well. let resp = label.interact(Sense::DRAG); + let scrub = label_scrub_response(ui, &resp, label.rect, max_gap); + ui.data_mut(|d| { + d.insert_temp(label_scrub_id(), scrub); + d.insert_temp(resp.id.with("pc-scrub-linked"), false); + }); + resp +} + +fn label_scrub_response(ui: &Ui, resp: &Response, original_rect: Rect, max_gap: f32) -> LabelScrub { // Whether a field took this label's drags last frame: only then is it scrubby. let linked = ui.data(|d| d.get_temp::(resp.id.with("pc-scrub-linked"))).unwrap_or(false); if linked && resp.enabled() && (resp.hovered() || resp.dragged()) { @@ -432,21 +481,17 @@ fn scrub_label_within(ui: &Ui, label: &Response, max_gap: f32) -> Response { steps = acc.trunc(); ui.data_mut(|d| d.insert_temp(acc_id, acc - steps)); } - let scrub = LabelScrub { + LabelScrub { pass: ui.ctx().cumulative_pass_nr(), label: resp.id, - rect: resp.rect, + rect: original_rect, max_gap, + option_area: false, steps, started: resp.drag_started(), dragging: resp.dragged(), stopped: resp.drag_stopped(), - }; - ui.data_mut(|d| { - d.insert_temp(label_scrub_id(), scrub); - d.insert_temp(resp.id.with("pc-scrub-linked"), false); - }); - resp + } } /// The label drag left for the field at `rect` this frame, if its label is just left of it on @@ -473,6 +518,8 @@ struct FieldOpts { decimals: Option, /// The unit typed units convert into (default: the suffix's, at [`set_field_resolution`]). units: Option, + /// The length (px) 100 % stands for, so lengths and percentages convert into each other. + extent: Option, wrap_drag: bool, } @@ -500,16 +547,26 @@ fn value_field_box( trailing: f32, opts: FieldOpts, ) -> (Response, Rect) { - let FieldOpts { decimals, units, wrap_drag } = opts; + let FieldOpts { decimals, units, extent, wrap_drag } = opts; let t = Tokens::get(ui.ctx()); let (rect, slot) = ui.allocate_exact_size(size, Sense::hover()); - let scrub = take_label_scrub(ui, rect); + let scrub = take_label_scrub(ui, rect).map(|s| { + if !s.option_area { + return s; + } + // A direct click+drag target avoids egui snapping the padding/suffix to the nearby + // numeric button. It is not focusable; the editor drawn next wins inside its bounds. + // Preserve a popup field's trailing arrow, and never extend outside the UI clip. + let hit = Rect::from_min_max(pos2(s.rect.left(), rect.top()), pos2(rect.right() - trailing, rect.bottom())).intersect(ui.clip_rect()); + let resp = ui.interact(hit, s.label, Sense::CLICK | Sense::DRAG); + label_scrub_response(ui, &resp, s.rect, s.max_gap) + }); let (dpi, ppi) = ui.data(|d| d.get_temp::<(f64, f64)>(field_resolution_id())).unwrap_or((72.0, 72.0)); // Typed text loses its spaces before parsing, so a " pt" suffix reads as "pt". let typed_suffix = suffix.trim(); let units = match units { - Some(u) => crate::numeric_expression::Units { unit: Some(u.unit), suffix: typed_suffix, dpi: u.dpi, ppi: u.ppi }, - None => crate::numeric_expression::Units { unit: unit_of_suffix(typed_suffix), suffix: typed_suffix, dpi, ppi }, + Some(u) => crate::numeric_expression::Units { unit: Some(u.unit), suffix: typed_suffix, dpi: u.dpi, ppi: u.ppi, extent }, + None => crate::numeric_expression::Units { unit: unit_of_suffix(typed_suffix), suffix: typed_suffix, dpi, ppi, extent }, }; surface(ui, rect, t.field, false); if !t.bevel { @@ -519,12 +576,13 @@ fn value_field_box( // 6 pt from the right edge), so a one-character unit like `%` leaves the number room (#3040). let suffix_font = theme::mono(11.0); let suffix_w = if suffix.is_empty() { 0.0 } else { ui.painter().layout_no_wrap(suffix.to_owned(), suffix_font.clone(), t.text_faint).size().x + 3.0 }; - // 1 pt higher than centred: digits have no descenders, so centred text looks low. The slot - // is at least one text row tall, so a slim box's number isn't pushed out of it (#2823). + // Centred: the digits' ink then sits within 0.2 pt of the box's centre, where a 1 pt lift + // (#1322) put it 0.9 pt high and the focus outline off-centre (#5150). The slot is at least + // one text row tall, so a slim box's number isn't pushed out of it (#2823). let font = theme::mono(12.0); let row_h = ui.painter().layout_no_wrap("0".into(), font.clone(), t.text).size().y; let slot_h = (rect.height() - 4.0).max(row_h).min(rect.height() - 2.0); - let cy = rect.center().y - 1.0; + let cy = rect.center().y; let field = Rect::from_min_max(pos2(rect.left() + 4.0, cy - slot_h / 2.0), pos2(rect.right() - 4.0 - suffix_w - trailing, cy + slot_h / 2.0)); // Small ranges (gamma 0.01–9.99, 0–1 centres) need two decimals and a finer drag, like Photoshop. let fine = range.end() - range.start() <= 10.0; @@ -586,7 +644,11 @@ fn value_field_box( let v = (v * 1e4).round() / 1e4; // `f32::clamp` panics on a reversed or NaN range, which `DragValue::range` accepts. *value = if lo <= hi { v.clamp(lo, hi) } else { v }; - ui.memory_mut(|m| m.request_focus(resp.id)); + // Only take focus back after a typed sum gave it up: re-requesting it while held resets + // the field's arrow-key filter next frame, and a held arrow then moves focus away (#5474). + if !ui.memory(|m| m.has_focus(resp.id)) { + ui.memory_mut(|m| m.request_focus(resp.id)); + } // The text stays selected, as in Photoshop: typing after a step replaces the value. crate::field_tab::select_all(ui.ctx(), resp.id, &if fine { fmt_num2(f64::from(*value)) } else { fmt_num(f64::from(*value)) }); resp.mark_changed(); @@ -718,14 +780,20 @@ pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, w ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside); } let font = if mono { theme::mono(12.0) } else { egui::FontId::proportional(12.5) }; - let mut edit = egui::TextEdit::singleline(text).id(id).hint_text(hint).frame(egui::Frame::NONE).font(font); + let inner = rect.shrink2(TEXT_FIELD_PAD); + // TextEdit draws its text at the top of its rect; fill the box and centre the line in it, so + // the text sits on the field's middle like a dropdown's or value field's. + let mut edit = + egui::TextEdit::singleline(text).id(id).hint_text(hint).frame(egui::Frame::NONE).font(font).min_size(inner.size()).vertical_align(egui::Align::Center); if let Some(limit) = char_limit { edit = edit.char_limit(limit); } // A child Ui keeps the field's selection colour local: `Visuals::selection` also fills selected // list rows and slider trails, which must keep the theme's. let layout = egui::Layout::centered_and_justified(egui::Direction::TopDown); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(TEXT_FIELD_PAD)).layout(layout)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(layout)); + // The theme's 24 pt interact height would make the edit taller than the box's inside. + child.spacing_mut().interact_size.y = inner.height(); child.style_mut().visuals.selection.bg_fill = t.field_selection(); child.add(edit) } @@ -746,6 +814,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. @@ -767,6 +837,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)); @@ -777,6 +866,9 @@ fn number_edit( let mut resp = ui.add( egui::DragValue::new(value) .range(range.clone()) + // Only edits are clamped (below): showing a value past the range, such as a type layer + // scaled to 2400 pt in a 1296 pt field, must not change it (#5717). + .clamp_existing_to_range(false) // A warp is an absolute-position jump. The scoped scrub below removes that jump. .speed(if wrapped_drag { 0.0 } else { speed }) .custom_formatter(move |v, _| match decimals { @@ -787,6 +879,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. @@ -797,7 +893,12 @@ fn number_edit( }), ); if wrap_drag { - crate::scrub_wrap::update(ui, &resp, value, before, range, speed, wrapping); + crate::scrub_wrap::update(ui, &resp, value, before, range.clone(), speed, wrapping); + } + let (lo, hi) = (*range.start(), *range.end()); + // `f32::clamp` panics on a reversed or NaN range, which `DragValue::range` accepts. + if *value != before && lo <= hi { + *value = value.clamp(lo, hi); } resp.flags.set(egui::response::Flags::CHANGED, *value != before); resp @@ -846,6 +947,34 @@ pub struct RowGestures { /// Each wheel notch over the slider moves the value by this step (up = higher; ×10 with /// Shift). Photoshop does this in dialogs, not in the Properties panel. pub wheel_step: Option, + /// A curved track: the knob at fraction f of the track is `lo + (hi - lo) * f^k`, so small + /// values get fine steps (Photoshop's mask Feather, #6545). Typed, scrubbed and stepped + /// values stay linear; only the track maps through the curve. + pub curve: Option, +} + +/// The value at fraction `f` of a track over `lo..=hi`, through `curve` (see [`RowGestures`]). +pub(crate) fn curve_value(f: f32, lo: f32, hi: f32, curve: Option) -> f32 { + match curve { + Some(k) if k.is_finite() && k > 0.0 => lo + f.clamp(0.0, 1.0).powf(k) * (hi - lo), + _ => lo + f * (hi - lo), + } +} + +/// Where `v` sits on a track over `lo..=hi`, as a fraction: the inverse of [`curve_value`]. +pub(crate) fn curve_fraction(v: f32, lo: f32, hi: f32, curve: Option) -> f32 { + let f = ((v - lo) / (hi - lo)).clamp(0.0, 1.0); + match curve { + // An unset (NaN) value draws the knob at the start rather than nowhere. + Some(k) if k.is_finite() && k > 0.0 => { + if f.is_finite() { + f.powf(1.0 / k) + } else { + 0.0 + } + } + _ => f, + } } /// Wheel notches this frame while the pointer is over `resp` (Shift ×10). Wheel lines add up in @@ -930,7 +1059,7 @@ fn slider_with(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive f32::EPSILON { *value = nv; resp.mark_changed(); @@ -953,7 +1082,7 @@ fn slider_with(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive { 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 @@ -1170,6 +1307,9 @@ pub fn dialog_buttons(ui: &mut Ui, buttons: &[DialogButton]) -> Option Vec2 { button_layout(ui, label, min_width, None, Tokens::get(ui.ctx()).text).1 } +/// The height of a primary or secondary button (and so of a dialog's button row). +pub fn button_height(ctx: &egui::Context) -> f32 { + if Tokens::get(ctx).pro { 28.0 } else { 30.0 } +} + fn button_layout(ui: &Ui, label: &str, min_width: f32, height: Option, fg: Color32) -> (std::sync::Arc, Vec2) { let galley = ui.painter().layout_no_wrap(tl!(label).to_owned(), theme::medium(13.0), fg); - let h = height.unwrap_or(if Tokens::get(ui.ctx()).pro { 28.0 } else { 30.0 }); + let h = height.unwrap_or(button_height(ui.ctx())); let size = vec2((galley.size().x + 28.0).max(min_width), h); (galley, size) } @@ -1293,9 +1438,16 @@ pub fn dropdown(ui: &mut Ui, id: &str, current: &mut T, op dropdown_hovered(ui, id, current, options, width).0 } +/// [`dropdown`] whose option labels are shown exactly as given: names the user typed (a channel +/// or layer name) must not be translated, unlike the built-in labels [`dropdown`] translates (#5335). +pub fn dropdown_literal(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32) -> bool { + let (chosen, _) = dropdown_with(ui, id, current, options, width, false, false); + !chosen.is_empty() +} + /// [`dropdown`], also returning the option under the pointer in its open list (live previews). pub fn dropdown_hovered(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32) -> (bool, Option) { - let (chosen, hovered) = dropdown_with(ui, id, current, options, width, false); + let (chosen, hovered) = dropdown_with(ui, id, current, options, width, false, true); (!chosen.is_empty(), hovered) } @@ -1306,11 +1458,21 @@ pub fn dropdown_hovered(ui: &mut Ui, id: &str, current: &m /// in order (one per notch; the last is `current`) and the option under the pointer in the open /// list, none while the pointer rests where the wheel last stepped. pub fn dropdown_wheel_hovered(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32) -> (Vec, Option) { - dropdown_with(ui, id, current, options, width, true) + dropdown_with(ui, id, current, options, width, true, true) } -fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32, wheel: bool) -> (Vec, Option) { - let label = options.iter().find(|(v, _)| v == current).map(|(_, l)| tl!(l)).unwrap_or("—"); +#[allow(clippy::too_many_arguments)] +fn dropdown_with( + ui: &mut Ui, + id: &str, + current: &mut T, + options: &[(T, &str)], + width: f32, + wheel: bool, + translate: bool, +) -> (Vec, Option) { + let shown = |l: &str| if translate { tl!(l).to_string() } else { l.to_string() }; + let label = options.iter().find(|(v, _)| v == current).map(|(_, l)| shown(l)).unwrap_or_else(|| "—".into()); let (mut chosen, mut hovered) = (Vec::new(), None); // The wheel's notches add up over the button and the open list alike. let wheel_id = ui.make_persistent_id(id).with("wheel"); @@ -1322,11 +1484,12 @@ fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, o let mut reveal = wheel && ui.data_mut(|d| d.remove_temp::(reveal_id)).unwrap_or(false); // The list opens on the press: press, drag onto an option and release to choose it (#2735). let (press, close) = crate::press_menu::PressCombo::before(ui, id); + let texts: Vec = options.iter().map(|(_, l)| shown(l)).collect(); let response = egui::ComboBox::from_id_salt(id).selected_text(label).width(width).height(420.0).icon(chevron_icon).close_behavior(close).show_ui(ui, |ui| { - fit_widest_option(ui, options.iter().map(|(_, l)| tl!(l))); + fit_widest_option(ui, texts.iter().map(|l| l.as_str())); // A typed letter jumps to the next option starting with it, ↩ closes the list (#1481). - let labels: Vec<&str> = options.iter().map(|(_, l)| tl!(l)).collect(); + let labels: Vec<&str> = texts.iter().map(|l| l.as_str()).collect(); let shown = options.iter().position(|(v, _)| v == current); if let Some((v, _)) = type_ahead::keys(ui, ui.make_persistent_id(id), shown, &labels).and_then(|i| options.get(i)) { *current = v.clone(); @@ -1345,8 +1508,8 @@ fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, o // The row nearest the pointer, by its distance to each row. let pointer = ui.ctx().pointer_hover_pos().filter(|_| ui.rect_contains_pointer(ui.clip_rect())); let mut nearest: Option<(f32, &T)> = None; - for (v, l) in options { - let item = ui.selectable_label(v == current, tl!(l)); + for ((v, _), text) in options.iter().zip(texts.iter()) { + let item = ui.selectable_label(v == current, text.as_str()); if reveal && v == current { item.scroll_to_me(None); } @@ -1842,9 +2005,9 @@ mod tests { h } - /// Digits have no descenders, so the number sits 1 pt above the box's centre to look centred. + /// The number's slot (and its focus outline) is centred in the box (#5150). #[test] - fn value_field_digits_sit_one_point_above_centre() { + fn value_field_number_slot_is_centred_in_its_box() { let mut h = Harness::new_ui_state( |ui, s: &mut (f32, egui::Rect)| { s.1 = super::value_field_in(ui, &mut s.0, 0.0..=100.0, "px", 80.0, 0.0, super::FieldOpts::default()).1; @@ -1854,11 +2017,47 @@ mod tests { h.run(); let number = h.get_by_role(egui::accesskit::Role::SpinButton).rect(); let field = h.state().1; - assert!((field.center().y - number.center().y - 1.0).abs() < 0.01, "field {field:?}, number {number:?}"); + assert!((field.center().y - number.center().y).abs() < 0.01, "field {field:?}, number {number:?}"); + } + + /// The digits' ink, not just their slot, is centred in the box, and starts level with the + /// `%`'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( + |ui, s: &mut (f32, egui::Rect)| { + s.1 = super::value_field_in(ui, &mut s.0, 0.0..=100.0, "%", 80.0, 0.0, super::FieldOpts::default()).1; + }, + (100.0, egui::Rect::NOTHING), + ); + crate::PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + let image = h.render().expect("render value field"); + let (field, suffix_left) = (h.state().1, h.state().1.right() - 14.0); + let px = |v: f32| (v * scale) as u32; + let bg = image.get_pixel(px(field.left() + 2.0), px(field.center().y)).0; + let lum = |p: [u8; 4]| u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]); + // Top and bottom of the ink (pt) between x0 and x1. + let ink = |x0: f32, x1: f32| { + let rows: Vec = (px(field.top() + 2.0)..px(field.bottom() - 2.0)) + .filter(|&y| (px(x0)..px(x1)).any(|x| lum(image.get_pixel(x, y).0) > lum(bg) + 150)) + .collect(); + let (top, bottom) = (rows.first().copied().unwrap_or(0), rows.last().copied().unwrap_or(0) + 1); + (top as f32 / scale, bottom as f32 / scale) + }; + let digits = ink(field.left() + 4.0, suffix_left - 2.0); + let unit = ink(suffix_left, field.right() - 2.0); + let what = format!("{kind:?}: field {field:?}, digits {digits:?}, unit {unit:?}"); + assert!(((digits.0 + digits.1) / 2.0 - field.center().y).abs() <= 0.25, "{what}"); + assert!((digits.0 - unit.0).abs() <= 0.25, "{what}"); + } } /// With the app's theme (its 24 pt interact height and 4 pt button padding), in a full-size - /// box and a slim status-bar box alike, the number stays inside the box, 1 pt above its centre, + /// box and a slim status-bar box alike, the number stays inside the box, centred in it, /// and a long number doesn't spill out of the box's left edge (#2823). #[test] fn themed_value_field_number_sits_inside_its_box_in_every_size() { @@ -1878,7 +2077,7 @@ mod tests { let field = harness.state().1; let what = format!("{kind:?} {w}x{h}: field {field:?}, number {number:?}"); assert!(field.contains_rect(number), "{what}"); - assert!((field.center().y - number.center().y - 1.0).abs() < 0.51, "{what}"); + assert!((field.center().y - number.center().y).abs() < 0.51, "{what}"); } } } @@ -1948,6 +2147,28 @@ mod tests { assert_eq!(press(&mut h, Modifiers::SHIFT, Key::ArrowUp), 1000.0); } + /// #6545: a curved track (the mask Feather) has fine steps at the left and coarse ones toward + /// the right end, and the knob drawn for a value lands where that value is picked. + #[test] + fn curved_track_maps_ends_middle_and_round_trips() { + use super::{curve_fraction, curve_value}; + for (f, want) in [(0.0, 0.0), (0.1, 1.0), (0.2, 8.0), (0.5, 125.0), (1.0, 1000.0)] { + assert!((curve_value(f, 0.0, 1000.0, Some(3.0)) - want).abs() < 1e-3, "{f}: {}", curve_value(f, 0.0, 1000.0, Some(3.0))); + } + assert_eq!(curve_value(0.25, 0.0, 1000.0, None), 250.0, "no curve is the linear track"); + assert_eq!(curve_value(0.5, 10.0, 20.0, Some(3.0)), 11.25, "the curve starts at the range's low end"); + for i in 0..=100 { + let f = i as f32 / 100.0; + let back = curve_fraction(curve_value(f, 0.0, 1000.0, Some(3.0)), 0.0, 1000.0, Some(3.0)); + assert!((back - f).abs() < 1e-4, "{f} -> {back}"); + let v = i as f32 * 10.0; + let again = curve_value(curve_fraction(v, 0.0, 1000.0, Some(3.0)), 0.0, 1000.0, Some(3.0)); + assert!((again - v).abs() < 1e-3, "{v} -> {again}"); + } + assert_eq!(curve_fraction(-5.0, 0.0, 1000.0, Some(3.0)), 0.0); + assert_eq!(curve_fraction(f32::NAN, 0.0, 1000.0, Some(3.0)), 0.0); + } + #[test] fn arrow_keys_work_out_a_typed_sum_then_step_it() { let mut h = focused(100.0, 0.0..=1000.0); @@ -1958,6 +2179,30 @@ mod tests { assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowDown), 10.0, "the field keeps focus"); } + /// #5474: a held arrow repeats on consecutive frames; every repeat steps the focused field + /// and none of them moves focus to the field above. + #[test] + fn held_arrow_keeps_stepping_the_focused_field() { + let mut h = Harness::new_ui_state( + |ui, s: &mut (f32, f32)| { + super::value_field(ui, &mut s.0, 0.0..=1000.0, "", 80.0); + super::value_field(ui, &mut s.1, 0.0..=1000.0, "", 80.0); + }, + (10.0, 59.0), + ); + h.run(); + h.get_all_by_role(egui::accesskit::Role::SpinButton).nth(1).expect("second field").click(); + h.run(); + // Held down: key-down repeats on consecutive frames, no key-up in between. + for repeat in [false, true, true, true, true, true] { + h.event(egui::Event::Key { key: Key::ArrowUp, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE }); + h.step(); + } + h.event(egui::Event::Key { key: Key::ArrowUp, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run(); + assert_eq!(*h.state(), (10.0, 65.0)); + } + #[test] fn arrow_keys_leave_an_unfocused_field_alone() { let mut h = field(100.0, 0.0..=1000.0); @@ -2156,6 +2401,29 @@ mod tests { assert_eq!(*h.state(), (25.0, 3)); } + /// #5717: a value past the range shows as it is without reporting a change; an edit is clamped. + #[test] + fn value_field_keeps_a_shown_value_past_its_range_and_clamps_edits() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut h = Harness::builder().with_size(egui::vec2(300.0, 100.0)).build_ui_state( + |ui, (v, changes): &mut (f32, u32)| { + if super::value_field(ui, v, 0.1..=1296.0, "pt", 90.0).changed() { + *changes += 1; + } + }, + (2400.0, 0), + ); + h.run_steps(3); + assert_eq!(*h.state(), (2400.0, 0), "showing it changes nothing"); + h.get_by_role(egui::accesskit::Role::SpinButton).click(); + h.run(); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("5000".into())); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(h.state().0, 1296.0, "a typed value is clamped"); + } + #[test] fn value_field_applies_typed_arithmetic() { use egui::Key::{Enter, Tab}; @@ -2487,3 +2755,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); +} diff --git a/crates/ui-egui/src/zoom_levels.rs b/crates/ui-egui/src/zoom_levels.rs index 8d87e92cea..a77f6cc35c 100644 --- a/crates/ui-egui/src/zoom_levels.rs +++ b/crates/ui-egui/src/zoom_levels.rs @@ -74,16 +74,20 @@ pub fn percent_range(view: &crate::state::View) -> std::ops::RangeInclusive (min(view.doc_size).min(z) * 100.0)..=(MAX.max(z) * 100.0) } -/// Zoom In (`dir` > 0) or Zoom Out (`dir` < 0) from `zoom`, for a document of `size` px. -pub fn step(zoom: f32, dir: i32, size: [u32; 2]) -> f32 { - let z = clamp(zoom, size); +/// Zoom In (`dir` > 0) or Zoom Out (`dir` < 0) within an explicit range. +pub fn step_in_range(zoom: f32, dir: i32, lo: f32, hi: f32) -> f32 { + let z = if zoom.is_finite() { zoom.clamp(lo, hi) } else { 1.0 }; let next = if dir > 0 { - LADDER.iter().copied().find(|s| *s > z * 1.001).unwrap_or(MAX) + LADDER.iter().copied().find(|s| *s > z * 1.001).unwrap_or(hi) } else { - // Below the ladder's lowest step (reached by the wheel), Zoom Out stays put. LADDER.iter().rev().copied().find(|s| *s < z * 0.999).unwrap_or(z) }; - clamp(next, size) + if next.is_finite() { next.clamp(lo, hi) } else { z } +} + +/// Zoom In (`dir` > 0) or Zoom Out (`dir` < 0) from `zoom`, for a document of `size` px. +pub fn step(zoom: f32, dir: i32, size: [u32; 2]) -> f32 { + step_in_range(zoom, dir, min(size), MAX) } #[cfg(test)] @@ -146,6 +150,18 @@ mod tests { assert_eq!(min([0, 0]), 0.001, "unknown size"); } + #[test] + fn step_in_range_respects_custom_bounds() { + assert_eq!(step_in_range(1.0, 1, 0.0001, 16.0), 2.0); + assert!((step_in_range(1.0, -1, 0.0001, 16.0) - 2.0 / 3.0).abs() < 1e-6); + assert_eq!(step_in_range(16.0, 1, 0.0001, 16.0), 16.0, "at max stays put"); + assert_eq!(step_in_range(0.0001, -1, 0.0001, 16.0), 0.0001, "at min stays put"); + assert_eq!(step_in_range(f32::NAN, 1, 0.0001, 16.0), 2.0, "NaN → 100% → step"); + assert_eq!(step_in_range(8.8, 1, 0.0001, 16.0), 12.0, "off-ladder steps up"); + assert_eq!(step_in_range(8.8, -1, 0.0001, 16.0), 8.0, "off-ladder steps down"); + assert_eq!(step(1.0, 1, PHOTO), step_in_range(1.0, 1, min(PHOTO), MAX), "step delegates"); + } + #[test] fn clamp_keeps_the_range_and_survives_bad_input() { assert_eq!(clamp(500.0, PHOTO), MAX); 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 068de36c49..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"); @@ -259,3 +264,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(); +} diff --git a/crates/ui-egui/tests/crop_popup_keys.rs b/crates/ui-egui/tests/crop_popup_keys.rs new file mode 100644 index 0000000000..9c893ae7e0 --- /dev/null +++ b/crates/ui-egui/tests/crop_popup_keys.rs @@ -0,0 +1,92 @@ +//! Crop tool: Esc and Enter belong to an open options bar popup first (#7386). The shortcut pass +//! runs before the panels are drawn, so the crop branch used to take the key: Esc cancelled the +//! frame and left the Ratio list open, Enter committed the crop beneath it. +//! +//! Skips when no GPU adapter exists (like `crop_default_mode.rs`). + +use egui::Key; +use egui_kittest::kittest::Queryable; +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::control::{ControlRequest, handle}; +use serde_json::{Value, json}; + +type Harness = egui_kittest::Harness<'static, PhotocraftApp>; + +fn harness() -> Option { + let built = std::panic::catch_unwind(|| { + egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 640.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()); + // The queries below use English labels; don't follow the system locale. + app.session.prefs.edit(|p| p.interface.language = "en".into()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app + }) + }); + match built { + Ok(h) => Some(h), + Err(_) => { + eprintln!("skipping: no GPU adapter"); + None + } + } +} + +fn control(h: &mut Harness, method: &str, params: Value) { + let ctx = h.ctx.clone(); + let (req, _rx) = ControlRequest::new(method, params); + handle(h.state_mut(), &ctx, &req); + h.run_steps(3); +} + +/// A 200x100 document with the Crop tool's 1:1 frame pending and the Ratio list open again. +fn setup() -> Option { + let mut h = harness()?; + h.run_steps(4); + { + let app = h.state_mut(); + app.run("file.new", json!({"width": 200, "height": 100, "background": "white"})).expect("new"); + app.sync_views(); + } + control(&mut h, "ui.set", json!({"tool": "crop", "zoom": 1.0, "center": [100, 50]})); + h.get_by_value("Ratio").click(); + h.run_steps(3); + h.get_by_label("1 : 1 (Square)").click(); + h.run_steps(3); + assert!(photocraft_ui_egui::crop_ui::pending(h.state()), "the 1:1 frame is pending"); + assert!(!egui::Popup::is_any_open(&h.ctx), "the list closed after the pick"); + h.get_by_value("1 : 1 (Square)").click(); + h.run_steps(3); + assert!(egui::Popup::is_any_open(&h.ctx), "the Ratio list is open again"); + Some(h) +} + +#[test] +fn esc_closes_the_open_ratio_list_and_keeps_the_frame() { + // Windows WARP can access-violate when GPU harnesses initialize or tear down concurrently: + // hold the GPU lock for the whole test, harness included. + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut h) = setup() else { return }; + let frame = h.state().ui.crop_rect; + h.key_press(Key::Escape); + h.run_steps(3); + assert!(!egui::Popup::is_any_open(&h.ctx), "Esc closes the list"); + assert_eq!(h.state().ui.crop_rect, frame, "the frame stays as it was"); + assert!(photocraft_ui_egui::crop_ui::pending(h.state()), "still pending"); + // With the list closed, the keys reach the crop again: Enter commits it. + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().doc.size, photocraft_doc::Size::new(100, 100), "the next Enter crops"); +} + +#[test] +fn enter_with_the_ratio_list_open_does_not_commit_the_crop() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut h) = setup() else { return }; + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().doc.size, photocraft_doc::Size::new(200, 100), "nothing cropped"); + assert!(photocraft_ui_egui::crop_ui::pending(h.state()), "the frame is still pending"); +} diff --git a/crates/ui-egui/tests/custom_print_paper.rs b/crates/ui-egui/tests/custom_print_paper.rs index 7361468bbc..d0d81dea69 100644 --- a/crates/ui-egui/tests/custom_print_paper.rs +++ b/crates/ui-egui/tests/custom_print_paper.rs @@ -10,6 +10,8 @@ fn app() -> (PhotocraftApp, u64) { let id = open_print(&mut app); // Every UI path in these fixtures is safe even if an unexpected Enter confirms the dialog. app.ui.dialog_mut(id).unwrap().fields.insert("dryRun".into(), json!(true)); + // Exercise PDF paper controls even when native printers are available, on every OS. + app.ui.dialog_mut(id).unwrap().fields.insert("__printers".into(), json!(["Test native printer"])); (app, id) } diff --git a/crates/ui-egui/tests/drag_preview_canvas.rs b/crates/ui-egui/tests/drag_preview_canvas.rs index fc516ed307..9b7d682abb 100644 --- a/crates/ui-egui/tests/drag_preview_canvas.rs +++ b/crates/ui-egui/tests/drag_preview_canvas.rs @@ -295,3 +295,27 @@ fn a_held_marquee_drag_keeps_requesting_frames() { assert!(d.is_some_and(|d| d <= Duration::from_millis(100)), "mid-drag repaint request: {d:?}"); button(&mut h, 300.0, 220.0, false); } + +#[test] +fn the_spot_healing_trail_is_a_dark_half_opaque_tint() { + // #7342: the trail was white at a quarter opacity, invisible on white and faint elsewhere. + // Photoshop shows the area to heal in half-opaque dark grey. + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut h) = harness(1.0) else { return }; + h.run_steps(4); + for bg in ["white", "#808080"] { + setup(&mut h, bg, "spotHealing"); + let app = h.state_mut(); + app.session.tools.brush.size = 40.0; + app.session.tools.brush.hardness = 1.0; + let before = render(&mut h); + drag_to(&mut h, [80.0, 150.0], [320.0, 150.0]); + let img = render(&mut h); + let p = screen(&h, 200.0, 150.0); + let (was, now) = (before.luma(p.x as i64, p.y as i64).unwrap_or(0), img.luma(p.x as i64, p.y as i64).unwrap_or(0)); + assert!(f32::from(now) < f32::from(was) * 0.6, "spot healing trail on {bg}: luma {was} -> {now}, not dark enough"); + assert!(f32::from(now) > f32::from(was) * 0.4, "spot healing trail on {bg}: luma {was} -> {now}, should stay see-through"); + button(&mut h, 320.0, 150.0, false); + h.run_steps(2); + } +} diff --git a/crates/ui-egui/tests/full_res_preview.rs b/crates/ui-egui/tests/full_res_preview.rs index 6ac2a513c5..f472dd2ad3 100644 --- a/crates/ui-egui/tests/full_res_preview.rs +++ b/crates/ui-egui/tests/full_res_preview.rs @@ -109,6 +109,10 @@ fn mean(r: &[i32]) -> f32 { r.iter().sum::() as f32 / r.len() as f32 } +/// Dragging an adjustment layer's control (Properties panel) previews through the reduced copy +/// only as coarse as the screen (#5764): at 100 % every column shows even with low-resolution +/// previews on; zoomed out to 50 % the default drag runs on the reduced copy (only the black columns, inverted), and with +/// them off it matches the committed view (the columns' mipmapped grey). #[test] fn adjustment_layer_drag_previews_at_full_resolution_when_low_res_is_off() { let _gpu = photocraft_testkit::gpu_lock(); @@ -126,29 +130,45 @@ fn adjustment_layer_drag_previews_at_full_resolution_when_low_res_is_off() { app.live_adjust = Some((id, json!({}))); id }; - 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 drag is never coarser than the screen ({})", columns(&at_100)); + control(&mut h, "ui.set", json!({"zoom": 0.5, "center": [W / 2, H / 2]})); + h.state_mut().live_adjust = None; + let committed = mean(&row(&mut h)); + assert!(committed > 60.0, "zoomed out the inverted columns average to grey ({committed})"); + h.state_mut().live_adjust = Some((layer, json!({}))); + let reduced = mean(&row(&mut h)); + assert!(reduced > 225.0, "default at 50 %: the reduced copy keeps only the (inverted) black columns ({reduced})"); low_res(&mut h, false); h.state_mut().live_adjust = Some((layer, json!({}))); - 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 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] diff --git a/crates/ui-egui/tests/paint_bucket_all_layers.rs b/crates/ui-egui/tests/paint_bucket_all_layers.rs index 27e1428942..9e71d918d0 100644 --- a/crates/ui-egui/tests/paint_bucket_all_layers.rs +++ b/crates/ui-egui/tests/paint_bucket_all_layers.rs @@ -52,7 +52,7 @@ fn setup(h: &mut Harness) -> LayerId { o.tolerance = 0.0; o.anti_alias = false; o.contiguous = true; - o.sample_all_layers = false; + o.sample_all.paint_bucket = false; o.bucket_fill_pattern = false; o.fill_opacity = 100.0; background @@ -98,7 +98,7 @@ fn all_layers_checkbox_limits_bucket_fill_to_the_visible_color() { h.get_by_label("All Layers").click(); h.run_steps(3); } - assert_eq!(h.state().ui.tool_options.sample_all_layers, all_layers, "checkbox changed the option"); + assert_eq!(h.state().ui.tool_options.sample_all.paint_bucket, all_layers, "checkbox changed the option"); let pos = screen(&h, 300.0, 150.0); h.event(egui::Event::PointerMoved(pos)); diff --git a/crates/ui-egui/tests/remove_channel_target.rs b/crates/ui-egui/tests/remove_channel_target.rs new file mode 100644 index 0000000000..5d18009baa --- /dev/null +++ b/crates/ui-egui/tests/remove_channel_target.rs @@ -0,0 +1,100 @@ +//! #7366: a background Remove on a Red-only target keeps G/B/alpha intact when a Type click +//! selects another layer while the job is pending. + +use photocraft_doc::LayerId; +use photocraft_engine::Session; +use photocraft_engine::jobs::Started; +use photocraft_ui_egui::{PhotocraftApp, Services, control, type_tool}; + +const REMOVE: &str = r#"{"points":[[12,10],[16,12]],"size":6}"#; + +fn run(s: &mut Session, id: &str, p: &str) { + s.execute(id, p.parse().unwrap()).unwrap(); +} + +fn pixels(s: &Session, doc: usize, id: LayerId) -> Vec { + let d = &s.documents()[doc].doc; + d.layer(id).unwrap().surface().unwrap().read_region(d.bounds()) +} + +#[derive(Clone, Copy)] +enum Input { + None, + TypeTool, + ControlPointer, +} + +fn probe(input: Input) { + let mut a = PhotocraftApp::new(Session::new(), Services::default()); + run(&mut a.session, "file.new", r#"{"name":"A","width":32,"height":24,"background":"transparent"}"#); + run(&mut a.session, "type.create", r#"{"text":"X","x":2,"y":18,"size":14}"#); + let text = a.session.active().unwrap().active_layer.unwrap(); + run(&mut a.session, "layer.new.layer", r#"{"name":"Target"}"#); + let target = a.session.active().unwrap().active_layer.unwrap(); + a.session + .edit("fixture", |d, l| { + let q = d.layer_mut(l.unwrap()).unwrap().surface_mut().unwrap(); + for y in 0..24 { + for x in 0..32 { + q.write_pixel(x, y, &[((x * 7 + y * 13) % 31) as f32 / 30., (x % 5) as f32 / 4., (y % 7) as f32 / 6., 1.]); + } + } + Ok(()) + }) + .unwrap(); + run(&mut a.session, "file.new", r##"{"name":"B","width":32,"height":24,"background":"#6a219c"}"##); + a.session.set_active(0); + a.sync_views(); + run(&mut a.session, "channel.target", r#"{"channel":"red"}"#); + let b = a.session.documents()[1].doc.clone(); + let text_before = a.session.documents()[0].doc.layer(text).unwrap().clone(); + + // The inline Red-only Remove is the oracle. + let mut inline = Session::new(); + inline.add_document(a.session.documents()[0].doc.as_ref().clone(), None); + inline.select_layer(target).unwrap(); + run(&mut inline, "channel.target", r#"{"channel":"red"}"#); + run(&mut inline, "paint.remove", REMOVE); + let expected = pixels(&inline, 0, target); + + let job = match a.session.start("paint.remove", REMOVE.parse().unwrap()).unwrap() { + Started::Job(id) => id, + v => panic!("must start a background job: {v:?}"), + }; + match input { + Input::None => {} + Input::TypeTool => assert!(type_tool::pointer_down(&mut a, 4., 14., Default::default()), "the click must hit the text"), + Input::ControlPointer => { + let (req, _rx) = control::ControlRequest::new("ui.pointer", r#"{"tool":"type","events":[{"kind":"down","x":4,"y":14}]}"#.parse().unwrap()); + let _ = control::handle(&mut a, &Default::default(), &req); + } + } + let selected = if matches!(input, Input::None) { target } else { text }; + assert_eq!(a.session.active().unwrap().active_layer, Some(selected), "the input selects the text layer"); + + a.session.set_active(1); + a.session.wait_job(job).unwrap(); + assert_eq!(a.session.documents()[1].doc, b, "B is unchanged"); + let st = &a.session.documents()[0]; + assert_eq!(st.doc.layer(text).unwrap(), &text_before, "the text layer is unchanged"); + assert_eq!(st.active_layer, Some(selected), "the user's selection survives the job"); + let actual = pixels(&a.session, 0, target); + let wrong = expected.as_chunks::<4>().0.iter().zip(actual.as_chunks::<4>().0).filter(|(e, r)| e[1..] != r[1..]).count(); + assert_eq!(wrong, 0, "pixels with G/B/alpha changed outside the Red target"); + assert_eq!(actual, expected); +} + +#[test] +fn no_input_keeps_red_restriction() { + probe(Input::None); +} + +#[test] +fn type_tool_click_keeps_red_restriction() { + probe(Input::TypeTool); +} + +#[test] +fn control_pointer_click_keeps_red_restriction() { + probe(Input::ControlPointer); +} diff --git a/crates/vector/src/edit.rs b/crates/vector/src/edit.rs index ea99cff25c..7f1f4d5823 100644 --- a/crates/vector/src/edit.rs +++ b/crates/vector/src/edit.rs @@ -80,6 +80,12 @@ fn handle(k: &Knot, h: Handle) -> Point { } } +/// Whether the segment from knot `a` to knot `b` is straight: both of its handles retracted. +fn straight(a: &Knot, b: &Knot) -> bool { + let retracted = |k: &Knot, h: Handle| dist(handle(k, h), k.anchor) < 1e-9; + retracted(a, Handle::Out) && retracted(b, Handle::In) +} + fn other(h: Handle) -> Handle { match h { Handle::In => Handle::Out, @@ -116,6 +122,39 @@ pub fn move_anchors(path: &mut Path, anchors: &[[usize; 2]], d: [f64; 2]) -> Res Ok(()) } +/// The Curvature Pen's anchor drag: moves the anchors like [`move_anchors`], then recomputes the +/// handles of every smooth knot in the subpaths it touched with [`curvature_knots`], so the curve +/// keeps flowing smoothly through all its anchors, as Photoshop does. Corner knots keep their own +/// handles (moved with them). +pub fn move_anchors_curvature(path: &mut Path, anchors: &[[usize; 2]], d: [f64; 2]) -> Result<(), String> { + move_anchors(path, anchors, d)?; + let mut subs: Vec = anchors.iter().map(|a| a[0]).collect(); + subs.sort_unstable(); + subs.dedup(); + for s in subs { + let sp = path.subpaths.get_mut(s).ok_or_else(|| format!("no subpath {s}"))?; + let pts: Vec<[f64; 2]> = sp.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect(); + let corners: Vec = sp.knots.iter().map(|k| !k.smooth).collect(); + let fresh = curvature_knots(&pts, &corners, sp.closed); + for (k, f) in sp.knots.iter_mut().zip(fresh) { + if k.smooth { + k.in_ctrl = Point::new(f[1][0], f[1][1]); + k.out_ctrl = Point::new(f[2][0], f[2][1]); + } + } + } + Ok(()) +} + +/// The Curvature Pen's double-click on an anchor: a corner becomes smooth and a smooth knot a +/// corner (handles retracted), then the subpath reflows as [`move_anchors_curvature`] does, so the +/// segments around a new smooth point curve through it. +pub fn toggle_curvature_point(path: &mut Path, knot: [usize; 2]) -> Result<(), String> { + let k = knot_mut(path, knot)?; + *k = if k.smooth { Knot::corner(k.anchor.x, k.anchor.y) } else { Knot { smooth: true, ..*k } }; + move_anchors_curvature(path, &[knot], [0.0, 0.0]) +} + /// Puts handle `h` of a knot at `to`. A smooth knot turns its other handle to stay collinear, /// unless `independent` (Convert Point / ⌥), which makes it a corner whose handles move apart. pub fn move_handle(path: &mut Path, knot: [usize; 2], h: Handle, to: Point, independent: bool) -> Result<(), String> { @@ -145,10 +184,9 @@ pub fn convert_point(path: &mut Path, knot: [usize; 2], out: Option) -> R /// the pointer exactly, with the nearer handle moving more. Smooth end knots stay smooth. pub fn bend_segment(path: &mut Path, knot: [usize; 2], t: f64, d: [f64; 2]) -> Result<(), String> { let end = segment_end(path, knot)?; - let retracted = |k: &Knot, h: Handle| dist(handle(k, h), k.anchor) < 1e-9; let a = *knot_mut(path, knot)?; let b = *knot_mut(path, end)?; - if retracted(&a, Handle::Out) && retracted(&b, Handle::In) { + if straight(&a, &b) { return move_anchors(path, &[knot, end], d); } // B(t) moves by 3(1−t)²t·Δc1 + 3(1−t)t²·Δc2. With Δc1 = (1−t)·s·d and Δc2 = t·s·d that is @@ -180,10 +218,9 @@ pub fn add_anchor(path: &mut Path, knot: [usize; 2], t: f64) -> Result<[usize; 2 return Err("`t` must be strictly between 0 and 1".into()); } let end = segment_end(path, knot)?; - let retracted = |k: &Knot, h: Handle| dist(handle(k, h), k.anchor) < 1e-9; let a = *knot_mut(path, knot)?; let b = *knot_mut(path, end)?; - let new = if retracted(&a, Handle::Out) && retracted(&b, Handle::In) { + let new = if straight(&a, &b) { // Hit testing and hover feedback use the cubic parameter, which is not linear even // when both handles are retracted. Keep the inserted corner at that same point. let p = eval(&[a.anchor, a.out_ctrl, b.in_ctrl, b.anchor], t); @@ -348,6 +385,60 @@ mod tests { path.subpaths[s].knots[k] } + /// A closed four-anchor curvature loop (all smooth), as the Curvature Pen draws it. + fn loop4() -> Path { + let pts = [[0.0, 50.0], [100.0, 0.0], [200.0, 50.0], [100.0, 100.0]]; + let knots = curvature_knots(&pts, &[false; 4], true) + .into_iter() + .map(|k| Knot::smooth(Point::new(k[0][0], k[0][1]), Point::new(k[1][0], k[1][1]), Point::new(k[2][0], k[2][1]))) + .collect(); + Path::new(vec![Subpath { closed: true, knots, op: Default::default() }]) + } + + #[test] + fn a_curvature_drag_reflows_every_smooth_knot_through_the_moved_anchor() { + let mut p = loop4(); + move_anchors_curvature(&mut p, &[[0, 1]], [0.0, 60.0]).unwrap(); + let pts = [[0.0, 50.0], [100.0, 60.0], [200.0, 50.0], [100.0, 100.0]]; + let want = curvature_knots(&pts, &[false; 4], true); + for (k, w) in p.subpaths[0].knots.iter().zip(want) { + assert_eq!([[k.anchor.x, k.anchor.y], [k.in_ctrl.x, k.in_ctrl.y], [k.out_ctrl.x, k.out_ctrl.y]], w); + } + // The neighbours turned: not a plain move. + assert_ne!(at(&p, 0, 0).out_ctrl, at(&loop4(), 0, 0).out_ctrl); + } + + #[test] + fn a_curvature_drag_keeps_corner_handles_and_rejects_bad_indices() { + let mut p = curve(); + move_anchors_curvature(&mut p, &[[0, 1]], [0.0, 10.0]).unwrap(); + assert_eq!((at(&p, 0, 0), at(&p, 0, 2)), (at(&curve(), 0, 0), at(&curve(), 0, 2)), "the corners kept their handles"); + assert_eq!(at(&p, 0, 1).anchor, Point::new(50.0, 10.0)); + let before = curve(); + let mut q = curve(); + assert!(move_anchors_curvature(&mut q, &[[0, 9]], [1.0, 1.0]).is_err()); + assert!(move_anchors_curvature(&mut q, &[[4, 0]], [1.0, 1.0]).is_err()); + assert_eq!(q, before, "a refused edit changes nothing"); + } + + #[test] + fn a_curvature_double_click_turns_a_corner_smooth_and_back() { + // A closed square of corners, as the Pen draws it. + let square = || Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)])]); + let mut p = square(); + toggle_curvature_point(&mut p, [0, 1]).unwrap(); + let k = at(&p, 0, 1); + assert!(k.smooth); + let want = curvature_knots(&[[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]], &[true, false, true, true], true)[1]; + assert_eq!([[k.in_ctrl.x, k.in_ctrl.y], [k.out_ctrl.x, k.out_ctrl.y]], [want[1], want[2]], "its handles follow its neighbours"); + assert_eq!((at(&p, 0, 0), at(&p, 0, 2)), (at(&square(), 0, 0), at(&square(), 0, 2)), "the corners around keep theirs"); + toggle_curvature_point(&mut p, [0, 1]).unwrap(); + assert_eq!(p, square(), "a second double-click makes it a corner again"); + assert!(toggle_curvature_point(&mut p, [0, 7]).is_err()); + assert!(toggle_curvature_point(&mut p, [3, 0]).is_err()); + assert_eq!(p, square()); + } + #[test] fn anchors_move_with_their_handles_once_each() { let mut p = curve(); diff --git a/crates/vector/src/flatten.rs b/crates/vector/src/flatten.rs index b3fd0a90f6..a586dfa1bf 100644 --- a/crates/vector/src/flatten.rs +++ b/crates/vector/src/flatten.rs @@ -109,8 +109,38 @@ pub fn validate_shape(path: &Path, stroke: Option<&photocraft_doc::ShapeStroke>) Ok(()) } -/// Flattens one subpath. Closed subpaths do not repeat the first point at the end. +/// Points one flattened path may hold. Paths that never pass [`validate_shape`] (vector masks +/// from files, imported SVG, effect outlines) are flattened more coarsely past it, so hostile +/// curves cannot exhaust memory. +pub(crate) const MAX_PATH_POINTS: usize = 1 << 20; + +/// How much to coarsen the curves of `subpaths` at `tol` so the whole path stays within +/// [`MAX_PATH_POINTS`]: the share of each curve's pieces to keep (1 = all of them). +fn piece_scale<'a>(subpaths: impl IntoIterator, tol: f64) -> f64 { + // Subpath starts and straight segments take one point each and cannot be coarsened. + let (mut fixed, mut curves) = (0usize, 0usize); + for s in subpaths { + fixed = fixed.saturating_add(1); + for seg in s.segments() { + if is_line(&seg) { + fixed = fixed.saturating_add(1); + } else { + curves = curves.saturating_add(pieces(&seg, tol)); + } + } + } + let total = fixed.saturating_add(curves); + if total <= MAX_PATH_POINTS { 1.0 } else { MAX_PATH_POINTS as f64 / total as f64 } +} + +/// Flattens one subpath. Closed subpaths do not repeat the first point at the end. A hostile +/// subpath is flattened more coarsely so it stays within a bounded number of points. pub fn flatten_subpath(s: &Subpath, tol: f64) -> Polyline { + flatten_scaled(s, tol, piece_scale([s], tol)) +} + +/// [`flatten_subpath`] keeping the share `scale` of each curve's pieces (at least one). +fn flatten_scaled(s: &Subpath, tol: f64, scale: f64) -> Polyline { let mut out = Polyline { closed: s.closed, ..Default::default() }; if let Some(k) = s.knots.first() { out.push((k.anchor.x, k.anchor.y), true); @@ -121,6 +151,7 @@ pub fn flatten_subpath(s: &Subpath, tol: f64) -> Polyline { continue; } let n = pieces(seg, tol); + let n = if scale < 1.0 { ((n as f64 * scale) as usize).max(1) } else { n }; for j in 1..=n { out.push(eval(seg, j as f64 / n as f64), j == n); } @@ -136,15 +167,18 @@ pub fn flatten_subpath(s: &Subpath, tol: f64) -> Polyline { out } -/// Flattens every subpath (see [`flatten_subpath`]). +/// Flattens every subpath (see [`flatten_subpath`]); a hostile path is flattened more coarsely +/// so the whole path stays within a bounded number of points. pub fn flatten_path(p: &Path, tol: f64) -> Vec { - p.subpaths.iter().map(|s| flatten_subpath(s, tol)).collect() + let scale = piece_scale(&p.subpaths, tol); + p.subpaths.iter().map(|s| flatten_scaled(s, tol, scale)).collect() } #[cfg(test)] mod tests { use super::*; use crate::shapes; + use photocraft_doc::Knot; #[test] fn lines_are_not_subdivided() { @@ -168,4 +202,26 @@ mod tests { assert!(p.pts.len() < 4 * (1 + (0.75 * 100.0 / tol.min(0.15)).sqrt() as usize + 1)); } } + + #[test] + fn hostile_curves_flatten_within_the_point_budget() { + // Unvalidated paths (a vector mask from a file, an imported SVG) can carry handles far + // outside the canvas: each of these segments alone asks for the 65536-piece cap, so the + // path would flatten to ~1.3M points (#5195). + let segments = 20; + let knots = (0..=segments) + .map(|i| { + let a = Point::new(f64::from(i) * 50.0, f64::from(i % 2) * 1000.0); + Knot::smooth(a, Point::new(a.x - 3e7, a.y + 3e7), Point::new(a.x + 3e7, a.y - 3e7)) + }) + .collect(); + let path = Path::new(vec![Subpath { closed: false, knots, op: Default::default() }]); + let lines = flatten_path(&path, 0.01); + let points: usize = lines.iter().map(|l| l.pts.len()).sum(); + assert!(points > 1000 && points <= MAX_PATH_POINTS + segments as usize + 1, "{points} points"); + assert_eq!(flatten_subpath(&path.subpaths[0], 0.01).pts.len(), points); + // Ordinary paths are flattened exactly as before. + let circle = shapes::ellipse(0.0, 0.0, 200.0, 200.0); + assert_eq!(flatten_path(&circle, 0.01)[0], flatten_scaled(&circle.subpaths[0], 0.01, 1.0)); + } } diff --git a/crates/vector/src/lib.rs b/crates/vector/src/lib.rs index 44e70ce760..d336435492 100644 --- a/crates/vector/src/lib.rs +++ b/crates/vector/src/lib.rs @@ -43,10 +43,12 @@ pub const DEFAULT_TOLERANCE: f64 = 0.01; /// folded with the subpath operations; open subpaths are closed implicitly. pub fn fill_rasterizer(path: &Path, tol: f64) -> Rasterizer { let mut r = Rasterizer::new(path.inverted); + // One point budget for the whole path, then one edge sort for all components. + let polys: Vec> = flatten_path(path, tol).into_iter().map(|l| l.pts).collect(); for c in path.components() { - let polys: Vec> = path.subpaths[c.clone()].iter().map(|s| flatten_subpath(s, tol).pts).collect(); - r.add_component(&polys, path.effective_op(c.start), path.fill_rule); + r.push_component(polys.get(c.clone()).unwrap_or_default(), path.effective_op(c.start), path.fill_rule); } + r.sort_edges(); r } @@ -105,26 +107,26 @@ impl CompiledVectorMask { /// Effective mask values over `rect`, with invocation-local rasterization state. pub fn render(&self, rect: Rect) -> Vec { - let mut v = match &self.geometry { - MaskGeometry::Constant(value) => vec![*value; rect.width() as usize * rect.height() as usize], - MaskGeometry::Path(rasterizer) => rasterizer.render(rect), - }; - self.apply_density(&mut v); - v + self.render_with(rect, Rasterizer::render) } /// Effective mask values without entering a thread pool. Use this while /// initializing a shared result that other Rayon workers may wait for. pub fn render_sequential(&self, rect: Rect) -> Vec { + self.render_with(rect, |rasterizer, rect| { + let mut v = vec![0.0; rect.width() as usize * rect.height() as usize]; + if !v.is_empty() { + rasterizer.render_into(rect, &mut v); + } + v + }) + } + + /// Mask values over `rect`, with a path's coverage from `coverage`. + fn render_with(&self, rect: Rect, coverage: impl FnOnce(&Rasterizer, Rect) -> Vec) -> Vec { let mut v = match &self.geometry { MaskGeometry::Constant(value) => vec![*value; rect.width() as usize * rect.height() as usize], - MaskGeometry::Path(rasterizer) => { - let mut v = vec![0.0; rect.width() as usize * rect.height() as usize]; - if !v.is_empty() { - rasterizer.render_into(rect, &mut v); - } - v - } + MaskGeometry::Path(rasterizer) => coverage(rasterizer, rect), }; self.apply_density(&mut v); v diff --git a/crates/vector/src/map.rs b/crates/vector/src/map.rs index ab2e62e1e8..6078e604f8 100644 --- a/crates/vector/src/map.rs +++ b/crates/vector/src/map.rs @@ -15,13 +15,18 @@ use photocraft_geom::Point; pub fn map_points(path: &Path, f: impl Fn(Point) -> Point) -> Path { let mut out = path.clone(); for s in &mut out.subpaths { - for k in &mut s.knots { - *k = Knot { anchor: f(k.anchor), in_ctrl: f(k.in_ctrl), out_ctrl: f(k.out_ctrl), smooth: k.smooth }; - } + map_knots(s, &f); } out } +/// Sends every anchor and control point of `s` through `f`, in place. +fn map_knots(s: &mut Subpath, f: &impl Fn(Point) -> Point) { + for k in &mut s.knots { + *k = Knot { anchor: f(k.anchor), in_ctrl: f(k.in_ctrl), out_ctrl: f(k.out_ctrl), smooth: k.smooth }; + } +} + /// Pieces a path may be split into, in total: bounds the work and memory on hostile input. const MAX_PIECES: usize = 1 << 20; /// Deepest split of one segment (2^6 = 64 pieces). @@ -47,7 +52,9 @@ fn map_subpath(s: &Subpath, f: &impl Fn(Point) -> Point, tol: f64, depth: u32) - let n = s.knots.len(); let segs = s.segments(); if segs.is_empty() { - return map_points(&Path::new(vec![s.clone()]), f).subpaths.pop().unwrap_or_default(); + let mut out = s.clone(); + map_knots(&mut out, f); + return out; } // The mapped pieces of each segment (segment i runs from knot i to knot i + 1). let pieces: Vec> = segs.iter().map(|seg| map_segment(seg, f, tol, depth)).collect(); diff --git a/crates/vector/src/raster.rs b/crates/vector/src/raster.rs index ba2cdd21ff..eb6c4a943b 100644 --- a/crates/vector/src/raster.rs +++ b/crates/vector/src/raster.rs @@ -37,6 +37,10 @@ impl Edge { } } +/// Polygon coordinates are clamped to `±COORD_LIMIT`: far outside any `i32` pixel rectangle, +/// and small enough that edge slopes and positions never overflow to infinity or NaN. +const COORD_LIMIT: f64 = 1e15; + /// A combination step: a component (set of polygons filled with `rule`) and how it folds into /// the result so far. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -66,6 +70,19 @@ impl Rasterizer { /// Adds a component made of closed polygons (open input is closed implicitly). pub fn add_component(&mut self, polys: &[Vec<(f64, f64)>], op: PathOp, rule: FillRule) { + self.push_component(polys, op, rule); + self.sort_edges(); + } + + /// Restores the `y0` order of the edges after [`Self::push_component`]. The sort is stable, + /// so sorting once after many components orders the edges exactly like sorting after each. + pub(crate) fn sort_edges(&mut self) { + self.edges.sort_by(|a, b| a.y0.total_cmp(&b.y0)); + } + + /// [`Self::add_component`] without restoring the edge order: callers adding many components + /// sort once at the end with [`Self::sort_edges`]. + pub(crate) fn push_component(&mut self, polys: &[Vec<(f64, f64)>], op: PathOp, rule: FillRule) { let comp = self.comps.len() as u32; self.comps.push(Component { op, rule }); if op != PathOp::Combine && comp != 0 { @@ -77,11 +94,13 @@ impl Rasterizer { continue; } for i in 0..n { - let a = poly[i]; - let b = poly[(i + 1) % n]; + let (a, b) = (poly[i], poly[(i + 1) % n]); if !(a.0.is_finite() && a.1.is_finite() && b.0.is_finite() && b.1.is_finite()) { continue; } + // Far beyond any pixel rectangle; keeps edge arithmetic from overflowing. + let clamp = |p: (f64, f64)| (p.0.clamp(-COORD_LIMIT, COORD_LIMIT), p.1.clamp(-COORD_LIMIT, COORD_LIMIT)); + let (a, b) = (clamp(a), clamp(b)); self.bounds = Some(match self.bounds { None => (a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)), Some(bb) => (bb.0.min(a.0.min(b.0)), bb.1.min(a.1.min(b.1)), bb.2.max(a.0.max(b.0)), bb.3.max(a.1.max(b.1))), @@ -90,10 +109,15 @@ impl Rasterizer { continue; } let (p, q, dir) = if a.1 < b.1 { (a, b, 1) } else { (b, a, -1) }; - self.edges.push(Edge { x0: p.0, y0: p.1, y1: q.1, dir, comp, dxdy: (q.0 - p.0) / (q.1 - p.1) }); + let dxdy = (q.0 - p.0) / (q.1 - p.1); + // Only a vanishingly short edge has an infinite slope once clamped: like a + // horizontal edge it covers no area, and it would turn coverage into NaN. + if !dxdy.is_finite() { + continue; + } + self.edges.push(Edge { x0: p.0, y0: p.1, y1: q.1, dir, comp, dxdy }); } } - self.edges.sort_by(|a, b| a.y0.total_cmp(&b.y0)); } /// Pixel bounds of everything that may be covered (`None` = nothing; the whole plane @@ -428,6 +452,43 @@ mod tests { assert!(r.render(Rect::new(0, 0, 4, 4)).iter().all(|v| *v == 1.0)); } + #[test] + fn huge_finite_coordinates_keep_edges_finite() { + // The diagonal runs from far left of row 3 to far right of row 4, crossing x = 0 at + // y = 4: row 3 is outside the triangle on the canvas, row 4 inside. Its slope used to + // overflow to infinity, and the NaN it gave at the edge's top dropped that boundary, + // filling row 3 (#5195). + let mut r = Rasterizer::new(false); + r.add_component(&[vec![(-1e308, 3.0), (1e308, 5.0), (-1e308, 5.0)]], PathOp::Combine, FillRule::NonZero); + let c = r.render(Rect::new(0, 0, 10, 8)); + assert!(c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); + for (y, row) in c.chunks(10).enumerate() { + let expect = if y == 4 { 1.0 } else { 0.0 }; + assert!(row.iter().all(|v| (v - expect).abs() < 1e-3), "row {y}: {row:?}"); + } + } + + #[test] + fn sorting_edges_once_matches_sorting_per_component() { + // Components sharing top rows (tied `y0`s) and a subtraction: one stable sort at the end + // gives the exact edge order (and so the exact bits) of a sort after every component. + let comps = [ + (vec![square(1.0, 1.0, 6.0), vec![(2.5, 1.0), (9.0, 4.0), (3.0, 8.5)]], PathOp::Combine, FillRule::NonZero), + (vec![square(3.0, 1.0, 2.5)], PathOp::Subtract, FillRule::EvenOdd), + (vec![vec![(0.5, 1.0), (8.0, 1.0), (4.2, 7.7)]], PathOp::Exclude, FillRule::NonZero), + ]; + let (mut each, mut once) = (Rasterizer::new(false), Rasterizer::new(false)); + for (polys, op, rule) in &comps { + each.add_component(polys, *op, *rule); + once.push_component(polys, *op, *rule); + } + once.sort_edges(); + let rect = Rect::new(0, 0, 10, 10); + let (a, b) = (each.render(rect), once.render(rect)); + assert!(a.iter().zip(&b).all(|(p, q)| p.to_bits() == q.to_bits())); + assert!(sum(&a) > 1.0); + } + #[test] fn pixel_bounds_clip_upper_edge_at_i32_max() { let mut r = Rasterizer::new(false); diff --git a/crates/vector/src/stroke.rs b/crates/vector/src/stroke.rs index dba9473e8f..2e08ab68ba 100644 --- a/crates/vector/src/stroke.rs +++ b/crates/vector/src/stroke.rs @@ -3,6 +3,8 @@ //! together with the non-zero rule yields their exact union without computing offset curves. //! Dashing splits the flattened polylines by arc length first. +use std::borrow::Cow; + use photocraft_doc::{LineCap, LineJoin}; use crate::flatten::Polyline; @@ -86,8 +88,14 @@ fn area_radius(r: f64, step: f64) -> f64 { if step < 1e-9 { r } else { r * (step / step.sin()).sqrt() } } -fn circle(c: P, r: f64, tol: f64) -> Vec

{ - let n = ((std::f64::consts::TAU / arc_step(r, tol)).ceil() as usize).max(8); +/// Points a full circle needs at `tol` (at least 8). +fn circle_points(r: f64, tol: f64) -> usize { + ((std::f64::consts::TAU / arc_step(r, tol)).ceil() as usize).max(8) +} + +/// A circle of at most `arc_cap` points (`usize::MAX` = no limit, at least 4). +fn circle(c: P, r: f64, tol: f64, arc_cap: usize) -> Vec

{ + let n = circle_points(r, tol).min(arc_cap).max(4); let rr = area_radius(r, std::f64::consts::TAU / n as f64); (0..n) .map(|i| { @@ -97,28 +105,78 @@ fn circle(c: P, r: f64, tol: f64) -> Vec

{ .collect() } +/// Dash steps one stroke may spend, shared by all of its lines: it bounds the number of dashes +/// (and so of caps) for file-provided patterns. Past it, the rest is drawn solid. +const DASH_STEP_BUDGET: usize = 100_000; + +/// Outline points a stroke may spend before its round caps and joins get coarser (#5211). +const OUTLINE_POINT_BUDGET: usize = 1 << 20; + /// Stroke outline pieces for `lines`. Fill them with the non-zero rule. +/// +/// The outline is bounded: when full-resolution round caps and joins would exceed +/// [`OUTLINE_POINT_BUDGET`] points, every round cap and join gets the same, smaller number of +/// segments (down to 4 per circle and 1 per join; areas stay exact). Strokes under the budget +/// are unchanged and no piece is dropped, so the total is at most the larger of the budget and +/// that minimal tessellation, which is linear in the (budgeted) flattened points and dashes. pub fn stroke_polygons(lines: &[Polyline], style: &StrokeStyle, tol: f64) -> Vec> { let mut out = Vec::new(); if style.width <= 0.0 || !style.width.is_finite() || !style.miter_limit.is_finite() || style.miter_limit < 1.0 { return out; } + let mut steps_left = DASH_STEP_BUDGET; + let mut pieces: Vec> = Vec::new(); for pl in lines { if pl.pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { continue; } if !style.dashes.is_empty() { - for dash in dash_polyline(pl, &style.dashes, style.dash_offset) { - stroke_one(&dash, style, tol, &mut out); - } + pieces.extend(dash_polyline_budgeted(pl, &style.dashes, style.dash_offset, &mut steps_left).into_iter().map(Cow::Owned)); } else { - stroke_one(pl, style, tol, &mut out); + pieces.push(Cow::Borrowed(pl)); } } + let arc_cap = round_arc_cap(&pieces, style, tol); + for pl in &pieces { + stroke_one(pl, style, tol, arc_cap, &mut out); + } out } -fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, out: &mut Vec>) { +/// Points per full circle allowed for round caps and joins so the outline of `pieces` stays +/// within [`OUTLINE_POINT_BUDGET`]; `usize::MAX` when full resolution fits. +fn round_arc_cap(pieces: &[Cow], style: &StrokeStyle, tol: f64) -> usize { + let (round_cap, round_join) = (style.cap == LineCap::Round, style.join == LineJoin::Round); + if !round_cap && !round_join { + return usize::MAX; + } + // Upper bounds: ribbons and miter/bevel pieces, full circles (caps and dots), round joins. + let (mut base, mut circles, mut joins) = (0usize, 0usize, 0usize); + for pl in pieces { + let m = pl.pts.len(); + if m == 0 { + continue; + } + base = base.saturating_add(m.saturating_mul(6)).saturating_add(8); + if round_cap && !(pl.closed && m > 2) { + circles = circles.saturating_add(if m == 1 { 1 } else { 2 }); + } + if round_join { + joins = joins.saturating_add(m); + } + } + // A join sweeps at most half a circle, plus its centre and two end points. + let cost = |n: usize| base.saturating_add(circles.saturating_mul(n)).saturating_add(joins.saturating_mul(n / 2 + 3)); + let full = circle_points(style.width * 0.5, tol); + if cost(full) <= OUTLINE_POINT_BUDGET { + return usize::MAX; + } + let left = OUTLINE_POINT_BUDGET.saturating_sub(base).saturating_sub(joins.saturating_mul(3)) as f64; + let per_circle = circles as f64 + joins as f64 * 0.5; + ((left / per_circle.max(1.0)) as usize).clamp(4, full) +} + +fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, arc_cap: usize, out: &mut Vec>) { let hw = style.width * 0.5; // Drop repeated points. let mut pts: Vec

= Vec::with_capacity(pl.pts.len()); @@ -145,7 +203,7 @@ fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, out: &mut Vec // A dot: only round and square caps draw something. let c = pts[0]; match style.cap { - LineCap::Round => emit(out, circle(c, hw, tol)), + LineCap::Round => emit(out, circle(c, hw, tol, arc_cap)), LineCap::Square => emit(out, vec![(c.0 - hw, c.1 - hw), (c.0 + hw, c.1 - hw), (c.0 + hw, c.1 + hw), (c.0 - hw, c.1 + hw)]), LineCap::Butt => {} } @@ -213,19 +271,19 @@ fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, out: &mut Vec (jn, true) => jn, (_, false) => LineJoin::Miter, }; - join_piece(v, d1, d2, hw, join, if knot[j] { style.miter_limit.min(500.0) } else { f64::INFINITY }, tol, out); + join_piece(v, d1, d2, hw, join, if knot[j] { style.miter_limit.min(500.0) } else { f64::INFINITY }, tol, arc_cap, out); } } if !closed { let d0 = norm(sub(pts[1], pts[0])); let d1 = norm(sub(pts[n - 1], pts[n - 2])); - cap_piece(pts[0], mul(d0, -1.0), hw, style.cap, tol, out); - cap_piece(pts[n - 1], d1, hw, style.cap, tol, out); + cap_piece(pts[0], mul(d0, -1.0), hw, style.cap, tol, arc_cap, out); + cap_piece(pts[n - 1], d1, hw, style.cap, tol, arc_cap, out); } } #[allow(clippy::too_many_arguments)] -fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol: f64, out: &mut Vec>) { +fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol: f64, arc_cap: usize, out: &mut Vec>) { let c = cross(d1, d2); let d = dot(d1, d2); if c.abs() < 1e-12 && d > 0.0 { @@ -239,15 +297,14 @@ fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol match join { LineJoin::Bevel => emit(out, vec![v, a, b]), LineJoin::Miter => { - // Miter length / width = 1 / sin(θ/2) where θ is the angle between the segments. - let half = ((1.0 - d) * 0.5).max(0.0).sqrt(); // sin of half the turning angle + // Miter length / width = 1 / sin(θ/2) where θ is the angle between the segments, + // i.e. 1 / cos of half the turning angle. let cos_half = ((1.0 + d) * 0.5).max(0.0).sqrt(); if cos_half < 1e-9 || 1.0 / cos_half > miter_limit { emit(out, vec![v, a, b]); } else { let bis = norm(add(n1, n2)); let m = add(v, mul(bis, hw / cos_half)); - let _ = half; emit(out, vec![v, a, m, b]); } } @@ -267,7 +324,12 @@ fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol delta = std::f64::consts::PI * if cross(n1, d1) > 0.0 { 1.0 } else { -1.0 }; } a1 = a0 + delta; - let steps = ((delta.abs() / arc_step(hw, tol)).ceil() as usize).max(1); + let mut steps = (delta.abs() / arc_step(hw, tol)).ceil() as usize; + if arc_cap != usize::MAX { + // Over the outline budget: the join's share of an `arc_cap`-point circle. + steps = steps.min((arc_cap as f64 * delta.abs() / std::f64::consts::TAU).ceil() as usize); + } + let steps = steps.max(1); let rr = area_radius(hw, delta.abs() / steps as f64); let mut poly = vec![v, a]; for k in 1..steps { @@ -280,10 +342,10 @@ fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol } } -fn cap_piece(p: P, dir: P, hw: f64, cap: LineCap, tol: f64, out: &mut Vec>) { +fn cap_piece(p: P, dir: P, hw: f64, cap: LineCap, tol: f64, arc_cap: usize, out: &mut Vec>) { match cap { LineCap::Butt => {} - LineCap::Round => emit(out, circle(p, hw, tol)), + LineCap::Round => emit(out, circle(p, hw, tol, arc_cap)), LineCap::Square => { let n = mul(perp(dir), hw); let e = mul(dir, hw); @@ -294,6 +356,12 @@ fn cap_piece(p: P, dir: P, hw: f64, cap: LineCap, tol: f64, out: &mut Vec /// Splits a polyline into dashes (open polylines). `pattern` alternates on/off lengths in px. pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec { + dash_polyline_budgeted(pl, pattern, offset, &mut DASH_STEP_BUDGET.clone()) +} + +/// [`dash_polyline`] spending at most `steps_left` dash steps (decremented); once they are +/// spent, the rest of the line is drawn solid. +fn dash_polyline_budgeted(pl: &Polyline, pattern: &[f64], offset: f64, steps_left: &mut usize) -> Vec { if pattern.is_empty() { return vec![pl.clone()]; } @@ -313,13 +381,16 @@ pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec = if pl.closed { + let mut pts = pl.pts.clone(); pts.push(pl.pts[0]); - knots.push(true); - } + pts.into() + } else { + pl.pts.as_slice().into() + }; + // Knot flags past the source's (and the repeated closing point) count as knots. + let knot_at = |i: usize| i >= pl.pts.len() || pl.knot.get(i).copied().unwrap_or(true); // Position in the pattern. let mut idx = 0usize; let mut left = pat[0]; @@ -336,23 +407,27 @@ pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec = if idx.is_multiple_of(2) { Some(Polyline { pts: vec![pts[0]], knot: vec![true], closed: false }) } else { None }; - let mut guard = 0usize; - for (wi, w) in pts.windows(2).enumerate() { + 'walk: for (wi, w) in pts.windows(2).enumerate() { let (a, b) = (w[0], w[1]); - let b_knot = knots[wi + 1]; + let b_knot = knot_at(wi + 1); let seg = len(sub(b, a)); let mut pos = 0.0; while seg - pos > 1e-12 { - guard += 1; - if guard > 100_000 { - // Bound allocations and time, including file-provided patterns. Commands - // reject patterns that exceed this budget before document mutation. - return Vec::new(); - } let step = left.min(seg - pos); - if step > 0.0 && pos + step == pos { - return Vec::new(); + if *steps_left == 0 || (step > 0.0 && pos + step == pos) { + // Past the step budget (which bounds allocations and time for file-provided + // patterns; commands reject such patterns before mutating the document) or + // dashes below the coordinates' precision: keep the dashes made so far and draw + // the rest of the line solid rather than dropping the whole stroke (#5195). + let here = if pos > 0.0 { add(a, mul(sub(b, a), pos / seg)) } else { a }; + let rest = cur.get_or_insert_with(|| Polyline { pts: vec![here], knot: vec![true], closed: false }); + for (j, p) in pts.iter().enumerate().skip(wi + 1) { + rest.pts.push(*p); + rest.knot.push(knot_at(j)); + } + break 'walk; } + *steps_left -= 1; pos += step; left -= step; let at_end = seg - pos <= 1e-12; @@ -451,11 +526,57 @@ mod tests { #[test] fn invalid_and_too_dense_dashes_finish_with_bounded_work() { let pl = line(&[(0.0, 0.0), (100.0, 0.0)], false); - for pattern in [vec![0.0, 0.0], vec![f64::INFINITY, 1.0], vec![f64::NAN, 1.0], vec![-1.0, 2.0], vec![1.0; 33], vec![1e-8, 1e-8]] { + for pattern in [vec![0.0, 0.0], vec![f64::INFINITY, 1.0], vec![f64::NAN, 1.0], vec![-1.0, 2.0], vec![1.0; 33]] { assert!(dash_polyline(&pl, &pattern, 0.0).is_empty()); } assert!(dash_polyline(&pl, &[2.0, 1.0], f64::INFINITY).is_empty()); assert!(dash_polyline(&line(&[(f64::NAN, 0.0), (1.0, 0.0)], false), &[2.0, 1.0], 0.0).is_empty()); + // Too dense for the step budget: bounded work, and the line is still drawn. + let d = dash_polyline(&pl, &[1e-8, 1e-8], 0.0); + assert!(!d.is_empty() && d.len() <= 50_001); + assert_eq!(d.last().and_then(|p| p.pts.last()), Some(&(100.0, 0.0))); + } + + #[test] + fn dashes_past_the_step_budget_keep_the_rest_of_the_line_solid() { + // An SVG `stroke-dasharray="0.5 0.5"` along 60000 px needs more steps than the budget: + // the stroke used to vanish entirely (#5195). The dashes made so far stay, the rest of + // the line (through its later vertices) is drawn solid. + let pl = line(&[(0.0, 0.0), (60_000.0, 0.0), (60_000.0, 1000.0)], false); + let d = dash_polyline(&pl, &[0.5, 0.5], 0.0); + assert!(d.len() > 40_000, "{} dashes", d.len()); + assert_eq!(d[0].pts, [(0.0, 0.0), (0.5, 0.0)]); + let tail = d.last().unwrap(); + assert_eq!(tail.pts.last(), Some(&(60_000.0, 1000.0))); + assert!(tail.pts.contains(&(60_000.0, 0.0)) && tail.pts.len() == tail.knot.len()); + // The solid tail starts where the dashes stopped (100000 steps of 0.5 px). + assert!((tail.pts[0].0 - 50_000.0).abs() < 1.0 && tail.pts[0].1 == 0.0, "{:?}", tail.pts[0]); + // Closed outlines finish the same way, back to their first point, where the solid tail + // joins the first dash (one piece with a join at the seam, no caps). + let outline = line(&[(0.0, 0.0), (60_000.0, 0.0), (60_000.0, 1000.0), (0.0, 1000.0)], true); + let d = dash_polyline(&outline, &[0.5, 0.5], 0.0); + let joined = &d[0]; + assert!(joined.pts.contains(&(60_000.0, 1000.0)) && joined.pts.contains(&(0.0, 1000.0)) && joined.pts.contains(&(0.0, 0.0))); + assert_eq!(joined.pts.last(), Some(&(0.5, 0.0))); + } + + #[test] + fn wide_round_capped_dashes_keep_the_outline_within_its_point_budget() { + let points = |polys: &[Vec

]| polys.iter().map(Vec::len).sum::(); + // 5000 dashes, each with two round caps of ~629 points at full resolution (#5211). + let st = StrokeStyle { width: 4000.0, cap: LineCap::Round, join: LineJoin::Round, dashes: vec![1.0, 9.0], ..Default::default() }; + let pl = line(&[(0.0, 0.0), (25_000.0, 0.0), (25_000.0, 25_000.0)], false); + let polys = stroke_polygons(std::slice::from_ref(&pl), &st, 0.01); + assert!(points(&polys) <= OUTLINE_POINT_BUDGET, "{} points", points(&polys)); + // No dash is dropped: a ribbon and two caps each. + assert!(polys.len() >= 3 * 5000, "{} pieces", polys.len()); + // Many tiny undashed subpaths: bounded too, every one still drawn. + let dots: Vec = (0..3000).map(|i| line(&[(f64::from(i), 0.0), (f64::from(i) + 0.5, 0.0)], false)).collect(); + let polys = stroke_polygons(&dots, &StrokeStyle { dashes: Vec::new(), ..st.clone() }, 0.01); + assert!(points(&polys) <= OUTLINE_POINT_BUDGET && polys.len() >= 3 * 3000, "{} points", points(&polys)); + // Under the budget, round caps keep their full resolution. + let one = stroke_polygons(&dots[..1], &StrokeStyle { dashes: Vec::new(), ..st }, 0.01); + assert!(one.iter().any(|p| p.len() == circle_points(2000.0, 0.01)) && circle_points(2000.0, 0.01) > 600); } #[test] diff --git a/crates/vector/src/trace.rs b/crates/vector/src/trace.rs index 5aa5c03722..1a547bfb0b 100644 --- a/crates/vector/src/trace.rs +++ b/crates/vector/src/trace.rs @@ -25,10 +25,8 @@ type P = (f64, f64); pub fn trace_mask(values: &[f32], rect: Rect, outside: f32, tolerance: f64) -> Path { let loops = contours(values, rect, outside); let mut subpaths = Vec::new(); + // `contours` only returns loops of three or more points. for (i, lp) in loops.iter().enumerate() { - if lp.len() < 3 { - continue; - } // Fit to half the tolerance: the worst point may deviate by the full amount only rarely. let mut s = fit_closed(lp, (tolerance * 0.5).max(0.05), 1.5); s.op = if i == 0 { PathOp::Combine } else { PathOp::Exclude }; diff --git a/docs/architecture.md b/docs/architecture.md index 7856636ccb..614cabfb05 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,16 +1,16 @@ # PhotoCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (current-state summary added; the design plan below is kept, with its deviations listed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (workspace size and test counts recounted; current-state summary added; the design plan below is kept, with its deviations listed) · **Target:** Adobe Photoshop 2026 ## Current state (2026-10-10) What exists on `main` today. The numbered sections below are the original design plan of 2026-09-30; where the code differs, this summary wins. -- **Workspace:** 25 library crates under `crates/`, 3 apps (`photocraft` desktop, `photocraft-cli`, - `photocraft-web`), `xtask`, an example plug-in and 5 fuzz crates; ~393,000 lines of Rust in - ~1,020 files; ~5,460 `#[test]` functions (ui-egui 1,677, engine 1,237, algo 408, io 377, - codecs 325, psd 263). Crate map and layers: [`AGENTS.md`](../AGENTS.md) §2 and +- **Workspace:** 25 library crates under `crates/`, 4 apps (`photocraft` desktop, `photocraft-cli`, + `photocraft-web`, `photocraft-web-boot`), `xtask`, an example plug-in and 5 fuzz crates; ~539,000 lines of Rust in + ~1,310 files; ~8,040 `#[test]` functions (ui-egui 2,809, engine 1,796, algo 572, io 539, + codecs 426, psd 341), counted 2026-10-13. Crate map and layers: [`AGENTS.md`](../AGENTS.md) §2 and `xtask/src/layers.rs` (enforced by `cargo xtask layers`). - **UI toolkit:** decided: egui/eframe on wgpu (no webview, no JavaScript). The same Rust builds to WebAssembly for the browser (trunk + wasm-bindgen). @@ -146,7 +146,8 @@ photocraft/ ├─ apps/ │ ├─ photocraft/ desktop binary (winit + wgpu + ui-egui + platform-native) │ ├─ photocraft-cli/ headless batch CLI (open → commands → export), also hosts `mcp` subcommand -│ └─ photocraft-web/ wasm32 binary (ui-egui + platform-web), demo +│ ├─ photocraft-web/ wasm32 binary (ui-egui + platform-web), demo +│ └─ photocraft-web-boot/ wasm32 binary that starts the packaged web app from parts of its wasm ├─ shaders/ (or inside gpu/) WGSL, with generated struct headers ├─ assets/ icons, bundled fonts, default brushes/swatches/presets ├─ fuzz/ cargo-fuzz targets (psd, codecs, format) @@ -588,5 +589,6 @@ Each phase ends with a demoable build and green CI on all targets. Phases 8–10 | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Workspace size and `#[test]` counts recounted (393k → 539k lines, 5,460 → 8,040 tests); other current-state figures unchanged | | 2026-10-10 | minor | Current-state summary and deviations from the plan; status line; broken link to `photoshop-parity.md` repointed to `target-app-parity.md` | | 2026-09-30 | major | Design plan v1 | diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 7a5e499820..05697f9e6a 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -99,7 +99,7 @@ tablets against Photoshop side by side. | Blur, Sharpen, Smudge (premultiplied, Sample All Layers, spacing) | done | BUG-207-2/3, #2171 | | Dodge, Burn, Sponge | done | | | Background Eraser, Magic Eraser | done | | -| Color Replacement | missing as a tool | engine command exists | +| Color Replacement | done | | | Quick Selection (brush-driven) | done | | ## Presets @@ -108,6 +108,7 @@ tablets against Photoshop side by side. |---|---|---| | .abr import (v1/v2/v6) and export, persistent presets, folders | done | `psd/src/abr.rs` | | Brushes panel: drag a folder into a folder | missing | #2014 | +| Dragged brush preset preview | done | Cached, translucent tip thumbnail plus name in panel list/grid and the preset picker; `ui-egui/src/brushes_tab.rs`, real pointer-drag test in `brushes_tab_tests.rs` (#4225, partial) | | Tool presets (.tpl) | partial | Tool Presets panel exists; .tpl import not found | | Import 500 brushes without blocking the UI | not run | P24 | @@ -115,9 +116,11 @@ tablets against Photoshop side by side. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Stale "missing tool" row corrected against main: Color Replacement (`paint.colorReplacement`) is a live toolbar tool; no brush-feel estimate changed | | 2026-10-10 | major | First version, from a source audit and the open issues | | 2026-10-11 | minor | `~` held with the Brush or Pencil erases with that tool's brush, and Clear joins their Mode menus (#3439) | | 2026-10-12 | minor | Five Brush Settings switch rows toggle without selecting a properties pane; Smoothing has a default-on switch and retains its settings while disabled (#4283). No brush-feel estimate changed. | | 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction | | 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Hue Jitter uses the same themed amount slider as other jitter controls (#3825, partial). No brush-feel estimate changed. | +| 2026-10-13 | minor | Dragging a brush preset shows its cached tip thumbnail and name in the Brushes panel and preset picker (#4225, partial). No brush-feel estimate changed. | diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 76d6724b7e..53b6d99bd9 100644 --- a/docs/context-menu-parity.md +++ b/docs/context-menu-parity.md @@ -1,6 +1,6 @@ # Context-menu parity contract -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (layer-mask thumbnail menu, #4575) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (Type tool menu over committed type, #7324) · **Target:** Adobe Photoshop 2026 Status: implementation contract, updated 2026-10-09. Scope: canvas, document tabs, and panels. “Right-click” means a secondary pointer click; macOS Control-click should also open the same menu where the platform sends a context-menu gesture. Photoshop changes a context menu with the active tool, selection, and panel item. Adobe documents that principle but does not publish a complete current menu catalog. **Documented** means an Adobe source explicitly names the gesture or action. **Inferred** means a PhotoCraft design target or an item known from a main menu whose presence in a Photoshop context menu still needs a current-product capture. Do not present inferred rows as exact Photoshop menus. [Adobe: panels and menus](https://helpx.adobe.com/photoshop/using/panels-menus.html), [Adobe: shortcuts and context menus](https://helpx.adobe.com/sa_en/photoshop/using/customizing-keyboard-shortcuts.html). @@ -27,9 +27,9 @@ The “PhotoCraft at branch point” column records behavior before this PR. Pic | Ruler, Note, Count | Unit / measurement, note edit/delete, count-group operations **(inferred)** | No tool menu | P2 | | Brush, Pencil, Eraser, Background Eraser, Spot Healing, Healing, Clone Stamp, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge | Brush preset, size and hardness where applicable | Picker | P0; [Adobe Brush Settings](https://helpx.adobe.com/photoshop/desktop/apply-painting-techniques/brushes-presets/create-brush-set-painting-options.html). Alt+secondary-drag on Windows and Control+Option-drag on macOS adjust size/hardness ([Adobe cursor shortcuts](https://helpx.adobe.com/in/photoshop/desktop/get-started/settings-and-preferences/change-tool-pointers.html)) | | Gradient, Paint Bucket | Gradient preset/type/mode or fill source/tolerance **(inferred context placement)** | No tool menu | P1 | -| Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | While editing text (canvas_tool_menu.rs `TYPE_EDIT_MENU`): Undo, Cut, Copy, Paste, Select All (on the text), Check Spelling…, Find and Replace Text…, Faux Bold/Italic (checked), Rasterize Type, Create Work Path, Convert to Shape, Horizontal/Vertical and the anti-alias modes (checked), Convert to Paragraph/Point Text, Warp Text…; Esc closes only the menu. Over a misspelled word the menu starts with up to 3 spelling suggestions (replace the word) and Add to Dictionary (#4370); Ignore / Ignore All and the non-editing type-layer menu remain open | P1; separate editing and nonediting states; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) | +| Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | While editing text (canvas_tool_menu.rs `TYPE_EDIT_MENU`): Undo, Cut, Copy, Paste, Select All (on the text), Check Spelling…, Find and Replace Text…, Faux Bold/Italic (checked), Rasterize Type, Create Work Path, Convert to Shape, Horizontal/Vertical and the anti-alias modes (checked), Convert to Paragraph/Point Text, Warp Text…; Esc closes only the menu. Over a misspelled word the menu starts with up to 3 spelling suggestions (replace the word) and Add to Dictionary (#4370). Over committed type (no edit running, `TYPE_LAYER_MENU`, #7324): the same rows without Undo, Cut, Copy, Paste, Select All and Faux Bold/Italic; the clicked type becomes the active layer first (a layer multiselection stays). The Type Mask tools get no menu. Ignore / Ignore All remain open | P1; editing and nonediting states are separate; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) | | Hand, Zoom | Fit on Screen, 100%, 200%, Print Size; zoom step choices **(inferred context placement)** | View menu (#3242): Zoom In, Zoom Out, Fit on Screen, Fill Screen, 100%, 200%, Print Size. Zoom In/Out step about the right-clicked point (Preferences › Tools › Zoom Clicked Point to Center centres it). Outside the image the pasteboard menu still wins. | P1; view actions stay out of document history; row order still needs a dated Photoshop capture | -| Pen, Path Selection | Path/point actions, Make Selection, Fill/Stroke Path, Free Transform Path **(inferred context placement)** | Pen canvas right-click follows the user-provided 20-row Photoshop capture below. Paths rows offer Make Selection, Fill and Stroke. Path Selection's own canvas menu remains open. | P1; distinguish node, segment, path, and blank canvas | +| Pen, Path Selection | Path/point actions, Make Selection, Fill/Stroke Path, Free Transform Path **(inferred context placement)** | Pen canvas right-click follows the user-provided 20-row Photoshop capture below. Paths rows offer Make Selection, Fill and Stroke. The Path Selection and Direct Selection tools open the same path menu for the selected path (#1056, 2026-10-15); node- and segment-specific rows remain open. | P1; distinguish node, segment, path, and blank canvas | | Rectangle, Ellipse Shape, Triangle, Polygon, Line, Custom Shape | Shape/path operations, fill/stroke settings, transform **(inferred context placement)** | No tool menu | P2 | | Slice, Slice Select | Slice properties, duplicate/delete, divide, guides **(inferred context placement)** | No tool menu | P2 | | Any tool while Free Transform is active | Scale, Rotate, Skew, Distort, Perspective, Warp, rotate/flip variants, commit/cancel **(inferred exact context placement)** | Missing per docs/scorecard.md TOOL-214-5 | P0; transform operations [documented by Adobe](https://helpx.adobe.com/photoshop/desktop/crop-resize-transform/transform-manipulate-reshape/adjust-scale-rotation-and-perspective.html); type restrictions [documented here](https://helpx.adobe.com/photoshop/using/creating-type.html) | @@ -100,6 +100,7 @@ Each slice needs pure model tests (including every item dispatching a known comm | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Type tool menu over committed type (#7324) | | 2026-10-13 | minor | Layer-mask thumbnail menu (#4575) | | 2026-10-10 | trivial | Status line, revision history | | 2026-10-09 | minor | Selection canvas reference and tool matrix updates | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 771fec92e9..918a9f811b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -53,9 +53,12 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar. - `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`. - `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`. +- `ui.set` also accepts `strokeTrace` (`true`, `false`, `"stderr"` or `"off"`): turn the stroke latency trace on or off at runtime; each stroke's summary is in `ui.inspect` `perf.strokeTrace`. A CSV file of every frame is set only at launch (`PHOTOCRAFT_STROKE_TRACE=` / `--stroke-trace `): the control channel doesn't write files outside the automation write root +- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, mode?}`, any subset. `by` is `kind`, `name` or `mode` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `mode` is a blend mode name (`Multiply`, `Pass Through`). Kind and Name filter only once a kind is on or text is typed; Mode always filters, by Normal at first, as in Photoshop. Fields left out keep their value; a bad one is an error and changes nothing. - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. - `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks. - `ui.set` also accepts the Crop tool's overlay menu (#1919): `cropOverlay` (`thirds`, `grid`, `diagonal`, `triangle`, `goldenRatio`, `goldenSpiral`), `cropOverlayShow` (`auto`: only while the crop box is dragged, Photoshop's default; `always`; `never`) and `cropOverlayOrientation` (0..3; Triangle has two orientations and Golden Spiral four, the others ignore it). `ui.inspect` reports them under `toolOptions` (`crop_overlay`, `crop_overlay_show`, `crop_overlay_orientation`). In the app, O cycles the overlay and ⇧O its orientation while the Crop tool shows a box. +- `ui.set` also accepts `cropFill` (`background`, Photoshop's Background (Default), or `contentAware`), the Crop tool's Fill dropdown (#3823): with `contentAware`, committing a turned or extending frame passes `contentAware: true` to `image.crop`, which fills the area the image doesn't cover from its surroundings. `ui.inspect` reports it as `toolOptions.crop_content_aware`. Both gradient modes use the current preset, including its opacity stops. Select `Foreground to Transparent` through `gradient.presets.select`, or pass custom `stops` and `transparency: [[0,100],[1,0]]` (location 0..1, opacity 0..100). Use `applyToLayer: false` to edit only the tool preset. Classic gradient drags dispatch `paint.gradient` without overriding them. - `ui.set` also accepts the Crop tool's gear menu (#1919) as `cropShield`, an object patching any of `show_cropped_area` (Show Cropped Area; off hides everything outside the box behind the canvas colour, H in the app), `enabled` (Enable Crop Shield), `color` (`matchCanvas`, the pasteboard colour, or `custom`), `custom_color` (`[r, g, b]`, 0..255), `opacity` (0..100, default 75), `auto_adjust` (Auto Adjust Opacity: half the opacity while the box is dragged), `classic_mode` (Use Classic Mode, default off: the box turns and moves over the image; off is Photoshop's default mode, where the box stays upright and the view turns and pans the image behind it while the Crop tool shows a frame, P in the app; in both modes picking the tool turns a rotated view upright about its centre, Classic Mode keeping the camera upright with the frame drawn turned, and the view stays upright after a commit or cancel; Classic Mode greys out Show Cropped Area, which then acts as on, and Auto Center Preview) and `auto_center_preview` (default on: in default mode a box being resized keeps its centre where it is on screen, also after a pan, a drawn one moves to the middle of the canvas, and X re-centres it; a move never re-centres the box). `ui.inspect` reports it as `toolOptions.crop_shield`. The pending frame (`crop_rect`, `crop_angle`: its turn on the image) and the `image.crop` a commit runs are the same in both modes. - `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`, `newCropPreset` / `deleteCropPreset`: the Crop tool's New / Delete Crop Preset… dialogs, fields `name` and `selected: [names]`; crop presets are `crop` tool presets, so `tool.presets.new/list/edit {"tool":"crop"}` drive them too) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog, wait?}` / `ui.dialog.cancel {dialog}`. Like `ui.menu.invoke`, `ui.dialog.confirm` waits for a background job its command starts (a filter dialog's OK) and replies with the result; `"wait": false` replies `{job, pending: true}` at once @@ -70,11 +73,12 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. - Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type. - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows -- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. +- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. - Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape. + - Image › Adjustments › Curves on top, no eyedropper armed (#7325): a `down` with `command: true` adds a point on the curve the dialog shows (its Channel popup), at the pixel's tone (RGB: Rec. 709 luma; Gray: the mean) and at the curve's current output there, so the preview does not change; with `shift: true` as well, one point on each of Red, Green and Blue at that channel's value. It samples the merged image like the eyedroppers, never moves an existing point (a point already within 2 levels is selected instead, a 16-point curve takes no more), and the tool is not driven. Nothing is added with a pencil-drawn curve, in Lab or CMYK, or with a mask, saved channel or Quick Mask targeted. - Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it. - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` (or `secondary`) or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing -- `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}` +- `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`. `{"key": "AltLeft"}` is a tap on Alt, which puts the keyboard on the in-window menu bar (then `ArrowRight`, `ArrowDown`, `Enter`, `Escape` or a menu's letter drive it); `{"key": "F", "alt": true}` opens the menu with that mnemonic (#4988) - `ui.pointer`, `ui.click`, `ui.move`, `ui.key` and `ui.screenshot` validate every parameter before acting, as `ui.set` does: an unknown field (top-level, under `modifiers` or in an event), a value of the wrong JSON type (`"x": "abc"`, `"ctrl": "yes"`), a `button`, `kind`, `tool` or `key` outside the accepted set, or a number outside its range (`pressure`, tilt, `rotation`, `count`) is an error naming the parameter, and nothing is queued or applied. A `null` field counts as omitted and takes its default - Engine commands (`engine.execute`, `photocraft-cli run`, MCP `command_run`) reject a param key their `params` doc does not name for the commands listed in `STRICT` (`crates/engine/src/param_check.rs`, today `layer.hideLayers` and `layer.showLayers`): `{"ids":[4]}` is the error `unknown parameter \`ids\` (expected: layer)`, and nothing runs. Other commands still ignore unknown keys until their `params` doc is complete and they are added to that list (`coalesce` and `target` are always accepted). - `ui.type {text}`: type text (goes to the focused widget, or to the canvas while the Type tool is editing) @@ -84,7 +88,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i base64 PNG data; a path is relative to the automation write root. Raises the window first (default) because occluded macOS windows stop rendering - `ui.focus`: bring the main window to the front -- `app.open {path}` / `app.save {path?, tgaBits?}`: relative file I/O through the configured automation roots (`app.open` reads under the read root, `app.save` writes under the write root; absolute paths, `..` and paths escaping the root are refused, and both fail closed when no root was granted). Both reply with `warnings` (import/export notes such as "adjustment layer flattened"; `[]` when none), also shown to the user in the status bar and as a notice (`notices` in `ui.inspect`); `app.open` also returns the `path` and document `name` (directory `.pcraft` — the native on-disk form — loads through the read root's capability; the single-file (zip) form takes the bytes path), `app.save` the `path` written. `app.save` without `path` writes back only to the document's own PSD, PSB or `.pcraft` file, like File › Save. `tgaBits` (24 or 32) sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none; without it, 32 with an alpha channel or transparency, else 24. Automation saves never show TGA or TIFF options. Automation opens and saves never fire script events. Use these two rather than `file.open`, `file.save`, `file.saveAs` or `file.saveACopy`, which the control channel refuses (see [Engine commands](#engine-commands)) +- `app.open {path}` / `app.save {path?, tgaBits?, ddsCompression?, ddsMipmaps?}`: relative file I/O through the configured automation roots (`app.open` reads under the read root, `app.save` writes under the write root; absolute paths, `..` and paths escaping the root are refused, and both fail closed when no root was granted). Both reply with `warnings` (import/export notes such as "adjustment layer flattened"; `[]` when none), also shown to the user in the status bar and as a notice (`notices` in `ui.inspect`); `app.open` also returns the `path` and document `name` (directory `.pcraft` — the native on-disk form — loads through the read root's capability; the single-file (zip) form takes the bytes path), `app.save` the `path` written. `app.save` without `path` writes back only to the document's own PSD, PSB or `.pcraft` file, like File › Save. `tgaBits` (24 or 32) sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none; without it, 32 with an alpha channel or transparency, else 24. `ddsCompression` (`none`, `bc1`, `bc2` or `bc3`) and `ddsMipmaps` (bool) set a DDS texture's compression and mip chain; without them, uncompressed without mipmaps. Automation saves never show TGA, DDS or TIFF options. Automation opens and saves never fire script events. Use these two rather than `file.open`, `file.save`, `file.saveAs` or `file.saveACopy`, which the control channel refuses (see [Engine commands](#engine-commands)) - `app.quit` ## Engine commands @@ -100,7 +104,7 @@ store expose and persist the same setting; scripts keep using canonical command |---|---| | `file.new` | `{"width":1920,"height":1080,"mode":"rgb","depth":8,"background":"white"}` | | `layer.new.layer` | `{"name":"Ink"}` | -| `layer.select` | `{"layer":id,"mode":"replace\|toggle\|range\|add"}`: ⌘-click = toggle, ⇧-click = range; `document.inspect` reports `selectedLayers` and a per-layer `selected` flag | +| `layer.select` | `{"layer":id\|str,"mode":"replace\|toggle\|range\|add\|remove"}` or `{"name":"Background","occurrence":0?}` (occurrence is zero-based document walk order; required for duplicate names): ⌘-click = toggle, ⇧-click = range; `document.inspect` reports `selectedLayers` and a per-layer `selected` flag | | `channel.target` / `channel.setVisible` | Channels panel target and eyes (`"composite"`, colour name, alpha index or name, `"quickMask"`). `document.inspect` (and `channel.list`) report `channels`: composite/colour/alpha rows with visibility, channel options, `quickMask`, `target`. Pixel commands without a `target` param follow the targeted channel | | `layer.setProps` | `{"layer":id?,"name":…,"visible":…,"opacity":0..1,"blend":"Multiply"}` | | `layer.fill.solidColor.set` | `{"layer":id?,"document":id?,"color":"#rrggbb"}`: edit a Solid Color Fill in one undo step; RGB without alpha preserves the fill's alpha. Float RGB arrays and a stored `Color` are also accepted. `document` rejects stale dialog targets. | @@ -111,6 +115,7 @@ store expose and persist the same setting; scripts keep using canonical command | `paint.stroke` | `{"points":[[x,y,pressure],…],"size":20,"color":"#ff0000"}` | | `select.rect` | `{"x":0,"y":0,"width":100,"height":50,"mode":"add","ellipse":false}` | | `document.inspect` | `{}`: layer tree, history, snapshots (`[{id, name}]`, for `history.selectSnapshot`), selection bounds | +| `layer.list` | `{"document":index?,"flat":bool?}`: just the layers, top-down: `id`, `name`, `kind`, `visible`, `bounds` (`[x,y,w,h]` or null), `selected`, `active`; groups list their `children`, or with `flat` every layer is one row with its `depth` and `parent` group id | | `document.pixel` | `{"x":10,"y":10}`: composite RGBA | | `document.presets.list` | `{}` → `{presets:[{name,settings}]}`: saved New Document configurations | | `document.presets.save` | `{"name":"Product square","settings":{"width":1600,"height":1600,"background":"transparent"}}`: save a snapshot; names are trimmed, 1–255 characters, and ASCII case-insensitive duplicates are rejected | @@ -196,9 +201,10 @@ Each element describes one command, in registry order: | `enabled` | boolean | Whether the command can run in the current state | | `disabledReason` | string or `null` | Why the command can't run now (`null` when `enabled`) | | `needsDocument` | boolean | `true` when it is off because no document is open (#3664) | +| `keywords` | string, optional | Other names the command is found by (#5060), e.g. `select.rect`'s Elliptical Marquee; only present on commands that have them | Filtering narrows the same array without changing its elements: `serve`'s `engine.commands -{filter}` and the MCP tool's `filter` keep commands whose id or label contains the text +{filter}` and the MCP tool's `filter` keep commands whose id, label or keywords contain the text (case-insensitive), and the MCP tool's `enabled_only` keeps those with `enabled: true`. The desktop control channel's `engine.commands` takes no filter and always returns every command. The shape is pinned by a test in `crates/automation/src/rpc.rs` (#3684). @@ -223,7 +229,7 @@ the same commands work in the app, the CLI and headless MCP. Paths are dotted ca | `prefs.set` | `{"path":"cursors.painting","value":"precise"}` or `{"values":{"unitsAndRulers.rulers":"cm","guidesGridAndSlices.gridColor":"#ff8800"}}`. Values are validated (choices, ranges, `#rrggbb` colours, shortcut syntax); a batch applies all or nothing. A section path takes an object and merges it key by key | | `prefs.reset` | `{"path":"performance"}` (a section or key); no path resets everything. An individual `shortcuts.` or `menus.colors.` reset removes the override and returns `null`, including when it is already absent | | `edit.preferences.

` | the section's values; in the app (no params) it opens the Preferences dialog on that section | -| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Alt+Space; Alt added to a held Zoom In, Cmd+Alt+Space, zooms out too), `tools.temporary.erase` (`` ` ``; with the Brush or Pencil, a stroke started while it is held is in Clear mode, `paint.stroke`/`paint.pencil` `"mode": "clear"`, erasing with that tool's brush); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by menu label, a key equal to the default restores it, keys also given in `set` win, tool keys are not imported, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`). In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once | +| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Alt+Space; Alt added to a held Zoom In, Cmd+Alt+Space, zooms out too), `tools.temporary.erase` (`` ` ``; with the Brush or Pencil, a stroke started while it is held is in Clear mode, `paint.stroke`/`paint.pencil` `"mode": "clear"`, erasing with that tool's brush); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by our id (`pc-id`, in sets PhotoCraft exported) or else by menu label, a key equal to the default restores it, keys also given in `set` win, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`, `windows`). A set saved by Photoshop on Windows (no ⌘ or Opt anywhere, Ctrl used) reads Ctrl as the primary modifier, so `Ctrl+L` is the `Cmd+L` default (`windows: true`). Tool keys are matched to tools by `pc-id` or name (a tool's own letter keeps its Shift cycling); `import.tools` lists the keys given. In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once. **Sets** (Edit › Keyboard Shortcuts › Set): `useSet` loads a saved set's bindings (`""` or `"PhotoCraft Defaults"`: the defaults) before the call's other changes; then `saveSet` saves the bindings as they end up under a name and uses it, `deleteSet` removes one (the bindings stay), `sets` replaces every saved set (`{"": {"shortcuts": {…}, "alternateShortcuts": {…}}}`, the dialog's OK) and `activeSet` names the set in use without loading it. Names are trimmed, 1–64 characters, at most 64 sets. Every reply has `sets`, `activeSet` (`""` for the defaults), `defaultsName` and `modified` (the bindings differ from the set in use). `exportKys: true` adds `kys`, the bindings of every bindable command and tool as a `.kys` file (Photoshop's macOS key spelling; each row carries our id as `pc-id`, so the file imports back exactly); `summarize: true` (or `{"mac": false}`) adds `html`, Photoshop's Summarize page. A bad parameter anywhere in the call, an export path that can't be written included, changes nothing. **Headless parity:** the shell's commands (View › Zoom In, the panels; `photocraft_engine::shell_commands::SHELL_COMMANDS`) and the tools (`tools.select.`, listed with `"tool": true`) are bindable, listed, imported, exported and checked for conflicts without the UI; tools may share a key (they cycle with Shift, so a shared letter is no conflict), while a command given a tool's key takes it from the tools. Keys the system takes first (Alt+Tab, Alt+F4, Ctrl+Alt+Delete everywhere but the Mac, plus Ctrl+Esc and Ctrl+Shift+Esc on Windows and Ctrl+Alt+F1–F12 on Linux; ⌘Tab, ⌘`, ⌘⌥Esc on the Mac) are refused, and left out of an imported set (`import.reserved`). Files: `importKys: {"path": …}` reads a `.kys`, `exportKys: {"path": …}` and `summarize: {"path": …}` write the file (the reply has `kysPath` / `htmlPath`); automation (MCP) refuses these paths, as it does `actions.save`'s | | `edit.menus` / `edit.toolbar` | `{"hide":["edit.fade"],"show":[…],"color":{"edit.fill":"red"},"reset":false}` / `{"hidden":["Sponge"],"order":[…]}` | | `edit.colorSettings` | `{"workingRgb":"display-p3","workingCmyk":"coated-cmyk","workingGray":"sgray","policyRgb":"preserve\|convert\|off",…,"askOnMismatch":true,"intent":"perceptual","bpc":true}`; honoured when opening files (`file.openAs`, the app's File › Open) and by Image › Mode and "working" profile specs. `color.profileMismatch {"action":"preserve\|convert\|discard\|assignWorking"}` answers the mismatch prompt | @@ -231,14 +237,21 @@ Values the app honours live: history states (every open document), effect-cache theme, canvas colour and border, checkerboard size and colours (CPU and GPU canvas), gamut warning colour and opacity, guide/grid/smart-guide colours and styles, grid spacing and subdivisions, ruler units (rulers, Info panel, Image Size default unit), image interpolation (Image Size default), -painting and other cursors, zoom with scroll wheel, Use Shift Key for Tool Switch, keyboard -shortcuts, hidden and coloured menu items, autosave interval and crash recovery, and the history +painting and other cursors, zoom with scroll wheel, Use Shift Key for Tool Switch, Bundle Arrow-Key +Nudges and Nudge Bundle Pause (arrow keys sent with `ui.key` that follow each other within the pause +nudge the layers, selection outline or Free Transform box as one history step; an agent calling +`layer.translate` or `select.transformSelection` itself gets the same with a shared `coalesce` key), +keyboard shortcuts, hidden and coloured menu items, autosave interval and crash recovery, and the history 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, and on Linux to `$XDG_STATE_HOME/photocraft/Recovery`, by default +`~/.local/state/photocraft/Recovery`, out of the config folder; 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 @@ -272,8 +285,12 @@ presets and preferences still load. Automatic Recent configurations are separate With View › Snap on, tool gestures snap to the View › Snap To targets (guides and grid while they are shown, layer edges and centres, document bounds and centre, selection edges) within 8 screen pixels: Move tool drags (the moved layers' bounds), marquee, crop, shape, type-box and pen points, -Free Transform handles and drags inside the box, and guides. Holding Ctrl disables snapping for -the drag. With View › Show › Smart Guides on, the Move tool also snaps to other layers and draws +Free Transform handles and drags inside the box, and guides. Distort corners snap to enabled +guides and their intersections, using the actual corner even when its handle is grabbed off-centre. +Guide targets are excluded when Guides, Canvas Guides or Extras is hidden, for all snapping gestures. +Ctrl/Cmd corner distortion in Free Transform does the same with its enabled snap targets. Holding +Ctrl disables snapping for other gestures; Ctrl used for an allowed corner distortion keeps snapping. +With View › Show › Smart Guides on, the Move tool also snaps to other layers and draws magenta alignment lines. `ui.pointer` drives the same code, so agents get identical results. ## MCP bridge @@ -304,7 +321,7 @@ How each MCP tool maps onto control methods in bridge mode: | `doc_export_layers {dir, …}` | `engine.execute {command: "file.export.layersToFiles", params}` | | `doc_export_data_sets {dir, …}` | `engine.execute {command: "file.export.dataSetsAsFiles", params}` | | `doc_import_data_sets {path, …}` | `engine.execute {command: "file.import.variableDataSets", params}` | -| `doc_save {path?, tgaBits?}` / `doc_export {path, tgaBits?}` | `app.save {path?, tgaBits?}` | +| `doc_save {path?, tgaBits?, ddsCompression?, ddsMipmaps?}` / `doc_export {path, tgaBits?, ddsCompression?, ddsMipmaps?}` | `app.save {path?, tgaBits?, ddsCompression?, ddsMipmaps?}` | | `doc_render_preview {max_side?}` | `ui.screenshot`, returned directly as PNG image content | | `session_list`, `ui_inspect` | `ui.inspect` | | `ui_screenshot {max_side?}` | `ui.screenshot`, returned as PNG image content | @@ -361,7 +378,7 @@ no MCP framing, no app start-up per command. Configure its file access with the | `doc.exportLayers` | `{dir, format? (png), prefix?, visibleOnly? (true), quality?, index?}` → `{files, count}`: one file per exported layer, named like `file.export.layersToFiles` names them. `dir` resolves beneath the write root, so the ambient command stays refused. The bytes come from the command's own core, so both paths write the same files; the write capability refuses to overwrite an existing file | | `doc.exportDataSets` | `{dir, format? (png), dataSets? (all), naming? ("{name}\|{index}\|{document}"), index?}` → `{files, count}`: the capability-scoped `file.export.dataSetsAsFiles`. The batch is planned first and colliding names are refused before any file is written, as in the command | | `doc.importDataSets` | `{path, delimiter? (,), index?}` → `{imported}`: the capability-scoped `file.import.variableDataSets`; the CSV is read beneath the read root | -| `doc.save` | `{path?, format?, quality?, index?, tiffLayers?, tgaBits?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF; `tgaBits` 24 or 32 sets a TGA's bits per pixel, as `app.save` does). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty | +| `doc.save` | `{path?, format?, quality?, index?, tiffLayers?, tgaBits?, ddsCompression?, ddsMipmaps?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF; `tgaBits` 24 or 32 sets a TGA's bits per pixel, and `ddsCompression`/`ddsMipmaps` a DDS texture's compression and mip chain, as `app.save` does). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty | | `doc.inspect` | `{index?}`: same JSON as `document.inspect` | | `doc.render` | `{index?, maxSide? (1024; 0 = full), path?}`: PNG to `path`, else `{mime, base64}` | | `doc.select` / `doc.close` | `{index}` / `{index?}` | @@ -545,6 +562,17 @@ visibility reset when the dialog opens. HDR scopes are not implemented. See ### Editing and composing actions +Newly recorded Select Layer steps store the layer name instead of a document-specific ID, +and the Actions panel shows `Select Layer (Background)`. Duplicate names include a zero-based +`occurrence` in document walk order (including layers inside groups); keep that order consistent +between source and target documents. A missing name or occurrence stops playback; a name without +an occurrence is rejected when ambiguous. Names are captured at selection time, so a later +rename in the action does not change the earlier target. Numeric `layer` IDs remain supported +for direct commands and older recordings, which the panel labels `Select Layer (ID 26)`. +String-valued `layer` targets from imported Photoshop actions keep main's topmost-name matching +and show the same named label; `remove` selection mode is supported for either target form. +Re-record old Select Layer steps: their original names cannot be recovered from an ID alone. + Creating an action scrolls its row into view; newly recorded steps are revealed immediately. Drag a step above or below another step in the same action to reorder it. Drag an action header to reorder the action list. The insertion line marks the drop position; dragging near the rows' diff --git a/docs/decisions.md b/docs/decisions.md index 15519ecf74..ac44d14500 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -5,3 +5,30 @@ Smart Object instances carry a document-scoped contents ID separately from their layer ID. Normal duplicates preserve it; New Smart Object via Copy creates a new one. Contents edits update the matching instances together, retaining their individual placement and filters. Copies into another document remap contents IDs as a batch, preserving sharing within the copied layers without joining existing destination objects. Native files persist this ID. Legacy embedded objects without an ID remain independent: identical file bytes do not prove that two objects were intended to share edits. Legacy nonempty linked paths and PSD source identifiers retain their explicit sharing relationship. PSD export keys embedded sources by contents identity as well as bytes, so independent equal-content objects stay independent. + +## 2026-10-17: keeping a replaced file's ACL and named streams (#7073, #7094, #7096) + +Saving replaces a file by renaming a new one over it, which drops everything the old file +carried. Carrying the security-relevant parts across needs platform calls that have no safe API +in `std`, and the workspace forbids `unsafe`, so each platform took a dependency rather than an +exception. + +**macOS ACLs: `exacl`, not `fclonefileat`.** The two routes that avoid `unsafe` are a macOS-only +`exacl` dependency (a safe wrapper over `acl_get_file`/`acl_set_file`) and `fclonefileat` with +the undocumented-in-`rustix` `CLONE_ACL` bit. The clone keeps the ACL, but only on APFS, and it +means writing every save as clone-then-truncate-then-write with a fallback for every other file +system — restructuring the one write path the whole app's durability rests on, to preserve an +ACL. `exacl` is a best-effort step beside the existing permission copy, it works wherever ACLs +do, and a failure cannot affect the bytes. + +**Windows: `ReplaceFileW` via `winsafe`, with a backup name.** Copying the `Zone.Identifier` +stream by hand with `std` would cover the Mark of the Web but not the DACL, and enumerating every +stream needs `FindFirstStreamW`. `ReplaceFileW` is the call Microsoft documents for the +save-through-a-temporary-file pattern and preserves the DACL, named streams, creation time and +object identifier in one step; `winsafe`'s `kernel` feature wraps it safely and pulls in nothing. + +The backup-file argument is a correctness requirement, not an option: `ReplaceFileW` without one +documents `ERROR_UNABLE_TO_MOVE_REPLACEMENT` as leaving the replaced file deleted and the +replacement under its temporary name — a failed save that loses the user's document. With a +backup name the same failure leaves both files alone, and the one failure that does move things +(`ERROR_UNABLE_TO_MOVE_REPLACEMENT_2`) is unwound in `atomic/meta.rs`. diff --git a/docs/development.md b/docs/development.md index a7344a8196..6a99280193 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 it with `--profile ci` (fail-fast, a 10-minute hang timeout), plus `cargo test --doc` for the doctests (#6117); a full `cargo test` stays the final check before a PR. ## Fonts (craft-fonts) @@ -78,7 +78,11 @@ 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`. + +## 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 @@ -95,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. @@ -111,7 +115,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `PHOTOCRAFT_AUTOMATION_READ_ROOT` | Desktop app only: automation read root. Headless CLI modes require the `--automation-read-root` flag | | `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` | Desktop app only: automation write root. Headless CLI modes require the `--automation-write-root` flag | | `PHOTOCRAFT_CPU_CANVAS=1` | Force the CPU canvas path instead of the wgpu shader canvas | -| `PHOTOCRAFT_NATIVE_WAYLAND=1` | Linux: stay on native Wayland when a pen is attached (by default the window then opens through Xwayland, because Wayland gives the app no pen input; #639) | +| `PHOTOCRAFT_NATIVE_WAYLAND=1` | Linux: stay on native Wayland when a pen is attached (by default the window then opens through Xwayland, because Wayland gives the app no pen input; #639). A pen is an input device with `BTN_TOOL_PEN` and the `ABS_X`/`ABS_Y` axes; virtual keyboards that claim every key are not pens (#1072) | | `WGPU_BACKEND=dx12` | Pick the wgpu backend(s) (`vulkan`, `dx12`, `metal`, `gl`); overrides `performance.gpuBackend` and the startup fallback | | `WGPU_DX12_COMPILER=fxc` | DX12 shader compiler (`fxc`, `dxc`, `auto`); `dxc` and `auto` look `dxcompiler.dll` up through the DLL search path | | `PHOTOCRAFT_GPU_TILE=2048` | Force GPU canvas tiling (tests tile seams) | @@ -119,6 +123,10 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `PHOTOCRAFT_CPU_COMPOSE=1` | Keep the wgpu canvas but composite on the CPU (compare GPU vs CPU renders, e.g. with the snapshot example) | | `PHOTOCRAFT_LOCALE` | Override Auto's native UI language for this launch; unsupported tags use English. See [system language detection](localization.md#first-launch-and-system-language). | | `PHOTOCRAFT_FX_TRACE=1` | Print the CPU time spent on GPU effect shapes and distance fields per rebuild | +| `PHOTOCRAFT_STROKE_TRACE=1` (or `=`; 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 @@ -129,6 +137,26 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. Keys are the field names of `theme::Tokens` (`crates/ui-egui/src/theme.rs`). Edit and save the file while the app runs to see changes immediately, with no recompile. This is compiled out of release builds. +### Stroke latency trace + +To find where a stroke's input-to-pixel time goes on a given machine (#1343), launch with `PHOTOCRAFT_STROKE_TRACE=1` (a summary per stroke on standard error) or `PHOTOCRAFT_STROKE_TRACE=strokes.csv` (also every frame, appended to that file). Paint a few strokes with the mouse or pen. Each summary gives p50/p95 in ms: + +```text +stroke trace: 62 frames on vulkan NVIDIA GeForce RTX 5090 (p50/p95 ms): interval 8.33/8.53 moves/frame 1.00/2.00 stroke 0.80/1.53 canvas 0.40/0.75 ui 0.91/1.41 cpu 2.20/2.84 wait 6.04/6.64 +``` + +| Column | What it is | +|---|---| +| `interval` | Time between frame starts while drawing (the display's refresh period when the app keeps up) | +| `moves/frame` | Pointer moves the OS delivered per frame (control-channel `ui.pointer` input bypasses raw input and counts 0) | +| `stroke` | Rendering the stroke's new points (the live stroke; on the last frame, the commit) | +| `canvas` | Canvas refresh: composite and upload | +| `ui` | Building the UI, the two above included | +| `cpu` | eframe's CPU time per frame: UI, tessellation and GPU submission, without the vsync wait | +| `wait` | `interval − cpu`: waiting on vsync or the swap chain, or idle | + +What the app can't time is the rest of input-to-photon: the OS input queue before a frame starts (up to one `interval`), frames queued for the display (the surface keeps up to two; the GL backend ignores that setting), and the compositor. So the first line names the backend and how frames are presented. To compare presentation settings without rebuilding, launch with `PHOTOCRAFT_PRESENT_MODE` and `PHOTOCRAFT_FRAME_LATENCY` (see [Environment variables](#environment-variables)): with `novsync` the app no longer waits on vsync (`wait` near 0): each refresh shows the newest frame, and where the driver only offers Immediate, the screen may tear. The last summary is also in the control channel's `perf.strokeTrace` (`ui.inspect`). The code is `crates/ui-egui/src/stroke_trace.rs`. + ## Driving the app programmatically Start the app with a private token file. It then accepts authenticated JSON lines on `127.0.0.1:7878`: @@ -222,7 +250,9 @@ claude mcp add photocraft -- "$PWD/target/release/photocraft-cli" mcp \ `doc_inspect` (and the engine command `document.inspect`) reports the layer tree with kinds, bounds, masks, selection, effects (`effects.items[].kind`), smart filters (`smartFilters[]`), smart object sources (`smartSource`: `embedded` with its file name, or `linked` with a file path or, for a -PSD placed layer, the `Idnt` uuid its duplicates share), type +PSD placed layer, the `Idnt` uuid its duplicates share), shared contents (`smartContents`: an `id` +every copy of the same contents has, and the number of `instances`; Replace Contents changes them +all and lists them in its result), type text, adjustment settings, channels and history, so agents can verify what they did without a screenshot. `crates/automation/tests/agent_tasks.rs` is the reference: ten realistic edit tasks (title card, colour grade, undo/redo, editable smart blur, masks, saved selections, align, @@ -243,7 +273,7 @@ A typical agent loop (place inputs under the configured read root and outputs un `crates/cms` is our own pure-Rust ICC engine (v2/v4 parsing, matrix/TRC and LUT profiles, all four intents, black point compensation). It ships CC0 built-in profiles, including a synthetic -"Photocraft Coated CMYK", because Adobe's CMYK profiles are proprietary (see `crates/cms/README.md`). +"PhotoCraft Coated CMYK", because Adobe's CMYK profiles are proprietary (see `crates/cms/README.md`). - Documents carry an optional embedded ICC profile (`Document::icc_profile`); `edit.assignProfile` and `edit.convertToProfile` change it. Mode changes (`image.mode.*`) convert through cms. @@ -354,7 +384,7 @@ trunk build --release # writes ../../dist/web (index.html, .js glue trunk serve --release # dev server on http://127.0.0.1:8765 ``` -Any static file server works for `dist/web`, for example `python3 -m http.server 8765` run inside that directory. Trunk downloads the matching `wasm-bindgen` and `wasm-opt` itself. `trunk build --release` uses the `wasm-release` Cargo profile (`data-cargo-profile` in `index.html`: fat LTO, opt-level "z" for the UI, engine, I/O and web shells and for large crates off the hot paths (document model, file parsers, JSON, SVG parsing, shader translation), "s" for other crates except pixel processing, which keeps level 3). The UI override reduces browser code size while retaining the speed-oriented settings for filters and compositing. Serve the `.wasm` with compression; its raw size varies with the source and compiler. Keep it under 24 MiB (`packaging/web/package.sh` enforces this; Cloudflare's per-file cap is 25 MiB). Pull-request CI also builds the actual browser app with the same pinned Trunk version as the release job and runs `packaging/web/package.sh`, including the relative-URL and 24 MiB checks. The `web build and wasm size gate` check runs on main and on PRs that change something the web build compiles or packages (`apps/photocraft-web`, `packaging/web`, crate sources and manifests, `Cargo.toml`/`Cargo.lock`, the CI workflow), and reports the resulting wasm size in the Actions job summary. The web build never embeds craft-fonts (see Fonts above). To see where the bytes go, run `twiggy top -n 40` on `target/wasm32-unknown-unknown/wasm-release/photocraft-web.wasm` (before wasm-opt strips the names). +Any static file server works for `dist/web`, for example `python3 -m http.server 8765` run inside that directory. Trunk downloads the matching `wasm-bindgen` and `wasm-opt` itself. `trunk build --release` uses the `wasm-release` Cargo profile (`data-cargo-profile` in `index.html`: fat LTO, opt-level "z" for the UI, engine, I/O and web shells and for large crates off the hot paths (document model, file parsers, JSON, SVG parsing, shader translation), "s" for other crates except pixel processing, which keeps level 3). The UI override reduces browser code size while retaining the speed-oriented settings for filters and compositing. Serve the `.wasm` with compression; its raw size varies with the source and compiler. Some static hosts refuse a file that large (Cloudflare's per-file cap is 25 MiB), so the release site doesn't hold it as one file: `packaging/web/package.sh` gzips it, cuts it into parts of at most 20 MiB under `app/`, and ships the page of `apps/photocraft-web-boot`, a small module that fetches the parts, has the browser inflate them (or inflates them itself where the browser can't) and passes the stream to the app's own loader (issue #5302). The script fails if any file in the site is over 24 MiB; the wasm's own size is no longer limited, but it is still what every visitor downloads, so watch it. Pull-request CI also builds the actual browser app with the same pinned Trunk version as the release job and runs `packaging/web/package.sh`, including the relative-URL and file-size checks. The `web build and wasm size gate` check runs on main and on PRs that change something the web build compiles or packages (`apps/photocraft-web`, `apps/photocraft-web-boot`, `packaging/web`, crate sources and manifests, `Cargo.toml`/`Cargo.lock`, the CI workflow), and reports the wasm's size and the site's largest file in the Actions job summary. The web build never embeds craft-fonts (see Fonts above). To see where the bytes go, run `twiggy top -n 40` on `target/wasm32-unknown-unknown/wasm-release/photocraft-web.wasm` (before wasm-opt strips the names). URL flags: `?webgl` forces the WebGL2 backend, and `?cpu` forces the CPU canvas path. @@ -370,9 +400,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. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cb2ca13acd..938a723b69 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); earlier: PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,12 +21,12 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 145/258, io 146/170, psd-tools 236/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | | JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | -| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804) | +| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | | BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode | | WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399) | @@ -38,12 +38,12 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Portable Bit Map (PBM/PGM/PPM/PNM/PFM/PAM) | R/W | yes | yes | 8/16/32f | | FITS | R/W (FITS Liberator plug-in) | yes | yes | 8/16/32f gray or RGB, header cards kept; no fpack, no debayer | | Targa | R/W | yes | yes | 8-bit | -| Photoshop PDF | R/W | no (#2541) | raster only (print, artboards to PDF) | No vector/text PDF, no multi-page save, no PDF presentation (FILE-215-10) | +| Photoshop PDF | R/W | no (#2541) | yes, one flattened page (Save As / Save a Copy / CLI, #3401; also print and artboards-to-PDF) | No vector/text PDF, no multi-page save, no PDF presentation, layers are not preserved (FILE-215-10); read still missing (#2541) | | Photoshop EPS, DCS 1.0/2.0, EPS TIFF/PICT preview | R/W | no | no | | | Generic EPS / AI | R (rasterize) | no | no | | | SVG | R (rasterize) | yes, as editable shape layers or a vector smart object | no | Better than Photoshop for editing; gradients approximated (FILE-215-9) | | JPEG 2000 | R/W | no (#1047) | no | | -| Cineon / DPX | R (DPX), R/W (CIN) | DPX: luma, RGB, RGBA at 8/10/12/16 bits, both byte orders (#1049); Cineon no | DPX: 8/10/12/16 bits (`dpxBits`, `--dpx-bits`); Cineon no | Writes DPX, which Photoshop only reads; header fields (creator, project, copyright, transfer, frame rate, time code…) round-trip; no Cineon, no RLE | +| Cineon / DPX | R (DPX), R/W (CIN) | DPX: luma, RGB, RGBA at 8/10/12/16 bits, both byte orders (#1049); Cineon: 10-bit gray/RGB, read-only | DPX: 8/10/12/16 bits (`dpxBits`, `--dpx-bits`); Cineon no | Writes DPX, which Photoshop only reads; header fields (creator, project, copyright, transfer, frame rate, time code…) round-trip; Cineon read-only, no RLE | | DICOM | R/W | no | no | | | PCX | R/W | no | no | | | Pixar | R/W | no | no | | @@ -63,10 +63,10 @@ supports many more, including CR3). | Camera format | PhotoCraft | Evidence | |---|---|---| -| DNG (incl. LJPEG and JPEG XL tiles) | yes; lossy DNG no | FILE-215-11 | +| DNG (incl. LJPEG, lossy JPEG and JPEG XL tiles) | yes | 2026-10-13: lossy DNG (DNG 1.4 compression 34892: 8-bit baseline JPEG tiles or strips, CFA and LinearRaw, partial edge tiles) decodes to the stored samples, which go through LinearizationTable / BlackLevel / WhiteLevel (default 255) like every other depth; `crates/raw/tests/dng.rs` `lossy_jpeg_*` (FILE-215-11, lossy DNG part; no lossy file in the raw corpus yet) | | Canon CR2 | yes | | | Canon CR3 | detected, unsupported | `crates/raw/src/lib.rs:219` | -| Nikon NEF/NRW (lossless, lossy) | yes; "lossy after split" no | #50; D5100 image area measured on three 14-bit lossy NEFs; [validation](nikon-nef-development.md) | +| Nikon NEF/NRW (lossless, lossy) | yes; "lossy after split" no | #50; D5100 and D90 image areas measured (14-bit NEFs); [validation](nikon-nef-development.md) | | Sony ARW/SR2/SRF (incl. compressed) | yes | | | Panasonic RW2, Pentax PEF | yes | | | Olympus ORF | uncompressed only | | @@ -90,7 +90,7 @@ D5100 colour profile. Counts and the G14 estimate are unchanged. | Paint.NET `.pdn` | yes | no | [pdn.md](pdn.md) | | OpenRaster `.ora` | yes | yes | [ora.md](ora.md) | | ICO, QOI | yes | yes | | -| DirectDraw Surface `.dds` | yes | no | Top mip level of a 2D texture: uncompressed RGB, RGBA or luminance, BC1–BC3 (DXT1/3/5), legacy or DX10 header. Cubemaps, arrays, volumes and BC4–BC7 are refused (#1327). Photoshop needs a plug-in | +| DirectDraw Surface `.dds` | yes | yes | Opens the top mip level of a 2D texture: uncompressed RGB, RGBA or luminance, BC1–BC3 (DXT1/3/5), legacy or DX10 header. Saves a 2D texture uncompressed (BGRA, BGRX, luminance with or without alpha) or BC1–BC3 with the legacy header, optionally with its full mip chain (box filter); Save As and Save a Copy ask (DDS Options), scripts pass `ddsCompression` and `ddsMipmaps` (`--dds-compression`, `--dds-mipmaps` on the CLI). Cubemaps, arrays, volumes and BC4–BC7 are refused (#1327). Photoshop needs a plug-in | | Krita `.kra`, GIMP `.xcf`, Procreate | no | no | Not in Photoshop either | ### Affinity import notes (moved from `roadmap.md`) @@ -129,7 +129,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Patterns `.pat` | R/W | R/W | | | Swatches `.aco`, `.ase` | R/W | R/W | FILE-215-14 | | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | -| Keyboard shortcuts `.kys` | R/W | R | `engine/src/kys.rs` | +| Keyboard shortcuts `.kys` | R/W | R/W | `engine/src/kys.rs` (`write`: Edit › Keyboard Shortcuts › Export…, rows carry our id; Photoshop reading our files is unverified) | | ICC profiles | R | R | Own CMS | | Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (112 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | | Styles `.asl` | R/W | no | #1693 | @@ -142,6 +142,27 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | DDS textures save (#1327): uncompressed or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, optional box-filtered mip chain, legacy header; DDS Options in Save As and Save a Copy, `ddsCompression`/`ddsMipmaps` for scripts, the CLI and MCP | +| 2026-10-15 | minor | PNG: a stereo PNG's `sTER` pairing (PNG Extensions 1.4.0 §3.6) is read on open and written back on save, so the file still says it is a stereo pair (#6063) | +| 2026-10-15 | minor | PSD guides: image resource 1092 (guides v2) gives guides their own colours on import; 1092 and 1097 (guide GUIDs) are written back only while they still describe the document's guides, so an edit no longer leaves Photoshop a stale second guide list (#5966) | +| 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | +| 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | +| 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | +| 2026-10-13 | minor | CMYK Hue/Saturation (not Colorize) and Color Balance work on C, M and Y as inverted RGB with black kept, on the inks for display, export and destructive edits; cmyk8/16 hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25% | +| 2026-10-13 | minor | CMYK Posterize works on the stored values (1 − ink), as Photoshop does: psd-tools posterize_cmyk passes (13.8 % → 0 of pixels off on the ink path); psd-tools floor → 242 of 309 | +| 2026-10-13 | minor | CMYK documents whose layers are plain raster layers plus Invert, Posterize, Threshold, Brightness/Contrast, Levels or Curves layers composite on their own inks for display, export and flattening: the cmyk8/16 oracles for those seven adjustments pass; Photoshop-oracle floor 151 → 165 of 258 | +| 2026-10-13 | minor | CMYK Threshold compares the Rec. 601 luma of the inks' plain complement, as Photoshop does (every pixel of cmyk8/16 threshold-128.psd); oracle bad pixels 1.6% → 0.2% | +| 2026-10-13 | minor | CMYK Brightness/Contrast (modern and legacy) curves each ink's stored value (1 − ink), exact on destructive edits; cmyk8/16 oracle bad pixels 71% → 48–57% (the RGB composite's separation still differs) | +| 2026-10-13 | minor | Modern Brightness/Contrast: the brightness curve refit to Photoshop, so the rgb and gray 8/16/32 brightness-contrast oracles pass; Photoshop-oracle floor 145 → 151 of 258 | +| 2026-10-13 | minor | Lab documents of plain layers with Invert, Posterize, Threshold, Brightness/Contrast or Lab Levels/Curves adjustment layers composite on the stored L, a, b instead of clipped display RGB, and flattening keeps them: lab16 invert, posterize-4, levels, curves and legacy Brightness/Contrast and lab8 invert and posterize-4 pass; Photoshop-oracle floor 145 → 152 of 258 | +| 2026-10-13 | minor | Levels round the gamma and output steps to whole levels too (8-bit), and the gamma above 1 starts along a toe of slope 2^gamma that joins the power curve: psd-tools levels_rgb and rgb8 stack-levels-curves-hue-posterize pass, rgb8/gray8 levels.psd exact; Photoshop-oracle floor 145 → 146 of 258, psd-tools 236 → 238 of 309 | +| 2026-10-13 | minor | 32-bit documents: legacy Brightness/Contrast, Channel Mixer, Photo Filter and the Black & White tint keep values above 1 and below 0 as Photoshop does (rgb32/gray32 brightness-contrast-legacy, rgb32 channel-mixer and black-white-tint pass); Photoshop-oracle floor 145 → 149 of 258 | +| 2026-10-13 | minor | Mask feathers blur with exactly the feather's variance (three extended boxes; whole-pixel boxes rounded 6 px to 6.48): psd-tools mask-parameters-no-real-channel passes, layer_mask_data 27 % → 4 % off; psd-tools floor → 240 of 309 (main 239) | +| 2026-10-13 | minor | Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B of whole samples, as Photoshop does (8-bit: rounded to a level, halves down; 16-bit: against the level in 16 bits), not the Rec. 601 grey: psd-tools threshold_rgb and threshold_16bits_rgb pass; psd-tools floor → 241 of 309 (main 239) | +| 2026-10-13 | minor | A pass-through group that is the base of a clipping group and holds an adjustment layer renders isolated, as Photoshop does (its blend modes still reach the backdrop): psd-tools passthrough_clipping_mask_adjustment passes (was 50 % of pixels off); psd-tools floor → 240 of 309 (main 239) | +| 2026-10-13 | minor | 16-bit Posterize bins the 16-bit sample and lands on 8-bit levels through a 16-bit step, as Photoshop does (RGB, Grayscale and the CMYK/Lab channel path): psd-tools posterize_16bits_rgb and posterize_16bits_grayscale pass; psd-tools floor → 241 of 309 (main 239) | +| 2026-10-13 | minor | Gradient Map adjustment layers are driven by the W3C luminosity (0.3, 0.59, 0.11), not the Rec. 601 grey: rgb8 and rgb16 gradient-map.psd pass; Photoshop-oracle floor 145 → 147 of 258 | +| 2026-10-13 | minor | PSD: a "Saving Merged Transparency" global block (`Mtrn`, `Mt16`, `Mt32`; Photoshop's 32-bit documents keep a positive layer count) marks the merged image's first extra channel as its transparency: opening such a file no longer adds a "Transparency" alpha channel, and the oracle compares psd-tools transparentbg.psd's alpha (83.7 % → 0.9 % of pixels off; its shape edges remain) | | 2026-10-13 | minor | Palette PNGs open in Indexed Color mode (#4804) | | 2026-10-12 | minor | `.atn`: 112 events mapped (Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, points, Bitmap) | | 2026-10-12 | minor | DDS textures open, read-only (#1327) | @@ -152,5 +173,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | 2026-10-12 | minor | Presets table: Contours `.shc` read and write through the Contour Editor (FILE-215-15); `.csh` and `.tpl` stay missing | | 2026-10-12 | minor | Palette BMPs open in Indexed Color mode (#3802) | | 2026-10-12 | minor | Save writes a JPEG at its own estimated quality and a WebP lossy or lossless as opened (#2399) | +| 2026-10-11 | minor | PSD smart filters: Filter Gallery (`GEfc`) maps to `filter.filterGallery` when every effect is modelled (Chrome so far), editable from the Layers panel | | 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning | +| 2026-10-11 | minor | PDF write now covers Save As / Save a Copy / CLI (`io::pdf::export`, #3401): one flattened RGB/Gray/CMYK page at the document's print size with the embedded profile. Read (#2541), multi-page save, PDF presentation, vector/text output still open | 2026-10-10 | major | First version: Photoshop 2026 27.11.0's formats from its bundle, against our codecs; Affinity notes moved from `roadmap.md` | diff --git a/docs/gaps.md b/docs/gaps.md index 25a286aab8..4457a87dde 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -43,7 +43,7 @@ what an agent can't finish alone. - **Missing:** PSDs we write are not always accepted by Photoshop, and Photoshop-authored files don't always render as Photoshop renders them. - **Evidence:** corpus floors (`crates/io/tests/corpus.rs`, scorecard): Photoshop-authored - oracles 145/258 (56%; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 146/170 (86%), psd-tools 236/309 (76%); round trips ~100% + oracles 188/258 (73%; smart filter masks 2026-10-14; CMYK and Lab adjustment layers, 32-bit unclipped adjustments, Levels, Threshold, Posterize and mask feathers 2026-10-13; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 148/170 (87%), psd-tools 249/309 (81%); round trips ~100% within the corpus. Smart-filter re-render matches 5/30. User reports: a PSD with text layers re-saved unmodified would not reopen (#2374, closed 2026-10-10), Photoshop 26.6 cannot reopen our files (#1281), "PSD not opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465, fixed 2026-10-12), PSB smart object @@ -54,8 +54,7 @@ what an agent can't finish alone. scripted check there) is the only real oracle. ### G6. Missing tools -- **Missing (4 of 68):** Color Replacement (engine command exists), Art - History Brush, Add Anchor Point, Delete Anchor Point. Convert Point landed (#3204). Freeform +- **Missing (1 of 68):** Art History Brush. Convert Point landed (#3204). Freeform Pen and Curvature Pen landed (#1054); Color Sampler landed (#1046, `view.colorSamplers.*`); Horizontal and Vertical Type Mask landed (#1055, `type.createSelection`); Perspective Crop landed (#1043, `image.perspectiveCrop`); Frame landed (#2914). @@ -70,8 +69,9 @@ what an agent can't finish alone. (TOOL-214-15); Vanishing Point plane editor (TOOL-214-14); Content-Aware Move's live preview of the result (TOOL-213-4; Transform On Drop and Content-Aware Scale through the transform box, TOOL-214-7, are done). - Blur Gallery: Field Blur has its on-canvas pins (2026-10-11, #3373); Iris, Tilt-Shift, Spin and - Path Blur still edit their pins as parameter dialogs. + Blur Gallery: Field, Iris, Tilt-Shift and Spin Blur are edited on the image with Photoshop's pins + and handles (2026-10-11, #3373); Tilt-Shift has one band (the engine takes one), and Path Blur + (#1759) is still a parameter dialog. - **Evidence:** scorecard rows above, all partial or missing. - **Impact:** these are the tools retouchers and compositors use daily; a slider dialog is not a substitute. @@ -129,6 +129,10 @@ what an agent can't finish alone. tuning retains the fitted look and applies user gains afterwards. Three D5100 NEFs establish a guarded 4928 × 3264 image area. This is an estimated rendering, not a measured D5100 colour calibration; [validation](nikon-nef-development.md). +- **Progress (2026-10-13):** the D90's image area (4288 × 2848 at (10, 10) in a + 4352 × 2868 plane) is measured by aligning the decoded plane with the camera's own + JPEG, so a D90 NEF opens on the camera's frame instead of the uncropped sensor plane; + `crates/raw/src/cameras.rs` (`IMAGE_AREAS`). - **Estimate:** 120–200 h. ### G15. AI and generative features @@ -195,8 +199,8 @@ what an agent can't finish alone. with saved bindings, matching toolbar hints and regression coverage (`ui-egui/src/tool_shortcuts.rs`). - **Missing:** 27 of 156 preferences do nothing (scorecard prefs audit); panel groups can't float or tear off into OS windows (UI-217-5, #1828, #1213, #2305); Color panel lacks wheel and - slider modes (UI-217-17, #2569); more themes (UI-217-11, #2661); layer clipboard - (UI-217-23, #2670); tool-specific context menus beyond brushes, selections and Pen + slider modes (UI-217-17, #2569); more themes (UI-217-11, #2661); a single pixel layer + copies without its mask and styles (UI-217-23; fill and adjustment layers copy whole, #2344); tool-specific context menus beyond brushes, selections and Pen ([context-menu-parity.md](context-menu-parity.md)); Libraries, Comments, Contextual Task Bar panels. - **Estimate:** 150–250 h. @@ -218,12 +222,15 @@ what an agent can't finish alone. ## File-format gaps ### G10. Missing and partial file formats -- **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and write and multi-page PDF - (FILE-215-10, #2541), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), - Cineon (DPX landed, #1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop +- **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and its vector/text/multi-page + write (FILE-215-10, #2541; a single flattened page now saves via Save As / Save a Copy / CLI, + 2026-10-11, #3401), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), + Cineon write (read landed), DICOM, EPS/DCS, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, .shc, .csh presets (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). +- **Progress (2026-10-13):** Cineon (`.cin`) 10-bit gray/RGB frames open read-only, sharing the + DPX header/unpacking (`crates/codecs/src/codecs/dpx.rs`); writing is still open. - **Estimate:** 120–220 h. ## Hardware gaps @@ -263,6 +270,25 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | +| 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | +| 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | +| 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | +| 2026-10-13 | minor | G1: CMYK Hue/Saturation and Color Balance work on C, M and Y as inverted RGB with black kept, as Photoshop does; cmyk hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25%; floor unchanged | +| 2026-10-13 | minor | G1: CMYK Posterize works on the stored values (1 − ink); psd-tools posterize_cmyk passes; psd-tools floor → 242 of 309 | +| 2026-10-13 | minor | G1: CMYK stacks with Invert, Posterize, Threshold, Brightness/Contrast, Levels or Curves layers composite on the document's own inks (14 CMYK oracles pass); Photoshop-oracle floor 151 → 165 of 258 | +| 2026-10-13 | minor | CMYK Threshold compares the Rec. 601 luma of the inks' plain complement, as Photoshop does (every pixel of cmyk8/16 threshold-128.psd); oracle bad pixels 1.6% → 0.2% | +| 2026-10-13 | minor | G1: CMYK Brightness/Contrast (modern and legacy) curves each ink's stored value as Photoshop does; oracle bad pixels 71% → 48–57%, floor unchanged | +| 2026-10-13 | minor | G1: modern Brightness/Contrast's brightness curve refit to Photoshop (six oracles pass); Photoshop-oracle floor 145 → 151 of 258 | +| 2026-10-13 | minor | G1: Lab documents composite Invert, Posterize, Threshold, Brightness/Contrast and Lab Levels/Curves on their stored L, a, b (no clipped RGB round trip); seven Lab oracles pass; Photoshop-oracle floor 145 → 152 of 258 | +| 2026-10-13 | minor | G1: Levels round every step to whole levels and use Photoshop's shadow toe (slope 2^gamma); levels_rgb and stack-levels-curves-hue-posterize pass; Photoshop-oracle floor 145 → 146 of 258, psd-tools 236 → 238 of 309 | +| 2026-10-13 | minor | G1: 32-bit legacy Brightness/Contrast (pivot 127), Channel Mixer (1/1024 steps, constant ×255/512), Photo Filter and Black & White tint unclipped as Photoshop computes them, CPU and GPU; Photoshop-oracle floor 145 → 149 of 258 | +| 2026-10-13 | minor | G1: mask feathers blur with exactly the feather's variance; psd-tools mask-parameters-no-real-channel passes, layer_mask_data 27 % → 4 % off; psd-tools floor → 240 of 309 | +| 2026-10-13 | minor | G1: Threshold splits on Photoshop's luminosity of whole samples (8- and 16-bit rules exact on four oracles); psd-tools threshold_rgb and threshold_16bits_rgb pass; psd-tools floor → 241 of 309 | +| 2026-10-13 | minor | G1: a pass-through group that is a clipping base and holds an adjustment layer renders isolated; psd-tools passthrough_clipping_mask_adjustment passes; psd-tools floor → 240 of 309 | +| 2026-10-13 | minor | G1: 16-bit Posterize bins the 16-bit sample, as Photoshop does (exact on two oracles); psd-tools floor → 241 of 309 | +| 2026-10-13 | minor | G1: Gradient Map driven by the W3C luminosity (0.3, 0.59, 0.11), as Photoshop is; rgb8/rgb16 gradient-map pass; Photoshop-oracle floor 145 → 147 of 258 | +| 2026-10-13 | minor | G6: stale "missing tool" list corrected against `main` — Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (P flyout, #4144) are live tools, so the toolbox is 67 of 68 and Art History Brush is the only one missing; no percentage or estimate changed | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | @@ -271,15 +297,19 @@ what an agent can't finish alone. | 2026-10-12 | minor | G1: #2465 (CS2 `PlLd`-only smart objects) marked fixed | | 2026-10-12 | minor | G1: CMYK Invert, Posterize and Threshold work on the inks, 16-bit Lab Invert keeps greys neutral; Photoshop-oracle floor 133 → 145 of 258 (145 already passed on main) | | 2026-10-12 | minor | G5: spring-loaded tool letters done (#2672) | +| 2026-10-13 | minor | G10: Cineon read landed (10-bit gray/RGB, shares the DPX path); Cineon write remains open | | 2026-10-12 | minor | G10: DPX read and write (#1049); Cineon remains open | | 2026-10-12 | minor | G4: checkbox-only Brush Settings rows preserve the open controls and Smoothing settings (#4283); no readiness percentage or estimate changed | | 2026-10-12 | minor | G11: Load/Save Actions `.atn` landed (AUTO-219-2 partial); remaining panel work listed | | 2026-10-12 | minor | Refreshed against main: G5 items fixed by #2480, #2327, #2609, #2684, #2675, #2556, #2396, #2527, #2625 and TOOL-214-5/#2442; G7 TOOL-214-7 and Transform On Drop done; G2 #2377 fixed (#2417); G9 UI scale (UI-217-9) and Stamp Visible (UI-217-22) done, prefs 27 of 156; G10 #2225 done (#2482); G13 Khmer, Lao, Myanmar fallback (#3259); G14 CR3 line; G17 Windows ARM64 done (DIST-220-2) | +| 2026-10-11 | minor | G7: Tilt-Shift's band (lines, turn dots) and Spin Blur's pins (outline handles, blur-angle ring) on the image; Path Blur remains a dialog | +| 2026-10-11 | minor | G7: Iris Blur's pins, outline, roundness and feather handles on the image too; Tilt-Shift, Spin and Path Blur remain dialogs | | 2026-10-11 | minor | G7: Field Blur pins are placed, dragged and tuned on the image (#3373); the other Blur Gallery filters remain dialogs | | 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open | | 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open | | 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted | | 2026-10-11 | minor | G8: Character panel OpenType buttons landed (TYPE-218-5, #3752); Numerals and Stylistic Sets submenus still missing | -| 2026-10-10 | minor | G2: recorded Tilt-Shift computation/filtered-proxy measurements and limits; gap and estimate remain open | +| 2026-10-11 | minor | G10: one-page flattened PDF write shipped (io::pdf, #3401); FILE-215-10 now partial | +| 2026-10-10 | minor | G2: recorded Tilt-Shift computation/filtered-proxy measurements and limits; gap and estimate remain open | 2026-10-10 | minor | Checked against the alpha gate (no alpha blockers); #2374 marked closed | | 2026-10-10 | major | First version, from the full re-measure against Photoshop 2026 27.11.0 and the open issues | diff --git a/docs/images/SOURCES.md b/docs/images/SOURCES.md index 723d1837fc..1d68651a57 100644 --- a/docs/images/SOURCES.md +++ b/docs/images/SOURCES.md @@ -2,15 +2,15 @@ | File | Content | Source / licence | |---|---|---| -| `photocraft-demo.jpg` | Photocraft UI screenshot (rendered offscreen with `cargo run -p photocraft-ui-egui --example snapshot`) editing *The Great Wave off Kanagawa*, Katsushika Hokusai, c. 1831 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Tsunami_by_hokusai_19th_century.jpg). UI: this project. | -| `photocraft-layer-styles.jpg` | Photocraft UI screenshot (offscreen snapshot) with *Earthrise*, William Anders, Apollo 8, 24 December 1968 (NASA photograph) | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg). UI: this project. | -| `photocraft-masks.jpg` | Photocraft UI screenshot (offscreen snapshot) with *Girl with a Pearl Earring*, Johannes Vermeer, c. 1665 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:1665_Girl_with_a_Pearl_Earring.jpg). UI: this project. | -| `photocraft-vector.jpg` | Photocraft UI screenshot (offscreen snapshot) with *Water Lilies*, Claude Monet, 1906 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Claude_Monet_-_Water_Lilies_-_1906,_Ryerson.jpg). UI: this project. | -| `photocraft-filters.jpg` | Photocraft UI screenshot (offscreen snapshot) with *The Starry Night*, Vincent van Gogh, 1889 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg). UI: this project. | -| `photocraft-adjustments.jpg` | Photocraft UI screenshot (offscreen snapshot) with *Impression, Sunrise*, Claude Monet, 1872 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Monet_-_Impression,_Sunrise.jpg). UI: this project. | -| `photocraft-transform.jpg` | Photocraft UI screenshot (offscreen snapshot) with *The Tetons and the Snake River*, Ansel Adams, 1942 (U.S. National Archives) | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg). UI: this project. | -| `photocraft-type.jpg` | Photocraft UI screenshot (offscreen snapshot) with *Among the Sierra Nevada, California*, Albert Bierstadt, 1868 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Albert_Bierstadt_-_Among_the_Sierra_Nevada,_California_-_Google_Art_Project.jpg). UI: this project. | -| `photocraft-export-light.jpg` | Photocraft UI screenshot (offscreen snapshot) with *The Kiss*, Gustav Klimt, 1907–1908 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Gustav_Klimt_016.jpg). UI: this project. | +| `photocraft-demo.jpg` | PhotoCraft UI screenshot (rendered offscreen with `cargo run -p photocraft-ui-egui --example snapshot`) editing *The Great Wave off Kanagawa*, Katsushika Hokusai, c. 1831 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Tsunami_by_hokusai_19th_century.jpg). UI: this project. | +| `photocraft-layer-styles.jpg` | PhotoCraft UI screenshot (offscreen snapshot) with *Earthrise*, William Anders, Apollo 8, 24 December 1968 (NASA photograph) | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg). UI: this project. | +| `photocraft-masks.jpg` | PhotoCraft UI screenshot (offscreen snapshot) with *Girl with a Pearl Earring*, Johannes Vermeer, c. 1665 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:1665_Girl_with_a_Pearl_Earring.jpg). UI: this project. | +| `photocraft-vector.jpg` | PhotoCraft UI screenshot (offscreen snapshot) with *Water Lilies*, Claude Monet, 1906 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Claude_Monet_-_Water_Lilies_-_1906,_Ryerson.jpg). UI: this project. | +| `photocraft-filters.jpg` | PhotoCraft UI screenshot (offscreen snapshot) with *The Starry Night*, Vincent van Gogh, 1889 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg). UI: this project. | +| `photocraft-adjustments.jpg` | PhotoCraft UI screenshot (offscreen snapshot) with *Impression, Sunrise*, Claude Monet, 1872 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Monet_-_Impression,_Sunrise.jpg). UI: this project. | +| `photocraft-transform.jpg` | PhotoCraft UI screenshot (offscreen snapshot) with *The Tetons and the Snake River*, Ansel Adams, 1942 (U.S. National Archives) | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg). UI: this project. | +| `photocraft-type.jpg` | PhotoCraft UI screenshot (offscreen snapshot) with *Among the Sierra Nevada, California*, Albert Bierstadt, 1868 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Albert_Bierstadt_-_Among_the_Sierra_Nevada,_California_-_Google_Art_Project.jpg). UI: this project. | +| `photocraft-export-light.jpg` | PhotoCraft UI screenshot (offscreen snapshot) with *The Kiss*, Gustav Klimt, 1907–1908 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Gustav_Klimt_016.jpg). UI: this project. | Screenshots must use famous public-domain artwork (or content made in the app), never personal photos. diff --git a/docs/localization-es.md b/docs/localization-es.md index cce7de461d..de47b78716 100644 --- a/docs/localization-es.md +++ b/docs/localization-es.md @@ -1,12 +1,12 @@ -# Spanish terminology +# Spanish terminology and dynamic UI labels -This note goes with the review of the Spanish catalog -(`crates/ui-egui/src/i18n/es.tsv`). The catalog already covered every measured key; the review -aligned its wording with the terminology a Spanish-speaking Photoshop user already knows, fixed -mistranslations, and made the register consistent. No proprietary translation resources, fonts, -screenshots or user documents are included: the terms are the ones a Spanish Photoshop user sees -in the product and its public help, and each was checked against the meaning of PhotoCraft's own -English label. +This note goes with the review and completion of the Spanish catalog +(`crates/ui-egui/src/i18n/es.tsv`). It already covered every key measured by +`cargo xtask i18n-coverage`; the terminology review aligned its wording with the vocabulary a +Spanish-speaking image editor already knows, fixed mistranslations, and made the register +consistent. A later pass added the 1,276 runtime-only labels it still lacked. No proprietary +translation resources, fonts, screenshots or user documents are included: every translation was +written from the meaning of PhotoCraft's own English label. ## Register and style @@ -17,6 +17,19 @@ English label. - Quotation marks are «angulares»: «{name}». - Neutral Spanish, matching Adobe's single Spanish catalog (for example «coma flotante»). +## Dynamic UI coverage + +After merging the newer upstream catalog rows, the catalog has 4,010 entries. It covers the +complete Italian runtime key set and retains two Spanish profile-prompt labels that other +catalogs also use. The additional labels include filter and adjustment parameters and choices, +Filter Gallery names, Camera Raw, Lighting Effects, History and Layers controls, metadata, +dialogs, status wrappers and System Info labels. Before this pass, those controls fell back to +English because many are assembled dynamically and cannot be found by the static coverage scan. + +`spanish_covers_generated_filter_options_and_gallery_names` in `filter_dialog.rs` enumerates the +generated filter and adjustment surface and fails if a future label has no Spanish row. A few +unused legacy labels are retained so the complete catalogs stay aligned. + ## Terminology | English source | Spanish UI | Was | @@ -61,7 +74,9 @@ Raw, droplet, OpenType, sRGB, Wayland) are not translated. ## Out of scope -Engine error, status and parameter-help messages are still English in every language (see -[`localization-parity.md`](localization-parity.md)); this review does not change that. The review -was AI-assisted, row by row against Photoshop's Spanish terminology; a review by a native -speaker is still welcome. +Engine-generated error details, status text and parameter help that do not pass through the UI +catalog remain English in every language (see +[`localization-parity.md`](localization-parity.md)). User document, layer and preset names and +automation command IDs also remain unchanged. The completion was contributed by +@gorevyoneticisi with OpenAI Codex as an AI contributor under the repository's MIT OR +Apache-2.0 terms; a review by a native speaker is still welcome. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 9c73eb094b..0b2be85595 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Serbian catalog; previously minor: Italian native review) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (Spanish dynamic labels; previously minor: Serbian catalog) · **Target:** Adobe Photoshop 2026 Per-language status of PhotoCraft's UI. How catalogs work and how to add one: [`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md), @@ -32,7 +32,7 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde |---|---|---|---:|---|---|---|---|---:| | English | `en` | 1,608 / 1,608 (100%) | source | all | Latin | – | full | 0 h | | Simplified Chinese | `zh-hans` | 1,608 / 1,608 (100%) | 2,366 | dialogs, tooltips yes; engine messages English | CJK fonts (lazy loader), IME preedit/commit, vertical type | no | partial | 8–12 h | -| Spanish | `es` | 1,608 / 1,608 (100%) | 2,413 | as above | Latin | no | partial | 6–10 h | +| Spanish | `es` | 1,844 / 1,844 (100%) | 4,010 | as above; complete dynamic UI key set | Latin | no | partial | 6–10 h | | Hindi | `hi` | 0 (0%) | – | – | Devanagari shaping in the text engine (parley/HarfRust); no Devanagari fallback font (TYPE-218-3) | no | none | 20–30 h | | Arabic | `ar` | 0 (0%) | – | – | Arabic shaping and bidi in the text engine, Arabic fallback font; RTL shell layout but not inside panels, and UI labels in LTR word order (DIST-220-4), paragraph direction has no UI (TYPE-218-2), layer names as tofu (#1408, #1693) | no | none | 40–60 h | | French | `fr` | 1,608 / 1,608 (100%) | 2,766 | as above | Latin | no | partial | 6–10 h | @@ -47,7 +47,8 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde 8 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522), -Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (3,928; also every dynamic label the +Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,008; also every dynamic label the +Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,164; also every dynamic label the other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Turkish `tr` (3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every measured key and the union of the other catalogs' dynamic labels; its one/few/many plurals reuse @@ -83,5 +84,6 @@ engine localization or native review is complete. | 2026-10-11 | minor | Czech review follow-ups: English System Info diagnostics, id-aware shortcut labels, display-only auto translation, warning punctuation and wording; catalog row count refreshed | | 2026-10-11 | minor | Italian: 1,287 dynamic labels added (filter parameters, Camera Raw, lighting, panels, dialogs) and terminology made consistent | | 2026-10-12 | minor | Added Turkish (`tr`) catalog with 100% measured UI coverage | +| 2026-10-12 | minor | Spanish: 1,276 runtime-only labels added (filter and adjustment parameters, Camera Raw, panels, dialogs and status wrappers); generated-label coverage test added | | 2026-10-12 | minor | Italian native-speaker review: mistranslations (Trap, HDR Toning, Display Text) and phrasing fixed | | 2026-10-12 | minor | Serbian (`sr`, Latin, Ekavian) catalog: every measured key plus the dynamic labels of the other catalogs | diff --git a/docs/mcp.md b/docs/mcp.md index 3046a669bc..9a8c573ce6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,5 +1,52 @@ # MCP conventions +## Install and register + +From the repository root, with the [source-build prerequisites](development.md#prerequisites) +installed, install the CLI independently of the build directory: + +```sh +cargo install --path apps/photocraft-cli --locked +# POSIX shells, for Cargo's default install location: +export PATH="${CARGO_HOME:-$HOME/.cargo}/bin:$PATH" +photocraft-cli --help +photocraft-cli mcp --help +``` + +Cargo installs a binary, not a symlink into `target/`, so it survives `cargo clean`. +Re-run the install command after updating the checkout. +By default the executable goes in `$CARGO_HOME/bin` (`~/.cargo/bin` when unset), or +`%USERPROFILE%\.cargo\bin` on Windows. If you set `--root`, `CARGO_INSTALL_ROOT`, or +Cargo's `install.root`, use that installation root's `bin` directory instead. Add it +to the `PATH` inherited by the MCP client, not just an interactive shell, and restart +the client after changing its environment. + +Register the installed command explicitly (replace `/work/project` with an existing folder): + +```sh +claude mcp add photocraft -- photocraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project +``` + +Other MCP clients can use this bare-name configuration: + +```json +{"mcpServers":{"photocraft":{"command":"photocraft-cli","args":["mcp","--automation-read-root","/work/project","--automation-write-root","/work/project"]}}} +``` + +Linux `.deb` and `.rpm` packages already install `/usr/bin/photocraft-cli`; those users +can skip the source install. Other release locations are listed in the +[installed-release guide](../book/src/automation/mcp.md#from-an-installed-release). +Installing the CLI does not start or register an MCP server automatically. + +If a client reports `ENOENT` or "Executable not found in $PATH", verify the install with +`command -v photocraft-cli` (PowerShell: `Get-Command photocraft-cli`) in the client's +environment. If that environment cannot be changed, set JSON `command` to the absolute +installed binary path, not a literal `~` or `$HOME` expression. Avoid symlinks into +`target/release`: cleaning the build directory breaks them. A source build alone does +not put the CLI on `PATH`. + +## Start a session + PhotoCraft follows the same conventions as FilmCraft #28. Start a headless session: ```sh 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. 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/psd-interleave-performance.md b/docs/psd-interleave-performance.md new file mode 100644 index 0000000000..5958442881 --- /dev/null +++ b/docs/psd-interleave-performance.md @@ -0,0 +1,46 @@ +# PSD pixel buffer allocation + +Write PSD interleaved pixels directly into the final buffer. Direct RLE output is already on main. The unrelated export-side sample-encoder inlining hint is removed locally. + +Whole-photo imports improve by about 4–7%, and the authored designs by about 20–26%. An added export-side inlining hint accompanied a separately confirmed 2.3% float PSD export slowdown (2440.168 → 2497.423 ms). Removing that one line leaves export source equal to main; follow-up float export is +0.4% and 16-bit export +0.9%, with both paired intervals crossing zero. These results supersede the much larger previous interleave-only percentages. + +Measured on 2026-10-19 against main `d95fdf3181c01ad41e95456091a394b105a9afb2`, independently of the other splits. Apple M4 Max, 16 CPUs, 128 GiB, macOS arm64, rustc 1.99.1; release O3, LTO off, 16 codegen units, debug off. Builds used nice 10 and warm APFS-cloned caches; measurements used default priority without concurrent builds or profiling. Five alternating paired samples follow a warmup; confirmation and fix runs use nine pairs. Time change compares the two medians; intervals resample paired ratios. Negative time change means faster. Small differences are not automatically established improvements or regressions. + +The full-resolution CC0 Pixls photographs are P1000475.RW2 (24 MP foliage centre crop, no resampling) and IMG_4059.CR2 (12 MP city), each converted to U8/U16/F32 with the ProPhoto RGB profile. F32 derives from 16-bit development, not HDR capture. MIT psd-tools masks.psd supplies a 640 x 1394 web layout with 22 imported layers and three masks; mask-index.psd supplies a 1000 x 867 illustrated card with a mask. The sources and hashes are pinned by the existing corpus. These are the available authored layered files, not a large layered-photo corpus. No generated noise, repeating ramps, enlarged charts or duplicated photo layers are used. + +The shared harness times in-memory operations. Input loading, image conversion, thumbnail rendering, hashes and equivalence checks are outside timers. Every save uses a fresh PcraftWriter and a real rendered thumbnail. Common reads use main-encoded files; own-output reads are separate. PSD exports use ZIP merged images at U8/U16, Raw merged images at F32, and normal RLE layer channels at U8/U16 / ZIP prediction at F32. Results do not measure filesystem throughput or browser performance. + +| Document | Operation | Main ms | Branch ms | Time change | Paired 95% interval | Main / branch bytes | +|---|---|---:|---:|---:|---:|---:| +| panasonic-32 | psd-export (fixed) | 2471.456 | 2481.877 | +0.4% | -0.1..+1.7% | 461,042,978 / 461,042,978 | +| panasonic-16 | psd-export (fixed) | 2649.246 | 2673.071 | +0.9% | -5.2..+1.6% | 274,409,683 / 274,409,683 | +| panasonic-8 | psd-import | 275.828 | 262.237 | -4.9% | -5.9..-4.1% | 98,823,859 / 98,823,859 | +| panasonic-8 | psd-export | 2937.854 | 2915.929 | -0.7% | -11.5..+5.6% | 98,823,859 / 98,823,859 | +| panasonic-16 | psd-import | 365.901 | 349.827 | -4.4% | -4.4..-2.6% | 274,409,683 / 274,409,683 | +| panasonic-32 | psd-import | 360.706 | 334.838 | -7.2% | -12.6..-4.4% | 461,042,978 / 461,042,978 | +| canon-8 | psd-import | 141.331 | 135.878 | -3.9% | -5.1..-3.6% | 56,209,597 / 56,209,597 | +| canon-8 | psd-export | 1052.421 | 1054.472 | +0.2% | -0.6..+1.3% | 56,209,597 / 56,209,597 | +| canon-16 | psd-import | 177.327 | 169.952 | -4.2% | -5.5..-1.7% | 140,673,004 / 140,673,004 | +| canon-16 | psd-export | 1248.449 | 1239.159 | -0.7% | -1.6..+0.6% | 140,673,004 / 140,673,004 | +| canon-32 | psd-import | 175.721 | 163.271 | -7.1% | -9.3..-4.9% | 228,234,238 / 228,234,238 | +| canon-32 | psd-export | 1008.772 | 1005.356 | -0.3% | -1.8..+0.7% | 228,234,238 / 228,234,238 | +| layout-masks | psd-import | 12.509 | 9.981 | -20.2% | -20.8..-14.5% | 493,006 / 493,006 | +| layout-masks | psd-export | 55.113 | 54.714 | -0.7% | -2.3..+1.2% | 493,006 / 493,006 | +| layout-mask-index | psd-import | 3.299 | 2.443 | -25.9% | -30.8..-23.4% | 411,435 / 411,435 | +| layout-mask-index | psd-export | 18.200 | 18.397 | +1.1% | -0.1..+1.7% | 411,435 / 411,435 | + +Real PSD exports remain byte-identical and imported layer/mask float samples match main. The committed literal previous-interleave oracle covers depths, bands, fills, truncation and inversion. + +Current local checks: affected `pixels::` unit tests (five passed, one ignored manual benchmark); strict all-target IO Clippy; affected IO wasm32 check. All passed. Follow-up exports at float and 16-bit depth remain byte-identical to main; no synthetic timing test was run. + +Local raw evidence and the shared harness: `.local/perf-real-261019/` in the repository checkout (ignored). This comparison supersedes the earlier synthetic performance table. Small synthetic unit fixtures remain correctness checks; ignored synthetic timing tests were not run. + +## Earlier split validation + +These checks passed when the split was prepared; they were not all rerun for this benchmark review. Current affected checks are listed above where production code changed. + +- `cargo test -p photocraft-io --locked`: passed (617 passed, 6 ignored). +- `cargo clippy -p photocraft-io --locked --all-targets -- -D warnings`: passed. +- `cargo xtask layers`: passed. +- `cargo check -p photocraft-io --locked --target wasm32-unknown-unknown`: passed. +- `cargo xtask test-corpus -p io`: passed (646 passed, 6 ignored). diff --git a/docs/releasing.md b/docs/releasing.md index 6d35b6379e..def1911aa8 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -284,11 +284,14 @@ and lists the tarball, which checks the layout without a FreeBSD machine. ### Web -`packaging/web/package.sh` runs `trunk build --release` (see `apps/photocraft-web/Trunk.toml`) -and zips `dist/web` together with sample `_headers` and `.htaccess` files and the hosting guide. -The site only uses relative URLs, so it works under any path and in an iframe. The wasm builds -with the size-optimized `wasm-release` Cargo profile (set in `apps/photocraft-web/index.html`), -and the script fails if any `.wasm` exceeds 24 MiB, below Cloudflare's 25 MiB per-file limit. +`packaging/web/package.sh` runs `trunk build --release` for the app (`apps/photocraft-web`, into +`dist/web`) and for the module that starts it (`apps/photocraft-web-boot`, into `dist/web-boot`). +It gzips the app's wasm, cuts it into parts of at most 20 MiB and zips the boot module's page with +the parts, sample `_headers` and `.htaccess` files and the hosting guide. The site only uses +relative URLs, so it works under any path and in an iframe. The wasm builds with the +size-optimized `wasm-release` Cargo profile (set in `apps/photocraft-web/index.html`), and the +script fails if any file in the site exceeds 24 MiB, below Cloudflare's 25 MiB per-file limit; +the app's wasm itself may be larger, since it ships as parts. [`packaging/web/README.md`](../packaging/web/README.md) covers MIME types, compression, caching, the iframe snippet and the `?webgl` / `?cpu` flags. diff --git a/docs/scorecard.md b/docs/scorecard.md index 92f9315ebd..c4b53a20f8 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -6,13 +6,13 @@ 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) | -| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 145/256, round trip ≥ 258/256 | -| [Tools](#tools) | 28 | 14 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 157 (target 0) | +| [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: 25 of 169 (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 28 of 28 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 | | @@ -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): @@ -92,9 +94,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | Corpus | Directory | Files | Oracle pass floor | Round-trip floor | |---|---|---:|---:|---:| -| io corpus | `corpus/psd` | 170 | 146 | 169 | -| psd-tools corpus | `corpus/psd-tools` | 309 | 236 | 308 | -| photoshop oracles | `corpus/photoshop` | 256 | 145 | 258 | +| io corpus | `corpus/psd` | 170 | 148 | 169 | +| psd-tools corpus | `corpus/psd-tools` | 309 | 249 | 308 | +| photoshop oracles | `corpus/photoshop` | 256 | 188 | 258 | | Id | Target | Status | Issue | Note | |---|---|---|---|---| @@ -107,7 +109,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | partial | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels and a Quick Mask are not written (the TIFF codec holds one transparency sample) | | FILE-215-8 | Large and BigTIFF import with banded decode across all IFDs | done | [#215](https://github.com/storytold/photocraft/issues/215) | codecs tiff_ifd.rs (bounds-checked IFD chain + SubIFD walk, classic and BigTIFF, cycle-safe; tiff_info / decode_tiff_page), tiff.rs banded parallel strip/tile decode, planar 1 and 2, BigTIFF write (tiff_bigtiff or > 4 GiB); io import_tiff_page; 30 MP RGB8 Deflate 345 -> 32 ms; no page picker in the UI yet | | FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | done | [#215](https://github.com/storytold/photocraft/issues/215) | io svg.rs: paths, groups, text outlines as shape layers (gradients approximated; clip/mask/filter/pattern/image subtrees rasterised); smart_cmds.rs renders an SVG source at its placement scale | -| FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only print_cmds.rs:133 print_pdf (raster print page) | +| FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | partial | [#215](https://github.com/storytold/photocraft/issues/215) | io pdf.rs: Save As / Save a Copy / CLI write one flattened RGB/Gray/CMYK page with ICC (#3401); print PDFs in print_cmds. Multi-page save, presentation, comps, vector/text output and PDF import (#2541) remain | | FILE-215-11 | RAW: lossy DNG, highlight modes, .xmp sidecars, RAW prefs wired, per-camera corpus | missing | [#215](https://github.com/storytold/photocraft/issues/215) | raw sensor.rs:210 lossy DNG unsupported; raw_defaults unread; no xmp | | FILE-215-12 | Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Interactive RGB/Lab scopes. PSD WB/Light/Presence, curves, HSL, grading, sharpening/noise detail, grain and numeric vignette controls mapped with descriptor preservation. Active manual Optics and unverified vignette style keep the updated ACR 18.4 fixture opaque; PSD/.pcraft saves preserve it. HDR, remaining settings and Photoshop acceptance still open | | FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping | @@ -140,23 +142,23 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-213-8 | Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); a shaped tip (sampled bitmap, rotation, roundness) clips the overlay to its outline (brush_cursor::tip_mask, #4372); W/H link (cloneSource.set linked, panel chain button, #3298) | | TOOL-213-9 | Eyedropper comparison ring (new colour above, current below) while sampling | done | [#213](https://github.com/storytold/photocraft/issues/213) | eyedropper_ring (canvas.rs): the sampled colour on the upper arc, the foreground below, drawn for the Eyedropper and Alt-sampling while the button is held; the Precise-cursor preference keeps the crosshair | | TOOL-213-10 | Ctrl+Alt drag resizes brush size/hardness with an on-canvas readout | done | [#213](https://github.com/storytold/photocraft/issues/213) | brush_resize.rs (#297, #370): Alt+right-drag, or Control+Option-drag on macOS (Control+Alt everywhere), sets diameter horizontally and hardness vertically, circle plus Diameter/Hardness readout, one coalesced tools.setBrush, no paint or history; 9 tests there | -| TOOL-213-11 | Editable pressure curve, global and per control | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Global: Preferences › Tools › Pressure Curve (prefs tools.pressureCurve; monotone cubic, engine prefs::PressureCurve; applied to every pen sample in ui-egui stylus.rs before any brush; editor pressure_curve_ui.rs with Linear/Soft/Hard presets). Missing: per-control curves (each brush dynamic's own response to pressure, tilt, rotation) | -| TOOL-213-12 | Import an image file as a brush tip | missing | [#213](https://github.com/storytold/photocraft/issues/213) | Only brush.defineFromSelection and brush.presets.importAbr | +| TOOL-213-11 | Editable pressure curve, global and per control | done | [#213](https://github.com/storytold/photocraft/issues/213) | Global: Preferences › Tools › Pressure Curve (prefs tools.pressureCurve; applied to every pen sample in ui-egui stylus.rs before any brush; editor pressure_curve_ui.rs with Linear/Soft/Hard presets). Per control: every brush Dynamic has its own curve (paint ResponseCurve, the same monotone cubic; Dynamic.curve, set by tools.setBrush, e.g. shapeDynamics.size.curve) applied to its Fade/Pressure/Tilt/Wheel/Rotation input in dynamics.rs dynamic_value; Brush Settings shows it behind each Control row's curve button (#3294) | +| TOOL-213-12 | Import an image file as a brush tip | done | [#213](https://github.com/storytold/photocraft/issues/213) | brush.presets.importImage (path or data); Brushes panel and preset picker Import Brushes… take .abr or an image (#3279) | | TOOL-213-13 | Brush regression tests: no seams, hard edge, zero-scatter defaults, ABR never black | done | [#213](https://github.com/storytold/photocraft/issues/213) | Seam, hard-edge and zero-scatter tests in brush_tests.rs; ABR tip colour test in abr_map/tests.rs | | TOOL-213-14 | Tablet: no OS arrow cursor over the canvas on macOS, Windows and Linux | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Brush tools set CursorIcon::None (canvas.rs:1259-1275); not verified per OS | | TOOL-213-18 | Pattern Stamp tool: sibling of Clone Stamp in the S flyout | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::PatternStamp in the S group; paint.patternStamp stamps the Patterns-panel tile with Aligned and Impressionist (an approximation: it jitters each dab's tile phase, not Photoshop's smeared impressionist look); engine tests cover depths, seams, hostile input | | TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); no workspace layout | | TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas.rs marquee_corners: Shift square or circle, Alt from the centre, live W x H readout beside the cursor (marquee_readout); marquee_tests.rs | -| TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Fuzziness-0 tests cover midtones/shadows only (selection_cmds.rs:444-447) | +| TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | done | [#214](https://github.com/storytold/photocraft/issues/214) | color_range_fuzziness_zero_selects_exact_match_only in selection_cmds.rs asserts exact match is selected and 1-level-off pixels are rejected at fuzziness 0; tonal presets covered in color_range_presets | | TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs, layer.selectInRect, #2844) done; double-click fit and drag to a tab open | | TOOL-214-5 | Right-click menu during Free Transform (scale, rotate, skew, warp, flip, rotate 90/180) | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas_tool_menu.rs TRANSFORM_MENU: modes, Content-Aware Scale / Puppet Warp greyed, Rotate 180 / 90 CW / 90 CCW and Flip turn the open box (transform_tool::intercept, #2832) | | TOOL-214-6 | Puppet Warp mesh follows the object outline (constrained triangulation) | done | [#214](https://github.com/storytold/photocraft/issues/214) | algo/puppet.rs build_mesh: traced outline of the opaque region grown by Expansion, triangulated with a Density-spaced lattice (conforming Delaunay); separate shapes get separate meshes (#3105) | | TOOL-214-7 | Content-Aware Scale through the Free Transform box with a live proxy preview | done | [#214](https://github.com/storytold/photocraft/issues/214) | Edit > Content-Aware Scale opens a scale-only Free Transform box (cas_transform.rs) with Amount, Protect and Protect Skin Tones in the options bar; edit.contentAwareScale carves a 384 px copy in the background for the live preview, and Enter runs it with the box's size and x/y as one step (#3096) | | TOOL-214-8 | A measured ruler angle prefills Image Rotation > Arbitrary (0.01 deg) | done | [#214](https://github.com/storytold/photocraft/issues/214) | analysis_ui.rs: image.rotation.arbitrary opens seeded with the ruler angle and the direction that straightens it; arbitrary_rotation_starts_at_the_ruler_angle checks 5.7106 deg within 1e-3 and cw/ccw for three measured lines | | TOOL-214-9 | Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ratio dropdown and fields, Straighten (TOOL-214-21); W x H x Resolution with units, built-in size presets and Front Image (crop_size.rs, image.crop targetWidth/targetHeight/resolution, #2443); New / Delete Crop Preset save and delete crop tool presets (crop_presets.rs, tool.presets.* with tool crop, persisted with the preferences) | -| TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); Backspace/Delete retracts the last point while drawing (#1466); Alt during a drag breaks the handle pair, Alt-drag turns the last anchor's outgoing handle alone, and dragging on the first anchor closes with that anchor's handles (vector_ui.rs, #3306); no Shift 45 deg | +| TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); Backspace/Delete retracts the last point while drawing (#1466); Alt during a drag breaks the handle pair, Alt-drag turns the last anchor's outgoing handle alone, and dragging on the first anchor closes with that anchor's handles (vector_ui.rs, #3306); Shift-click puts the next anchor on the nearest 45 deg ray from the previous one and Shift-drag turns the handles to 45 deg steps, live (vector_ui.rs pen_shift_click_puts_the_next_anchor_on_a_45_degree_ray, pen_shift_drag_turns_handles_to_45_degrees_live, #3751) | | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | done | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978) | -| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.convertToShape with tolerance (vector_cmds.rs:1256); radius as a field only | +| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather | | TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | | TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | @@ -172,13 +174,14 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-23 | Crop: Photoshop's default mode (the image turns and moves behind an upright box, Auto Center Preview keeps a resized box's centre in place and centres a drawn one) and Use Classic Mode (P) in the gear menu | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_mode.rs: the crop model (crop_rect + crop_angle) and image.crop are shared by both modes; default mode sets the view's rotation to -crop_angle while the tool shows a frame (picking the tool, in either mode, turns a Rotate View turn upright about the view centre, not restored after Enter/Esc or by P; Classic Mode keeps the camera upright with the frame drawn turned; as Photoshop 25.1), pivoting so the box stays put, moves view and frame together for drags inside and arrow keys, keeps a resized box's centre in place live (Auto Center Preview; a drawn box is centred on release, a moved one never), readout = the image's turn; gear menu order and Classic Mode greying as Photoshop 25.1; CropShield classic_mode / auto_center_preview (ui.set cropShield), P ahead of the Pen key | | TOOL-1053-1 | Shape stroke controls: solid/dashed/dotted, custom dash/gap/offset, caps, joins, alignment, miter limit and opacity | done | [#1053](https://github.com/storytold/photocraft/issues/1053) | shape_stroke_ui.rs: shared staged editor, renderer samples, cached Properties canvas preview; existing shape.create/edit/info; serde tool defaults, ui.set shapeStroke; pcraft and vstk style round trips. Gradient fill UI and saved stroke libraries remain open; Line retains filled-bar geometry | | TOOL-213-19 | Remove tool: paint over an object (or circle it) and it is filled from its surroundings | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Options bar: Sample All Layers, Create a new layer (the fill on a new layer above, `newLayer`), Remove after each stroke off (strokes collect as a tint and ↩ applies them as one `strokes` job, Esc drops them). Missing: generative AI mode, live preview of the result | +| TOOL-2259-1 | Arrow-key nudges of one thing (layers, the selection outline, the Free Transform box) that follow each other are one History state, with a preference to turn it off | done | [#2259](https://github.com/storytold/photocraft/issues/2259) | nudge_bundle.rs: a run shares one `coalesce` key while each nudge comes within Tools > Nudge Bundle Pause (1 s) and nothing else touched the document (an edit, Undo/Redo, a layer selection, a save end it); move_mods.rs runs layer.translate and select.transformSelection through it, transform_tool.rs folds box nudges into one session step. Tools > Bundle Arrow-Key Nudges off gives every press its own state. Selected pixels float until dropped, which is one state already; ⌥-nudges (a copy each) never join a run | | TOOL-1045-1 | Color Replacement tool on the toolbar | done | [#1045](https://github.com/storytold/photocraft/issues/1045) | Tool::ColorReplacement in the Brush (B) flyout after the Mixer Brush; Mode/Sampling/Limits/Tolerance/Anti-alias options drive paint.colorReplacement with the foreground colour; live preview (LiveDab with the stroke's finishing dab), one History step; color_replacement_tests.rs | ## Workspace and UI -**Settings that do nothing: 26 of 157** (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: 25 of 169** (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`. +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`, `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`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| @@ -189,9 +192,9 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-5 | Panel groups can float (menu + drag out of the dock) and are restored at launch | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No floating groups in dock.rs/panels.rs | | UI-217-6 | Document tabs: overflow list, context menu (Close/Others/All/Reveal), drag to reorder | done | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs: Close/Others/All menu with unsaved prompt; Reveal reuses file.revealInFinder (engine layer_menu_cmds::reveal); the » chevron (tab_strip) lists overflow; dragging a tab reorders via document.move | | UI-217-7 | Command palette shows recently run commands on an empty query | done | [#217](https://github.com/storytold/photocraft/issues/217) | palette.rs: note_recent from menus::invoke keeps the last 8 commands (dedup, newest first); an empty query lists them via recent_hits; the_palette_records_and_replays_recent_commands_on_an_empty_query covers record -> empty query -> replay | -| UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet | +| UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); over committed type the Type tool opens the type layer menu (#7324); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet | | UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | done | [#217](https://github.com/storytold/photocraft/issues/217) | prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale, fractional and unscaled 4K included, #1072/#1278) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting | -| UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); no canvas pattern toggle yet (#2264) and no option to hide the Home screen recents | +| UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); the canvas dot pattern toggle is interface.showCanvasDots (#2264, global rather than per theme); interface.showHomeRecentFiles hides the Home screen recents (#4382) | | UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | partial | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has fourteen themes: the twelve earlier ones plus Soft Dark and Soft Light (low-contrast Studio-layout palettes, text at 4.5:1, selectable in Preferences with the existing theme-card previews, ui.set {theme}, Window > Theme); outlined knobs in the light themes are still missing | | UI-217-12 | Save custom New Document presets; a real Recent category | done | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent lists Clipboard, the last 20 documents created in the dialog (document.recent.*, persisted with the presets, newest first, deduplicated) and the uncovered defaults (#3131); created_documents_fill_the_recent_category | | UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | done | [#217](https://github.com/storytold/photocraft/issues/217) | widgets::value_field: arithmetic plus units (1in, 5mm, 72pt, 50%) converted at the document resolution (#3099); widgets::scrub_label makes options-bar, slider-row and Character labels scrubby (Shift x10, Alt fine); slider rows with a known default reset on double-click | @@ -204,7 +207,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-20 | 'Show rulers in new documents' preference; guides stored as f64 | done | [#217](https://github.com/storytold/photocraft/issues/217) | View › Rulers is remembered in unitsAndRulers.showRulers and applied at launch and on change (#2918; rulers_open_as_last_set_after_a_restart); doc Guides are Vec through New Guide, .pcraft and PSD 1032 (guides_keep_sub_pixel_positions_through_pcraft_and_psd) | | UI-217-21 | One Preset Manager for every preset kind with folders, search, reorder, load/save sets | partial | [#217](https://github.com/storytold/photocraft/issues/217) | edit_menu_cmds.rs:714 covers brushes, custom shapes, patterns with reorder | | UI-217-22 | Stamp Visible into a new layer keeping originals; Alt+Merge keeps originals | done | [#217](https://github.com/storytold/photocraft/issues/217) | stamp_cmds.rs layer.stampVisible (Cmd+Alt+Shift+E) merges every visible layer into a new layer above the active one and keeps the originals; layer.stampDown (Cmd+Alt+E) too. As in Photoshop they have no menu item of their own: Alt + Layer > Merge Visible / Merge Down / Merge Layers run them (stamp_cmds::alt_variant, menus.rs); stamp_tests.rs alt_click_on_merge_items_stamps and cmd_alt_shift_e_stamps_visible_and_can_be_rebound | -| UI-217-23 | Layer clipboard: copy/paste whole layers (masks, styles, type) between documents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | layer.copyToDocument copies layers into another open document keeping names, content, masks, styles and blending, converting to the destination's profile and depth, as one history step (layer_copy_cmds.rs, #589). It is the command a drag between documents dispatches; there is still no copy/paste layer clipboard | +| UI-217-23 | Layer clipboard: copy/paste whole layers (masks, styles, type) between documents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Edit › Copy with no pixel selection takes layers whole and Paste recreates them in this or another open document, converted to its profile and depth, as one history step (edit_cmds.rs takes_whole, paste_layers): smart objects (#2428), type, shape and group layers (#2670), fill and adjustment layers (#2344), and several selected layers with their masks and styles (#2478). layer.copyToDocument is the drag between documents (layer_copy_cmds.rs, #589). Missing: a single pixel layer copies its pixels only, leaving its mask and styles behind | ## Type @@ -231,7 +234,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | AUTO-219-2 | Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import | done | [#219](https://github.com/storytold/photocraft/issues/219) | 2026-10-12: set folders (collapsible, drag between sets, actions.assignSet), dialog toggle column (playback opens the step's dialog seeded with its values and resumes on OK; actions.dialog), Insert Stop… (actions.insert), step toggles, reorder, delete a step; Load/Save Actions (.atn) in the panel menu, CLI batch and actions.load/save (atn_cmds.rs): 90 Photoshop events map to commands both ways, the rest are kept verbatim and fail at playback by name; step toggles, reorder and delete a step done; Stop steps play (actions.stopMessage) but there is no Insert Stop menu item; sets and dialog toggles are stored and round-trip but the panel neither groups by set nor edits the dialog toggle | | AUTO-219-3 | actions.record/stop/play/list commands for agents and the CLI | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions.list/get/record/stop/play/delete in actions_cmds.rs; the panel, CLI and MCP share them. Nested steps pass the session authorize hook. | | AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions_cmds.rs: a_recorded_edit_run_replays_on_a_second_document records a crop, a Gaussian Blur, a selection fill and faux-bold type on one document and replays all of them onto a second (plus the original layer/select/fill replay) | -| AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial; no recursion, open-documents source, cancel or error-log file | +| AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial, Include All Subfolders (includeSubfolders: depth/loop/file-count bounded, results keep their subfolder; Batch and Image Processor dialogs, #3753); no open-documents source, cancel or error-log file | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | | AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | @@ -240,7 +243,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T ## Reliability -Never-crash attribute on 28 of 28 crate roots. +Never-crash attribute on 30 of 30 crate roots. | Id | Target | Status | Issue | Note | |---|---|---|---|---| diff --git a/docs/sparse-raster-reads-performance.md b/docs/sparse-raster-reads-performance.md new file mode 100644 index 0000000000..1cc1a04502 --- /dev/null +++ b/docs/sparse-raster-reads-performance.md @@ -0,0 +1,45 @@ +# Sparse raster reads + +Skip absent zero-default tiles and avoid temporary default-pixel allocations and copies. ZIP CRC acceleration is already on main. + +A roughly 4% median dense 16-bit city-photo read slowdown repeated, with paired intervals crossing zero. The local fix shares the existing contiguous row loop between allocating and reusable-buffer reads, removes duplicated code, and preserves zero-filled allocation for allocating reads. Final measurements include all photo depths and authored designs; all outputs match bit for bit. + +Measured on 2026-10-19 against main `d95fdf3181c01ad41e95456091a394b105a9afb2`, independently of the other splits. Apple M4 Max, 16 CPUs, 128 GiB, macOS arm64, rustc 1.99.1; release O3, LTO off, 16 codegen units, debug off. Builds used nice 10 and warm APFS-cloned caches; measurements used default priority without concurrent builds or profiling. Five alternating paired samples follow a warmup; confirmation and fix runs use nine pairs. Time change compares the two medians; intervals resample paired ratios. Negative time change means faster. Small differences are not automatically established improvements or regressions. + +The full-resolution CC0 Pixls photographs are P1000475.RW2 (24 MP foliage centre crop, no resampling) and IMG_4059.CR2 (12 MP city), each converted to U8/U16/F32 with the ProPhoto RGB profile. F32 derives from 16-bit development, not HDR capture. MIT psd-tools masks.psd supplies a 640 x 1394 web layout with 22 imported layers and three masks; mask-index.psd supplies a 1000 x 867 illustrated card with a mask. The sources and hashes are pinned by the existing corpus. These are the available authored layered files, not a large layered-photo corpus. No generated noise, repeating ramps, enlarged charts or duplicated photo layers are used. + +The shared harness times in-memory operations. Input loading, image conversion, thumbnail rendering, hashes and equivalence checks are outside timers. Every save uses a fresh PcraftWriter and a real rendered thumbnail. Common reads use main-encoded files; own-output reads are separate. PSD exports use ZIP merged images at U8/U16, Raw merged images at F32, and normal RLE layer channels at U8/U16 / ZIP prediction at F32. Results do not measure filesystem throughput or browser performance. + +| Document | Operation | Main ms | Branch ms | Time change | Paired 95% interval | Main / branch bytes | +|---|---|---:|---:|---:|---:|---:| +| panasonic-16 | read (fixed) | 54.689 | 41.140 | -24.8% | -28.0..-23.8% | — | +| panasonic-16 | render (fixed) | 38.492 | 38.345 | -0.4% | -1.8..+2.0% | — | +| canon-16 | read (fixed) | 27.634 | 19.236 | -30.4% | -34.0..-23.3% | — | +| panasonic-8 | read (fixed) | 50.429 | 20.210 | -59.9% | -62.2..-56.7% | — | +| panasonic-8 | render (fixed) | 30.953 | 30.670 | -0.9% | -3.4..+1.5% | — | +| panasonic-32 | read (fixed) | 91.120 | 69.922 | -23.3% | -23.6..-20.7% | — | +| panasonic-32 | render (fixed) | 39.436 | 39.867 | +1.1% | -4.5..+3.7% | — | +| canon-8 | read (fixed) | 24.899 | 10.543 | -57.7% | -58.5..-54.9% | — | +| canon-8 | render (fixed) | 16.925 | 16.901 | -0.1% | -5.6..+5.5% | — | +| canon-16 | render (fixed) | 21.569 | 21.716 | +0.7% | -2.2..+2.4% | — | +| canon-32 | read (fixed) | 45.494 | 35.200 | -22.6% | -23.5..-21.5% | — | +| canon-32 | render (fixed) | 22.056 | 22.180 | +0.6% | -1.8..+6.9% | — | +| layout-masks | read (fixed) | 35.240 | 3.438 | -90.2% | -90.7..-89.9% | — | +| layout-masks | render (fixed) | 8.167 | 7.461 | -8.6% | -10.6..-8.3% | — | +| layout-mask-index | read (fixed) | 2.314 | 1.051 | -54.6% | -61.7..-50.0% | — | +| layout-mask-index | render (fixed) | 1.926 | 1.827 | -5.1% | -13.0..+0.6% | — | + +All real read-region and render float bits match main. The committed previous-read-region oracle also covers sparse boundaries, negative coordinates, signed zero, defaults and eight pixel formats. + +Current local checks: `cargo test -p photocraft-raster --locked --lib` (27 passed, one ignored manual benchmark); strict all-target Clippy; affected wasm32 check. All passed. The existing previous-read-path oracle covers the shared loop. + +Local raw evidence and the shared harness: `.local/perf-real-261019/` in the repository checkout (ignored). This comparison supersedes the earlier synthetic performance table. Small synthetic unit fixtures remain correctness checks; ignored synthetic timing tests were not run. + +## Earlier split validation + +These checks passed when the split was prepared; they were not all rerun for this benchmark review. Current affected checks are listed above where production code changed. + +- `cargo test -p photocraft-raster --locked`: passed (27 passed, 1 ignored). +- `cargo clippy -p photocraft-raster --locked --all-targets -- -D warnings`: passed. +- `cargo xtask layers`: passed. +- `cargo check -p photocraft-raster --locked --target wasm32-unknown-unknown`: passed. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a7c802904b..3eca518c27 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (`.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -167,14 +167,14 @@ parity for the area and are already contained in the dimension rows above. | Area | % | Hours | Notes | |---|---:|---:|---| -| Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: layer clipboard between documents (UI-217-23: `layer.copyToDocument` exists, no copy/paste), smart-object perspective (FILE-215-6), a user contour library (FILE-215-15). Multi-layer Smart Object conversion (#2052, #2651), Stamp Visible (UI-217-22) and mask targeting by command (#2458) are done | +| Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: a single pixel layer copies its pixels without its mask and styles (UI-217-23: smart object, type, shape, group, fill and adjustment layers and multi-layer selections copy and paste whole), smart-object perspective (FILE-215-6), a user contour library (FILE-215-15). Multi-layer Smart Object conversion (#2052, #2651), Stamp Visible (UI-217-22) and mask targeting by command (#2458) are done | | Selections, Select and Mask, channels | 55% | 120–200 | All selection tools, single row/column marquee included (#2837). Select and Mask is a dialog with Photoshop's view modes, a Refine Edge Brush and six outputs, not a workspace (TOOL-214-1), no Refine Hair (#2671); Select Subject/Sky classical, 559 ms p50 on 24 MP vs a 100 ms budget | | Painting and brush engine | 55% | 150–250 | Every Brush Settings section exists; feel is the gap: lag on big brushes, pressure jumps, smoothing and edge quality reports ([brush-parity.md](brush-parity.md)) | | Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 | Remove tool non-generative; Content-Aware Fill has no workspace (TOOL-214-15); live stroke preview only for Brush/Eraser (TOOL-213-7) | | Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right, with a right-click transform menu (TOOL-214-5) and H/V skew fields; Puppet Warp mesh follows the object outline (TOOL-214-6); Liquify called "useless" by a user (#2606) | | Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | | Type | 40% | 120–200 | Engine works; no type on a path, manual kerning, RTL paragraphs, OpenType panel UI, live Warp Text preview; Type tool freeze on macOS (#2518) | -| Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); missing on-canvas corner radius (TOOL-214-12), path-point nudge; shape parity tracking issue #2496 | +| Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); missing path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | | Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); no ExtendScript/UXP | @@ -444,7 +444,7 @@ filled bar. This extends UI reachability and does not claim Photoshop-authored s | Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. | | Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. | | UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418). 2026-10-09: canvas zoom is physical at any display scale — 100% is one document pixel per physical display pixel, and every screen-space overlay, cursor, scrollbar, navigator and fit follows it (#1943) | low–medium | Needs recurring visual QA with realistic documents. | -| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush and Frame (Perspective Crop landed 2026-10-10, #1043: `image.perspectiveCrop`) | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | +| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. The only tool still missing is Art History Brush: Frame landed (#2914), Red Eye is a live toolbar tool (`paint.redEye`, TOOL-213-17) and Perspective Crop landed (2026-10-10, #1043, `image.perspectiveCrop`) | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | | Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Native Wayland pen input open (#79; a pen opens the window through Xwayland meanwhile); X11 pressure confirmed by a user, macOS not yet verified on tablet hardware. | | Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. | | Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. | @@ -565,6 +565,9 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Layers: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial; percentages unchanged | +| 2026-10-13 | minor | Vector shapes: on-canvas corner radius widgets on live rectangles (TOOL-214-12); estimates unchanged | +| 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the Pen flyout) are live toolbar tools and Frame landed, so Art History Brush is the only tool still missing; percentages unchanged | | 2026-10-12 | minor | Free Transform options bar H/V skew fields; percentages unchanged | | 2026-10-12 | minor | `.atn` import: 112 events mapped (TrueGrit Texture Supply sets play end to end); percentages unchanged | | 2026-10-12 | minor | Photoshop Actions `.atn` load and save (90 events mapped both ways); percentages unchanged | diff --git a/docs/text-raster-performance.md b/docs/text-raster-performance.md new file mode 100644 index 0000000000..5bc446ead1 --- /dev/null +++ b/docs/text-raster-performance.md @@ -0,0 +1,63 @@ +# Text rasterization: what a type render costs + +Measured 2026-10-17 on Windows 11, `i5-10300H` (4 cores / 8 threads, 16 GB), release build, +`rustc 1.98.1`, with a probe that renders one paragraph layer through +`photocraft_text::render::rasterize_warped` and times each stage of it. + +Document: 300 dpi, a 2400 × 1600 px paragraph box holding 4000 characters at 14 pt — the worst +case a type edit has (a whole page of text at print resolution; 1851 laid-out glyphs, 2329 × 1610 px +of ink = 3.75 MP). + +| Stage | ms | What it does | +|---|---:|---| +| ink rectangle (`ink_rect_warped`) | 4.1 | its own pass over every glyph, to size the surface | +| draw (`draw_pass` into `Coverage`) | 18.6–22.9 | glyph outlines, curve flattening (0.03 px tolerance), winding scanlines | +| prefix sum (`Coverage::finish`) | 7.0–8.1 | resolves the winding accumulator to coverage 0…1, one running sum over 3.75 M pixels | +| composite | 22.6–33.4 | each colour's coverage over the premultiplied accumulator | +| unpremultiply | 11.2–14.0 | divides the accumulator back to straight colour | +| `Surface::write_region` | 23.8 | converts to the document format and fills the tiles | +| `Surface::prune` | 3.1 | drops tiles that came back the layer's default | +| **total** | **86–104** | | + +So the glyph geometry — what one would expect to dominate — is under a quarter of it. The rest is +scalar work over the pixel buffers, and every one of those passes is per pixel: a pixel's maths +reads and writes only its own slots. + +## What runs in parallel now + +`composite`, `unpremultiply` and the tile conversion run on all cores (rayon, native only; the web +build keeps the serial loops, since wasm has no threads). The rasterizer writes its tiles with +`Surface::write_region_par`; `Surface::write_region` stays serial, because mask builds call it inside +a `OnceLock` initializer, which must not enter rayon. Nothing about them +is order-dependent, so each pixel still sees the same operations on the same inputs — the pixels +are identical, not merely close. + +| | before | after | +|---|---:|---:| +| the 3.75 MP case above | 98.3–103.9 ms | 60.3–61.8 ms | + +`crates/text/src/tests.rs::parallel_raster_matches_a_single_thread` renders the shapes the pipeline +can take (plain, faux bold italic, no antialias, wide gamut, a wrapping paragraph box; 8/16/32-bit) +on a one-thread pool and on the default pool and compares the surfaces; `crates/raster`'s existing +`region_writes_match_scalar_bytes_for_every_format` compares `write_region` and `write_region_par` +against a scalar reference implementation, tile for tile. + +## What stays on one thread, and why + +- **draw** (19–23 ms): the winding accumulator carries across scanlines and rows — a pixel's value + depends on the rows above it, and one scanline can spill into the next row's first pixel. A band + split can be exact (bands keep absolute coordinates, carry the spill in band order, and are + assembled before the prefix sum), but each band re-traverses every glyph — the traversal is not + free, so the win is bounded by the band count. Worth doing when the draw matters more than it does + now. +- **prefix sum** (7–8 ms): a running f32 sum over the whole image. Splitting it per row needs the + row's starting sum, which is that same sequential chain — the only exactly-known starting sums are + the ones the chain computes. +- **ink rectangle** (4.1 ms): a second full traversal into a bounds sink. Per-glyph bounds reduce by + min/max, which is associative and exact, so this is a candidate for the same treatment as the + stages above (a glyph-range split, not a band split). + +## Related + +Byte-identical output matters here beyond taste: text pixels reach a PSD's saved cache and the +round-trip corpora. `cargo xtask test-corpus` and the Photoshop oracle tests are the gate. diff --git a/docs/ui-design.md b/docs/ui-design.md index 13fd8b99f1..d0ee20f7bb 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -58,6 +58,7 @@ curl -sfL -o assets/icons/.svg https://raw.githubusercontent.com/lucide-ic | Quick layer pick | `quick_pick.rs` | macOS: ⌘⌥⌃-click with any tool selects the topmost visible layer with pixels under the pointer (`layer.pickAt`) without switching tools; the drag and release are swallowed. Windows/Linux have no third modifier distinct from the ⌃⌥ brush resize, so the gesture is a no-op there (use the Move tool's Auto-Select). A failed pick is a status-bar error. | | Layers rows | `panels.rs` | Double-click: on the name renames in place; on the Background makes it a normal layer; on an adjustment or fill thumbnail opens its Properties; on a Smart Object thumbnail opens its contents (Edit Contents); anywhere else on the row opens Layer Style. | | Layer masks | `panels.rs` | Clicking the mask thumbnail targets the mask (corner-bracket frame; the tab reads "Layer, Layer Mask/8"). Brush, eraser (paints background colour), gradient, bucket and moving the selected pixels (⌘-drag, `select.float`; the hole takes the background colour) then send `"target": "mask"`. Adjustment and fill layers target their mask automatically. | +| Channels selection | `channels_panel.rs` | Load channel as selection in the footer follows the highlighted channel row. When the active layer mask is targeted for painting while the underlying channel view remains Composite, it loads that layer mask; otherwise it loads the selected composite, color, alpha or vector mask channel. | | Path thumbnails | `vector_ui.rs` | Ctrl/Cmd-click the thumbnail of a Work Path, saved path or layer path to load it as a selection; Shift adds, Alt subtracts, Shift+Alt intersects. Clicking the row name only selects the path. | | Levels / Curves | `tone.rs` | Histogram of the image *below* the adjustment. Curves: click to add a point, drag out to delete. Every change is a coalesced `layer.setAdjustment`, so one drag = one undo step and the canvas updates at full resolution on the GPU. | | Shape Stroke Options | `shape_stroke_ui.rs`, `vector_ui.rs` | Direct editing: valid changes apply immediately, with one numeric drag per undo step in Properties. Escape/outside click closes the popup and retains changes; a child dropdown keeps the parent open. The options bar sets new-shape defaults; Properties edits the selected shape. | @@ -97,13 +98,13 @@ The app's top bar (`panels::title_bar`) starts with the brand mark (the app icon - **Windows and Linux:** the window has no OS decorations, so there is one bar, not two stacked. The top bar is the title bar (`titlebar.rs`): Minimize, Maximize/Restore and Close (red on hover, `caption_close` tokens) sit flush in the window's top-right corner, the free gap between the menus and the controls drags the window and a double-click there maximizes it, and invisible 5 pt edges (12 pt corners) resize it. Close runs File › Exit, so unsaved documents are asked about first. - **macOS:** the traffic lights sit over the integrated title strip (`integrated_titlebar`). -- **Narrow windows** drop controls that are also in a menu before anything overlaps: Discord (Help › Discord), then the theme toggle, then search (Edit › Search), then the workspace switcher narrows (Window › Workspace). +- **Narrow windows** drop controls that are also in a menu before anything overlaps: the theme toggle (Preferences), then search (Edit › Search), then the workspace switcher narrows (Window › Workspace). ## Compact tool controls The toolbar scrolls its tool rows when two columns still exceed the available height. Foreground -and background colours stay visible below those rows, together with Pro's Quick Mask and Screen -Mode controls. Long options rows scroll horizontally. Both scrollers bring newly focused +and background colours stay visible below those rows, together with the Quick Mask and Screen +Mode controls in every theme. Long options rows scroll horizontally. Both scrollers bring newly focused controls into view and retain the existing tool actions and popups. The offscreen regression fixture uses production panels, fonts and SVG loaders across every @@ -173,6 +174,15 @@ monitors. A 4K display the system runs at 100% stays at 100% (#1278). The fixed 300%) set an absolute UI scale, for example 200% on an unscaled 4K display. Canvas zoom shortcuts continue to control the document independently of UI scaling. +On a Wayland session with a fractional display scale, the interface is sharp only in a native +Wayland window, where the window is drawn at the display's physical resolution +(`wp_fractional_scale_v1`). Through Xwayland the compositor enlarges a lower-resolution window, so +everything is blurred and looks larger than the chosen scale (#1072). The window opens through +Xwayland when a pen is attached (#639), with Preferences › Performance › Linux display server = +X11, or with `WAYLAND_DISPLAY=` cleared; those are also the ways to get file drag and drop, which +native Wayland lacks (#386). Sharp text or drag and drop: on such a desktop it is one or the other +until winit supports Wayland drops. + Interface → UI Font Size changes interface text independently of UI Scale and canvas zoom. Tiny, Small (the default), Medium and Large use 10/12, 1, 14/12 and 16/12 of the theme's original font sizes, preserving the relative sizes of headings, captions and numeric fields. Apply or OK diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f2092fd3e2..793f09840c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -34,13 +34,13 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Frame | Frame (frames are groups clipped by a rectangular or elliptical mask) | | | Eyedropper | Eyedropper, Color Sampler, Ruler, Note, Count | | | Retouch | Remove, Spot Healing, Healing, Patch, Content-Aware Move, Red Eye | | -| Brush | Brush, Pencil, Mixer Brush | Color Replacement (`paint.colorReplacement` exists) | +| Brush | Brush, Pencil, Mixer Brush | | | Stamp | Clone Stamp, Pattern Stamp | | | History | History Brush | Art History Brush | | Eraser | Eraser, Background Eraser, Magic Eraser | | | Fill | Gradient, Paint Bucket | | | Focus, toning | Blur, Sharpen, Smudge, Dodge, Burn, Sponge | | -| Pen | Pen, Freeform Pen, Curvature Pen (#1054), Convert Point (#3204; also ⌥ with Pen) | Add Anchor Point, Delete Anchor Point | +| Pen | Pen, Freeform Pen, Curvature Pen (#1054), Convert Point (#3204; also ⌥ with Pen), Add Anchor Point, Delete Anchor Point (P cycles them; #4144 auto add/delete with the Pen itself) | | | Type | Horizontal, Vertical, Vertical Type Mask, Horizontal Type Mask | | | Path selection | Path Selection, Direct Selection | | | Shapes | Rectangle, Ellipse, Triangle, Polygon, Line, Custom Shape | (Star is a Polygon option in Photoshop) | @@ -53,6 +53,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Group remembers the last tool used | partial | L always resets to Lasso (#2608) | | Press-and-hold or click-drag opens the flyout | missing | right-click only (#2301) | | Edit Toolbar customization | done | `edit.toolbar` | +| Last selected tool restored after quitting and relaunching; temporary tool switches excluded | done | `tool_memory.rs`; `prefs_ui::tests::selected_tool_survives_a_restart_without_option_changes`, `selected_tool_excludes_temporary_switches` | ## Free Transform (⌘T) and transform handles @@ -66,6 +67,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | ⌘-corner distort, ⌘-edge skew, ⌘⌥⇧ perspective, ⇧ rotate in 15° steps | done | | | Warp: presets, bend, split crosswise/vertical/horizontal, 3×3–5×5 and custom grids | done | | | Options bar X/Y, W/H % with link, angle | done | | +| With a selection, the box is the selection's rectangle and the marching ants follow the box live | done | `transform_frame`, `transform_tool.rs` (`draw_selection`): move, scale, rotate, skew, distort, perspective and warp, before the transform is applied; also the Move tool's Show Transform Controls (#5297) | | Options bar H/V skew fields | done | `transform_tool.rs` (`Parts`, `skew_about_pivot`): W/H, angle and skew read as Photoshop decomposes the box; `skew_fields_lean_the_box_about_the_reference_point` | | Interpolation in the options bar | partial | Bicubic/Bilinear/Nearest; no Bicubic Smoother/Sharper/Automatic, Preserve Details | | Commit/cancel buttons in the options bar | missing | #2442 | @@ -94,6 +96,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | ⌥-drag duplicates, ⇧ constrains to 45° | done | `move_mods.rs` | | Auto-Select with hover outline, box-select layers, double-click fit | missing | TOOL-214-4 | | Auto-Select bounding box and snapping follow the picked layer | partial | follow the previous layer (#2666) | +| Several layers selected (⇧-click adds, box-select): Show Transform Controls frames them all, with every selected shape's outline | partial: the shared frame has no handles, since Free Transform works on one layer (`canvas.rs` `transform_controls_layers`, `several_selected_layers_share_one_frame_without_handles`; `move_auto_select_tests.rs`) | | | ⌘-drag temporary Move shows the box and snaps | partial | #2681 | | ⌘-right-click layer list under the pointer | done | `layer_pick_ui.rs` | | Double-click type to edit it (caret at the pointer, Type tool) | done | #1132, `type_tool::edit_at` | @@ -116,11 +119,12 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Behaviour | Status | Evidence | |---|---|---| -| Click corner, drag smooth, ⌥ breaks a handle, ⌘ direct-selects, ⇧ 45° | partial | TOOL-214-10 | +| Click corner, drag smooth, ⌥ breaks a handle, ⌘ direct-selects, ⇧ 45° | done | TOOL-214-10; `vector_ui.rs` (#3751) | | Handle on the last point, close-drag handle | partial | #1482 | -| Pen Auto Add/Delete: click a segment of the active path to add, an anchor to delete; ⇧ suspends it | done | #4144 | +| Pen Auto Add/Delete: click a segment of the active path, or of the path being drawn, to add, an anchor to delete; ⇧ suspends it | done | #4144, #6140 | +| Curvature Pen edits the active path with no modifier: drag an anchor (the curve reflows), click a segment to add an anchor, double-click smooth ↔ corner; cursor badge + on segments, four arrows on anchors | done: `direct_select.rs` (`curvature_press`, `curvature_hover`), `path.moveAnchors`/`path.convertPoint` with `curvature`, `direct_select_tests.rs` | | | Path operations in the options bar (combine, subtract, intersect, exclude) | partial | `layer.combineShapes.*`; Pen options #1397 | -| On-canvas corner-radius handles on live shapes | missing | TOOL-214-12 | +| On-canvas corner-radius handles on live shapes | done: every selected live rectangle shows a widget at each arc centre with Path Selection, the Rectangle tool and Move with Show Transform Controls; a drag rounds every corner of every selected rectangle to the same radius, ⌥ that corner on each, with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 | | Shape stroke controls (dash, caps, joins, align) | done | TOOL-1053-1, [`shape-strokes.md`](shape-strokes.md) | | Rotated shape selection hints correct | partial | #2512 | | Curved shape bounds exact | partial | one pixel too wide (#2537) | @@ -139,13 +143,14 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Default mode: the image turns and moves behind an upright box; Auto Center Preview; Use Classic Mode | done | TOOL-214-23 | | W × H × Resolution with units, built-in size presets, Front Image | done | TOOL-214-9 (`crop_size.rs`) | | Saved custom crop presets | missing | TOOL-214-9 | -| Content-Aware crop fill, Delete Cropped Pixels off | partial | | +| Fill: Content-Aware Fill: a turned or extending crop fills the uncovered corners and strips from the image (each piece with its own sampling window), on the Background or else the active pixel layer, in the crop's one undo step; Generative Expand not offered | done | `tests/crop_content_aware.rs`, `crop_ui.rs::commit_passes_the_fill_choice` (#3823, 2026-10-12) | +| Delete Cropped Pixels off | partial | | ## Snapping, guides, rulers, precision | Behaviour | Status | Evidence | |---|---|---| -| Snap to guides, grid, layers, document bounds and centre, selection, slices; ⌘ disables while dragging | done | `engine/src/snap.rs`, `snap_ui.rs` | +| Snap to guides, grid, layers, document bounds and centre, selection, slices; Ctrl disables other gestures, while Distort corners snap to guides and intersections | done | `engine/src/snap.rs`, `snap_ui.rs` (actual-corner grab-offset regressions, #3632) | | Snap vector tools and transforms to the pixel grid | done | `snap_ui.rs:196` | | Smart guides (magenta alignment lines) | done | | | Smart guide distance and spacing labels | missing | | @@ -182,12 +187,14 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | PhotoCraft | ~199 shortcut assignments, 113 distinct modifier/F-key combos, 20 tool letters, 3 temporary-tool bindings | measured (source) | | Shortcut audit | 214 → 0 failures (2026-10-07) | measured then; not re-run here | | `.kys` import | done (`engine/src/kys.rs`) | | +| Shortcut sets: Set menu, New Set…, Save, Delete, `.kys` Export…, Summarize… | done (`engine/src/shortcut_sets.rs`, `prefs_ui::set_row`); sets live in the preferences. Photoshop reading our exported `.kys` is unverified | `shortcut_sets` tests, `shortcut_set_tests.rs` | | Shortcut glyphs per platform | done (UI-217-19) | | | Toolbar tool shortcut editing | done (#4086): each tool has a row under Tools; assignments persist, removed tools leave the default key group, and unchanged tools keep Shift cycling. Toolbar and flyout hints follow the binding | `tool_shortcuts.rs`, `shortcut_capture_tests.rs` | | Brush size/hardness keys on non-US layouts | done (#3109): `[` `]` need AltGr on many layouts, so Photoshop uses other keys there (German: Ö / #, ⇧ for hardness). `layout_keys.rs` has a data table of such layouts (`LAYOUTS`, add rows to support one more) and a fallback for all others: the keys at the US `[` / `]` positions act as `[` / `]` when they don't type brackets. Off once a real bracket is typed; Edit › Keyboard Shortcuts overrides still apply | | | Layer and selection keys: ⌘, toggles visibility, ⌘\ targets the mask, / locks transparency, ⌘⌥2 loads luminosity, ⌘⌥⌫ fills from history, Tab / ⇧Tab rename the next layer, ⌥-click a thumbnail fits the layer | done (2026-10-11) | | | Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | | | Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | | +| Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | | ## Panels and workspace @@ -204,31 +211,49 @@ Paragraph; the rest open as in-app floating windows. | Color panel wheel and RGB/HSB/CMYK/Lab sliders | missing | UI-217-17, #2569 | | Window size and dock width remembered | done | UI-217-2 | | Command palette with recents | done | UI-217-7 | +| Menu bar from the keyboard: Alt / F10 focus the bar, ← → ↓ ↩ drive it, Alt+letter opens a menu, underlined mnemonics run items | done | `menu_keys.rs`, #4988; macOS uses the native bar | | Every preference takes effect | partial | 46 of 149 do nothing (scorecard) | | UI scale presets | missing | UI-217-9, #2492 | -| History snapshots: camera button, snapshot rows (click returns, rename, delete), `history.*Snapshot` commands | partial | `history_cmds.rs`, `snapshot_rows` in `panels.rs`; New Snapshot dialog, first snapshot and History Options to come (#2966) | +| History snapshots: camera button, snapshot rows (click returns and selects, rename, delete; the trash deletes the selected snapshot, and a snapshot or state dragged onto it, #5324), `history.*Snapshot` commands | partial | `history_cmds.rs`, `snapshot_rows` in `panels.rs`; New Snapshot dialog, first snapshot and History Options to come (#2966) | | Themes: Photoshop's four greys | partial | five themes; #2661 | | Document tabs: overflow, context menu, reorder | done | UI-217-6; middle-click close #2570 | | Layers panel: wheel steps blend modes, drop on New/Trash | partial | UI-217-14 | +| Layers panel filter-type dropdown (Kind, Name, Effect, Mode, Attribute, Color, Smart Object, Selected, Artboard) | partial | Kind, Name and Mode; the other six missing. `layer_filter_ui.rs`, `ui.set {layerFilter}` (#5048) | | Smart filter rows: click selects, drag onto another smart object moves, ⌥-drag copies, drag onto Delete removes | done | `smart_ui.rs`, `layer.smartFilter.transfer` (#4271) | +| Layers panel: Clip Thumbnails to Layer Bounds (panel menu, `interface.clipThumbnailsToLayerBounds`) | done (2026-10-12) | `layer_thumb_bounds_tests.rs`; #4281. Thumbnail size and the Panel Options dialog to come | | Smart filter rows: drag up or down the stack to reorder (a line marks the drop; the bottom filter is applied first) | done | `smart_ui.rs` `reorder_drop`, `layer.smartFilter.move` | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | +| 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | +| 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | | 2026-10-13 | minor | Smart object perspective (non-affine) corrected to done: #426 landed four-corner placements with PSD round trip; scorecard FILE-215-6 flipped | | 2026-10-10 | major | First version, from a source audit against Photoshop 2026 27.11.0's shortcuts file and the open issues | +| 2026-10-12 | minor | Closed #3632: Distort and Ctrl/Cmd corner drags snap actual corners to guides and intersections; shared guide snapping respects Extras and Canvas Guides visibility, with offset, visibility and constrained-alignment regressions | | 2026-10-10 | minor | Fixed Crop live resize snapping feedback and outdated-camera rendering, applied ratios immediately to centred inscribed previews, and removed two-point options-bar overflow; added pointer, rendering, dropdown/Enter, Swap/X and scrolling regressions | | 2026-10-11 | minor | Flick panning done: releasing a moving Hand drag (tool, Space or middle button) glides and decays, gated by Enable Flick Panning | | 2026-10-11 | minor | History snapshots: the camera button takes one, snapshot rows return to it as a new state, rename and delete it (#2966) | +| 2026-10-14 | minor | History: a clicked snapshot stays selected and the trash deletes it; snapshot and state rows dragged onto the trash are deleted (#5324) | | 2026-10-11 | minor | ⇧-drag a guide out of a ruler lands on the nearest ruler tick (#3770) | | 2026-10-11 | minor | Layer and selection keys from Photoshop's shortcut set: ⌘, toggles, ⌘\, /, ⌘⌥2, ⌘⌥⌫, Tab rename-next, ⌥-click thumbnail to fit | | 2026-10-11 | minor | Tool keys from Photoshop's shortcut set: ← / → rotate the brush tip, ⇧-click for new type, Space+F canvas colour | | 2026-10-11 | minor | Spring-loaded tool letters done (#2672): holding any tool letter (Z included) past 300 ms, or using the canvas while it is down, makes the tool temporary until release | | 2026-10-11 | minor | Type formatting keys done: ⇧⌘K, U, B, I and /, ⌥↑/↓, ⇧⌥↑/↓, ⌥←/→ over a selection and ⌥⇧⌘H format the selected type while editing, and no longer reach Color Settings, Desaturate, Auto Color or Inverse | | 2026-10-12 | minor | Pen Auto Add/Delete on the active path, with its options-bar checkbox, hover feedback and + / − cursor badge (#4144) | +| 2026-10-15 | minor | Pen Auto Add/Delete works on the path being drawn: a click on its segment adds an anchor, on a middle anchor deletes it, and drawing carries on (#6140) | | 2026-10-12 | minor | Toolbar tool shortcuts can be reassigned, removed and reset in Keyboard Shortcuts (#4086); capture, restart, group cycling, conflict and text-focus regressions | | 2026-10-12 | minor | Smart filter rows can be selected, and dragged (⌥: copied) onto another smart object or onto Delete (#4271) | | 2026-10-12 | minor | Free Transform options bar: H and V skew fields; a sheared box reads as skew instead of a taller H | +| 2026-10-12 | minor | Crop tool Fill › Content-Aware Fill done (#3823); Delete Cropped Pixels off split into its own row, still partial | +| 2026-10-12 | minor | Layers panel: Clip Thumbnails to Layer Bounds frames each layer's pixels instead of the canvas (#4281) | +| 2026-10-12 | minor | Guides: ⌥-click turns a guide through the click; double-click opens Preferences › Guides, Grid & Slices | | 2026-10-13 | minor | Smart filter rows can be dragged up or down their stack to reorder the filters | +| 2026-10-13 | minor | On-canvas corner radius widgets on live rectangles (TOOL-214-12) | +| 2026-10-13 | minor | Curvature Pen edits the active path: drag anchors, click a segment to add, double-click smooth/corner, cursor badges | +| 2026-10-11 | minor | Pen ⇧ 45° done: ⇧-click puts the next anchor on the nearest 45° ray, ⇧-drag turns its handles in 45° steps (TOOL-214-10 done, #3751) | +| 2026-10-14 | minor | Last selected tool restored across launches; temporary overrides excluded, explicit Eraser choices retained, and final selection flushed on normal exit | +| 2026-10-13 | minor | Free Transform with a selection: the box is the selection's rectangle, and the marching ants follow it while it is transformed (#5297) | +| 2026-10-14 | minor | Move › Show Transform Controls frames every selected layer and outlines the selected shapes | diff --git a/docs/user/AGENTS.md b/docs/user/AGENTS.md new file mode 100644 index 0000000000..dd791ea1f5 --- /dev/null +++ b/docs/user/AGENTS.md @@ -0,0 +1,60 @@ +# User docs: rules for agents and contributors + +Everything in this folder (except this file, CLAUDE.md and README.md) is published at +https://getartcraft.com/docs/photocraft. The file path is the URL: +`troubleshooting/taskbar-turns-black.md` → `/docs/photocraft/troubleshooting/taskbar-turns-black`. +Renaming a file breaks links, so add the old path to `redirect_from` when you rename. + +## Who reads this + +Artists using PhotoCraft, Discord mods answering them, and AI assistants looking things up for +either. Write for an artist, not a developer. + +## Page rules + +- One question or problem per page. Lead with the answer or the fix in the first two lines. +- Plain words, short sentences, numbered steps for anything the reader does. +- Start the body at `##`. The page title comes from frontmatter. +- Link to other pages instead of repeating them. Use relative links to the `.md` file: + `[Install PhotoCraft](../getting-started/install.md)`. +- No marketing copy, no "simply", no claims you have not checked in the code or a release. +- Call the product "PhotoCraft", as the app, its downloads and getartcraft.com do. +- Name UI labels exactly as the app shows them, in **bold**. Menu paths read `File › Open…`. +- Troubleshooting pages: `## Symptoms`, `## Fix`, `## Why this happens` (optional), + `## Still stuck?` (link the issue tracker or Discord). + +## Frontmatter + + --- + title: Export an image as PNG or JPEG + description: One sentence for search results, link previews and AI summaries. + order: 10 # optional, sorts within the folder (lower first) + verified: 0.7.0 # optional, app version you last checked this page against + redirect_from: [old/path] # optional, old URLs (relative to /docs/photocraft) + --- + +Only `key: value` and `key: [a, b]` lines work. Quote values that start with a quote or contain ` #`. + +Set `verified` to the latest release (the newest release tag) after checking the page against that tag with `git show :`. Pages describe the latest release users download, not unreleased code on main. + +## Folders + +- `getting-started/` install and first run +- `guides/` how to do one thing +- `troubleshooting/` symptom → fix +- `faq/` short answers +- `reference/` lists and settings + +A folder's `index.md` names the folder in the sidebar. Files or folders starting with `_` are +drafts and are not published. + +## Check before you push + + node /frontend/apps/artcraft-website-next/scripts/docs/core.mjs docs/user + +The checker lives in storytold/artcraft-services. A CI check will be added once it is on that repo's main branch. + +## Support PRs + +Pages written to answer a user question come in as PRs titled `docs(support): `. +Review them like code: check every claim against the source. diff --git a/docs/user/CLAUDE.md b/docs/user/CLAUDE.md new file mode 100644 index 0000000000..e7bcfb70ed --- /dev/null +++ b/docs/user/CLAUDE.md @@ -0,0 +1 @@ +Read `AGENTS.md` in this folder: it has the rules for the user docs. diff --git a/docs/user/faq/index.md b/docs/user/faq/index.md new file mode 100644 index 0000000000..c50180a390 --- /dev/null +++ b/docs/user/faq/index.md @@ -0,0 +1,10 @@ +--- +title: FAQ +description: Short answers about PhotoCraft's price, Photoshop files and how it compares with Photoshop. +order: 40 +verified: 0.8.2 +--- + +- [Is PhotoCraft free?](is-photocraft-free.md) +- [Can PhotoCraft open my Photoshop files?](open-photoshop-files.md) +- [Can PhotoCraft replace Photoshop?](replace-photoshop.md) diff --git a/docs/user/faq/is-photocraft-free.md b/docs/user/faq/is-photocraft-free.md new file mode 100644 index 0000000000..dd9946540a --- /dev/null +++ b/docs/user/faq/is-photocraft-free.md @@ -0,0 +1,14 @@ +--- +title: Is PhotoCraft free? +description: PhotoCraft is free and open source under the MIT or Apache 2.0 license, and works offline. +order: 10 +verified: 0.8.2 +--- + +Yes. PhotoCraft is one of the Crafting Apps, which the project describes as free, open-source creative tools. The code is licensed under MIT or Apache 2.0, your choice, and is on [GitHub](https://github.com/storytold/photocraft). + +PhotoCraft works offline. Its smart selection tools run on your computer, with no cloud and no account. + +The ArtCraft logos in the repository are not open source. + +See [Install PhotoCraft](../getting-started/install.md) to download it. diff --git a/docs/user/faq/open-photoshop-files.md b/docs/user/faq/open-photoshop-files.md new file mode 100644 index 0000000000..06fbe367bc --- /dev/null +++ b/docs/user/faq/open-photoshop-files.md @@ -0,0 +1,16 @@ +--- +title: Can PhotoCraft open my Photoshop files? +description: PhotoCraft opens and saves layered PSD and PSB files and keeps what it does not understand yet. +order: 20 +verified: 0.8.2 +--- + +Yes. PhotoCraft opens, edits and saves layered PSD and PSB files, including 16-bit, 32-bit, CMYK and Lab documents. + +When it saves the file again, the picture renders the same for 307 of the 309 files in the psd-tools test set the project checks against. Parts PhotoCraft does not model yet, such as raw blocks and descriptors, are carried over instead of being dropped. A re-saved file is not byte for byte the same as the original, because PhotoCraft rewrites image resources, layer records and the composite. + +Text layers stay editable. In version 0.7.0 some of them moved when you started editing them; 0.8.2 fixes that, see [Text moves when I start editing a PSD text layer](../troubleshooting/text-moves-when-editing.md). + +Results vary from file to file. If a PSD does not open or looks wrong, search the issue tracker or report it with the file. + +PhotoCraft also opens layered TIFF, Paint.NET, OpenRaster, SVG, Krita and Affinity files (Krita and Affinity read only), and many flat formats. The full list is in [Open and save files](../guides/open-and-save-files.md). diff --git a/docs/user/faq/replace-photoshop.md b/docs/user/faq/replace-photoshop.md new file mode 100644 index 0000000000..56027f2706 --- /dev/null +++ b/docs/user/faq/replace-photoshop.md @@ -0,0 +1,12 @@ +--- +title: Can PhotoCraft replace Photoshop? +description: Not yet. PhotoCraft is in early alpha and is not ready for daily professional work, but it follows Photoshop's menus and shortcuts. +order: 30 +verified: 0.9.0 +--- + +Not yet. PhotoCraft is in early alpha. Much of Photoshop's feature surface exists in some form, but the project says plainly that it is not yet a Photoshop replacement for daily professional work. + +The biggest gaps the project lists are AI and generative features, tools Photoshop has that PhotoCraft does not yet, depth in typography and professional workflows, and plug-in compatibility. Every Photoshop menu item is connected to a command, but that only shows the menu is wired up. Having a tool or command is not the same as matching Photoshop's behaviour. + +What PhotoCraft does well already: layers, masks, adjustment layers, layer styles, type, vector shapes, brushes and live-preview filters, in a menu and shortcut layout that follows Photoshop. See [PhotoCraft](../index.md) for an overview, and the [project roadmap](https://github.com/storytold/photocraft/blob/main/ROADMAP.md) for what comes next. diff --git a/docs/user/getting-started/index.md b/docs/user/getting-started/index.md new file mode 100644 index 0000000000..2383e17f0b --- /dev/null +++ b/docs/user/getting-started/index.md @@ -0,0 +1,21 @@ +--- +title: Getting started +description: Install PhotoCraft and open your first image. +order: 10 +verified: 0.8.2 +--- + +Install PhotoCraft, then open an image with **File › Open…**. The panels you use most are **Layers**, **Properties** and **History**. + +1. [Install PhotoCraft](install.md). +2. Start PhotoCraft and choose **File › Open…**. To start from nothing, choose **File › New…** and click **Create**. +3. Pick the **Horizontal Type Tool**, a selection tool or an adjustment, and work on the image. Every step is listed in the **History** panel and can be undone. +4. Keep your layers with **File › Save As…** (PSD or PhotoCraft), or make a flat copy with **File › Export › Export As…**. + +The guides walk through each of these: + +- [Open and save files](../guides/open-and-save-files.md) +- [Adjust colors without changing your pixels](../guides/adjust-colors-with-adjustment-layers.md) +- [Select part of an image and mask it](../guides/select-and-mask.md) +- [Add and style text](../guides/add-and-style-text.md) +- [Export an image](../guides/export-an-image.md) diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md new file mode 100644 index 0000000000..975f7f3b71 --- /dev/null +++ b/docs/user/getting-started/install.md @@ -0,0 +1,59 @@ +--- +title: Install PhotoCraft +description: Download PhotoCraft for Windows 10 or 11, macOS or Linux from getartcraft.com or GitHub, or install it with ArtCraft Launcher. +order: 10 +verified: 0.8.2 +--- + +The easiest way is the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). You can also install it with [ArtCraft Launcher](https://getartcraft.com/docs/craft-launcher/guides/install-and-update-apps), which lists PhotoCraft. For a specific build or file format, take it from the [latest release](https://github.com/storytold/photocraft/releases/latest) on GitHub. In the file names below, `` stands for the version number, for example `0.8.2`. + +## Windows + +PhotoCraft needs Windows 10 or later. On Windows 7 and 8 the installer stops with "PhotoCraft needs Windows 10 or later." + +1. Open the [latest release](https://github.com/storytold/photocraft/releases/latest). +2. Download `photocraft--windows-x64.msi`. It fits most PCs (64-bit Intel and AMD). +3. Run the file and follow the installer. + +To run without installing, download `photocraft--windows-x64-portable.zip` instead and unzip it. + +| Your PC | Installer | Portable | +|------------------------------------|------------------------------------------|---------------------------------------------------| +| 64-bit Intel or AMD | `photocraft--windows-x64.msi` | `photocraft--windows-x64-portable.zip` | +| Arm (Snapdragon and other Arm PCs) | `photocraft--windows-arm64.msi` | `photocraft--windows-arm64-portable.zip` | +| 32-bit | `photocraft--windows-x86.msi` | `photocraft--windows-x86-portable.zip` | + +The installers and executables are code-signed. + +## macOS + +1. Download `photocraft--macos-universal.dmg`. It works on Apple silicon and Intel Macs, and is signed and notarized by Apple. +2. Open it and drag PhotoCraft to Applications. + +## Linux + +Pick one file from the release. Use `x86_64` for Intel and AMD, or `aarch64` for Arm. + +| Format | File | Notes | +|---------------------|----------------------------------------------|------------------------------------------------------------------------------------| +| AppImage | `photocraft--linux-x86_64.AppImage` | Runs anywhere and needs no install. | +| Debian and Ubuntu | `photocraft--linux-x86_64.deb` | | +| Fedora and openSUSE | `photocraft--linux-x86_64.rpm` | | +| Flatpak | `photocraft--linux-x86_64.flatpak` | Run `flatpak install --user `, then `flatpak run ai.storyteller.photocraft`. | +| Tarball | `photocraft--linux-x86_64.tar.gz` | Unpack it anywhere. | + +The first time you run the AppImage, it adds its icon and menu entry under `~/.local/share`. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that. For RISC-V, take the `riscv64` file: `photocraft--linux-riscv64.tar.gz`, `.deb` or `.rpm`. The RISC-V tarball needs glibc 2.39 or newer. + +On a Wayland session, files dropped on the PhotoCraft window do not open yet. Use **File › Open…** instead, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. PhotoCraft shows the right command for your install in a notice until you dismiss it. + +## FreeBSD and the web + +FreeBSD 14 (x86_64) has a tarball, `photocraft--freebsd-x86_64.tar.gz`, laid out like `/usr/local`. The web version comes as `photocraft-web-.zip`, a static site you host on any web server. It runs in a modern browser. + +## Check your download + +`SHA256SUMS.txt` on the release lists a checksum for every file. + +## Next step + +Open an image: [Open and save files](../guides/open-and-save-files.md). diff --git a/docs/user/guides/add-and-style-text.md b/docs/user/guides/add-and-style-text.md new file mode 100644 index 0000000000..71dcd7db97 --- /dev/null +++ b/docs/user/guides/add-and-style-text.md @@ -0,0 +1,36 @@ +--- +title: Add and style text +description: Add point or paragraph text with the Horizontal Type Tool in PhotoCraft, then give it a drop shadow or glow. +order: 40 +verified: 0.8.2 +--- + +Pick the **Horizontal Type Tool** (shortcut `T`), click on the canvas for a line of text or drag for a text box, and type. Text stays editable, so you can change the words and the style later. + +## Add text + +1. Choose the **Horizontal Type Tool** in the toolbox, or press `T`. +2. Click the canvas to start a line of text (point text), or drag a rectangle to start a block of text (paragraph text). +3. Type. The text is edited right on the canvas. +4. Set the font, size, leading, tracking, alignment and color with the character and paragraph controls. Each typing session is one **Edit Type** step in the **History** panel. + +To switch between the two kinds later, use **Type › Convert to Paragraph Text** or **Type › Convert to Point Text**. + +## Edit the text later + +Pick the **Horizontal Type Tool** and click the text on the canvas. Text from a PSD that was made in Photoshop stays editable too. If PSD text jumps when you start editing it, update to 0.8.2; see [Text moves when I start editing a PSD text layer](../troubleshooting/text-moves-when-editing.md). + +To start new text on top of an existing text layer instead of editing it, hold `Shift` while you click. + +## Add a layer style + +1. Click the text layer in the **Layers** panel. +2. Choose **Layer › Layer Style**, then an effect such as **Drop Shadow…**, **Outer Glow…**, **Stroke…** or **Bevel & Emboss…**. The **Layer Style** dialog opens. +3. Adjust the effect and confirm. + +The other effects in the menu are **Inner Shadow…**, **Inner Glow…**, **Satin…**, **Color Overlay…**, **Gradient Overlay…** and **Pattern Overlay…**. **Layer › Layer Style** also has **Copy Layer Style** and **Paste Layer Style** to reuse a style on another layer, and **Hide All Effects**. + +## Turn text into shapes or pixels + +- **Type › Convert to Shape** makes vector shapes from the letters. +- **Type › Rasterize Type Layer** turns the text into pixels. After that you can no longer edit the words. diff --git a/docs/user/guides/adjust-colors-with-adjustment-layers.md b/docs/user/guides/adjust-colors-with-adjustment-layers.md new file mode 100644 index 0000000000..500ff3521c --- /dev/null +++ b/docs/user/guides/adjust-colors-with-adjustment-layers.md @@ -0,0 +1,32 @@ +--- +title: Adjust colors without changing your pixels +description: Add a Curves, Levels or other adjustment layer in PhotoCraft so you can change or switch it off later. +order: 20 +verified: 0.8.2 +--- + +Use **Layer › New Adjustment Layer** and pick an adjustment. The change sits on its own layer above your image, so your original pixels stay as they were and you can edit or hide the adjustment later. + +## Add an adjustment layer + +1. Open your image and click the layer the adjustment should sit above in the **Layers** panel. +2. Choose **Layer › New Adjustment Layer**, then an adjustment such as **Curves…**, **Levels…**, **Vibrance…** or **Hue/Saturation…**. +3. Change the settings in the **Properties** panel, which shows the settings of the new layer. + +The same list is behind the button at the bottom of the **Layers** panel, labelled **Create new fill or adjustment layer** when you hover over it. + +The adjustments in the menu are **Brightness/Contrast**, **Levels**, **Curves**, **Exposure**, **Vibrance**, **Hue/Saturation**, **Color Balance**, **Black & White**, **Photo Filter**, **Channel Mixer**, **Color Lookup**, **Invert**, **Posterize**, **Threshold**, **Gradient Map** and **Selective Color**. + +## Limit it to one area + +1. Select the area first, for example with the **Elliptical Marquee Tool**. See [Select part of an image and mask it](select-and-mask.md). +2. Add the adjustment layer as above. +3. With the adjustment layer active, choose **Layer › Layer Mask › Reveal Selection**. The adjustment now shows only inside the selection. + +## Change it later + +Click the adjustment layer in the **Layers** panel and edit its settings in **Properties**. Every step you take is also listed in the **History** panel. + +## Apply an adjustment straight to the pixels + +If you do not need to change it later, use **Image › Adjustments**, for example **Image › Adjustments › Curves…**. These change the pixels of the layer directly and are not kept as a separate layer. diff --git a/docs/user/guides/export-an-image.md b/docs/user/guides/export-an-image.md new file mode 100644 index 0000000000..82d5c5a4e4 --- /dev/null +++ b/docs/user/guides/export-an-image.md @@ -0,0 +1,38 @@ +--- +title: Export an image +description: Export a PNG, JPG, WebP, TIFF or TGA from PhotoCraft with a preview, a size estimate and a scale. +order: 50 +verified: 0.8.2 +--- + +Choose **File › Export › Export As…**, set the format, and click **Export**. The dialog shows a preview and an estimate of the file size before you save. Your layered document is not changed. + +## Export with settings + +1. Choose **File › Export › Export As…**. +2. Under **File Settings**, pick a **Format**: **PNG**, **JPG**, **WebP**, **TIFF** or **TGA**. +3. Set the options for that format: + - **JPG** and lossy **WebP** have a **Quality** slider from 1 to 100. + - **WebP** also has a **Lossless** checkbox. + - **TGA** has **Resolution**: **Auto**, **24 bits/pixel** or **32 bits/pixel**. + - **Transparency** keeps transparent areas. JPG has no transparency, so this checkbox is not shown for JPG and the image is flattened. + - **Metadata** is **None** or **All**. +4. Under **Image Size**, set **Scale** in percent. The size in pixels shows below it. +5. Check the preview on the right. The line under it shows the format and the estimated file size. +6. Click **Export**, then choose a name and a place for the file. + +PhotoCraft shows "Exported" with the file path and size in the status bar when it is done. + +## Export in one step + +**File › Export › Quick Export as PNG** exports without asking about settings. It uses the format, quality, metadata and location you set in **File › Export › Export Preferences…**. + +## Export layers + +Choose **Layer › Export As…** (or **Layer › Quick Export as PNG**) to export only the active layer. + +With several layers or artboards selected in the **Layers** panel, both commands export each one as its own file. You choose a name and place once. The first file gets that name, and the others get the same name with a number and the layer name added. + +## Keep your layers + +Exporting makes a flat copy. To keep the layers, use **File › Save As…** with **PSD Document** or **PhotoCraft**; see [Open and save files](open-and-save-files.md). diff --git a/docs/user/guides/index.md b/docs/user/guides/index.md new file mode 100644 index 0000000000..a493dc2c8a --- /dev/null +++ b/docs/user/guides/index.md @@ -0,0 +1,14 @@ +--- +title: Guides +description: Step-by-step guides for the everyday tasks in PhotoCraft. +order: 20 +verified: 0.8.2 +--- + +- [Open and save files](open-and-save-files.md) +- [Adjust colors without changing your pixels](adjust-colors-with-adjustment-layers.md) +- [Select part of an image and mask it](select-and-mask.md) +- [Add and style text](add-and-style-text.md) +- [Export an image](export-an-image.md) + +Menu paths are written like **File › Open…**. Where a shortcut is shown, `Cmd` is the Ctrl key on Windows and Linux. diff --git a/docs/user/guides/open-and-save-files.md b/docs/user/guides/open-and-save-files.md new file mode 100644 index 0000000000..0c2b4306af --- /dev/null +++ b/docs/user/guides/open-and-save-files.md @@ -0,0 +1,32 @@ +--- +title: Open and save files +description: Open a PSD or other image in PhotoCraft, and save it with its layers or as a flat copy. +order: 10 +verified: 0.8.2 +--- + +Open a file with **File › Open…** and keep your layers with **File › Save As…**, choosing **PSD Document** or **PhotoCraft** as the type. Formats such as PNG and JPEG are flat: they hold one image with no layers. + +## Open a file + +1. Choose **File › Open…**. +2. Pick your file. The type menu in the dialog offers **All Formats**, **PhotoCraft**, **OpenRaster**, **Krita**, **SVG** and **Paint.NET**. +3. Confirm in the dialog. + +You can also reopen a recent file from **File › Open Recent**. On Linux under Wayland, dropping a file on the window does not work yet; see [Install PhotoCraft](../getting-started/install.md). + +PhotoCraft opens PSD and PSB (including 16-bit, 32-bit, CMYK and Lab files), layered TIFF, Paint.NET (`.pdn`), OpenRaster (`.ora`), SVG, and the flat formats PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, QOI, PNM, PCX, OpenEXR, Radiance HDR and FITS. Krita files (`.kra`) open as one image layer made from the picture Krita saved, without Krita's layers. HEIC and HEIF photos from iPhone and Mac open too, read only, and so do camera raw files such as DNG, CR2, CR3, NEF and ARW. It can also open Affinity files (`.af`, `.afphoto`, `.afdesign`, `.afpub`), read only; PhotoCraft cannot save Affinity files. Anything it cannot match exactly, such as layer effects and adjustments, is approximated or left out and PhotoCraft shows a warning. + +## Save with layers + +1. Choose **File › Save As…**. +2. Pick **PSD Document** (`.psd` or `.psb`) to share the file with Photoshop, or **PhotoCraft** (`.pcraft`) for PhotoCraft's own format. +3. Name the file and save. + +After that, **File › Save** saves into the same file. **File › Save a Copy…** saves a copy under a new name. While a large file saves, the status bar and the tab title show "Saving" with a percentage. + +The **Save As…** dialog also lists OpenRaster, PNG, JPEG, WebP, TIFF, Targa, OpenEXR and FITS. **File › Save** on an opened JPEG keeps its quality, and on a WebP keeps it lossy or lossless. Check the file extension in the name box: PhotoCraft writes the format that the extension names. If you want a flat PNG or JPEG with a size and quality preview, use [Export an image](export-an-image.md) instead. + +## Good to know + +Saving a PSD again keeps the layers and the rendered result, but the file is not byte for byte the same as the one you opened. PhotoCraft rewrites image resources, layer records and the composite. It keeps parts it does not model yet instead of dropping them. diff --git a/docs/user/guides/select-and-mask.md b/docs/user/guides/select-and-mask.md new file mode 100644 index 0000000000..50f78b7685 --- /dev/null +++ b/docs/user/guides/select-and-mask.md @@ -0,0 +1,40 @@ +--- +title: Select part of an image and mask it +description: Make a selection with the marquee tools or Select Subject, soften its edge, and turn it into a layer mask in PhotoCraft. +order: 30 +verified: 0.8.2 +--- + +Pick a selection tool, drag around the area, then choose **Layer › Layer Mask › Reveal Selection** to show only that area. A mask hides part of a layer without erasing any pixels. + +## Make a selection + +Use one of these: + +- **Elliptical Marquee Tool** or **Rectangular Marquee Tool** for simple shapes. Drag on the canvas. +- **Quick Selection Tool**, **Magic Wand Tool** or **Lasso Tool** to follow the edges of an object. +- **Select › Subject** to let PhotoCraft pick the main subject. The **Quick Selection Tool** and **Object Selection Tool** also have a **Select Subject** button in the options bar. If nothing is found, PhotoCraft says "No subject found" and suggests the Object Selection or Lasso tool. + +Smart selection runs on your computer. It needs no account and no cloud. + +## Adjust the selection + +- **Select › Inverse Selection** swaps the selected and unselected areas. +- **Select › Modify** has **Border…**, **Smooth…**, **Expand…**, **Contract…** and **Feather…**. Feather softens the edge. +- **Select › Select and Mask…** refines an edge, for example around hair. +- **Select › Deselect** removes the selection. **Select › Reselect** brings back the last one. + +## Turn the selection into a mask + +1. Click the layer you want to mask in the **Layers** panel. +2. Choose **Layer › Layer Mask › Reveal Selection** to show only the selected area, or **Hide Selection** to hide it. + +To start with the whole layer visible or hidden and paint the mask yourself, use **Layer › Layer Mask › Reveal All** or **Hide All**. + +## Remove or apply a mask + +- **Layer › Layer Mask › Delete** removes the mask. +- **Layer › Layer Mask › Apply** applies the mask to the layer's pixels. +- **Layer › Layer Mask › Disable Layer Mask** (or **Enable Layer Mask**) switches the mask off and on. + +To limit a color adjustment to the selection, see [Adjust colors without changing your pixels](adjust-colors-with-adjustment-layers.md). diff --git a/docs/user/index.md b/docs/user/index.md new file mode 100644 index 0000000000..29e229c0bd --- /dev/null +++ b/docs/user/index.md @@ -0,0 +1,25 @@ +--- +title: PhotoCraft +description: PhotoCraft is a free, open-source image editor with layers, masks, type and real PSD files, built as an alternative to Adobe Photoshop. +verified: 0.8.2 +--- + +PhotoCraft is a free, open-source image editor with layers, masks, adjustment layers, layer styles, type, vector shapes and brushes. It is an alternative to Adobe Photoshop: the menus, panels, tools and shortcuts follow Photoshop's layout, so Photoshop users find things where they expect them. + +It is a native app written in Rust that draws the canvas on your graphics card, works offline, and opens and saves layered PSD files. It runs on macOS, Windows, Linux and FreeBSD, and a web version runs in the browser. PhotoCraft is in early alpha: much of Photoshop's feature surface exists, but it is not yet a Photoshop replacement for daily professional work. See [Can PhotoCraft replace Photoshop?](faq/replace-photoshop.md). + +## Start here + +- [Install PhotoCraft](getting-started/install.md) +- [Your first edit](getting-started/index.md) +- [Guides: open, adjust, select, type, export](guides/index.md) +- [Troubleshooting](troubleshooting/index.md) +- [FAQ](faq/index.md) + +## Part of the Craft App Suite + +PhotoCraft is one of the Crafting Apps from the ArtCraft team, next to VectorCraft, FilmCraft and the other Craft apps. You can install and update them from [ArtCraft Launcher](https://getartcraft.com/docs/craft-launcher). + +## Get help + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/photocraft/issues). In PhotoCraft, **Help › Report an Issue…** opens the same issue tracker. diff --git a/docs/user/troubleshooting/get-help.md b/docs/user/troubleshooting/get-help.md new file mode 100644 index 0000000000..ee8bc860ee --- /dev/null +++ b/docs/user/troubleshooting/get-help.md @@ -0,0 +1,19 @@ +--- +title: Get help with a problem +description: How to ask for help or file a PhotoCraft bug so it can be fixed, and what to include. +order: 30 +verified: 0.8.2 +--- + +Open **Help › Report an Issue…**. It takes you to the [PhotoCraft issue tracker](https://github.com/storytold/photocraft/issues) on GitHub. You can also ask in [Discord](https://discord.gg/artcraft). + +## What to include + +1. What you did, step by step, and what you expected. +2. What happened instead. A screenshot or a short video helps. +3. Your system details. Choose **Help › System Info…**, click **Copy**, and paste the text into your message. It lists the PhotoCraft version, your operating system and processor, and your graphics adapter. +4. The file, if one file causes it. Zip a PSD or other file and attach it. Leave it out if it contains anything private. + +## Before you post + +Search the tracker for your problem first. If someone already reported it, add your details there instead of opening a new issue. For known problems, see the other pages in [Troubleshooting](index.md). diff --git a/docs/user/troubleshooting/index.md b/docs/user/troubleshooting/index.md new file mode 100644 index 0000000000..4785ae412b --- /dev/null +++ b/docs/user/troubleshooting/index.md @@ -0,0 +1,11 @@ +--- +title: Troubleshooting +description: Fixes and workarounds for known PhotoCraft problems. +order: 30 +verified: 0.8.2 +--- + +- [PhotoCraft does not start](photocraft-does-not-start.md) +- [The Windows taskbar turns black when PhotoCraft is maximized](taskbar-turns-black.md) +- [Text moves when I start editing a PSD text layer](text-moves-when-editing.md) +- [Get help with a problem](get-help.md) diff --git a/docs/user/troubleshooting/photocraft-does-not-start.md b/docs/user/troubleshooting/photocraft-does-not-start.md new file mode 100644 index 0000000000..549f6bd65d --- /dev/null +++ b/docs/user/troubleshooting/photocraft-does-not-start.md @@ -0,0 +1,58 @@ +--- +title: PhotoCraft does not start +description: PhotoCraft closes or crashes at startup on Windows PCs with Intel graphics (it uses DirectX 12 and picks a safer graphics mode after a crash) or on older Intel Macs (0.8.2 fixes one cause; no fix yet for Macs patched with OpenCore Legacy Patcher). +order: 30 +verified: 0.8.2 +--- + +On Windows, PhotoCraft starts Intel graphics on DirectX 12. If PhotoCraft closed while it was starting, start it again: it picks a safer graphics mode after a crash. On an older Intel Mac that shows a `Device(Lost)` error, update to 0.8.2, which fixes one cause; on Macs patched with OpenCore Legacy Patcher there is no fix yet. + +## Symptoms + +- Windows with Intel graphics (for example Intel UHD Graphics 630): PhotoCraft closes or crashes right after you start it. Windows Event Viewer shows an application error in `igvk64.dll` with exception `0xc0000005` ([issue 2320](https://github.com/storytold/photocraft/issues/2320), [issue 4](https://github.com/storytold/photocraft/issues/4)). +- Intel Mac: the app icon bounces in the Dock and the app disappears. Started from Terminal, it prints `Error: Wgpu(RequestDeviceError(RequestDeviceError { inner: Core(Device(Lost)) }))` ([issue 1859](https://github.com/storytold/photocraft/issues/1859)). + +## Fix + +### Windows + +1. Use PhotoCraft 0.7.0 or later. Older builds do not have the steps below. +2. Start PhotoCraft again. Double-clicking its icon or a `.pcraft` file works. PhotoCraft notes that the last start did not finish and uses the next safer graphics mode this time: + - A start on Vulkan is followed by one on DirectX 12. + - A start on DirectX 12 is followed by one on the CPU. The CPU mode does not depend on the graphics driver. +3. If it started, nothing more to do. PhotoCraft remembers the mode that worked. You can see or reset it under **Edit › Preferences › Performance…** (on macOS, **PhotoCraft › Settings › Performance…**). + +If PhotoCraft still closes on every start, force DirectX 12 from PowerShell: + +1. Open PowerShell in the folder that contains `photocraft.exe`. +2. Run these three lines: + + $env:WGPU_BACKEND = "dx12" + & .\photocraft.exe + Remove-Item Env:WGPU_BACKEND + +The last line clears the setting so later launches use the default again. You need the first two lines each time you start PhotoCraft this way. While `WGPU_BACKEND` is set, PhotoCraft uses it and does not step down to a safer mode by itself ([issue 2320](https://github.com/storytold/photocraft/issues/2320)). + +### Intel Mac + +1. Update to PhotoCraft 0.8.2 or later. On Intel Macs it skips a set of graphics checks that macOS could not compile on older Intel graphics, such as Intel Iris Pro on macOS Monterey ([issue 3107](https://github.com/storytold/photocraft/issues/3107)). +2. Start PhotoCraft again. + +If it still fails, there is no fix yet. This is the case reported on Macs patched with OpenCore Legacy Patcher, where setting `WGPU_BACKEND`, `--safe-gpu` and `PHOTOCRAFT_CPU_CANVAS=1` did not change the error ([issue 1859](https://github.com/storytold/photocraft/issues/1859)). Add your Mac model, macOS version and PhotoCraft version to that issue. + +## Why this happens + +On Windows, PhotoCraft draws with your graphics driver. Intel's Vulkan driver (`igvk64.dll`) crashed PhotoCraft on a UHD 630 PC with PhotoCraft 0.6.0, and the DirectX 12 setting above avoided the crash ([issue 2320](https://github.com/storytold/photocraft/issues/2320)). Since 0.7.0 PhotoCraft prefers DirectX 12 on Intel graphics. Before it opens the window, it writes a small file called `gpu-starting.json` in its settings folder and deletes it once the first frames are drawn. A start that crashed leaves the file behind, and the next start reads it and moves to the next safer mode. + +## Still stuck? + +See [Get help with a problem](get-help.md). Say which graphics card you have, which PhotoCraft version you use and how you started it. Add one of these: + +- If you can open PhotoCraft: the text from **Help › System Info…**. +- If PhotoCraft does not open at all: the log file `photocraft.log`, plus the faulting module and exception code from Event Viewer on Windows. PhotoCraft keeps it in a `logs` folder inside its settings folder: + - Windows: `%APPDATA%\Photocraft\logs\photocraft.log` + - macOS: `~/Library/Application Support/Photocraft/logs/photocraft.log` + - Linux: `~/.config/photocraft/logs/photocraft.log` + - Portable copy (a `portable.txt` file next to the program): the `PhotoCraftData\logs` folder next to the program. + + Every start moves the previous log to `photocraft.1.log`. If you already started PhotoCraft again after the crash, attach `photocraft.1.log` too, because it holds the run that crashed. diff --git a/docs/user/troubleshooting/taskbar-turns-black.md b/docs/user/troubleshooting/taskbar-turns-black.md new file mode 100644 index 0000000000..0c8f5b733e --- /dev/null +++ b/docs/user/troubleshooting/taskbar-turns-black.md @@ -0,0 +1,29 @@ +--- +title: The Windows taskbar turns black when PhotoCraft is maximized +description: On Windows 11, PhotoCraft 0.7.0 can turn the taskbar black or hide it when maximized. PhotoCraft 0.8.2 fixes this; update. +order: 10 +verified: 0.8.2 +--- + +Update PhotoCraft. Version 0.7.0 on Windows 11 could turn the taskbar black or hide it while the window was maximized. PhotoCraft 0.8.2 includes the fix. + +## Symptoms + +- You maximize the PhotoCraft window, or switch it to full screen, and the Windows taskbar turns completely black or is hidden. +- After you resize the window and maximize it again, a black bar the height of the taskbar stays at the bottom of the screen. + +## Fix + +1. Check your version with **Help › System Info…**, which starts with "PhotoCraft" and the version. +2. If it is older than 0.8.2, download the [latest release](https://github.com/storytold/photocraft/releases/latest) or update with [ArtCraft Launcher](https://getartcraft.com/docs/craft-launcher). See [Install PhotoCraft](../getting-started/install.md). +3. Start PhotoCraft again and maximize the window. + +A user who saw the black taskbar on 0.7.0 confirmed that it is gone in 0.8.2 ([issue 3864](https://github.com/storytold/photocraft/issues/3864)). + +## Why this happens + +PhotoCraft draws its own title bar. When such a window was maximized, Windows sized it to cover the whole screen, taskbar included, took it for a full-screen app and dropped the taskbar's background. The fix keeps a maximized PhotoCraft window clear of the taskbar ([issue 3144](https://github.com/storytold/photocraft/issues/3144), [pull request 3308](https://github.com/storytold/photocraft/pull/3308)). + +## Still stuck? + +If you still see it on 0.8.2, add your Windows version and PhotoCraft version to [issue 3864](https://github.com/storytold/photocraft/issues/3864), or ask in [Discord](https://discord.gg/artcraft). As a test, you can switch on **System title bar** under **Edit › Preferences › Interface…**, click **Apply** and restart PhotoCraft; Windows then draws the title bar. See [Get help with a problem](get-help.md) for what to include. diff --git a/docs/user/troubleshooting/text-moves-when-editing.md b/docs/user/troubleshooting/text-moves-when-editing.md new file mode 100644 index 0000000000..7130c8c7cf --- /dev/null +++ b/docs/user/troubleshooting/text-moves-when-editing.md @@ -0,0 +1,29 @@ +--- +title: Text moves when I start editing a PSD text layer +description: In PhotoCraft 0.7.0, a text layer from a Photoshop file can jump when you click it with the Type tool. PhotoCraft 0.8.2 fixes the known causes; update. +order: 20 +verified: 0.8.2 +--- + +Update PhotoCraft. In version 0.7.0, a text layer from a Photoshop file could jump or disappear when you started editing it. PhotoCraft 0.8.2 reads the text's styles and position the way Photoshop saved them, so the text stays where it was. + +## Symptoms + +- You open a PSD that was made in Photoshop and click one of its text layers with the **Horizontal Type Tool**. The text shifts, often to the left, as soon as editing starts. +- Centered text may show as left-aligned in the options bar. +- In some files, text that you click can disappear. + +## Fix + +1. Check your version with **Help › System Info…**. If it is older than 0.8.2, download the [latest release](https://github.com/storytold/photocraft/releases/latest) or update with [ArtCraft Launcher](https://getartcraft.com/docs/craft-launcher). +2. Open the PSD again and click the text layer with the **Horizontal Type Tool**. + +If you already saved the file in 0.7.0 after the text moved, the moved text is now in the file. Go back to your original PSD if you still have it. + +## Why this happens + +Clicking a PSD text layer with the Type tool starts editing it, and PhotoCraft then lays the text out itself instead of showing the pixels Photoshop saved. PhotoCraft 0.7.0 read the alignment and size of each run of text only from Photoshop's default styles, and ignored part of the text position that recent Photoshop versions store. Both are fixed in 0.8.2 ([issue 3795](https://github.com/storytold/photocraft/issues/3795), [pull request 3819](https://github.com/storytold/photocraft/pull/3819)). + +## Still stuck? + +If text still moves in 0.8.2, keep a copy of the original with **File › Save a Copy…**, then report it in the [issue tracker](https://github.com/storytold/photocraft/issues) with the PSD (zipped) and the Photoshop version that saved it. You can also ask in [Discord](https://discord.gg/artcraft). See [Get help with a problem](get-help.md). diff --git a/packaging/linux/AppRun b/packaging/linux/AppRun index f04c5f4eab..b0c58d9d31 100755 --- a/packaging/linux/AppRun +++ b/packaging/linux/AppRun @@ -35,25 +35,34 @@ integrate() { src_desktop="$APPDIR/usr/share/applications/$DESK_ID.desktop" [ -f "$src_desktop" ] || return 0 - # If an external AppImage integrator (AppImageLauncher, appimaged, Gear Lever) is - # already managing this AppImage, do not write a second launcher (#3623); remove our - # own if left over from an earlier run, and still install the hicolor icons below. + # If another integrator already registered a .desktop whose Exec points at this + # AppImage, do not write a second launcher (#3623); remove our own if one is left + # over, and still install the hicolor icons below. $DESKTOPINTEGRATION is not that + # signal: PhotoCraft sets X-AppImage-Integrate=false, so AppImageLauncher skips the + # AppImage and still exports the variable on its binfmt bypass. Treating it as + # "already integrated" left the menu with no entry (#5534). apps="$data/applications" mkdir -p "$apps" 2>/dev/null || return 0 external=0 - if [ -n "${DESKTOPINTEGRATION:-}" ]; then - external=1 - else - for f in "$apps"/*.desktop; do - if [ ! -f "$f" ] || [ "$f" = "$apps/$DESK_ID.desktop" ]; then - continue - fi - if grep '^Exec=' "$f" 2>/dev/null | grep -Fq "$appimage"; then - external=1 - break - fi - done - fi + for f in "$apps"/*.desktop; do + if [ ! -f "$f" ] || [ "$f" = "$apps/$DESK_ID.desktop" ]; then + continue + fi + if grep '^Exec=' "$f" 2>/dev/null | grep -Fq "$appimage"; then + external=1 + 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 475fb5ee5e..18f920560b 100755 --- a/packaging/linux/apprun-test.sh +++ b/packaging/linux/apprun-test.sh @@ -83,11 +83,27 @@ printf '[Desktop Entry]\nType=Application\nName=PhotoCraft\nExec="%s" %%F\nIcon= [ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons missing with external integrator" rm -f "$ext_desktop" -# 7. DESKTOPINTEGRATION set by an external integrator skips creating a second .desktop entry. +# 7. AppImageLauncher exports DESKTOPINTEGRATION even after X-AppImage-Integrate=false +# made it skip the AppImage. That is not an existing launcher, so AppRun still +# writes its own entry and a later launch must not delete it (#5534). rm -rf "$XDG_DATA_HOME" DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "DESKTOPINTEGRATION launch exited non-zero" -[ ! -e "$DESKTOP" ] || fail "DESKTOPINTEGRATION still wrote duplicate desktop entry" +[ -f "$DESKTOP" ] || fail "DESKTOPINTEGRATION suppressed the desktop entry (#5534)" +grep -Fxq "Exec=\"$(readlink -f "$APPIMAGE")\" %F" "$DESKTOP" || fail "Exec missing under DESKTOPINTEGRATION" +grep -Fxq "X-AppImage-Integrate=false" "$DESKTOP" || fail "X-AppImage-Integrate=false missing under DESKTOPINTEGRATION" [ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons not installed under DESKTOPINTEGRATION" +before="$(stat -c %i "$DESKTOP")" +DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "second DESKTOPINTEGRATION launch exited non-zero" +[ -f "$DESKTOP" ] || fail "DESKTOPINTEGRATION removed the desktop entry on a later launch" +[ "$before" = "$(stat -c %i "$DESKTOP")" ] || fail "desktop entry rewritten under DESKTOPINTEGRATION although unchanged" +# A real integrator entry still wins, including when DESKTOPINTEGRATION is also set (#3623). +ext_desktop="$XDG_DATA_HOME/applications/appimagekit-photocraft.desktop" +printf '[Desktop Entry]\nType=Application\nName=PhotoCraft\nExec="%s" %%F\nIcon=%s\n' "$(readlink -f "$APPIMAGE")" "$APP_ID" >"$ext_desktop" +DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "launch with integrator entry and DESKTOPINTEGRATION exited non-zero" +[ ! -e "$DESKTOP" ] || fail "duplicate desktop entry kept when an integrator entry exists" +[ -f "$ext_desktop" ] || fail "external integrator desktop entry removed" +[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons missing when DESKTOPINTEGRATION and an integrator entry coexist" +rm -f "$ext_desktop" # 8. Opt-out installs nothing. rm -rf "$XDG_DATA_HOME" @@ -113,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" 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/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 @@ - diff --git a/packaging/web/.htaccess b/packaging/web/.htaccess index 7b7e6e185f..4aca774cc8 100644 --- a/packaging/web/.htaccess +++ b/packaging/web/.htaccess @@ -13,4 +13,12 @@ AddType text/javascript .js Header set Cache-Control "no-cache" + # app/: the app's loader and its list of parts keep their names from release to release; the + # parts (the app's wasm, gzipped and cut up) carry a content hash. + + Header set Cache-Control "no-cache" + + + Header set Cache-Control "public, max-age=31536000, immutable" + diff --git a/packaging/web/README.md b/packaging/web/README.md index 5ca375f6cc..e96dc38257 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -6,8 +6,10 @@ static site in `photocraft-web-/`: | File | What it is | |---|---| | `index.html` | The page. It loads everything through relative URLs. | -| `photocraft-web-.js` | wasm-bindgen glue (generated, ES module) | -| `photocraft-web-_bg.wasm` | The app: about 23 MiB raw, 10.5 MiB with gzip (see [Sizes](#sizes)) | +| `photocraft-web-boot-.js`, `photocraft-web-boot-_bg.wasm` | A small module (about 120 KB) that starts the app from its parts, with its wasm-bindgen glue | +| `app/photocraft-web-.part-aa`, `-ab`, … | The app's WebAssembly, gzipped and cut into parts of at most 20 MiB (see [Sizes](#sizes)) | +| `app/parts.txt` | The parts, in order | +| `app/photocraft-web.js` | The app's wasm-bindgen glue (generated, ES module) | | `_headers`, `.htaccess` | Sample header rules for Netlify/Cloudflare Pages and Apache | | `fonts/` (optional) | Fonts loaded on demand, listed in `fonts/manifest.txt` (see [Fonts](#fonts)) | @@ -68,44 +70,63 @@ The editor and image processing run on the visitor's device. Open uses the brows picker; Save and Export download files. Preferences use browser localStorage. There is no server-side document storage, desktop TCP control server, or web autosave/crash recovery; save/download work before closing or reloading the page. A browser with WebGPU or WebGL2 is -required; `?webgl` forces the fallback when troubleshooting. +required; `?webgl` forces the fallback when troubleshooting. In a browser that can't +inflate a stream itself (no `DecompressionStream`: before Safari 16.4 and Firefox 113), the +release zip's site inflates the app's parts in its own code, a little slower. To verify the browser deployment, open the page, open an image and save/export it. ## Sizes -Measured on 2026-10-12 (0.8.0, `packaging/web/package.sh`; gzip `-9`): +Measured on 2026-10-13 (0.8.2, `packaging/web/package.sh`): -| File | Raw | gzip | -|---|---|---| -| `photocraft-web-_bg.wasm` | 24,167,186 bytes (23.0 MiB) | 10,998,053 (10.5 MiB) | -| `photocraft-web-.js` | about 170 KB | about 26 KB | +| File | Size | +|---|---| +| `app/photocraft-web-.part-aa` (the only part today) | 11,401,527 bytes (10.9 MiB) | +| `app/photocraft-web.js` | about 170 KB (26 KB with gzip) | +| `photocraft-web-boot-_bg.wasm` and `.js` | about 120 KB together | + +The app's WebAssembly is 25,019,043 bytes (23.9 MiB) before gzip. Per-file upload limits: Cloudflare Pages and Workers static assets reject any file over -**25 MiB** (26,214,400 bytes). The wasm fits, and `packaging/web/package.sh` fails the build if -it ever grows past 24 MiB, so a release can't ship a file a common host refuses. (Releases up to -0.2.0 shipped a 25.8 MiB wasm, which Cloudflare rejected; issue #198.) The size comes from the -`wasm-release` Cargo profile (fat LTO, size-optimized code with the pixel and file-decoding -crates kept at speed) plus `wasm-opt -Oz`, and from storing the bundled fonts (PhotoCraft's and -egui's) and the translation catalogs compressed, unpacked on first use. +**25 MiB** (26,214,400 bytes), and the app's WebAssembly is at that size. (Releases up to 0.2.0 +shipped a 25.8 MiB wasm, which Cloudflare rejected; issue #198.) So the site doesn't hold it as +one file: packaging gzips it and cuts the result into parts of at most 20 MiB, and the page puts +them back together while it downloads them. `packaging/web/package.sh` fails if any file in the +site is over 24 MiB, so a release can't ship a file a common host refuses, and the app can grow +without reaching that limit (issue #5302). A build straight from Trunk (`trunk build --release` +in `apps/photocraft-web`, or the Docker image above) still has the app in one `.wasm`, for hosts +without such a cap. + +The app is built with the `wasm-release` Cargo profile (fat LTO, size-optimized code with the +pixel and file-decoding crates kept at speed) plus `wasm-opt -Oz`, and stores the bundled fonts +(PhotoCraft's and egui's) and the translation catalogs compressed, unpacked on first use. ## Any path works All URLs in `index.html` are relative (`public_url = "./"` in `apps/photocraft-web/Trunk.toml`), so the site works at a domain root (`https://example.com/`), under a prefix (`https://example.com/tools/photocraft/`) and from a CDN bucket. The asset names carry a content -hash, so they can be cached forever. Only `index.html` needs revalidation. +hash, so they can be cached forever. `index.html`, `app/photocraft-web.js` and `app/parts.txt` keep +their names and need revalidation. ## Required server settings - **MIME type:** serve `.wasm` as `application/wasm`. Browsers refuse to stream-compile it under any other type, and the app then loads slowly or not at all. Serve `.js` as `text/javascript`. Most hosts already do both. For nginx, check that `mime.types` has `application/wasm wasm;`. -- **Compression:** turn on gzip or Brotli for `.wasm`, `.js` and `.html`. That takes the - download from about 19 MiB to about 8 MiB (gzip) or 5.6 MiB (Brotli). You can also - precompress (`brotli -k *.wasm`) and let the server send `Content-Encoding: br`. -- **Caching:** `Cache-Control: public, max-age=31536000, immutable` on the hashed `.wasm` and - `.js` files, and `no-cache` on `index.html`. +- **The parts as they are:** serve `app/*.part-*` byte for byte, with no `Content-Encoding` (they + are gzip already, and the page inflates them). Their extension is one servers don't + know, so the usual result, `application/octet-stream` and no compression, is right. +- **Missing files are 404s:** a host that answers every unknown path with `index.html` (a + single-page-app fallback) hides a failed upload. PhotoCraft notices and says the download + failed, but a 404 is clearer in the server's logs. +- **Compression:** turn on gzip or Brotli for `.wasm`, `.js` and `.html`. It matters little for + the download, since the parts are compressed already; it shrinks the two `.js` files and the + small boot module. You can also precompress and let the server send `Content-Encoding: br`. +- **Caching:** `Cache-Control: public, max-age=31536000, immutable` on the files whose names + carry a hash (the parts and `photocraft-web-boot-.*`), and `no-cache` on `index.html`, + `app/photocraft-web.js` and `app/parts.txt`. The sample `_headers` and `.htaccess` do this. - **HTTPS:** WebGPU (and the clipboard) only work in a secure context, which means `https://` or `http://localhost`. Over plain HTTP elsewhere, the app falls back to WebGL2. - **No special isolation headers:** PhotoCraft doesn't use `SharedArrayBuffer`, so it doesn't @@ -120,8 +141,9 @@ location /photocraft/ { types { application/wasm wasm; text/javascript js; text/html html; } gzip on; gzip_types application/wasm text/javascript text/html; - location ~* \.(wasm|js)$ { add_header Cache-Control "public, max-age=31536000, immutable"; } - location ~* index\.html$ { add_header Cache-Control "no-cache"; } + # The first matching line wins, so the files that keep their names come first. + location ~* (index\.html|app/photocraft-web\.js|app/parts\.txt)$ { add_header Cache-Control "no-cache"; } + location ~* (\.(wasm|js)|\.part-[a-z]+)$ { add_header Cache-Control "public, max-age=31536000, immutable"; } } ``` diff --git a/packaging/web/_headers b/packaging/web/_headers index fac0fbe1fd..f8a7e7ca45 100644 --- a/packaging/web/_headers +++ b/packaging/web/_headers @@ -11,3 +11,16 @@ /index.html Cache-Control: no-cache + +# app/ holds the app's wasm, gzipped and cut into parts whose names carry a content hash... +/app/* + Cache-Control: public, max-age=31536000, immutable + +# ...and the app's loader and its list of parts, which keep their names from release to release. +/app/photocraft-web.js + ! Cache-Control + Cache-Control: no-cache + +/app/parts.txt + ! Cache-Control + Cache-Control: no-cache diff --git a/packaging/web/package.sh b/packaging/web/package.sh index 8c6c1f8e2c..58b9bccd07 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -6,6 +6,10 @@ # Needs: trunk (brew install trunk / cargo install trunk --locked) and the wasm32-unknown-unknown # target. The zip holds a self-contained static site in photocraft-web-/ that works # from any URL path and inside an