mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-25 18:37:10 -06:00
Merge main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+11
-2
@@ -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"
|
||||
|
||||
+166
-40
@@ -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,9 +58,15 @@ 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
|
||||
@@ -71,19 +79,25 @@ jobs:
|
||||
echo "web=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
|
||||
echo "os=$all" >> "$GITHUB_OUTPUT"
|
||||
echo "legs=$all" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT"
|
||||
echo "legs=[$linux]" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
test:
|
||||
name: test (${{ matrix.os }})
|
||||
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,121 @@ 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
|
||||
|
||||
- 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 >/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 +313,7 @@ jobs:
|
||||
run: cargo xtask test-corpus
|
||||
|
||||
wasm:
|
||||
timeout-minutes: 30
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -206,6 +330,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
|
||||
@@ -253,6 +378,7 @@ jobs:
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
fuzz:
|
||||
timeout-minutes: 60
|
||||
name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }})
|
||||
# Placeholder: manual trigger only until a nightly schedule is enabled.
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.fuzz
|
||||
|
||||
@@ -27,6 +27,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
timeout-minutes: 15
|
||||
name: mdBook build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Generated
+2
-2
@@ -6113,9 +6113,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
|
||||
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
|
||||
dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
+21
-5
@@ -75,9 +75,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 +136,24 @@ opt-level = "z"
|
||||
[profile.wasm-release.package.naga]
|
||||
opt-level = "z"
|
||||
|
||||
# Pixel processing keeps its speed-oriented optimization level.
|
||||
# Pixel processing keeps its speed-oriented optimization level, with two exceptions.
|
||||
# Colour-management transforms are built once and cached (`transform::cached`), so "z" costs
|
||||
# little runtime. The browser app composites per frame on the GPU (`crates/gpu`, WebGPU/WebGL2),
|
||||
# so the CPU compositor is not the browser's hot path there; "z" for compose keeps the wasm
|
||||
# under the 24 MiB gate (measured −122 KB vs opt-level 3). algo's filters run per pixel on
|
||||
# every interactive filter application; "z" there is the size lever of last resort (−331 KB),
|
||||
# taken because the gate is enforced in CI: if browser filters feel too slow, restore 3 here
|
||||
# and reclaim the bytes from a feature-gated decoder instead.
|
||||
[profile.wasm-release.package.photocraft-raster]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-color]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-cms]
|
||||
opt-level = 3
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-compose]
|
||||
opt-level = 3
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-algo]
|
||||
opt-level = 3
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-paint]
|
||||
opt-level = 3
|
||||
|
||||
|
||||
@@ -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<ControlRe
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, 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<ControlRequest>, 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<ControlRequest>, 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<ControlRequest>, 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::<ControlRequest>();
|
||||
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() {
|
||||
|
||||
@@ -443,6 +443,14 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless
|
||||
/// turned on), read before the window opens: it picks the surface's present mode.
|
||||
pub fn read_low_latency(path: Option<&Path>) -> bool {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
let default = photocraft_engine::prefs::Preferences::default().performance.low_latency_drawing;
|
||||
v.get("performance").and_then(|p| p.get("lowLatencyDrawing")).and_then(Value::as_bool).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Read the policy leniently at startup, including old settings without renderingMode.
|
||||
pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
@@ -463,6 +471,23 @@ mod tests {
|
||||
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_latency_drawing_is_read_before_the_window_opens_and_defaults_off() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-low-latency-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("preferences.json");
|
||||
let read = |json: &str| {
|
||||
std::fs::write(&file, json).unwrap();
|
||||
read_low_latency(Some(&file))
|
||||
};
|
||||
assert!(read(r#"{"performance":{"lowLatencyDrawing":true}}"#));
|
||||
assert!(!read(r#"{"performance":{"lowLatencyDrawing":false}}"#));
|
||||
assert!(!read(r#"{"performance":{"lowLatencyDrawing":"nope"}}"#), "garbage keeps the default (off)");
|
||||
assert!(!read("not json"));
|
||||
assert!(!read_low_latency(None), "no settings file: the default (off)");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_server_preference_is_read_before_the_window_opens() {
|
||||
use photocraft_engine::prefs::LinuxDisplayServer;
|
||||
|
||||
+130
-5
@@ -2,7 +2,17 @@
|
||||
//!
|
||||
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
|
||||
//! --control-token-file <path>] [--automation-read-root <dir>]
|
||||
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [files…]`
|
||||
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [--present-mode <mode>]
|
||||
//! [--frame-latency <n>] [--stroke-trace <stderr|file.csv>] [--new-instance] [files…]`
|
||||
//!
|
||||
//! On Windows and Linux a launch hands its files to the PhotoCraft that is already running (new
|
||||
//! tabs in its window) and exits; `--new-instance` starts a separate copy instead, as does any
|
||||
//! `--control` launch (see `single_instance`).
|
||||
//!
|
||||
//! `--present-mode` (`fifo`, `autovsync`, `novsync`) and `--frame-latency` (1–3) override how
|
||||
//! frames reach the display for this launch (`PHOTOCRAFT_PRESENT_MODE` / `…_FRAME_LATENCY`;
|
||||
//! `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 +54,15 @@ mod linux_libs;
|
||||
mod logging;
|
||||
mod monitor_profile;
|
||||
mod photoshop_settings;
|
||||
// Save As through the desktop portal, which reports the file type picked (#6121).
|
||||
#[cfg(target_os = "linux")]
|
||||
mod portal_save;
|
||||
mod presentation;
|
||||
mod screen_color;
|
||||
mod services;
|
||||
// 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;
|
||||
@@ -191,6 +208,10 @@ fn main() -> eframe::Result {
|
||||
// reported in a notice once the app is up; a control token is a usage error like a bad port.
|
||||
let mut unreadable_paths: Vec<String> = Vec::new();
|
||||
let mut safe_gpu = false;
|
||||
let mut new_instance = false;
|
||||
// Options that apply only to the process they're given to; a launch with any keeps its own.
|
||||
let mut launch_options: Vec<&'static str> = Vec::new();
|
||||
let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option<String>, Option<String>, Option<String>) = (None, None, None);
|
||||
let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
@@ -208,10 +229,40 @@ fn main() -> eframe::Result {
|
||||
None => control_token = None,
|
||||
},
|
||||
Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from),
|
||||
Some("--automation-read-root") => automation_read_root = args.next().map(std::path::PathBuf::from),
|
||||
Some("--automation-write-root") => automation_write_root = args.next().map(std::path::PathBuf::from),
|
||||
Some("--safe-gpu") => safe_gpu = true,
|
||||
Some("--in-window-menus") => in_window_menus = true,
|
||||
Some("--automation-read-root") => {
|
||||
launch_options.push("--automation-read-root");
|
||||
automation_read_root = args.next().map(std::path::PathBuf::from);
|
||||
}
|
||||
Some("--automation-write-root") => {
|
||||
launch_options.push("--automation-write-root");
|
||||
automation_write_root = args.next().map(std::path::PathBuf::from);
|
||||
}
|
||||
Some("--safe-gpu") => {
|
||||
launch_options.push("--safe-gpu");
|
||||
safe_gpu = true;
|
||||
}
|
||||
Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
|
||||
launch_options.push(match flag {
|
||||
"--present-mode" => "--present-mode",
|
||||
"--frame-latency" => "--frame-latency",
|
||||
_ => "--stroke-trace",
|
||||
});
|
||||
let flag = flag.to_string();
|
||||
match args.next().map(std::ffi::OsString::into_string) {
|
||||
Some(Ok(v)) => match flag.as_str() {
|
||||
"--present-mode" => present_mode_arg = Some(v),
|
||||
"--frame-latency" => frame_latency_arg = Some(v),
|
||||
_ => stroke_trace_arg = Some(v),
|
||||
},
|
||||
// A bad value is reported and ignored; it never fails the launch.
|
||||
_ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"),
|
||||
}
|
||||
}
|
||||
Some("--in-window-menus") => {
|
||||
launch_options.push("--in-window-menus");
|
||||
in_window_menus = true;
|
||||
}
|
||||
Some("--new-instance") => new_instance = true,
|
||||
Some("--version") => {
|
||||
println!("photocraft {}", photocraft_engine::build_info::long_version());
|
||||
return Ok(());
|
||||
@@ -243,6 +294,48 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
|
||||
// One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs
|
||||
// become tabs in its window. Only with the per-user settings folder (a portable copy's may be
|
||||
// shared between users), and not for automation, `--new-instance` or per-process options.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let single_instance = {
|
||||
let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok();
|
||||
let context = single_instance::LaunchContext {
|
||||
per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform,
|
||||
new_instance_flag: new_instance,
|
||||
new_instance_env: new_instance_env.as_deref(),
|
||||
control: control_port.is_some(),
|
||||
unreadable_paths: !unreadable_paths.is_empty(),
|
||||
launch_options: &launch_options,
|
||||
};
|
||||
match (single_instance::skip_reason(&context), services::config_dir()) {
|
||||
(Some(reason), _) => {
|
||||
log::info!("running as a separate PhotoCraft: {reason}");
|
||||
None
|
||||
}
|
||||
(None, None) => None,
|
||||
(None, Some(dir)) => {
|
||||
let paths: Vec<String> = 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")]
|
||||
@@ -364,6 +457,21 @@ fn main() -> eframe::Result {
|
||||
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
|
||||
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
|
||||
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
|
||||
// Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about
|
||||
// 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers.
|
||||
if gpu_startup::read_low_latency(services::prefs_file().as_deref()) {
|
||||
options.wgpu_options.surface.present_mode = eframe::wgpu::PresentMode::AutoNoVsync;
|
||||
}
|
||||
// Testers' present mode and frame latency overrides (#1343), after the preference.
|
||||
let present = presentation::Overrides::from_args_or_env(present_mode_arg.as_deref(), frame_latency_arg.as_deref());
|
||||
options.wgpu_options.surface = present.apply(options.wgpu_options.surface);
|
||||
let present_desc = presentation::describe(&options.wgpu_options.surface, &present);
|
||||
// Without vsync, frames are capped at 240 fps (#4472) so repaint loops don't spin flat out.
|
||||
let uncapped = !matches!(
|
||||
options.wgpu_options.surface.present_mode,
|
||||
eframe::wgpu::PresentMode::Fifo | eframe::wgpu::PresentMode::FifoRelaxed | eframe::wgpu::PresentMode::AutoVsync
|
||||
);
|
||||
log::info!("presentation: {present_desc}");
|
||||
let sentinel_ms = t_sentinel.elapsed().as_secs_f64() * 1000.0;
|
||||
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
|
||||
let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu;
|
||||
@@ -392,6 +500,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.min_frame_ms = uncapped.then_some(1000.0 / 240.0);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
// Only the title bar's free gap drags the window, never the menus (mac_window.rs).
|
||||
@@ -423,6 +532,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}")),
|
||||
@@ -485,6 +598,11 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = in_window_menus;
|
||||
// Files from later launches (Explorer, file managers) open here as new tabs.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if let Some(primary) = single_instance {
|
||||
app.services.os_events = Some(primary.connect(&cc.egui_ctx));
|
||||
}
|
||||
// Where file drags and drops are (winit 0.30 doesn't say).
|
||||
app.services.cursor_pos = cursor::service(cc);
|
||||
// The custom title bar's undecorated window must stay borderless, or its caption and
|
||||
@@ -522,6 +640,13 @@ fn main() -> eframe::Result {
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
);
|
||||
// The window is gone: refuse further files and free the lock, so a launch that is waiting (or
|
||||
// the CPU-compatibility relaunch below) becomes the primary instead of handing its files to a
|
||||
// process that is exiting.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if let Some(primary) = single_instance {
|
||||
primary.shutdown();
|
||||
}
|
||||
// Closed or failed outside graphics initialization: not a driver crash. A renderer error
|
||||
// keeps the marker, so the next start tries a safer backend, unless there is no safer one:
|
||||
// a failed CPU start would otherwise pin every later start to CPU.
|
||||
|
||||
@@ -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<String>);
|
||||
|
||||
/// A Save dialog to show.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SaveRequest {
|
||||
/// The portal's parent window identifier (`x11:<xid>`), 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<String>,
|
||||
/// 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<String>,
|
||||
pub folder: Option<PathBuf>,
|
||||
pub filters: Vec<Filter>,
|
||||
/// The filter the dialog starts on.
|
||||
pub current: Option<usize>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// The portal's parent window identifier for `window`: `x11:<xid>` on X11, empty elsewhere.
|
||||
pub fn parent_id(window: Option<eframe::wgpu::rwh::RawWindowHandle>) -> 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<Option<Saved>, 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<String, OwnedValue>) = 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::<Vec<_>>());
|
||||
if !req.filters.is_empty() {
|
||||
o.insert("filters", Value::from(req.filters.iter().map(wire).collect::<Vec<_>>()));
|
||||
}
|
||||
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<String, OwnedValue>) -> Result<Option<Saved>, String> {
|
||||
match code {
|
||||
0 => {}
|
||||
1 | 2 => return Ok(None),
|
||||
_ => return Err(format!("the save dialog failed (portal response {code})")),
|
||||
}
|
||||
let uris: Vec<String> = 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<PathBuf> {
|
||||
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<Option<PathBuf>, 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;
|
||||
@@ -0,0 +1,232 @@
|
||||
use super::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[test]
|
||||
fn file_uris_become_local_paths() {
|
||||
assert_eq!(file_uri_path("file:///home/me/Untitled-1.psd"), Some(PathBuf::from("/home/me/Untitled-1.psd")));
|
||||
assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.png"), Some(PathBuf::from("/home/me/A bé.png")));
|
||||
assert_eq!(file_uri_path("file://localhost/tmp/x.png"), Some(PathBuf::from("/tmp/x.png")));
|
||||
assert_eq!(file_uri_path("sftp://host/x.png"), None);
|
||||
assert_eq!(file_uri_path("file://host/x.png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x11_windows_are_named_to_the_portal() {
|
||||
use eframe::wgpu::rwh::{RawWindowHandle, XlibWindowHandle};
|
||||
assert_eq!(parent_id(Some(RawWindowHandle::Xlib(XlibWindowHandle::new(0x3a0000f)))), "x11:3a0000f");
|
||||
assert_eq!(parent_id(None), "");
|
||||
}
|
||||
|
||||
/// What the mock portal answers to one `SaveFile` call: the response code, and the file and
|
||||
/// filter label the user "picked".
|
||||
type Script = Vec<(u32, Option<(&'static str, &'static str)>)>;
|
||||
|
||||
/// The options of each `SaveFile` call the mock portal received: current_name and the label of
|
||||
/// current_filter.
|
||||
type Seen = Arc<Mutex<Vec<(Option<String>, Option<String>)>>>;
|
||||
|
||||
/// A private session bus (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser` that
|
||||
/// answers `SaveFile` from `script`. `None` when there is no `dbus-daemon` to run.
|
||||
fn mock_portal(script: Script) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
|
||||
mock_portal_with(script, false)
|
||||
}
|
||||
|
||||
/// [`mock_portal`]; with `own_paths` it is a portal older than 0.9, which ignores `handle_token`,
|
||||
/// answers on a request path of its own and returns that from `SaveFile`, as a real dialog does
|
||||
/// once the user is done with it (here after 300 ms).
|
||||
fn mock_portal_with(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
|
||||
use std::io::BufRead;
|
||||
let mut daemon =
|
||||
std::process::Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(std::process::Stdio::piped()).spawn().ok()?;
|
||||
let mut address = String::new();
|
||||
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
|
||||
let address = address.trim().to_string();
|
||||
let server = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap();
|
||||
let seen: Seen = Arc::default();
|
||||
let log = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut script = script.into_iter();
|
||||
for msg in zbus::blocking::MessageIterator::from(&server) {
|
||||
let Ok(msg) = msg else { break };
|
||||
let header = msg.header();
|
||||
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") {
|
||||
continue;
|
||||
}
|
||||
let (_parent, _title, options): (String, String, HashMap<String, OwnedValue>) = 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();
|
||||
}
|
||||
@@ -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<PresentMode>,
|
||||
pub latency: Option<u32>,
|
||||
}
|
||||
|
||||
/// 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<String>) {
|
||||
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::<u32>() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
+117
-14
@@ -95,18 +95,51 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
|
||||
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<String> {
|
||||
/// 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<String> {
|
||||
let path = Path::new(suggested);
|
||||
let name = path.file_name()?.to_string_lossy().into_owned();
|
||||
let (Some(stem), Some(ext)) = (path.file_stem(), path.extension().and_then(|e| e.to_str())) else { return Some(name) };
|
||||
let bare = windows && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase();
|
||||
let bare = adds_extension && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase();
|
||||
Some(if bare { stem.to_string_lossy().into_owned() } else { name })
|
||||
}
|
||||
|
||||
@@ -148,17 +181,52 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
|
||||
}
|
||||
FileDialogRequest::Save { suggested } => {
|
||||
for (name, exts) in save_filters(&suggested) {
|
||||
dialog = dialog.add_filter(name, &exts);
|
||||
let filters = save_filters(&suggested);
|
||||
let name = dialog_file_name(&suggested, cfg!(windows));
|
||||
let dir = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()).map(Path::to_path_buf);
|
||||
for (name, exts) in &filters {
|
||||
dialog = dialog.add_filter(name, exts);
|
||||
}
|
||||
if let Some(name) = dialog_file_name(&suggested, cfg!(windows)) {
|
||||
if let Some(name) = &name {
|
||||
dialog = dialog.set_file_name(name);
|
||||
}
|
||||
if let Some(dir) = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
if let Some(dir) = &dir {
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
|
||||
// On Linux the portal's own dialog, which reports the file type picked (#6121); rfd if
|
||||
// the portal can't show it.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use eframe::wgpu::rwh::HasWindowHandle;
|
||||
let parent = crate::portal_save::parent_id(parent.and_then(|p| p.window_handle().ok()).map(|h| h.as_raw()));
|
||||
let req = crate::portal_save::SaveRequest {
|
||||
parent,
|
||||
name: dialog_file_name(&suggested, true),
|
||||
suggested: Path::new(&suggested).file_name().map(|n| n.to_string_lossy().into_owned()),
|
||||
folder: dir,
|
||||
filters,
|
||||
current: Some(0),
|
||||
};
|
||||
Box::pin(async move {
|
||||
let portal = zbus::blocking::connection::Builder::session()
|
||||
.and_then(|b| b.build())
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|conn| crate::portal_save::save_as(&conn, req));
|
||||
match portal {
|
||||
Ok(path) => path.map(|p| FileDialogAnswer::SaveTo(p.to_string_lossy().into_owned())),
|
||||
Err(e) => {
|
||||
log::info!("portal save dialog unavailable ({e}), using rfd");
|
||||
dialog.save_file().await.map(|file| FileDialogAnswer::SaveTo(path_of(&file)))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = filters;
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
|
||||
}
|
||||
}
|
||||
};
|
||||
// Without the thread the reply is dropped unanswered, which the app takes as Cancel.
|
||||
@@ -585,6 +653,41 @@ mod tests {
|
||||
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// #6121: the file type picked in the save dialog gives the name its extension where the
|
||||
/// dialog left the one it opened with, and only there.
|
||||
#[test]
|
||||
fn the_picked_file_type_gives_the_name_its_extension() {
|
||||
let exts = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
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() {
|
||||
|
||||
@@ -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<String> {
|
||||
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<Option<File>>,
|
||||
shared: Arc<Shared>,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
inbox: Mutex<Inbox>,
|
||||
/// Set by [`Primary::shutdown`]: new connections are dropped, waiting ones refused.
|
||||
closed: AtomicBool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inbox {
|
||||
pending: Vec<Pending>,
|
||||
ctx: Option<egui::Context>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
/// Paths waiting for the UI; `taken` tells the connection that sent them.
|
||||
struct Pending {
|
||||
id: u64,
|
||||
paths: Vec<String>,
|
||||
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<OsEvent> {
|
||||
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<String>, 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<Primary, String> {
|
||||
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<usize> {
|
||||
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<String> {
|
||||
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<String>, 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::<Option<Vec<_>>>()?;
|
||||
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::<Value>(&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<Launch> {
|
||||
let (dir, paths): (PathBuf, Vec<String>) = (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<OsEvent> {
|
||||
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<OsEventsFn> = 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<String> = (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<TcpStream> = (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")));
|
||||
}
|
||||
}
|
||||
@@ -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 `<name> --exact`, which falls
|
||||
// through to the test below.
|
||||
let args: Vec<String> = 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();
|
||||
|
||||
@@ -16,7 +16,7 @@ synth = []
|
||||
[dependencies]
|
||||
crc32fast = "1.5"
|
||||
flate2 = { workspace = true }
|
||||
ruzstd = "0.9"
|
||||
ruzstd = "0.9.1"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
||||
@@ -29,8 +29,8 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::budgets::{BatchReplyBudget, write_reply};
|
||||
use crate::security::{
|
||||
ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, discard_rest_of_line,
|
||||
read_bounded_line, repeat_auth_reply,
|
||||
AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply,
|
||||
configure_stream, discard_rest_of_line, read_bounded_line, repeat_auth_reply,
|
||||
};
|
||||
use crate::{AutomationError, Headless};
|
||||
|
||||
@@ -262,10 +262,12 @@ pub fn serve_lines(h: &Mutex<Headless>, mut r: impl BufRead, mut w: impl Write)
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_tcp_connection(h: &Mutex<Headless>, 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<Headless>, stream: std::net::TcpStream, token: &str, auth_deadline: std::time::Duration) -> std::io::Result<()> {
|
||||
configure_stream(&stream)?;
|
||||
let read = stream.try_clone()?;
|
||||
let mut reader = std::io::BufReader::new(read);
|
||||
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
|
||||
let mut out = stream;
|
||||
let mut line = String::new();
|
||||
let mut authenticated = false;
|
||||
@@ -290,6 +292,9 @@ fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token:
|
||||
} else {
|
||||
let (reply, ok) = authentication_reply(&line, token);
|
||||
authenticated = ok;
|
||||
if ok {
|
||||
reader.get_mut().authenticated()?;
|
||||
}
|
||||
reply
|
||||
};
|
||||
write_reply(&mut out, &reply)?;
|
||||
@@ -322,7 +327,7 @@ pub fn serve_tcp(addr: &str, h: Arc<Mutex<Headless>>, token: String, ready: impl
|
||||
let token = Arc::clone(&token);
|
||||
std::thread::spawn(move || {
|
||||
let _permit = permit;
|
||||
let _ = serve_tcp_connection(&h, stream, &token);
|
||||
let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE);
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
@@ -814,6 +819,60 @@ mod tests {
|
||||
assert!(v["result"].as_array().unwrap().iter().any(|m| m == "batch"));
|
||||
}
|
||||
|
||||
/// #4965: a connection that keeps sending without authenticating is closed at the deadline,
|
||||
/// and one that authenticated in time keeps being served after it.
|
||||
#[test]
|
||||
fn tcp_connections_must_authenticate_before_the_deadline_whatever_they_send() {
|
||||
use std::io::{BufReader, Write as _};
|
||||
use std::time::{Duration, Instant};
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let h = Mutex::new(Headless::new());
|
||||
let mut results = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let started = Instant::now();
|
||||
let r = serve_tcp_connection(&h, stream, TOKEN, Duration::from_millis(300));
|
||||
results.push((r.map_err(|e| e.kind()), started.elapsed()));
|
||||
}
|
||||
results
|
||||
});
|
||||
|
||||
// Never a newline, a byte every 20 ms: each read succeeds long before the idle timeout.
|
||||
let mut slow = std::net::TcpStream::connect(addr).unwrap();
|
||||
for _ in 0..150 {
|
||||
if slow.write_all(b" ").is_err() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
drop(slow);
|
||||
|
||||
// Authenticates at once, then asks again 300 ms after the deadline.
|
||||
let mut s = std::net::TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
writeln!(s, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();
|
||||
let mut reader = BufReader::new(s.try_clone().unwrap());
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&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::<Value>(&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 _};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! Shared security primitives for the loopback JSON control transports.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{BufRead, Write};
|
||||
use std::io::{BufRead, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -21,6 +21,10 @@ pub const MAX_CONNECTIONS: usize = 16;
|
||||
pub const MAX_BATCH_STEPS: usize = 256;
|
||||
/// Idle/read and write timeout for loopback TCP connections.
|
||||
pub const IO_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// How long a new TCP connection has to authenticate, however active it is. [`IO_TIMEOUT`] starts
|
||||
/// again with every read, so a client that kept sending without ever completing `auth` held one
|
||||
/// of the [`MAX_CONNECTIONS`] permits for as long as it liked (#4965).
|
||||
pub const AUTH_DEADLINE: Duration = Duration::from_secs(10);
|
||||
|
||||
const TOKEN_BYTES: usize = 32;
|
||||
const TOKEN_HEX_LEN: usize = TOKEN_BYTES * 2;
|
||||
@@ -242,6 +246,44 @@ pub fn configure_stream(stream: &std::net::TcpStream) -> std::io::Result<()> {
|
||||
stream.set_write_timeout(Some(IO_TIMEOUT))
|
||||
}
|
||||
|
||||
/// The read half of a control connection. Until [`Self::authenticated`] is called, reads fail
|
||||
/// with `TimedOut` once the authentication deadline has passed, whatever arrived in the meantime;
|
||||
/// the caller then drops the connection and its [`ConnectionPermit`].
|
||||
pub struct AuthDeadlineStream {
|
||||
stream: std::net::TcpStream,
|
||||
/// `None` once authenticated.
|
||||
deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
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<usize> {
|
||||
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<String>, token_file: Option<PathBuf>) -> (Option<String>, Option<PathBuf>) {
|
||||
let supplied = supplied.or_else(|| std::env::var("PHOTOCRAFT_CONTROL_TOKEN").ok());
|
||||
@@ -327,6 +369,70 @@ mod tests {
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
/// A connected loopback pair: the accepted (server) end and the client end.
|
||||
fn tcp_pair() -> (std::net::TcpStream, std::net::TcpStream) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
|
||||
let (server, _) = listener.accept().unwrap();
|
||||
configure_stream(&server).unwrap();
|
||||
(server, client)
|
||||
}
|
||||
|
||||
/// #4965: activity must not extend the time a connection may stay unauthenticated.
|
||||
#[test]
|
||||
fn an_active_connection_is_dropped_at_the_authentication_deadline() {
|
||||
let (server, mut client) = tcp_pair();
|
||||
// A byte every 20 ms and never a newline: each read succeeds long before the idle timeout.
|
||||
let trickle = std::thread::spawn(move || {
|
||||
for _ in 0..150 {
|
||||
if client.write_all(b"x").is_err() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
});
|
||||
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(300)));
|
||||
let mut line = String::new();
|
||||
let started = Instant::now();
|
||||
let err = read_bounded_line(&mut reader, &mut line).unwrap_err();
|
||||
assert!(matches!(err.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock), "{err}");
|
||||
assert!(started.elapsed() >= Duration::from_millis(250), "not before the deadline: {:?}", started.elapsed());
|
||||
assert!(started.elapsed() < Duration::from_secs(3), "the trickle did not extend it: {:?}", started.elapsed());
|
||||
// It stays expired.
|
||||
assert!(read_bounded_line(&mut reader, &mut line).is_err());
|
||||
drop(reader);
|
||||
trickle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_connection_is_dropped_at_the_authentication_deadline() {
|
||||
let (server, _client) = tcp_pair();
|
||||
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200)));
|
||||
let mut line = String::new();
|
||||
let started = Instant::now();
|
||||
assert!(read_bounded_line(&mut reader, &mut line).is_err());
|
||||
assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_authenticated_connection_outlives_the_authentication_deadline() {
|
||||
let (server, mut client) = tcp_pair();
|
||||
let sender = std::thread::spawn(move || {
|
||||
client.write_all(b"one\n").unwrap();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
client.write_all(b"two\n").unwrap();
|
||||
});
|
||||
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200)));
|
||||
let mut line = String::new();
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
|
||||
assert_eq!(line, "one\n");
|
||||
reader.get_mut().authenticated().unwrap();
|
||||
// Sent 300 ms after the deadline: only the idle timeout applies now.
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
|
||||
assert_eq!(line, "two\n");
|
||||
sender.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_limiter_releases_capacity() {
|
||||
let limiter = ConnectionLimiter::new(1);
|
||||
|
||||
@@ -325,10 +325,11 @@ pub struct UiSetParams {
|
||||
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
|
||||
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
|
||||
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
|
||||
/// active layer's storage tiles). layerFilter (an object patching the Layers panel filter:
|
||||
/// by: kind/name/mode; kinds: a list of pixel/adjustment/type/shape/smart; name: a string;
|
||||
/// mode: a blend mode name such as Multiply or Pass Through). Omitted filter fields keep
|
||||
/// their current values. Other fields are an error.
|
||||
/// active layer's storage tiles). layerFilter (an object patching by ("kind", "name" or
|
||||
/// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a
|
||||
/// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off":
|
||||
/// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not
|
||||
/// over this channel). Other fields are an error.
|
||||
pub fields: Value,
|
||||
}
|
||||
|
||||
|
||||
@@ -369,6 +369,10 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
|
||||
}
|
||||
// With a `path` the file is written there; without one the bytes come back as `data`.
|
||||
"actions.save" | "swatches.export" => params.get("path").is_some(),
|
||||
// Shortcut sets: a .kys read or written by path, or a Summarize page written by path.
|
||||
"edit.keyboardShortcuts" => {
|
||||
params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some())
|
||||
}
|
||||
"plugin.reload" => true,
|
||||
_ => false,
|
||||
}
|
||||
@@ -712,6 +716,12 @@ mod tests {
|
||||
assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err());
|
||||
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
|
||||
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok());
|
||||
// Shortcut sets: text in and out is fine; a path reads or writes the filesystem.
|
||||
let ks = |p: serde_json::Value| authorize_engine_command("edit.keyboardShortcuts", &p);
|
||||
assert!(ks(serde_json::json!({"exportKys": true, "summarize": true, "importKys": "<photoshop-keyboard-shortcuts/>"})).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());
|
||||
|
||||
@@ -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<u8> {
|
||||
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<T: std::fmt::Debug>(result: Result<T, impl std::fmt::Display>) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<String>) -> 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<Vec<u8>, 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::<u8>(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<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, CodecE
|
||||
/// has the uncompressed length is stored raw whatever the declared compression.
|
||||
fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, 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<Image, CodecError> {
|
||||
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<u8> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,23 @@ fn gen_deep(
|
||||
tile: Option<(usize, usize)>,
|
||||
counts: &[u32],
|
||||
sample: &dyn Fn(usize, usize) -> Vec<f32>,
|
||||
) -> Vec<u8> {
|
||||
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<f32>,
|
||||
full_tile_tables: bool,
|
||||
) -> Vec<u8> {
|
||||
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<u32> = (0..35).map(|p| (p * 3) % 4).collect();
|
||||
let sample = |p: usize, ci: usize| -> Vec<f32> { (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<u32> = (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<f32> { (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 {
|
||||
|
||||
@@ -563,14 +563,14 @@ impl LayerKeys {
|
||||
fn identity(&self, layer: &Layer, doc: Option<&Document>) -> u64 {
|
||||
let addr = std::ptr::from_ref(layer) as usize;
|
||||
let in_doc = doc.is_some_and(|d| self.in_doc.get_or_init(|| doc_layer_addresses(d)).contains(&addr));
|
||||
if in_doc && let Some(id) = self.ids.lock().unwrap_or_else(|e| e.into_inner()).get(&addr) {
|
||||
return *id;
|
||||
}
|
||||
let id = self.compute(layer);
|
||||
if in_doc {
|
||||
self.ids.lock().unwrap_or_else(|e| e.into_inner()).insert(addr, id);
|
||||
let mut ids = self.ids.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Computing an identity only hashes immutable layer data; it does not run Rayon
|
||||
// work or re-enter this memo. Keep the miss and insertion atomic across callers.
|
||||
*ids.entry(addr).or_insert_with(|| self.compute(layer))
|
||||
} else {
|
||||
self.compute(layer)
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn compute(&self, layer: &Layer) -> u64 {
|
||||
|
||||
@@ -3190,3 +3190,27 @@ fn effect_cache_keeps_no_pixels_of_a_dropped_layer() {
|
||||
assert_eq!(std::sync::Arc::strong_count(&own), 1);
|
||||
assert_eq!(std::sync::Arc::strong_count(&child), 1);
|
||||
}
|
||||
|
||||
// Concurrent callers share one memoized effect identity.
|
||||
#[test]
|
||||
fn concurrent_effect_identity_misses_compute_once() {
|
||||
let mut doc = identity_doc(SampleType::U8);
|
||||
// Make the existing pure identity hash long enough to expose overlapping misses.
|
||||
doc.layers[1].effects.items = vec![Effect::DropShadow(shadow(4.0, 120.0)); 512];
|
||||
let layer = &doc.layers[1];
|
||||
let keys = LayerKeys::default();
|
||||
let start = std::sync::Barrier::new(8);
|
||||
std::thread::scope(|scope| {
|
||||
let handles: Vec<_> = (0..8)
|
||||
.map(|_| {
|
||||
scope.spawn(|| {
|
||||
start.wait();
|
||||
keys.identity(layer, Some(&doc))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let ids: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
|
||||
assert!(ids.iter().all(|id| *id == ids[0]));
|
||||
});
|
||||
assert_eq!(keys.built.load(std::sync::atomic::Ordering::Relaxed), 1, "concurrent cache misses must compute one memoized identity");
|
||||
}
|
||||
|
||||
@@ -661,3 +661,53 @@ fn replace_color_localized_clusters_only_change_the_colour_near_the_sample() {
|
||||
let err = s.execute("image.adjustments.replaceColor", json!({"points": [[1]]})).unwrap_err();
|
||||
assert!(err.to_string().contains("bad point"), "{err}");
|
||||
}
|
||||
|
||||
/// #6095: ⌘I in Quick Mask inverts the whole mask, not only where it was white, so leaving Quick
|
||||
/// Mask selects everything but the original rectangle, whichever way the colour reads. With
|
||||
/// Color Indicates Selected Areas and nothing selected, it selects everything. A black alpha
|
||||
/// channel inverts to white.
|
||||
#[test]
|
||||
fn invert_turns_the_black_of_quick_mask_and_alpha_channels_white() {
|
||||
for depth in DEPTHS {
|
||||
let mut s = session(depth, "rgb");
|
||||
let pixels = rgba(&s, 2, 2);
|
||||
s.execute("select.rect", json!({"x": 10, "y": 8, "width": 20, "height": 10})).unwrap();
|
||||
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
|
||||
s.execute("image.adjustments.invert", json!({"target": "quickMask"})).unwrap();
|
||||
let q = &doc(&s).quick_mask.as_ref().unwrap().surface;
|
||||
let (inside, outside) = (q.sample_channel(15, 12, 0), q.sample_channel(2, 2, 0));
|
||||
assert!(inside < 0.01 && outside > 0.99, "{depth}-bit Quick Mask inverted: inside {inside}, outside {outside}");
|
||||
s.execute("select.editInQuickMaskMode", json!({"on": false})).unwrap();
|
||||
let sel = doc(&s).selection.as_ref().expect("an inverse selection after Quick Mask");
|
||||
let (inside, outside) = (sel.sample_channel(15, 12, 0), sel.sample_channel(2, 2, 0));
|
||||
assert!(inside < 0.01 && outside > 0.99, "{depth}-bit selection: inside {inside}, outside {outside}");
|
||||
assert_eq!(rgba(&s, 2, 2), pixels, "{depth}-bit: the layer is untouched");
|
||||
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute("channel.new", json!({"name": "Black", "fill": "black"})).unwrap();
|
||||
s.execute("image.adjustments.invert", json!({"target": {"channel": 0}})).unwrap();
|
||||
let a = doc(&s).channels[0].surface.sample_channel(40, 30, 0);
|
||||
assert!(a > 0.99, "{depth}-bit black alpha channel inverts to white ({a})");
|
||||
s.undo();
|
||||
assert!(doc(&s).channels[0].surface.sample_channel(40, 30, 0) < 0.01, "{depth}-bit: undo restores the black");
|
||||
|
||||
// Color Indicates Selected Areas: a rectangle comes back inverted, and nothing selected
|
||||
// comes back as everything selected.
|
||||
for (start, inside, outside) in [("rectangle", 0.0, 1.0), ("nothing", 1.0, 1.0)] {
|
||||
let mut s = session(depth, "rgb");
|
||||
s.execute("channel.options", json!({"channel": "quickMask", "indicates": "selected"})).unwrap();
|
||||
if start == "rectangle" {
|
||||
s.execute("select.rect", json!({"x": 10, "y": 8, "width": 20, "height": 10})).unwrap();
|
||||
}
|
||||
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
|
||||
s.execute("image.adjustments.invert", json!({"target": "quickMask"})).unwrap();
|
||||
s.execute("select.editInQuickMaskMode", json!({"on": false})).unwrap();
|
||||
let sel = doc(&s).selection.as_ref().unwrap_or_else(|| panic!("{depth}-bit Selected Areas, {start}: a selection after Quick Mask"));
|
||||
let got = (sel.sample_channel(15, 12, 0), sel.sample_channel(2, 2, 0));
|
||||
assert!(
|
||||
(got.0 - inside).abs() < 0.01 && (got.1 - outside).abs() < 0.01,
|
||||
"{depth}-bit Selected Areas, {start}: selection (inside, outside) {got:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,11 @@ fn group_of(doc: &Document, id: LayerId) -> Vec<LayerId> {
|
||||
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<LayerId> {
|
||||
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<LayerId> {
|
||||
@@ -51,6 +56,14 @@ fn clipped_to(doc: &Document, id: LayerId) -> Vec<LayerId> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<LayerId> {
|
||||
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 {
|
||||
|
||||
+100
-44
@@ -253,7 +253,10 @@ pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> {
|
||||
}
|
||||
/// Layer › Create / Release Clipping Mask. With a `layer` param, just that layer. Otherwise, as in
|
||||
/// Photoshop, the selection (#1248): Create clips every selected layer except the lowest, which
|
||||
/// becomes the base; Release releases every selected layer. One history step either way.
|
||||
/// becomes the base; Release releases every selected layer. Releasing a clipped layer also
|
||||
/// releases the clipped layers above it in its group, as Adobe documents ("removes the selected
|
||||
/// layer and any layers above it from the clipping mask"), so none is left clipped to a layer
|
||||
/// that is no longer clipped. One history step either way.
|
||||
fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result<Value> {
|
||||
let label = if clip { "Create Clipping Mask" } else { "Release Clipping Mask" };
|
||||
let ids: Vec<LayerId> = if p.get("layer").is_some() {
|
||||
@@ -267,6 +270,20 @@ fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result<Value> {
|
||||
(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;
|
||||
@@ -381,12 +398,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)
|
||||
});
|
||||
@@ -724,10 +741,32 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Layer Properties",
|
||||
[],
|
||||
None,
|
||||
r##"{"layer":id?,"name":str?,"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##,
|
||||
r##"{"layer":id?,"layers":[id,…]? (the same change to each, one step: the Layers panel's controls with several layers selected),"name":str? (one layer only),"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##,
|
||||
has_layer,
|
||||
|s, p| {
|
||||
let id = layer_param(s, p)?;
|
||||
// Several layers at once (the Layers panel with several selected, as in Photoshop),
|
||||
// else the given or active one.
|
||||
let ids: Vec<LayerId> = 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<Option<f32>> {
|
||||
@@ -738,9 +777,19 @@ fn build() -> Vec<CommandSpec> {
|
||||
}
|
||||
};
|
||||
let (opacity, fill) = (unit("opacity")?, unit("fill")?);
|
||||
let only_visibility =
|
||||
p.as_object().is_some_and(|o| o.contains_key("visible") && o.keys().all(|k| matches!(k.as_str(), "layer" | "visible" | "coalesce")));
|
||||
let label = if only_visibility { "Layer Visibility" } else { "Layer Properties" };
|
||||
// Photoshop's History names for a single kind of change; a mix is "Layer Properties".
|
||||
let changed: Vec<&str> = p
|
||||
.as_object()
|
||||
.map(|o| o.keys().map(String::as_str).filter(|k| !matches!(*k, "layer" | "layers" | "coalesce")).collect())
|
||||
.unwrap_or_default();
|
||||
let label = match changed.as_slice() {
|
||||
["visible"] => "Layer Visibility",
|
||||
["opacity"] => "Opacity Change",
|
||||
["fill"] => "Fill Opacity Change",
|
||||
["blend"] => "Blending Change",
|
||||
["name"] => "Rename Layer",
|
||||
_ => "Layer Properties",
|
||||
};
|
||||
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
|
||||
// Clipping and channel changes reach the layers around it: recomposite everything.
|
||||
let local = p.get("clipped").is_none() && p.get("channels").is_none();
|
||||
@@ -749,47 +798,54 @@ fn build() -> Vec<CommandSpec> {
|
||||
// one that changes its look turns it into a normal layer ("Layer 0") first.
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64).is_some_and(|v| v < 1.0);
|
||||
let blends = p.get("blend").and_then(Value::as_str).and_then(blend_from_str).is_some_and(|b| b != photocraft_color::BlendMode::Normal);
|
||||
if num("opacity") || num("fill") || blends {
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
}
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if let Some(v) = p.get("name").and_then(Value::as_str) {
|
||||
l.name = v.to_string();
|
||||
}
|
||||
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
|
||||
l.visible = v;
|
||||
}
|
||||
if let Some(v) = opacity {
|
||||
l.opacity = v;
|
||||
}
|
||||
if let Some(v) = fill {
|
||||
l.fill_opacity = v;
|
||||
}
|
||||
if let Some(v) = p.get("blend").and_then(Value::as_str) {
|
||||
l.blend = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?;
|
||||
}
|
||||
if let Some(v) = p.get("clipped").and_then(Value::as_bool) {
|
||||
l.clipped = v;
|
||||
}
|
||||
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
|
||||
l.locks.all = v;
|
||||
}
|
||||
if let Some(Value::Array(ch)) = p.get("channels") {
|
||||
// Blending Options › Advanced Blending › Channels: unchecked = left out.
|
||||
l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i);
|
||||
}
|
||||
if let Some(Value::Object(m)) = p.get("locks") {
|
||||
for (k, v) in m {
|
||||
let v = v.as_bool().unwrap_or(false);
|
||||
if !l.locks.set(k, v) {
|
||||
return Err(bad("layer.setProps", format!("unknown lock `{k}`")));
|
||||
for &id in &ids {
|
||||
if num("opacity") || num("fill") || blends {
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
}
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if let Some(v) = p.get("name").and_then(Value::as_str) {
|
||||
l.name = v.to_string();
|
||||
}
|
||||
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
|
||||
l.visible = v;
|
||||
}
|
||||
if let Some(v) = opacity {
|
||||
l.opacity = v;
|
||||
}
|
||||
if let Some(v) = fill {
|
||||
l.fill_opacity = v;
|
||||
}
|
||||
if let Some(v) = p.get("blend").and_then(Value::as_str) {
|
||||
let b = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?;
|
||||
// Pass Through is a group's mode only, as in Photoshop.
|
||||
if b == photocraft_color::BlendMode::PassThrough && !l.is_group() {
|
||||
return Err(bad("layer.setProps", format!("Pass Through applies to groups only; layer {} isn't one", l.id.0)));
|
||||
}
|
||||
l.blend = b;
|
||||
}
|
||||
if let Some(v) = p.get("clipped").and_then(Value::as_bool) {
|
||||
l.clipped = v;
|
||||
}
|
||||
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
|
||||
l.locks.all = v;
|
||||
}
|
||||
if let Some(Value::Array(ch)) = p.get("channels") {
|
||||
// Blending Options › Advanced Blending › Channels: unchecked = left out.
|
||||
l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i);
|
||||
}
|
||||
if let Some(Value::Object(m)) = p.get("locks") {
|
||||
for (k, v) in m {
|
||||
let v = v.as_bool().unwrap_or(false);
|
||||
if !l.locks.set(k, v) {
|
||||
return Err(bad("layer.setProps", format!("unknown lock `{k}`")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if local {
|
||||
crate::layer_multi_cmds::note_damage(s, &before, &[id]);
|
||||
crate::layer_multi_cmds::note_damage(s, &before, &ids);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
@@ -522,11 +522,16 @@ fn delete_where(s: &mut Session, label: &str, pred: fn(&Layer) -> bool) -> Resul
|
||||
n
|
||||
}
|
||||
let backup = doc.layers.clone();
|
||||
// The clip groups of the doomed layers, read before they go (clip_group rule 3: layers
|
||||
// clipped to a deleted base are released).
|
||||
let doomed: Vec<LayerId> = 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();
|
||||
}
|
||||
@@ -878,8 +883,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
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 +909,38 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
},
|
||||
|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(())
|
||||
})?;
|
||||
|
||||
@@ -174,8 +174,9 @@ pub fn params_for(id: &str, p: &Value) -> Option<FilterParams> {
|
||||
/// 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<photocraft_raster::Surface> {
|
||||
) -> Result<Option<photocraft_raster::Surface>> {
|
||||
// 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> {
|
||||
|
||||
+43
-11
@@ -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<Option<Authorization>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
pub(crate) struct AuthorizationScope(Option<Authorization>);
|
||||
|
||||
impl AuthorizationScope {
|
||||
fn enter(gate: Option<Authorization>) -> 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<Authorization>) -> AuthorizationScope {
|
||||
AuthorizationScope::enter(gate)
|
||||
}
|
||||
|
||||
/// Recheck a stored filter's command and parameters before evaluating it or using its cache.
|
||||
pub(crate) fn authorize_render_command(id: &str, params: &Value) -> Result<()> {
|
||||
match RENDER_AUTHORIZATION.with(std::cell::Cell::get) {
|
||||
Some(gate) => gate(id, params),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies one background job for the session's lifetime.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, serde::Deserialize)]
|
||||
pub struct JobId(pub u64);
|
||||
@@ -284,6 +321,8 @@ pub fn run<T: Send + 'static>(
|
||||
work: impl FnOnce(&JobCtx) -> Result<T> + Send + 'static,
|
||||
apply: impl FnOnce(&mut Session, T) -> Result<Value> + Send + 'static,
|
||||
) -> Result<Value> {
|
||||
let authorization = s.authorize.or_else(|| RENDER_AUTHORIZATION.with(std::cell::Cell::get));
|
||||
let _authorization = AuthorizationScope::enter(authorization);
|
||||
if !s.jobs.spawn || cfg!(target_arch = "wasm32") {
|
||||
let t = work(&s.jobs.inline.clone().unwrap_or_default())?;
|
||||
return apply(s, t);
|
||||
@@ -299,6 +338,7 @@ pub fn run<T: Send + 'static>(
|
||||
let ctx = JobCtx::new();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Result<Payload>>();
|
||||
let erased: Apply = Box::new(move |s, any| {
|
||||
let _authorization = AuthorizationScope::enter(authorization);
|
||||
let t = any.downcast::<T>().map_err(|_| EngineError::Other("internal error: job result type mismatch".into()))?;
|
||||
apply(s, *t)
|
||||
});
|
||||
@@ -311,6 +351,7 @@ pub fn run<T: Send + 'static>(
|
||||
// The work closure is moved into the thread. If spawning fails (thread limits), there is
|
||||
// no way to get it back, so the error is reported instead.
|
||||
let spawned = std::thread::Builder::new().name(format!("photocraft-job-{}", id.0)).spawn(move || {
|
||||
let _authorization = AuthorizationScope::enter(authorization);
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(&wctx).map(|t| Box::new(t) as Payload)))
|
||||
.unwrap_or_else(|_| Err(EngineError::Other(format!("`{label_w}` failed with an internal error (logged); the document is unchanged"))));
|
||||
// The receiver is gone when the job was cancelled: nothing to report.
|
||||
@@ -652,6 +693,7 @@ impl Session {
|
||||
if let Some(gate) = self.authorize {
|
||||
gate(id, ¶ms)?;
|
||||
}
|
||||
let _authorization = AuthorizationScope::enter(self.authorize);
|
||||
// Last Filter runs as the filter it repeats: journaled, recorded in an action, faded and
|
||||
// run as a job like that filter, so a recorded step replays the same filter anywhere
|
||||
// (#3089), as Transform Again records its transform (#1440). With no filter yet it falls
|
||||
@@ -671,17 +713,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;
|
||||
|
||||
+14
-13
@@ -160,12 +160,14 @@ fn key(label: &str) -> String {
|
||||
l.trim_end_matches('…').trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// The engine's own candidates: every command in a menu, in command order.
|
||||
/// The engine's own candidates: every command in a menu (the engine's, then the shell's), in
|
||||
/// command order.
|
||||
pub fn command_candidates() -> Vec<Candidate> {
|
||||
crate::command_specs()
|
||||
.iter()
|
||||
.filter(|c| !c.menu.is_empty())
|
||||
.map(|c| Candidate { id: c.id.to_string(), label: c.label.to_string(), default: c.shortcut.map(str::to_string) })
|
||||
let engine = crate::command_specs().iter().map(|c| (c.id, c.label, c.menu, c.shortcut));
|
||||
engine
|
||||
.chain(crate::shell_commands::SHELL_COMMANDS.iter().copied())
|
||||
.filter(|c| !c.2.is_empty())
|
||||
.map(|(id, label, _, default)| Candidate { id: id.to_string(), label: label.to_string(), default: default.map(str::to_string) })
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -219,8 +221,8 @@ pub fn plan(candidates: impl IntoIterator<Item = Candidate>, set: &KysSet) -> Pl
|
||||
}
|
||||
|
||||
/// The tool keys of `set` matched to `tools` (our tool id and Photoshop's name for it, which is
|
||||
/// ours too: "Move Tool"): tool id → key (a single letter, upper case), and the names with no
|
||||
/// tool here. A row PhotoCraft wrote names the tool by id. A key that isn't one letter is skipped.
|
||||
/// ours too: "Move Tool"): tool id → key (normalised: a letter, or an F-key), and the names with no
|
||||
/// tool here. A row PhotoCraft wrote names the tool by id. A key with modifiers is skipped.
|
||||
pub fn plan_tools<'a>(tools: impl IntoIterator<Item = (&'a str, &'a str)>, set: &KysSet) -> (BTreeMap<String, String>, Vec<String>) {
|
||||
let tools: Vec<(&str, &str)> = tools.into_iter().collect();
|
||||
let mut keys = BTreeMap::new();
|
||||
@@ -232,11 +234,10 @@ pub fn plan_tools<'a>(tools: impl IntoIterator<Item = (&'a str, &'a str)>, set:
|
||||
unknown.push(row.name.clone());
|
||||
continue;
|
||||
};
|
||||
let mut letters = row.shortcuts.first().map(|k| k.trim()).unwrap_or_default().chars();
|
||||
if let (Some(c), None) = (letters.next(), letters.next())
|
||||
&& c.is_ascii_alphanumeric()
|
||||
{
|
||||
keys.insert((*id).to_string(), c.to_ascii_uppercase().to_string());
|
||||
// One key without modifiers (Photoshop's letters; our tools may also take an F-key).
|
||||
let key = row.shortcuts.first().and_then(|k| normalize_shortcut(k.trim()));
|
||||
if let Some(k) = key.filter(|k| !k.contains('+') || k == "+") {
|
||||
keys.insert((*id).to_string(), k);
|
||||
}
|
||||
}
|
||||
(keys, unknown)
|
||||
@@ -448,7 +449,7 @@ mod tests {
|
||||
assert_eq!(keys.get("tools.select.move").map(String::as_str), Some("V"));
|
||||
assert_eq!(keys.get("tools.select.pencil").map(String::as_str), Some("N"), "case-insensitive, upper-cased");
|
||||
assert_eq!(keys.get("tools.select.brush").map(String::as_str), Some("B"), "by pc-id");
|
||||
assert!(!keys.contains_key("tools.select.lasso"), "a tool key is one letter");
|
||||
assert!(!keys.contains_key("tools.select.lasso"), "a tool key has no modifiers");
|
||||
assert_eq!(unknown, vec!["Imaginary Tool".to_string()]);
|
||||
// Our own export reads back by id.
|
||||
let out = write("T", &[Entry { id: "tools.select.pencil".into(), label: "Pencil Tool".into(), tool: true, keys: vec!["F".into()] }]);
|
||||
|
||||
@@ -263,7 +263,8 @@ fn can_align(s: &Session) -> std::result::Result<(), String> {
|
||||
let d = doc_state(s)?;
|
||||
match d.selected_layers().len() {
|
||||
0 => Err("no layer selected".into()),
|
||||
// Photoshop aligns a single layer to the canvas (or to an active selection); 2+ align to each other.
|
||||
// Photoshop aligns to an active selection however many layers are selected; without one,
|
||||
// a single layer aligns to the canvas and 2+ align to each other.
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -961,8 +962,9 @@ fn align(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
|
||||
"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)),
|
||||
@@ -1312,6 +1314,12 @@ fn merge_layers(s: &mut Session) -> Result<Value> {
|
||||
let fmt = PixelFormat::new(fmt.mode, fmt.sample, true);
|
||||
let mut merged = Layer::raster(top.name.clone(), fmt);
|
||||
merged.locks = top.locks;
|
||||
merged.label = top.label;
|
||||
// Clipped layers of one chain merge into a layer still clipped to their base. Clipped
|
||||
// layers of different chains don't: the result, at the top one's slot, would clip the
|
||||
// lower chain's pixels to the upper chain's base.
|
||||
let base = crate::clip_group::base_of(doc, top_id);
|
||||
merged.clipped = base.is_some() && solo.layers.iter().all(|l| l.clipped && crate::clip_group::base_of(doc, l.id) == base);
|
||||
*crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers_uncropped(&solo, fmt);
|
||||
let mid = merged.id;
|
||||
// Hidden selected layers are discarded, as in Photoshop.
|
||||
@@ -1431,7 +1439,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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, _| {
|
||||
@@ -1479,7 +1487,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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",
|
||||
|
||||
@@ -103,6 +103,7 @@ pub mod sample_cmds;
|
||||
pub mod sampler_cmds;
|
||||
pub mod select_extra_cmds;
|
||||
pub mod selection_cmds;
|
||||
pub mod shell_commands;
|
||||
pub mod shortcut_sets;
|
||||
pub mod slice_cmds;
|
||||
pub mod smart_cmds;
|
||||
@@ -406,6 +407,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<DocState>,
|
||||
@@ -417,9 +441,9 @@ pub struct Session {
|
||||
coalesce_request: Option<String>,
|
||||
/// Pixels copied with Edit › Copy / Cut (shared by all documents, like Photoshop).
|
||||
pub clipboard: Option<edit_cmds::Clip>,
|
||||
/// 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<CopiedStyle>,
|
||||
/// Shape path context menu: copied vector fill and stroke styles.
|
||||
pub path_fill_clipboard: Option<photocraft_doc::Fill>,
|
||||
pub path_stroke_clipboard: Option<photocraft_doc::ShapeStroke>,
|
||||
@@ -644,6 +668,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<Value> {
|
||||
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)
|
||||
@@ -708,6 +779,9 @@ impl Session {
|
||||
|
||||
/// Apply an undoable edit to the active document.
|
||||
pub fn edit<R>(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Option<LayerId>) -> Result<R>) -> Result<R> {
|
||||
// Every mutation path (linked-file refresh, transform, place) re-renders smart objects
|
||||
// through this entry; the requesting session's authority must reach those renders.
|
||||
let _authorization = crate::jobs::render_authorization_scope(self.authorize);
|
||||
let restrict = self.color_restrict;
|
||||
let sampler_map = self.sampler_map.take();
|
||||
let st = self.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// `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<Value> {
|
||||
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<NewLayer> {
|
||||
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<Value> {
|
||||
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)]
|
||||
|
||||
@@ -415,5 +415,10 @@ pub fn merge_down(doc: &Document, lower: &Layer, upper: &Layer) -> Layer {
|
||||
// Merge Down replaces the lower layer, so keep its editing restrictions.
|
||||
// In particular, a merged Background must remain opaque and position-locked.
|
||||
merged.locks = lower.locks;
|
||||
// And its place: two layers clipped in one chain merge into a layer still clipped to the base,
|
||||
// with the lower layer's colour label and link.
|
||||
merged.clipped = lower.clipped && upper.clipped;
|
||||
merged.label = lower.label;
|
||||
merged.link_group = lower.link_group;
|
||||
merged
|
||||
}
|
||||
|
||||
+367
-31
@@ -529,8 +529,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,
|
||||
@@ -560,7 +571,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,
|
||||
}
|
||||
@@ -1099,6 +1112,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> {
|
||||
"interface.notificationDurationSeconds" => (1.0, 120.0),
|
||||
"export.jpegQuality" | "export.webpQuality" => (1.0, 100.0),
|
||||
"performance.memoryUsageMb" => (256.0, 1_048_576.0),
|
||||
"performance.gpuMemoryMb" => (0.0, 1_048_576.0),
|
||||
"performance.historyStates" => (1.0, 1000.0),
|
||||
"performance.cacheLevels" => (1.0, 8.0),
|
||||
"performance.cacheTileSize" => (256.0, 16384.0),
|
||||
@@ -1495,9 +1509,65 @@ pub fn default_alternates(id: &str) -> impl Iterator<Item = &'static str> + '_ {
|
||||
ALTERNATE_SHORTCUTS.iter().filter(move |(c, _)| *c == id).map(|(_, sc)| *sc)
|
||||
}
|
||||
|
||||
/// Every bindable id with its default: commands, then the temporary tools.
|
||||
/// Keys the system takes before the app sees them (switching apps, closing the window, force
|
||||
/// quit): `edit.keyboardShortcuts` won't bind them, as Photoshop won't. `sc` is in our notation,
|
||||
/// where `Cmd` is Ctrl off the Mac.
|
||||
pub fn reserved_shortcut(sc: &str) -> bool {
|
||||
let Some(n) = normalize_shortcut(sc) else { return false };
|
||||
let reserved: &[&str] = if cfg!(target_os = "macos") {
|
||||
&["Cmd+Tab", "Cmd+Shift+Tab", "Cmd+Alt+Escape", "Cmd+`"]
|
||||
} else if cfg!(target_os = "windows") {
|
||||
&["Alt+Tab", "Alt+Shift+Tab", "Alt+F4", "Cmd+Alt+Delete", "Cmd+Escape", "Cmd+Shift+Escape"]
|
||||
} else {
|
||||
// Linux and the BSDs: window switching and closing, and the virtual consoles.
|
||||
&[
|
||||
"Alt+Tab",
|
||||
"Alt+Shift+Tab",
|
||||
"Alt+F4",
|
||||
"Cmd+Alt+Delete",
|
||||
"Cmd+Alt+F1",
|
||||
"Cmd+Alt+F2",
|
||||
"Cmd+Alt+F3",
|
||||
"Cmd+Alt+F4",
|
||||
"Cmd+Alt+F5",
|
||||
"Cmd+Alt+F6",
|
||||
"Cmd+Alt+F7",
|
||||
"Cmd+Alt+F8",
|
||||
"Cmd+Alt+F9",
|
||||
"Cmd+Alt+F10",
|
||||
"Cmd+Alt+F11",
|
||||
"Cmd+Alt+F12",
|
||||
]
|
||||
};
|
||||
reserved.iter().filter_map(|r| normalize_shortcut(r)).any(|r| r == n)
|
||||
}
|
||||
|
||||
/// What a bindable id is: a command (the engine's or the shell's), a tool, or a held tool.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BindKind {
|
||||
Command,
|
||||
Tool,
|
||||
Hold,
|
||||
}
|
||||
|
||||
/// Every bindable id: (id, label, menu path, default key, kind). The engine's commands, the
|
||||
/// shell's ([`crate::shell_commands::SHELL_COMMANDS`]), the tools and the held temporary tools,
|
||||
/// so Edit › Keyboard Shortcuts works the same with or without the app's UI.
|
||||
pub fn bindable_items() -> impl Iterator<Item = (&'static str, &'static str, &'static [&'static str], Option<&'static str>, 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<Item = (&'static str, Option<&'static str>)> {
|
||||
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).
|
||||
@@ -1866,18 +1936,45 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<String> = 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<String> = &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) });
|
||||
@@ -1892,6 +1989,9 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"alternates": plan.alternates,
|
||||
"alternateKeys": plan.alternate_keys,
|
||||
"toolKeys": set.tool_keys,
|
||||
"tools": tool_keys,
|
||||
"unknownTools": unknown_tools,
|
||||
"reserved": skipped,
|
||||
"windows": set.windows,
|
||||
}));
|
||||
let mut with_import = p.clone();
|
||||
@@ -1914,6 +2014,17 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<String> {
|
||||
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}`")));
|
||||
@@ -1935,9 +2046,11 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.chain(alts.iter().flat_map(|(id, v)| alternate_keys(v).unwrap_or_default().into_iter().map(move |k| (id.as_str(), k))))
|
||||
.collect();
|
||||
for (id, sc) in assigned {
|
||||
// Shell commands (View › Zoom In) have alternates but no engine default.
|
||||
let shell = ALTERNATE_SHORTCUTS.iter().filter(|(c, _)| crate::commands::find(c).is_none()).map(|(c, _)| (*c, None));
|
||||
for (c, def) in bindable().chain(shell) {
|
||||
for (c, def) in bindable() {
|
||||
// Tools may share a key: they cycle with Shift, as Photoshop's groups do.
|
||||
if is_tool(id) && is_tool(c) {
|
||||
continue;
|
||||
}
|
||||
// Never strip a command that this same call is assigning:
|
||||
// two entries for one key are a clash inside the call, which
|
||||
// the returned conflicts list reports (#719). Stripping
|
||||
@@ -1959,16 +2072,11 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Then the sets are saved, deleted or switched, from the bindings as they now stand.
|
||||
changed |= crate::shortcut_sets::store(&mut next, p).map_err(|e| bad(cmd, e))?;
|
||||
if changed {
|
||||
s.edit_prefs(|p| *p = next);
|
||||
}
|
||||
let prefs = s.prefs();
|
||||
// The reply and the exports read the bindings as they will be; they are committed only once
|
||||
// the exports are written, so a bad export target changes nothing.
|
||||
let prefs = &next;
|
||||
let filter = p.get("filter").and_then(Value::as_str).map(str::to_ascii_lowercase);
|
||||
let temporary = TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, &["Tools", "Temporary"][..], Some(t.2), true));
|
||||
let list: Vec<Value> = crate::command_specs()
|
||||
.iter()
|
||||
.map(|c| (c.id, c.label, c.menu, c.shortcut, false))
|
||||
.chain(temporary)
|
||||
let list: Vec<Value> = 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)
|
||||
@@ -1976,15 +2084,17 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|| prefs.shortcuts.contains_key(c.0)
|
||||
|| prefs.alternate_shortcuts.contains_key(c.0)
|
||||
})
|
||||
.map(|(id, label, menu, def, hold)| {
|
||||
.map(|(id, label, menu, def, kind)| {
|
||||
let main = prefs.shortcut(id, def);
|
||||
let defaults: Vec<&str> = default_alternates(id).collect();
|
||||
let mut v = json!({"id": id, "label": label, "menu": menu, "default": def, "shortcut": main, "alternates": prefs.alternates(id, main)});
|
||||
if !defaults.is_empty() {
|
||||
v["defaultAlternates"] = json!(defaults);
|
||||
}
|
||||
if hold {
|
||||
v["hold"] = json!(true);
|
||||
match kind {
|
||||
BindKind::Hold => v["hold"] = json!(true),
|
||||
BindKind::Tool => v["tool"] = json!(true),
|
||||
BindKind::Command => {}
|
||||
}
|
||||
v
|
||||
})
|
||||
@@ -1995,30 +2105,59 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
bindings.extend(main.map(|m| (id, m.to_string())));
|
||||
bindings.extend(prefs.alternates(id, main).into_iter().map(|k| (id, k)));
|
||||
}
|
||||
let conflicts: Vec<Value> =
|
||||
conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str()))).into_iter().map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})).collect();
|
||||
// Tools sharing a key are a cycling group, not a conflict.
|
||||
let conflicts: Vec<Value> = conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str())))
|
||||
.into_iter()
|
||||
.filter(|(_, ids)| !ids.iter().all(|id| is_tool(id)))
|
||||
.map(|(sc, ids)| json!({"shortcut": sc, "commands": ids}))
|
||||
.collect();
|
||||
let mut out = json!({"overrides": prefs.shortcuts, "alternateOverrides": prefs.alternate_shortcuts, "commands": list, "conflicts": conflicts});
|
||||
if let (Some(o), Value::Object(sets)) = (out.as_object_mut(), crate::shortcut_sets::describe(prefs)) {
|
||||
o.extend(sets);
|
||||
}
|
||||
// The bindings as a `.kys` file, or Photoshop's Summarize page (the engine's commands; the
|
||||
// app's dialog writes both with the shell's commands and tools added).
|
||||
// The bindings as a `.kys` file, or Photoshop's Summarize page: every bindable item (the
|
||||
// engine's commands, the shell's commands and the tools).
|
||||
let set_name = if prefs.shortcut_set.is_empty() { crate::shortcut_sets::DEFAULTS_NAME } else { prefs.shortcut_set.as_str() };
|
||||
if p.get("exportKys").and_then(Value::as_bool) == Some(true) {
|
||||
// Both targets are resolved and their texts made before anything is written, and a path that
|
||||
// can't be written is refused before either file is: a bad export changes nothing.
|
||||
let kys_target = file_target(cmd, p.get("exportKys"))?;
|
||||
let html_target = file_target(cmd, p.get("summarize"))?;
|
||||
let mut files: Vec<(&str, String, String)> = Vec::new();
|
||||
if let Some(target) = kys_target {
|
||||
let entries: Vec<crate::kys::Entry> =
|
||||
crate::shortcut_sets::bound(prefs).into_iter().map(|(id, label, _, keys)| crate::kys::Entry { id, label, tool: false, keys }).collect();
|
||||
out["kys"] = json!(crate::kys::write(set_name, &entries));
|
||||
crate::shortcut_sets::bound(prefs).into_iter().map(|b| crate::kys::Entry { id: b.id, label: b.label, tool: b.tool, keys: b.keys }).collect();
|
||||
let text = crate::kys::write(set_name, &entries);
|
||||
match target {
|
||||
Some(path) => files.push(("kysPath", path, text)),
|
||||
None => out["kys"] = json!(text),
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("summarize").filter(|v| !v.is_null() && *v != &json!(false)) {
|
||||
if let Some(target) = html_target {
|
||||
let v = p.get("summarize").cloned().unwrap_or(Value::Null);
|
||||
let mac = v.get("mac").and_then(Value::as_bool).unwrap_or(cfg!(target_os = "macos"));
|
||||
let mut sections: Vec<crate::shortcut_sets::Section> = Vec::new();
|
||||
for (_, label, menu, keys) in crate::shortcut_sets::bound(prefs) {
|
||||
for crate::shortcut_sets::Bound { label, menu, keys, .. } in crate::shortcut_sets::bound(prefs) {
|
||||
match sections.iter_mut().find(|(m, _)| *m == menu) {
|
||||
Some((_, rows)) => rows.push((label, keys)),
|
||||
None => sections.push((menu, vec![(label, keys)])),
|
||||
}
|
||||
}
|
||||
out["html"] = json!(crate::shortcut_sets::summarize(set_name, §ions, mac));
|
||||
let html = crate::shortcut_sets::summarize(set_name, §ions, mac);
|
||||
match target {
|
||||
Some(path) => files.push(("htmlPath", path, html)),
|
||||
None => out["html"] = json!(html),
|
||||
}
|
||||
}
|
||||
for (_, path, _) in &files {
|
||||
writable(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
}
|
||||
let list: Vec<(&str, &str)> = files.iter().map(|(_, path, text)| (path.as_str(), text.as_str())).collect();
|
||||
write_files_atomically(&list, &rename_path).map_err(|e| bad(cmd, e))?;
|
||||
for (key, path, _) in &files {
|
||||
out[*key] = json!(path);
|
||||
}
|
||||
if changed {
|
||||
s.edit_prefs(|p| *p = next);
|
||||
}
|
||||
if let Some(import) = import {
|
||||
out["import"] = import;
|
||||
@@ -2026,6 +2165,203 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Option<Option<String>>> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<PathBuf> = 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<PathBuf>)> = 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<PathBuf>)]| -> 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<Value> {
|
||||
let ids = |k: &str| -> Vec<String> {
|
||||
@@ -2151,7 +2487,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Keyboard Shortcuts…",
|
||||
["Edit"],
|
||||
Some("Cmd+Alt+Shift+K"),
|
||||
r##"{"set":{"<command id>|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"<command id>":["F3",…]|[](none)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?,"useSet":"<name>"|""(defaults)?,"saveSet":"<name>"?,"deleteSet":"<name>"?,"sets":{"<name>":{"shortcuts":{…},"alternateShortcuts":{…}}}?,"activeSet":"<name>"|""?,"exportKys":bool?,"summarize":bool|{"mac":bool}?}"##,
|
||||
r##"{"set":{"<command id>|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"<command id>":["F3",…]|[](none)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"|{"path":".kys"}?,"useSet":"<name>"|""(defaults)?,"saveSet":"<name>"?,"deleteSet":"<name>"?,"sets":{"<name>":{"shortcuts":{…},"alternateShortcuts":{…}}}?,"activeSet":"<name>"|""?,"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
|
||||
),
|
||||
|
||||
@@ -827,3 +827,91 @@ 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);
|
||||
}
|
||||
|
||||
@@ -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.<tool>`), 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<Option<&'static str>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -234,20 +234,28 @@ pub fn summarize(set: &str, sections: &[Section], mac: bool) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Every engine command (and temporary tool) with a key under the bindings of `p`: (id,
|
||||
/// label, top-level menu, keys with the main one first). The app's dialog adds the commands
|
||||
/// only the shell knows (View › Zoom In, the tools).
|
||||
pub fn bound(p: &Preferences) -> Vec<(String, String, String, Vec<String>)> {
|
||||
let temporary = crate::prefs::TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, "Tools", Some(t.2)));
|
||||
crate::command_specs()
|
||||
.iter()
|
||||
.map(|c| (c.id, c.label, c.menu.first().copied().unwrap_or("Other"), c.shortcut))
|
||||
.chain(temporary)
|
||||
.filter_map(|(id, label, menu, def)| {
|
||||
/// A command (or tool) with a key under some bindings.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Bound {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
/// Its top-level menu ("Tools" for tools).
|
||||
pub menu: String,
|
||||
/// Main key first, then the further keys.
|
||||
pub keys: Vec<String>,
|
||||
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<Bound> {
|
||||
crate::prefs::bindable_items()
|
||||
.filter_map(|(id, label, menu, def, kind)| {
|
||||
let main = p.shortcut(id, def);
|
||||
let mut keys: Vec<String> = main.and_then(normalize_shortcut).into_iter().collect();
|
||||
keys.extend(p.alternates(id, main));
|
||||
(!keys.is_empty()).then(|| (id.to_string(), label.to_string(), menu.to_string(), keys))
|
||||
let menu = menu.first().copied().unwrap_or("Other").to_string();
|
||||
(!keys.is_empty()).then(|| Bound { id: id.to_string(), label: label.to_string(), menu, keys, tool: kind == crate::prefs::BindKind::Tool })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -340,8 +348,12 @@ mod tests {
|
||||
fn bound_lists_main_keys_then_alternates() {
|
||||
let p = prefs();
|
||||
let b = bound(&p);
|
||||
let copy = b.iter().find(|(id, ..)| id == "edit.copy").expect("Copy has keys");
|
||||
assert_eq!(copy.3, vec!["Cmd+C".to_string(), "F3".to_string()]);
|
||||
assert!(b.iter().any(|(id, ..)| id == "tools.temporary.hand"));
|
||||
let copy = b.iter().find(|x| x.id == "edit.copy").expect("Copy has keys");
|
||||
assert_eq!(copy.keys, vec!["Cmd+C".to_string(), "F3".to_string()]);
|
||||
assert!(b.iter().any(|x| x.id == "tools.temporary.hand"));
|
||||
// The shell's commands and the tools too: a set saved headless is the whole set.
|
||||
assert!(b.iter().any(|x| x.id == "view.zoomIn" && x.keys.len() >= 2), "Zoom In with its Cmd++");
|
||||
let mv = b.iter().find(|x| x.id == "tools.select.move").expect("Move Tool");
|
||||
assert!(mv.tool && mv.keys == ["V"] && mv.menu == "Tools");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,28 +298,35 @@ fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32)
|
||||
out
|
||||
}
|
||||
|
||||
/// Runs the smart filter stack on the placed (unfiltered) content.
|
||||
pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> Surface {
|
||||
/// Runs the smart filter stack on the placed (unfiltered) content. A filter the session's
|
||||
/// authority denies surfaces its error (the trusted compositor path has no gate installed, so
|
||||
/// this reads ambiently only when the caller established one and it refuses).
|
||||
pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> Result<Surface> {
|
||||
if !sm.filters_enabled || !sm.smart_filters.iter().any(|f| f.visible) {
|
||||
return placed.clone();
|
||||
return Ok(placed.clone());
|
||||
}
|
||||
let mut cur = placed.clone();
|
||||
// Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters.
|
||||
let extent = canvas.union(&placed.content_bounds());
|
||||
for f in sm.smart_filters.iter().filter(|f| f.visible) {
|
||||
cur = apply_smart_filter(f, &cur, canvas, extent);
|
||||
cur = apply_smart_filter(f, &cur, canvas, extent)?;
|
||||
}
|
||||
match sm.filter_mask.as_ref().filter(|m| m.enabled) {
|
||||
Ok(match sm.filter_mask.as_ref().filter(|m| m.enabled) {
|
||||
Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m),
|
||||
None => cur,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// One visible smart filter over `cur`, blended with its blend mode and opacity.
|
||||
fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect) -> Surface {
|
||||
// Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone.
|
||||
let Some(out) = crate::filters::apply_filter_to_surface(&f.command, &f.params, cur, canvas, None, extent) else { return cur.clone() };
|
||||
if f.blend == BlendMode::Normal && f.opacity >= 1.0 { out } else { blend_surfaces(cur, &out, f.blend, f.opacity.clamp(0.0, 1.0)) }
|
||||
/// One visible smart filter over `cur`, blended with its blend mode and opacity. `Err` when the
|
||||
/// requesting session's authority denies the filter's params: callers surface the error instead of
|
||||
/// rendering (and caching) unfiltered pixels.
|
||||
fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect) -> Result<Surface> {
|
||||
let out = match crate::filters::apply_filter_to_surface(&f.command, &f.params, cur, canvas, None, extent)? {
|
||||
// Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone.
|
||||
Some(out) => out,
|
||||
None => cur.clone(),
|
||||
};
|
||||
if f.blend == BlendMode::Normal && f.opacity >= 1.0 { Ok(out) } else { Ok(blend_surfaces(cur, &out, f.blend, f.opacity.clamp(0.0, 1.0))) }
|
||||
}
|
||||
|
||||
/// The source placed in document pixels, before any smart filter.
|
||||
@@ -377,6 +384,11 @@ pub fn render(doc: &Document, sm: &SmartObject) -> Result<Option<Surface>> {
|
||||
/// 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<Option<Surface>> {
|
||||
// Stored filters can come from a file or another layer. Check before looking up cached
|
||||
// stages too: a trusted UI render may have populated this process-wide cache earlier.
|
||||
for filter in sm.smart_filters.iter().filter(|f| sm.filters_enabled && f.visible) {
|
||||
crate::jobs::authorize_render_command(&filter.command, &filter.params)?;
|
||||
}
|
||||
let Some((name, bytes)) = source_bytes(&doc.metadata, &sm.source) else { return Ok(None) };
|
||||
let (fmt, canvas) = (doc.pixel_format(), doc.bounds());
|
||||
let placed_key = stack_cache::placed_key(&bytes, fmt, sm, canvas);
|
||||
@@ -410,7 +422,7 @@ fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result<Option<Su
|
||||
for (i, (f, key)) in filters.iter().zip(&keys).enumerate().skip(done) {
|
||||
ctx.check()?;
|
||||
ctx.progress(i as f32 / n, "");
|
||||
cur = Stage { surface: apply_smart_filter(f, &cur.surface, canvas, cur.extent), extent: cur.extent };
|
||||
cur = Stage { surface: apply_smart_filter(f, &cur.surface, canvas, cur.extent)?, extent: cur.extent };
|
||||
stack_cache::put(*key, &cur);
|
||||
}
|
||||
ctx.progress(1.0, "");
|
||||
@@ -522,7 +534,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);
|
||||
@@ -1000,6 +1012,7 @@ fn set_filter_visible(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn set_filter_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "layer.smartFilter.setParams";
|
||||
let new = p.get("params").cloned().ok_or_else(|| bad(CMD, "pass `params` (merged into the filter's parameters)"))?;
|
||||
let authorize = s.authorize;
|
||||
edit_filters(s, p, "Edit Smart Filter", |sm| {
|
||||
let i = filter_index(CMD, p, sm)?;
|
||||
let f = &mut sm.smart_filters[i];
|
||||
@@ -1010,6 +1023,11 @@ fn set_filter_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(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(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<u64>) {
|
||||
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);
|
||||
}
|
||||
@@ -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<Value> {
|
||||
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:#?}");
|
||||
}
|
||||
@@ -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!(
|
||||
"<photoshop-keyboard-shortcuts><command name=\"Copy\"><shortcut>F9</shortcut><shortcut>{key}</shortcut></command><command name=\"Cut\"><shortcut>{key}</shortcut></command><tool name=\"Pencil Tool\">N</tool><tool name=\"Move Tool\">V</tool></photoshop-keyboard-shortcuts>"
|
||||
);
|
||||
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</tool>") && text.contains("pc-id=\"view.zoomIn\""), "the whole set: {text}");
|
||||
assert!(std::fs::read_to_string(&html).unwrap().contains("<h2>Tools</h2>"));
|
||||
// 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ flate2 = { workspace = true }
|
||||
# Same IEEE CRC-32 the ZIP container needs; already in Cargo.lock through flate2.
|
||||
crc32fast = "1.5"
|
||||
blake3 = { version = "1.8", default-features = false, features = ["pure"] }
|
||||
ruzstd = "0.9"
|
||||
ruzstd = "0.9.1"
|
||||
|
||||
# Unix directory cleanup uses no-follow descriptor-relative operations; the same
|
||||
# dependency provides the plain-fsync fallback on Apple file systems.
|
||||
|
||||
@@ -794,6 +794,11 @@ impl Compositor {
|
||||
total
|
||||
}
|
||||
|
||||
/// The GPU memory resident layer pages may hold (bytes; see [`Compositor::set_memory_budget`]).
|
||||
pub fn resident_budget(&self) -> u64 {
|
||||
self.resident_budget
|
||||
}
|
||||
|
||||
/// Whether a render of `region` keeps every layer page it needs within the budget; when it
|
||||
/// doesn't, each render re-uploads what was evicted.
|
||||
pub fn fits_budget(&self, doc: &Document, region: Rect) -> bool {
|
||||
|
||||
@@ -275,7 +275,11 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m
|
||||
}
|
||||
if is_psd(bytes) {
|
||||
// A merged image the import reads is decoded with the parse: a ZIP one is inflated once.
|
||||
let (file, merged) = PsdFile::from_bytes_and_merged(bytes, |f| psd_import::check_group_depth(f).is_ok() && psd_import::merged_needed(f))?;
|
||||
let (file, merged) = PsdFile::from_bytes_and_merged_recovering(
|
||||
bytes,
|
||||
|f| psd_import::check_group_depth(f).is_ok() && psd_import::merged_needed(f),
|
||||
psd_import::can_recover_layers,
|
||||
)?;
|
||||
// Nesting past the document model's cap could never be saved; reject the file with the
|
||||
// actionable limit.
|
||||
psd_import::check_group_depth(&file)?;
|
||||
|
||||
@@ -764,6 +764,12 @@ pub(crate) fn merged_needed(file: &PsdFile) -> bool {
|
||||
!layered || file.layers().is_empty() || usize::from(h.channels) > mode.color_channels() + usize::from(file.merged_has_alpha())
|
||||
}
|
||||
|
||||
/// A damaged merged preview need not prevent opening independently stored layers (#6152).
|
||||
/// Other modes are imported flattened, so their merged image cannot be discarded.
|
||||
pub(crate) fn can_recover_layers(file: &PsdFile) -> bool {
|
||||
matches!(file.header.color_mode, PsdMode::Grayscale | PsdMode::Rgb | PsdMode::Cmyk | PsdMode::Lab) && file.header.depth != 1 && !file.layers().is_empty()
|
||||
}
|
||||
|
||||
/// [`psd_to_document_with`] with the merged image already decoded (by
|
||||
/// [`PsdFile::from_bytes_and_merged`] with [`merged_needed`]), so it is not decoded again.
|
||||
pub(crate) fn psd_to_document_decoded(
|
||||
@@ -901,9 +907,15 @@ pub(crate) fn psd_to_document_decoded(
|
||||
ctl.progress(0.1);
|
||||
if let Err(e) = &m {
|
||||
cx.warn(format!("merged image could not be decoded: {e}"));
|
||||
if can_recover_layers(file) {
|
||||
cx.warn("extra alpha/spot channels in the merged image could not be recovered");
|
||||
}
|
||||
}
|
||||
Some(m)
|
||||
} else {
|
||||
if let Some(Err(e)) = decoded {
|
||||
cx.warn(format!("merged image could not be decoded; opened the available layers instead: {e}"));
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ thread_local! {
|
||||
}
|
||||
|
||||
/// Files known to be invalid upstream: their parse error is expected.
|
||||
const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")];
|
||||
const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "malformed merged image data; recovery is tested in psd_damaged_merged.rs (#6152)")];
|
||||
|
||||
/// One corpus directory with its own floors.
|
||||
struct Source {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! #6152: intact layers can be recovered when only the merged preview is damaged.
|
||||
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer};
|
||||
use photocraft_geom::Size;
|
||||
use photocraft_io::{ExportOptions, export, import};
|
||||
use photocraft_psd::{Compression, GroupSpec, PsdBuilder, PsdFile};
|
||||
|
||||
fn layered(depth: SampleType, extension: &str) -> PsdFile {
|
||||
layered_in_mode(ColorMode::Rgb, depth, extension)
|
||||
}
|
||||
|
||||
fn layered_in_mode(mode: ColorMode, depth: SampleType, extension: &str) -> PsdFile {
|
||||
let mut doc = Document::new("recovery", Size::new(3, 2), mode, depth);
|
||||
let format = doc.pixel_format();
|
||||
let mut layer = Layer::raster("red pixels", doc.pixel_format());
|
||||
let mut pixel = vec![0.0; format.channels()];
|
||||
photocraft_raster::from_rgba_into(&format, [1.0, 0.0, 0.0, 1.0], &mut pixel);
|
||||
layer.surface_mut().unwrap().fill_rect(doc.bounds(), &pixel);
|
||||
doc.layers.push(layer);
|
||||
PsdFile::from_bytes(&export(&doc, extension, &ExportOptions::default()).unwrap().bytes).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_merged_image_keeps_layer_pixels_at_every_depth_and_compression() {
|
||||
use ColorMode::{Cmyk, Grayscale, Lab, Rgb};
|
||||
use SampleType::{F32, U8, U16};
|
||||
for extension in ["x.psd", "x.psb"] {
|
||||
for (mode, depth) in
|
||||
[(Rgb, U8), (Rgb, U16), (Rgb, F32), (Grayscale, U8), (Grayscale, U16), (Grayscale, F32), (Cmyk, U8), (Cmyk, U16), (Lab, U8), (Lab, U16)]
|
||||
{
|
||||
for compression in Compression::ALL {
|
||||
let mut file = layered_in_mode(mode, depth, extension);
|
||||
let intact = import(extension, &file.to_bytes().unwrap()).unwrap();
|
||||
let expected = photocraft_compose::flatten(&intact.document).px;
|
||||
let pixels = file.decode_merged().unwrap();
|
||||
file.image_data = photocraft_psd::ImageData::encode(compression, &pixels, &file.header).unwrap();
|
||||
file.image_data.data.truncate(file.image_data.data.len() / 2);
|
||||
let bytes = file.to_bytes().unwrap();
|
||||
assert!(PsdFile::from_bytes(&bytes).is_err(), "strict parsing stays strict");
|
||||
let result = import(extension, &bytes).expect("recover the intact layers");
|
||||
assert_eq!(result.document.layer_count(), 1);
|
||||
assert_eq!(result.document.depth, depth);
|
||||
assert!(result.warnings.iter().any(|w| w.contains("merged image")), "{:?}", result.warnings);
|
||||
let rendered = photocraft_compose::flatten(&result.document);
|
||||
assert_eq!(rendered.px, expected, "{extension} {mode:?} {depth:?} {compression:?}");
|
||||
// Saving the recovered document replaces the bad preview with a valid one.
|
||||
let saved = export(&result.document, extension, &ExportOptions::default()).unwrap();
|
||||
assert!(PsdFile::from_bytes(&saved.bytes).unwrap().decode_merged().is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_zip_merged_image_keeps_the_layers() {
|
||||
let mut file = layered(SampleType::U8, "x.psd");
|
||||
file.image_data = photocraft_psd::ImageData::encode(Compression::Zip, &file.decode_merged().unwrap(), &file.header).unwrap();
|
||||
*file.image_data.data.last_mut().unwrap() ^= 0xff;
|
||||
let result = import("x.psd", &file.to_bytes().unwrap()).unwrap();
|
||||
assert_eq!(result.document.layer_count(), 1);
|
||||
assert!(result.warnings.iter().any(|w| w.contains("merged image")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_group_survives_a_truncated_preview() {
|
||||
// The reported psd-tools fixture has only the two records of an empty group.
|
||||
let mut builder = PsdBuilder::new(100, 100);
|
||||
builder.begin_group(GroupSpec::new("empty group"));
|
||||
builder.end_group().unwrap();
|
||||
let mut file = builder.build().unwrap();
|
||||
file.image_data.data.truncate(file.image_data.data.len() / 2);
|
||||
let result = import("empty.psd", &file.to_bytes().unwrap()).unwrap();
|
||||
assert_eq!(result.document.layer_count(), 1);
|
||||
assert_eq!(result.document.layers[0].name, "empty group");
|
||||
assert!(result.warnings.iter().any(|w| w.contains("merged image")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flattened_file_with_no_recoverable_layers_still_fails() {
|
||||
let mut file = layered(SampleType::U8, "x.psd");
|
||||
file.layer_info = None;
|
||||
file.image_data.data.truncate(file.image_data.data.len() / 2);
|
||||
assert!(import("flat.psd", &file.to_bytes().unwrap()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_imported_flattened_do_not_recover_from_unusable_layer_records() {
|
||||
for mode in [photocraft_psd::ColorMode::Indexed, photocraft_psd::ColorMode::Multichannel, photocraft_psd::ColorMode::Duotone] {
|
||||
let mut file = layered(SampleType::U8, "x.psd");
|
||||
file.header.color_mode = mode;
|
||||
file.image_data = photocraft_psd::ImageData { compression: Compression::Raw, data: Vec::new() };
|
||||
assert!(import("flat-mode.psd", &file.to_bytes().unwrap()).is_err(), "{mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_extra_channels_are_reported_while_layers_remain_usable() {
|
||||
let mut file = layered(SampleType::U8, "x.psd");
|
||||
file.header.channels = 5;
|
||||
file.image_data = photocraft_psd::ImageData { compression: Compression::Raw, data: Vec::new() };
|
||||
let result = import("extra-channel.psd", &file.to_bytes().unwrap()).unwrap();
|
||||
assert_eq!(result.document.layer_count(), 1);
|
||||
assert!(result.document.channels.is_empty());
|
||||
assert!(result.warnings.iter().any(|w| w.contains("alpha/spot channels")), "{:?}", result.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_layer_sections_are_not_recovered() {
|
||||
let file = layered(SampleType::U8, "x.psd");
|
||||
let mut bytes = file.to_bytes().unwrap();
|
||||
bytes.truncate(bytes.len() - file.image_data.data.len() - 3);
|
||||
assert!(import("broken-layers.psd", &bytes).is_err());
|
||||
}
|
||||
|
||||
#[cfg(feature = "corpus")]
|
||||
#[test]
|
||||
fn reported_group_divider_fixture_opens_with_its_group() {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd-tools/blend-modes/group-divider-blend-mode.psd");
|
||||
let bytes = std::fs::read(path).expect("run cargo xtask corpus --psd-tools");
|
||||
let result = import("group-divider-blend-mode.psd", &bytes).expect("#6152 opens");
|
||||
assert_eq!(result.document.layer_count(), 1);
|
||||
assert!(matches!(result.document.layers[0].content, photocraft_doc::LayerContent::Group(_)));
|
||||
assert!(result.warnings.iter().any(|w| w.contains("merged image")));
|
||||
}
|
||||
+40
-4
@@ -102,6 +102,22 @@ impl PsdFile {
|
||||
/// Errors are those of [`Self::from_bytes`]; a merged image that passes the parse's check but
|
||||
/// cannot be decoded is the inner `Err`, as [`Self::decode_merged`] would return it.
|
||||
pub fn from_bytes_and_merged(data: &[u8], want: impl FnOnce(&PsdFile) -> bool) -> Result<(Self, Option<Result<Vec<u8>>>)> {
|
||||
Self::from_bytes_and_merged_recovering(data, want, |_| false)
|
||||
}
|
||||
|
||||
/// Parses like [`Self::from_bytes_and_merged`], but lets `recover` accept damage confined to
|
||||
/// the merged image after all layer records and channel data have been read successfully.
|
||||
/// Callers should opt in only when they can reconstruct the document from its layers.
|
||||
///
|
||||
/// The original encoded image bytes are preserved and its parse error is returned as
|
||||
/// `Some(Err(..))`, even when `want` did not request a decode. The caller must report that
|
||||
/// error rather than silently discarding the merged image (which may hold extra channels).
|
||||
/// Header/layer errors, missing compression fields and parse safety-limit errors stay fatal.
|
||||
pub fn from_bytes_and_merged_recovering(
|
||||
data: &[u8],
|
||||
want: impl FnOnce(&PsdFile) -> bool,
|
||||
recover: impl FnOnce(&PsdFile) -> bool,
|
||||
) -> Result<(Self, Option<Result<Vec<u8>>>)> {
|
||||
let mut r = Reader::new(data);
|
||||
let header = Header::read(&mut r)?;
|
||||
let psb = header.version.is_psb();
|
||||
@@ -161,10 +177,30 @@ impl PsdFile {
|
||||
let image_data = ImageData { compression: Compression::Raw, data: Vec::new() };
|
||||
let mut file =
|
||||
PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement, global_layer_mask, global_blocks, layer_mask_trailing, image_data };
|
||||
let merged = if want(&file) { Some(file.read_merged_decoded(&mut r)?) } else { None };
|
||||
if merged.is_none() {
|
||||
file.image_data = ImageData::read(&mut r, &file.header)?;
|
||||
}
|
||||
let mut encoded = r.clone();
|
||||
let merged = if want(&file) {
|
||||
file.read_merged_decoded(&mut r).map(Some)
|
||||
} else {
|
||||
ImageData::read(&mut r, &file.header).map(|image| {
|
||||
file.image_data = image;
|
||||
None
|
||||
})
|
||||
};
|
||||
let merged = match merged {
|
||||
Ok(merged) => merged,
|
||||
Err(error) => {
|
||||
if matches!(error, PsdError::LimitExceeded(_)) || !recover(&file) {
|
||||
return Err(error);
|
||||
}
|
||||
let compression = Compression::from_u16(encoded.u16()?);
|
||||
let source = encoded.peek_rest();
|
||||
let mut data = Vec::new();
|
||||
data.try_reserve_exact(source.len()).map_err(|_| PsdError::LimitExceeded("not enough memory to retain the damaged merged image"))?;
|
||||
data.extend_from_slice(source);
|
||||
file.image_data = ImageData { compression, data };
|
||||
Some(Err(error))
|
||||
}
|
||||
};
|
||||
Ok((file, merged))
|
||||
}
|
||||
|
||||
|
||||
+25
-7
@@ -681,13 +681,12 @@ pub mod transcode {
|
||||
if !self.from.is_little() && TaggedBlock::is_foreign_order_signature(self.r.peek_rest()) {
|
||||
let sig = self.r.array::<4>()?;
|
||||
let mut key = self.r.array::<4>()?;
|
||||
let long = uses_long_length(self.version, &{
|
||||
let mut k = key;
|
||||
if self.to.is_little() {
|
||||
k.reverse();
|
||||
}
|
||||
k
|
||||
});
|
||||
// Only reached with `from == Big`: the key is canonical here whatever the
|
||||
// target order. `TaggedBlock::write` sizes the length field from that same key.
|
||||
// Reversing it for the target order before the check
|
||||
// asked about a key `PSB_LONG_KEYS` never contains, so a PSB long key's 8-byte
|
||||
// length was read as 4 and every later block was copied unconverted.
|
||||
let long = uses_long_length(self.version, &key);
|
||||
let len = self.r.len_field(long)?;
|
||||
let data = self.r.bytes_u64(len)?;
|
||||
if self.to.is_little() {
|
||||
@@ -2211,6 +2210,25 @@ mod tests {
|
||||
assert_eq!(ImageSourceData::from_bytes(&big).unwrap().0.global_blocks, back.global_blocks);
|
||||
}
|
||||
|
||||
/// A PSB keeps `Mt16`/`Mt32`/`Mtrn`/`Alph` blocks of unknown layout verbatim, and their
|
||||
/// length field is 8 bytes (a PSB long key). Writing them into a little-endian TIFF must
|
||||
/// read that width; the old code looked the key up after reversing it for the target
|
||||
/// order, which never matches `PSB_LONG_KEYS`.
|
||||
#[test]
|
||||
fn psb_foreign_order_long_key_roundtrips() {
|
||||
let d = ImageSourceData {
|
||||
version: Version::Psb,
|
||||
byte_order: ByteOrder::Little,
|
||||
global_blocks: vec![TaggedBlock { signature: *b"MIB8", key: *b"Mt16", data: vec![9, 8, 7, 6, 5, 4], padding: None }],
|
||||
..Default::default()
|
||||
};
|
||||
let (little, w) = d.to_bytes(ByteOrder::Little).unwrap();
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
let (back, w) = ImageSourceData::from_bytes(&little).unwrap();
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
assert_eq!(back.global_blocks, d.global_blocks, "the 8-byte length and the bytes survive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rle_counts_follow_the_container_and_samples_do_not() {
|
||||
let file = testgen::layered(Version::Psd, ColorMode::Rgb, 16, Compression::Rle);
|
||||
|
||||
@@ -21,7 +21,8 @@ fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
|
||||
/// Files known to be invalid upstream, with the reason. They are reported but
|
||||
/// do not fail the test.
|
||||
const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")];
|
||||
const KNOWN_BAD: &[(&str, &str)] =
|
||||
&[("group-divider-blend-mode.psd", "malformed merged image data; strict parsing rejects it, but the importer recovers its group (#6152)")];
|
||||
|
||||
#[test]
|
||||
fn corpus_parse_and_byte_stable() {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Opt-in merged-image recovery must not weaken strict parsing or lose encoded bytes.
|
||||
|
||||
use photocraft_psd::{Compression, PsdError, PsdFile, Version, compression, testgen};
|
||||
|
||||
#[test]
|
||||
fn recovery_is_explicit_and_preserves_the_original_file() {
|
||||
for version in [Version::Psd, Version::Psb] {
|
||||
for compression in Compression::ALL {
|
||||
let mut original = testgen::small(version, compression);
|
||||
original.image_data.data.pop();
|
||||
let bytes = original.to_bytes().unwrap();
|
||||
let strict_error = PsdFile::from_bytes(&bytes).unwrap_err();
|
||||
for want in [false, true] {
|
||||
let rejected = PsdFile::from_bytes_and_merged_recovering(&bytes, |_| want, |_| false).unwrap_err();
|
||||
assert_eq!(rejected, strict_error);
|
||||
let (file, merged) = PsdFile::from_bytes_and_merged_recovering(&bytes, |_| want, |_| true).unwrap();
|
||||
assert_eq!(merged.unwrap().unwrap_err(), strict_error);
|
||||
assert_eq!(file, original);
|
||||
assert_eq!(file.to_bytes().unwrap(), bytes, "{version:?} {compression:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_does_not_accept_missing_compression_or_incomplete_layers() {
|
||||
let original = testgen::small(Version::Psd, Compression::Raw);
|
||||
let bytes = original.to_bytes().unwrap();
|
||||
let merged_offset = bytes.len() - original.image_data.data.len() - 2;
|
||||
for cut in [merged_offset - 1, merged_offset, merged_offset + 1] {
|
||||
assert!(PsdFile::from_bytes_and_merged_recovering(&bytes[..cut], |_| true, |_| true).is_err(), "cut {cut}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_never_suppresses_a_merged_stream_limit_error() {
|
||||
let mut original = testgen::small(Version::Psd, Compression::Zip);
|
||||
original.image_data.data = compression::zip_compress(&vec![0; (1 << 20) + 4096]);
|
||||
let bytes = original.to_bytes().unwrap();
|
||||
for want in [false, true] {
|
||||
assert!(matches!(PsdFile::from_bytes_and_merged_recovering(&bytes, |_| want, |_| true), Err(PsdError::LimitExceeded(_))));
|
||||
}
|
||||
}
|
||||
@@ -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<String> = 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| {
|
||||
|
||||
@@ -47,7 +47,7 @@ sys-locale = "=0.3.2"
|
||||
# Installed RAM for the GPU memory budget (`gpu_canvas::physical_memory`): the safe binding the
|
||||
# desktop app already uses, pinned to the same version.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] }
|
||||
winsafe = { version = "=0.0.29", default-features = false, features = ["kernel", "dxgi"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# egui's own default fonts in tests: the oracle for egui_fonts.rs, and contexts made with
|
||||
|
||||
@@ -200,9 +200,17 @@ fn main() {
|
||||
.map(|s| s.tiles().map(|(_, t)| t.bytes().len() as u64).sum::<u64>())
|
||||
.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() }));
|
||||
|
||||
|
||||
@@ -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<u64> {
|
||||
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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -41,16 +41,21 @@ fn gpu_app() -> Option<Gpu> {
|
||||
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");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Layers panel: hovering a mode in the open blend-mode list previews it on the active layer
|
||||
//! (Photoshop's live blend-mode preview, #970). Hovering records nothing: leaving the list, or
|
||||
//! Layers panel: hovering a mode in the open blend-mode list previews it on the layers it would
|
||||
//! change, the active one or every selected one (Photoshop's live blend-mode preview, #970). Hovering records nothing: leaving the list, or
|
||||
//! closing it without a choice, shows the document again, and choosing a mode commits it through
|
||||
//! `layer.setProps` (one history step). The canvas re-renders once per hovered mode, and only
|
||||
//! where the layer's blending can change pixels.
|
||||
@@ -15,17 +15,17 @@ use crate::PhotocraftApp;
|
||||
/// Preview keys (`canvas::display_doc`) are `BASE` plus a hash of the revision and the mode.
|
||||
const BASE: u64 = 1 << 37;
|
||||
|
||||
/// A blend mode hovered for a layer of a document at a revision.
|
||||
/// A blend mode hovered for layers of a document at a revision.
|
||||
pub(crate) struct BlendPreview {
|
||||
doc: DocId,
|
||||
revision: u64,
|
||||
layer: LayerId,
|
||||
layers: Vec<LayerId>,
|
||||
/// 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<BlendMode>,
|
||||
/// 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<Rect>,
|
||||
/// The document shown for `mode`, with its key.
|
||||
shown: Option<(u64, Arc<Document>)>,
|
||||
@@ -33,11 +33,11 @@ pub(crate) struct BlendPreview {
|
||||
|
||||
/// The Layers panel, each frame it draws `layer`'s blend-mode dropdown: the mode under the
|
||||
/// pointer in its open list, if any.
|
||||
pub(crate) fn hover(app: &mut PhotocraftApp, layer: LayerId, mode: Option<BlendMode>) {
|
||||
pub(crate) fn hover(app: &mut PhotocraftApp, layers: &[LayerId], mode: Option<BlendMode>) {
|
||||
let frame = app.frame;
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let (doc, revision) = (st.doc.id, st.revision);
|
||||
if let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision && p.layer == layer) {
|
||||
if let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision && p.layers == layers) {
|
||||
p.frame = frame;
|
||||
if mode.is_none() {
|
||||
p.shown = None;
|
||||
@@ -46,8 +46,15 @@ pub(crate) fn hover(app: &mut PhotocraftApp, layer: LayerId, mode: Option<BlendM
|
||||
return;
|
||||
}
|
||||
app.blend_preview = mode.and_then(|mode| {
|
||||
let area = photocraft_compose::change_bounds(st.doc.layer(layer)?, st.doc.bounds());
|
||||
Some(BlendPreview { doc, revision, layer, mode: Some(mode), frame, area, shown: None })
|
||||
// Anywhere when any layer can change anything; else the union of their areas.
|
||||
let mut area: Option<Rect> = Some(Rect::EMPTY);
|
||||
for &id in layers {
|
||||
area = match (area, photocraft_compose::change_bounds(st.doc.layer(id)?, st.doc.bounds())) {
|
||||
(Some(a), Some(b)) => Some(if a.is_empty() { b } else { a.union(&b) }),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
Some(BlendPreview { doc, revision, layers: layers.to_vec(), mode: Some(mode), frame, area, shown: None })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,15 +64,23 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Do
|
||||
let st = app.session.documents().get(idx)?;
|
||||
let p = app.blend_preview.as_mut().filter(|p| p.doc == st.doc.id && p.revision == st.revision && p.frame + 1 >= frame)?;
|
||||
let mode = p.mode?;
|
||||
let key = preview_key(p.revision, p.layer, mode);
|
||||
let key = preview_key(p.revision, &p.layers, mode);
|
||||
if let Some((k, shown)) = &p.shown
|
||||
&& *k == key
|
||||
{
|
||||
return Some((shown.clone(), key));
|
||||
}
|
||||
let mut doc = (*st.doc).clone();
|
||||
let l = doc.layer_mut(p.layer).filter(|l| l.blend != mode)?;
|
||||
l.blend = mode;
|
||||
let mut changed = false;
|
||||
for &id in &p.layers {
|
||||
if let Some(l) = doc.layer_mut(id).filter(|l| l.blend != mode) {
|
||||
l.blend = mode;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
// Duotone documents display through their inks.
|
||||
let doc = Arc::new(photocraft_engine::mode_cmds::display_document(&doc).unwrap_or(doc));
|
||||
p.shown = Some((key, doc.clone()));
|
||||
@@ -73,7 +88,7 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Do
|
||||
}
|
||||
|
||||
/// What changed between preview (or document) key `seen` and `now` of document `doc` at
|
||||
/// `revision`: the hovered layer's area. `None` when either key is another kind of preview, or
|
||||
/// `revision`: the hovered layers' area. `None` when either key is another kind of preview, or
|
||||
/// the layer can change anything (the canvas then recomposites everything).
|
||||
pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, seen: u64, now: u64) -> Option<Rect> {
|
||||
let p = app.blend_preview.as_ref().filter(|p| p.doc == doc && p.revision == revision)?;
|
||||
@@ -85,8 +100,9 @@ fn is_preview_key(key: u64) -> bool {
|
||||
(BASE..BASE << 1).contains(&key)
|
||||
}
|
||||
|
||||
fn preview_key(revision: u64, layer: LayerId, mode: BlendMode) -> u64 {
|
||||
let h = mode.label().bytes().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ layer.0, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b)));
|
||||
fn preview_key(revision: u64, layers: &[LayerId], mode: BlendMode) -> u64 {
|
||||
let seed = layers.iter().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15), |h, id| h.wrapping_mul(0x100_0000_01b3) ^ id.0);
|
||||
let h = mode.label().bytes().fold(seed, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b)));
|
||||
(h & (BASE - 1)) | BASE
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ fn a_hovered_mode_shows_on_the_canvas_until_the_pointer_leaves() {
|
||||
let (s, top) = session();
|
||||
let mut app = PhotocraftApp::new(s, crate::Services::default());
|
||||
let (undo, revision) = (steps(&app), app.session.active().unwrap().revision);
|
||||
hover(&mut app, top, Some(BlendMode::Multiply));
|
||||
hover(&mut app, &[top], Some(BlendMode::Multiply));
|
||||
let (shown, key) = display_doc(&mut app, 0).expect("previewed");
|
||||
assert_eq!(shown.layer(top).unwrap().blend, BlendMode::Multiply);
|
||||
let (again, same) = display_doc(&mut app, 0).unwrap();
|
||||
@@ -45,17 +45,17 @@ fn a_hovered_mode_shows_on_the_canvas_until_the_pointer_leaves() {
|
||||
// From the document to the preview and between previews: only the layer's area redraws.
|
||||
let doc = app.session.active().unwrap().doc.id;
|
||||
assert_eq!(damage(&app, doc, revision, 0, key).map(|r| r.width()), Some(16));
|
||||
hover(&mut app, top, Some(BlendMode::Screen));
|
||||
hover(&mut app, &[top], Some(BlendMode::Screen));
|
||||
let (_, screen) = display_doc(&mut app, 0).unwrap();
|
||||
assert_ne!(screen, key);
|
||||
assert!(damage(&app, doc, revision, key, screen).is_some());
|
||||
// The current mode, or no mode, shows the document; and so does a list no longer drawn.
|
||||
hover(&mut app, top, Some(BlendMode::Normal));
|
||||
hover(&mut app, &[top], Some(BlendMode::Normal));
|
||||
assert!(display_doc(&mut app, 0).is_none());
|
||||
hover(&mut app, top, None);
|
||||
hover(&mut app, &[top], None);
|
||||
assert!(display_doc(&mut app, 0).is_none());
|
||||
assert!(damage(&app, doc, revision, screen, 0).is_some(), "back to the document: the layer's area");
|
||||
hover(&mut app, top, Some(BlendMode::Multiply));
|
||||
hover(&mut app, &[top], Some(BlendMode::Multiply));
|
||||
app.frame += 2;
|
||||
assert!(display_doc(&mut app, 0).is_none(), "a stale hover previews nothing");
|
||||
}
|
||||
@@ -124,3 +124,17 @@ fn moving_between_modes_keeps_a_preview_across_the_gaps_between_them() {
|
||||
}
|
||||
assert_eq!(previewed(&mut h), Some(BlendMode::Multiply));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hovered_mode_previews_on_every_selected_layer() {
|
||||
let (mut s, top) = session();
|
||||
let other = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.execute("edit.fill", json!({"color": "#3366cc"})).unwrap();
|
||||
let mut app = PhotocraftApp::new(s, crate::Services::default());
|
||||
hover(&mut app, &[top, other], Some(BlendMode::Multiply));
|
||||
let (shown, key) = display_doc(&mut app, 0).expect("previewed");
|
||||
assert_eq!((shown.layer(top).unwrap().blend, shown.layer(other).unwrap().blend), (BlendMode::Multiply, BlendMode::Multiply));
|
||||
// A different set of layers is a different preview.
|
||||
hover(&mut app, &[top], Some(BlendMode::Multiply));
|
||||
assert_ne!(display_doc(&mut app, 0).unwrap().1, key);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -1442,7 +1449,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) {
|
||||
@@ -1629,7 +1636,21 @@ 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());
|
||||
@@ -1641,12 +1662,17 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible:
|
||||
// draws it (checked before the memory budget, which walks the history, every frame).
|
||||
if !gpu.fits_canvas(&doc) {
|
||||
crate::background_redraw::cancel(app, id);
|
||||
// Drawn on the CPU canvas: no layer budget applies to it.
|
||||
if app.perf.gpu_layer_fit.is_some_and(|f| f.doc == id.0) {
|
||||
app.perf.gpu_layer_fit = None;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let partial = present && damage.is_some();
|
||||
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.
|
||||
@@ -1687,21 +1713,32 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible:
|
||||
true
|
||||
}
|
||||
|
||||
/// Point the wgpu compositor at what the view shows, and size its memory budget from Memory
|
||||
/// Usage, the document's pixels and History, 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.memory_bytes() as u64;
|
||||
let budget = crate::gpu_canvas::memory_budget(allowance, pixels, crate::gpu_canvas::physical_memory());
|
||||
let budget = crate::gpu_canvas::memory_budget(allowance.0, pixels, crate::gpu_canvas::physical_memory(), gpu.video_memory(), allowance.1);
|
||||
gpu.set_memory_budget(budget);
|
||||
app.perf.gpu_budget_mb = budget >> 20;
|
||||
app.perf.gpu_budget_allowance = allowance;
|
||||
if changed && let Some(fit) = gpu.measure_fit(&st.doc) {
|
||||
app.perf.gpu_layer_fit = Some(fit);
|
||||
}
|
||||
}
|
||||
|
||||
/// Memory Usage and GPU Memory (Preferences › Performance), in bytes.
|
||||
fn budget_allowance(app: &PhotocraftApp) -> (u64, u64) {
|
||||
let performance = &app.session.prefs().performance;
|
||||
(u64::from(performance.memory_usage_mb).saturating_mul(1 << 20), u64::from(performance.gpu_memory_mb).saturating_mul(1 << 20))
|
||||
}
|
||||
|
||||
/// Live preview for an open filter dialog: run the filter on the proxy and upload it. With
|
||||
@@ -4541,8 +4578,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;
|
||||
@@ -6549,6 +6586,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
|
||||
@@ -6566,6 +6622,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;
|
||||
@@ -7484,6 +7541,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();
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -131,8 +131,9 @@ pub enum Outcome {
|
||||
/// field's value is validated before the first one is applied, so a typo, an unknown field, a
|
||||
/// bad value or a bad nested key can't reply with success while nothing — or only half of it —
|
||||
/// changed (#412).
|
||||
pub const UI_SET_FIELDS: [&str; 37] = [
|
||||
pub const UI_SET_FIELDS: [&str; 38] = [
|
||||
"tool",
|
||||
"strokeTrace",
|
||||
"panels",
|
||||
"dock",
|
||||
"dockTabs",
|
||||
@@ -492,6 +493,19 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
None => None,
|
||||
};
|
||||
let gradient_classic = bool_field(p, "gradientClassic")?;
|
||||
// The stroke latency trace (`stroke_trace`), at runtime: on (stderr) or off. A CSV
|
||||
// file is only for the user's own launch (`--stroke-trace <path>`): 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 <path>)".into()),
|
||||
},
|
||||
Some(_) => return Err("strokeTrace must be true, false, \"stderr\" or \"off\"".into()),
|
||||
};
|
||||
// The Eyedropper's options bar (#1649): Sample Size, Sample, Show Sampling Ring.
|
||||
let eyedropper_size = match p.get("eyedropperSampleSize") {
|
||||
Some(v) => Some(
|
||||
@@ -663,6 +677,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
if let Some(mode) = gradient_blend {
|
||||
app.ui.tool_options.gradient_blend_mode = mode;
|
||||
}
|
||||
if let Some(v) = &stroke_trace {
|
||||
app.stroke_trace.set(v);
|
||||
}
|
||||
if let Some(classic) = gradient_classic {
|
||||
app.ui.tool_options.gradient_classic = classic;
|
||||
}
|
||||
@@ -1385,6 +1402,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_set_turns_the_stroke_trace_on_and_off_at_runtime() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
assert!(!app.stroke_trace.enabled());
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"strokeTrace": true}))["ok"], true);
|
||||
assert!(app.stroke_trace.enabled());
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"strokeTrace": "off"}))["ok"], true);
|
||||
assert!(!app.stroke_trace.enabled());
|
||||
let bad = call(&mut app, &ctx, "ui.set", json!({"strokeTrace": 3, "tool": "Pencil"}));
|
||||
assert_eq!(bad["ok"], false);
|
||||
// A file path isn't accepted over the channel (it would write outside the automation
|
||||
// write root); nothing is created.
|
||||
let path = std::env::temp_dir().join(format!("photocraft-trace-{}.csv", std::process::id()));
|
||||
let bad = call(&mut app, &ctx, "ui.set", json!({"strokeTrace": path.to_str().unwrap(), "tool": "Pencil"}));
|
||||
assert_eq!(bad["ok"], false);
|
||||
assert!(!path.exists());
|
||||
assert!(!app.stroke_trace.enabled() && app.ui.tool != crate::state::Tool::Pencil, "a rejected call applies nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_raw_control_updates_and_commits_or_cancels_an_open_raw() {
|
||||
use photocraft_raw::testgen::{DngSpec, mosaic, scene};
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::{cell::RefCell, rc::Rc};
|
||||
type Writes = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
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));
|
||||
|
||||
@@ -262,8 +262,21 @@ impl PhotocraftApp {
|
||||
match e {
|
||||
OsEvent::Open(paths) => {
|
||||
self.open_paths(&paths);
|
||||
// Bring the window forward when another app (Finder, the Dock) asked us to open.
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
// Bring the window forward when another app (Finder, the Dock, Explorer via a
|
||||
// second launch) asked us to open; restore it first if it was minimized.
|
||||
if ctx.input(|i| i.viewport().minimized) == Some(true) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
|
||||
}
|
||||
// Native Wayland lets a window come forward only with the launcher's activation
|
||||
// token, which winit can't apply to an existing window (its focus is a no-op
|
||||
// there): mark the window as wanting attention instead, so the taskbar
|
||||
// highlights it rather than the file opening out of sight.
|
||||
let raise = if self.services.is_wayland {
|
||||
egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational)
|
||||
} else {
|
||||
egui::ViewportCommand::Focus
|
||||
};
|
||||
ctx.send_viewport_cmd(raise);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
OsEvent::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
|
||||
@@ -317,6 +317,28 @@ fn os_open_events_open_files_with_paths() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The viewport commands an `Open` event sends: focus, or an attention request on native Wayland
|
||||
/// where winit can't focus (#3708).
|
||||
fn raise_commands(is_wayland: bool) -> Vec<egui::ViewportCommand> {
|
||||
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());
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::num::NonZeroU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use eframe::egui_wgpu::{self, CallbackResources, CallbackTrait, RenderState, ScreenDescriptor};
|
||||
use eframe::wgpu;
|
||||
@@ -110,6 +111,11 @@ pub struct GpuCanvas {
|
||||
tile: u32,
|
||||
high: HighPolicy,
|
||||
health: photocraft_gpu::DeviceHealth,
|
||||
/// [`video_memory`] of the adapter, detected once.
|
||||
vram: Option<u64>,
|
||||
/// [`canvas_budget`], shared with the paint callback's resources: [`GpuCanvas::format_for`]
|
||||
/// reads it while the renderer is locked.
|
||||
canvas_budget: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
/// Whether `regenerate_mips` builds each level in a scratch texture and copies it back: on
|
||||
@@ -158,9 +164,16 @@ impl GpuCanvas {
|
||||
res.health = health.clone();
|
||||
let info = rs.adapter.get_info();
|
||||
res.separate_mip_targets = separate_mip_targets(info.backend, info.vendor, std::env::var("PHOTOCRAFT_SEPARATE_MIPS").ok().as_deref());
|
||||
let vram = video_memory(&info);
|
||||
res.canvas_budget.store(canvas_budget(physical_memory(), vram), Ordering::Relaxed);
|
||||
let budget = res.canvas_budget.clone();
|
||||
rs.renderer.write().callback_resources.insert(res);
|
||||
log::info!("gpu canvas: target {:?}, max texture {max}, tile {tile}, 16F canvas {high:?}", rs.target_format);
|
||||
Self { rs: rs.clone(), tile, high, health }
|
||||
log::info!(
|
||||
"gpu canvas: target {:?}, max texture {max}, tile {tile}, 16F canvas {high:?}, video memory {}",
|
||||
rs.target_format,
|
||||
vram.map_or_else(|| "unknown".to_owned(), |b| format!("{} MB", b >> 20))
|
||||
);
|
||||
Self { rs: rs.clone(), tile, high, health, vram, canvas_budget: budget }
|
||||
}
|
||||
|
||||
/// The device's health flag (shared with the wgpu compositor and the paint callback).
|
||||
@@ -192,6 +205,12 @@ impl GpuCanvas {
|
||||
self.rs.adapter.get_info()
|
||||
}
|
||||
|
||||
/// The adapter's own memory where it has one and the platform reports it (see
|
||||
/// [`video_memory`]); it caps [`memory_budget`] and [`canvas_budget`].
|
||||
pub fn video_memory(&self) -> Option<u64> {
|
||||
self.vram
|
||||
}
|
||||
|
||||
/// The canvas texture format for a document of `depth` and `size`: `Rgba16Float` for 16/32-bit
|
||||
/// documents when the adapter supports it and the document fits [`F16_BUDGET_PX`], else
|
||||
/// `Rgba8Unorm`.
|
||||
@@ -204,7 +223,11 @@ impl GpuCanvas {
|
||||
let px = size[0] as u64 * size[1] as u64;
|
||||
let high = match self.high {
|
||||
HighPolicy::Off => false,
|
||||
HighPolicy::Budget => px <= F16_BUDGET_PX,
|
||||
// Within the canvas budget too: a 16-bit canvas a small card can't hold as floats
|
||||
// stays on the GPU as `Rgba8Unorm` rather than falling back to the CPU canvas.
|
||||
HighPolicy::Budget => {
|
||||
px <= F16_BUDGET_PX && canvas_texture_bytes(size, self.tile, texel_bytes(FORMAT_HIGH)) <= self.canvas_budget.load(Ordering::Relaxed)
|
||||
}
|
||||
HighPolicy::Always => true,
|
||||
};
|
||||
if high && depth != photocraft_doc::SampleType::U8 { FORMAT_HIGH } else { FORMAT }
|
||||
@@ -240,10 +263,7 @@ impl GpuCanvas {
|
||||
|
||||
/// Set the GPU bytes one document's display texture set may take (see [`canvas_budget`]).
|
||||
pub fn set_canvas_budget(&self, bytes: u64) {
|
||||
let mut r = self.rs.renderer.write();
|
||||
if let Some(res) = r.callback_resources.get_mut::<Resources>() {
|
||||
res.canvas_budget = bytes;
|
||||
}
|
||||
self.canvas_budget.store(bytes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether `doc`'s display textures fit [`canvas_budget`]; when they don't, nothing is
|
||||
@@ -252,7 +272,7 @@ impl GpuCanvas {
|
||||
let size = [doc.size.width, doc.size.height];
|
||||
let bytes = canvas_texture_bytes(size, self.tile, texel_bytes(self.format_for(doc.depth, size)));
|
||||
let r = self.rs.renderer.read();
|
||||
r.callback_resources.get::<Resources>().is_some_and(|res| bytes <= res.canvas_budget)
|
||||
r.callback_resources.get::<Resources>().is_some_and(|res| bytes <= res.canvas_budget.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Cap lower-stack composite reuse; zero disables it for profiling the uncached path.
|
||||
@@ -283,6 +303,28 @@ impl GpuCanvas {
|
||||
Some((c.resident_bytes(), c.fx_cache_bytes().saturating_add(usize::try_from(c.composite_cache_bytes()).unwrap_or(usize::MAX))))
|
||||
}
|
||||
|
||||
/// Whether `doc`'s layers fit the budget as it now stands, measured without a refresh (a GPU
|
||||
/// Memory change shows in the status bar at once). `None` before the compositor exists.
|
||||
pub fn measure_fit(&self, doc: &photocraft_doc::Document) -> Option<LayerFit> {
|
||||
let mut r = self.rs.renderer.write();
|
||||
let res = r.callback_resources.get_mut::<Resources>()?;
|
||||
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<LayerFit> {
|
||||
let r = self.rs.renderer.read();
|
||||
r.callback_resources.get::<Resources>()?.layer_fit
|
||||
}
|
||||
|
||||
/// Set the checkerboard and gamut warning colours.
|
||||
pub fn set_style(&self, style: CanvasStyle) {
|
||||
let mut r = self.rs.renderer.write();
|
||||
@@ -451,9 +493,13 @@ impl GpuCanvas {
|
||||
// compositor does it (in the background on desktop). So does a large damage rect, such
|
||||
// as hiding a big layer (#4024); small edits in the view stay on the GPU.
|
||||
let large = region.width() as u64 * region.height() as u64 >= DEFER_PX;
|
||||
if (region == doc.bounds() || large) && !comp.fits_budget(doc, region) {
|
||||
res.compositor = Some(comp);
|
||||
return Err(photocraft_gpu::Unsupported(OVER_BUDGET.into()));
|
||||
if region == doc.bounds() || large {
|
||||
let fit = LayerFit { doc: doc.id.0, needed: comp.working_set(doc, region), budget: comp.resident_budget() };
|
||||
res.layer_fit = Some(fit);
|
||||
if !fit.fits() {
|
||||
res.compositor = Some(comp);
|
||||
return Err(photocraft_gpu::Unsupported(OVER_BUDGET.into()));
|
||||
}
|
||||
}
|
||||
if fresh {
|
||||
let Some(tex) = DocTextures::new(device, res, size, self.tile, format) else {
|
||||
@@ -906,9 +952,13 @@ pub struct Perf {
|
||||
/// GPU memory the compositor may hold for layer pages and effect maps (MB; see
|
||||
/// [`memory_budget`]).
|
||||
pub gpu_budget_mb: u64,
|
||||
/// The Memory Usage allowance (bytes) `gpu_budget_mb` was computed from.
|
||||
/// The Memory Usage allowance and the GPU Memory preference (bytes) `gpu_budget_mb` was
|
||||
/// computed from.
|
||||
#[serde(skip)]
|
||||
pub gpu_budget_allowance: u64,
|
||||
pub gpu_budget_allowance: (u64, u64),
|
||||
/// The layer pages the last full refresh needed against the part of the budget they may
|
||||
/// hold; when they don't fit, edits that redraw the whole document do it on the CPU.
|
||||
pub gpu_layer_fit: Option<LayerFit>,
|
||||
/// Last command run through the app and its duration (ms).
|
||||
pub last_command: String,
|
||||
pub command_ms: f64,
|
||||
@@ -934,6 +984,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<u64>,
|
||||
/// `performance.gpuBackend` at launch.
|
||||
pub preference: String,
|
||||
/// The backend startup chose ("auto", "dx12", "cpu", …, or "env" for `WGPU_BACKEND`).
|
||||
@@ -945,6 +998,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<String>,
|
||||
/// 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 {
|
||||
@@ -961,6 +1017,7 @@ impl GpuInfo {
|
||||
}
|
||||
.to_string();
|
||||
self.driver = [info.driver.as_str(), info.driver_info.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" ");
|
||||
self.video_memory_mb = video_memory(info).map(|b| b >> 20);
|
||||
}
|
||||
|
||||
/// Human-readable lines for Help › System Info.
|
||||
@@ -1185,21 +1242,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>) -> 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<u64>, vram: Option<u64>, 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.
|
||||
@@ -1217,11 +1306,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>) -> 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<u64>, vram: Option<u64>) -> 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
|
||||
@@ -1285,6 +1375,70 @@ fn detect_physical_memory() -> Option<u64> {
|
||||
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<u64> {
|
||||
if let Some(mb) = std::env::var("PHOTOCRAFT_VRAM_MB").ok().and_then(|v| v.trim().parse::<u64>().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<u64>)>;
|
||||
static SEEN: std::sync::Mutex<Seen> = 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<u64> {
|
||||
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<u64> {
|
||||
drm_video_memory(std::path::Path::new("/sys/class/drm"), vendor, device)
|
||||
}
|
||||
|
||||
/// amdgpu's VRAM size (`<card>/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<u64> {
|
||||
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<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
struct Resources {
|
||||
view_bgl: wgpu::BindGroupLayout,
|
||||
tile_bgl: wgpu::BindGroupLayout,
|
||||
@@ -1312,10 +1466,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<u64>,
|
||||
/// 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<LayerFit>,
|
||||
compositor_cache_budget: Option<u64>,
|
||||
compositor_focus: Option<photocraft_geom::Rect>,
|
||||
/// GPU bytes one document's display texture set may take (see [`canvas_budget`]).
|
||||
canvas_budget: u64,
|
||||
canvas_budget: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
encode_bgl: wgpu::BindGroupLayout,
|
||||
encode_pipeline: wgpu::RenderPipeline,
|
||||
lut_bgl: wgpu::BindGroupLayout,
|
||||
@@ -1579,9 +1736,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,
|
||||
}
|
||||
@@ -1620,7 +1778,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<Self> {
|
||||
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();
|
||||
@@ -2237,16 +2395,84 @@ mod tests {
|
||||
#[test]
|
||||
fn gpu_budget_follows_memory_usage_and_ram() {
|
||||
const G: u64 = 1 << 30;
|
||||
// What Memory Usage leaves after the document, within a quarter of RAM.
|
||||
assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G)), 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]
|
||||
@@ -2277,12 +2503,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]
|
||||
|
||||
@@ -51,6 +51,7 @@ fn read_mips(gpu: &GpuCanvas, key: u64) -> Vec<Vec<[f32; 4]>> {
|
||||
|
||||
#[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");
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -2442,7 +2442,6 @@ cameraRaw Color Barvy
|
||||
Redrawing Překreslování
|
||||
This redraw took {seconds} s on the CPU. Toto překreslení na CPU trvalo {seconds} s.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Úpravy, které mění celý dokument, například režim prolnutí, krytí nebo viditelnost vrstvy, jej překreslí celý.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Vrstvy se nevejdou do rozpočtu paměti GPU. Zvýšením hodnoty Využití paměti v Předvolbách › Výkon se rozpočet zvětší, nejvýše na čtvrtinu paměti tohoto počítače.
|
||||
The GPU compositor wasn't used: {reason} Kompozitor GPU nebyl použit: {reason}
|
||||
fontWeight Thin Jemná
|
||||
fontWeight ExtraLight Extrajemná
|
||||
@@ -3797,3 +3796,23 @@ navigator Zoom Zvětšení
|
||||
There are too many sets: delete one first. Sad je příliš mnoho: nejprve jednu smažte.
|
||||
set name název sady
|
||||
{set} (modified) {set} (změněno)
|
||||
Accept and Go to Conflict Přijmout a přejít ke konfliktu
|
||||
Changes undone. Změny vráceny.
|
||||
Choose a new shortcut for this command, or leave it without one. Zvolte pro tento příkaz novou zkratku, nebo ho nechte bez ní.
|
||||
Put this command's shortcuts back as they were Vrátit zkratky tohoto příkazu do původního stavu
|
||||
Take the shortcut from the other command and select it Odebrat zkratku druhému příkazu a vybrat ho
|
||||
Undo Changes Vrátit změny
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} je vyhrazena systémem a nelze ji přiřadit.
|
||||
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)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2481,7 +2481,6 @@ layerLabel No Color Keine Farbe
|
||||
Redrawing Neuzeichnen
|
||||
This redraw took {seconds} s on the CPU. Dieses Neuzeichnen auf der CPU dauerte {seconds} s.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Änderungen, die das ganze Dokument betreffen, etwa Füllmethode, Deckkraft oder Sichtbarkeit einer Ebene, zeichnen es vollständig neu.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Die Ebenen passen nicht in das GPU-Speicherbudget. Eine höhere Speichernutzung unter Voreinstellungen › Leistung erhöht das Budget, bis zu einem Viertel des Arbeitsspeichers dieses Computers.
|
||||
The GPU compositor wasn't used: {reason} Der GPU-Compositor wurde nicht verwendet: {reason}
|
||||
cameraRaw Light Licht
|
||||
fontWeight Thin Haarfein
|
||||
@@ -3191,3 +3190,23 @@ curves Smooth Glätten
|
||||
There are too many sets: delete one first. Zu viele Sätze: Löschen Sie zuerst einen.
|
||||
set name Name des Satzes
|
||||
{set} (modified) {set} (geändert)
|
||||
Accept and Go to Conflict Übernehmen und zum Konflikt
|
||||
Changes undone. Änderungen rückgängig gemacht.
|
||||
Choose a new shortcut for this command, or leave it without one. Wählen Sie einen neuen Kurzbefehl für diesen Befehl oder lassen Sie ihn ohne.
|
||||
Put this command's shortcuts back as they were Die Kurzbefehle dieses Befehls wiederherstellen
|
||||
Take the shortcut from the other command and select it Den Kurzbefehl dem anderen Befehl entziehen und diesen auswählen
|
||||
Undo Changes Änderungen rückgängig machen
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} ist vom System reserviert und kann nicht zugewiesen werden.
|
||||
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)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3439,7 +3439,6 @@ cameraRaw Yellow Κίτρινο
|
||||
HSB/HSL HSB/HSL
|
||||
Import Swatches… Εισαγωγή δειγμάτων χρωμάτων…
|
||||
Impressionist Ιμπρεσιονιστικό
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Τα επίπεδά του δεν χωρούν στο όριο μνήμης της GPU. Αυξάνοντας τη Χρήση μνήμης στις Προτιμήσεις › Επιδόσεις αυξάνεται το όριο, έως το ένα τέταρτο της μνήμης αυτού του υπολογιστή.
|
||||
Knockout Διάτρηση
|
||||
Large document: redrawing on the CPU Μεγάλο έγγραφο: επανασχεδίαση στη CPU
|
||||
Redrawing Επανασχεδίαση
|
||||
@@ -3889,3 +3888,23 @@ curves Smooth Εξομάλυνση
|
||||
There are too many sets: delete one first. Υπάρχουν πάρα πολλά σύνολα: διαγράψτε πρώτα ένα.
|
||||
set name όνομα συνόλου
|
||||
{set} (modified) {set} (τροποποιημένο)
|
||||
Accept and Go to Conflict Αποδοχή και μετάβαση στη σύγκρουση
|
||||
Changes undone. Οι αλλαγές αναιρέθηκαν.
|
||||
Choose a new shortcut for this command, or leave it without one. Επιλέξτε νέα συντόμευση για αυτήν την εντολή ή αφήστε την χωρίς.
|
||||
Put this command's shortcuts back as they were Επαναφορά των συντομεύσεων αυτής της εντολής όπως ήταν
|
||||
Take the shortcut from the other command and select it Αφαίρεση της συντόμευσης από την άλλη εντολή και επιλογή της
|
||||
Undo Changes Αναίρεση αλλαγών
|
||||
{shortcut} is reserved by the system and can't be assigned. Η συντόμευση {shortcut} δεσμεύεται από το σύστημα και δεν μπορεί να εκχωρηθεί.
|
||||
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 = Αυτόματο)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1030 and column 18.
|
@@ -2512,7 +2512,6 @@ cameraRaw Amount Cantidad
|
||||
Redrawing Redibujando
|
||||
This redraw took {seconds} s on the CPU. Este redibujado en la CPU tardó {seconds} s.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Las ediciones que cambian todo el documento, como el modo de fusión, la opacidad o la visibilidad de una capa, lo redibujan por completo.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Las capas no caben en el presupuesto de memoria de la GPU. Aumentar Uso de memoria en Preferencias › Rendimiento amplía el presupuesto, hasta una cuarta parte de la memoria de este equipo.
|
||||
The GPU compositor wasn't used: {reason} No se usó el compositor de GPU: {reason}
|
||||
fontWeight Thin Ultrafina
|
||||
fontWeight ExtraLight Extrafina
|
||||
@@ -4151,3 +4150,23 @@ navigator Zoom Zoom
|
||||
There are too many sets: delete one first. Hay demasiados conjuntos: elimine uno primero.
|
||||
set name nombre del conjunto
|
||||
{set} (modified) {set} (modificado)
|
||||
Accept and Go to Conflict Aceptar e ir al conflicto
|
||||
Changes undone. Cambios deshechos.
|
||||
Choose a new shortcut for this command, or leave it without one. Elija un nuevo método abreviado para este comando o déjelo sin ninguno.
|
||||
Put this command's shortcuts back as they were Restaurar los métodos abreviados de este comando como estaban
|
||||
Take the shortcut from the other command and select it Quitar el método abreviado al otro comando y seleccionarlo
|
||||
Undo Changes Deshacer cambios
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} está reservado por el sistema y no se puede asignar.
|
||||
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)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2862,7 +2862,6 @@ cameraRaw Blending Fusion
|
||||
Redrawing Redessin
|
||||
This redraw took {seconds} s on the CPU. Ce redessin sur le CPU a pris {seconds} s.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Les modifications qui touchent tout le document, comme le mode de fusion, l'opacité ou la visibilité d'un calque, le redessinent entièrement.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Les calques dépassent le budget de mémoire GPU. Augmenter Utilisation de la mémoire dans Préférences › Performances relève ce budget, jusqu'au quart de la mémoire de cet ordinateur.
|
||||
The GPU compositor wasn't used: {reason} Le compositeur GPU n'a pas été utilisé : {reason}
|
||||
fontWeight Thin Ultrafin
|
||||
fontWeight ExtraLight Extrafin
|
||||
@@ -3194,3 +3193,23 @@ curves Smooth Lisser
|
||||
set name nom du jeu
|
||||
{set} (modified) {set} (modifié)
|
||||
Random Aléatoire
|
||||
Accept and Go to Conflict Accepter et aller au conflit
|
||||
Changes undone. Modifications annulées.
|
||||
Choose a new shortcut for this command, or leave it without one. Choisissez un nouveau raccourci pour cette commande ou laissez-la sans raccourci.
|
||||
Put this command's shortcuts back as they were Rétablir les raccourcis de cette commande
|
||||
Take the shortcut from the other command and select it Retirer le raccourci de l’autre commande et la sélectionner
|
||||
Undo Changes Annuler les modifications
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} est réservé par le système et ne peut pas être attribué.
|
||||
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)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3312,7 +3312,6 @@ cameraRaw Yellow Sárga
|
||||
Interpret Footage Videoanyag értelmezése
|
||||
Interval Intervallum
|
||||
Invalid {mode} profile Érvénytelen profil ({mode})
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. A dokumentum rétegei nem férnek el a GPU memóriakeretében. Ha a Beállítások › Teljesítmény alatt növeled a memóriahasználatot, a keret is nő, legfeljebb a számítógép memóriájának negyedéig.
|
||||
Join us on Discord Csatlakozz hozzánk a Discordon
|
||||
Keep Using CPU Marad a CPU
|
||||
Kernel Szűrőmag
|
||||
@@ -4108,3 +4107,23 @@ cameraRaw Yellow Sárga
|
||||
There are too many sets: delete one first. Túl sok készlet van: előbb töröljön egyet.
|
||||
set name készlet neve
|
||||
{set} (modified) {set} (módosítva)
|
||||
Accept and Go to Conflict Elfogadás és ugrás az ütközéshez
|
||||
Changes undone. Változások visszavonva.
|
||||
Choose a new shortcut for this command, or leave it without one. Válasszon új billentyűparancsot ehhez a parancshoz, vagy hagyja nélküle.
|
||||
Put this command's shortcuts back as they were A parancs billentyűparancsainak visszaállítása
|
||||
Take the shortcut from the other command and select it A billentyűparancs elvétele a másik parancstól, és annak kijelölése
|
||||
Undo Changes Változások visszavonása
|
||||
{shortcut} is reserved by the system and can't be assigned. A(z) {shortcut} a rendszer számára fenntartott, nem rendelhető hozzá.
|
||||
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)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2849,7 +2849,6 @@ cameraRaw Blending Pembauran
|
||||
Redrawing Menggambar ulang
|
||||
This redraw took {seconds} s on the CPU. Penggambaran ulang di CPU ini memakan waktu {seconds} dtk.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Suntingan yang mengubah seluruh dokumen, seperti mode campuran, opasitas, atau visibilitas layer, menggambar ulang semuanya.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Layer tidak muat dalam anggaran memori GPU. Menaikkan Pemakaian memori di Preferensi › Performa menaikkan anggaran, hingga seperempat memori komputer ini.
|
||||
The GPU compositor wasn't used: {reason} Kompositor GPU tidak digunakan: {reason}
|
||||
fontWeight Thin Sangat Tipis
|
||||
fontWeight ExtraLight Ekstra Tipis
|
||||
@@ -3181,3 +3180,23 @@ curves Smooth Haluskan
|
||||
set name nama set
|
||||
{set} (modified) {set} (diubah)
|
||||
Random Acak
|
||||
Accept and Go to Conflict Terima dan Buka Konflik
|
||||
Changes undone. Perubahan dibatalkan.
|
||||
Choose a new shortcut for this command, or leave it without one. Pilih pintasan baru untuk perintah ini, atau biarkan tanpa pintasan.
|
||||
Put this command's shortcuts back as they were Kembalikan pintasan perintah ini seperti semula
|
||||
Take the shortcut from the other command and select it Ambil pintasan dari perintah lain dan pilih perintah itu
|
||||
Undo Changes Batalkan Perubahan
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} dicadangkan oleh sistem dan tidak dapat ditetapkan.
|
||||
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)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2512,7 +2512,6 @@ cameraRaw Amount Quantità
|
||||
Redrawing Ridisegno
|
||||
This redraw took {seconds} s on the CPU. Questo ridisegno sulla CPU ha richiesto {seconds} s.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Le modifiche che cambiano tutto il documento, come metodo di fusione, opacità o visibilità di un livello, lo ridisegnano per intero.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. I livelli non rientrano nel budget di memoria della GPU. Aumentare Uso memoria in Preferenze › Prestazioni alza il budget, fino a un quarto della memoria di questo computer.
|
||||
The GPU compositor wasn't used: {reason} Il compositore GPU non è stato usato: {reason}
|
||||
fontWeight Thin Sottile
|
||||
fontWeight ExtraLight Extraleggero
|
||||
@@ -4310,3 +4309,23 @@ shape Appearance Aspetto
|
||||
There are too many sets: delete one first. Ci sono troppi set: eliminane prima uno.
|
||||
set name nome del set
|
||||
{set} (modified) {set} (modificato)
|
||||
Accept and Go to Conflict Accetta e vai al conflitto
|
||||
Changes undone. Modifiche annullate.
|
||||
Choose a new shortcut for this command, or leave it without one. Scegli una nuova scorciatoia per questo comando o lascialo senza.
|
||||
Put this command's shortcuts back as they were Ripristina le scorciatoie di questo comando com'erano
|
||||
Take the shortcut from the other command and select it Togli la scorciatoia all'altro comando e selezionalo
|
||||
Undo Changes Annulla modifiche
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} è riservata dal sistema e non può essere assegnata.
|
||||
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)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2653 and column 53.
|
@@ -2413,7 +2413,6 @@ cameraRaw Color カラー
|
||||
Redrawing 再描画中
|
||||
This redraw took {seconds} s on the CPU. この再描画は CPU で {seconds} 秒かかりました。
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. レイヤーの描画モード、不透明度、表示/非表示など、ドキュメント全体に関わる編集では全体が再描画されます。
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. レイヤーが GPU メモリの予算に収まりません。環境設定 › パフォーマンスのメモリ使用量を増やすと、このコンピューターのメモリの4分の1まで予算が増えます。
|
||||
The GPU compositor wasn't used: {reason} GPU コンポジターは使用されませんでした: {reason}
|
||||
fontWeight Thin 極細
|
||||
fontWeight ExtraLight 特細
|
||||
@@ -3865,3 +3864,23 @@ curves Smooth スムーズ
|
||||
There are too many sets: delete one first. セットが多すぎます。先に 1 つ削除してください。
|
||||
set name セット名
|
||||
{set} (modified) {set}(変更あり)
|
||||
Accept and Go to Conflict 適用して競合へ移動
|
||||
Changes undone. 変更を取り消しました。
|
||||
Choose a new shortcut for this command, or leave it without one. このコマンドに新しいショートカットを選択するか、なしのままにしてください。
|
||||
Put this command's shortcuts back as they were このコマンドのショートカットを元に戻す
|
||||
Take the shortcut from the other command and select it もう一方のコマンドからショートカットを外して選択
|
||||
Undo Changes 変更を取り消し
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} はシステムで予約されているため割り当てられません。
|
||||
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 = 自動)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3022,7 +3022,6 @@ cameraRaw Color 색상
|
||||
Redrawing 다시 그리는 중
|
||||
This redraw took {seconds} s on the CPU. 이번 다시 그리기는 CPU에서 {seconds}초 걸렸습니다.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. 레이어의 혼합 모드, 불투명도, 표시 여부처럼 문서 전체를 바꾸는 편집은 문서 전체를 다시 그립니다.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. 레이어가 GPU 메모리 예산에 맞지 않습니다. 환경 설정 › 성능에서 메모리 사용량을 늘리면 이 컴퓨터 메모리의 4분의 1까지 예산이 늘어납니다.
|
||||
The GPU compositor wasn't used: {reason} GPU 합성기를 사용하지 않았습니다: {reason}
|
||||
fontWeight Thin 아주 가늘게
|
||||
fontWeight ExtraLight 더 가늘게
|
||||
@@ -3367,3 +3366,23 @@ curves Smooth 매끄럽게
|
||||
There are too many sets: delete one first. 세트가 너무 많습니다. 먼저 하나를 삭제하세요.
|
||||
set name 세트 이름
|
||||
{set} (modified) {set}(수정됨)
|
||||
Accept and Go to Conflict 적용 후 충돌 항목으로 이동
|
||||
Changes undone. 변경 사항을 취소했습니다.
|
||||
Choose a new shortcut for this command, or leave it without one. 이 명령의 새 단축키를 선택하거나 단축키 없이 두세요.
|
||||
Put this command's shortcuts back as they were 이 명령의 단축키를 원래대로 되돌리기
|
||||
Take the shortcut from the other command and select it 다른 명령에서 단축키를 빼고 그 명령 선택
|
||||
Undo Changes 변경 사항 취소
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut}은(는) 시스템에서 예약되어 할당할 수 없습니다.
|
||||
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 = 자동)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3165 and column 53.
|
@@ -2441,7 +2441,6 @@ layerLabel No Color Geen kleur
|
||||
Redrawing Opnieuw tekenen
|
||||
This redraw took {seconds} s on the CPU. Opnieuw tekenen duurde {seconds} s op de CPU.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Bewerkingen die het hele document veranderen, zoals de overvloeimodus, dekking of zichtbaarheid van een laag, tekenen alles opnieuw.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. De lagen passen niet binnen het GPU-geheugenbudget. Door Geheugengebruik te verhogen in Voorkeuren › Prestaties wordt het budget groter, tot een kwart van het geheugen van deze computer.
|
||||
The GPU compositor wasn't used: {reason} De GPU-compositor is niet gebruikt: {reason}
|
||||
cameraRaw Light Licht
|
||||
fontWeight Thin Dun
|
||||
@@ -3949,3 +3948,23 @@ curves Smooth Vloeiend maken
|
||||
There are too many sets: delete one first. Er zijn te veel sets: verwijder er eerst een.
|
||||
set name naam van de set
|
||||
{set} (modified) {set} (gewijzigd)
|
||||
Accept and Go to Conflict Accepteren en naar conflict
|
||||
Changes undone. Wijzigingen ongedaan gemaakt.
|
||||
Choose a new shortcut for this command, or leave it without one. Kies een nieuwe sneltoets voor deze opdracht of laat hem zonder.
|
||||
Put this command's shortcuts back as they were De sneltoetsen van deze opdracht herstellen
|
||||
Take the shortcut from the other command and select it De sneltoets van de andere opdracht afnemen en die selecteren
|
||||
Undo Changes Wijzigingen ongedaan maken
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} is door het systeem gereserveerd en kan niet worden toegewezen.
|
||||
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)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -3471,7 +3471,6 @@ cameraRaw Roughness Chropowatość
|
||||
Redrawing Odświeżanie
|
||||
This redraw took {seconds} s on the CPU. To odświeżenie na CPU trwało {seconds} s.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Zmiany obejmujące cały dokument, takie jak tryb mieszania, krycie lub widoczność warstwy, odświeżają go w całości.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Warstwy nie mieszczą się w budżecie pamięci GPU. Zwiększenie wartości Użycie pamięci w Preferencje › Wydajność zwiększa budżet, maksymalnie do jednej czwartej pamięci tego komputera.
|
||||
The GPU compositor wasn't used: {reason} Kompozytor GPU nie został użyty: {reason}
|
||||
# Pattern Stamp tool
|
||||
Pattern Stamp Tool Narzędzie Pieczątka wzoru
|
||||
@@ -3787,3 +3786,23 @@ curves Smooth Wygładź
|
||||
There are too many sets: delete one first. Zestawów jest za dużo: najpierw usuń jeden.
|
||||
set name nazwa zestawu
|
||||
{set} (modified) {set} (zmieniony)
|
||||
Accept and Go to Conflict Akceptuj i przejdź do konfliktu
|
||||
Changes undone. Zmiany cofnięte.
|
||||
Choose a new shortcut for this command, or leave it without one. Wybierz nowy skrót dla tego polecenia lub zostaw je bez skrótu.
|
||||
Put this command's shortcuts back as they were Przywróć skróty tego polecenia
|
||||
Take the shortcut from the other command and select it Odbierz skrót drugiemu poleceniu i zaznacz je
|
||||
Undo Changes Cofnij zmiany
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} jest zarezerwowany przez system i nie można go przypisać.
|
||||
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)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2865,7 +2865,6 @@ cameraRaw Whites Brancos
|
||||
Redrawing Redesenhando
|
||||
This redraw took {seconds} s on the CPU. Este redesenho na CPU levou {seconds} s.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Edições que mudam o documento inteiro, como modo de mesclagem, opacidade ou visibilidade de uma camada, redesenham tudo.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. As camadas não cabem no orçamento de memória da GPU. Aumentar Uso de memória em Preferências › Desempenho eleva o orçamento, até um quarto da memória deste computador.
|
||||
The GPU compositor wasn't used: {reason} O compositor de GPU não foi usado: {reason}
|
||||
fontWeight Thin Fina
|
||||
fontWeight ExtraLight Extrafina
|
||||
@@ -3979,3 +3978,23 @@ layerLabel No Color Sem cor
|
||||
There are too many sets: delete one first. Há conjuntos demais: exclua um primeiro.
|
||||
set name nome do conjunto
|
||||
{set} (modified) {set} (modificado)
|
||||
Accept and Go to Conflict Aceitar e ir para o conflito
|
||||
Changes undone. Alterações desfeitas.
|
||||
Choose a new shortcut for this command, or leave it without one. Escolha um novo atalho para este comando ou deixe-o sem atalho.
|
||||
Put this command's shortcuts back as they were Restaurar os atalhos deste comando como estavam
|
||||
Take the shortcut from the other command and select it Tirar o atalho do outro comando e selecioná-lo
|
||||
Undo Changes Desfazer alterações
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} é reservado pelo sistema e não pode ser atribuído.
|
||||
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)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2712,7 +2712,6 @@ cameraRaw Roughness Неровность
|
||||
Redrawing Перерисовка
|
||||
This redraw took {seconds} s on the CPU. Эта перерисовка на CPU заняла {seconds} с.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Правки, затрагивающие весь документ, например режим наложения, непрозрачность или видимость слоя, перерисовывают его целиком.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Слои не помещаются в бюджет памяти GPU. Увеличение параметра «Память» в разделе Настройки › Производительность повышает бюджет, но не больше четверти памяти этого компьютера.
|
||||
The GPU compositor wasn't used: {reason} Компоновщик GPU не использовался: {reason}
|
||||
fontWeight Thin Тонкий
|
||||
fontWeight ExtraLight Сверхтонкий
|
||||
@@ -3159,3 +3158,23 @@ curves Smooth Сгладить
|
||||
set name имя набора
|
||||
{set} (modified) {set} (изменён)
|
||||
Random Случайно
|
||||
Accept and Go to Conflict Принять и перейти к конфликту
|
||||
Changes undone. Изменения отменены.
|
||||
Choose a new shortcut for this command, or leave it without one. Выберите новую комбинацию для этой команды или оставьте её без комбинации.
|
||||
Put this command's shortcuts back as they were Вернуть комбинации этой команды
|
||||
Take the shortcut from the other command and select it Снять комбинацию с другой команды и выбрать её
|
||||
Undo Changes Отменить изменения
|
||||
{shortcut} is reserved by the system and can't be assigned. Комбинация {shortcut} зарезервирована системой и не может быть назначена.
|
||||
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 = автоматически)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2836,7 +2836,6 @@ cameraRaw Blending Stapanje
|
||||
Large document: redrawing on the CPU Veliki dokument: ponovno iscrtavanje procesorom
|
||||
This redraw took {seconds} s on the CPU. Ovo iscrtavanje je trajalo {seconds} s na procesoru.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Izmene koje menjaju ceo dokument, kao što su režim stapanja, neprozirnost ili vidljivost sloja, ponovo iscrtavaju ceo dokument.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Njegovi slojevi ne staju u budžet GPU memorije. Povećanje vrednosti Korišćenje memorije u Podešavanja › Performanse povećava budžet, do četvrtine memorije ovog računara.
|
||||
The GPU compositor wasn't used: {reason} GPU kompozitor nije korišćen: {reason}
|
||||
fontWeight Thin Tanko
|
||||
fontWeight ExtraLight Ekstra lagano
|
||||
@@ -4262,3 +4261,23 @@ shape Appearance Izgled
|
||||
There are too many sets: delete one first. Previše skupova: prvo obrišite jedan.
|
||||
set name ime skupa
|
||||
{set} (modified) {set} (izmenjen)
|
||||
Accept and Go to Conflict Prihvati i idi na sukob
|
||||
Changes undone. Izmene su poništene.
|
||||
Choose a new shortcut for this command, or leave it without one. Izaberite novu prečicu za ovu komandu ili je ostavite bez prečice.
|
||||
Put this command's shortcuts back as they were Vrati prečice ove komande kakve su bile
|
||||
Take the shortcut from the other command and select it Uzmi prečicu drugoj komandi i izaberi je
|
||||
Undo Changes Poništi izmene
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} je rezervisana od strane sistema i ne može se dodeliti.
|
||||
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)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2410,7 +2410,6 @@ layerLabel No Color Renk Yok
|
||||
Large document: redrawing on the CPU Büyük belge: CPU'da yeniden çizim
|
||||
This redraw took {seconds} s on the CPU. Bu yeniden çizim CPU'da {seconds} saniye sürdü.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Bir katmanın karışım modu, opaklığı veya görünürlüğü gibi belgenin tamamını değiştiren düzenlemeler, belgenin tamamını yeniden çizer.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Katmanları GPU bellek bütçesine uymuyor. Tercihler'de Bellek kullanımını artırma › Performans, bütçeyi bu bilgisayarın belleğinin dörtte birine kadar artırır.
|
||||
The GPU compositor wasn't used: {reason} GPU dizgicisi kullanılmadı: {reason}
|
||||
cameraRaw Light Açık Tonlar
|
||||
fontWeight Thin İnce
|
||||
@@ -3919,3 +3918,23 @@ cameraData Lens Lens
|
||||
There are too many sets: delete one first. Çok fazla küme var: önce birini silin.
|
||||
set name küme adı
|
||||
{set} (modified) {set} (değiştirildi)
|
||||
Accept and Go to Conflict Kabul Et ve Çakışmaya Git
|
||||
Changes undone. Değişiklikler geri alındı.
|
||||
Choose a new shortcut for this command, or leave it without one. Bu komut için yeni bir kısayol seçin veya kısayolsuz bırakın.
|
||||
Put this command's shortcuts back as they were Bu komutun kısayollarını eski haline getir
|
||||
Take the shortcut from the other command and select it Kısayolu diğer komuttan al ve o komutu seç
|
||||
Undo Changes Değişiklikleri Geri Al
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} sistem tarafından ayrılmıştır ve atanamaz.
|
||||
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)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -2679,7 +2679,6 @@ cameraRaw Roughness Шорсткість
|
||||
Redrawing Перемальовування
|
||||
This redraw took {seconds} s on the CPU. Це перемальовування на CPU тривало {seconds} с.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Зміни, що впливають на весь документ, як-от режим накладання, непрозорість чи видимість шару, перемальовують його повністю.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Його шари не вміщуються в бюджет пам'яті GPU. Збільшення використання пам'яті в Налаштування › Продуктивність підвищує бюджет до чверті пам'яті цього комп'ютера.
|
||||
The GPU compositor wasn't used: {reason} Компонувальник GPU не використовувався: {reason}
|
||||
fontWeight Thin Тонкий
|
||||
fontWeight ExtraLight Екстралегкий
|
||||
@@ -3888,3 +3887,23 @@ curves Smooth Згладити
|
||||
There are too many sets: delete one first. Забагато наборів: спершу видаліть один.
|
||||
set name назва набору
|
||||
{set} (modified) {set} (змінено)
|
||||
Accept and Go to Conflict Прийняти й перейти до конфлікту
|
||||
Changes undone. Зміни скасовано.
|
||||
Choose a new shortcut for this command, or leave it without one. Виберіть нове клавіатурне скорочення для цієї команди або залиште її без нього.
|
||||
Put this command's shortcuts back as they were Повернути клавіатурні скорочення цієї команди
|
||||
Take the shortcut from the other command and select it Забрати скорочення в іншої команди й вибрати її
|
||||
Undo Changes Скасувати зміни
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} зарезервовано системою, його не можна призначити.
|
||||
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 = автоматично)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1388 and column 18.
|
@@ -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
|
||||
@@ -3675,3 +3674,23 @@ curves Smooth Làm mượt
|
||||
There are too many sets: delete one first. Có quá nhiều bộ: hãy xóa một bộ trước.
|
||||
set name tên bộ
|
||||
{set} (modified) {set} (đã sửa đổi)
|
||||
Accept and Go to Conflict Chấp nhận và đến xung đột
|
||||
Changes undone. Đã hoàn tác thay đổi.
|
||||
Choose a new shortcut for this command, or leave it without one. Chọn phím tắt mới cho lệnh này, hoặc để lệnh không có phím tắt.
|
||||
Put this command's shortcuts back as they were Khôi phục phím tắt của lệnh này như cũ
|
||||
Take the shortcut from the other command and select it Lấy phím tắt khỏi lệnh kia và chọn lệnh đó
|
||||
Undo Changes Hoàn tác thay đổi
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} được hệ thống dành riêng và không thể gán.
|
||||
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)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2454,7 +2454,6 @@ cameraRaw Blending 混合
|
||||
Redrawing 正在重绘
|
||||
This redraw took {seconds} s on the CPU. 此次 CPU 重绘耗时 {seconds} 秒。
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. 更改整个文档的编辑(如图层的混合模式、不透明度或可见性)会重绘整个文档。
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. 图层超出 GPU 内存预算。在 首选项 › 性能 中提高内存使用量可增加预算,最多为本机内存的四分之一。
|
||||
The GPU compositor wasn't used: {reason} 未使用 GPU 合成器:{reason}
|
||||
fontWeight Thin 极细
|
||||
fontWeight ExtraLight 特细
|
||||
@@ -2805,3 +2804,23 @@ curves Smooth 平滑
|
||||
set name 组名称
|
||||
{set} (modified) {set}(已修改)
|
||||
Random 随机
|
||||
Accept and Go to Conflict 接受并转到冲突
|
||||
Changes undone. 已撤消更改。
|
||||
Choose a new shortcut for this command, or leave it without one. 为此命令选择新的快捷键,或保留为无快捷键。
|
||||
Put this command's shortcuts back as they were 将此命令的快捷键恢复原样
|
||||
Take the shortcut from the other command and select it 从另一个命令移除该快捷键并选中它
|
||||
Undo Changes 撤消更改
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} 由系统保留,无法指定。
|
||||
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 = 自动)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2599 and column 53.
|
@@ -2424,7 +2424,6 @@ cameraRaw Blending 混合
|
||||
Redrawing 正在重繪
|
||||
This redraw took {seconds} s on the CPU. 此次 CPU 重繪耗時 {seconds} 秒。
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. 變更整份文件的編輯(例如圖層的混合模式、不透明度或可見度)會重繪整份文件。
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. 圖層超出 GPU 記憶體預算。在 偏好設定 › 效能 中提高記憶體使用量可增加預算,最多為本機記憶體的四分之一。
|
||||
The GPU compositor wasn't used: {reason} 未使用 GPU 合成器:{reason}
|
||||
fontWeight Thin 極細
|
||||
fontWeight ExtraLight 特細
|
||||
@@ -2790,3 +2789,23 @@ curves Smooth 平滑
|
||||
set name 組合名稱
|
||||
{set} (modified) {set}(已修改)
|
||||
Random 隨機
|
||||
Accept and Go to Conflict 接受並前往衝突
|
||||
Changes undone. 已還原變更。
|
||||
Choose a new shortcut for this command, or leave it without one. 為此指令選擇新的快速鍵,或保持沒有快速鍵。
|
||||
Put this command's shortcuts back as they were 將此指令的快速鍵恢復原狀
|
||||
Take the shortcut from the other command and select it 從另一個指令移除該快速鍵並選取它
|
||||
Undo Changes 還原變更
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} 由系統保留,無法指定。
|
||||
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 = 自動)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -35,8 +35,7 @@ pub fn plan(app: &PhotocraftApp, set: &KysSet) -> Plan {
|
||||
/// The set's tool keys matched to our tools by name (Photoshop's names are ours): tool
|
||||
/// shortcut id (`tools.select.move`) → letter, and the tools PhotoCraft doesn't have.
|
||||
pub fn plan_tools(set: &KysSet) -> (BTreeMap<String, String>, Vec<String>) {
|
||||
let tools: Vec<(String, &str)> = crate::state::Tool::ALL.into_iter().map(|t| (crate::tool_shortcuts::id(t), t.label())).collect();
|
||||
photocraft_engine::kys::plan_tools(tools.iter().map(|(id, name)| (id.as_str(), *name)), set)
|
||||
photocraft_engine::kys::plan_tools(photocraft_engine::shell_commands::TOOLS.iter().map(|t| (t.0, t.1)), set)
|
||||
}
|
||||
|
||||
/// Put the plan into the open Keyboard Shortcuts dialog (opening one when none is), as
|
||||
|
||||
@@ -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<u64> = 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");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user