diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ba8243353d..b06e15449d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -37,7 +37,8 @@ concurrency:
jobs:
# Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get Linux and
# Windows. Other PRs get Linux, plus Windows when they touch code whose behaviour differs by
- # platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
+ # platform: the app shell, UI, text and fonts, GPU, pen input, native saves, build scripts or
+ # the lockfile. Format changes must exercise the Windows-only publication barriers (#8034).
# macOS is tested only by release.yml.
platforms:
timeout-minutes: 10
@@ -78,7 +79,7 @@ jobs:
else
echo "web=false" >> "$GITHUB_OUTPUT"
fi
- if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
+ if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif|format)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
echo "legs=$all" >> "$GITHUB_OUTPUT"
else
echo "legs=[$linux]" >> "$GITHUB_OUTPUT"
@@ -113,7 +114,7 @@ jobs:
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: clippy, rustfmt
@@ -122,7 +123,7 @@ jobs:
# 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
+ - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }}
cache-on-failure: true
@@ -251,9 +252,9 @@ jobs:
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
shared-key: test
save-if: false
@@ -306,8 +307,8 @@ jobs:
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
steps:
- uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@stable
- - uses: Swatinem/rust-cache@v2
+ - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
+ - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: corpus
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -328,10 +329,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: wasm32
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -351,17 +352,17 @@ jobs:
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: ci-web
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Install trunk
run: |
- curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
+ curl --proto '=https' -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
# package.sh runs Trunk's release builds, validates relative URLs, cuts the wasm into
@@ -419,15 +420,17 @@ jobs:
- { crate: format, name: load }
steps:
- uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@nightly
- - uses: Swatinem/rust-cache@v2
+ - uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly
+ - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: crates/${{ matrix.target.crate }}/fuzz
- name: Install cargo-fuzz
run: cargo install cargo-fuzz --locked
- name: Fuzz
working-directory: crates/${{ matrix.target.crate }}
- run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time=${{ inputs.fuzz_seconds }}
+ env:
+ FUZZ_SECONDS: ${{ inputs.fuzz_seconds }}
+ run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time="$FUZZ_SECONDS"
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 67e6b31894..dca0dacfbb 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- name: Install mdBook
run: cargo install mdbook --version 0.4.52 --locked
- name: Build documentation
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index 970c6577a4..56d1ad0831 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -32,5 +32,7 @@ jobs:
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"
+ # The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a device at
+ # once, so photocraft-gpu's tests run one at a time, like ui-egui's integration tests.
+ custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui --exclude photocraft-gpu && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-gpu --locked -- --test-threads=1 && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1"
run-package-check: true
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
index 18392587a8..01cb9cd663 100644
--- a/.github/workflows/nix.yml
+++ b/.github/workflows/nix.yml
@@ -18,16 +18,6 @@ concurrency:
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
+ nix:
+ # 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/nix.yml@main
diff --git a/.github/workflows/perf-nightly.yml b/.github/workflows/perf-nightly.yml
index 10d22292d2..4b09278804 100644
--- a/.github/workflows/perf-nightly.yml
+++ b/.github/workflows/perf-nightly.yml
@@ -44,9 +44,9 @@ jobs:
with:
persist-credentials: false
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: perf-release
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
new file mode 100644
index 0000000000..ecee45a6d8
--- /dev/null
+++ b/.github/workflows/sonarcloud.yml
@@ -0,0 +1,20 @@
+name: SonarCloud
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sonarcloud-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ sonarcloud:
+ # Tracked at @main so org-wide CI improvements propagate without PR churn.
+ uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
+ secrets: inherit
diff --git a/AGENTS.md b/AGENTS.md
index a003c661ba..172bc7bd70 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,7 +19,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
| `docs/parity-checklist.md` | Generated list of every Photoshop menu item, live or missing |
| `docs/scorecard.md` | Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing |
| `crates//README.md` (where present) | Public API of that crate |
-| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
+| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs and GIMP-authored XCFs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
## 2. Workspace map
@@ -70,6 +70,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
8. **Performance is a feature.** Benchmark heavy operations on a 24–36 MP image in release. Work per tile in parallel (rayon), skip empty tiles, never scan a full surface per frame (cache per revision), and record before/after timings in the dev log.
9. **Never panic on input** (see *Never crash* above). A command's `run` closure and anything it calls must return `Err`, never panic, for *any* params or document state: validate params, check bounds before indexing or dividing, and reject absurd sizes before allocating. The `panic_hunt` integration test fuzzes every command with adversarial params and must stay green.
+10. **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
## 4. Picking work
@@ -107,7 +108,7 @@ cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text
While iterating, run less and keep the full commands above as the final gate: `cargo test -p --lib --tests` skips the examples and doctests plain `cargo test` builds every time (ui-egui has 11 examples), and `cargo test -p --test ` runs one integration-test file. `cargo nextest run` runs tests in parallel across binaries; see `docs/development.md` › Faster test loops.
-**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
+**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop- and GIMP-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
Commands must **never panic** on bad input (Rule 9): every `run` closure and the code it calls returns `Err`, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → `Err`, not a crash).
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index cbbbe069a5..d8244b4fec 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -25,19 +25,23 @@ next to them.
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
+| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
-| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
+| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
+| `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
+| `assets/icons/text-warp.svg` | Create Warped Text glyph (Type tool options bar): a T above a bowed arc, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
+| `assets/icons/align-edge-*.svg`, `assets/icons/distribute-*.svg` | Move tool options-bar glyphs: Align (six edges/centres) and Distribute (edges, centres, spacing), a guide line or edge lines with bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -110,11 +114,13 @@ next to the files:
| Path (fetched) | Title | Author | Source | License |
|---|---|---|---|---|
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
+| `corpus/gimp/` (28 files, 251 KB) | GIMP XCF oracles: 40×30 procedural layers written by GIMP 3.2.6 through the corpus generator `tools/gimp-xcf/xcf_fixtures.py`, each with GIMP's merged rendering as a PNG | PhotoCraft contributors (authored with GIMP 3.2.6 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`gimp/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
| `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) |
| `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause |
| `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
+| `corpus/atn/` (16 files, 0.9 MB) | Photoshop Actions sets, one folder per repository: `sbaril/` (Frequency Separation, Firefly, Dimension, Harmonize, Photo, Quick Object Extraction, Generative Upscale), `bjango/` (Bjango Actions, Adjustment Actions, Scale Actions), `bkwld/` (Save layers for web), `atntools/` (test.atn), `trimmer/` (Trimmer), `alpha-utility/` (AlphaUtilityAdditions), `gif-creator/` (BannerActions) | Stéphane Baril; Bjango and Marc Edwards; BKWLD; Maurits Rijk; damsmcfly; UCLA Game Lab; overbyte | [sbaril/Photoshop-Scripts](https://github.com/sbaril/Photoshop-Scripts), [bjango/Bjango-Actions](https://github.com/bjango/Bjango-Actions), [BKWLD/photoshop-actions](https://github.com/BKWLD/photoshop-actions), [mrijk/atntools](https://github.com/mrijk/atntools), [damsmcfly/photoshop-cleaner-trimmer](https://github.com/damsmcfly/photoshop-cleaner-trimmer), [uclagamelab/AlphaUtilityAdditions](https://github.com/uclagamelab/AlphaUtilityAdditions), [overbyte/photoshop-gif-creator-script](https://github.com/overbyte/photoshop-gif-creator-script), pinned in [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | MIT (sbaril, BKWLD, atntools, gif-creator), BSD (bjango, per `Help/License.md`), BSD-2-Clause (alpha-utility), Unlicense (trimmer); each licence file sits next to its files |
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | | Public domain |
| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
diff --git a/Cargo.lock b/Cargo.lock
index b0ff196c55..98e1290c29 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4638,10 +4638,11 @@ dependencies = [
[[package]]
name = "photocraft"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"arboard",
"async-io",
+ "chrono",
"core-graphics2",
"dispatch2",
"eframe",
@@ -4683,7 +4684,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4693,7 +4694,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4706,7 +4707,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"base64 0.22.1",
"cap-std",
@@ -4716,8 +4717,8 @@ dependencies = [
"photocraft-doc",
"photocraft-engine",
"photocraft-format",
- "photocraft-geom",
"photocraft-io",
+ "photocraft-raw",
"rmcp",
"rustix 1.1.5",
"schemars",
@@ -4729,13 +4730,14 @@ dependencies = [
[[package]]
name = "photocraft-cli"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
"photocraft-engine",
"photocraft-format",
"photocraft-io",
+ "photocraft-raw",
"serde_json",
"tokio",
"winresource",
@@ -4743,7 +4745,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"moxcms",
"rayon",
@@ -4751,7 +4753,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"exr",
"flate2",
@@ -4772,7 +4774,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"photocraft-cms",
"serde",
@@ -4780,7 +4782,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"criterion",
"photocraft-color",
@@ -4795,7 +4797,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4806,9 +4808,10 @@ dependencies = [
[[package]]
name = "photocraft-engine"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"blake3",
+ "chrono",
"craft-print",
"exr",
"flate2",
@@ -4844,7 +4847,7 @@ dependencies = [
[[package]]
name = "photocraft-format"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"blake3",
"crc32fast",
@@ -4867,17 +4870,18 @@ dependencies = [
[[package]]
name = "photocraft-geom"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"half",
"log",
+ "photocraft-cms",
"photocraft-color",
"photocraft-compose",
"photocraft-doc",
@@ -4891,14 +4895,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"blake3",
"craft-docpath",
@@ -4928,18 +4932,18 @@ dependencies = [
[[package]]
name = "photocraft-ipp"
-version = "0.11.1"
+version = "0.12.0"
[[package]]
name = "photocraft-ops"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4950,7 +4954,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4965,7 +4969,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4976,7 +4980,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4987,7 +4991,7 @@ dependencies = [
[[package]]
name = "photocraft-raw"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"flate2",
"image",
@@ -5002,7 +5006,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -5014,18 +5018,15 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"memory-stats",
- "photocraft-color",
- "photocraft-geom",
- "photocraft-raster",
"serde_json",
]
[[package]]
name = "photocraft-text"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"flate2",
"parley",
@@ -5040,7 +5041,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"base64 0.22.1",
"craft-print",
@@ -5075,6 +5076,7 @@ dependencies = [
"serde",
"serde_json",
"sys-locale",
+ "tempfile",
"toml",
"unicode-bidi",
"winsafe",
@@ -5082,7 +5084,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -5093,7 +5095,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"eframe",
"egui",
@@ -5115,7 +5117,7 @@ dependencies = [
[[package]]
name = "photocraft-web-boot"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"flate2",
"js-sys",
@@ -8711,8 +8713,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
+ "chrono",
"serde",
"serde_json",
"toml",
diff --git a/Cargo.toml b/Cargo.toml
index b3f49802e3..b4a66136b8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
-version = "0.11.1"
+version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -59,6 +59,8 @@ egui = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
log = "0.4"
+# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
+chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
criterion = { version = "0.5", default-features = false }
getrandom = "0.3"
diff --git a/Dockerfile b/Dockerfile
index 66734f255a..1803d3a2e9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -25,8 +25,8 @@ RUN set -eu; \
asset="trunk-${arch}-unknown-linux-musl.tar.gz"; \
url="https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}"; \
cd /tmp; \
- curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
- curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \
+ curl --proto '=https' -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
+ curl --proto '=https' -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \
printf '%s %s\n' "$(cat "$asset.sha256")" "$asset" | sha256sum -c -; \
tar -xzf "$asset" -C /usr/local/bin trunk; \
rm "$asset" "$asset.sha256"; \
diff --git a/README.md b/README.md
index 7554fcd8f2..74a5c78f36 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,10 @@
+
+
+
+
PhotoCraft
@@ -202,7 +206,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
🗂️ Formats
- PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), Paint.NET PDN3 (read, with editable layers and all 14 blend modes), OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP), Krita .kra (read, with paint layers, groups, blend modes and transparency masks), SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native .pcraft format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the codec capability matrix for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional --features heif for source builds). AVIF is write-only with the optional avif build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (.af, .afdesign, .afphoto, .afpub) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
+ PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), Paint.NET PDN3 (read, with editable layers and all 14 blend modes), OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP), GIMP XCF (read, with layers, groups, masks, blend modes, channels and the selection), Krita .kra (read, with paint layers, groups, blend modes and transparency masks), SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native .pcraft format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the codec capability matrix for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional --features heif for source builds). AVIF is write-only with the optional avif build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (.af, .afdesign, .afphoto, .afpub) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
🪄 The everyday essentials
@@ -217,7 +221,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
PhotoCraft's PSD support is a standalone crate written from Adobe's public specification and tested against a corpus of real-world files.
-- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/corpus.rs`).
+- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/it/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/it/corpus.rs`).
- **Pixels that match:** a composite oracle compares our render with Photoshop's own merged image, covering gradient interpolation (Classic, Perceptual and Linear), layer effects, shape strokes, clipping and fill opacity.
- **Large documents:** PSB, 16 and 32-bit files, and CMYK and Lab documents open natively.
@@ -339,7 +343,7 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi
**New to PhotoCraft?** Download it from the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). That's the easiest way to install it.
-**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) has earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
+**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) have earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
### Windows
diff --git a/ROADMAP.md b/ROADMAP.md
index f41b71bfc9..829ff75af8 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
+> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Object Finder retains large enclosed foregrounds (#6548); Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
@@ -104,7 +104,7 @@ because engine messages are still English. Detail: [localization-parity](docs/lo
| Korean | `ko` | 100% | partial |
| Vietnamese | `vi` | 0% | none |
-Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
+Also shipped: Traditional Chinese, Russian (every dynamic label, [notes](docs/localization-ru.md)), Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
## Upcoming
@@ -122,6 +122,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Entry |
|---|---|
+| 2026-10-17 | Object Finder classifies background by boundary coverage, retaining large enclosed foregrounds and rejecting surrounding backdrop (#6548); learned segmentation remains open (#2788) |
| 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized to the craftrules standard (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. |
| 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus |
| 2026-10-08 | 0.5.0. Affinity import; Magnetic Lasso, Pattern Stamp, Mixer Brush, Pencil, Patch, Vertical Type on the toolbar |
@@ -135,7 +136,9 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
-| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
+| 2026-10-17 | minor | Object Finder retains large enclosed foregrounds (#6548) |
+| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/it/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
+| 2026-10-15 | minor | Russian catalog covers every dynamic UI label the other catalogs translate |
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
| 2026-10-10 | minor | Readiness by audience with hours per tier |
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml
index f4848f379a..70f15d5be1 100644
--- a/apps/photocraft-cli/Cargo.toml
+++ b/apps/photocraft-cli/Cargo.toml
@@ -31,6 +31,10 @@ photocraft-codecs = { workspace = true }
serde_json = { workspace = true }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
+[dev-dependencies]
+# Synthetic camera raws (#7789).
+photocraft-raw = { workspace = true, features = ["testgen"] }
+
[build-dependencies]
# Icon + VERSIONINFO for photocraft-cli.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"
diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs
index 0e3429d074..c2497e1bf1 100644
--- a/apps/photocraft-cli/src/lib.rs
+++ b/apps/photocraft-cli/src/lib.rs
@@ -15,21 +15,27 @@ pub const USAGE: &str = "\
photocraft-cli: headless PhotoCraft
USAGE:
- photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps]
+ photocraft-cli convert [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
- TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
+ An that is the file is refused, as the result would replace the original;
+ --in-place allows it.
+ TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data);
+ --tiff-compression compresses it (none, the default; lzw; zip).
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
--dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1
(DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain.
+ --jpeg-format picks a JPEG's scans, as JPEG Options does: baseline (the default), optimized
+ (Huffman tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so
+ only Gray and CMYK shrink) or progressive.
photocraft-cli info [--compact]
Print the document as JSON (size, mode, depth, layer tree).
- photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps]
+ photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ]
Open a file, run engine commands in order, save the result. Each --params
applies to the preceding --cmd. Prints each command's JSON result.
- photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps]
+ photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ]
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
@@ -73,20 +79,44 @@ struct Subcommand {
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand {
name: "convert",
- values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
- bare: &["--tiff-layers", "--dds-mipmaps"],
+ values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--tiff-compression", "--jpeg-format"],
+ bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
run: convert,
},
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
Subcommand {
name: "run",
- values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
+ values: &[
+ "--new",
+ "--cmd",
+ "--params",
+ "--out",
+ "--format",
+ "--quality",
+ "--tga-bits",
+ "--dpx-bits",
+ "--dds-compression",
+ "--tiff-compression",
+ "--jpeg-format",
+ ],
bare: &["--tiff-layers", "--dds-mipmaps"],
run: run_cmds,
},
Subcommand {
name: "batch",
- values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
+ values: &[
+ "--actions",
+ "--action",
+ "--in",
+ "--out",
+ "--format",
+ "--quality",
+ "--tga-bits",
+ "--dpx-bits",
+ "--dds-compression",
+ "--tiff-compression",
+ "--jpeg-format",
+ ],
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
run: batch,
},
@@ -226,6 +256,18 @@ fn export_opts(a: &Args) -> Result {
o.encode.dpx_bits =
Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?);
}
+ if let Some(c) = a.get("--tiff-compression") {
+ o.encode.tiff_compression = match c {
+ "none" => photocraft_codecs::TiffCompression::None,
+ "lzw" => photocraft_codecs::TiffCompression::Lzw,
+ "zip" => photocraft_codecs::TiffCompression::Deflate,
+ _ => return Err(format!("bad --tiff-compression `{c}`: expected none, lzw or zip")),
+ };
+ }
+ if let Some(f) = a.get("--jpeg-format") {
+ o.encode.jpeg_format =
+ photocraft_codecs::JpegFormat::parse(f).ok_or_else(|| format!("bad --jpeg-format `{f}`: expected baseline, optimized or progressive"))?;
+ }
if let Some(c) = a.get("--dds-compression") {
o.encode.dds.compression =
photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?;
@@ -251,12 +293,28 @@ fn automation_workspace(args: &Args) -> Result {
.map_err(|error| error.to_string())
}
+/// An output that is the input file itself, however it is spelt (relative, with `./`, through a
+/// hard or symbolic link), would replace the original, and a lossy re-encode can't be undone: it
+/// is refused unless `--in-place` asks for it, as `batch` refuses an `--out` that is its `--in`
+/// folder (#7437). The paths are compared when the command starts and the save opens `output`
+/// again later, so another process swapping in a link meanwhile goes unnoticed: this guards
+/// against mistakes, it is not a security boundary.
+fn refuse_overwriting_input(a: &Args, input: &str, output: &str) -> R {
+ if !a.has("--in-place") && photocraft_engine::file_cmds::same_file(input, output) {
+ return Err(format!("{output} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it"));
+ }
+ Ok(())
+}
+
fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
let [input, output] = a.positional.as_slice() else {
return Err("convert needs ".into());
};
let opts = export_opts(a)?;
- let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
+ refuse_overwriting_input(a, input, output)?;
+ let mut o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
+ // Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw.
+ photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document);
warn_all(err, &o.warnings);
warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document));
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
@@ -456,11 +514,32 @@ fn is_input(p: &Path) -> bool {
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
}
+/// Drops the Save and view steps the desktop records from `action` (Batch saves each result
+/// itself, #3143), keeping its unchecked and dialog flags on the steps that remain (#6860).
+fn strip_shell_steps(action: &mut Action) {
+ use photocraft_engine::actions_cmds::{shell_save_command, shell_view_command};
+ let (mut steps, mut disabled, mut dialogs) = (Vec::new(), std::collections::BTreeSet::new(), std::collections::BTreeSet::new());
+ for (i, (id, p)) in std::mem::take(&mut action.steps).into_iter().enumerate() {
+ if shell_save_command(&id) || shell_view_command(&id) {
+ continue;
+ }
+ if action.disabled.contains(&i) {
+ disabled.insert(steps.len());
+ }
+ if action.dialogs.contains(&i) {
+ dialogs.insert(steps.len());
+ }
+ steps.push((id, p));
+ }
+ (action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs);
+}
+
fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let actions_path = a.get("--actions").ok_or("batch needs --actions ")?;
let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in ")?);
let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out ")?);
- let (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
+ let (actions, mut set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
+ set.iter_mut().for_each(strip_shell_steps);
for w in &warnings {
let _ = writeln!(err, "warning: {w}");
}
@@ -763,4 +842,37 @@ mod batch_action_set_tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
+
+ /// Save and view steps recorded inside a batched action set are skipped, also in a called
+ /// action, and unchecked steps stay unchecked once they are gone (#6860).
+ #[test]
+ fn batch_skips_save_and_view_steps_inside_an_action_set() {
+ let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-save-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ let path = |name: &str| dir.join(name).to_string_lossy().to_string();
+ std::fs::create_dir_all(dir.join("in")).unwrap();
+ let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]);
+ assert_eq!(code, 0, "{stderr}");
+ let set = json!({"version": 1, "actions": [
+ {"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}], ["file.saveAs", {}]]},
+ {"name": "Rotate", "steps": [
+ ["file.save", {}],
+ ["image.imageRotation.90cw", {}],
+ ["view.fitOnScreen", {}],
+ ["image.imageRotation.90cw", {}],
+ ["file.saveAs", {}],
+ ], "disabled": [3], "dialogs": [1]},
+ ]});
+ std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
+ for action in ["Outer", "Rotate"] {
+ let (code, stdout, stderr) =
+ invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
+ assert_eq!(code, 0, "{stdout}{stderr}");
+ let (code, info, stderr) = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]);
+ assert_eq!(code, 0, "{stderr}");
+ let info: Value = serde_json::from_str(&info).unwrap();
+ assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "{action}: rotated once, the unchecked step skipped");
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+ }
}
diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/it/cli.rs
similarity index 80%
rename from apps/photocraft-cli/tests/cli.rs
rename to apps/photocraft-cli/tests/it/cli.rs
index 19258ca9ed..c31239d14f 100644
--- a/apps/photocraft-cli/tests/cli.rs
+++ b/apps/photocraft-cli/tests/it/cli.rs
@@ -563,6 +563,33 @@ fn tga_bits_choose_the_targa_depth() {
}
}
+/// `--jpeg-format` picks the JPEG's scans: progressive writes a progressive frame (SOF2). One it
+/// doesn't name is refused and nothing is written.
+#[test]
+fn jpeg_format_chooses_the_scans() {
+ let d = tmp("jpeg-format");
+ let a = d.join("a.png");
+ write_png(&a, 16, 8, 3);
+ let marker = |b: &[u8], m: u8| b.windows(2).any(|w| w == [0xFF, m]);
+ for (format, progressive) in [(None, false), (Some("baseline"), false), (Some("optimized"), false), (Some("progressive"), true)] {
+ let out = d.join("a.jpg");
+ let mut convert = bin();
+ convert.arg("convert").arg(&a).arg(&out);
+ if let Some(f) = format {
+ convert.args(["--jpeg-format", f]);
+ }
+ ok(&mut convert);
+ assert_eq!(marker(&std::fs::read(&out).unwrap(), 0xC2), progressive, "--jpeg-format {format:?}");
+ }
+ for bad in ["Progressive", "1", ""] {
+ let out = d.join("bad.jpg");
+ let o = bin().arg("convert").arg(&a).arg(&out).args(["--jpeg-format", bad]).output().unwrap();
+ assert_eq!(o.status.code(), Some(1), "--jpeg-format {bad}");
+ assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --jpeg-format `{bad}`")), "--jpeg-format {bad}");
+ assert!(!out.exists(), "--jpeg-format {bad} wrote a file");
+ }
+}
+
/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before
/// anything is written, unless `--in-place` asks for it.
#[test]
@@ -609,6 +636,42 @@ fn batch_refuses_to_write_over_its_inputs() {
assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced");
}
+/// #7437: `convert` to its own input file, however the output is spelt, is refused before
+/// anything is read or written, unless `--in-place` asks for it (as `batch` does, #492).
+#[test]
+fn convert_refuses_to_write_over_its_input() {
+ let d = tmp("convert-in-place");
+ write_png(&d.join("in.png"), 8, 4, 5);
+ let before = std::fs::read(d.join("in.png")).unwrap();
+ let convert = |out: &Path, extra: &[&str]| bin().current_dir(&d).args(["convert", "in.png"]).arg(out).args(extra).output().unwrap();
+ let mut outs = vec![PathBuf::from("in.png"), PathBuf::from("./in.png"), d.join("in.png"), d.join(".").join("in.png")];
+ std::fs::hard_link(d.join("in.png"), d.join("hard.png")).unwrap();
+ outs.push(PathBuf::from("hard.png"));
+ #[cfg(unix)]
+ {
+ std::os::unix::fs::symlink(d.join("in.png"), d.join("soft.png")).unwrap();
+ outs.push(PathBuf::from("soft.png"));
+ }
+ for out in &outs {
+ for extra in [&[][..], &["--format", "png"][..], &["--quality", "80"][..]] {
+ let o = convert(out, extra);
+ let err = String::from_utf8_lossy(&o.stderr);
+ assert_eq!(o.status.code(), Some(1), "{} {extra:?}: {err}", out.display());
+ assert!(err.contains("is the input file") && err.contains("--in-place"), "{err}");
+ assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before, "{} {extra:?} changed the original", out.display());
+ }
+ }
+ // Another file converts as before, leaving the original alone.
+ ok(bin().current_dir(&d).args(["convert", "in.png", "out.jpg"]));
+ assert!(d.join("out.jpg").is_file());
+ assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before);
+ // `--in-place` opts in: the result replaces the original, which still opens.
+ ok(bin().current_dir(&d).args(["convert", "in.png", "in.png", "--in-place"]));
+ let (out, _) = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"]));
+ let info: Value = serde_json::from_str(&out).unwrap();
+ assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}");
+}
+
#[test]
fn commands_listing() {
let (out, _) = ok(bin().arg("commands"));
@@ -796,3 +859,136 @@ fn batch_plays_a_photoshop_actions_file() {
assert!(err.contains("Generative Fill"), "{err}");
assert!(!o.status.success());
}
+
+/// `photocraft_cli::run` in-process: `(exit code, stdout, stderr)`.
+fn invoke(args: &[&str]) -> (i32, String, String) {
+ let (mut out, mut err) = (Vec::new(), Vec::new());
+ let args: Vec = args.iter().map(|s| s.to_string()).collect();
+ let code = photocraft_cli::run(&args, &mut out, &mut err);
+ (code, String::from_utf8(out).unwrap(), String::from_utf8(err).unwrap())
+}
+
+const SUBCOMMANDS: [&str; 8] = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"];
+
+/// Pins top-level dispatch: help goes to stdout (exit 0), no arguments is a usage error on stderr.
+#[test]
+fn top_level_help_version_and_no_arguments() {
+ let (code, out, err) = invoke(&[]);
+ assert_eq!((code, out.is_empty(), err.is_empty()), (2, true, false));
+ for help in ["--help", "-h", "help"] {
+ let (code, out, err) = invoke(&[help]);
+ assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{help}");
+ }
+ for version in ["--version", "version"] {
+ let (code, out, err) = invoke(&[version]);
+ assert_eq!((code, err.as_str()), (0, ""), "{version}");
+ assert!(out.starts_with("photocraft-cli "), "{version}: {out}");
+ }
+ let (code, out, err) = invoke(&["frobnicate"]);
+ assert_eq!((code, out.is_empty()), (2, true));
+ assert!(err.contains("frobnicate"), "{err}");
+}
+
+/// ` --help` / `-h` prints help on stdout and returns without running the subcommand
+/// (`mcp` and `serve` would otherwise block on stdin).
+#[test]
+fn every_subcommand_help_returns_on_stdout() {
+ for sub in SUBCOMMANDS {
+ for help in ["--help", "-h"] {
+ let (code, out, err) = invoke(&[sub, help]);
+ assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{sub} {help}: {err}");
+ // `commands` would also exit 0 with output if it ran; its list names `file.new`, help doesn't.
+ assert!(!out.contains("file.new"), "{sub} {help} ran instead of printing help: {out}");
+ }
+ }
+}
+
+/// Every subcommand refuses an unknown flag (bare or `=value`) with exit 2, naming it on stderr.
+#[test]
+fn every_subcommand_rejects_an_unknown_flag() {
+ for sub in SUBCOMMANDS {
+ for flag in [&["--fromat"][..], &["--fromat=jpg"][..], &["--fromat", "jpg"][..]] {
+ let args: Vec<&str> = [sub].into_iter().chain(flag.iter().copied()).collect();
+ let (code, out, err) = invoke(&args);
+ assert_eq!((code, out.is_empty()), (2, true), "{args:?}: {err}");
+ assert!(err.contains("--fromat"), "{args:?}: {err}");
+ }
+ }
+}
+
+/// `--flag=value`, a bare flag given a value, a value flag without one, and repeated flags.
+#[test]
+fn flag_value_forms_and_repeats() {
+ let ids = |out: &str| out.lines().filter_map(|l| l.split_whitespace().next()).map(str::to_owned).collect::>();
+ let (code, eq, err) = invoke(&["commands", "--filter=file.new"]);
+ assert_eq!(code, 0, "{err}");
+ let (_, spaced, _) = invoke(&["commands", "--filter", "file.new"]);
+ assert_eq!(eq, spaced);
+ assert!(ids(&eq).contains(&"file.new".to_string()), "{eq}");
+ // The last of a repeated value flag wins, whichever order they come in.
+ let (code, out, err) = invoke(&["commands", "--filter", "zzz-no-match", "--filter", "file.new"]);
+ assert_eq!(code, 0, "{err}");
+ assert_eq!(out, eq);
+ let (code, out, err) = invoke(&["commands", "--filter", "file.new", "--filter", "zzz-no-match"]);
+ assert_eq!(code, 0, "{err}");
+ assert!(out.is_empty(), "{out}");
+ // A bare flag takes no value; a value flag needs one.
+ let (code, out, err) = invoke(&["commands", "--json=1"]);
+ assert_eq!((code, out.is_empty()), (2, true));
+ assert!(err.contains("--json"), "{err}");
+ let (code, out, err) = invoke(&["commands", "--filter"]);
+ assert_eq!((code, out.is_empty()), (2, true));
+ assert!(err.contains("--filter"), "{err}");
+}
+
+/// Each `--params` applies to the `--cmd` before it; a `--cmd` without one gets `{}`; a
+/// `--params` ahead of every `--cmd` fails naming `--params`.
+#[test]
+fn run_pairs_params_with_the_preceding_cmd() {
+ let (code, out, err) = invoke(&[
+ "run",
+ "--new",
+ r#"{"width":8,"height":8}"#,
+ "--cmd",
+ "layer.new.layer",
+ "--params",
+ r#"{"name":"First"}"#,
+ "--cmd",
+ "layer.new.layer",
+ "--cmd",
+ "layer.new.layer",
+ "--params",
+ r#"{"name":"Third"}"#,
+ "--cmd",
+ "document.inspect",
+ ]);
+ assert_eq!(code, 0, "{err}");
+ let rows: Vec = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
+ assert_eq!(rows.len(), 4, "{out}");
+ let names: Vec<&str> = rows[3]["result"]["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect();
+ let count = |n: &str| names.iter().filter(|x| **x == n).count();
+ assert_eq!((count("First"), count("Third")), (1, 1), "each --params applies to its own --cmd only: {names:?}");
+ // Three new layers plus the background; the middle one (no --params) has a default name.
+ assert_eq!(names.len(), 4, "{names:?}");
+ // Layers list top first: Third, the unnamed middle one, First, Background. Pairing --params
+ // with --cmd by position (params[i] -> cmd[i]) would name the middle one Third instead.
+ assert_eq!((names[0], names[2]), ("Third", "First"), "{names:?}");
+ let (code, _, err) = invoke(&["run", "--new", "{}", "--params", "{}", "--cmd", "layer.new.layer"]);
+ assert_ne!(code, 0);
+ assert!(err.contains("--params"), "{err}");
+}
+
+/// #7789: `convert` opens a developed raw at Raw Defaults › Resolution (default 300 ppi), as
+/// `run`, `info` and the app do; it wrote 72.
+#[test]
+fn convert_opens_a_raw_at_the_raw_defaults_resolution() {
+ use photocraft_raw::testgen::{DngSpec, mosaic, scene};
+ let d = tmp("convert-raw");
+ let (w, h) = (24, 16);
+ let (raw, psd) = (d.join("IMG_0001.dng"), d.join("IMG_0001.psd"));
+ std::fs::write(&raw, DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap();
+ ok(bin().args(["convert"]).arg(&raw).arg(&psd));
+ let doc = photocraft_io::import("IMG_0001.psd", &std::fs::read(&psd).unwrap()).unwrap().document;
+ assert_eq!(doc.resolution_dpi, 300.0);
+ std::fs::remove_dir_all(d).unwrap();
+}
diff --git a/apps/photocraft-cli/tests/it/main.rs b/apps/photocraft-cli/tests/it/main.rs
new file mode 100644
index 0000000000..581c983b3c
--- /dev/null
+++ b/apps/photocraft-cli/tests/it/main.rs
@@ -0,0 +1,4 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod cli;
+mod orientation;
diff --git a/apps/photocraft-cli/tests/orientation.rs b/apps/photocraft-cli/tests/it/orientation.rs
similarity index 100%
rename from apps/photocraft-cli/tests/orientation.rs
rename to apps/photocraft-cli/tests/it/orientation.rs
diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs
index a248ecbb13..fc17612908 100644
--- a/apps/photocraft-web/src/web.rs
+++ b/apps/photocraft-web/src/web.rs
@@ -17,11 +17,12 @@ use wasm_bindgen::JsCast as _;
type Inbox = Arc)>>>;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
-/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
+/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .act, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
- "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
- "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef",
- "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
+ "pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
+ "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm", "ilbm", "heic",
+ "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "af", "afdesign",
+ "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -322,6 +323,12 @@ impl WebShell {
}
impl eframe::App for WebShell {
+ fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
+ // A tap reaches eframe as Pointer Events and Touch Events: keep one press per tap, or the
+ // twin press closes the menu the first one opened (#2095).
+ photocraft_ui_egui::touch_twins::drop_twins(ctx, raw_input);
+ }
+
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
if !dropped.is_empty() {
@@ -384,8 +391,8 @@ impl eframe::App for WebShell {
fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services {
Services {
screen_pick: screen_color_service(),
- import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
- photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
+ import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| {
+ photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
})),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml
index 505947e222..4ba562db68 100644
--- a/apps/photocraft/Cargo.toml
+++ b/apps/photocraft/Cargo.toml
@@ -33,6 +33,7 @@ eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11",
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+chrono = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
@@ -50,11 +51,12 @@ core-graphics2 = { version = "=0.6.1", default-features = false, features = ["di
# safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
# Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs) and find where the
-# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position). Already in
+# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position), and play the
+# system alert sound for Beep When Done (services.rs, NSBeep). Already in
# Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions.
objc2 = "=0.6.4"
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] }
-objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] }
+objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSGraphics", "NSResponder", "NSView", "NSWindow"] }
# The macOS menu bar (mac_menu.rs): a standalone native menu crate (from the tauri-apps org, but
# not Tauri: no webview, no JS), AppKit menus behind a safe API (no GTK with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.1", default-features = false }
diff --git a/apps/photocraft/src/app_icon.rs b/apps/photocraft/src/app_icon.rs
index 54eea2d6b5..e20e70206a 100644
--- a/apps/photocraft/src/app_icon.rs
+++ b/apps/photocraft/src/app_icon.rs
@@ -34,6 +34,13 @@ pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool {
!(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle))
}
+/// Value for [`egui::ViewportBuilder::with_icon`]: [`window_icon`] when [`sets_runtime_icon`]
+/// is true; otherwise [`egui::IconData::default`] so eframe does not substitute its own icon
+/// on the Dock after the window opens (#7253).
+pub fn viewport_icon(exe: Option<&std::path::Path>) -> egui::IconData {
+ if sets_runtime_icon(exe) { window_icon() } else { egui::IconData::default() }
+}
+
/// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`.
fn in_app_bundle(exe: &std::path::Path) -> bool {
let name = |p: Option<&std::path::Path>| p.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
@@ -80,6 +87,19 @@ mod tests {
assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos"));
}
+ /// #7253: omitting `with_icon` made eframe paint its default glyph on the Dock once the window loaded.
+ #[test]
+ fn viewport_icon_blocks_eframe_default_when_runtime_icon_is_skipped() {
+ use std::path::Path;
+ let bundled = Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"));
+ if sets_runtime_icon(bundled) {
+ let icon = viewport_icon(bundled);
+ assert!(icon.width >= 256 && icon.width == icon.height);
+ } else {
+ assert_eq!(viewport_icon(bundled), egui::IconData::default());
+ }
+ }
+
#[test]
fn window_icon_decodes() {
let icon = window_icon();
diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs
index 30ae610b70..d73984eb8a 100644
--- a/apps/photocraft/src/control_server.rs
+++ b/apps/photocraft/src/control_server.rs
@@ -25,7 +25,7 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver Receiver String {
+ match listener.local_addr() {
+ Ok(addr) => addr.to_string(),
+ Err(_) => format!("127.0.0.1:{requested_port}"),
+ }
+}
+
fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context) {
serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE);
}
@@ -131,6 +141,17 @@ mod tests {
use super::*;
use std::io::BufRead;
+ /// #6427: with port 0 the log names the port the OS bound, which a client can connect to.
+ #[test]
+ fn port_zero_logs_the_bound_port() {
+ let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
+ let bound = listener.local_addr().unwrap();
+ assert_ne!(bound.port(), 0);
+ let logged = listening_addr(&listener, 0);
+ assert_eq!(logged, format!("127.0.0.1:{}", bound.port()));
+ TcpStream::connect(&logged).unwrap();
+ }
+
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
/// (releasing its connection permit), and nothing it sent is dispatched.
#[test]
diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs
index 33be58cd4c..9b08d4d292 100644
--- a/apps/photocraft/src/gpu_startup.rs
+++ b/apps/photocraft/src/gpu_startup.rs
@@ -12,10 +12,10 @@
//! - macOS: Metal → CPU
//!
//! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance,
-//! where it can be reset). With `auto`, Intel adapters on Windows use DX12, and when DXGI lists an
-//! Intel adapter the instance leaves Vulkan out, so the first start doesn't load the Intel Vulkan
-//! driver either. `WGPU_BACKEND` still overrides everything, and `--safe-gpu` forces the CPU path
-//! for one launch.
+//! where it can be reset). With `auto`, Intel and Qualcomm (Windows on ARM) adapters on Windows
+//! use DX12, and when DXGI lists an Intel adapter the instance leaves Vulkan out, so the first
+//! start doesn't load the Intel Vulkan driver either. `WGPU_BACKEND` still overrides everything,
+//! and `--safe-gpu` forces the CPU path for one launch.
//!
//! "CPU" composites on the CPU and draws the window with a software adapter where the platform
//! has one (WARP on Windows, llvmpipe over GL elsewhere). On Linux a GL adapter that can't present
@@ -35,6 +35,10 @@ use serde_json::{Value, json};
pub const MARKER_FILE: &str = "gpu-starting.json";
/// PCI vendor id of Intel.
pub const INTEL: u32 = 0x8086;
+/// Qualcomm's vendor id as its Vulkan driver reports it.
+pub const QUALCOMM: u32 = 0x5143;
+/// Qualcomm's vendor id as DXGI (DX12) reports it: "QCOM".
+pub const QUALCOMM_DXGI: u32 = 0x4D4F_4351;
/// Largest marker read (a marker is a few hundred bytes).
const MARKER_MAX: u64 = 64 << 10;
@@ -244,6 +248,18 @@ pub fn dx12_compiler(env: Option<&str>, exe_dir: Option<&Path>) -> wgpu::Dx12Com
}
}
+/// The GPU makers whose Windows Vulkan driver `auto` avoids, so DX12 comes first: Intel's
+/// (`igvk64.dll` crashed while the device was created, #4) and Qualcomm's Adreno driver on
+/// Windows on ARM (it lost the device mid-session and the window went with it; DX12 on the same
+/// machine didn't, #7537). The name is for System Info.
+fn dx12_first_on_windows(vendor: u32) -> Option<&'static str> {
+ match vendor {
+ INTEL => Some("Intel"),
+ QUALCOMM | QUALCOMM_DXGI => Some("Qualcomm"),
+ _ => None,
+ }
+}
+
/// What adapter selection needs to know about an adapter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Candidate {
@@ -258,9 +274,10 @@ pub struct Candidate {
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
-/// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback
-/// for presenting the window (see [`backends`]). On Windows GL comes after every other adapter,
-/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788).
+/// Intel and Qualcomm on Windows ([`dx12_first_on_windows`]). For `cpu`, any GL adapter comes
+/// before Vulkan, which is only the fallback for presenting the window (see [`backends`]). On
+/// Windows GL comes after every other adapter, WARP included: GL there (VMware's SVGA3D, GL-only
+/// old GPUs) drew a black window (#5191, #1788).
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl));
let ty = match a.device_type {
@@ -276,7 +293,7 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
_ => ty,
};
let be = match a.backend {
- wgpu::Backend::Dx12 if os == Os::Windows && a.vendor == INTEL => 0,
+ wgpu::Backend::Dx12 if os == Os::Windows && dx12_first_on_windows(a.vendor).is_some() => 0,
wgpu::Backend::Vulkan | wgpu::Backend::Metal => 1,
wgpu::Backend::Dx12 => 2,
wgpu::Backend::Gl => 3,
@@ -329,15 +346,18 @@ pub fn wanted_adapter(env: Option<&str>, pref: &str) -> Option {
[env.unwrap_or(""), pref].iter().map(|s| s.trim()).find(|s| !s.is_empty()).map(|s| s.chars().take(256).collect())
}
-/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
-/// same vendor was available, or `vulkan_left_out`: [`windows_auto_backends`] left Vulkan out of
-/// the instance).
-pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> bool {
- let Some(c) = adapters.get(chosen) else { return false };
- os == Os::Windows
- && c.vendor == INTEL
- && c.backend == wgpu::Backend::Dx12
- && (vulkan_left_out || adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan))
+/// The maker's name when picking `chosen` applied the Windows DX12 default of
+/// [`dx12_first_on_windows`]: a Vulkan adapter of the same maker was available, or, for Intel,
+/// `vulkan_left_out` ([`windows_auto_backends`] left Vulkan out of the instance).
+pub fn dx12_default_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> Option<&'static str> {
+ let c = adapters.get(chosen)?;
+ if os != Os::Windows || c.backend != wgpu::Backend::Dx12 {
+ return None;
+ }
+ let maker = dx12_first_on_windows(c.vendor)?;
+ let applied =
+ (vulkan_left_out && c.vendor == INTEL) || adapters.iter().any(|a| a.backend == wgpu::Backend::Vulkan && dx12_first_on_windows(a.vendor) == Some(maker));
+ applied.then_some(maker)
}
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
@@ -349,8 +369,8 @@ fn skips_indirect_validation(os: Os, intel: bool) -> bool {
}
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
-/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
-/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
+/// selection (Intel and Qualcomm on Windows, software adapters for `cpu`) and the marker update
+/// once the adapter is chosen. `note` receives a remark for System Info (e.g. the DX12 default).
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted: Option, sentinel: SharedSentinel, note: Arc>>) {
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
if let Some(b) = backends(plan, os) {
@@ -396,9 +416,11 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted:
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into());
}
- if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os, vulkan_left_out) {
+ if backend == GpuBackend::Auto
+ && let Some(maker) = dx12_default_applied(&cands, i, os, vulkan_left_out)
+ {
*note.lock().unwrap_or_else(PoisonError::into_inner) =
- Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
+ Some(format!("{maker} graphics on Windows: using DirectX 12 (the {maker} Vulkan driver is known to crash)"));
}
adapters.get(i).cloned().ok_or_else(|| "no graphics adapter found".to_string())
}));
@@ -772,13 +794,13 @@ mod tests {
let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
let i = pick(&intel, Auto, Os::Windows).unwrap();
assert_eq!(intel[i].backend, B::Dx12);
- assert!(intel_dx12_applied(&intel, i, Os::Windows, false));
+ assert_eq!(dx12_default_applied(&intel, i, Os::Windows, false), Some("Intel"));
// Elsewhere (and for other vendors) Vulkan stays first.
assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan);
let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
let i = pick(&nv, Auto, Os::Windows).unwrap();
assert_eq!(nv[i].backend, B::Vulkan);
- assert!(!intel_dx12_applied(&nv, i, Os::Windows, false));
+ assert_eq!(dx12_default_applied(&nv, i, Os::Windows, false), None);
// Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan.
let hybrid = [intel[0], intel[1], nv[1]];
assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2));
@@ -789,6 +811,27 @@ mod tests {
assert_eq!(pick(&[], Auto, Os::Windows), None);
}
+ /// #7537: on Windows on ARM the Adreno Vulkan driver lost the device mid-session and took the
+ /// window with it; DX12 on the same machine didn't. DXGI and Vulkan report different vendor ids.
+ #[test]
+ fn qualcomm_on_windows_defaults_to_dx12() {
+ use wgpu::{Backend as B, DeviceType as T};
+ let adreno = [cand(QUALCOMM, T::IntegratedGpu, B::Vulkan), cand(QUALCOMM_DXGI, T::IntegratedGpu, B::Dx12)];
+ let i = pick(&adreno, Auto, Os::Windows).unwrap();
+ assert_eq!(adreno[i].backend, B::Dx12);
+ assert_eq!(dx12_default_applied(&adreno, i, Os::Windows, false), Some("Qualcomm"));
+ // Only Vulkan offered: still Vulkan, and no note.
+ assert_eq!(pick(&adreno[..1], Auto, Os::Windows), Some(0));
+ assert_eq!(dx12_default_applied(&adreno, 0, Os::Windows, false), None);
+ // An Intel Vulkan adapter doesn't make a Qualcomm DX12 pick "the default".
+ let mixed = [cand(INTEL, T::IntegratedGpu, B::Vulkan), adreno[1]];
+ assert_eq!(dx12_default_applied(&mixed, 1, Os::Windows, false), None);
+ // Vulkan left out of the instance (an Intel adapter was listed) speaks only for Intel.
+ assert_eq!(dx12_default_applied(&adreno[1..], 0, Os::Windows, true), None);
+ // Elsewhere Vulkan stays first.
+ assert_eq!(adreno[pick(&adreno, Auto, Os::Other).unwrap()].backend, B::Vulkan);
+ }
+
/// #4: an instance with Vulkan loads the Intel Vulkan driver, which crashed at startup, before
/// the selector picks Intel's DX12 adapter. With an Intel adapter in the DX12 list, `auto` on
/// Windows creates the instance without Vulkan.
@@ -824,8 +867,8 @@ mod tests {
let listed = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(WARP, T::Cpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
let i = pick(&listed, Auto, Os::Windows).unwrap();
assert_eq!(i, 0);
- assert!(intel_dx12_applied(&listed, i, Os::Windows, true));
- assert!(!intel_dx12_applied(&listed, i, Os::Windows, false));
+ assert_eq!(dx12_default_applied(&listed, i, Os::Windows, true), Some("Intel"));
+ assert_eq!(dx12_default_applied(&listed, i, Os::Windows, false), None);
}
#[test]
diff --git a/apps/photocraft/src/logging.rs b/apps/photocraft/src/logging.rs
index 5c8f0697dd..6a0cc8f052 100644
--- a/apps/photocraft/src/logging.rs
+++ b/apps/photocraft/src/logging.rs
@@ -91,27 +91,36 @@ fn matches(name: &str, target: &str) -> bool {
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
+/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
+/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
- let secs = since.as_secs();
- let (days, rem) = (secs / 86_400, secs % 86_400);
- let (y, m, d) = civil_from_days(days);
- format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
+ // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
+ // past `i64::MAX` seconds saturates rather than falling back to the epoch.
+ let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
+ let (y, t) = utc_parts(secs, since.subsec_nanos());
+ // `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
+ format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
-/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
-/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch).
-fn civil_from_days(days: u64) -> (u64, u64, u64) {
- let z = days + 719_468;
- let era = z / 146_097;
- let doe = z % 146_097;
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = doy - (153 * mp + 2) / 5 + 1;
- let m = if mp < 10 { mp + 3 } else { mp - 9 };
- let y = yoe + era * 400 + u64::from(m <= 2);
- (y, m, d)
+/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
+/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
+/// so month, day and time of day come from chrono and only the year is carried, which keeps every
+/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
+fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
+ use chrono::Datelike;
+ let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
+ let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
+ let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
+ match (date, time) {
+ (Some(d), Some(t)) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
+ }
+ _ => (1970, chrono::NaiveDateTime::default()), // unreachable
+ }
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -416,6 +425,20 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
+ // Past chrono's year range still converts via the 400-year cycle (the old code did too,
+ // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
+ // SystemTime can't hold this instant, so it is only checked where it exists.
+ // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
+ assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
+ }
+ // Years past 9999 keep the plain zero-padded year (no `+`).
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
+ // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
+ // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
+ assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
+ }
}
#[test]
diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs
index b4cf01d3df..1b58e89f03 100644
--- a/apps/photocraft/src/main.rs
+++ b/apps/photocraft/src/main.rs
@@ -102,9 +102,7 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
/// different offset every launch (#419). A later launch restores the saved position instead
/// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves.
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
- let viewport = egui::ViewportBuilder::default();
- // Not inside a macOS app bundle: its Dock icon is the bundle's .icns (`app_icon`, #5849).
- let viewport = if app_icon::sets_runtime_icon(std::env::current_exe().ok().as_deref()) { viewport.with_icon(app_icon::window_icon()) } else { viewport };
+ let viewport = egui::ViewportBuilder::default().with_icon(app_icon::viewport_icon(std::env::current_exe().ok().as_deref()));
eframe::NativeOptions {
viewport: viewport
.with_app_id(APP_ID)
@@ -526,6 +524,13 @@ fn main() -> eframe::Result {
#[cfg(not(target_os = "macos"))]
let single_instance = {
let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok();
+ // A variable documented as the equivalent of a per-launch flag counts as that flag: a
+ // launch that sets only `PHOTOCRAFT_PRESENT_MODE` (say) has per-process options too, so it
+ // keeps its own process instead of handing its files over and being ignored.
+ // Folded in here, inside the block that only non-macOS builds compile, so the binding
+ // above keeps its `mut` off macOS, where nothing is folded in.
+ let mut launch_options = launch_options;
+ launch_options.extend_from_slice(&single_instance::env_launch_options());
let context = single_instance::LaunchContext {
per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform,
new_instance_flag: new_instance,
@@ -786,6 +791,9 @@ fn main() -> eframe::Result {
if let Some(rs) = cc.wgpu_render_state.clone() {
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
app.perf.gpu_info.set_available(&rs.available_adapters);
+ // Every egui texture uploads through this adapter, whichever canvas draws:
+ // Iris Xe on DX12 shows stale memory in all-zero texels (texel_guard.rs).
+ photocraft_ui_egui::texel_guard::install(&cc.egui_ctx, &rs.adapter.get_info());
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
// Keep an earlier note, such as a requested adapter that couldn't be used.
@@ -803,8 +811,9 @@ fn main() -> eframe::Result {
{
app.set_wgpu(rs);
} else {
- // The window still draws with wgpu: record its errors instead of panicking.
- let _ = photocraft_ui_egui::gpu_canvas::DeviceHealth::watch(&rs.device);
+ // The window still draws with wgpu: record its errors instead of panicking,
+ // and notice when the device is lost (#3438).
+ app.watch_window_device(&rs.device);
}
}
let fallback_reason = std::env::var("PHOTOCRAFT_GPU_STARTUP_FAILURE").ok().or_else(|| {
diff --git a/apps/photocraft/src/portal_save.rs b/apps/photocraft/src/portal_save.rs
index 42588e2bc4..d16432193b 100644
--- a/apps/photocraft/src/portal_save.rs
+++ b/apps/photocraft/src/portal_save.rs
@@ -156,6 +156,18 @@ fn file_uri_path(uri: &str) -> Option {
Some(PathBuf::from(std::ffi::OsString::from_vec(out)))
}
+/// Which file type's extensions belong on the name the dialog returned: the one it reported, or,
+/// when the dialog offered only one type, that one. It reports no pick then, and the name it was
+/// given is the stem (`services::dialog_file_name`), so a bare `Swatches` has to come back as
+/// `Swatches.aco`. `None` when the answer names no type this dialog offered.
+fn chosen_type(filters: &[Filter], picked: Option) -> Option {
+ match picked {
+ Some(i) if i < filters.len() => Some(i),
+ None if filters.len() == 1 => Some(0),
+ _ => None,
+ }
+}
+
/// 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).
@@ -163,10 +175,10 @@ pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Resul
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,
- _ => &[],
+ // A typed extension is the user's own: `with_chosen_type` keeps one it recognises.
+ let exts: &[String] = match chosen_type(&req.filters, picked) {
+ Some(i) => &req.filters[i].1,
+ None => &[],
};
let path = crate::services::with_chosen_type(&saved.path, req.suggested.as_deref(), exts);
if path == saved.path || !path.exists() {
diff --git a/apps/photocraft/src/portal_save/tests.rs b/apps/photocraft/src/portal_save/tests.rs
index 5a839b6025..7a939639af 100644
--- a/apps/photocraft/src/portal_save/tests.rs
+++ b/apps/photocraft/src/portal_save/tests.rs
@@ -235,3 +235,33 @@ fn a_single_file_type_keeps_the_typed_extension() {
assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.ase")));
daemon.kill().ok();
}
+
+/// The same single type, with the name left as the dialog shows it: the bare stem comes back, and
+/// that one type gives it its extension instead of a file named `Swatches` being written.
+#[test]
+fn a_single_file_type_gives_the_bare_name_its_extension() {
+ let typed: &'static str = "file:///tmp/photocraft-none/Swatches";
+ 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.aco")));
+ daemon.kill().ok();
+}
+
+/// Which type's extensions the returned name gets. The portal cases above need a `dbus-daemon`,
+/// so the rule itself is checked here too, which needs none.
+#[test]
+fn the_type_that_gives_the_extension_is_the_one_picked() {
+ let one: Vec = vec![("Color Swatches".into(), vec!["aco".into()])];
+ // One type offered: the dialog reports no pick, and it was given the stem.
+ assert_eq!(chosen_type(&one, None), Some(0));
+ assert_eq!(chosen_type(&one, Some(0)), Some(0));
+ let many: Vec = request("test").filters;
+ assert_eq!(chosen_type(&many, Some(1)), Some(1));
+ // Several types and no pick: nothing to add, as before.
+ assert_eq!(chosen_type(&many, None), None);
+ // An answer naming a type this dialog never offered is not a pick either.
+ assert_eq!(chosen_type(&many, Some(99)), None);
+}
diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs
index 999952f186..cb7dae9fc5 100644
--- a/apps/photocraft/src/services.rs
+++ b/apps/photocraft/src/services.rs
@@ -1,10 +1,8 @@
//! Native platform services: file dialogs (rfd), filesystem, codecs.
-use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS};
-use photocraft_color::{ColorMode, SampleType};
-use photocraft_doc::{Document, Layer, Size};
+use photocraft_codecs::{ChannelLayout, EncodeOptions, Image};
+use photocraft_doc::Document;
use photocraft_format::RecoveryStore;
-use photocraft_geom::Rect;
use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services};
use std::cell::{Cell, RefCell};
use std::future::Future;
@@ -17,12 +15,13 @@ use std::sync::Arc;
mod recovery_tests;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
-/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
+/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .act, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
- "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
- "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw",
- "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
+ "pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
+ "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm",
+ "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase",
+ "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -45,6 +44,7 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[
("Photoshop", &["psd", "psb", "psdt"]),
("Affinity", &["af", "afdesign", "afphoto", "afpub"]),
("Krita", &["kra", "krz"]),
+ ("GIMP", &["xcf", "xcfgz", "gz"]),
("OpenRaster", &["ora"]),
("Paint.NET", &["pdn"]),
("SVG", SVG_EXTS),
@@ -66,10 +66,12 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[
("Radiance HDR", &["hdr"]),
("FITS", &["fits", "fit", "fts"]),
("DPX and Cineon", &["dpx", "cin"]),
+ ("Amiga IFF", &["iff", "lbm", "ilbm"]),
+ ("DICOM", &["dcm", "dicom", "dc3"]),
("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]),
("Brushes", &["abr"]),
("Gradients", &["grd"]),
- ("Swatches", &["aco", "ase"]),
+ ("Swatches", &["aco", "act", "ase"]),
("Keyboard Shortcuts", &["kys"]),
];
@@ -101,11 +103,12 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("DPX", &["dpx"]),
// Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327).
("DDS", &["dds"]),
+ ("Cineon", &["cin"]),
];
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
/// never swaps their extension for a document format's.
-const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Swatch Exchange", &["ase"])];
+const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Color Table", &["act"]), ("Swatch Exchange", &["ase"])];
/// Lists the save dialog's file types with the `suggested` type first (added if unlisted), so the dialog keeps that extension instead of .psd.
fn save_filters(suggested: &str) -> Vec<(String, Vec)> {
@@ -197,10 +200,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
};
if multiple {
let picked = dialog.pick_files();
- Box::pin(async move { picked.await.map(|files| FileDialogAnswer::Paths(files.iter().map(path_of).collect())) })
+ Box::pin(async move { picked.await.map(|files| answer(files.iter().map(|f| f.path()), false, FileDialogAnswer::Paths)) })
} else {
let picked = dialog.pick_file();
- Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
+ Box::pin(async move { picked.await.map(|file| answer([file.path()], false, FileDialogAnswer::Paths)) })
}
}
FileDialogRequest::Folder { initial_dir } => {
@@ -208,7 +211,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
dialog = dialog.set_directory(dir);
}
let picked = dialog.pick_folder();
- Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
+ Box::pin(async move { picked.await.map(|folder| answer_one(folder.path(), true, FileDialogAnswer::FolderTo)) })
}
FileDialogRequest::Save { suggested } => {
let filters = save_filters(&suggested);
@@ -243,10 +246,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
.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())),
+ Ok(path) => path.map(|p| answer_one(&p, true, FileDialogAnswer::SaveTo)),
Err(e) => {
log::info!("portal save dialog unavailable ({e}), using rfd");
- dialog.save_file().await.map(|file| FileDialogAnswer::SaveTo(path_of(&file)))
+ dialog.save_file().await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo))
}
}
})
@@ -255,7 +258,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
{
let _ = filters;
let picked = dialog.save_file();
- Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
+ Box::pin(async move { picked.await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo)) })
}
}
};
@@ -269,8 +272,22 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
}
}
-fn path_of(file: &rfd::FileHandle) -> String {
- file.path().to_string_lossy().into_owned()
+/// The dialog's answer for the chosen `paths`, or the error for one whose name isn't valid
+/// Unicode, which a lossy copy would turn into another file's name (#6601). `saving`: the paths
+/// are to be written to.
+fn answer<'a>(paths: impl IntoIterator, saving: bool, make: impl FnOnce(Vec) -> FileDialogAnswer) -> FileDialogAnswer {
+ match paths.into_iter().map(|p| photocraft_ui_egui::file_open::path_text(p, saving)).collect() {
+ Ok(paths) => make(paths),
+ Err(e) => FileDialogAnswer::Unavailable(e),
+ }
+}
+
+/// [`answer`] for a dialog that picks one path.
+fn answer_one(path: &Path, saving: bool, make: impl FnOnce(String) -> FileDialogAnswer) -> FileDialogAnswer {
+ match photocraft_ui_egui::file_open::path_text(path, saving) {
+ Ok(path) => make(path),
+ Err(e) => FileDialogAnswer::Unavailable(e),
+ }
}
/// Runs `future` to completion on this thread, sleeping until it is woken.
@@ -329,6 +346,44 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R
photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string())
}
+/// Whether `a` and `b` reach the same file: the same device and inode on Unix, the same final path
+/// on Windows (where it holds the name's letter case as stored). False when either can't be read.
+fn same_file(a: &Path, b: &Path) -> bool {
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::MetadataExt;
+ match (std::fs::metadata(a), std::fs::metadata(b)) {
+ (Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
+ _ => false,
+ }
+ }
+ #[cfg(not(unix))]
+ {
+ match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
+ (Ok(a), Ok(b)) => a == b,
+ _ => false,
+ }
+ }
+}
+
+/// Whether `a` and `b` name one entry in one folder: `same_file` finds they reach the same file
+/// in the same folder, and the folder doesn't list both names. Another spelling of a name on a
+/// file system that ignores letter case is the same entry; a hard link is another entry, which a
+/// save replaces while the first name keeps the old file. False when the folder can't be read.
+fn same_entry(a: &Path, b: &Path, same_file: impl Fn(&Path, &Path) -> bool) -> bool {
+ let folder = |p: &Path| p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")).to_path_buf();
+ if !same_file(a, b) || !same_file(&folder(a), &folder(b)) {
+ return false;
+ }
+ let (Some(x), Some(y)) = (a.file_name(), b.file_name()) else { return false };
+ if x == y {
+ return true;
+ }
+ let Ok(entries) = std::fs::read_dir(folder(a)) else { return false };
+ let listed: Vec<_> = entries.filter_map(|e| e.ok().map(|e| e.file_name())).collect();
+ !(listed.iter().any(|n| n == x) && listed.iter().any(|n| n == y))
+}
+
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
settings.io_options()
}
@@ -386,12 +441,13 @@ fn with_store(store: &SharedRecovery, f: impl FnOnce(&mut RecoveryStore) -> R
fn recovery_services(dir: Option) -> Services {
let recover_dir = dir.clone();
let store: SharedRecovery = Rc::new(RefCell::new(dir.map(RecoveryStore::new)));
- let (s1, s2, s4) = (store.clone(), store.clone(), store.clone());
+ let (s1, s2, s4, s5) = (store.clone(), store.clone(), store.clone(), store.clone());
Services {
autosave: Some(Box::new(move |doc: &Arc, revision: u64, path: Option<&str>| {
with_store(&s1, |s| s.autosave_checked(doc, revision, path.map(str::to_string)))?
})),
autosave_results: Some(Box::new(move || with_store(&s4, |s| s.take_completed()).unwrap_or_default())),
+ flush_autosaves: Some(Box::new(move || with_store(&s5, RecoveryStore::flush).unwrap_or_default())),
discard_autosave: Some(Box::new(move |id: u64| {
let _ = with_store(&s2, |s| s.discard(id));
})),
@@ -414,6 +470,17 @@ fn recovery_services(dir: Option) -> Services {
}
}
+/// The OS clipboard read and write run under the crash guard, like Open and Export (#7582): a
+/// panic in arboard, or in decoding a copied file, becomes the paste's (or copy's) error and the
+/// app keeps its documents.
+fn guarded_clipboard_get(mut read: impl FnMut() -> Result