mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-16 20:26:53 -04:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a4ed48354 | ||
|
|
dd69f0797d | ||
|
|
aecac608b4 | ||
|
|
c7d2c85faf | ||
|
|
9628290b36 | ||
|
|
db46713c2c | ||
|
|
06ad07ab9b | ||
|
|
f61ed26e14 | ||
|
|
840edc311c | ||
|
|
462adeadd2 | ||
|
|
8fe2c74f58 | ||
|
|
93f6376c5e | ||
|
|
e33384c9ba | ||
|
|
f360cb0b4a | ||
|
|
651647fdf6 | ||
|
|
f1c45b887e | ||
|
|
6a7fc88c79 | ||
|
|
97f50b4714 | ||
|
|
ad6463b50f | ||
|
|
3c75db7a80 | ||
|
|
19227022ca | ||
|
|
54a4782849 | ||
|
|
2a952f164c | ||
|
|
d12be8ecd0 | ||
|
|
58f855a40f | ||
|
|
6368e6a851 | ||
|
|
6d3443c098 | ||
|
|
297520b9c8 | ||
|
|
57437ea1a9 | ||
|
|
7053f703a5 | ||
|
|
fd1faf4e13 | ||
|
|
8f58ea4629 | ||
|
|
0cc161e2a8 | ||
|
|
3cb3580a46 | ||
|
|
b630d7cb9a | ||
|
|
2b3d05636f | ||
|
|
fdaa9b8316 | ||
|
|
8376363d8c | ||
|
|
b09aeae2e2 | ||
|
|
691a022b12 | ||
|
|
f0f0d6f58a | ||
|
|
3bd9bc72a7 | ||
|
|
b23a01aa8f | ||
|
|
4a93095939 | ||
|
|
e99f09b306 | ||
|
|
e5bf9c22c9 | ||
|
|
39904a27ce | ||
|
|
110d58bea9 | ||
|
|
60d682d65d | ||
|
|
6382148eb8 | ||
|
|
2ff756b51a | ||
|
|
fa17b6f4b1 | ||
|
|
7b1dd35b7b | ||
|
|
bf93459200 | ||
|
|
b9aa935d19 |
No files matched your search
@@ -12,7 +12,7 @@
|
||||
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
|
||||
|
||||
+132
-29
@@ -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
|
||||
@@ -43,7 +44,7 @@ jobs:
|
||||
name: select platforms
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
os: ${{ steps.pick.outputs.os }}
|
||||
legs: ${{ steps.pick.outputs.legs }}
|
||||
web: ${{ steps.pick.outputs.web }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -57,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
|
||||
@@ -72,20 +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:
|
||||
timeout-minutes: 100
|
||||
name: test (${{ matrix.os }})
|
||||
name: test (${{ matrix.os }}${{ matrix.part != 'all' && format(', {0}', matrix.part) || '' }})
|
||||
needs: platforms
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ${{ fromJSON(needs.platforms.outputs.os) }}
|
||||
include: ${{ fromJSON(needs.platforms.outputs.legs) }}
|
||||
env:
|
||||
# No debuginfo on Windows: writing PDBs for ~200 test binaries cost 2.2 of ~21 build minutes.
|
||||
# A panic still names its file and line; only backtrace frames lose theirs (Linux keeps
|
||||
# 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
|
||||
|
||||
@@ -108,21 +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
|
||||
|
||||
# The cheap checks run first: a format, lint, layering or stale-scorecard failure is red
|
||||
# after a few minutes instead of after the test passes (#6119). Clippy restores main's cache.
|
||||
# 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
|
||||
|
||||
# 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,
|
||||
@@ -131,36 +166,104 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo xtask scorecard --check
|
||||
|
||||
# cargo-nextest runs the test binaries in parallel (`cargo test` runs them one at a time) and
|
||||
# its `ci` profile (.config/nextest.toml) stops at the first failure and fails hung tests.
|
||||
# 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: [email protected]
|
||||
|
||||
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
|
||||
# nextest doesn't run doctests, so `cargo test --doc` does, with the same selection.
|
||||
# Separate native commands into steps so Windows reports either command's failure.
|
||||
# `--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
|
||||
run: cargo nextest run --workspace --locked --features heif --profile ci
|
||||
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
|
||||
run: cargo test --doc --workspace --locked --features heif
|
||||
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: [email protected]
|
||||
|
||||
# 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'
|
||||
if: matrix.variant == 'without heif'
|
||||
run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci
|
||||
|
||||
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
|
||||
# Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace
|
||||
# --features heif` of the Test step: any other package or feature selection resolves a
|
||||
# different feature set, recompiles the whole dependency graph into the same target dir and
|
||||
# runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
|
||||
# Only the tests of the crates that read `CRAFT_FONTS` run (the `-E` filter changes what runs,
|
||||
# not what is built); engine and the apps only pass text on to them (#6120).
|
||||
# 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: runner.os == 'Linux'
|
||||
if: matrix.variant == 'with craft-fonts'
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
@@ -168,12 +271,12 @@ jobs:
|
||||
path: craft-fonts
|
||||
|
||||
- name: Test with craft-fonts
|
||||
if: runner.os == 'Linux'
|
||||
if: matrix.variant == 'with craft-fonts'
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: >-
|
||||
cargo nextest run --workspace --locked --features heif --profile ci
|
||||
cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests
|
||||
-E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
|
||||
|
||||
corpus:
|
||||
|
||||
@@ -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
|
||||
+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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "photocraft-cli"
|
||||
description = "Headless Photocraft: convert, inspect, run commands, batch, MCP server"
|
||||
description = "Headless PhotoCraft: convert, inspect, run commands, batch, MCP server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Headless Photocraft command line. `run` is the whole program, so it can be
|
||||
//! Headless PhotoCraft command line. `run` is the whole program, so it can be
|
||||
//! tested in-process as well as through the binary.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
@@ -12,7 +12,7 @@ use photocraft_io::ExportOptions;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub const USAGE: &str = "\
|
||||
photocraft-cli: headless Photocraft
|
||||
photocraft-cli: headless PhotoCraft
|
||||
|
||||
USAGE:
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "photocraft-web"
|
||||
description = "Photocraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
description = "PhotoCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Photocraft in the browser.
|
||||
//! PhotoCraft in the browser.
|
||||
//!
|
||||
//! Runs the same [`photocraft_ui_egui::PhotocraftApp`] as the desktop app through eframe's web
|
||||
//! runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release`
|
||||
|
||||
@@ -4,7 +4,7 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
default-run = "photocraft"
|
||||
description = "Photocraft: an open-source, native image editor"
|
||||
description = "PhotoCraft: an open-source, native image editor"
|
||||
build = "build.rs"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -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;
|
||||
|
||||
+131
-6
@@ -1,8 +1,18 @@
|
||||
//! Photocraft desktop app.
|
||||
//! PhotoCraft desktop app.
|
||||
//!
|
||||
//! 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();
|
||||
|
||||
@@ -79,23 +79,48 @@ fn mirror(i: isize, n: usize) -> usize {
|
||||
|
||||
const TAPS: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
|
||||
|
||||
/// Planes smaller than this run their rows on one thread: the filter builds a whole pyramid per
|
||||
/// reference (up to 96), and splitting its small levels into one-row tasks cost more in rayon's
|
||||
/// scheduling than the rows themselves (a fifth of a Shadows render on 36 MP, measured).
|
||||
const PAR_MIN: usize = 1 << 15;
|
||||
|
||||
/// [`par_rows`] for a plane, on one thread when it is small (see [`PAR_MIN`]).
|
||||
fn rows(buf: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32]) + Sync + Send) {
|
||||
if buf.len() < PAR_MIN {
|
||||
buf.chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| f(y, row));
|
||||
} else {
|
||||
par_rows(buf, w, 1, f);
|
||||
}
|
||||
}
|
||||
|
||||
/// The binomial 5-tap blur, sampled every `step` pixels from 0 (1 = full resolution).
|
||||
fn blur_step(p: &Plane, step: usize) -> Plane {
|
||||
let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step));
|
||||
// Columns first at the kept rows, then rows at the kept columns.
|
||||
// Columns first at the kept rows, then rows at the kept columns. Each sum adds the same terms
|
||||
// in the same order as a `mirror` per tap would, so the result is bit for bit the same; the
|
||||
// source rows are found once per row and the columns mirrored only near the edges.
|
||||
let mut tmp = vec![0.0f32; p.w * h2];
|
||||
par_rows(&mut tmp, p.w, 1, |y2, row| {
|
||||
rows(&mut tmp, p.w, |y2, row| {
|
||||
let y = (y2 * step) as isize;
|
||||
let src: [&[f32]; 5] = std::array::from_fn(|k| {
|
||||
let r = mirror(y + k as isize - 2, p.h);
|
||||
&p.v[r * p.w..(r + 1) * p.w]
|
||||
});
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
*o = (0..5).map(|k| TAPS[k] * p.v[mirror(y + k as isize - 2, p.h) * p.w + x]).sum();
|
||||
*o = (0..5).map(|k| TAPS[k] * src[k][x]).sum();
|
||||
}
|
||||
});
|
||||
let mut out = vec![0.0f32; w2 * h2];
|
||||
par_rows(&mut out, w2, 1, |y2, row| {
|
||||
rows(&mut out, w2, |y2, row| {
|
||||
let src = &tmp[y2 * p.w..(y2 + 1) * p.w];
|
||||
for (x2, o) in row.iter_mut().enumerate() {
|
||||
let x = (x2 * step) as isize;
|
||||
*o = (0..5).map(|k| TAPS[k] * src[mirror(x + k as isize - 2, p.w)]).sum();
|
||||
let x = x2 * step;
|
||||
*o = if x >= 2 && x + 2 < p.w {
|
||||
let s = &src[x - 2..x + 3];
|
||||
(0..5).map(|k| TAPS[k] * s[k]).sum()
|
||||
} else {
|
||||
(0..5).map(|k| TAPS[k] * src[mirror(x as isize + k as isize - 2, p.w)]).sum()
|
||||
};
|
||||
}
|
||||
});
|
||||
Plane { w: w2, h: h2, v: out }
|
||||
@@ -107,14 +132,45 @@ fn down(p: &Plane) -> Plane {
|
||||
}
|
||||
|
||||
/// `p` brought up to `w × h`: zeros between samples, then the blur times four.
|
||||
///
|
||||
/// Three samples in four are zero, so their taps are skipped instead of blurring a zero-filled
|
||||
/// plane: the nonzero terms are added in the same order (skipping `+0.0` terms changes at most the
|
||||
/// sign of a zero, and the filter only adds and exponentiates these values).
|
||||
fn up(p: &Plane, w: usize, h: usize) -> Plane {
|
||||
let mut z = Plane { w, h, v: vec![0.0; w * h] };
|
||||
for y in 0..p.h.min(h.div_ceil(2)) {
|
||||
for x in 0..p.w.min(w.div_ceil(2)) {
|
||||
z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0;
|
||||
}
|
||||
let (lh, lw) = (p.h.min(h.div_ceil(2)), p.w.min(w.div_ceil(2)));
|
||||
// The source sample behind index `i` of a zero-stuffed axis of length `n` holding `lim` samples.
|
||||
let src = |i: isize, n: usize, lim: usize| {
|
||||
let m = mirror(i, n);
|
||||
(m.is_multiple_of(2) && m / 2 < lim).then_some(m / 2)
|
||||
};
|
||||
// Columns first, only at the even columns (the odd ones are all zero).
|
||||
let mut tmp = vec![0.0f32; lw * h];
|
||||
if lw > 0 {
|
||||
rows(&mut tmp, lw, |y, row| {
|
||||
let taps: [Option<&[f32]>; 5] = std::array::from_fn(|k| src(y as isize + k as isize - 2, h, lh).map(|r| &p.v[r * p.w..r * p.w + lw]));
|
||||
for (j, o) in row.iter_mut().enumerate() {
|
||||
*o = (0..5).filter_map(|k| taps[k].map(|s| TAPS[k] * (s[j] * 4.0))).sum();
|
||||
}
|
||||
});
|
||||
}
|
||||
blur_step(&z, 1)
|
||||
let mut out = vec![0.0f32; w * h];
|
||||
rows(&mut out, w, |y, row| {
|
||||
let t = &tmp[y * lw..(y + 1) * lw];
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
*o = if x >= 2 && x + 2 < w {
|
||||
// Away from the edges no tap mirrors.
|
||||
(0..5)
|
||||
.filter_map(|k| {
|
||||
let i = x + k - 2;
|
||||
(i.is_multiple_of(2) && i / 2 < lw).then(|| TAPS[k] * t[i / 2])
|
||||
})
|
||||
.sum()
|
||||
} else {
|
||||
(0..5).filter_map(|k| src(x as isize + k as isize - 2, w, lw).map(|j| TAPS[k] * t[j])).sum()
|
||||
};
|
||||
}
|
||||
});
|
||||
Plane { w, h, v: out }
|
||||
}
|
||||
|
||||
/// Levels down to about 2 pixels on the short side.
|
||||
@@ -154,7 +210,7 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
|
||||
for k in 0..refs {
|
||||
let r = lo + step * k as f32;
|
||||
let mut m = Plane { w: u.w, h: u.h, v: u.v.clone() };
|
||||
par_rows(&mut m.v, u.w, 1, |_, row| {
|
||||
rows(&mut m.v, u.w, |_, row| {
|
||||
for v in row.iter_mut() {
|
||||
*v = remap(*v, r);
|
||||
}
|
||||
@@ -163,7 +219,7 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
|
||||
for l in 0..n - 1 {
|
||||
let coarse = up(&gr[l + 1], gr[l].w, gr[l].h);
|
||||
let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w);
|
||||
par_rows(&mut lap[l], lw, 1, |y, row| {
|
||||
rows(&mut lap[l], lw, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let i = y * lw + x;
|
||||
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
|
||||
@@ -337,6 +393,62 @@ mod tests {
|
||||
|
||||
const SRGB: Encoding = Encoding(0.0);
|
||||
|
||||
/// The plain blur: a `mirror` per tap over the whole plane, the reference for [`blur_step`].
|
||||
fn blur_step_reference(p: &Plane, step: usize) -> Plane {
|
||||
let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step));
|
||||
let mut tmp = vec![0.0f32; p.w * h2];
|
||||
for y2 in 0..h2 {
|
||||
for x in 0..p.w {
|
||||
tmp[y2 * p.w + x] = (0..5).map(|k| TAPS[k] * p.v[mirror((y2 * step) as isize + k as isize - 2, p.h) * p.w + x]).sum();
|
||||
}
|
||||
}
|
||||
let mut out = vec![0.0f32; w2 * h2];
|
||||
for y2 in 0..h2 {
|
||||
for x2 in 0..w2 {
|
||||
out[y2 * w2 + x2] = (0..5).map(|k| TAPS[k] * tmp[y2 * p.w + mirror((x2 * step) as isize + k as isize - 2, p.w)]).sum();
|
||||
}
|
||||
}
|
||||
Plane { w: w2, h: h2, v: out }
|
||||
}
|
||||
|
||||
/// The plain upsampling: a zero-stuffed plane blurred whole, the reference for [`up`].
|
||||
fn up_reference(p: &Plane, w: usize, h: usize) -> Plane {
|
||||
let mut z = Plane { w, h, v: vec![0.0; w * h] };
|
||||
for y in 0..p.h.min(h.div_ceil(2)) {
|
||||
for x in 0..p.w.min(w.div_ceil(2)) {
|
||||
z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0;
|
||||
}
|
||||
}
|
||||
blur_step_reference(&z, 1)
|
||||
}
|
||||
|
||||
fn noise_plane(w: usize, h: usize, seed: u32) -> Plane {
|
||||
let mut s = seed.wrapping_mul(2_654_435_761).max(1);
|
||||
let v = (0..w * h)
|
||||
.map(|_| {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
(s % 20_000) as f32 / 1000.0 - 16.0
|
||||
})
|
||||
.collect();
|
||||
Plane { w, h, v }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fast_pyramid_matches_the_plain_one_exactly() {
|
||||
// Odd, even, tiny and large-enough-to-run-in-parallel sizes, as the pyramid's levels are.
|
||||
for (i, (w, h)) in [(1, 1), (1, 7), (2, 2), (3, 5), (5, 3), (8, 6), (37, 23), (64, 64), (301, 211)].into_iter().enumerate() {
|
||||
let p = noise_plane(w, h, i as u32 + 1);
|
||||
for step in [1, 2] {
|
||||
assert_eq!(blur_step(&p, step).v, blur_step_reference(&p, step).v, "blur {w}×{h} step {step}");
|
||||
}
|
||||
for (uw, uh) in [(w * 2, h * 2), (w * 2 - 1, h * 2 - 1), (w * 2, h * 2 - 1), (w.max(2) * 2 - 3, h * 2)] {
|
||||
assert_eq!(up(&p, uw, uh).v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> {
|
||||
(0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "photocraft-automation"
|
||||
description = "MCP server (rmcp) over the Photocraft engine, plus a bridge to a running GUI"
|
||||
description = "MCP server (rmcp) over the PhotoCraft engine, plus a bridge to a running GUI"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Photocraft colour management: a pure-Rust ICC v2/v4 colour management module.
|
||||
//! PhotoCraft colour management: a pure-Rust ICC v2/v4 colour management module.
|
||||
//!
|
||||
//! * [`Profile`] parses ICC profiles (matrix/TRC RGB, gray TRC, `mft1`/`mft2`/`mAB `/`mBA `
|
||||
//! LUT profiles incl. CMYK↔Lab, `curv`/`para` curves) and writes ICC v4 profiles.
|
||||
|
||||
@@ -245,7 +245,7 @@ fn smooth(x: f32) -> f32 {
|
||||
x * x * (3.0 - 2.0 * x)
|
||||
}
|
||||
|
||||
/// Generated looks shipped with Photocraft (procedural, CC0): `(id, label)`.
|
||||
/// Generated looks shipped with PhotoCraft (procedural, CC0): `(id, label)`.
|
||||
pub const BUILTIN: [(&str, &str); 8] = [
|
||||
("warm", "Warm Filter"),
|
||||
("cool", "Cool Filter"),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39 …) are proprietary, and the
|
||||
//! freely downloadable characterisation-based profiles come with redistribution conditions, so
|
||||
//! Photocraft ships a CMYK profile it generates itself from a documented printer model:
|
||||
//! PhotoCraft ships a CMYK profile it generates itself from a documented printer model:
|
||||
//!
|
||||
//! * **Forward model** (CMYK → XYZ): Yule–Nielsen modified Neugebauer (n = 2) over the 16
|
||||
//! ink overprints, with Demichel coverage weights and a parabolic tone value increase (dot
|
||||
|
||||
@@ -66,6 +66,21 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// PAM ends its header with an LF-terminated line, unlike P4–P6's single separator.
|
||||
fn pam_endhdr(&mut self) -> Result<(), CodecError> {
|
||||
while let Some(&c) = self.b.get(self.pos) {
|
||||
self.pos += 1;
|
||||
if c == b'\n' {
|
||||
// Stop at the first LF: whitespace and '#' after it are raster bytes.
|
||||
return Ok(());
|
||||
}
|
||||
if !c.is_ascii_whitespace() {
|
||||
return Err(err("unexpected data after PAM ENDHDR"));
|
||||
}
|
||||
}
|
||||
Err(err("unterminated PAM ENDHDR line"))
|
||||
}
|
||||
|
||||
fn rest(&self) -> &'a [u8] {
|
||||
&self.b[self.pos.min(self.b.len())..]
|
||||
}
|
||||
@@ -269,7 +284,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result<Image, CodecError>
|
||||
_ => return Err(err("unknown PAM header field")),
|
||||
}
|
||||
}
|
||||
r.single_ws()?;
|
||||
r.pam_endhdr()?;
|
||||
let (w, h, depth, maxval) = match (w, h, depth, maxval) {
|
||||
(Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d),
|
||||
_ => return Err(err("incomplete PAM header")),
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
use photocraft_codecs::{ChannelLayout, CodecError, Format, SampleType, decode, decode_as};
|
||||
|
||||
const PAM_GRAY: &[u8] = b"P7\nWIDTH 1\nHEIGHT 1\nDEPTH 1\nMAXVAL 255\nENDHDR";
|
||||
const ENDINGS: &[&[u8]] = &[b"\n", b" \n", b"\t \n", b"\r\n", b" \t\r\n"];
|
||||
|
||||
#[test]
|
||||
fn pam_consumes_the_complete_endhdr_line() {
|
||||
for ending in ENDINGS {
|
||||
let mut bytes = PAM_GRAY.to_vec();
|
||||
bytes.extend_from_slice(ending);
|
||||
bytes.push(0x80);
|
||||
let img = decode(&bytes).unwrap();
|
||||
assert_eq!(img.dimensions(), (1, 1));
|
||||
assert_eq!(img.layout(), ChannelLayout::Gray);
|
||||
assert_eq!(img.data(), &[0x80], "ENDHDR ending {ending:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pam_preserves_every_possible_first_raster_byte() {
|
||||
// In particular, LF, CR, space, tab and '#' must not be skipped as header syntax.
|
||||
for ending in ENDINGS {
|
||||
for first in 0..=255u8 {
|
||||
let mut bytes = PAM_GRAY.to_vec();
|
||||
bytes.extend_from_slice(ending);
|
||||
bytes.push(first);
|
||||
assert_eq!(decode(&bytes).unwrap().data(), &[first], "ENDHDR ending {ending:?}, raster byte {first}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pam_crlf_header_preserves_u16_samples_and_alpha() {
|
||||
for ending in ENDINGS {
|
||||
let mut bytes = b"P7\r\nWIDTH 1\r\nHEIGHT 1\r\nDEPTH 2\r\nMAXVAL 65535\r\nTUPLTYPE GRAYSCALE_ALPHA\r\nENDHDR".to_vec();
|
||||
bytes.extend_from_slice(ending);
|
||||
bytes.extend_from_slice(b" \n#\r");
|
||||
let img = decode(&bytes).unwrap();
|
||||
assert_eq!(img.layout(), ChannelLayout::GrayA);
|
||||
assert_eq!(img.sample_type(), SampleType::U16);
|
||||
assert_eq!(img.to_u16_samples().unwrap(), vec![0x200a, 0x230d], "ENDHDR ending {ending:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pam_rejects_unterminated_or_invalid_endhdr_lines() {
|
||||
for suffix in [b"".as_slice(), b" ", b"\r", b" \t\r", b" \x80", b" \x80\n\x7f", b"#comment\n\x7f"] {
|
||||
let mut bytes = PAM_GRAY.to_vec();
|
||||
bytes.extend_from_slice(suffix);
|
||||
assert!(matches!(decode_as(Format::Pnm, &bytes), Err(CodecError::Malformed { .. })), "ENDHDR suffix {suffix:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pam_truncation_never_uses_header_whitespace_as_pixels() {
|
||||
for ending in ENDINGS {
|
||||
let mut bytes = PAM_GRAY.to_vec();
|
||||
bytes.extend_from_slice(ending);
|
||||
bytes.push(0x80);
|
||||
for cut in 0..bytes.len() {
|
||||
assert!(decode_as(Format::Pnm, &bytes[..cut]).is_err(), "ENDHDR ending {ending:?}, truncated at {cut}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_pnm_keeps_its_single_whitespace_separator() {
|
||||
for separator in *b" \t\r\n" {
|
||||
for first in *b" \t\r\n#" {
|
||||
for (header, channels) in [(b"P5\n1 1\n255".as_slice(), 1), (b"P6\n1 1\n255", 3)] {
|
||||
let mut bytes = header.to_vec();
|
||||
bytes.push(separator);
|
||||
bytes.extend(std::iter::repeat_n(first, channels));
|
||||
assert_eq!(decode(&bytes).unwrap().data(), vec![first; channels]);
|
||||
}
|
||||
let mut bytes = b"P4\n8 1".to_vec();
|
||||
bytes.extend_from_slice(&[separator, first]);
|
||||
let expected: Vec<u8> = (0..8).map(|bit| if first & (0x80 >> bit) == 0 { 255 } else { 0 }).collect();
|
||||
assert_eq!(decode(&bytes).unwrap().data(), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The Photocraft document model: pure data, no rendering, no UI.
|
||||
//! The PhotoCraft document model: pure data, no rendering, no UI.
|
||||
//!
|
||||
//! Layer order: inside every group (and the root), `children[0]` is the **bottom** layer, matching
|
||||
//! compositing order and the PSD file order. UIs display the list reversed.
|
||||
|
||||
@@ -528,7 +528,7 @@ fn chosen_artboards(doc: &Document, p: &Value) -> Vec<LayerId> {
|
||||
}
|
||||
}
|
||||
|
||||
/// File › Export › Artboards to Files: `<prefix>_<artboard name>.<format>` per board.
|
||||
/// File › Export › Artboards to Files: `<prefix>_<index:04>_<artboard name>.<format>` per board.
|
||||
fn artboards_to_files(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.export.artboardsToFiles";
|
||||
let dir = str_param(p, "dir", cmd).or_else(|_| str_param(p, "output", cmd))?.to_string();
|
||||
@@ -538,8 +538,9 @@ fn artboards_to_files(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name));
|
||||
let doc = d.doc.clone();
|
||||
let mut files = Vec::new();
|
||||
for one in chosen_documents(&doc, p)? {
|
||||
let name = if prefix.is_empty() { sanitize(&one.name) } else { format!("{}_{}", sanitize(&prefix), sanitize(&one.name)) };
|
||||
for (i, one) in chosen_documents(&doc, p)?.into_iter().enumerate() {
|
||||
// Names may be identical, or collide after sanitizing or filesystem case folding.
|
||||
let name = if prefix.is_empty() { format!("{i:04}_{}", sanitize(&one.name)) } else { format!("{}_{i:04}_{}", sanitize(&prefix), sanitize(&one.name)) };
|
||||
let path = join(&dir, &format!("{name}.{format}"));
|
||||
save_doc(&one, &path, save)?;
|
||||
files.push(path);
|
||||
|
||||
@@ -146,8 +146,8 @@ fn export_to_files_and_pdf() {
|
||||
let r = s.execute("file.export.artboardsToFiles", json!({"dir": d, "prefix": "site"})).unwrap();
|
||||
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(files.len(), 2);
|
||||
assert!(files[0].ends_with("site_Empty_One.png"), "{files:?}");
|
||||
assert!(files[1].ends_with("site_Red Board.png"), "{files:?}");
|
||||
assert!(files[0].ends_with("site_0000_Empty_One.png"), "{files:?}");
|
||||
assert!(files[1].ends_with("site_0001_Red Board.png"), "{files:?}");
|
||||
let img = photocraft_io::import("x.png", &std::fs::read(&files[1]).unwrap()).unwrap().document;
|
||||
assert_eq!((img.size.width, img.size.height), (10, 10));
|
||||
let img2 = photocraft_io::import("x.png", &std::fs::read(&files[0]).unwrap()).unwrap().document;
|
||||
|
||||
@@ -863,9 +863,13 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Paint amount = darkness × alpha × selection (black = full paint, like Photoshop).
|
||||
let (w, h) = (area.width() as usize, area.height() as usize);
|
||||
let mut v = vec![0.0f32; w * h];
|
||||
// The sampled tip is one f32 buffer over the whole selection; a large selection must fail
|
||||
// gracefully instead of aborting the process on allocation, so reserve it fallibly.
|
||||
let mut v = crate::allocation::filled(w * h, 0.0, "defining a brush from the selection")?;
|
||||
let sc = sel.channels();
|
||||
let sv = sel.read_region(area);
|
||||
// The selection mask is a second buffer of the same size; it must fail gracefully too
|
||||
// (review of #5469), so read it through the fallible allocator.
|
||||
let sv = crate::allocation::read_region(sel, area, "defining a brush from the selection")?;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let c = surf.rgba(area.x0 + x as i32, area.y0 + y as i32);
|
||||
|
||||
@@ -442,6 +442,26 @@ fn define_brush_from_selection() {
|
||||
assert!(rgba(&s, 30, 32)[3] > 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn define_brush_from_selection_survives_allocation_failure() {
|
||||
// The sampled tip is one f32 buffer over the whole selection, and the selection mask is a
|
||||
// second of the same size. Both are reserved through the fallible allocator, so an allocation
|
||||
// failure must be a graceful error (brush, presets, history and document untouched), never a
|
||||
// panic or an OOM abort.
|
||||
let mut s = session(40, 40);
|
||||
s.execute("select.rect", json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
|
||||
let brush_before = s.tools.brush.clone();
|
||||
let presets_before = s.tools.presets.clone();
|
||||
let history_before = s.active().unwrap().history.past_len();
|
||||
crate::allocation::fail_next_for_test();
|
||||
let result = s.execute("brush.defineFromSelection", json!({"name": "Blob"}));
|
||||
let _ = crate::allocation::capacity::<u8>(0, "reset failpoint");
|
||||
assert!(result.is_err(), "an allocation failure must be reported, not panic");
|
||||
assert_eq!(s.tools.brush, brush_before, "the current brush is unchanged");
|
||||
assert_eq!(s.tools.presets, presets_before, "the preset list is unchanged");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), history_before, "no history step was recorded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn works_on_cmyk_and_16_bit() {
|
||||
for (mode, depth) in [("cmyk", 8), ("rgb", 16), ("grayscale", 32)] {
|
||||
|
||||
@@ -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()] }]);
|
||||
|
||||
@@ -1487,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;
|
||||
@@ -421,6 +422,14 @@ pub struct CopiedStyle {
|
||||
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>,
|
||||
@@ -659,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)
|
||||
@@ -723,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)]
|
||||
|
||||
+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,103 @@
|
||||
//! #6613: each artboard must have its own output even when names collide on disk.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
struct Board {
|
||||
id: u64,
|
||||
width: u32,
|
||||
color: [f32; 4],
|
||||
}
|
||||
|
||||
fn fixture(depth: u32, names: &[&str]) -> (Session, Vec<Board>) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"name": "Board Export", "width": 96, "height": 32, "depth": depth})).unwrap();
|
||||
let mut boards = Vec::new();
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
let width = 3 + i as u32;
|
||||
let color = if i % 2 == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] };
|
||||
let result = s.execute("layer.new.artboard", json!({"name": name, "rect": [12 * i, 0, width, 2], "background": "custom", "color": color})).unwrap();
|
||||
boards.push(Board { id: result["layer"].as_u64().unwrap(), width, color });
|
||||
}
|
||||
(s, boards)
|
||||
}
|
||||
|
||||
fn check_export(depth: u32, names: &[&str], prefix: Option<&str>, format: &str, selected: Option<&[usize]>) {
|
||||
let (mut s, boards) = fixture(depth, names);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut params = json!({"dir": dir.path(), "format": format});
|
||||
if let Some(prefix) = prefix {
|
||||
params["prefix"] = json!(prefix);
|
||||
}
|
||||
if let Some(selected) = selected {
|
||||
params["artboards"] = json!(selected.iter().map(|i| boards[*i].id).collect::<Vec<_>>());
|
||||
}
|
||||
let expected: Vec<_> = boards.iter().enumerate().rev().filter(|(i, _)| selected.is_none_or(|ids| ids.contains(i))).map(|(_, b)| b).collect();
|
||||
let before = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap();
|
||||
let revision = s.active().unwrap().revision;
|
||||
let history = s.active().unwrap().history.past_len();
|
||||
let result = s.execute("file.export.artboardsToFiles", params).unwrap();
|
||||
let files: Vec<_> = result["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
|
||||
assert_eq!(files.len(), expected.len());
|
||||
// Also require uniqueness on filesystems that compare names without letter case.
|
||||
assert_eq!(files.iter().map(|f| f.to_lowercase()).collect::<HashSet<_>>().len(), files.len(), "export paths collide: {files:?}");
|
||||
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), files.len(), "one physical output per returned path");
|
||||
for (file, board) in files.iter().zip(expected) {
|
||||
assert_eq!(Path::new(file).parent(), Some(dir.path()));
|
||||
let bytes = std::fs::read(file).unwrap();
|
||||
let doc = photocraft_io::import(file, &bytes).unwrap().document;
|
||||
assert_eq!((doc.size.width, doc.size.height), (board.width, 2), "wrong artboard in {file}");
|
||||
let px = photocraft_compose::flatten(&doc).px[0];
|
||||
for (actual, expected) in px.iter().zip(board.color) {
|
||||
assert!((actual - expected).abs() < 1e-3, "wrong pixels in {file}: {px:?}");
|
||||
}
|
||||
}
|
||||
let after = s.active().unwrap();
|
||||
assert_eq!(photocraft_format::save_to_bytes(&after.doc, &Default::default()).unwrap(), before);
|
||||
assert_eq!(after.revision, revision, "export must not edit the source document");
|
||||
assert_eq!(after.history.past_len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_names_preserve_each_board_at_every_depth_and_prefix() {
|
||||
for depth in [8, 16, 32] {
|
||||
for prefix in [None, Some(""), Some("Launch/Run")] {
|
||||
check_export(depth, &["Artboard 1", "Artboard 1"], prefix, "png", None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitized_and_empty_names_preserve_each_tiff() {
|
||||
check_export(16, &["Hero/1", "Hero:1", "Hero?1", ".", " ", "layer"], Some(""), "tiff", None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_only_and_index_like_names_remain_distinct() {
|
||||
check_export(8, &["Logo", "logo", "Logo_0001", "0000_Logo", "Лого", "лого"], Some("site"), "png", None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_duplicate_names_keep_their_psd_contents_in_stack_order() {
|
||||
check_export(16, &["Same", "Skip", "Same"], Some("chosen"), "psd", Some(&[0, 2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_names_keep_their_corresponding_contents() {
|
||||
check_export(8, &["Red", "Blue"], None, "png", None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_export_params_fail_before_creating_files() {
|
||||
let (mut s, _) = fixture(8, &["One", "Two"]);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for params in [json!({"dir": 3}), json!({"dir": dir.path(), "artboards": []}), json!({"dir": dir.path(), "artboards": [u64::MAX]})] {
|
||||
assert!(s.execute("file.export.artboardsToFiles", params).is_err());
|
||||
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
|
||||
}
|
||||
assert!(Session::new().execute("file.export.artboardsToFiles", json!({"dir": dir.path()})).is_err());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Geometry primitives shared by every Photocraft crate.
|
||||
//! Geometry primitives shared by every PhotoCraft crate.
|
||||
//!
|
||||
//! Document space is integer pixels with the origin at the canvas top-left.
|
||||
//! Layers may extend beyond the canvas (negative coordinates are valid).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Photocraft GPU compositor kernels.
|
||||
// PhotoCraft GPU compositor kernels.
|
||||
//
|
||||
// Every pass renders one chunk of the canvas into a straight-alpha RGBA32F accumulator, reading
|
||||
// its inputs with `textureLoad` at the same pixel. Effect-map kernels (`fs_m*`) render
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -181,7 +181,8 @@ fn layer_surface(img: &Image, max: &mut (u32, u32)) -> Result<(Surface, ColorMod
|
||||
}
|
||||
|
||||
/// Imports the chosen entries as the layers of one document: parts whole, groups from their
|
||||
/// own channels, F16 kept losslessly as F32, the linear-light EXR values tagged linear sRGB.
|
||||
/// own channels, F16 kept losslessly as F32, the linear-light EXR values tagged linear sRGB
|
||||
/// or linear gray.
|
||||
/// `precomp` adds the additive sum of the file's AOV groups as a top layer and fails,
|
||||
/// naming the missing AOVs, when the light-path set is incomplete.
|
||||
pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result<ImportResult, IoError> {
|
||||
@@ -270,10 +271,9 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result<ImportResult,
|
||||
let mode = if gray_only { ColorMode::Grayscale } else { ColorMode::Rgb };
|
||||
let mut doc = Document::new(name, Size::new(max.0, max.1), mode, DocSample::F32);
|
||||
doc.layers = layers;
|
||||
if mode == ColorMode::Rgb {
|
||||
// OpenEXR holds linear, scene-referred values: tag them linear sRGB, as File › Open does.
|
||||
doc.icc_profile = Some(Builtin::LinearSrgb.profile().to_bytes());
|
||||
}
|
||||
// OpenEXR holds linear, scene-referred values (Y is linear luminance): use the profile
|
||||
// matching the selected parts' mode, as File › Open does.
|
||||
doc.icc_profile = Some(if gray_only { photocraft_cms::builtin::linear_gray().to_bytes() } else { Builtin::LinearSrgb.profile().to_bytes() });
|
||||
Ok(ImportResult { document: doc, warnings, source_read_only: false, preview_only: false })
|
||||
}
|
||||
|
||||
|
||||
@@ -559,9 +559,10 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti
|
||||
} else if let Some(linear) = convert_gray(&img, photocraft_cms::builtin::linear_gray(), CSample::F32)? {
|
||||
img = linear;
|
||||
}
|
||||
} else if !format.caps().icc {
|
||||
// Untagged files read back as sRGB/sGray: convert to it (as Quick Export does) rather than
|
||||
// write values that only mean something under the dropped profile.
|
||||
} else if !format.caps().icc || (img.layout().is_gray() && !format.caps().layouts.iter().any(|l| l.is_gray())) {
|
||||
// Untagged files read back as sRGB/sGray. Even an ICC-capable format like WebP loses a
|
||||
// gray profile when the codec expands gray to RGB. Convert first (as Quick Export does)
|
||||
// rather than write values that only mean something under the dropped profile.
|
||||
if let Some(srgb) = convert_rgb(&img, Builtin::Srgb.profile(), Intent::Perceptual, true, img.sample_type())? {
|
||||
img = srgb;
|
||||
warnings.push(format!("colours converted to sRGB; {format:?} can't embed the document's colour profile"));
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
+32
-5
@@ -258,9 +258,23 @@ fn props(node: roxmltree::Node<'_, '_>, layer: &mut Layer, index: usize, warning
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// OpenRaster 0.0.6 defaults to isolated groups and makes `auto` explicitly pass-through.
|
||||
/// Older files used inference from the group's blend mode; keep that behavior when the
|
||||
/// version is absent or unrecognized, too. Compare the numeric core, not version strings.
|
||||
fn uses_explicit_group_isolation(version: Option<&str>) -> bool {
|
||||
let Some(core) = version.and_then(|v| v.trim().split(['-', '+']).next()) else { return false };
|
||||
let mut parts = core.split('.');
|
||||
let number = |part: Option<&str>| part.and_then(|s| s.parse::<u32>().ok());
|
||||
match (number(parts.next()), number(parts.next()), number(parts.next()), parts.next()) {
|
||||
(Some(major), Some(minor), Some(patch), None) => [major, minor, patch] >= [0, 0, 6],
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
struct Reader<'a, 'c> {
|
||||
zip: ZipReader<'a>,
|
||||
ctl: &'c Interrupt<'c>,
|
||||
explicit_group_isolation: bool,
|
||||
nodes: usize,
|
||||
budget: usize,
|
||||
warnings: Warnings,
|
||||
@@ -325,10 +339,15 @@ impl Reader<'_, '_> {
|
||||
"stack" => {
|
||||
let mut layer = Layer::group("", Vec::new());
|
||||
props(child, &mut layer, index, &mut self.warnings)?;
|
||||
// `auto` (the default) isolates only when something needs it: an
|
||||
// OpenRaster group without its own blend mode is a pass-through group.
|
||||
let isolate = child.attribute("isolation").map(str::trim) == Some("isolate");
|
||||
if !isolate && layer.blend == BlendMode::Normal {
|
||||
let isolation = child.attribute("isolation").map(str::trim);
|
||||
let pass_through = if self.explicit_group_isolation {
|
||||
// Since 0.0.6 only explicit `auto` passes through, ignoring the
|
||||
// group's composite-op. An omitted isolation means `isolate`.
|
||||
isolation == Some("auto")
|
||||
} else {
|
||||
isolation != Some("isolate") && layer.blend == BlendMode::Normal
|
||||
};
|
||||
if pass_through {
|
||||
layer.blend = BlendMode::PassThrough;
|
||||
}
|
||||
let children = self.stack(child, depth + 1)?;
|
||||
@@ -415,7 +434,15 @@ pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result<Im
|
||||
return Err(invalid("the canvas exceeds the 1 GiB pixel limit"));
|
||||
}
|
||||
let root = image.children().find(|n| n.has_tag_name("stack")).ok_or_else(|| invalid("stack.xml has no root <stack>"))?;
|
||||
let mut reader = Reader { zip, ctl, nodes: 0, budget: MAX_PIXEL_BYTES, warnings: Warnings::default(), images: Vec::new() };
|
||||
let mut reader = Reader {
|
||||
zip,
|
||||
ctl,
|
||||
explicit_group_isolation: uses_explicit_group_isolation(image.attribute("version")),
|
||||
nodes: 0,
|
||||
budget: MAX_PIXEL_BYTES,
|
||||
warnings: Warnings::default(),
|
||||
images: Vec::new(),
|
||||
};
|
||||
let nodes = reader.stack(root, 1)?;
|
||||
ctl.progress(0.8);
|
||||
let depth = document_depth(&reader.images);
|
||||
|
||||
@@ -205,6 +205,84 @@ fn gray16_png(w: u32, h: u32) -> Vec<u8> {
|
||||
codecs::encode(&Image::from_raw(w, h, ChannelLayout::Gray, CSample::U16, data).unwrap(), Format::Png, &Default::default()).unwrap()
|
||||
}
|
||||
|
||||
/// A green Multiply layer over a red backdrop distinguishes isolation (green) from
|
||||
/// pass-through (black), even though both groups use Normal compositing by default.
|
||||
fn group_isolation_document(version: Option<&str>, attributes: &str, depth: SampleType) -> Document {
|
||||
let version = version.map(|v| format!("version=\"{v}\"")).unwrap_or_default();
|
||||
let xml = format!(
|
||||
r#"<image {version} w="1" h="1"><stack>
|
||||
<stack name="Group" {attributes}>
|
||||
<layer name="Multiply" src="green.png" composite-op="svg:multiply"/>
|
||||
</stack>
|
||||
<layer name="Backdrop" src="red.png"/>
|
||||
</stack></image>"#
|
||||
);
|
||||
let fmt = PixelFormat::new(ColorMode::Rgb, depth, true);
|
||||
let png = |rgba| {
|
||||
let image = Image::from_raw(1, 1, ChannelLayout::Rgba, csample(depth), px(fmt, rgba)).unwrap();
|
||||
codecs::encode(&image, Format::Png, &Default::default()).unwrap()
|
||||
};
|
||||
let bytes = archive(&xml, &[("green.png", png([0.0, 1.0, 0.0, 1.0])), ("red.png", png([1.0, 0.0, 0.0, 1.0]))]);
|
||||
let result = crate::import("isolation.ora", &bytes).unwrap();
|
||||
assert!(result.warnings.is_empty(), "{:?}", result.warnings);
|
||||
assert_eq!(result.document.depth, depth);
|
||||
result.document
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_group_isolation_defaults_to_isolated_since_version_0_0_6() {
|
||||
for depth in [SampleType::U8, SampleType::U16] {
|
||||
for version in ["0.0.6", " 0.0.6 ", "0.0.6+app.1", "0.0.6-rc.1", "0.0.10", "0.1.0", "1.0.0"] {
|
||||
let d = group_isolation_document(Some(version), "", depth);
|
||||
assert_eq!(d.layers[1].blend, BlendMode::Normal, "{version}, {depth:?}");
|
||||
assert_eq!(photocraft_compose::flatten(&d).px, [[0.0, 1.0, 0.0, 1.0]], "{version}, {depth:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_group_isolation_preserves_isolate_and_auto() {
|
||||
for depth in [SampleType::U8, SampleType::U16] {
|
||||
for version in ["0.0.3", "0.0.5", "0.0.6"] {
|
||||
for (attributes, blend, expected) in [
|
||||
("isolation=' isolate '", BlendMode::Normal, [0.0, 1.0, 0.0, 1.0]),
|
||||
("isolation=' auto '", BlendMode::PassThrough, [0.0, 0.0, 0.0, 1.0]),
|
||||
("isolation='isolate' composite-op='svg:multiply'", BlendMode::Multiply, [0.0, 0.0, 0.0, 1.0]),
|
||||
] {
|
||||
let d = group_isolation_document(Some(version), attributes, depth);
|
||||
assert_eq!(d.layers[1].blend, blend, "{version}, {attributes}, {depth:?}");
|
||||
assert_eq!(photocraft_compose::flatten(&d).px, [expected], "{version}, {attributes}, {depth:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_group_ignores_its_composite_op_since_version_0_0_6() {
|
||||
for depth in [SampleType::U8, SampleType::U16] {
|
||||
let d = group_isolation_document(Some("0.0.6"), "isolation='auto' composite-op='svg:screen'", depth);
|
||||
assert_eq!(d.layers[1].blend, BlendMode::PassThrough);
|
||||
assert_eq!(photocraft_compose::flatten(&d).px, [[0.0, 0.0, 0.0, 1.0]], "{depth:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_group_isolation_keeps_inference_from_blend_mode() {
|
||||
for depth in [SampleType::U8, SampleType::U16] {
|
||||
for version in [None, Some("0.0.3"), Some("0.0.5"), Some("unknown"), Some("0.0"), Some("0.0.6.0")] {
|
||||
for (attributes, blend, expected) in [
|
||||
("", BlendMode::PassThrough, [0.0, 0.0, 0.0, 1.0]),
|
||||
("composite-op='svg:screen'", BlendMode::Screen, [1.0, 1.0, 0.0, 1.0]),
|
||||
("isolation='auto' composite-op='svg:screen'", BlendMode::Screen, [1.0, 1.0, 0.0, 1.0]),
|
||||
] {
|
||||
let d = group_isolation_document(version, attributes, depth);
|
||||
assert_eq!(d.layers[1].blend, blend, "{version:?}, {attributes}, {depth:?}");
|
||||
assert_eq!(photocraft_compose::flatten(&d).px, [expected], "{version:?}, {attributes}, {depth:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_other_applications_stacks() {
|
||||
// The shapes Krita, MyPaint and GIMP write: a root stack with attributes, `auto`
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use photocraft_cms::{Builtin, Intent, Profile, Transform};
|
||||
use photocraft_codecs::{ChannelLayout, SampleType as CodecSample, decode};
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer, LayerContent};
|
||||
use photocraft_geom::Size;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, LayerMask};
|
||||
use photocraft_geom::{Rect, Size};
|
||||
use photocraft_io::{ExportOptions, document_to_image, export};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
@@ -15,6 +15,32 @@ fn document(depth: SampleType, alpha: f32) -> Document {
|
||||
doc
|
||||
}
|
||||
|
||||
/// Three bands exercise opaque, translucent and empty pixels in the same composite.
|
||||
fn mixed_region_document(depth: SampleType, opaque: bool) -> Document {
|
||||
let mut doc = Document::new("CMYK regions", Size::new(48, 16), ColorMode::Cmyk, depth);
|
||||
let mut bottom = Surface::new(doc.pixel_format());
|
||||
bottom.fill_rect(Rect::new(0, 0, 16, 16), &[0.2, 0.6, 0.1, 0.15, 1.0]);
|
||||
bottom.fill_rect(Rect::new(16, 0, 32, 16), &[0.1, 0.2, 0.7, 0.25, 0.5]);
|
||||
if opaque {
|
||||
bottom.fill_rect(doc.bounds(), &[0.2, 0.6, 0.1, 0.15, 1.0]);
|
||||
}
|
||||
doc.layers.push(Layer::new("Ink", LayerContent::Raster(bottom)));
|
||||
let mut surface = Surface::new(doc.pixel_format());
|
||||
surface.fill_rect(Rect::new(0, 0, 16, 16), &[0.7, 0.1, 0.3, 0.2, 1.0]);
|
||||
let mut top = Layer::new("Masked ink", LayerContent::Raster(surface));
|
||||
top.opacity = 0.5;
|
||||
top.mask = Some(LayerMask {
|
||||
surface: Surface::with_default(PixelFormat::new(ColorMode::Grayscale, depth, false), &[0.5]),
|
||||
enabled: true,
|
||||
linked: true,
|
||||
density: 1.0,
|
||||
feather: 0.0,
|
||||
});
|
||||
doc.layers.push(top);
|
||||
doc.resolution_dpi = 300.0;
|
||||
doc
|
||||
}
|
||||
|
||||
fn tag(bytes: &[u8], id: u16) -> Option<u16> {
|
||||
assert_eq!(&bytes[..2], b"II");
|
||||
let start = usize::try_from(u32::from_le_bytes(bytes[4..8].try_into().unwrap())).unwrap();
|
||||
@@ -71,6 +97,69 @@ fn tiff_keeps_true_zero_alpha_when_composited() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_and_layered_tiff_agree_for_mixed_cmyk_regions() {
|
||||
let custom = photocraft_cms::synth::cmyk_profile(&photocraft_cms::synth::CmykParams {
|
||||
description: "Mixed regions uncoated".into(),
|
||||
tvi: [0.26, 0.26, 0.26, 0.3],
|
||||
grid_a2b: 5,
|
||||
grid_b2a: 9,
|
||||
..Default::default()
|
||||
});
|
||||
let coated = Builtin::CoatedCmyk.profile().to_bytes();
|
||||
for (name, profile) in [("untagged", None), ("coated", Some(coated.clone())), ("uncoated", Some(custom.to_bytes()))] {
|
||||
for (depth, sample) in [(SampleType::U8, CodecSample::U8), (SampleType::U16, CodecSample::U16), (SampleType::F32, CodecSample::F32)] {
|
||||
for opaque in [false, true] {
|
||||
let context = format!("{name}, {depth:?}, opaque={opaque}");
|
||||
let mut doc = mixed_region_document(depth, opaque);
|
||||
doc.icc_profile = profile.clone();
|
||||
let flat = export(&doc, "tiff", &ExportOptions::default()).unwrap();
|
||||
let layered = export(&doc, "tiff", &ExportOptions { tiff_layers: true, ..Default::default() }).unwrap();
|
||||
let image = decode(&flat.bytes).unwrap();
|
||||
let reference = decode(&layered.bytes).unwrap();
|
||||
assert!(!flat.warnings.iter().any(|warning| warning.contains("sRGB")), "{context}: {:?}", flat.warnings);
|
||||
// Flat composites embed the fallback profile; the layered path keeps the source tag.
|
||||
assert_eq!(image.icc.as_deref(), Some(profile.as_ref().unwrap_or(&coated).as_slice()), "{context}");
|
||||
assert_eq!(reference.icc.as_deref(), profile.as_deref().map(Vec::as_slice), "{context}");
|
||||
assert!(image.meta.photoshop_layers.is_none(), "{context}");
|
||||
assert!(reference.meta.photoshop_layers.is_some(), "{context}");
|
||||
for (decoded, bytes) in [(&image, &flat.bytes), (&reference, &layered.bytes)] {
|
||||
assert_eq!(decoded.dimensions(), (48, 16), "{context}");
|
||||
assert_eq!(decoded.layout(), if opaque { ChannelLayout::Cmyk } else { ChannelLayout::CmykA }, "{context}");
|
||||
assert_eq!(decoded.sample_type(), sample, "{context}");
|
||||
assert_eq!(decoded.meta.dpi, Some((300.0, 300.0)), "{context}");
|
||||
assert_eq!(tag(bytes, 262), Some(5), "{context}");
|
||||
assert_eq!(tag(bytes, 277), Some(if opaque { 4 } else { 5 }), "{context}");
|
||||
assert_eq!(tag(bytes, 338), if opaque { None } else { Some(2) }, "{context}");
|
||||
}
|
||||
// The PSD-backed layered path inverts CMYK samples twice, so allow one stored level.
|
||||
let tolerance = match depth {
|
||||
SampleType::U8 => 1.0 / 255.0,
|
||||
SampleType::U16 => 1.0 / 65535.0,
|
||||
SampleType::F32 => 1e-6,
|
||||
};
|
||||
let actual = image.to_normalized();
|
||||
let expected = reference.to_normalized();
|
||||
assert_eq!(actual.len(), expected.len(), "{context}");
|
||||
let difference = actual.iter().zip(&expected).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(difference <= tolerance + 1e-6, "{context}: maximum sample difference {difference}");
|
||||
if !opaque {
|
||||
for (i, px) in actual.as_chunks::<5>().0.iter().enumerate() {
|
||||
match i % 48 {
|
||||
0..16 => assert_eq!(px[4], 1.0, "{context}: opaque region"),
|
||||
16..32 => assert!((px[4] - 0.5).abs() <= tolerance, "{context}: translucent region: {px:?}"),
|
||||
_ => assert_eq!(px[4], 0.0, "{context}: empty region"),
|
||||
}
|
||||
}
|
||||
}
|
||||
let back = photocraft_io::import("regions.tiff", &flat.bytes).unwrap().document;
|
||||
assert_eq!((back.mode, back.depth), (ColorMode::Cmyk, depth), "{context}");
|
||||
assert_eq!(back.resolution_dpi, 300.0, "{context}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_keeps_cmyk_mode_depth_and_profile_when_composited() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
|
||||
@@ -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,117 @@
|
||||
//! Selecting an EXR part must interpret its linear samples like ordinary File > Open.
|
||||
|
||||
use photocraft_cms::{Builtin, ColorSpace, Intent, Profile, Transform};
|
||||
use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType as CodecSample};
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::Document;
|
||||
use photocraft_io::{ExportOptions, ImportResult, export, exr_parts, import};
|
||||
|
||||
fn exr(layout: ChannelLayout, depth: CodecSample) -> Vec<u8> {
|
||||
// Exactly representable in half precision, including negative and HDR luminance.
|
||||
let samples: Vec<f32> = [-0.25, 0.25, 0.5, 2.0]
|
||||
.into_iter()
|
||||
.zip([1.0, 0.5, 0.25, 0.0])
|
||||
.flat_map(|(v, a)| match layout {
|
||||
ChannelLayout::Gray => vec![v],
|
||||
ChannelLayout::GrayA => vec![v, a],
|
||||
ChannelLayout::Rgb => vec![v, 0.125, 0.75],
|
||||
ChannelLayout::Rgba => vec![v, 0.125, 0.75, a],
|
||||
_ => panic!("not an EXR layout"),
|
||||
})
|
||||
.collect();
|
||||
let image = Image::from_f32(4, 1, layout, &samples).unwrap().convert(layout, depth);
|
||||
codecs::encode(&image, Format::OpenExr, &Default::default()).unwrap()
|
||||
}
|
||||
|
||||
fn selected_part(bytes: &[u8]) -> ImportResult {
|
||||
let entries = exr_parts::entries(bytes).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].kind, exr_parts::EntryKind::Part);
|
||||
let selection = exr_parts::Selection { parts: vec![entries[0].name.clone()], groups: vec![], view: "both".into(), precomp: false };
|
||||
exr_parts::import("selected", bytes, &selection).unwrap()
|
||||
}
|
||||
|
||||
fn samples(doc: &Document) -> Vec<f32> {
|
||||
assert_eq!(doc.layers.len(), 1);
|
||||
doc.layers[0].surface().unwrap().read_region(doc.bounds())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_gray_exr_part_keeps_linear_profile_and_float_samples() {
|
||||
for depth in [CodecSample::F16, CodecSample::F32] {
|
||||
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA] {
|
||||
let bytes = exr(layout, depth);
|
||||
let selected = selected_part(&bytes);
|
||||
let ordinary = import("ordinary.exr", &bytes).unwrap();
|
||||
let doc = &selected.document;
|
||||
assert_eq!((doc.mode, doc.depth), (ColorMode::Grayscale, SampleType::F32));
|
||||
assert_eq!(doc.size, ordinary.document.size);
|
||||
assert_eq!(selected.warnings, ordinary.warnings);
|
||||
assert_eq!(selected.warnings.len(), usize::from(depth == CodecSample::F16));
|
||||
let expected = codecs::decode(&bytes).unwrap().convert(ChannelLayout::GrayA, CodecSample::F32).to_f32_samples().unwrap();
|
||||
assert_eq!(samples(doc), expected, "{layout:?} {depth:?}: samples and alpha stay unchanged");
|
||||
assert_eq!(samples(doc), samples(&ordinary.document));
|
||||
assert_eq!(doc.icc_profile, Some(photocraft_cms::builtin::linear_gray().to_bytes()), "{layout:?} {depth:?}: linear luminance");
|
||||
assert_eq!(doc.icc_profile, ordinary.document.icc_profile);
|
||||
|
||||
// The tagged samples convert to display gray, rather than being interpreted as sGray.
|
||||
let profile = Profile::parse(doc.icc_profile.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(profile.color_space, ColorSpace::Gray);
|
||||
let transform = Transform::new(&profile, Builtin::SGray.profile(), Intent::RelativeColorimetric, false).unwrap();
|
||||
let mut display = samples(doc);
|
||||
transform.apply(&mut display, 2);
|
||||
for i in [1, 2] {
|
||||
let expected_gray = photocraft_color::convert::linear_to_srgb(expected[i * 2]);
|
||||
assert!((display[i * 2] - expected_gray).abs() < 1e-4, "{layout:?} {depth:?}: display gray");
|
||||
assert_eq!(display[i * 2 + 1], expected[i * 2 + 1], "alpha is not gamma encoded");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_gray_exr_part_exports_like_ordinary_import() {
|
||||
for depth in [CodecSample::F16, CodecSample::F32] {
|
||||
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA] {
|
||||
let bytes = exr(layout, depth);
|
||||
let selected = selected_part(&bytes).document;
|
||||
let ordinary = import("ordinary.exr", &bytes).unwrap().document;
|
||||
for ext in ["exr", "tga"] {
|
||||
let output = export(&selected, ext, &ExportOptions::default()).unwrap();
|
||||
let control = export(&ordinary, ext, &ExportOptions::default()).unwrap();
|
||||
let output = codecs::decode(&output.bytes).unwrap();
|
||||
let control = codecs::decode(&control.bytes).unwrap();
|
||||
assert_eq!(output.layout(), control.layout());
|
||||
assert_eq!(output.to_normalized(), control.to_normalized(), "{layout:?} {depth:?} {ext}: same colours as File > Open");
|
||||
let output = output.convert(ChannelLayout::GrayA, CodecSample::F32).to_f32_samples().unwrap();
|
||||
let original = samples(&selected);
|
||||
if ext == "exr" {
|
||||
assert_eq!(output, original, "linear EXR samples, including negative/HDR and alpha, survive re-export");
|
||||
} else {
|
||||
for i in [1, 2] {
|
||||
let expected = photocraft_color::convert::linear_to_srgb(original[i * 2]);
|
||||
assert!((output[i * 2] - expected).abs() <= 1.0 / 255.0, "TGA uses display-encoded gray");
|
||||
assert!((output[i * 2 + 1] - original[i * 2 + 1]).abs() <= 1.0 / 255.0, "TGA preserves alpha");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_rgb_exr_part_keeps_linear_srgb() {
|
||||
for depth in [CodecSample::F16, CodecSample::F32] {
|
||||
for layout in [ChannelLayout::Rgb, ChannelLayout::Rgba] {
|
||||
let bytes = exr(layout, depth);
|
||||
let doc = selected_part(&bytes).document;
|
||||
let ordinary = import("ordinary.exr", &bytes).unwrap().document;
|
||||
assert_eq!((doc.mode, doc.depth), (ColorMode::Rgb, SampleType::F32));
|
||||
assert_eq!(doc.icc_profile, Some(Builtin::LinearSrgb.profile().to_bytes()));
|
||||
assert_eq!(doc.icc_profile, ordinary.icc_profile);
|
||||
let expected = codecs::decode(&bytes).unwrap().convert(ChannelLayout::Rgba, CodecSample::F32).to_f32_samples().unwrap();
|
||||
assert_eq!(samples(&doc), expected, "{layout:?} {depth:?}: RGB and alpha stay unchanged");
|
||||
assert_eq!(samples(&doc), samples(&ordinary));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -610,6 +610,73 @@ fn linear_gray_export_preserves_alpha_and_embeddable_profiles() {
|
||||
}
|
||||
}
|
||||
|
||||
/// WebP can embed RGB profiles, but expanding gray to RGB drops a gray profile. Its untagged
|
||||
/// RGB samples must use the sRGB transfer function, not the source gray encoding.
|
||||
#[test]
|
||||
fn linear_gray_webp_exports_use_display_encoding() {
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions};
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
for alpha in [1.0, 0.5] {
|
||||
let mut d = columns(ColorMode::Grayscale, depth, &[&[0.25, alpha], &[0.5, alpha]]);
|
||||
d.icc_profile = Some(photocraft_cms::builtin::linear_gray().to_bytes());
|
||||
let stored = column_pixels(&d, 2);
|
||||
for lossless in [true, false] {
|
||||
let opts = ExportOptions { encode: EncodeOptions { webp_lossless: lossless, webp_quality: 100, ..Default::default() }, ..Default::default() };
|
||||
let r = export(&d, "webp", &opts).unwrap();
|
||||
let img = photocraft_codecs::decode(&r.bytes).unwrap();
|
||||
let what = format!("{depth:?}, alpha {alpha}, lossless {lossless}");
|
||||
assert_eq!(img.layout(), if alpha == 1.0 { ChannelLayout::Rgb } else { ChannelLayout::Rgba }, "{what}");
|
||||
assert_eq!(img.icc, None, "{what}: a gray ICC cannot describe RGB samples");
|
||||
let values = img.to_normalized();
|
||||
let stride = img.layout().channels();
|
||||
let tolerance = if lossless { 1.5 / 255.0 } else { 3.0 / 255.0 };
|
||||
for (i, src) in stored.iter().enumerate() {
|
||||
let offset = (8 * img.width() as usize + 16 * i + 8) * stride;
|
||||
let expected = photocraft_color::convert::linear_to_srgb(src[0]);
|
||||
for &value in &values[offset..offset + 3] {
|
||||
assert!((value - expected).abs() <= tolerance, "{what}, pixel {i}: got {value}, expected {expected}");
|
||||
}
|
||||
if img.layout().has_alpha() {
|
||||
assert!((values[offset + 3] - src[1]).abs() <= 1.0 / 255.0, "{what}: alpha changed");
|
||||
}
|
||||
}
|
||||
assert!(r.warnings.iter().any(|w| w.contains("converted to sGray")), "{what}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_gray_webp_exports_keep_samples() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
for icc in [None, Some(photocraft_cms::Builtin::SGray.profile().to_bytes())] {
|
||||
let mut d = columns(ColorMode::Grayscale, depth, &[&[0.25, 0.5], &[0.5, 1.0]]);
|
||||
d.icc_profile = icc;
|
||||
let stored = column_pixels(&d, 2);
|
||||
let r = export(&d, "webp", &ExportOptions::default()).unwrap();
|
||||
let img = photocraft_codecs::decode(&r.bytes).unwrap();
|
||||
assert_eq!(img.icc, None, "{depth:?}: a gray ICC cannot describe RGB samples");
|
||||
let back = import("gray.webp", &r.bytes).unwrap().document;
|
||||
let want: Vec<Vec<f32>> = stored.iter().map(|p| vec![p[0], p[0], p[0], p[1]]).collect();
|
||||
assert_colors(&column_pixels(&back, 2), &want, 1.0 / 255.0, &format!("{depth:?}: sGray samples"));
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("converted to sGray")), "{depth:?}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_webp_exports_keep_embeddable_profiles() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut d = columns(ColorMode::Rgb, depth, &[&[0.25, 0.5, 0.75, 0.5]]);
|
||||
d.icc_profile = Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes());
|
||||
let r = export(&d, "webp", &ExportOptions::default()).unwrap();
|
||||
let back = import("rgb.webp", &r.bytes).unwrap().document;
|
||||
assert_eq!(back.icc_profile, d.icc_profile, "{depth:?}: WebP keeps RGB ICC");
|
||||
assert_colors(&column_pixels(&back, 1), &column_pixels(&d, 1), 1.0 / 255.0, &format!("{depth:?}: RGB samples"));
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("converted to sRGB")), "{depth:?}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
/// #518: a JPEG cut off inside its image data opens with a warning, never silently.
|
||||
#[test]
|
||||
fn truncated_jpeg_imports_with_a_warning() {
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -169,7 +169,7 @@ fn corpus_photoshop_txt2_survives_an_unedited_save() {
|
||||
println!("Txt2 kept byte-for-byte in {checked} Photoshop type files");
|
||||
}
|
||||
|
||||
/// A text layer created in Photocraft (no PSD data) exports as an editable type layer and
|
||||
/// A text layer created in PhotoCraft (no PSD data) exports as an editable type layer and
|
||||
/// imports back with the same model.
|
||||
#[test]
|
||||
fn created_text_layer_roundtrips_through_psd() {
|
||||
|
||||
@@ -678,7 +678,7 @@ impl BrushSettings {
|
||||
pub struct BrushPreset {
|
||||
pub name: String,
|
||||
pub brush: BrushSettings,
|
||||
/// Shipped with Photocraft (built-ins can be deleted from a session but are regenerated on reset).
|
||||
/// Shipped with PhotoCraft (built-ins can be deleted from a session but are regenerated on reset).
|
||||
#[serde(default)]
|
||||
pub builtin: bool,
|
||||
/// Preset group (folder) in the Brushes panel, e.g. "General" or an imported file's name.
|
||||
|
||||
+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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -14,5 +14,7 @@ photocraft-raster = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# In-process resident memory for the benchmarks (`perf`; MIT OR Apache-2.0).
|
||||
memory-stats = "1.2"
|
||||
# In-process resident memory for the benchmarks (`perf`; MIT OR Apache-2.0). `always_use_statm`:
|
||||
# on Linux read /proc/self/statm, not /proc/self/smaps, which walks every mapping under the mmap
|
||||
# lock and made the 2 ms sampler thread a fifth of a large bench's CPU time.
|
||||
memory-stats = { version = "1.2", features = ["always_use_statm"] }
|
||||
@@ -35,7 +35,7 @@ pub static INTER_MEDIUM: LazyLock<Vec<u8>> = LazyLock::new(|| inflate_font(bundl
|
||||
pub static INTER_SEMIBOLD: LazyLock<Vec<u8>> = LazyLock::new(|| inflate_font(bundled_font!("Inter-SemiBold.ttf")));
|
||||
pub static JETBRAINS_MONO_REGULAR: LazyLock<Vec<u8>> = LazyLock::new(|| inflate_font(bundled_font!("JetBrainsMono-Regular.ttf")));
|
||||
|
||||
/// Fonts shipped with Photocraft (OFL; licences in `assets/fonts`).
|
||||
/// Fonts shipped with PhotoCraft (OFL; licences in `assets/fonts`).
|
||||
pub static BUNDLED: [(&str, &LazyLock<Vec<u8>>); 4] = [
|
||||
("Inter-Regular.ttf", &INTER_REGULAR),
|
||||
("Inter-Medium.ttf", &INTER_MEDIUM),
|
||||
|
||||
@@ -586,6 +586,12 @@ impl Layouter {
|
||||
for p in style_props(&out.styles[si0], k, &fallback, si0 as u32, small_caps[si0]) {
|
||||
b.push_default(p);
|
||||
}
|
||||
// Paragraph text breaks a word wider than the box at the box edge, as Photoshop
|
||||
// does (#6694): parley's default only breaks at UAX #14 opportunities and lets the
|
||||
// word run out of the box. Point text has no width to overflow.
|
||||
if is_box {
|
||||
b.push_default(StyleProperty::OverflowWrap(parley::style::OverflowWrap::BreakWord));
|
||||
}
|
||||
for (ri, st) in out.styles.iter().enumerate() {
|
||||
let rs = run_starts[ri];
|
||||
let (a, z) = (rs.max(prange.start), (rs + runs[ri].len).min(content_end));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Photocraft's type engine.
|
||||
//! PhotoCraft's type engine.
|
||||
//!
|
||||
//! * [`fonts::FontDb`]: bundled fonts (Inter, JetBrains Mono; always available, including on the
|
||||
//! web), optional system fonts from platform directory scans plus Fontconfig's Linux file list,
|
||||
|
||||
@@ -2008,3 +2008,37 @@ fn hyphenless_postscript_names_split_trailing_styles() {
|
||||
assert_eq!((r.family.as_str(), r.weight, r.italic), (family, weight, italic), "{ps}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A word wider than its paragraph box wraps at the box edge instead of running past it (#6694);
|
||||
/// a normal break opportunity still wins, and a box that fits the word lays out exactly as before.
|
||||
#[test]
|
||||
fn a_word_wider_than_the_paragraph_box_breaks_at_the_box_edge() {
|
||||
let mut e = TextEngine::new();
|
||||
let word = "BLOQUEADO?!";
|
||||
let alone = e.layout(&point(word, 12.0), 72.0);
|
||||
assert_eq!(alone.lines.len(), 1, "{:?}", alone.lines);
|
||||
let word_width = width(&alone);
|
||||
let narrow = 30.0;
|
||||
assert!(word_width > narrow * 2.0, "{word_width}");
|
||||
|
||||
let mut t = point(word, 12.0);
|
||||
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: narrow, height: 200.0 };
|
||||
let l = e.layout(&t, 72.0);
|
||||
assert!(l.lines.len() >= 2, "{:?}", l.lines);
|
||||
for line in &l.lines {
|
||||
assert!(line.x1 <= narrow + 0.5, "{line:?}");
|
||||
}
|
||||
let joined: String = l.lines.iter().map(|l| &word[l.range.clone()]).collect();
|
||||
assert_eq!(joined, word);
|
||||
|
||||
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: word_width + 20.0, height: 200.0 };
|
||||
let fits = e.layout(&t, 72.0);
|
||||
assert_eq!(fits.lines.len(), 1, "{:?}", fits.lines);
|
||||
assert!((width(&fits) - word_width).abs() < 0.01, "{} vs {word_width}", width(&fits));
|
||||
|
||||
let mut spaced = point("ab cd", 12.0);
|
||||
spaced.shape = TextShape::Box { x: 0.0, y: 0.0, width: narrow, height: 200.0 };
|
||||
let l = e.layout(&spaced, 72.0);
|
||||
assert_eq!(l.lines.len(), 2, "{:?}", l.lines);
|
||||
assert_eq!(l.lines[1].range.start, 3, "{:?}", l.lines);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Render the full Photocraft UI offscreen (no window, no focus stealing) and save a PNG.
|
||||
//! Render the full PhotoCraft UI offscreen (no window, no focus stealing) and save a PNG.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run --release -p photocraft-ui-egui --example snapshot -- \
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -15,6 +15,13 @@ use crate::state::{Tool, View};
|
||||
/// Largest texture side we upload; bigger documents display downsampled until the GPU path lands.
|
||||
pub const MAX_TEXTURE: u32 = 4096;
|
||||
|
||||
/// The largest texture side to upload on this device: [`MAX_TEXTURE`], or less where the GPU
|
||||
/// allows less (2048 on some older or low-end GPUs and WebGL contexts). Past the device's limit
|
||||
/// egui asserts and the backend fails to create the texture.
|
||||
fn texture_side(ctx: &egui::Context) -> u32 {
|
||||
u32::try_from(ctx.input(|i| i.max_texture_side)).unwrap_or(MAX_TEXTURE).clamp(1, MAX_TEXTURE)
|
||||
}
|
||||
|
||||
pub(crate) struct ClonePreviewCache {
|
||||
key: String,
|
||||
texture: egui::TextureHandle,
|
||||
@@ -1421,7 +1428,7 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize,
|
||||
if cache.tex_revision != revision || cache.texture.is_none() || cache.tex_preview_key != preview_key {
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
let longest = doc.size.width.max(doc.size.height);
|
||||
let factor = longest.div_ceil(MAX_TEXTURE).max(1);
|
||||
let factor = longest.div_ceil(texture_side(ctx)).max(1);
|
||||
let (w, h) = ((doc.size.width / factor).max(1), (doc.size.height / factor).max(1));
|
||||
let current = cache.texture.as_mut().filter(|t| t.size() == [w as usize, h as usize]);
|
||||
if let (Some(d), Some(t)) = (damage, current) {
|
||||
@@ -1608,7 +1615,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());
|
||||
@@ -1620,12 +1641,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.
|
||||
@@ -1666,21 +1692,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
|
||||
@@ -4054,6 +4091,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
if primary && hand_pan && response.dragged() {
|
||||
crate::transform_tool::draw_content(app, &painter, &xf);
|
||||
crate::transform_tool::draw_overlay(app, &painter, &xf);
|
||||
// The Pen's path in progress survives a Space pan, and stays on screen through it (#6592).
|
||||
crate::vector_ui::draw_overlay(app, &painter, &xf, &doc);
|
||||
}
|
||||
app.tool_override = None;
|
||||
if let Some(b) = layer_edges_rect(app, idx) {
|
||||
@@ -4520,8 +4559,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;
|
||||
@@ -5459,6 +5498,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
{
|
||||
e.dragging = false;
|
||||
e.resize = None;
|
||||
app.ui.caret_blink_time = app.last_frame_time;
|
||||
}
|
||||
if tool == Tool::Pen {
|
||||
crate::vector_ui::pen_up(app);
|
||||
@@ -6525,6 +6565,25 @@ mod tests {
|
||||
assert_eq!(uploads(&ctx), vec![(true, crisp)], "a partial update keeps the crisp sampling");
|
||||
}
|
||||
|
||||
/// A device that allows smaller textures than MAX_TEXTURE (2048 on some older GPUs and WebGL
|
||||
/// contexts, and on the fallback adapter the GPU tests sometimes get under load): the canvas
|
||||
/// texture is reduced to fit it. A 2600 px document was uploaded at full size, which egui
|
||||
/// asserts on and the backend can't create.
|
||||
#[test]
|
||||
fn cpu_canvas_texture_fits_the_device_limit() {
|
||||
for (side, (w, h), want) in [(2048, (2600, 1700), [1300, 850]), (2048, (5000, 2000), [1666, 666]), (8192, (5000, 2000), [2500, 1000])] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": w, "height": h})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
ctx.input_mut(|i| i.max_texture_side = side);
|
||||
let (tex, _) = ensure_texture(&mut app, &ctx, 0, None).unwrap();
|
||||
let mut delta = ctx.tex_manager().write().take_delta();
|
||||
let sizes: Vec<_> = delta.set.get(&tex).into_iter().flatten().map(|d| d.image.size()).collect();
|
||||
delta.clear();
|
||||
assert_eq!(sizes, vec![want], "{w}x{h} on a {side} px device");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_canvas_of_a_reduced_document_stays_bilinear() {
|
||||
// Over MAX_TEXTURE the texture holds one texel per `factor` pixels: a texel is not a
|
||||
@@ -6542,6 +6601,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn gpu_display_luts_are_per_display() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
let Ok(rs) = std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default())) else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
@@ -7460,6 +7520,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 {
|
||||
|
||||
@@ -171,7 +171,7 @@ pub fn open(app: &mut PhotocraftApp, target: &str) -> u64 {
|
||||
let c = if target == "background" { app.session.tools.background } else { app.session.tools.foreground };
|
||||
let rgb = [c[0], c[1], c[2]];
|
||||
let label = if target == "background" { "Color Picker (Background Color)" } else { "Color Picker (Foreground Color)" };
|
||||
let mut fields = initial_fields(target, label, rgb);
|
||||
let mut fields = fields_with_web(app, target, label, rgb);
|
||||
// A colour that was picked as CMYK shows the inks it was picked as, not what its sRGB stand-in converts to (#4196).
|
||||
if let Some(cmyk) = app.session.tools.picked_cmyk(rgb) {
|
||||
fields.insert("__cmyk".into(), json!(cmyk));
|
||||
@@ -192,10 +192,23 @@ pub(crate) fn initial_fields(target: &str, label: &str, rgb: [f32; 3]) -> Map<St
|
||||
f
|
||||
}
|
||||
|
||||
/// Seed [`initial_fields`] with the last Only Web Colors setting. With web-only on, the opening
|
||||
/// colour is snapped and `__orig` follows it: OK without an edit is "unchanged", so opening the
|
||||
/// picker never rewrites a fill behind the user's back (#4995).
|
||||
pub(crate) fn fields_with_web(app: &PhotocraftApp, target: &str, label: &str, rgb: [f32; 3]) -> Map<String, Value> {
|
||||
let mut f = initial_fields(target, label, rgb);
|
||||
f.insert("__webOnly".into(), json!(app.ui.color_panel.web_only));
|
||||
if app.ui.color_panel.web_only {
|
||||
set_rgb(&mut f, rgb, Keep::Nothing);
|
||||
f.insert("__orig".into(), f["color"].clone());
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// Open the picker on `rgb` for something other than the tool colours: OK runs `command` with
|
||||
/// `params` plus `"color": "#rrggbb"` (e.g. a gradient stop's colour).
|
||||
pub fn open_for_command(app: &mut PhotocraftApp, label: &str, rgb: [f32; 3], command: &str, params: Value) -> u64 {
|
||||
let mut f = initial_fields("command", label, rgb);
|
||||
let mut f = fields_with_web(app, "command", label, rgb);
|
||||
f.insert("__command".into(), json!(command));
|
||||
f.insert("__params".into(), params);
|
||||
app.ui.open_dialog(DialogKind::Command, f)
|
||||
@@ -204,33 +217,16 @@ pub fn open_for_command(app: &mut PhotocraftApp, label: &str, rgb: [f32; 3], com
|
||||
/// Open the picker on `rgb` for a colour field of another dialog (e.g. Edit › Fill's Color…): OK
|
||||
/// writes `"#rrggbb"` into `field` of dialog `dialog`, which stays open; Cancel leaves it alone.
|
||||
pub fn open_for_field(app: &mut PhotocraftApp, label: &str, rgb: [f32; 3], dialog: u64, field: &str) -> u64 {
|
||||
let hsv = rgb_to_hsv(rgb);
|
||||
let mut f = Map::new();
|
||||
f.insert("__colorPicker".into(), json!("field"));
|
||||
f.insert("__label".into(), json!(label));
|
||||
let mut f = fields_with_web(app, "field", label, rgb);
|
||||
f.insert("__dialog".into(), json!(dialog));
|
||||
f.insert("__field".into(), json!(field));
|
||||
f.insert("color".into(), json!(hex(rgb)));
|
||||
f.insert("__orig".into(), json!(hex(rgb)));
|
||||
f.insert("__hsv".into(), json!(hsv));
|
||||
f.insert("__mode".into(), json!("h"));
|
||||
f.insert("__webOnly".into(), json!(false));
|
||||
app.ui.open_dialog(DialogKind::Command, f)
|
||||
}
|
||||
|
||||
/// Open the picker on `rgb` for a colour the shell keeps itself (e.g. the Crop shield's custom
|
||||
/// colour): OK hands `"#rrggbb"` to `target`'s setter in [`confirm`]; Cancel leaves it alone.
|
||||
pub fn open_for_target(app: &mut PhotocraftApp, target: &str, label: &str, rgb: [f32; 3]) -> u64 {
|
||||
let hsv = rgb_to_hsv(rgb);
|
||||
let mut f = Map::new();
|
||||
f.insert("__colorPicker".into(), json!(target));
|
||||
f.insert("__label".into(), json!(label));
|
||||
f.insert("color".into(), json!(hex(rgb)));
|
||||
f.insert("__orig".into(), json!(hex(rgb)));
|
||||
f.insert("__hsv".into(), json!(hsv));
|
||||
f.insert("__mode".into(), json!("h"));
|
||||
f.insert("__webOnly".into(), json!(false));
|
||||
app.ui.open_dialog(DialogKind::Command, f)
|
||||
app.ui.open_dialog(DialogKind::Command, fields_with_web(app, target, label, rgb))
|
||||
}
|
||||
|
||||
/// Set by a dialog body (to the picker's title) to open the Color Picker on its `color` field.
|
||||
@@ -367,7 +363,7 @@ pub fn current(f: &Map<String, Value>) -> Components {
|
||||
|
||||
fn set_rgb(f: &mut Map<String, Value>, rgb: [f32; 3], keep: Keep) {
|
||||
let web = f.get("__webOnly").and_then(Value::as_bool).unwrap_or(false);
|
||||
let (rgb, keep) = if web { (rgb.map(|v| (v * 5.0).round() / 5.0), Keep::Nothing) } else { (rgb, keep) };
|
||||
let (rgb, keep) = if web { (web_safe(rgb), Keep::Nothing) } else { (rgb, keep) };
|
||||
f.insert("color".into(), json!(hex(rgb)));
|
||||
for key in ["__hsv", "__lab", "__cmyk"] {
|
||||
f.remove(key);
|
||||
@@ -688,9 +684,14 @@ fn fields(ui: &mut egui::Ui, f: &mut Map<String, Value>, mode: &str, now: &Compo
|
||||
let focused = ui.memory(|m| m.has_focus(id));
|
||||
let pasted = if focused { take_hex_paste(ui) } else { None };
|
||||
let typing = if focused { ui.data(|d| d.get_temp::<String>(id)) } else { None };
|
||||
// Drop stale typing when another path (web snap, field drag, Current) changed `color`.
|
||||
let shown = hex(rgb);
|
||||
let mut h = match pasted {
|
||||
Some(c) => hex(c).trim_start_matches('#').to_ascii_uppercase(),
|
||||
None => typing.unwrap_or_else(|| hex(rgb).trim_start_matches('#').to_ascii_uppercase()),
|
||||
None => match typing {
|
||||
Some(t) if parse_hex(&t).is_none() || parse_hex(&t).map(hex).as_deref() == Some(shown.as_str()) => t,
|
||||
_ => shown.trim_start_matches('#').to_ascii_uppercase(),
|
||||
},
|
||||
};
|
||||
let r = crate::widgets::text_field_ex(ui, id, &mut h, "", 64.0, true, Some(6));
|
||||
if let Some(c) = pasted {
|
||||
@@ -783,6 +784,9 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value,
|
||||
if crate::layer_style::color_picker::owns(f) {
|
||||
return crate::layer_style::color_picker::confirm(app, f);
|
||||
}
|
||||
if crate::solid_fill_ui::owns_new(f) {
|
||||
return crate::solid_fill_ui::confirm_new(app, f);
|
||||
}
|
||||
if crate::solid_fill_ui::owns(f) {
|
||||
return crate::solid_fill_ui::confirm(app, f);
|
||||
}
|
||||
@@ -1041,6 +1045,7 @@ mod tests {
|
||||
h.get_by_label("Only Web Colors").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(color(&h), "#003366");
|
||||
assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("003366"), "hex field follows the snap while it has focus");
|
||||
h.get_by_label("Only Web Colors").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Click to restore the current colour").click();
|
||||
@@ -1048,6 +1053,48 @@ mod tests {
|
||||
assert_eq!(color(&h), "#123456");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_only_snaps_hex_field_and_component_edits() {
|
||||
let mut f = Map::new();
|
||||
f.insert("color".into(), json!("#123456"));
|
||||
f.insert("__webOnly".into(), json!(true));
|
||||
set_rgb(&mut f, parse_hex("#ff8800").unwrap(), Keep::Nothing);
|
||||
assert_eq!(f["color"], json!("#ff9900"));
|
||||
set_rgb(&mut f, hsv_to_rgb(40.0, 1.0, 1.0), Keep::Hsv([40.0, 1.0, 1.0]));
|
||||
assert_eq!(f["color"], json!("#ff9900"));
|
||||
assert!(f.get("__hsv").is_none(), "kept HSV does not outlive a web snap");
|
||||
set_rgb(&mut f, photocraft_color::convert::lab_to_srgb([50.0, 40.0, 20.0]).map(|c| c.clamp(0.0, 1.0)), Keep::Lab([50.0, 40.0, 20.0]));
|
||||
assert_eq!(not_web_safe(parse_hex(f["color"].as_str().unwrap()).unwrap()), None);
|
||||
assert!(f.get("__lab").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_only_survives_reopen_and_other_picker_targets() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.ui.color_panel.web_only = true;
|
||||
let id = open(&mut app, "foreground");
|
||||
let f = &app.ui.dialog_mut(id).unwrap().fields;
|
||||
assert_eq!(f["__webOnly"], json!(true));
|
||||
assert_eq!(f["color"], json!(hex(web_safe([app.session.tools.foreground[0], app.session.tools.foreground[1], app.session.tools.foreground[2]]))));
|
||||
assert_eq!(f["__orig"], f["color"], "the opening snap moves __orig too, so OK with no edit is unchanged");
|
||||
app.ui.dialogs.clear();
|
||||
let id = open_for_target(&mut app, "noteColor", "Color Picker", [1.0, 0.53, 0.0]);
|
||||
let f = &app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields;
|
||||
assert_eq!(f["__webOnly"], json!(true));
|
||||
assert_eq!(f["color"], json!("#ff9900"));
|
||||
assert_eq!(f["__orig"], json!("#ff9900"), "a non-web-safe start colour is not committed by opening the picker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_at_snaps_when_only_web_colors_is_on() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let id = open(&mut app, "foreground");
|
||||
let f = &mut app.ui.dialog_mut(id).unwrap().fields;
|
||||
f.insert("__webOnly".into(), json!(true));
|
||||
set_rgb(f, [0.07, 0.2, 0.34], Keep::Nothing);
|
||||
assert_eq!(f["color"], json!("#003366"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_buttons_and_keyboard_confirm_or_cancel_without_changing_the_other_color() {
|
||||
for target in ["foreground", "background"] {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -109,7 +109,7 @@ pub fn savable(o: &ToolOptions) -> Option<(Map<String, Value>, String)> {
|
||||
if !(a.is_finite() && b.is_finite() && a > 0.0 && b > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let name = format!("{} : {}", crate::widgets::fmt_num(a), crate::widgets::fmt_num(b));
|
||||
let name = format!("{} : {}", crop_size::fmt_ratio(a), crop_size::fmt_ratio(b));
|
||||
let mut fields = Map::new();
|
||||
fields.insert("crop_ratio".into(), json!(o.crop_ratio));
|
||||
Some((fields, name))
|
||||
|
||||
@@ -146,6 +146,14 @@ pub fn ratio(o: &ToolOptions, doc_dpi: f64) -> Option<f64> {
|
||||
Some(w / h).filter(|k| k.is_finite() && *k > 0.0)
|
||||
}
|
||||
|
||||
/// Decimals a custom ratio side keeps and shows: 1.414 : 1 (ISO A sizes) as in Photoshop (#6487).
|
||||
pub const RATIO_DECIMALS: u32 = 3;
|
||||
|
||||
/// A ratio side as stored in a `crop_ratio` key ("1.414", "16").
|
||||
pub fn fmt_ratio(v: f64) -> String {
|
||||
crate::widgets::fmt_num_prec(v, RATIO_DECIMALS)
|
||||
}
|
||||
|
||||
/// Editable W and H shown for a custom aspect ratio. An existing valid ratio is authoritative;
|
||||
/// when either side is zero, use the in-progress values retained in ToolOptions.
|
||||
pub fn custom_ratio_fields(o: &ToolOptions, doc_size: (f64, f64)) -> (f32, f32) {
|
||||
@@ -170,7 +178,7 @@ pub fn set_custom_ratio_fields(o: &mut ToolOptions, width: f32, height: f32) {
|
||||
o.crop_custom_ratio_width = width;
|
||||
o.crop_custom_ratio_height = height;
|
||||
if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 {
|
||||
o.crop_ratio = format!("{}:{}", crate::widgets::fmt_num(f64::from(width)), crate::widgets::fmt_num(f64::from(height)));
|
||||
o.crop_ratio = format!("{}:{}", fmt_ratio(f64::from(width)), fmt_ratio(f64::from(height)));
|
||||
} else {
|
||||
o.crop_ratio.clear();
|
||||
}
|
||||
@@ -347,6 +355,15 @@ mod tests {
|
||||
assert_eq!(width / height, 4.0 / 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_ratio_keeps_three_decimals() {
|
||||
// #6487: 1.414 : 1 (ISO A sizes) stays 1.414, not 1.4, as in Photoshop.
|
||||
let mut options = ToolOptions::default();
|
||||
set_custom_ratio_fields(&mut options, 1.414, 1.0);
|
||||
assert_eq!(options.crop_ratio, "1.414:1");
|
||||
assert_eq!(custom_ratio_fields(&options, (0.0, 0.0)), (1.414, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_free_ratio_clears_previous_custom_dimensions() {
|
||||
let mut options = ToolOptions { crop_ratio: "16:9".into(), ..ToolOptions::default() };
|
||||
|
||||
@@ -645,7 +645,7 @@ pub fn swapped(r: [f64; 4]) -> [f64; 4] {
|
||||
/// no ratio is set; "original" becomes the document's size turned on its side.
|
||||
pub fn swapped_ratio(key: &str, doc_w: f64, doc_h: f64) -> Option<String> {
|
||||
let (w, h) = crate::chrome_ui::crop_ratio(key, doc_w, doc_h).filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0)?;
|
||||
Some(format!("{}:{}", crate::widgets::fmt_num(h), crate::widgets::fmt_num(w)))
|
||||
Some(format!("{}:{}", crate::crop_size::fmt_ratio(h), crate::crop_size::fmt_ratio(w)))
|
||||
}
|
||||
|
||||
/// X: swaps the pending frame between portrait and landscape about its centre, keeping its angle,
|
||||
@@ -1949,6 +1949,7 @@ mod tests {
|
||||
app.ui.tool_options.crop_ratio = String::new();
|
||||
assert!(swap_orientation(&mut app));
|
||||
assert_eq!(app.ui.tool_options.crop_ratio, "", "no ratio stays none");
|
||||
assert_eq!(swapped_ratio("1.414:1", 1.0, 1.0).as_deref(), Some("1:1.414"), "decimals survive the swap (#6487)");
|
||||
assert_eq!(swapped_ratio("0:5", 1.0, 1.0), None);
|
||||
assert_eq!(swapped_ratio("nonsense", 1.0, 1.0), None);
|
||||
// No frame, or another tool: X is Switch Foreground and Background Colors.
|
||||
|
||||
@@ -334,6 +334,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
DialogKind::Command if crate::color_picker_ui::owns(&fields) => {
|
||||
outcome = crate::color_picker_ui::body(ui, &mut fields);
|
||||
crate::color_picker_ui::take_add_swatch(app, &mut fields);
|
||||
if let Some(web) = fields.get("__webOnly").and_then(Value::as_bool) {
|
||||
app.ui.color_panel.web_only = web;
|
||||
}
|
||||
}
|
||||
DialogKind::Command if crate::color_range_ui::owns(&fields) => crate::color_range_ui::body(app, ui, &mut fields),
|
||||
DialogKind::Command if crate::prefs_ui::owns(&fields) => crate::prefs_ui::body(app, ui, &mut fields),
|
||||
|
||||
@@ -21,6 +21,8 @@ pub struct Prompt {
|
||||
/// The document the command was aimed at (`document` param, else the active one), if it has one.
|
||||
target: Option<DocId>,
|
||||
docs: Vec<DocId>,
|
||||
/// The document whose question has already put keyboard focus on its default answer.
|
||||
focused: Option<DocId>,
|
||||
}
|
||||
|
||||
fn index_of(app: &PhotocraftApp, id: DocId) -> Option<usize> {
|
||||
@@ -69,7 +71,7 @@ pub fn intercept(app: &mut PhotocraftApp, id: &str, params: &Value) -> bool {
|
||||
if docs.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let prompt = Prompt { id: id.to_string(), params: params.clone(), target, docs };
|
||||
let prompt = Prompt { id: id.to_string(), params: params.clone(), target, docs, focused: None };
|
||||
match &app.discard {
|
||||
None => ask(app, prompt),
|
||||
// Quitting overrides whatever is pending: it covers every document, so nothing is lost.
|
||||
@@ -226,6 +228,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
// Save can ask for a path and then TIFF or Targa Options, and then write in the background.
|
||||
// Wait for all three; cancellation or a failed save brings us back.
|
||||
if app.file_dialog_open() || app.save_options_open() || app.saving() {
|
||||
// Hidden meanwhile: the question takes focus again when it comes back.
|
||||
if let Some(p) = app.discard.as_mut() {
|
||||
p.focused = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(p) = &app.discard else { return };
|
||||
@@ -246,6 +252,12 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
};
|
||||
(tl!("Unsaved changes"), crate::i18n::fmt(template, &[("name", &name)]))
|
||||
};
|
||||
// Each question comes up with its default answer focused, as in Photoshop: the arrow keys then
|
||||
// move between the answers and Enter presses the focused one (#6441).
|
||||
let focus = p.focused != Some(doc);
|
||||
if let Some(p) = app.discard.as_mut() {
|
||||
p.focused = Some(doc);
|
||||
}
|
||||
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
|
||||
// The answers in the platform's words; `dialog_buttons` puts them in its order. Windows (and
|
||||
// Linux) ask Yes / No / Cancel with Y, N and Esc, as Photoshop does there; macOS asks
|
||||
@@ -266,7 +278,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
};
|
||||
let mut answer = ctx.input_mut(|i| buttons.iter().find(|b| b.2.is_some_and(|k| i.consume_key(egui::Modifiers::NONE, k))).map(|b| b.4));
|
||||
let labels = button_labels(mac, &buttons.iter().map(|b| (b.1, b.2)).collect::<Vec<_>>());
|
||||
let row: Vec<DialogButton> = buttons.iter().zip(&labels).map(|(b, label)| DialogButton::new(b.0, label, b.3)).collect();
|
||||
let row: Vec<DialogButton> =
|
||||
buttons.iter().zip(&labels).map(|(b, label)| DialogButton::new(b.0, label, b.3).focus(focus && b.0 == ButtonRole::Default)).collect();
|
||||
let modal = egui::Modal::new(egui::Id::new("discard-prompt")).show(ctx, |ui| {
|
||||
ui.set_max_width(420.0);
|
||||
ui.label(egui::RichText::new(tl!(&title)).font(crate::theme::semibold(15.0)));
|
||||
@@ -582,31 +595,60 @@ mod tests {
|
||||
v.into_iter().map(|(_, l)| l).collect()
|
||||
}
|
||||
|
||||
/// Tab walks `labels` left to right and wraps; Shift+Tab steps back.
|
||||
fn tab_walks(h: &mut Prompted, labels: [&str; 3]) {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let focused = |h: &Prompted| labels.into_iter().find(|l| h.get_by_label(l).is_focused());
|
||||
for want in [labels[0], labels[1], labels[2], labels[0]] {
|
||||
/// Tab walks `labels` left to right from the focused default answer, `labels[default]`, and
|
||||
/// wraps; Shift+Tab steps back.
|
||||
fn tab_walks(h: &mut Prompted, labels: [&str; 3], default: usize) {
|
||||
assert_eq!(focused(h, &labels), Some(labels[default]));
|
||||
for step in 1..=3 {
|
||||
h.key_press(Key::Tab);
|
||||
h.run_steps(2);
|
||||
assert_eq!(focused(h), Some(want));
|
||||
assert_eq!(focused(h, &labels), Some(labels[(default + step) % 3]));
|
||||
}
|
||||
h.key_press_modifiers(egui::Modifiers::SHIFT, Key::Tab);
|
||||
h.run_steps(2);
|
||||
assert_eq!(focused(h), Some(labels[2]));
|
||||
assert_eq!(focused(h, &labels), Some(labels[(default + 2) % 3]));
|
||||
}
|
||||
|
||||
fn docs_left(h: &Prompted) -> Option<usize> {
|
||||
h.state().discard.as_ref().map(|p| p.docs.len())
|
||||
}
|
||||
|
||||
/// Which of `labels` has keyboard focus.
|
||||
fn focused<'a>(h: &Prompted, labels: &[&'a str]) -> Option<&'a str> {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
labels.iter().copied().find(|l| h.get_by_label(l).is_focused())
|
||||
}
|
||||
|
||||
/// The default answer has keyboard focus as each question comes up, so the arrow keys move
|
||||
/// between the answers and Enter presses the one they reached, as in Photoshop (#6441).
|
||||
#[test]
|
||||
fn each_question_comes_up_with_the_default_answer_focused() {
|
||||
let mut h = prompt_on(egui::os::OperatingSystem::Windows);
|
||||
let labels = ["(Y)es", "(N)o", "(C)ancel"];
|
||||
assert_eq!(focused(&h, &labels), Some("(Y)es"));
|
||||
h.key_press(Key::ArrowRight);
|
||||
h.run_steps(2);
|
||||
assert_eq!(focused(&h, &labels), Some("(N)o"));
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(2);
|
||||
assert_eq!(docs_left(&h), Some(1), "Enter pressed No");
|
||||
assert_eq!(focused(&h, &labels), Some("(Y)es"), "the next question starts on the default again");
|
||||
|
||||
let mut h = prompt_on(egui::os::OperatingSystem::Mac);
|
||||
let labels = ["Don't Save", "Cancel", "Save"];
|
||||
assert_eq!(focused(&h, &labels), Some("Save"));
|
||||
h.key_press(Key::ArrowLeft);
|
||||
h.run_steps(2);
|
||||
assert_eq!(focused(&h, &labels), Some("Cancel"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_and_linux_ask_yes_no_cancel_with_the_default_first() {
|
||||
for os in [egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] {
|
||||
let mut h = prompt_on(os);
|
||||
let labels = ["(Y)es", "(N)o", "(C)ancel"];
|
||||
assert_eq!(drawn_order(&h, labels), labels, "{os:?}");
|
||||
tab_walks(&mut h, labels);
|
||||
tab_walks(&mut h, labels, 0);
|
||||
h.key_press(Key::N);
|
||||
h.run_steps(2);
|
||||
assert_eq!(docs_left(&h), Some(1), "N answered the first document");
|
||||
@@ -699,7 +741,7 @@ mod tests {
|
||||
let mut h = prompt_on(egui::os::OperatingSystem::Mac);
|
||||
let labels = ["Don't Save", "Cancel", "Save"];
|
||||
assert_eq!(drawn_order(&h, labels), labels);
|
||||
tab_walks(&mut h, labels);
|
||||
tab_walks(&mut h, labels, 2);
|
||||
h.key_press(Key::D);
|
||||
h.run_steps(2);
|
||||
assert_eq!(docs_left(&h), Some(1), "D answered the first document");
|
||||
@@ -736,11 +778,9 @@ mod tests {
|
||||
h.run_steps(2);
|
||||
assert_eq!(asked.borrow().len(), 1, "Enter answered Yes, which saves");
|
||||
assert_eq!(docs_left(&h), Some(2));
|
||||
// Tab to No: Enter now presses it.
|
||||
for _ in 0..2 {
|
||||
h.key_press(Key::Tab);
|
||||
h.run_steps(2);
|
||||
}
|
||||
// Tab from the focused Yes to No: Enter now presses it.
|
||||
h.key_press(Key::Tab);
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(2);
|
||||
assert_eq!(asked.borrow().len(), 1, "no second dialog");
|
||||
@@ -776,7 +816,7 @@ mod tests {
|
||||
assert!(h.query_by_label("(Y)es").is_none(), "the prompt waits behind the dialog");
|
||||
answer(&mut h, None);
|
||||
assert_eq!(docs_left(&h), Some(1));
|
||||
assert!(h.query_by_label("(Y)es").is_some());
|
||||
assert_eq!(focused(&h, &["(Y)es", "(N)o", "(C)ancel"]), Some("(Y)es"), "the prompt comes back with Yes focused");
|
||||
// Saving closes the document.
|
||||
h.key_press(Key::Y);
|
||||
h.run_steps(3);
|
||||
|
||||
@@ -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á
|
||||
@@ -3804,3 +3803,16 @@ navigator Zoom Zvětšení
|
||||
Take the shortcut from the other command and select it Odebrat zkratku druhému příkazu a vybrat ho
|
||||
Undo Changes Vrátit změny
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} je vyhrazena systémem a nelze ji přiřadit.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Kreslení s nízkou latencí (bez vertikální synchronizace, platí po dalším spuštění)
|
||||
# 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
|
||||
@@ -3198,3 +3197,16 @@ curves Smooth Glätten
|
||||
Take the shortcut from the other command and select it Den Kurzbefehl dem anderen Befehl entziehen und diesen auswählen
|
||||
Undo Changes Änderungen rückgängig machen
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} ist vom System reserviert und kann nicht zugewiesen werden.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Zeichnen mit geringer Latenz (ohne VSync, gilt ab dem nächsten Start)
|
||||
# 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 Επανασχεδίαση
|
||||
@@ -3896,3 +3895,16 @@ curves Smooth Εξομάλυνση
|
||||
Take the shortcut from the other command and select it Αφαίρεση της συντόμευσης από την άλλη εντολή και επιλογή της
|
||||
Undo Changes Αναίρεση αλλαγών
|
||||
{shortcut} is reserved by the system and can't be assigned. Η συντόμευση {shortcut} δεσμεύεται από το σύστημα και δεν μπορεί να εκχωρηθεί.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Σχεδίαση χαμηλής καθυστέρησης (χωρίς vsync, ισχύει στην επόμενη εκκίνηση)
|
||||
# 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
|
||||
@@ -4158,3 +4157,16 @@ navigator Zoom Zoom
|
||||
Take the shortcut from the other command and select it Quitar el método abreviado al otro comando y seleccionarlo
|
||||
Undo Changes Deshacer cambios
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} está reservado por el sistema y no se puede asignar.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Dibujo de baja latencia (sin sincronización vertical, se aplica al volver a iniciar)
|
||||
# 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
|
||||
@@ -3201,3 +3200,16 @@ curves Smooth Lisser
|
||||
Take the shortcut from the other command and select it Retirer le raccourci de l’autre commande et la sélectionner
|
||||
Undo Changes Annuler les modifications
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} est réservé par le système et ne peut pas être attribué.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Dessin à faible latence (sans synchronisation verticale, au prochain lancement)
|
||||
# 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
|
||||
@@ -4115,3 +4114,16 @@ cameraRaw Yellow Sárga
|
||||
Take the shortcut from the other command and select it A billentyűparancs elvétele a másik parancstól, és annak kijelölése
|
||||
Undo Changes Változások visszavonása
|
||||
{shortcut} is reserved by the system and can't be assigned. A(z) {shortcut} a rendszer számára fenntartott, nem rendelhető hozzá.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Kis késleltetésű rajzolás (vsync nélkül, a következő indításkor lép életbe)
|
||||
# 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.
|
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user