diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000000..2585577ec1 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,15 @@ +# cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test +# binaries. Optional for local runs; `cargo test` keeps working and stays what CI runs (#2204). +# +# cargo nextest run --workspace # all tests except doctests +# cargo test --doc --workspace # nextest does not run doctests +# +# GPU tests: each test is its own process here, so the in-process `gpu_lock()` mutexes no longer +# serialize them. Too many concurrent wgpu devices time out (`PollError: ... Wait timed out`), so +# this group caps them at 4. +[test-groups] +gpu = { max-threads = 4 } + +[[profile.default.overrides]] +filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))' +test-group = 'gpu' diff --git a/AGENTS.md b/AGENTS.md index f193d206c1..e44dbe32a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,12 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written | `docs/contributing.md` | Rules: clean-room, tests, layering, style, commits; the "add a command" checklist | | `docs/control-protocol.md` | JSON control channel: how agents drive and screenshot the running app | | `docs/ui-design.md` | Design tokens, themes, widgets, and how to match Photoshop's look | -| `docs/roadmap.md` | Honest parity assessment (where we're lacking, where we're going), milestones, **current focus** | -| `docs/parity.md` | Generated list of every Photoshop menu item, live or missing | +| `ROADMAP.md` | One-page summary: stage, the two parity numbers, every dimension, languages, what's next | +| `docs/target-app-parity.md` | The authoritative parity assessment against Photoshop 2026: numbers, weights, evidence, hours | +| `docs/gaps.md` | Every known shortfall, ranked: the work list to pick from | +| `docs/roadmap.md` | Milestones, **current focus**, what's next with estimates | +| `docs/ui-parity.md`, `docs/brush-parity.md`, `docs/context-menu-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md` | Per-dimension parity checklists | +| `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` | @@ -70,15 +74,16 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file, Priorities: important infrastructure first, then low-hanging parity, then the long tail. -0. **Read `docs/roadmap.md` → "Honest parity assessment" first.** It says, dimension by dimension, - where PhotoCraft is lacking and the priority order of where we're going. `docs/parity.md` - (menu wiring) is not a measure of behaviour. When your work moves a measured number (PSD oracle, - round trips, workflow tests, performance), update that section with the dated figure. +0. **Read `ROADMAP.md`, then `docs/gaps.md`.** `docs/target-app-parity.md` says, dimension by + dimension, where PhotoCraft is lacking; `docs/gaps.md` ranks the work. `docs/parity-checklist.md` + (menu wiring) is not a measure of behaviour. When your work moves a number or closes a gap, + update the parity doc and gap entry it belongs to, with the date, and bump their status line + and revision history (craftrules `standards/progress-docs.md`). 1. **Check `docs/scorecard.md`** before picking work: each area's `missing` and `partial` rows, the performance scenarios that are over budget or not measurable yet, and the count of settings that do nothing. Its numbers are measured; prefer them to estimates. 2. `docs/roadmap.md` → **Current focus**. -3. `cargo xtask parity` → `docs/parity.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`. +3. `cargo xtask parity` → `docs/parity-checklist.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`. 4. `log/devlog.md` → the "Still open" bullets of recent entries. When parity rises, raise `FLOOR` in `crates/ui-egui/src/parity.rs` (never lower it). @@ -90,7 +95,7 @@ cargo test -p cargo clippy -p --all-targets -- -D warnings cargo xtask layers cargo xtask wasm # if you touched L0–L6 -cargo xtask parity # if you added commands; commit the regenerated docs/parity.md +cargo xtask parity # if you added commands; commit the regenerated docs/parity-checklist.md cargo test -p photocraft-engine --test panic_hunt -- --ignored # if you added/changed commands: no panic on adversarial input (Rule 9) cargo xtask scorecard # if you moved a number: flip the checklist row in scorecard/*.toml, raise a # corpus floor, fix a dead preference, or meet a budget (then set enforce = true @@ -99,6 +104,8 @@ cargo xtask perf --quick # if you touched a hot path; `cargo xtask perf --upd cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text or format (or: --changed decides) ``` +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. 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). @@ -117,7 +124,8 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still - `docs/roadmap.md`: milestones M0–M12, status and the current focus. -- `docs/parity.md`: generated Photoshop menu coverage. +- `ROADMAP.md`, `docs/target-app-parity.md`, `docs/gaps.md` and the `docs/*-parity.md` checklists: where we stand against Photoshop. +- `docs/parity-checklist.md`: generated Photoshop menu coverage. - `docs/scorecard.md`: generated scorecard (sources: `scorecard/*.toml`, `perf/budgets.toml`, `perf/baseline.json`, corpus floors, prefs audit). - `docs/releasing.md`: cutting a release (`cargo xtask version`, the `release` branch), signing secrets, packaging scripts in `packaging/`. - `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index dfc31372f2..ed1e59365a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -20,9 +20,10 @@ next to them. | `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian 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`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg` and `symmetry-*.svg` (110 files) | 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`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (110 files) | 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-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/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/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | @@ -36,6 +37,7 @@ next to them. | `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn in ArtCraft, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) | | `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code | | `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) | +| `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, (its colour schemes are LGPL-2.0-or-later) | Original work | ### Optional build input: craft-fonts (not files in this repo) diff --git a/Cargo.lock b/Cargo.lock index f4de5bc855..780c4ebdb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4348,6 +4348,7 @@ name = "photocraft-engine" version = "0.6.0" dependencies = [ "blake3", + "exr", "flate2", "photocraft-affinity", "photocraft-algo", @@ -4513,11 +4514,13 @@ name = "photocraft-raw" version = "0.6.0" dependencies = [ "flate2", + "image", "jxl-oxide", "photocraft-raw", "png 0.18.1", "rayon", "zune-core 0.5.3", + "zune-jpeg 0.5.15", "zune-jpegxl", ] diff --git a/Cargo.toml b/Cargo.toml index 5317eb5a08..5d394d3e81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,9 @@ arc-swap = "1" bytemuck = { version = "1", features = ["derive"] } flate2 = { version = "1", default-features = false, features = ["rust_backend"] } image = { version = "0.25", default-features = false } +# Baseline-JPEG decode of lossy DNG tiles (compression 34892), shared with zune-jpeg. +zune-core = "0.5" +zune-jpeg = "0.5" proptest = "1" eframe = { version = "0.36", default-features = false } egui = "0.36" @@ -67,6 +70,10 @@ opt-level = 2 [profile.dev] opt-level = 1 +# Line tables keep file:line in panic backtraces but drop variable info, which roughly halves link +# time and PDB size for the ~260 test binaries (#2204). CI sets the same through +# CARGO_PROFILE_DEV_DEBUG. To inspect locals in a debugger, build with CARGO_PROFILE_DEV_DEBUG=true. +debug = "line-tables-only" [profile.release] lto = "thin" diff --git a/README.md b/README.md index fd5dd0a495..fd16ea669f 100644 --- a/README.md +++ b/README.md @@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
-

🧰 53 tools

- Move · Rectangular and Elliptical Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom +

🧰 55 tools

+ Move · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom

🖌️ A real brush engine

@@ -298,7 +298,7 @@ photocraft Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published. > [!IMPORTANT] -> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, about twenty missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity.md`](docs/parity.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap's parity assessment](docs/roadmap.md#honest-parity-assessment-2026-10-05). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft). +> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, 15 missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity-checklist.md`](docs/parity-checklist.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap](ROADMAP.md) and [parity assessment](docs/target-app-parity.md). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft). ## Documentation diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000000..709d77b878 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,141 @@ +# PhotoCraft roadmap + +**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-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: full re-measure against Photoshop 2026 27.11.0) · **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) +(methodology, weights, evidence), [gaps](docs/gaps.md) (the ranked work list), +[roadmap](docs/roadmap.md) (milestones, current focus), [scorecard](docs/scorecard.md) and +[menu checklist](docs/parity-checklist.md) (both generated and measured), +[architecture](docs/architecture.md). + +Hours are Opus 5.5 agent wall-clock hours for one agent working sequentially, calibrated from this +repository's 1,004 merged PRs (see [calibration](docs/target-app-parity.md#remaining-effort-and-calibration)). +About 80% of the work parallelizes. + +## Headline numbers + +| | Value | Kind | +|---|---|---| +| **Feature breadth** | **~76%** | partly measured: menus 628/628, tools 53/68, panels 30/35, formats 13+4 of 31 | +| **Ready for real work** | **~45%** (40–50%) | estimated: weighted by dimension below | +| Mainstream practitioner | ~43% (38–48%) | estimated: weekly areas of a typical pro, with discounts ([method](docs/target-app-parity.md#mainstream-practitioner-43)) | +| Essentials user | ~61% (55–67%) | estimated: core features only ([method](docs/target-app-parity.md#essentials-user-61)) | +| Remaining to **beta** | **~1,100–1,800 h** | estimated | +| Remaining to **full parity** | **~2,300–4,100 h** | estimated; AI and plug-ins need owner decisions | + +### Readiness by audience + +| Audience | Ready % | Opus 5.5 agent-hours to ~95% | Work that dominates | +|---|---:|---:|---| +| Full target (ready for real work) | ~45% (additive weighted sum: 46%) | 2,300–4,100 | Everything below, plus AI tier, plug-in/scripting ecosystem, video, localization, hardware | +| Mainstream practitioner | ~43% | 1,700–2,800 | Depth in the 11 weekly areas (1,180–2,000), performance budgets, stability on real machines, PSD exchange with Photoshop | +| Essentials user | ~61% | 400–700 | Startup and GPU robustness, brush and transform responsiveness, Type tool, discoverability (flyouts, commit buttons), AVIF/PDF open | + +Hours calibrated as in [Remaining effort](docs/target-app-parity.md#remaining-effort-and-calibration) (2–9 h per checklist +item, 8–20 h per format or subsystem, from the repo's 1,004 merged PRs); each tier is a subset of +the one above. About 80% parallelizes (independent crates and command modules); performance +architecture and the PSD writer are the serial parts. + +**Why alpha.** Core workflows (open, layer, select, paint, adjust, filter, type, save) work end to +end, and breadth is high, but users of 0.5/0.6 have 579 open issues, 22 of 25 performance budgets +are missed, and PSDs we write are not yet reliably accepted by Photoshop (#1281, #2469). Beta +needs ~75% ready and no blocking PSD gap. The [alpha gate](docs/roadmap.md#alpha-gate) passes: all six core workflows +(retouch, composite, cut-out, design, paint, PSD exchange) complete end to end and save and reopen; +four are partial in depth, none blocked. + +## By dimension + +| Dimension | % ready | Hours to full | Doc | +|---|---:|---:|---| +| Features (depth) | 55% (breadth 76%) | 600–1,000 | [target-app-parity](docs/target-app-parity.md#feature-areas) | +| UI/UX fidelity (tools, handles, modifiers, brush feel, panels) | 40% | 350–600 | [ui-parity](docs/ui-parity.md), [brush-parity](docs/brush-parity.md), [context-menu-parity](docs/context-menu-parity.md) | +| File formats | 60% (presence ~80%) | 300–500 | [file-format-parity](docs/file-format-parity.md) | +| Hardware (GPU, pen, displays) | 50% | 80–150 | [hardware-parity](docs/hardware-parity.md) | +| Localization | 55% | 80–140 | [localization-parity](docs/localization-parity.md) | +| Performance | 25% (3/25 budgets met, measured) | 200–350 | [scorecard](docs/scorecard.md#performance) | +| Stability | 45% | 150–250 | [gaps](docs/gaps.md#g3-user-reported-bug-backlog) | +| Platforms | 80% | 30–60 | [scorecard](docs/scorecard.md#distribution) | +| Ecosystem / plug-ins / scripting | 15% | 250–450 | [gaps](docs/gaps.md#g16-plug-in-compatibility) | +| AI features | 10% | 300–600 | [gaps](docs/gaps.md#g15-ai-and-generative-features) | + +## Features + +| Area | % ready | Hours | +|---|---:|---:| +| Layers, masks, blend modes, smart objects, styles | 65% | 150–250 | +| Selections, Select and Mask, channels | 55% | 120–200 | +| Painting and brush engine | 55% | 150–250 | +| Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 | +| Transform, warp, puppet, liquify | 55% | 100–180 | +| Adjustments and filters | 70% | 120–200 | +| Type | 40% | 120–200 | +| Vector shapes and paths | 55% | 80–140 | +| Colour management and modes | 70% | 60–100 | +| Camera Raw and computational photo | 45% | 120–200 | +| Automation, actions, scripting | 45% | 120–200 | +| File I/O | 60% | 300–500 | +| Print, export, Save for Web | 50% | 60–100 | +| Video / timeline, data-driven graphics | 30% | 100–200 | +| Workspace, panels, preferences | 40% | 150–250 | +| AI / generative | 10% | 300–600 | + +Rows and evidence: [target-app-parity › Feature areas](docs/target-app-parity.md#feature-areas). + +## Languages + +Measured with the `cargo xtask i18n-coverage` key universe (1,608 keys). `partial` everywhere +because engine messages are still English. Detail: [localization-parity](docs/localization-parity.md). + +| Language | Code | UI strings | Status | +|---|---|---:|---| +| English | `en` | 100% | full | +| Simplified Chinese | `zh-hans` | 100% | partial | +| Spanish | `es` | 100% | partial | +| Hindi | `hi` | 0% | none | +| Arabic | `ar` | 0% (no RTL UI) | none | +| French | `fr` | 100% | partial | +| Portuguese (Brazil) | `pt-br` | 100% | partial | +| Indonesian | `id` | 100% | partial | +| Japanese | `ja` | 100% | partial | +| German | `de` | 100% | partial | +| Korean | `ko` | 100% | partial | +| Vietnamese | `vi` | 0% | none | + +Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian (8). + +## Upcoming + +| Rank | Milestone | Estimate | +|---|---|---:| +| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 133 → 200+ of 256 | 150–250 h | +| 2 | Performance budgets: the 22 over-budget scenarios and the 150-layer GPU crash | 200–350 h | +| 3 | User bug backlog to under 100 open | 250–400 h | +| 4 | Brush and transform feel (brush-parity, ui-parity rows) | 160–290 h | +| 5 | The 15 missing tools | 60–100 h | + +Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). + +## Progress log + +| Date | Entry | +|---|---| +| 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 | +| 2026-10-07 | 0.3.0. 10 UI languages; shortcut audit 214 → 0 failures; TIFF decode 10× faster | +| 2026-10-05 | 0.2.0, first signed and notarized release. Honest assessment: ready for real work 25–35% | +| 2026-10-03 | Menu breadth 625/625 | +| 2026-10-01 | Menu breadth 224 → 532 of 625 in a day | +| 2026-09-30 | First commit | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Readiness by audience with hours per tier | +| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added | +| 2026-10-10 | minor | Alpha gate result (passes; stays alpha) in the stage explanation | +| 2026-10-10 | major | First version, per craftrules `standards/progress-docs.md` | diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 21b70492a7..21c55a8d8b 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -353,17 +353,30 @@ fn services(inbox: Inbox) -> Services { photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) })), file_dialog: Some(Box::new(|request, _parent, reply| match request { - // The browser's file picker hands over the file's contents, not a path. - FileDialogRequest::Open { extensions, .. } => wasm_bindgen_futures::spawn_local(async move { + // The browser's file picker hands over the files' contents, not paths. File › Open + // asks for several files (`multiple`), each opened in its own tab. + FileDialogRequest::Open { multiple, extensions, .. } => wasm_bindgen_futures::spawn_local(async move { let dialog = if let Some(exts) = extensions { rfd::AsyncFileDialog::new().add_filter("Supported Files", &exts) } else { rfd::AsyncFileDialog::new().add_filter("All Formats", OPEN_EXTS).add_filter("SVG", SVG_EXTS) }; - let picked = dialog.pick_file().await; - let answer = match picked { - Some(file) => Some(FileDialogAnswer::Contents(file.file_name(), file.read().await)), - None => None, + let answer = if multiple { + match dialog.pick_files().await { + Some(picked) if !picked.is_empty() => { + let mut files = Vec::with_capacity(picked.len()); + for file in picked { + files.push((file.file_name(), file.read().await)); + } + Some(FileDialogAnswer::ContentsMany(files)) + } + _ => None, + } + } else { + match dialog.pick_file().await { + Some(file) => Some(FileDialogAnswer::Contents(file.file_name(), file.read().await)), + None => None, + } }; reply.send(answer); }), diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index d268314912..c031c4495e 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -74,8 +74,9 @@ async-io = "=2.6.0" # re-export winit's `EventLoopBuilderExtX11`. Already in Cargo.lock (eframe); pinned exactly. winit = { version = "=0.30.13", default-features = false, features = ["x11"] } -# Drop position on Windows (`cursor.rs`): safe Win32 wrappers (GetCursorPos), no dependencies of -# its own. Pinned exactly: review the diff before bumping. +# Safe Win32 wrappers on Windows: the pointer position for file drops (`cursor.rs`, +# GetCursorPos) and the window frame for the custom title bar (`window_frame.rs`, window styles), +# no dependencies of its own. Pinned exactly: review the diff before bumping. [target.'cfg(target_os = "windows")'.dependencies] # Safe Windows screen-capture bindings; Linux uses the pure-Rust X11 client/portal. xcap = "=0.9.8" diff --git a/apps/photocraft/src/appearance.rs b/apps/photocraft/src/appearance.rs index 7e6e70ba16..8389ee83eb 100644 --- a/apps/photocraft/src/appearance.rs +++ b/apps/photocraft/src/appearance.rs @@ -69,52 +69,37 @@ fn code_of(theme: Option) -> u8 { } } +#[cfg(target_os = "linux")] +fn portal_startup(subscribe: impl FnOnce(), read: impl FnOnce() -> T) -> T { + subscribe(); + read() +} + /// Reads the colour scheme once, then follows the portal's `SettingChanged` signal. There is no /// polling: the worker sleeps in the signal iterator and wakes the UI only when the value changes. pub fn service() -> Option { #[cfg(target_os = "linux")] { - use std::sync::{ - Arc, OnceLock, - atomic::{AtomicU8, Ordering}, - }; - let value = Arc::new(AtomicU8::new(0)); - let wake: Arc> = Arc::new(OnceLock::new()); - let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake)); - let (ready, wait) = std::sync::mpsc::channel(); - if std::thread::Builder::new() - .name("appearance-portal".into()) - .spawn(move || { - let proxy = portal_proxy(); - let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme); - worker_value.store(code_of(initial), Ordering::Relaxed); - let _ = ready.send(()); - let Some(proxy) = proxy else { return }; - let Ok(signals) = proxy.receive_signal("SettingChanged") else { return }; - for message in signals { - let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { - continue; - }; - if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY { - continue; - } - let code = code_of(portal_code(changed).and_then(decode)); - if worker_value.swap(code, Ordering::Relaxed) != code - && let Some(ctx) = worker_wake.get() - { - ctx.request_repaint(); - } + start(|publisher| { + let proxy = portal_proxy(); + // Subscribe before the first read, so a change during start-up isn't lost. + let mut signals = None; + let initial = portal_startup( + || signals = proxy.as_ref().and_then(|proxy| proxy.receive_signal("SettingChanged").ok()), + || proxy.as_ref().and_then(portal_theme).or_else(gtk_theme), + ); + publisher.publish(initial); + let Some(signals) = signals else { return }; + for message in signals { + let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { + continue; + }; + if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY { + continue; } - }) - .is_err() - { - return None; - } - let _ = wait.recv_timeout(std::time::Duration::from_millis(250)); - Some(Box::new(move |ctx| { - let _ = wake.set(ctx.clone()); - decode(u32::from(value.load(Ordering::Relaxed))) - })) + publisher.publish(portal_code(changed).and_then(decode)); + } + }) } #[cfg(not(target_os = "linux"))] { @@ -122,8 +107,72 @@ pub fn service() -> Option { } } +/// The worker's handle on the shared value. Every change wakes the registered UI, including the +/// first read when it lands after the start-up wait below. +#[cfg(target_os = "linux")] +struct Publisher { + value: std::sync::Arc, + wake: std::sync::Arc>, + ready: std::sync::mpsc::Sender<()>, +} + +#[cfg(target_os = "linux")] +impl Publisher { + fn publish(&self, theme: Option) { + let code = code_of(theme); + if self.value.swap(code, std::sync::atomic::Ordering::Relaxed) != code + && let Some(ctx) = self.wake.get() + { + ctx.request_repaint(); + } + let _ = self.ready.send(()); + } +} + +/// Runs `worker` on its own thread and waits up to 250 ms for its first value, so a fast desktop +/// answers before the first frame. +#[cfg(target_os = "linux")] +fn start(worker: impl FnOnce(&Publisher) + Send + 'static) -> Option { + use std::sync::{Arc, OnceLock, atomic::AtomicU8}; + let value = Arc::new(AtomicU8::new(0)); + let wake: Arc> = Arc::new(OnceLock::new()); + let (ready, wait) = std::sync::mpsc::channel(); + let publisher = Publisher { value: Arc::clone(&value), wake: Arc::clone(&wake), ready }; + std::thread::Builder::new().name("appearance-portal".into()).spawn(move || worker(&publisher)).ok()?; + let _ = wait.recv_timeout(std::time::Duration::from_millis(250)); + Some(Box::new(move |ctx| { + let _ = wake.set(ctx.clone()); + decode(u32::from(value.load(std::sync::atomic::Ordering::Relaxed))) + })) +} + #[cfg(test)] mod tests { + #[cfg(target_os = "linux")] + #[test] + fn startup_keeps_changes_emitted_during_initial_read() { + use std::cell::Cell; + + for (initial_value, changed_value) in [(2, 1), (1, 2), (2, 2)] { + let subscribed = Cell::new(false); + let mut portal_value = initial_value; + let mut pending_signal = None; + let initial = super::portal_startup( + || subscribed.set(true), + || { + let initial = portal_value; + portal_value = changed_value; + if subscribed.get() && portal_value != initial { + pending_signal = Some(portal_value); + } + initial + }, + ); + let cached = pending_signal.unwrap_or(initial); + assert_eq!(cached, changed_value, "portal changed during startup but cached {cached}"); + } + } + #[cfg(target_os = "linux")] #[test] fn portal_scheme_values() { @@ -137,4 +186,33 @@ mod tests { assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark)); assert_eq!(super::parse_gtk_scheme("'default'\n"), None); } + + /// #2798: a first read that misses the start-up wait still wakes the idle UI. + #[cfg(target_os = "linux")] + #[test] + fn late_initial_read_requests_repaint() { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + let (release, gate) = std::sync::mpsc::channel::<()>(); + let (done, finished) = std::sync::mpsc::channel::<()>(); + let reader = super::start(move |publisher| { + let _ = gate.recv(); + publisher.publish(Some(egui::Theme::Light)); + let _ = done.send(()); + }) + .expect("worker thread"); + let ctx = egui::Context::default(); + let repaints = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&repaints); + ctx.set_request_repaint_callback(move |_| { + counter.fetch_add(1, Ordering::Relaxed); + }); + assert_eq!(reader(&ctx), None, "start-up wait elapsed without an answer"); + release.send(()).unwrap(); + finished.recv().unwrap(); + assert_eq!(repaints.load(Ordering::Relaxed), 1); + assert_eq!(reader(&ctx), Some(egui::Theme::Light)); + } } diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs index 33923d9fac..932e31ee60 100644 --- a/apps/photocraft/src/control_server.rs +++ b/apps/photocraft/src/control_server.rs @@ -5,13 +5,14 @@ use std::io::{BufReader, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender, channel}; -use std::time::{Duration, Instant}; +use std::time::Instant; use photocraft_automation::budgets::write_reply; use photocraft_automation::security::{ ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, }; use photocraft_ui_egui::ControlRequest; +use photocraft_ui_egui::control::{REPLY_DEADLINE, timeout_error}; use serde_json::{Value, json}; pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver { @@ -83,14 +84,15 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui:: let id = msg.get("id").cloned().unwrap_or(Value::Null); let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); let params = msg.get("params").cloned().unwrap_or(json!({})); - let deadline = Instant::now() + Duration::from_secs(60); + let deadline = Instant::now() + REPLY_DEADLINE; let (mut req, rrx) = ControlRequest::new(method, params); req.deadline = Some(deadline); if tx.send(req).is_err() { break; } ctx.request_repaint(); - let mut r = rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})); + let mut r = + rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": timeout_error()})); if let Some(o) = r.as_object_mut() { o.insert("id".into(), id); } @@ -111,6 +113,7 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui:: mod tests { use super::*; use std::io::BufRead; + use std::time::Duration; #[test] fn oversized_reply_preserves_framing_id_and_the_next_control_request() { diff --git a/apps/photocraft/src/linux_libs.rs b/apps/photocraft/src/linux_libs.rs index a5b674dc8c..b2b64fdcd1 100644 --- a/apps/photocraft/src/linux_libs.rs +++ b/apps/photocraft/src/linux_libs.rs @@ -9,8 +9,8 @@ //! //! No `dlopen` here (that needs `unsafe`): we read the dynamic linker's cache (`ldconfig -p`) and //! look in `LD_LIBRARY_PATH` and the usual library directories. Only the cache is conclusive: if -//! it can't be read (NixOS, some containers) and a library isn't found in the directories, we -//! warn and start anyway. `PHOTOCRAFT_SKIP_LIB_CHECK=1` skips the check. +//! it can't be read (musl, NixOS, some containers) and a library isn't found in the directories, +//! that proves nothing, so we start silently. `PHOTOCRAFT_SKIP_LIB_CHECK=1` skips the check. //! //! The parsing and decision logic is pure and tested on every platform; only [`preflight`] //! and [`available`] touch the system, and they are only called on Linux. @@ -200,7 +200,7 @@ fn install_command(packages: &[&str], distro: Distro) -> String { } /// The message for missing libraries: what's missing, and the command that installs it. -pub fn missing_message(session: DisplaySession, missing: &[AnyOf], distro: Distro, conclusive: bool) -> String { +pub fn missing_message(session: DisplaySession, missing: &[AnyOf], distro: Distro) -> String { let session_name = match session { DisplaySession::Wayland => "Wayland", DisplaySession::X11 => "X11", @@ -214,19 +214,13 @@ pub fn missing_message(session: DisplaySession, missing: &[AnyOf], distro: Distr packages.push(p); } } - let mut msg = if conclusive { - format!("photocraft: cannot start: the {session_name} session needs libraries that are not installed: {}.\n", names.join(", ")) - } else { - format!("photocraft: warning: could not find these libraries the {session_name} session needs: {}. Starting anyway.\n", names.join(", ")) - }; + let mut msg = format!("photocraft: cannot start: the {session_name} session needs libraries that are not installed: {}.\n", names.join(", ")); msg.push_str(&format!("Install them with:\n {}\n", install_command(&packages, distro))); if distro == Distro::Unknown { let fedora: Vec<&str> = missing.iter().filter_map(|any| any.first()).map(|l| l.fedora).collect(); msg.push_str(&format!(" Fedora: sudo dnf install {}\n", fedora.join(" "))); } - if conclusive { - msg.push_str("(Set PHOTOCRAFT_SKIP_LIB_CHECK=1 to start anyway.)\n"); - } + msg.push_str("(Set PHOTOCRAFT_SKIP_LIB_CHECK=1 to start anyway.)\n"); msg } @@ -269,8 +263,7 @@ pub fn available(session: DisplaySession) -> bool { } /// Run the check for the display server the window opens on against this system. `Err` carries -/// the message to print before exiting with a non-zero status; a warning is printed here and the -/// app starts. +/// the message to print before exiting with a non-zero status. pub fn preflight(session: DisplaySession) -> Result<(), String> { if std::env::var_os("PHOTOCRAFT_SKIP_LIB_CHECK").is_some_and(|v| !v.is_empty() && v != "0") { return Ok(()); @@ -278,18 +271,17 @@ pub fn preflight(session: DisplaySession) -> Result<(), String> { if session == DisplaySession::None { return Ok(()); } - match check(session, &system_probe(), |p| p.exists()) { - Verdict::Ok => Ok(()), - Verdict::Missing { missing, conclusive } => { - let distro = std::fs::read_to_string("/etc/os-release").map(|t| distro_from_os_release(&t)).unwrap_or(Distro::Unknown); - let msg = missing_message(session, &missing, distro, conclusive); - if conclusive { - Err(msg) - } else { - eprint!("{msg}"); - Ok(()) - } - } + let verdict = check(session, &system_probe(), |p| p.exists()); + decide(session, verdict, || std::fs::read_to_string("/etc/os-release").map(|t| distro_from_os_release(&t)).unwrap_or(Distro::Unknown)) +} + +/// `Err` with the message to print only when the linker cache was read and the libraries are +/// really absent. Without the cache (musl has no `ldconfig -p`; NixOS, AppImages that ship their +/// own libraries) not finding a file proves nothing, so we start without a misleading warning. +fn decide(session: DisplaySession, verdict: Verdict, distro: impl FnOnce() -> Distro) -> Result<(), String> { + match verdict { + Verdict::Missing { missing, conclusive: true } => Err(missing_message(session, &missing, distro())), + Verdict::Missing { conclusive: false, .. } | Verdict::Ok => Ok(()), } } @@ -362,7 +354,7 @@ mod tests { assert!(conclusive); assert_eq!(missing.len(), 1); assert_eq!(missing[0][0].soname, "libxkbcommon-x11.so.0"); - let msg = missing_message(DisplaySession::X11, &missing, Distro::Debian, conclusive); + let msg = missing_message(DisplaySession::X11, &missing, Distro::Debian); assert!(msg.contains("libxkbcommon-x11.so.0"), "{msg}"); assert!(msg.contains("sudo apt install libxkbcommon-x11-0"), "{msg}"); assert!(msg.contains("PHOTOCRAFT_SKIP_LIB_CHECK"), "{msg}"); @@ -384,13 +376,13 @@ mod tests { let probe = Probe { ldconfig: Some(cache_for_this_arch(&["libwayland-client.so.0", "libxkbcommon.so.0"])), dirs: vec![] }; let Verdict::Missing { missing, .. } = check(DisplaySession::Wayland, &probe, |_| false) else { panic!("expected missing") }; assert_eq!(missing.len(), 1); - let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Fedora, true); + let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Fedora); assert!(msg.contains("libvulkan.so.1 or libEGL.so.1"), "{msg}"); assert!(msg.contains("sudo dnf install vulkan-loader"), "{msg}"); } #[test] - fn library_dirs_count_and_unread_cache_only_warns() { + fn library_dirs_count_and_unread_cache_is_inconclusive() { let dirs = search_dirs(Some("/opt/x/lib::/nix/store/abc/lib")); assert_eq!(dirs[0], PathBuf::from("/opt/x/lib")); assert_eq!(dirs[1], PathBuf::from("/nix/store/abc/lib")); @@ -398,16 +390,29 @@ mod tests { let probe = Probe { ldconfig: None, dirs: vec![PathBuf::from("/nix/lib")] }; let present = ["/nix/lib/libwayland-client.so.0", "/nix/lib/libxkbcommon.so.0", "/nix/lib/libEGL.so.1"]; assert_eq!(check(DisplaySession::Wayland, &probe, |p| present.iter().any(|q| Path::new(q) == p)), Verdict::Ok); - // Not found and no cache to confirm it: inconclusive, so a warning rather than an exit. + // Not found and no cache to confirm it: inconclusive (the app starts, see #1144). let v = check(DisplaySession::Wayland, &probe, |_| false); let Verdict::Missing { missing, conclusive } = v else { panic!("expected missing") }; assert!(!conclusive); - let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Unknown, conclusive); - assert!(msg.contains("Starting anyway"), "{msg}"); + let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Unknown); assert!(msg.contains("apt install libwayland-client0 libxkbcommon0 libvulkan1"), "{msg}"); assert!(msg.contains("dnf install libwayland-client libxkbcommon vulkan-loader"), "{msg}"); } + #[test] + fn unreadable_cache_never_warns_or_fails() { + // Issue #1144: Alpine (musl) has no `ldconfig -p`, so a library that exists was reported missing. + let probe = Probe { ldconfig: None, dirs: vec![] }; + let verdict = check(DisplaySession::X11, &probe, |_| false); + assert_eq!(decide(DisplaySession::X11, verdict, || Distro::Debian), Ok(())); + // A readable cache that lacks a library is still an error. + let names: Vec<&str> = ALL_X11.iter().copied().filter(|n| *n != "libxkbcommon-x11.so.0").collect(); + let probe = Probe { ldconfig: Some(cache_for_this_arch(&names)), dirs: vec![] }; + let verdict = check(DisplaySession::X11, &probe, |_| false); + let Err(msg) = decide(DisplaySession::X11, verdict, || Distro::Debian) else { panic!("expected an error") }; + assert!(msg.contains("libxkbcommon-x11.so.0"), "{msg}"); + } + #[test] fn distro_detection() { assert_eq!(distro_from_os_release("NAME=\"Ubuntu\"\nID=ubuntu\nID_LIKE=debian\n"), Distro::Debian); diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 0755d71434..709c2b9bb9 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -46,6 +46,7 @@ mod services; #[cfg(any(target_os = "macos", target_os = "linux", test))] mod tablet; mod ui_state; +mod window_frame; use photocraft_engine::Session; use photocraft_ui_egui::PhotocraftApp; @@ -447,6 +448,9 @@ fn main() -> eframe::Result { let _ = in_window_menus; // Where file drags and drops are (winit 0.30 doesn't say). app.services.cursor_pos = cursor::service(cc); + // The custom title bar's undecorated window must stay borderless, or its caption and + // border offset the content and the pointer on some Windows systems (#2246). + app.services.window_frame = window_frame::service(cc, custom_titlebar); // Caps Lock state (X11/Windows/macOS; `None` on native Wayland): the canvas shows the // precise crosshair for painting tools while it is toggled on (#1758). app.services.caps_lock = caps_lock::service(cc); diff --git a/apps/photocraft/src/window_frame.rs b/apps/photocraft/src/window_frame.rs new file mode 100644 index 0000000000..cb6ea91d27 --- /dev/null +++ b/apps/photocraft/src/window_frame.rs @@ -0,0 +1,107 @@ +//! Keeps the custom (undecorated) title bar window truly borderless (#2246). +//! +//! winit 0.30 gives an undecorated window the `WS_CAPTION | WS_BORDER` styles and hides the +//! non-client area by spreading the client rect over it (`WM_NCCALCSIZE`). On some systems +//! (reported: Windows 10 LTSB, Intel HD Graphics 4600, 1366 × 768) the desktop compositor still +//! reserves that frame for the window surface: the content is drawn offset by the caption and +//! border, leaving black bars at the window's top-left, while the pointer keeps the un-offset +//! client coordinates, so it no longer points at what is drawn under it. +//! +//! winit 0.27 — before rust-windowing/winit#2419 — simply omitted those styles for undecorated +//! windows, and there was no offset. [`service`] restores that: it strips `WS_CAPTION` and +//! `WS_BORDER` and forces Windows to recalculate the frame. winit puts them back whenever it +//! changes the window flags (maximize, restore, …), so the returned guard checks every frame. +//! +//! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and +//! winsafe can't wrap a raw handle without it, so the window is found by its handle instead. + +pub use platform::service; + +#[cfg(target_os = "windows")] +mod platform { + use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle}; + use photocraft_ui_egui::WindowFrameFn; + use winsafe::{HWND, HwndPlace, POINT, SIZE, co}; + + /// A guard that keeps the window borderless each frame, or `None` when the app uses the + /// system title bar (the window is decorated) or its handle can't be found. + pub fn service(cc: &eframe::CreationContext<'_>, custom_titlebar: bool) -> Option { + if !custom_titlebar { + return None; + } + let target = match cc.window_handle().ok()?.as_raw() { + RawWindowHandle::Win32(h) => h.hwnd.get(), + _ => return None, + }; + let hwnd = find_window(target)?; + strip(&hwnd); + Some(Box::new(move || strip(&hwnd))) + } + + /// The top-level window with the given raw handle. `EnumWindows` hands out real `HWND`s, so + /// this matches winit's own window without wrapping a raw handle (which needs `unsafe`). + fn find_window(target: isize) -> Option { + let mut found = None; + let _ = winsafe::EnumWindows(|hwnd| { + if hwnd.ptr() as isize == target { + found = Some(hwnd); + false + } else { + true + } + }); + found + } + + /// Removes `WS_CAPTION`/`WS_BORDER` and has Windows recalculate the frame. A no-op once they + /// are gone, so it is cheap to call every frame. + fn strip(hwnd: &HWND) { + let style = hwnd.style(); + let without = borderless(style); + if without.raw() == style.raw() { + return; + } + hwnd.set_style(without); + let _ = hwnd.SetWindowPos( + HwndPlace::None, + POINT::new(), + SIZE::new(), + co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE, + ); + } + + /// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes + /// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap + /// keep working, as they did with winit 0.27's undecorated windows. + fn borderless(style: co::WS) -> co::WS { + style & !(co::WS::CAPTION | co::WS::BORDER) + } + + #[cfg(test)] + mod tests { + use super::borderless; + use winsafe::co; + + #[test] + fn the_borderless_style_drops_the_caption_and_border_only() { + // `WS_CAPTION` is 0x00c0_0000 and already contains `WS_BORDER` (0x0080_0000). + let decorated = co::WS::CAPTION | co::WS::BORDER | co::WS::SYSMENU | co::WS::THICKFRAME | co::WS::MAXIMIZEBOX | co::WS::MINIMIZEBOX; + let bare = borderless(decorated); + assert!(!bare.has(co::WS::CAPTION), "caption gone"); + assert!(!bare.has(co::WS::BORDER), "border gone"); + assert!(bare.has(co::WS::THICKFRAME), "edge resizing kept"); + assert!(bare.has(co::WS::MAXIMIZEBOX) && bare.has(co::WS::SYSMENU), "window buttons kept"); + // Already borderless: unchanged, so the per-frame call does nothing. + assert_eq!(bare.raw(), borderless(bare).raw()); + } + } +} + +#[cfg(not(target_os = "windows"))] +mod platform { + use photocraft_ui_egui::WindowFrameFn; + + pub fn service(_cc: &eframe::CreationContext<'_>, _custom_titlebar: bool) -> Option { + None + } +} diff --git a/assets/icons/marquee-column.svg b/assets/icons/marquee-column.svg new file mode 100644 index 0000000000..519eddf063 --- /dev/null +++ b/assets/icons/marquee-column.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/assets/icons/marquee-row.svg b/assets/icons/marquee-row.svg new file mode 100644 index 0000000000..aa5984521a --- /dev/null +++ b/assets/icons/marquee-row.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/book/src/formats/affinity.md b/book/src/formats/affinity.md index 36544c7fc5..f77a5f0f32 100644 --- a/book/src/formats/affinity.md +++ b/book/src/formats/affinity.md @@ -11,6 +11,11 @@ PhotoCraft opens native Affinity documents: `.af` files from Affinity 3 and `.af - placed JPEG and PNG images become embedded smart objects, and pixel layers become pixel layers; - vector and pixel masks, opacity, visibility, locks, names and blend modes are kept. +Artboards are recognized from both the legacy flag and current Affinity 3 artboard properties. +They keep their names, bounds and editable children when saved as `.pcraft` or PSD; moving an +artboard moves its children together. Nested boards become masked groups, and rotated or curved +boards currently use their bounding rectangle. + The document is 8-bit RGB at the Affinity document's resolution. What PhotoCraft can't reproduce is listed in a warning when the file opens, for example layer effects, adjustment layers and live filters, brush strokes (drawn as plain strokes), special shapes such as clouds and hearts (drawn as diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index d3eb9dfda9..4b7f4dec16 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -17,7 +17,7 @@ Capability details such as sample depth, channel layout, alpha, ICC, EXIF, XMP, Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_export`, Layers to Files, Layer › Export As) flattens it the same way: - Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink. -- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead. +- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened. ## Implemented decode limits diff --git a/book/src/getting-started/building.md b/book/src/getting-started/building.md index 83edc917af..d32307a4f0 100644 --- a/book/src/getting-started/building.md +++ b/book/src/getting-started/building.md @@ -2,6 +2,10 @@ PhotoCraft uses the Rust workspace at the repository root. Install the stable Rust toolchain, clone the repository, and run commands from that root. +On Linux, install the windowing, GL and GTK development packages first. The development guide's +[Prerequisites](https://github.com/storytold/photocraft/blob/main/docs/development.md#prerequisites) +list them for Debian/Ubuntu, Fedora, Arch and openSUSE. + ```sh cargo build --workspace cargo run --release -p photocraft -- image.psd diff --git a/book/src/project/roadmap.md b/book/src/project/roadmap.md index 7c5eea8a41..388d733b9f 100644 --- a/book/src/project/roadmap.md +++ b/book/src/project/roadmap.md @@ -1,6 +1,6 @@ # Roadmap -The live product roadmap, milestone definitions, parity measurements, and current focus are maintained in [`docs/roadmap.md`](https://github.com/storytold/photocraft/blob/main/docs/roadmap.md). Menu coverage is generated into [`docs/parity.md`](https://github.com/storytold/photocraft/blob/main/docs/parity.md) by `cargo xtask parity`. +The live product roadmap, milestone definitions, parity measurements, and current focus are maintained in [`docs/roadmap.md`](https://github.com/storytold/photocraft/blob/main/docs/roadmap.md). Menu coverage is generated into [`docs/parity-checklist.md`](https://github.com/storytold/photocraft/blob/main/docs/parity-checklist.md) by `cargo xtask parity`. Security hardening is cross-cutting rather than a replacement for those product milestones. The current security sequence is: diff --git a/contributors/people.toml b/contributors/people.toml index 140a9acb61..0adfc2f4a1 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -74,3 +74,22 @@ sync_github_name = true real_name = "Ahmed Moussa" display_name = "Ahmed Moussa" sync_github_name = false + +[people.alexanderalber] +real_name = "Alexander Alber" +display_name = "Alexander Alber" +sync_github_name = false + +[people.Erica4fr] +sync_github_name = true + +[people.itsF4LCON] +real_name = "Ran Geerts" +display_name = "Ran Geerts" +sync_github_name = false + +[people.vangel76] +sync_github_name = true + +[people.FinnNk] +sync_github_name = true diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 0fcc9647ef..a88b85bd4d 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -13,7 +13,7 @@ Affinity files. | Affinity | PhotoCraft | |---|---| | Pages, Publisher spreads | one artboard per spread, spreads side by side 64 px apart | -| Artboards (`ShpN` with `ABEn`), with name, solid background, any transform | artboards (nested ones as groups with a vector mask) | +| Artboards (legacy `ABEn`, or current `.af` `phrp`/`aprp` properties, including converted curves), with name, solid background, any transform | artboards (nested ones as groups with a vector mask; rotated/curved boards use their bounding rectangle) | | Layers (`Scop`), groups (`Grup`), pass-through or isolated | groups (pass-through or Normal) | | Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | shape layers (multi-subpath curves fill even-odd) | | Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers | diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index 3579152acf..07d0adf788 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -431,7 +431,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { b"Scop" => Kind::Layer, b"Grup" => Kind::Group, b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => { - if class == Tag::of(b"ShpN") && s.bool(id, b"ABEn") == Some(true) { + // Current .af boards carry artboard properties (aprp) in phrp; older + // documents use ABEn. Converted curve boards keep those properties too. + // Other property classes also use phrp, so the field alone is not a marker. + if s.bool(id, b"ABEn") == Some(true) || s.obj(id, b"phrp").is_some_and(|p| s.is(p, b"aprp")) { + if class != Tag::of(b"ShpN") { + self.warn("a converted artboard outline (imported at its bounding box)"); + } return Ok(self.artboard(id, world)); } let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else { diff --git a/crates/algo/examples/bench_iris.rs b/crates/algo/examples/bench_iris.rs new file mode 100644 index 0000000000..533b87eb48 --- /dev/null +++ b/crates/algo/examples/bench_iris.rs @@ -0,0 +1,67 @@ +//! End-to-end Iris Blur timings; fixture construction and output drop are untimed. +//! `cargo run --release -p photocraft-algo --example bench_iris -- 6000 4000 3` +//! An optional fourth argument models the UI's proxy factor (e.g. 1500 1000 5 4). +use photocraft_algo::{FilterParams, IrisPin, apply_in}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +fn main() { + let args: Vec = std::env::args().collect(); + let number = |i: usize, fallback| args.get(i).and_then(|s| s.parse::().ok()).unwrap_or(fallback); + let (w, h, reps) = (number(1, 6000).clamp(1, 6000), number(2, 4000).clamp(1, 6000), number(3, 3).clamp(1, 20)); + let proxy_factor = number(4, 1).clamp(1, 8) as f32; + let bounds = Rect::new(0, 0, w, h); + for (sample, name) in [(SampleType::U8, "U8"), (SampleType::U16, "U16"), (SampleType::F32, "F32")] { + let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true)); + // Write rows to avoid a second full-image fixture allocation. + for y in 0..h { + let row: Vec = (0..w) + .flat_map(|x| { + let a = if (x + y) % 31 == 0 { 0.0 } else { 0.7 + (x % 7) as f32 / 30.0 }; + let hdr = if sample == SampleType::F32 { 3.0 } else { 1.0 }; + [hdr * (x % 256) as f32 / 255.0, ((x * 3 + y) % 251) as f32 / 250.0, ((x ^ y) % 256) as f32 / 255.0, a] + }) + .collect(); + source.write_region(Rect::new(0, y, w, y + 1), &row); + } + let mut cases = vec![("medium", vec![IrisPin { blur: 80.0, angle: 27.0, ..IrisPin::default() }])]; + if sample == SampleType::U8 { + cases.extend([ + ("small", vec![IrisPin::default()]), + ("large", vec![IrisPin { blur: 300.0, ..IrisPin::default() }]), + ( + "multiple", + vec![ + IrisPin { x: 0.3, blur: 80.0, roundness: 65.0, ..IrisPin::default() }, + IrisPin { x: 0.8, angle: 73.0, blur: 40.0, ..IrisPin::default() }, + ], + ), + ("focused", vec![IrisPin { radius_x: 5.0, radius_y: 5.0, blur: 80.0, ..IrisPin::default() }]), + ("blurred", vec![IrisPin { x: -5.0, blur: 80.0, ..IrisPin::default() }]), + ]); + } + for (label, mut pins) in cases { + // Top-level one-pin blur scales in preview_document_with. Its nested + // JSON pin list retains supplied amounts; model that existing path. + if pins.len() == 1 { + for pin in &mut pins { + pin.blur /= proxy_factor; + } + } + let p = FilterParams::IrisBlur { pins }; + let warm = apply_in(&source, &p, bounds, bounds, None, bounds); + std::hint::black_box(&warm); + drop(warm); + let mut times = Vec::new(); + for _ in 0..reps { + let start = std::time::Instant::now(); + let output = apply_in(&source, &p, bounds, bounds, None, bounds); + times.push(start.elapsed().as_secs_f64() * 1000.0); + std::hint::black_box(&output); + drop(output); + } + println!("{w}x{h} {name} {label}: {times:?} ms"); + } + } +} diff --git a/crates/algo/examples/bench_pixelate.rs b/crates/algo/examples/bench_pixelate.rs new file mode 100644 index 0000000000..a37e8f0775 --- /dev/null +++ b/crates/algo/examples/bench_pixelate.rs @@ -0,0 +1,89 @@ +//! Full filter timing, including tile reads, filtering, writeback and pruning. +//! RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_pixelate -- facet 6000 4000 u8 3 output.bin +//! Optional final argument after output.bin: rgb (default), gray, cmyk or lab. +//! Run the same example at the base commit and the PR commit, then compare output.bin with cmp. +use photocraft_algo::{FilterParams, apply_in}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use std::io::Write; +use std::time::Instant; + +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().skip(1).collect(); + let case = args.first().map(String::as_str).unwrap_or("facet"); + let parse = |i: usize, fallback: i32| -> Result> { + Ok(match args.get(i) { + Some(s) => s.parse()?, + None => fallback, + }) + }; + let (w, h, repeats) = (parse(1, 6000)?, parse(2, 4000)?, parse(4, 3)?); + if !(1..=10000).contains(&w) || !(1..=10000).contains(&h) || !(1..=9).contains(&repeats) { + return Err("dimensions must be 1..10000, repeats 1..9".into()); + } + let depth = match args.get(3).map(String::as_str).unwrap_or("u8") { + "u8" => SampleType::U8, + "u16" => SampleType::U16, + "f32" => SampleType::F32, + _ => return Err("depth must be u8, u16 or f32".into()), + }; + let params = match case { + "facet" => FilterParams::Facet, + "halftone" => FilterParams::ColorHalftone { max_radius: 8.0, angles: [108.0, 162.0, 90.0, 45.0] }, + "halftone-small" => FilterParams::ColorHalftone { max_radius: 2.0, angles: [108.0, 162.0, 90.0, 45.0] }, + "halftone-large" => FilterParams::ColorHalftone { max_radius: 64.0, angles: [108.0, 162.0, 90.0, 45.0] }, + _ => return Err("case must be facet, halftone, halftone-small or halftone-large".into()), + }; + let mode = match args.get(6).map(String::as_str).unwrap_or("rgb") { + "rgb" => ColorMode::Rgb, + "gray" => ColorMode::Grayscale, + "cmyk" => ColorMode::Cmyk, + "lab" => ColorMode::Lab, + _ => return Err("mode must be rgb, gray, cmyk or lab".into()), + }; + let format = PixelFormat::new(mode, depth, true); + let n = format.channels(); + let bounds = Rect::new(-17, -29, w - 17, h - 29); + let mut source = Surface::new(format); + for tile in bounds.tiles() { + let r = tile.rect().intersect(&bounds); + let mut pixels = Vec::with_capacity(r.width() as usize * r.height() as usize * n); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let seed = (x as u32).wrapping_mul(0x9e37_79b9) ^ (y as u32).wrapping_mul(0x85eb_ca6b); + for c in 0..n { + let v = seed.rotate_left(c as u32 * 7).wrapping_add(c as u32 * 71) % 65536; + pixels.push(if c + 1 == n && seed.is_multiple_of(5) { 0.0 } else { v as f32 / 65535.0 }); + } + } + } + source.write_region(r, &pixels); + } + let warm = Rect::new(bounds.x0, bounds.y0, bounds.x0 + w.min(128), bounds.y0 + h.min(128)); + drop(apply_in(&source, ¶ms, warm, bounds, None, bounds)); + let mut times = Vec::new(); + for run in 0..repeats { + let start = Instant::now(); + let result = apply_in(&source, ¶ms, bounds, bounds, None, bounds); + let seconds = start.elapsed().as_secs_f64(); + println!("{case} {w}x{h} {depth:?} {mode:?} run={} seconds={seconds:.6}", run + 1); + times.push(seconds); + if run + 1 == repeats + && let Some(path) = args.get(5) + { + let mut file = std::io::BufWriter::new(std::fs::File::create(path)?); + for tile in bounds.tiles() { + for sample in result.read_region(tile.rect().intersect(&bounds)) { + file.write_all(&sample.to_bits().to_le_bytes())?; + } + } + file.flush()?; + } + } + times.sort_by(f64::total_cmp); + if let Some(median) = times.get(times.len() / 2) { + println!("MEDIAN {case} {w}x{h} {depth:?} {mode:?} seconds={median:.6}"); + } + Ok(()) +} diff --git a/crates/algo/src/gallery.rs b/crates/algo/src/gallery.rs index 3e719b94d9..7cd78dbac8 100644 --- a/crates/algo/src/gallery.rs +++ b/crates/algo/src/gallery.rs @@ -8,10 +8,14 @@ use photocraft_geom::Rect; -use crate::fxutil::{MAXC, add_sample_premul, gauss_blur_n, premul_window, smoothstep, unpremul_px, xy}; -use crate::image::Image; +use crate::fxutil::{MAXC, add_sample_premul, gauss_blur_n, gauss_box_radii, premul_window, smoothstep, unpremul_px, xy}; +use crate::image::{Image, premultiply}; use crate::{BlurPath, Ctx, FieldPin, IrisPin, SpinPin}; +#[cfg(test)] +#[path = "tests_iris.rs"] +mod tests; + /// Gaussian σ for a gallery blur amount in px. fn sigma_of(blur: f32) -> f32 { blur.clamp(0.0, 500.0) * 0.6 @@ -25,17 +29,27 @@ pub(crate) fn reach(blur: f32) -> f32 { const LEVELS: usize = 6; /// Blurs `src` over `out` with a per-pixel σ (`sigma(x, y)`, ≤ `smax`). -fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, sigma: impl Fn(f32, f32) -> f32) -> Vec { +fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, trim_levels: bool, sigma: impl Fn(f32, f32) -> f32) -> Vec { let n = src.ch; + if n == 0 || out.is_empty() { + return Vec::new(); + } if smax < 0.3 { return src.crop(out); } let win = src.rect; let (ww, wh) = (win.width() as usize, win.height() as usize); let (ow, oh) = (out.width() as usize, out.height() as usize); + let Some(row_len) = ww.checked_mul(n).filter(|&len| len > 0) else { return Vec::new() }; + let Some(source_len) = row_len.checked_mul(wh) else { return Vec::new() }; + let Some(pixels) = ow.checked_mul(oh) else { return Vec::new() }; + let Some(samples) = pixels.checked_mul(n) else { return Vec::new() }; + if trim_levels && src.data.len() != source_len { + return Vec::new(); + } // Level σs: 0, then smax / 2^(LEVELS-1) … smax. let lv: Vec = std::iter::once(0.0).chain((0..LEVELS).map(|k| smax / 2f32.powi((LEVELS - 1 - k) as i32))).collect(); - let sig: Vec = (0..ow * oh) + let sig: Vec = (0..pixels) .map(|i| { let (x, y) = xy(out, i); sigma(x as f32 + 0.5, y as f32 + 0.5).clamp(0.0, smax) @@ -54,20 +68,49 @@ fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, sigma: impl Fn(f3 0.0 } }; - let p = premul_window(src, win, ctx.alpha); - let mut acc = vec![0.0f32; ow * oh * n]; + let p = if trim_levels { + // The window is exactly src.rect: read interleaved channels together, + // retaining the same multiplication and alpha order as premul_window. + let mut p = src.data.clone(); + premultiply(&mut p, n, ctx.alpha); + p + } else { + premul_window(src, win, ctx.alpha) + }; + let mut acc = vec![0.0f32; samples]; let mut buf = Vec::new(); for (k, &lvk) in lv.iter().enumerate() { if !sig.iter().any(|&s| weight(k, s) > 0.0) { continue; } - let level: &[f32] = if k == 0 { - &p + let (level, lw): (&[f32], usize) = if k == 0 { + (&p, ww) } else { + // Only Iris opts in. Each level needs its own box-cascade reach, + // not the maximum blur's trailing halo. Keep the original top/left: + // moving the running sums' start changes floating-point cancellation. + let trim = trim_levels && win.intersect(&out) == out; + let reach = if !trim || lvk < 0.3 { 0 } else { gauss_box_radii(lvk).iter().sum::() }; + let reach = i32::try_from(reach).unwrap_or(i32::MAX); + let lw = if trim { (i64::from(out.x1.saturating_add(reach).min(win.x1)) - i64::from(win.x0)) as usize } else { ww }; + let lh = if trim { (i64::from(out.y1.saturating_add(reach).min(win.y1)) - i64::from(win.y0)) as usize } else { wh }; buf.clear(); - buf.extend_from_slice(&p); - gauss_blur_n(&mut buf, ww, wh, n, lvk); - &buf + if buf.capacity() == 0 { + // Match the old single full-window allocation. Row appends must + // not geometrically grow the capacity beyond that window. + buf.reserve_exact(p.len()); + } + if trim { + for row in p.chunks_exact(row_len).take(lh) { + if let Some(row) = row.get(..lw * n) { + buf.extend_from_slice(row); + } + } + } else { + buf.extend_from_slice(&p); + } + gauss_blur_n(&mut buf, lw, lh, n, lvk); + (&buf, lw) }; for (i, &s) in sig.iter().enumerate() { let wk = weight(k, s); @@ -75,9 +118,11 @@ fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, sigma: impl Fn(f3 continue; } let (x, y) = xy(out, i); - let o = (((y - win.y0) as usize) * ww + (x - win.x0) as usize) * n; - for c in 0..n { - acc[i * n + c] += level[o + c] * wk; + let o = (((y - win.y0) as usize) * lw + (x - win.x0) as usize) * n; + if let (Some(dst), Some(px)) = (acc.get_mut(i * n..(i + 1) * n), level.get(o..o + n)) { + for (dst, px) in dst.iter_mut().zip(px) { + *dst += px * wk; + } } } } @@ -103,7 +148,7 @@ pub(crate) fn tilt_shift(src: &Image, out: Rect, ctx: &Ctx, blur: f32, centre: ( let (nx, ny) = (s, c); let smax = sigma_of(blur); let (f0, f1) = (focus.max(0.0), focus.max(0.0) + transition.max(1e-3)); - variable_blur(src, out, ctx, smax, |x, y| { + variable_blur(src, out, ctx, smax, false, |x, y| { let d = ((x - cx) * nx + (y - cy) * ny).abs() / ss; smax * smoothstep(f0, f1, d) }) @@ -128,20 +173,74 @@ fn pin_distance(x: f32, y: f32, b: Rect, px: f32, py: f32, rx: f32, ry: f32, ang pub(crate) fn iris(src: &Image, out: Rect, ctx: &Ctx, pins: &[IrisPin]) -> Vec { let b = ctx.bounds; let smax = pins.iter().map(|p| sigma_of(p.blur)).fold(0.0, f32::max); - if pins.is_empty() { + if pins.is_empty() || smax < 0.3 { return src.crop(out); } - variable_blur(src, out, ctx, smax, |x, y| { - pins.iter() - .map(|p| { - let m = 2.0 + p.roundness.clamp(0.0, 100.0) / 100.0 * 6.0; - let (e, _, _) = pin_distance(x, y, b, p.x, p.y, p.radius_x, p.radius_y, p.angle, m); - sigma_of(p.blur) * smoothstep(p.feather.clamp(0.0, 0.999), 1.0, e) - }) - .fold(f32::MAX, f32::min) + // Small immutable per-tile preparation; allocation failure retains the + // same arithmetic without the table. No additional image-sized cache. + let mut prepared = Vec::new(); + let prepared = if prepared.try_reserve_exact(pins.len()).is_ok() { + prepared.extend(pins.iter().map(|p| PreparedIris::new(p, b))); + Some(prepared) + } else { + None + }; + variable_blur(src, out, ctx, smax, true, |x, y| { + if let Some(prepared) = &prepared { + prepared.iter().map(|p| p.sigma(x, y)).fold(f32::MAX, f32::min) + } else { + pins.iter().map(|p| PreparedIris::new(p, b).sigma(x, y)).fold(f32::MAX, f32::min) + } }) } +struct PreparedIris { + cx: f32, + cy: f32, + rx: f32, + ry: f32, + sin: f32, + cos: f32, + exponent: f32, + feather: f32, + blur: f32, +} + +impl PreparedIris { + fn new(p: &IrisPin, b: Rect) -> Self { + let ss = short_side(b); + let angle = if p.angle.is_finite() { p.angle } else { 0.0 }; + let (sin, cos) = angle.to_radians().sin_cos(); + Self { + cx: b.x0 as f32 + p.x * b.width() as f32, + cy: b.y0 as f32 + p.y * b.height() as f32, + rx: p.radius_x.max(1e-3) * ss, + ry: p.radius_y.max(1e-3) * ss, + sin, + cos, + exponent: 2.0 + p.roundness.clamp(0.0, 100.0) / 100.0 * 6.0, + feather: p.feather.clamp(0.0, 0.999), + blur: sigma_of(p.blur), + } + } + + fn sigma(&self, x: f32, y: f32) -> f32 { + let (dx, dy) = (x - self.cx, y - self.cy); + let (u, v) = ((dx * self.cos + dy * self.sin) / self.rx, (-dx * self.sin + dy * self.cos) / self.ry); + // Outside the normalized bounding square, the superellipse distance + // is at least one. The falloff is saturated: no sqrt/powf is needed. + let e = if u.abs() >= 1.0 || v.abs() >= 1.0 { + f32::INFINITY + } else if (self.exponent - 2.0).abs() < 1e-3 { + (u * u + v * v).sqrt() + } else { + (u.abs().powf(self.exponent) + v.abs().powf(self.exponent)).powf(1.0 / self.exponent) + }; + let e = if e.is_finite() { e } else { f32::INFINITY }; + self.blur * smoothstep(self.feather, 1.0, e) + } +} + /// Field Blur: blur amounts at pins, interpolated by inverse squared distance. pub(crate) fn field(src: &Image, out: Rect, ctx: &Ctx, pins: &[FieldPin]) -> Vec { let b = ctx.bounds; @@ -151,7 +250,7 @@ pub(crate) fn field(src: &Image, out: Rect, ctx: &Ctx, pins: &[FieldPin]) -> Vec let smax = pins.iter().map(|p| sigma_of(p.blur)).fold(0.0, f32::max); let pts: Vec<(f32, f32, f32)> = pins.iter().map(|p| (b.x0 as f32 + p.x * b.width() as f32, b.y0 as f32 + p.y * b.height() as f32, sigma_of(p.blur))).collect(); - variable_blur(src, out, ctx, smax, |x, y| { + variable_blur(src, out, ctx, smax, false, |x, y| { let (mut ws, mut s) = (0.0f32, 0.0f32); for &(px, py, sg) in &pts { let w = 1.0 / ((x - px).powi(2) + (y - py).powi(2) + 1.0); diff --git a/crates/algo/src/inpaint.rs b/crates/algo/src/inpaint.rs index 4f485ad466..215c337a5d 100644 --- a/crates/algo/src/inpaint.rs +++ b/crates/algo/src/inpaint.rs @@ -27,7 +27,8 @@ //! ring around the hole (sum of squared differences), i.e. an automatic Healing Brush source. //! * [`synthesize`], create texture: patch-based texture synthesis in the style of A. Efros, //! W. Freeman, *Image Quilting for Texture Synthesis and Transfer*, SIGGRAPH 2001 (blocks picked from -//! random candidates by overlap error, feathered overlap instead of a min-cut seam). +//! random candidates by overlap error, joined along minimum-error boundary cuts, so the texture is +//! copied, never averaged). //! //! All functions take interleaved `w × h × ch` normalised floats (any colour model/depth) and a //! `w × h` hole mask, and return a full buffer equal to the input outside the hole. They are @@ -971,20 +972,34 @@ pub fn synthesize(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], blo } } let c = best.0; - for y in (by - ov)..(by + b + ov) { - for x in (bx - ov)..(bx + b + ov) { - if x < 0 || y < 0 || x >= w as i32 || y >= h as i32 { - continue; - } + let (x0, y0) = ((bx - ov).max(0), (by - ov).max(0)); + let (x1, y1) = ((bx + b + ov).min(w as i32), (by + b + ov).min(h as i32)); + let src = |x: i32, y: i32| ((y - by + c.1) as usize * w + (x - bx + c.0) as usize) * ch; + // Squared difference between what is there and the block, where both exist. + let err = |x: i32, y: i32| { + let i = y as usize * w + x as usize; + if !(hole[i] && filled[i]) { + return 0.0; + } + let (a, s) = (i * ch, src(x, y)); + (0..ch).map(|q| (out[a + q] - img[s + q]).powi(2)).sum::() + }; + // Minimum-error boundary cuts through the overlaps with the blocks to the left and + // above: the block is copied right of and below them, untouched pixels are kept + // on the other side. Copying (rather than averaging) keeps the texture's grain. + let left = min_cut((x0..(bx + ov).min(x1)).len(), (y0..y1).len(), |u, v| err(x0 + u as i32, y0 + v as i32)); + let top = min_cut((y0..(by + ov).min(y1)).len(), (x0..x1).len(), |u, v| err(x0 + v as i32, y0 + u as i32)); + for y in y0..y1 { + for x in x0..x1 { let i = y as usize * w + x as usize; if !hole[i] { continue; } - let (a, s) = (i * ch, ((y - by + c.1) as usize * w + (x - bx + c.0) as usize) * ch); - // Feather into already-synthesised overlap. - let k = if filled[i] { 0.5 } else { 1.0 }; - for q in 0..ch { - out[a + q] += (img[s + q] - out[a + q]) * k; + let (u, v) = ((x - x0) as usize, (y - y0) as usize); + let keep = filled[i] && (left.get(v).is_some_and(|s| u < *s) || top.get(u).is_some_and(|s| v < *s)); + if !keep { + let (a, s) = (i * ch, src(x, y)); + out[a..a + ch].copy_from_slice(&img[s..s + ch]); } filled[i] = true; } @@ -997,6 +1012,39 @@ pub fn synthesize(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], blo out } +/// The minimum-error cut through a band `across` wide and `along` long (Efros & Freeman's +/// boundary cut, by dynamic programming): for each step along the band, the offset across it +/// where the cut runs (`err(across, along)` is the cost of a pixel). Pixels before the cut keep +/// what is there. Empty when there is no cost anywhere (nothing to cut through). +fn min_cut(across: usize, along: usize, err: impl Fn(usize, usize) -> f32) -> Vec { + if across == 0 || along == 0 { + return Vec::new(); + } + // Cumulative cost, step by step along the band; the cut may move by one pixel per step. + let mut acc: Vec = (0..along).flat_map(|v| (0..across).map(move |u| (u, v))).map(|(u, v)| err(u, v)).collect(); + if acc.iter().all(|x| *x == 0.0) { + return Vec::new(); + } + for v in 1..along { + for u in 0..across { + let lo = u.saturating_sub(1); + let hi = (u + 1).min(across - 1); + let m = (lo..=hi).map(|k| acc[(v - 1) * across + k]).fold(f32::INFINITY, f32::min); + acc[v * across + u] += m; + } + } + let argmin = |v: usize, lo: usize, hi: usize| (lo..=hi).min_by(|a, b| acc[v * across + a].total_cmp(&acc[v * across + b])).unwrap_or(lo); + let mut cut = vec![0; along]; + let mut u = argmin(along - 1, 0, across - 1); + for v in (0..along).rev() { + if v + 1 < along { + u = argmin(v, u.saturating_sub(1), (u + 1).min(across - 1)); + } + cut[v] = u; + } + cut +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/algo/src/pixelate.rs b/crates/algo/src/pixelate.rs index acf08fdc34..1a1325975a 100644 --- a/crates/algo/src/pixelate.rs +++ b/crates/algo/src/pixelate.rs @@ -5,6 +5,13 @@ use photocraft_geom::Rect; +mod facet; +#[cfg(test)] +mod reference; +mod screen; +#[cfg(test)] +mod tests; + use crate::fxutil::{MAXC, cell_point, luma, native, ncol, rgba, set_rgba, subtractive, via_rgb, xy}; use crate::image::Image; use crate::noise::hash01; @@ -49,6 +56,14 @@ pub(crate) fn color_halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32, }; let w = out.width() as usize; let chans = if rgb { 3 } else { cc }; + // Keep cache metadata on the stack; only the optional, bounded tables allocate. + let mut screens: [Option; MAXC] = std::array::from_fn(|c| { + if c >= chans { + return None; + } + let &(s, co) = rot.get(c)?; + screen::Screen::new(out, (ox, oy), (s, co), cell) + }); let mut vals = [0.0f32; MAXC]; for (i, px) in res.chunks_exact_mut(n).enumerate() { let (x, y) = (out.x0 + (i % w) as i32, out.y0 + (i / w) as i32); @@ -68,14 +83,21 @@ pub(crate) fn color_halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32, } // Back to document space to read the cell's value. let (dx, dy) = (mu * co - mv * s + ox, mu * s + mv * co + oy); - let raw = value(dx, dy, c).clamp(0.0, 1.0); - let ink = if sub && !rgb { raw } else { 1.0 - raw }; - // Dot area tracks ink: πr² = ink·cell² until dots touch, then grow to cover the corners. - let touch = std::f32::consts::FRAC_PI_4; - let r = if ink <= touch { - cell * (ink / std::f32::consts::PI).sqrt() - } else { - cell * (0.5 + (ink - touch) / (1.0 - touch) * (std::f32::consts::FRAC_1_SQRT_2 - 0.5)) + // The five source taps and ink-to-radius mapping depend only on + // the screen cell, not on the output pixel inside that cell. + let radius = || { + let raw = value(dx, dy, c).clamp(0.0, 1.0); + let ink = if sub && !rgb { raw } else { 1.0 - raw }; + let touch = std::f32::consts::FRAC_PI_4; + if ink <= touch { + cell * (ink / std::f32::consts::PI).sqrt() + } else { + cell * (0.5 + (ink - touch) / (1.0 - touch) * (std::f32::consts::FRAC_1_SQRT_2 - 0.5)) + } + }; + let r = match screens.get_mut(c).and_then(Option::as_mut) { + Some(screen) => screen.radius(cu + di as f32, cv + dj as f32, radius), + None => radius(), }; // Tiny dots cannot cover more than their own area. cov = cov.max((r - d + 0.5).clamp(0.0, 1.0).min(std::f32::consts::PI * r * r)); @@ -175,6 +197,10 @@ pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, see /// least-varied of its four overlapping quadrants, clumping similar colours /// into flat facets while keeping edges. pub(crate) fn facet(src: &Image, out: Rect, ctx: &Ctx) -> Vec { + facet::run(src, out, ctx).unwrap_or_else(|| facet_direct(src, out, ctx)) +} + +fn facet_direct(src: &Image, out: Rect, ctx: &Ctx) -> Vec { let n = src.ch; let r = 2i32; let mut res = src.crop(out); diff --git a/crates/algo/src/pixelate/facet.rs b/crates/algo/src/pixelate/facet.rs new file mode 100644 index 0000000000..938b97f691 --- /dev/null +++ b/crates/algo/src/pixelate/facet.rs @@ -0,0 +1,94 @@ +//! Reuse each 3x3 quadrant at its four output pixels. Keep the original +//! nine-sample addition order: reassociating the sums can change variance ties. +use crate::fxutil::{MAXC, luma}; +use crate::{Ctx, image::Image}; +use photocraft_geom::Rect; + +#[derive(Clone, Copy, Default)] +struct Quadrant { + variance: f32, + mean: [f32; MAXC], +} + +pub(super) fn run(src: &Image, out: Rect, ctx: &Ctx) -> Option> { + let n = src.ch; + if n == 0 || n > MAXC || out.is_empty() { + return None; + } + let win = Rect::new(out.x0.checked_sub(2)?, out.y0.checked_sub(2)?, out.x1.checked_add(2)?, out.y1.checked_add(2)?); + if win.intersect(&src.rect) != win { + return None; + } + let (w, h) = (win.width() as usize, win.height() as usize); + let len = w.checked_mul(h)?; + // A normal 256px tile needs 264 KiB. Bound scratch for explicit tile overrides. + if len > 1024 * 1024 { + return None; + } + let stride = (src.rect.width() as usize).checked_mul(n)?; + let offset = (win.x0.abs_diff(src.rect.x0) as usize).checked_mul(n)?; + let row = |y: i32| -> Option<&[f32]> { + let start = (y.abs_diff(src.rect.y0) as usize).checked_mul(stride)?.checked_add(offset)?; + src.data.get(start..start.checked_add(w.checked_mul(n)?)?) + }; + let mut lum = Vec::new(); + lum.try_reserve_exact(len).ok()?; + for y in win.y0..win.y1 { + lum.extend(row(y)?.chunks_exact(n).map(|px| luma(ctx, px))); + } + let qw = w.checked_sub(2)?; + let mut quadrants: [Vec; 3] = std::array::from_fn(|_| Vec::new()); + for line in &mut quadrants { + line.try_reserve_exact(qw).ok()?; + line.resize(qw, Quadrant::default()); + } + let build_row = |y: usize, line: &mut [Quadrant]| -> Option<()> { + let rows = [ + row(win.y0.checked_add(i32::try_from(y).ok()?)?)?, + row(win.y0.checked_add(i32::try_from(y + 1).ok()?)?)?, + row(win.y0.checked_add(i32::try_from(y + 2).ok()?)?)?, + ]; + for (x, q) in line.iter_mut().enumerate() { + let mut sum = [0.0; MAXC]; + let (mut s, mut s2) = (0.0, 0.0); + for (dy, pixels) in rows.iter().enumerate() { + let start = x.checked_mul(n)?; + let samples = pixels.get(start..start.checked_add(3 * n)?)?; + let lights = lum.get((y + dy) * w + x..(y + dy) * w + x + 3)?; + for (pixel, &l) in samples.chunks_exact(n).zip(lights) { + for (total, value) in sum.iter_mut().zip(pixel) { + *total += *value; + } + s += l; + s2 += l * l; + } + } + for v in &mut sum { + *v /= 9.0; + } + *q = Quadrant { variance: s2 / 9.0 - (s / 9.0).powi(2), mean: sum }; + } + Some(()) + }; + build_row(0, &mut quadrants[0])?; + build_row(1, &mut quadrants[1])?; + let width = out.width() as usize; + let height = out.height() as usize; + let mut result = Vec::new(); + result.try_reserve_exact(width.checked_mul(height)?.checked_mul(n)?).ok()?; + for y in 0..height { + build_row(y + 2, &mut quadrants[2])?; + for x in 0..width { + let mut best = Quadrant { variance: f32::MAX, mean: [0.0; MAXC] }; + // Preserve quadrant order, including exact ties and non-finite variances. + for q in [quadrants[0].get(x)?, quadrants[0].get(x + 2)?, quadrants[2].get(x)?, quadrants[2].get(x + 2)?] { + if q.variance < best.variance { + best = *q; + } + } + result.extend(best.mean.iter().take(n)); + } + quadrants.rotate_left(1); + } + Some(result) +} diff --git a/crates/algo/src/pixelate/reference.rs b/crates/algo/src/pixelate/reference.rs new file mode 100644 index 0000000000..780df8b8ff --- /dev/null +++ b/crates/algo/src/pixelate/reference.rs @@ -0,0 +1,118 @@ +//! Original scalar kernels, kept as independent test oracles. +use super::*; +pub(crate) fn halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32, angles: [f32; 4]) -> Vec { + let n = src.ch; + let cc = ncol(ctx, n); + let rmax = max_radius.max(1.0); + // Cell side such that a full-size dot just covers the cell's corners. + let cell = rmax * std::f32::consts::SQRT_2; + let sub = subtractive(ctx); + let rgb = via_rgb(ctx); + let mut res = src.crop(out); + let b = ctx.bounds; + let (ox, oy) = (b.x0 as f32, b.y0 as f32); + let rot: Vec<(f32, f32)> = (0..cc.max(3)).map(|c| angles[c.min(3)].to_radians().sin_cos()).collect(); + let clip = b.intersect(&src.rect); + if clip.is_empty() { + return res; + } + // Channel value at a point averaged over five taps (for Lab, of the RGB conversion). + let value = |x: f32, y: f32, c: usize| -> f32 { + let d = cell * 0.25; + let mut acc = 0.0; + let mut px = [0.0f32; MAXC]; + for (dx, dy) in [(0.0, 0.0), (-d, -d), (d, -d), (-d, d), (d, d)] { + // Clamp to the bounds so cells straddling the edge read real pixels. + let (ix, iy) = (((x + dx).floor() as i32).clamp(clip.x0, clip.x1 - 1), ((y + dy).floor() as i32).clamp(clip.y0, clip.y1 - 1)); + if rgb { + for (k, p) in px.iter_mut().enumerate().take(n) { + *p = src.get(ix, iy, k); + } + acc += rgba(ctx, &px[..n])[c]; + } else { + acc += src.get(ix, iy, c); + } + } + acc / 5.0 + }; + let w = out.width() as usize; + let chans = if rgb { 3 } else { cc }; + let mut vals = [0.0f32; MAXC]; + for (i, px) in res.chunks_exact_mut(n).enumerate() { + let (x, y) = (out.x0 + (i % w) as i32, out.y0 + (i / w) as i32); + let (fx, fy) = (x as f32 + 0.5 - ox, y as f32 + 0.5 - oy); + for (c, v) in vals.iter_mut().enumerate().take(chans) { + let (s, co) = rot[c]; + // Rotate into screen space. + let (u, w2) = (fx * co + fy * s, -fx * s + fy * co); + let (cu, cv) = ((u / cell).floor(), (w2 / cell).floor()); + let mut cov: f32 = 0.0; + for dj in -1..=1 { + for di in -1..=1 { + let (mu, mv) = ((cu + di as f32 + 0.5) * cell, (cv + dj as f32 + 0.5) * cell); + let d = ((u - mu).powi(2) + (w2 - mv).powi(2)).sqrt(); + if d >= rmax + 0.5 { + continue; + } + // Back to document space to read the cell's value. + let (dx, dy) = (mu * co - mv * s + ox, mu * s + mv * co + oy); + let raw = value(dx, dy, c).clamp(0.0, 1.0); + let ink = if sub && !rgb { raw } else { 1.0 - raw }; + // Dot area tracks ink: πr² = ink·cell² until dots touch, then grow to cover the corners. + let touch = std::f32::consts::FRAC_PI_4; + let r = if ink <= touch { + cell * (ink / std::f32::consts::PI).sqrt() + } else { + cell * (0.5 + (ink - touch) / (1.0 - touch) * (std::f32::consts::FRAC_1_SQRT_2 - 0.5)) + }; + // Tiny dots cannot cover more than their own area. + cov = cov.max((r - d + 0.5).clamp(0.0, 1.0).min(std::f32::consts::PI * r * r)); + } + } + *v = if sub && !rgb { cov } else { 1.0 - cov }; + } + if rgb { + let a = if ctx.alpha { px[n - 1] } else { 1.0 }; + set_rgba(ctx, px, [vals[0], vals[1], vals[2], a]); + } else { + px[..cc].copy_from_slice(&vals[..cc]); + } + } + res +} + +pub(crate) fn facet(src: &Image, out: Rect, ctx: &Ctx) -> Vec { + let n = src.ch; + let r = 2i32; + let mut res = src.crop(out); + let mut tmp = [0.0f32; MAXC]; + for (i, px) in res.chunks_exact_mut(n).enumerate() { + let (x, y) = xy(out, i); + let mut best = (f32::MAX, [0.0f32; MAXC]); + for (qx, qy) in [(-r, -r), (0, -r), (-r, 0), (0, 0)] { + let mut sum = [0.0f32; MAXC]; + let (mut s, mut s2) = (0.0f32, 0.0f32); + for yy in y + qy..=y + qy + r { + for xx in x + qx..=x + qx + r { + for (c, t) in tmp.iter_mut().enumerate().take(n) { + *t = src.get(xx, yy, c); + sum[c] += *t; + } + let l = luma(ctx, &tmp[..n]); + s += l; + s2 += l * l; + } + } + let cnt = ((r + 1) * (r + 1)) as f32; + let var = s2 / cnt - (s / cnt).powi(2); + if var < best.0 { + for v in sum.iter_mut() { + *v /= cnt; + } + best = (var, sum); + } + } + px.copy_from_slice(&best.1[..n]); + } + res +} diff --git a/crates/algo/src/pixelate/screen.rs b/crates/algo/src/pixelate/screen.rs new file mode 100644 index 0000000000..2fdcb8a5c9 --- /dev/null +++ b/crates/algo/src/pixelate/screen.rs @@ -0,0 +1,67 @@ +//! Tile-local memoization in rotated screen coordinates. Each cell's radius +//! is independent of the output pixel, including the five-tap edge clamping. +use photocraft_geom::Rect; + +pub(super) struct Screen { + x0: i32, + y0: i32, + width: usize, + height: usize, + radii: Vec>, +} + +impl Screen { + pub(super) fn new(out: Rect, origin: (f32, f32), rotation: (f32, f32), cell: f32) -> Option { + if out.is_empty() || !cell.is_finite() || cell < 1.0 { + return None; + } + let (s, co) = rotation; + let (mut lo_x, mut lo_y, mut hi_x, mut hi_y) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); + for y in [out.y0, out.y1.checked_sub(1)?] { + for x in [out.x0, out.x1.checked_sub(1)?] { + let (fx, fy) = (x as f32 + 0.5 - origin.0, y as f32 + 0.5 - origin.1); + let (u, v) = ((fx * co + fy * s) / cell, (-fx * s + fy * co) / cell); + if !u.is_finite() || !v.is_finite() || u.abs() > 4_000_000.0 || v.abs() > 4_000_000.0 { + return None; + } + lo_x = lo_x.min(u); + lo_y = lo_y.min(v); + hi_x = hi_x.max(u); + hi_y = hi_y.max(v); + } + } + // Include the neighbouring dots and a rounding margin. If a key ever + // falls outside this box, radius() computes it directly. + let (x0, y0) = (lo_x.floor() as i32 - 2, lo_y.floor() as i32 - 2); + let width = usize::try_from(hi_x.floor() as i32 + 3 - x0).ok()?; + let height = usize::try_from(hi_y.floor() as i32 + 3 - y0).ok()?; + let len = width.checked_mul(height)?; + // At most 2 MiB per channel, even for an explicit oversized tile. + if len > 256 * 1024 { + return None; + } + let mut radii = Vec::new(); + radii.try_reserve_exact(len).ok()?; + radii.resize(len, None); + Some(Self { x0, y0, width, height, radii }) + } + + pub(super) fn radius(&mut self, x: f32, y: f32, compute: impl FnOnce() -> f32) -> f32 { + // Keys must represent exact integers: don't alias non-finite or large + // coordinates onto cells whose floating-point arithmetic differs. + let slot = if x.is_finite() && y.is_finite() && x.abs() <= 4_000_001.0 && y.abs() <= 4_000_001.0 { + usize::try_from(x as i32 - self.x0) + .ok() + .zip(usize::try_from(y as i32 - self.y0).ok()) + .filter(|&(cx, cy)| cx < self.width && cy < self.height) + .and_then(|(cx, cy)| cy.checked_mul(self.width)?.checked_add(cx)) + .and_then(|i| self.radii.get_mut(i)) + } else { + None + }; + match slot { + Some(value) => *value.get_or_insert_with(compute), + None => compute(), + } + } +} diff --git a/crates/algo/src/pixelate/tests.rs b/crates/algo/src/pixelate/tests.rs new file mode 100644 index 0000000000..83d89bf303 --- /dev/null +++ b/crates/algo/src/pixelate/tests.rs @@ -0,0 +1,193 @@ +use super::*; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_raster::Surface; + +fn fixture(rect: Rect, ctx: &Ctx, pattern: u32) -> Image { + let n = PixelFormat::new(ctx.mode, SampleType::F32, ctx.alpha).channels(); + let mut image = Image::new(rect, n); + for (i, pixel) in image.data.chunks_exact_mut(n).enumerate() { + let (x, y) = xy(rect, i); + let seed = (x as u32).wrapping_mul(0x9e37_79b9) ^ (y as u32).wrapping_mul(0x85eb_ca6b); + for (c, sample) in pixel.iter_mut().enumerate() { + *sample = match pattern { + 0 => 0.5, + 1 => { + if x + y > -4 { + 0.9 + } else { + 0.1 + } + } + 2 => (seed.rotate_left(c as u32 * 7) % 65536) as f32 / 65535.0, + 3 => (seed.rotate_left(c as u32 * 7) % 65536) as f32 / 16384.0 - 1.0, + _ => match seed.wrapping_add(c as u32) % 7 { + 0 => f32::NAN, + 1 => f32::INFINITY, + 2 => f32::NEG_INFINITY, + _ => 0.5, + }, + }; + } + if ctx.alpha && seed.is_multiple_of(5) { + pixel[n - 1] = 0.0; + } + } + image +} + +fn assert_bits(got: &[f32], want: &[f32]) { + assert_eq!(got.len(), want.len()); + for (i, (a, b)) in got.iter().zip(want).enumerate() { + assert_eq!(a.to_bits(), b.to_bits(), "sample {i}: {a} != {b}"); + } +} + +#[test] +fn facet_reuse_matches_scalar_in_every_model() { + let out = Rect::new(-17, -9, 20, 18); + for mode in [ + ColorMode::Rgb, + ColorMode::Grayscale, + ColorMode::Cmyk, + ColorMode::Lab, + ColorMode::Bitmap, + ColorMode::Duotone, + ColorMode::Indexed, + ColorMode::Multichannel, + ] { + for alpha in [false, true] { + let ctx = Ctx { bounds: out, mode, alpha }; + for pattern in 0..5 { + let src = fixture(out.inflate(3), &ctx, pattern); + assert_bits(&facet(&src, out, &ctx), &reference::facet(&src, out, &ctx)); + } + } + } +} + +#[test] +fn facet_partial_halo_and_thin_regions_match_scalar() { + for out in [Rect::new(-3, 7, -2, 8), Rect::new(-5, 0, 30, 1), Rect::new(9, -8, 10, 29)] { + let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true }; + for halo in [0, 1, 2, 3] { + let src = fixture(out.inflate(halo), &ctx, 2); + assert_bits(&facet(&src, out, &ctx), &reference::facet(&src, out, &ctx)); + } + } +} + +#[test] +fn halftone_cache_matches_scalar_screen_angles_radii_and_models() { + let out = Rect::new(-17, -9, 20, 18); + for mode in [ + ColorMode::Rgb, + ColorMode::Grayscale, + ColorMode::Cmyk, + ColorMode::Lab, + ColorMode::Bitmap, + ColorMode::Duotone, + ColorMode::Indexed, + ColorMode::Multichannel, + ] { + for alpha in [false, true] { + let ctx = Ctx { bounds: Rect::new(-23, -15, 29, 31), mode, alpha }; + for pattern in 0..5 { + let src = fixture(ctx.bounds, &ctx, pattern); + for radius in [1.0, 2.0, 3.75, 8.0, 64.0] { + for angles in [[108.0, 162.0, 90.0, 45.0], [0.0, -17.5, 359.99, 720.0]] { + assert_bits(&color_halftone(&src, out, &ctx, radius, angles), &reference::halftone(&src, out, &ctx, radius, angles)); + } + } + } + } + } +} + +#[test] +fn halftone_unusual_coordinates_and_params_keep_direct_semantics() { + for out in [Rect::new(16_777_211, -3, 16_777_220, 4), Rect::new(-3, 4, -2, 5)] { + let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true }; + let src = fixture(out.inflate(2), &ctx, 2); + for radius in [f32::NAN, f32::INFINITY, -10.0, 1.0] { + for angles in [[f32::NAN, 0.0, f32::INFINITY, 45.0], [0.0; 4]] { + assert_bits(&color_halftone(&src, out, &ctx, radius, angles), &reference::halftone(&src, out, &ctx, radius, angles)); + } + } + } + assert!(screen::Screen::new(Rect::new(0, 0, 100_000, 100_000), (0.0, 0.0), (0.0, 1.0), 1.0).is_none()); + assert!(screen::Screen::new(Rect::new(8_000_000, 0, 8_000_010, 10), (0.0, 0.0), (0.0, 1.0), 1.0).is_none()); + let mut screen = screen::Screen::new(Rect::new(0, 0, 1, 1), (0.0, 0.0), (0.0, 1.0), 1.0).unwrap(); + assert_eq!(screen.radius(-100.0, 0.0, || 3.0), 3.0); + assert_eq!(screen.radius(f32::NAN, 0.0, || 4.0), 4.0); + assert_eq!(screen.radius(0.0, 0.0, || 5.0), 5.0); + assert_eq!(screen.radius(0.0, 0.0, || panic!("cached dot recomputed")), 5.0); +} + +#[test] +fn tiled_filters_match_scalar_at_all_depths_with_soft_selection() { + use crate::{FilterParams, apply_tiled}; + let bounds = Rect::new(-17, -9, 26, 24); + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for alpha in [false, true] { + let ctx = Ctx { bounds, mode, alpha }; + let src = fixture(bounds, &ctx, 2); + let mut surface = Surface::new(PixelFormat::new(mode, depth, alpha)); + surface.write_region(bounds, &src.data); + let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false)); + let coverage: Vec<_> = (0..bounds.width() * bounds.height()).map(|i| (i % 7) as f32 / 6.0).collect(); + selection.write_region(bounds, &coverage); + for params in [FilterParams::Facet, FilterParams::ColorHalftone { max_radius: 3.75, angles: [108.0, 162.0, 90.0, 45.0] }] { + for extent in [None, Some(bounds)] { + assert!(matches!(params.halo(), crate::Halo::Radius(_))); + let halo = match params.halo() { + crate::Halo::Radius(r) => r, + _ => 0, + }; + let image = match extent { + Some(e) => Image::read_clamped(&surface, bounds.inflate(halo), e), + None => Image::read(&surface, bounds.inflate(halo)), + }; + let mut expected_data = match params { + FilterParams::Facet => reference::facet(&image, bounds, &ctx), + FilterParams::ColorHalftone { max_radius, angles } => reference::halftone(&image, bounds, &ctx, max_radius, angles), + _ => Vec::new(), + }; + crate::mix_selection(&mut expected_data, bounds, &selection, &image); + let mut expected = surface.clone(); + expected.write_region(bounds, &expected_data); + for tile in [1, 7, 17, 256] { + let got = apply_tiled(&surface, ¶ms, bounds, bounds, Some(&selection), tile, extent); + assert_bits(&got.read_region(bounds), &expected.read_region(bounds)); + } + } + } + } + } + } +} + +#[test] +fn extended_channels_and_large_screen_offsets_match_scalar() { + let out = Rect::new(16_777_211, -3, 16_777_220, 4); + let ctx = Ctx { bounds: Rect::new(0, -10, out.x1 + 10, 10), mode: ColorMode::Multichannel, alpha: true }; + for n in 5..=MAXC { + let mut src = Image::new(out.inflate(3), n); + for (i, sample) in src.data.iter_mut().enumerate() { + *sample = (i * 73 % 257) as f32 / 256.0; + } + assert_bits(&facet(&src, out, &ctx), &reference::facet(&src, out, &ctx)); + assert_bits(&color_halftone(&src, out, &ctx, 1.0, [0.0; 4]), &reference::halftone(&src, out, &ctx, 1.0, [0.0; 4])); + } +} + +#[test] +fn facet_scratch_rejects_large_or_incomplete_storage() { + let out = Rect::new(0, 0, 10_000, 10_000); + let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true }; + let src = Image { rect: out.inflate(3), ch: 4, data: Vec::new() }; + assert!(facet::run(&src, out, &ctx).is_none()); + let out = Rect::new(0, 0, 2, 2); + let src = Image { rect: out.inflate(3), ch: 4, data: Vec::new() }; + assert!(facet::run(&src, out, &ctx).is_none()); +} diff --git a/crates/algo/src/segment/edge_starts.rs b/crates/algo/src/segment/edge_starts.rs new file mode 100644 index 0000000000..411542b074 --- /dev/null +++ b/crates/algo/src/segment/edge_starts.rs @@ -0,0 +1,55 @@ +//! Source pixels of canonical grid links touching a free pixel, in raster order. +//! Merge the free pixels and their left/upper three predecessors. Each stream +//! is sorted; a five-way merge deduplicates in O(free pixels), without a dense bitmap. + +pub(super) struct Starts<'a> { + free: &'a [usize], + w: usize, + at: [usize; 5], + peek: [Option; 5], +} + +impl<'a> Starts<'a> { + pub(super) fn new(free: &'a [usize], w: usize) -> Self { + let mut this = Self { free, w, at: [0; 5], peek: [None; 5] }; + for k in 0..5 { + this.advance(k) + } + this + } + + fn advance(&mut self, k: usize) { + self.peek[k] = None; + while let Some(&i) = self.free.get(self.at[k]) { + self.at[k] += 1; + let x = i % self.w; + let value = match k { + 0 => Some(i), + 1 => (x > 0).then(|| i - 1), + 2 => (i >= self.w).then(|| i - self.w), + 3 => (i >= self.w && x > 0).then(|| i - self.w - 1), + _ => (i >= self.w && x + 1 < self.w).then(|| i - self.w + 1), + }; + if value.is_some() { + self.peek[k] = value; + break; + } + } + } +} + +impl Iterator for Starts<'_> { + type Item = usize; + fn next(&mut self) -> Option { + let first = self.peek.iter().flatten().copied().min()?; + for k in 0..5 { + if self.peek[k] == Some(first) { + self.advance(k) + } + } + Some(first) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/algo/src/segment/edge_starts/tests.rs b/crates/algo/src/segment/edge_starts/tests.rs new file mode 100644 index 0000000000..a55745c0f6 --- /dev/null +++ b/crates/algo/src/segment/edge_starts/tests.rs @@ -0,0 +1,107 @@ +use super::*; +use crate::segment::{FREE, HARD_BG, HARD_FG, RgbImage, grid_cut_weighted}; + +#[test] +fn merged_starts_equal_all_live_edge_sources() { + for (w, h) in [(1, 1), (1, 7), (7, 1), (2, 3), (4, 3)] { + for bits in 0..1usize << (w * h) { + let free: Vec<_> = (0..w * h).filter(|i| bits & (1 << i) != 0).collect(); + let mut expected = Vec::new(); + for y in 0..h { + for x in 0..w { + let i = y * w + x; + let live = free.contains(&i) + || crate::segment::NB.iter().any(|&(dx, dy, _)| { + let (xx, yy) = (x as i32 + dx, y as i32 + dy); + xx >= 0 && xx < w as i32 && yy < h as i32 && free.contains(&(yy as usize * w + xx as usize)) + }); + if live { + expected.push(i) + } + } + } + assert_eq!(Starts::new(&free, w).collect::>(), expected, "{w}x{h} bits={bits}"); + } + } +} + +#[test] +fn every_small_constraint_mask_matches_the_previous_graph() { + for (w, h) in [(1, 1), (1, 6), (6, 1), (2, 3)] { + let img = RgbImage::from_fn(w, h, |x, y| [(x % 3) as f32 / 2.0, (y % 4) as f32 / 3.0, ((x + y) % 5) as f32 / 4.0]); + for code in 0..3usize.pow((w * h) as u32) { + let mut state = code; + let fixed: Vec<_> = (0..w * h) + .map(|_| { + let v = state % 3; + state /= 3; + [FREE, HARD_FG, HARD_BG][v] + }) + .collect(); + let fg: Vec<_> = (0..w * h).map(|i| (i % 3) as f32 * 0.1).collect(); + let bg: Vec<_> = (0..w * h).map(|i| (i % 4) as f32 * 0.15).collect(); + for kind in 0..3 { + let weight = |d: f32| match kind { + 0 => 1.0, + 1 => 0.3 * (-2.0 * d).exp(), + _ => 0.02 + 1.0 / (1.0 + (d.sqrt() / 0.05).powf(1.98)), + }; + assert_eq!( + grid_cut_weighted::(&img, &fg, &bg, &fixed, weight), + grid_cut_weighted::(&img, &fg, &bg, &fixed, weight), + "{w}x{h} constraints={code} weight={kind}" + ); + } + } + } +} + +#[test] +fn sparse_graph_keeps_exact_masks_and_avoids_irrelevant_weights() { + use std::cell::Cell; + let (w, h) = (101usize, 93usize); + let img = RgbImage::from_fn(w, h, |x, y| [((x * 13 + y * 7) % 257) as f32 / 256.0; 3]); + let cost: Vec<_> = (0..w * h).map(|i| (i % 11) as f32 * 0.01).collect(); + for kind in 0..4 { + let fixed: Vec<_> = (0..w * h) + .map(|i| { + if (kind == 0 && i % 503 == 0) || (kind == 1 && i % w >= 48 && i % w <= 52) || (kind == 2 && i / w == 0) || (kind == 3 && i % w == w - 1) { + FREE + } else if i % w < 50 { + HARD_FG + } else { + HARD_BG + } + }) + .collect(); + let before = Cell::new(0); + let after = Cell::new(0); + let old = grid_cut_weighted::(&img, &cost, &cost, &fixed, |d| { + before.set(before.get() + 1); + 0.3 * (-2.0 * d).exp() + }); + let new = grid_cut_weighted::(&img, &cost, &cost, &fixed, |d| { + after.set(after.get() + 1); + 0.3 * (-2.0 * d).exp() + }); + assert_eq!(new, old, "kind={kind}"); + assert!(after.get() * 5 < before.get(), "kind={kind} counts {} -> {}", before.get(), after.get()); + } +} + +#[test] +fn malformed_grid_dimensions_and_short_inputs_return_empty() { + for img in [ + RgbImage { w: usize::MAX, h: 2, px: Vec::new() }, + RgbImage { w: i32::MAX as usize + 1, h: 0, px: Vec::new() }, + RgbImage { w: 2, h: 2, px: vec![[0.0; 3]; 3] }, + ] { + assert!(crate::segment::grid_cut(&img, &[], &[], &[], 1.0, 1.0).is_empty()); + } + let img = RgbImage::new(2, 2); + let cost = [0.0; 4]; + let fixed = [FREE; 4]; + for (fg, bg, constraints) in [(&cost[..3], &cost[..], &fixed[..]), (&cost[..], &cost[..3], &fixed[..]), (&cost[..], &cost[..], &fixed[..3])] { + assert!(crate::segment::grid_cut_with(&img, fg, bg, constraints, |_| 1.0).is_empty()); + } +} diff --git a/crates/algo/src/segment/maxflow.rs b/crates/algo/src/segment/maxflow.rs index 8243b81880..868347ae7e 100644 --- a/crates/algo/src/segment/maxflow.rs +++ b/crates/algo/src/segment/maxflow.rs @@ -1,4 +1,4 @@ -//! Boykov–Kolmogorov max-flow / min-cut. +//! Hybrid max-flow / min-cut: Boykov–Kolmogorov and FIFO push/relabel. //! //! Implemented from Y. Boykov and V. Kolmogorov, "An Experimental Comparison of Min-Cut/Max-Flow //! Algorithms for Energy Minimization in Vision", IEEE PAMI 26(9), 2004: two search trees @@ -6,9 +6,16 @@ //! path; saturated tree edges create orphans that are re-adopted (or freed) before growing again. //! Search trees are reused between augmentations, which makes the algorithm very fast on the //! short-path grid graphs of image segmentation. +//! +//! Large finite graphs with both terminal directions use FIFO push/relabel instead: +//! excess is discharged locally, with periodic exact distances to both terminals. +//! This avoids repeated path augmentation for the many area-cost terminals in Quick Selection. +//! Small graphs and graphs with only one terminal direction retain the BK path. use std::collections::VecDeque; +mod push_relabel; + const NONE: u32 = u32::MAX; const TERMINAL: u32 = u32::MAX - 1; const ORPHAN: u32 = u32::MAX - 2; @@ -103,6 +110,24 @@ impl Graph { /// Computes the maximum flow (= minimum cut capacity). pub fn maxflow(&mut self) -> f64 { + if self.node_count() >= 4096 { + let mut source = false; + let mut sink = false; + for &capacity in &self.tr_cap { + if !capacity.is_finite() { + return self.maxflow_bk(); + } + source |= capacity > EPS; + sink |= capacity < -EPS; + } + if source && sink && self.r_cap.iter().all(|v| v.is_finite() && *v >= 0.0) { + return self.maxflow_push_relabel(); + } + } + self.maxflow_bk() + } + + pub(super) fn maxflow_bk(&mut self) -> f64 { let n = self.node_count(); let mut queue: VecDeque = VecDeque::new(); let mut orphans: VecDeque = VecDeque::new(); diff --git a/crates/algo/src/segment/maxflow/push_relabel.rs b/crates/algo/src/segment/maxflow/push_relabel.rs new file mode 100644 index 0000000000..8ae7632c3b --- /dev/null +++ b/crates/algo/src/segment/maxflow/push_relabel.rs @@ -0,0 +1,179 @@ +//! FIFO push/relabel with periodic exact distances to both terminals. +//! +//! Implemented from Goldberg & Tarjan, "A New Approach to the Maximum-Flow +//! Problem", JACM 35(4), 1988, with FIFO scheduling and global relabelling. +//! The existing SoA residual graph is reused. Terminal source arcs are saturated +//! into a preflow; `back` keeps their reverse capacity, `excess` uses f64 so local +//! f32 capacity updates do not accumulate excess-rounding drift. +use super::{EPS, Graph, NONE, TERMINAL}; +use std::collections::VecDeque; + +impl Graph { + pub(super) fn maxflow_push_relabel(&mut self) -> f64 { + let n = self.node_count(); + let Some(source_height) = u32::try_from(n).ok().filter(|&n| n < u32::MAX / 2).map(|n| n + 2) else { + return self.maxflow_bk(); + }; + let mut back = vec![0.0f32; n]; + let mut excess = vec![0.0f64; n]; + for i in 0..n { + self.active[i] = false; + if self.tr_cap[i] > EPS { + back[i] = self.tr_cap[i]; + excess[i] = f64::from(self.tr_cap[i]); + self.tr_cap[i] = 0.0; + } + } + self.global_relabel(source_height, &back); + let mut queue = VecDeque::new(); + for (i, &e) in excess.iter().enumerate() { + if e > f64::from(EPS) { + self.active[i] = true; + queue.push_back(i) + } + } + let mut work = 0usize; + let frequency = self.r_cap.len().saturating_mul(2).saturating_add(n); + while let Some(i) = queue.pop_front() { + self.active[i] = false; + while excess[i] > f64::from(EPS) { + if self.dist[i] == 1 && self.tr_cap[i] < -EPS { + let delta = (excess[i] as f32).min(-self.tr_cap[i]); + self.tr_cap[i] += delta; + excess[i] -= f64::from(delta); + self.flow += f64::from(delta); + if excess[i] <= f64::from(EPS) { + break; + } + } + if self.dist[i] == source_height + 1 && back[i] > EPS { + let delta = (excess[i] as f32).min(back[i]); + back[i] -= delta; + self.tr_cap[i] += delta; + excess[i] -= f64::from(delta); + if excess[i] <= f64::from(EPS) { + break; + } + } + let mut a = self.parent[i]; + while a != NONE { + let ai = a as usize; + let j = self.head[ai] as usize; + work += 1; + if self.r_cap[ai] > EPS && self.dist[i] == self.dist[j].saturating_add(1) { + let delta = (excess[i] as f32).min(self.r_cap[ai]); + self.r_cap[ai] -= delta; + self.r_cap[ai ^ 1] += delta; + excess[i] -= f64::from(delta); + excess[j] += f64::from(delta); + if !self.active[j] && excess[j] > f64::from(EPS) { + self.active[j] = true; + queue.push_back(j); + } + if excess[i] <= f64::from(EPS) { + break; + } + } + a = self.next[ai]; + self.parent[i] = a; + } + if excess[i] <= f64::from(EPS) { + break; + } + let mut height = if self.tr_cap[i] < -EPS { 0 } else { u32::MAX }; + if back[i] > EPS { + height = height.min(source_height) + } + let mut a = self.first[i]; + while a != NONE { + let ai = a as usize; + work += 1; + if self.r_cap[ai] > EPS { + height = height.min(self.dist[self.head[ai] as usize]) + } + a = self.next[ai]; + } + if height == u32::MAX { + break; + } + self.dist[i] = height.saturating_add(1); + self.parent[i] = self.first[i]; + } + if work >= frequency { + self.global_relabel(source_height, &back); + work = 0; + } + } + // Report the smallest source-side cut, independently of discharge order. + self.parent.fill(NONE); + self.is_sink.fill(true); + for i in 0..n { + if self.tr_cap[i] > EPS { + self.parent[i] = TERMINAL; + self.is_sink[i] = false; + queue.push_back(i); + } + } + while let Some(i) = queue.pop_front() { + let mut a = self.first[i]; + while a != NONE { + let ai = a as usize; + let j = self.head[ai] as usize; + if self.r_cap[ai] > EPS && self.parent[j] == NONE { + self.parent[j] = TERMINAL; + self.is_sink[j] = false; + queue.push_back(j); + } + a = self.next[ai]; + } + } + self.flow + } + + fn global_relabel(&mut self, source_height: u32, back: &[f32]) { + self.dist.fill(u32::MAX); + self.parent.clone_from(&self.first); + let mut queue = VecDeque::new(); + for i in 0..self.node_count() { + if self.tr_cap[i] < -EPS { + self.dist[i] = 1; + queue.push_back(i) + } + } + while let Some(i) = queue.pop_front() { + let mut a = self.first[i]; + while a != NONE { + let ai = a as usize; + let j = self.head[ai] as usize; + if self.r_cap[ai ^ 1] > EPS && self.dist[j] == u32::MAX { + self.dist[j] = self.dist[i].saturating_add(1); + queue.push_back(j); + } + a = self.next[ai]; + } + } + // Give sink-unreachable vertices exact distances back to the source. + // Resetting all of them to the same height can undo discharge progress. + for (i, &capacity) in back.iter().enumerate() { + if capacity > EPS && self.dist[i] == u32::MAX { + self.dist[i] = source_height + 1; + queue.push_back(i); + } + } + while let Some(i) = queue.pop_front() { + let mut a = self.first[i]; + while a != NONE { + let ai = a as usize; + let j = self.head[ai] as usize; + if self.r_cap[ai ^ 1] > EPS && self.dist[j] == u32::MAX { + self.dist[j] = self.dist[i].saturating_add(1); + queue.push_back(j); + } + a = self.next[ai]; + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/algo/src/segment/maxflow/push_relabel/tests.rs b/crates/algo/src/segment/maxflow/push_relabel/tests.rs new file mode 100644 index 0000000000..6c7141572e --- /dev/null +++ b/crates/algo/src/segment/maxflow/push_relabel/tests.rs @@ -0,0 +1,181 @@ +use super::*; +#[test] +fn preflow_matches_bk_on_integer_graphs_and_returns_unused_source() { + let mut rng = 7u32; + let mut next = || { + rng ^= rng << 13; + rng ^= rng >> 17; + rng ^= rng << 5; + rng + }; + for n in 1..40 { + for _ in 0..30 { + let mut g = Graph::with_capacity(n, n * 4); + for i in 0..n { + g.add_tweights(i, (next() % 10) as f32, (next() % 10) as f32) + } + for i in 0..n { + for j in i + 1..n { + if next() % 7 == 0 { + g.add_edge(i, j, (next() % 8) as f32, (next() % 8) as f32) + } + } + } + let mut old = g.clone(); + assert_eq!(g.maxflow_push_relabel(), old.maxflow_bk()); + assert_eq!((0..n).map(|i| g.in_source(i)).collect::>(), (0..n).map(|i| old.in_source(i)).collect::>()); + assert_eq!(g.residual_out_of_source_side(), 0.0); + let flow = g.flow(); + assert_eq!(g.maxflow_push_relabel(), flow); + } + } +} + +fn energy(g: &Graph, labels: &[bool]) -> f64 { + let mut sum = g.flow; + for (i, &source) in labels.iter().enumerate() { + sum += f64::from(if source { (-g.tr_cap[i]).max(0.0) } else { g.tr_cap[i].max(0.0) }); + for (j, capacity, _) in g.arcs(i) { + if source && !labels[j] { + sum += f64::from(capacity); + } + } + } + sum +} + +#[test] +fn fractional_flows_match_brute_force_minimum_cuts() { + let mut rng = crate::segment::Rng::new(911); + for n in 1..=12 { + for _ in 0..20 { + let mut g = Graph::with_capacity(n, n * n); + for i in 0..n { + g.add_tweights(i, (rng.next_u64() % 31) as f32 / 32.0, (rng.next_u64() % 31) as f32 / 32.0); + for j in 0..i { + if rng.next_u64().is_multiple_of(5) { + g.add_edge(i, j, (rng.next_u64() % 31) as f32 / 32.0, (rng.next_u64() % 31) as f32 / 32.0); + } + } + } + let input = g.clone(); + let mut optimum = f64::INFINITY; + let mut smallest = vec![true; n]; + for code in 0..1usize << n { + let labels: Vec<_> = (0..n).map(|i| code & (1 << i) != 0).collect(); + let value = energy(&input, &labels); + if value < optimum { + optimum = value; + smallest.clone_from(&labels); + } else if value == optimum { + for (a, b) in smallest.iter_mut().zip(labels) { + *a &= b; + } + } + } + assert_eq!(g.maxflow_push_relabel(), optimum, "n={n}"); + let labels: Vec<_> = (0..n).map(|i| g.in_source(i)).collect(); + assert_eq!(energy(&input, &labels), optimum); + assert_eq!(labels, smallest); + } + } +} + +#[test] +fn float_capacity_graphs_match_bk() { + let mut rng = crate::segment::Rng::new(819); + for n in [1, 2, 7, 16, 39, 64] { + for case in 0..100 { + let mut g = Graph::with_capacity(n, n * 4); + for i in 0..n { + g.add_tweights(i, (rng.next_u64() % 1001) as f32 / 97.0, (rng.next_u64() % 1001) as f32 / 97.0); + for j in 0..i { + if rng.next_u64().is_multiple_of(7) { + g.add_edge(i, j, (rng.next_u64() % 1001) as f32 / 113.0, (rng.next_u64() % 1001) as f32 / 113.0); + } + } + } + let input = g.clone(); + let mut old = g.clone(); + let a = g.maxflow_push_relabel(); + let b = old.maxflow_bk(); + assert!((a - b).abs() < 0.001, "n={n} case={case}: flow {a} vs {b}"); + let labels: Vec<_> = (0..n).map(|i| g.in_source(i)).collect(); + let expected: Vec<_> = (0..n).map(|i| old.in_source(i)).collect(); + assert_eq!(labels, expected, "n={n} case={case}: energy {} vs {}", energy(&input, &labels), energy(&input, &expected)); + } + } +} + +#[test] +fn source_return_survives_long_disconnected_chains() { + let n = 5000; + let mut g = Graph::with_capacity(n, n); + g.add_tweights(0, 200.0, 0.0); + g.add_tweights(n - 1, 0.0, 100.0); + for i in 1..n - 1 { + g.add_edge(i - 1, i, 100.0, 100.0); + } + assert_eq!(g.maxflow_push_relabel(), 0.0); + for i in 0..n { + assert_eq!(g.in_source(i), i != n - 1); + } + assert_eq!(g.maxflow_push_relabel(), 0.0); + g.add_edge(n - 2, n - 1, 30.0, 30.0); + assert_eq!(g.maxflow_push_relabel(), 30.0); + assert_eq!(g.residual_out_of_source_side(), 0.0); +} + +#[test] +#[ignore = "release solver comparison across sparse and dense terminal graphs"] +fn solver_workload_release_comparison() { + use std::{hint::black_box, time::Instant}; + for width in [32usize, 64, 128, 256] { + let n = width * width; + for kind in ["chain", "all-source", "dense-grid"] { + let mut input = Graph::with_capacity(n, n * 4); + if kind == "chain" { + input.add_tweights(0, 100.0, 0.0); + input.add_tweights(n - 1, 0.0, 100.0); + for i in 1..n { + input.add_edge(i - 1, i, 1.0, 1.0); + } + } else { + for y in 0..width { + for x in 0..width { + let i = y * width + x; + if kind == "all-source" { + input.add_tweights(i, 1.0, 0.0); + } else { + input.add_tweights(i, if x < width / 4 { 3.0 } else { 0.0 }, 0.01 + (i % 17) as f32 * 0.001); + } + if x > 0 { + input.add_edge(i, i - 1, 1.0, 1.0); + } + if y > 0 { + input.add_edge(i, i - width, 1.0, 1.0); + } + } + } + } + let (mut old_ms, mut new_ms) = (Vec::new(), Vec::new()); + let mut reference = input.clone(); + let flow = reference.maxflow_bk(); + for run in 0..3 { + for fast in [run % 2 == 0, run % 2 != 0] { + let mut g = input.clone(); + let start = Instant::now(); + let value = if fast { g.maxflow() } else { g.maxflow_bk() }; + black_box(value); + let ms = start.elapsed().as_secs_f64() * 1000.0; + assert!((value - flow).abs() < 0.01); + assert_eq!((0..n).map(|i| g.in_source(i)).collect::>(), (0..n).map(|i| reference.in_source(i)).collect::>()); + if fast { new_ms.push(ms) } else { old_ms.push(ms) } + } + } + old_ms.sort_by(f64::total_cmp); + new_ms.sort_by(f64::total_cmp); + println!("SOLVER width={width} n={n} case={kind} old_ms={old_ms:?} new_ms={new_ms:?} speedup={:.2}", old_ms[1] / new_ms[1]); + } + } +} diff --git a/crates/algo/src/segment/mod.rs b/crates/algo/src/segment/mod.rs index ee85294d2a..f14a22d1eb 100644 --- a/crates/algo/src/segment/mod.rs +++ b/crates/algo/src/segment/mod.rs @@ -3,7 +3,8 @@ //! //! Everything here is classical (no learned models) and implemented from the published papers: //! -//! * [`maxflow`]: Boykov–Kolmogorov min-cut / max-flow (Boykov & Kolmogorov, "An Experimental +//! * [`maxflow`]: hybrid push/relabel (Goldberg & Tarjan, JACM 1988) and Boykov–Kolmogorov +//! min-cut / max-flow (Boykov & Kolmogorov, "An Experimental //! Comparison of Min-Cut/Max-Flow Algorithms for Energy Minimization in Vision", PAMI 2004). //! * [`gmm`]: Gaussian mixture colour models (k-means++ initialisation, Arthur & Vassilvitskii //! 2007; EM refinement). @@ -232,60 +233,25 @@ pub const HARD_BG: u8 = 2; /// become graph nodes (links to fixed neighbours fold into terminal capacities). Returns /// `true` for foreground. pub fn grid_cut(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[u8], gamma: f32, beta: f32) -> Vec { - let (w, h) = (img.w as i32, img.h as i32); - let n = img.w * img.h; - let mut idx = vec![u32::MAX; n]; - let mut count = 0u32; - for i in 0..n { - if fixed[i] == FREE { - idx[i] = count; - count += 1; - } - } - let mut out: Vec = fixed.iter().map(|f| *f == HARD_FG).collect(); - if count == 0 { - return out; - } - let mut g = Graph::with_capacity(count as usize, count as usize * 4); - for i in 0..n { - if idx[i] != u32::MAX { - g.add_tweights(idx[i] as usize, cost_bg[i], cost_fg[i]); - } - } - for y in 0..h { - for x in 0..w { - let i = (y * w + x) as usize; - let a = img.px[i]; - for (dx, dy, len) in NB { - let (nx, ny) = (x + dx, y + dy); - if nx < 0 || nx >= w || ny >= h { - continue; - } - let j = (ny * w + nx) as usize; - let wt = gamma * (-beta * d2(a, img.px[j])).exp() / len; - match (idx[i] != u32::MAX, idx[j] != u32::MAX) { - (true, true) => g.add_edge(idx[i] as usize, idx[j] as usize, wt, wt), - // Neighbour fixed: disagreeing with it costs `wt`. - (true, false) => add_fixed_link(&mut g, idx[i] as usize, fixed[j], wt), - (false, true) => add_fixed_link(&mut g, idx[j] as usize, fixed[i], wt), - (false, false) => {} - } - } - } - } - g.maxflow(); - for i in 0..n { - if idx[i] != u32::MAX { - out[i] = g.in_source(idx[i] as usize); - } - } - out + grid_cut_weighted::(img, cost_fg, cost_bg, fixed, |d2| gamma * (-beta * d2).exp()) } /// [`grid_cut`] with any edge weight: separating neighbours costs `weight(‖z_p−z_q‖²) / dist(p,q)`. +/// Only links touching a free pixel need a weight; fixed/fixed links cannot change the cut. pub fn grid_cut_with(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[u8], weight: impl Fn(f32) -> f32) -> Vec { + grid_cut_weighted::(img, cost_fg, cost_bg, fixed, weight) +} + +mod edge_starts; + +// The const parameter also runs the previous full scan in before/after workflow tests. +fn grid_cut_weighted(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[u8], weight: impl Fn(f32) -> f32) -> Vec { let (w, h) = (img.w as i32, img.h as i32); - let n = img.w * img.h; + let Some(n) = img.w.checked_mul(img.h).filter(|&n| n < u32::MAX as usize - 2) else { return Vec::new() }; + if img.w > i32::MAX as usize || img.h > i32::MAX as usize || [img.px.len(), cost_fg.len(), cost_bg.len(), fixed.len()].iter().any(|&len| len < n) { + return Vec::new(); + } + let mut free = Vec::new(); let mut idx = vec![u32::MAX; n]; let mut count = 0u32; for i in 0..n { @@ -304,27 +270,47 @@ pub fn grid_cut_with(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[ g.add_tweights(idx[i] as usize, cost_bg[i], cost_fg[i]); } } - for y in 0..h { - for x in 0..w { - let i = (y * w + x) as usize; - let a = img.px[i]; - for (dx, dy, len) in NB { - let (nx, ny) = (x + dx, y + dy); - if nx < 0 || nx >= w || ny >= h { - continue; - } - let j = (ny * w + nx) as usize; - let wt = weight(d2(a, img.px[j])) / len; - match (idx[i] != u32::MAX, idx[j] != u32::MAX) { - (true, true) => g.add_edge(idx[i] as usize, idx[j] as usize, wt, wt), - (true, false) => add_fixed_link(&mut g, idx[i] as usize, fixed[j], wt), - (false, true) => add_fixed_link(&mut g, idx[j] as usize, fixed[i], wt), - (false, false) => {} - } + let use_sparse = OPTIMIZED && (count as usize) < n / 8; + if use_sparse { + free.extend((0..n).filter(|&i| idx[i] != u32::MAX)) + } + let mut visit = |i: usize, x: i32, y: i32| { + let a = img.px[i]; + for (dx, dy, len) in NB { + let (nx, ny) = (x + dx, y + dy); + if nx < 0 || nx >= w || ny >= h { + continue; + } + let j = (ny as usize) * img.w + nx as usize; + if OPTIMIZED && idx[i] == u32::MAX && idx[j] == u32::MAX { + continue; + } + let wt = weight(d2(a, img.px[j])) / len; + match (idx[i] != u32::MAX, idx[j] != u32::MAX) { + (true, true) => g.add_edge(idx[i] as usize, idx[j] as usize, wt, wt), + (true, false) => add_fixed_link(&mut g, idx[i] as usize, fixed[j], wt), + (false, true) => add_fixed_link(&mut g, idx[j] as usize, fixed[i], wt), + (false, false) => {} + } + } + }; + if use_sparse { + for i in edge_starts::Starts::new(&free, img.w) { + visit(i, (i % img.w) as i32, (i / img.w) as i32); + } + } else { + // Keep the original dense raster traversal, including its arithmetic order. + for y in 0..h { + for x in 0..w { + visit((y as usize) * img.w + x as usize, x, y) } } } - g.maxflow(); + if OPTIMIZED { + g.maxflow(); + } else { + g.maxflow_bk(); + } for i in 0..n { if idx[i] != u32::MAX { out[i] = g.in_source(idx[i] as usize); @@ -686,3 +672,6 @@ pub fn subsample(v: &[T], max: usize) -> Vec { #[cfg(test)] mod tests; + +#[cfg(test)] +mod workflow_tests; diff --git a/crates/algo/src/segment/quick.rs b/crates/algo/src/segment/quick.rs index ec9947d220..859575b2cc 100644 --- a/crates/algo/src/segment/quick.rs +++ b/crates/algo/src/segment/quick.rs @@ -84,6 +84,10 @@ pub fn quick_select(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f32)], /// [`quick_select`] with explicit model constants. pub fn quick_select_with(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f32)], size: f32, t: &Tuning) -> Option { + quick_select_using::(sampler, canvas, points, size, t) +} + +pub(super) fn quick_select_using(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f32)], size: f32, t: &Tuning) -> Option { if points.is_empty() || canvas.is_empty() { return None; } @@ -120,7 +124,7 @@ pub fn quick_select_with(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f3 // them is the best over the whole canvas too (outside the window it could only add cost). // One that reaches a side inside the canvas is recomputed in a larger window. let inner = [window.x0 > canvas.x0, window.y0 > canvas.y0, window.x1 < canvas.x1, window.y1 < canvas.y1]; - let low = select_working(&img, &seeds, rw, t); + let low = select_working_using::(&img, &seeds, rw, t); let touches = |side: usize| match side { 0 => (0..lh).any(|y| low[y * lw]), 1 => (0..lw).any(|x| low[x]), @@ -139,6 +143,10 @@ pub fn quick_select_with(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f3 /// The model at working resolution: `seeds` (the brush footprint, `radius` working pixels) are /// selected; returns the selection. pub fn select_working(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -> Vec { + select_working_using::(img, seeds, radius, t) +} + +fn select_working_using(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -> Vec { let (w, h) = (img.w, img.h); let d = crate::selection::edt(seeds, w, h); let grow = (t.grow + t.grow_frac * radius).max(0.0); @@ -146,7 +154,7 @@ pub fn select_working(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) - let fixed: Vec = d.iter().map(|d| if *d <= grow { HARD_FG } else { FREE }).collect(); let (floor, sigma, k) = (t.floor, t.sigma.max(1e-4), t.k); let weight = move |d2: f32| floor + 1.0 / (1.0 + (d2.sqrt() / sigma).powf(k)); - cut_levels(img, &fixed, &cost_fg, &weight, 1.0, t.direct_px.max(1024)) + cut_levels::(img, &fixed, &cost_fg, &weight, 1.0, t.direct_px.max(1024)) } /// Band half-width (pixels) re-cut at each finer level. @@ -156,11 +164,11 @@ const BAND: f32 = 3.0; /// problem (colours averaged, pixel costs summed, cut weights doubled since a coarse side spans two /// fine ones) gives a boundary, and the full-size cut is redone only within `BAND` of it. Seeds /// stay foreground. -fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f32], weight: &dyn Fn(f32) -> f32, scale: f32, direct_px: usize) -> Vec { +fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f32], weight: &dyn Fn(f32) -> f32, scale: f32, direct_px: usize) -> Vec { let (w, h) = (img.w, img.h); let zeros = vec![0.0f32; w * h]; if w * h <= direct_px || w < 8 || h < 8 { - return super::grid_cut_with(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2)); + return super::grid_cut_weighted::(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2)); } let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); let coarse_img = img.downsample(2); @@ -175,7 +183,7 @@ fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f32], weight: &dyn Fn(f32 } } } - let coarse = cut_levels(&coarse_img, &cfix, &ccost, weight, scale * 2.0, direct_px); + let coarse = cut_levels::(&coarse_img, &cfix, &ccost, weight, scale * 2.0, direct_px); let proj: Vec = (0..w * h).map(|i| coarse[(i / w / 2) * cw + (i % w) / 2]).collect(); // Band: within BAND of the projected boundary. let edge: Vec = (0..w * h) @@ -202,7 +210,7 @@ fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f32], weight: &dyn Fn(f32 } }) .collect(); - super::grid_cut_with(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2)) + super::grid_cut_weighted::(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2)) } /// Pixels within `radius` (working pixels) of the polyline `pts`, plus each point's own pixel. diff --git a/crates/algo/src/segment/workflow_tests.rs b/crates/algo/src/segment/workflow_tests.rs new file mode 100644 index 0000000000..ca3be5b40f --- /dev/null +++ b/crates/algo/src/segment/workflow_tests.rs @@ -0,0 +1,176 @@ +use super::*; +#[test] +#[ignore = "release comparison of actual Quick Selection strokes on 24-36 MP documents"] +#[allow(clippy::assertions_on_constants)] +fn quick_selection_workflow_release_comparison() { + use std::{hint::black_box, time::Instant}; + assert!(!cfg!(debug_assertions), "run with --release"); + for (w, h) in [(6000usize, 4000usize), (7200, 5000)] { + for (name, size, points, ow, oh) in [ + ("click", 30.0f32, vec![(0.0, 0.0)], 220.0f32, 220.0f32), + ("long-stroke", 256.0, vec![(-1000.0, 0.0), (1000.0, 0.0)], 2400.0, 1000.0), + ( + "paint-object", + 180.0, + vec![(-1000.0, -400.0), (1000.0, -400.0), (1000.0, 0.0), (-1000.0, 0.0), (-1000.0, 400.0), (1000.0, 400.0)], + 2400.0, + 1200.0, + ), + ] { + let (cx, cy) = (w as f32 * 0.5, h as f32 * 0.5); + let points: Vec<_> = points.iter().map(|&(x, y)| (x + cx, y + cy)).collect(); + let img = RgbImage::from_fn(w, h, |x, y| { + let inside = (x as f32 - cx).abs() < ow * 0.5 && (y as f32 - cy).abs() < oh * 0.5; + let noise = ((x * 13 + y * 7) % 31) as f32 / 620.0; + if inside { [0.8 + noise, 0.1 + noise, 0.1] } else { [0.1, 0.1 + noise, 0.7 + noise] } + }); + let sampler = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, w as i32, h as i32); + let tune = quick::Tuning::default(); + let old = quick::quick_select_using::(&sampler, canvas, &points, size, &tune).unwrap(); + let new = quick::quick_select_using::(&sampler, canvas, &points, size, &tune).unwrap(); + assert_eq!(new.bbox, old.bbox); + assert_eq!(new.mask, old.mask); + let (mut old_ms, mut new_ms) = (Vec::new(), Vec::new()); + for run in 0..3 { + let mut measure = |fast: bool| { + let start = Instant::now(); + let out = if fast { + quick::quick_select_using::(&sampler, canvas, black_box(&points), size, &tune) + } else { + quick::quick_select_using::(&sampler, canvas, black_box(&points), size, &tune) + } + .unwrap(); + let ms = start.elapsed().as_secs_f64() * 1000.0; + assert_eq!(out.bbox, old.bbox); + assert_eq!(out.mask, old.mask); + if fast { new_ms.push(ms) } else { old_ms.push(ms) } + }; + measure(run % 2 == 0); + measure(run % 2 != 0); + } + old_ms.sort_by(f64::total_cmp); + new_ms.sort_by(f64::total_cmp); + println!("QUICK {w}x{h} case={name} brush={size} old_ms={old_ms:?} new_ms={new_ms:?} speedup={:.2} bbox={:?}", old_ms[1] / new_ms[1], old.bbox); + } + } +} + +#[test] +fn quick_selection_preserves_textures_low_contrast_and_thin_features() { + let (w, h) = (360usize, 280usize); + let canvas = Rect::new(0, 0, w as i32, h as i32); + for kind in 0..6 { + let img = RgbImage::from_fn(w, h, |x, y| { + let inside = if kind == 2 { + (x as f32 - 180.0).hypot(y as f32 - 140.0) < 72.0 + } else if kind == 3 { + y > 130 && y < 145 + } else { + x > 60 && x < 300 && y > 60 && y < 220 + }; + let t = ((x * 23 + y * 19) % 101) as f32 / 100.0; + match kind { + 0 => { + if inside { + [0.6 + t * 0.1, 0.3, 0.2] + } else { + [0.4, 0.3 + t * 0.1, 0.2] + } + } + 1 => [0.4 + x as f32 / 1800.0, 0.3 + y as f32 / 1400.0, t * 0.02], + 4 => [t, ((x * 7 + y * 31) % 79) as f32 / 78.0, 0.4], + 5 => [0.4; 3], + _ => { + if inside { + [0.8 + t * 0.1, 0.3, 0.2] + } else { + [0.2, 0.3 + t * 0.1, 0.8] + } + } + } + }); + let sampler = ImageSampler { img: &img, origin: (0, 0) }; + for (points, brush) in [(vec![(180.0, 140.0)], 12.0), (vec![(90.0, 140.0), (270.0, 140.0)], 40.0), (vec![(1.0, 1.0), (160.0, 130.0)], 64.0)] { + for direct_px in [10_000, 300_000] { + let tune = quick::Tuning { direct_px, ..quick::Tuning::default() }; + let old = quick::quick_select_using::(&sampler, canvas, &points, brush, &tune); + let new = quick::quick_select_using::(&sampler, canvas, &points, brush, &tune); + match (old, new) { + (Some(a), Some(b)) => { + assert_eq!(a.bbox, b.bbox, "kind={kind}"); + assert_eq!(a.mask, b.mask, "kind={kind} brush={brush} direct={direct_px}"); + } + (None, None) => {} + _ => panic!("different empty result kind={kind}"), + } + } + } + } +} + +#[test] +#[ignore = "release comparison through editor raster surfaces on 24-36 MP documents"] +#[allow(clippy::assertions_on_constants)] +fn quick_selection_surface_release_comparison() { + use photocraft_color::PixelFormat; + use std::{hint::black_box, time::Instant}; + assert!(!cfg!(debug_assertions), "run with --release"); + for (w, h) in [(6000usize, 4000usize), (7200, 5000)] { + for format in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0); + let canvas = Rect::new(0, 0, w as i32, h as i32); + let mut surface = Surface::new(format); + // Fill the document in bounded row strips, outside the measured strokes. + for y0 in (0..h).step_by(256) { + let y1 = (y0 + 256).min(h); + let data: Vec<_> = (y0..y1) + .flat_map(|y| { + (0..w).flat_map(move |x| { + let noise = ((x * 13 + y * 7) % 31) as f32 / 620.0; + if (x as f32 - cx).abs() < 1200.0 && (y as f32 - cy).abs() < 600.0 { + [0.8 + noise, 0.1 + noise, 0.1, 1.0] + } else { + [0.1, 0.1 + noise, 0.7 + noise, 1.0] + } + }) + }) + .collect(); + surface.write_region(Rect::new(0, y0 as i32, w as i32, y1 as i32), &data); + } + let sampler = SurfaceSampler(&surface); + let points = [ + (cx - 1000.0, cy - 400.0), + (cx + 1000.0, cy - 400.0), + (cx + 1000.0, cy), + (cx - 1000.0, cy), + (cx - 1000.0, cy + 400.0), + (cx + 1000.0, cy + 400.0), + ]; + let tune = quick::Tuning::default(); + let old = quick::quick_select_using::(&sampler, canvas, &points, 180.0, &tune).unwrap(); + let new = quick::quick_select_using::(&sampler, canvas, &points, 180.0, &tune).unwrap(); + assert_eq!(new.bbox, old.bbox); + assert_eq!(new.mask, old.mask); + let (mut before, mut after) = (Vec::new(), Vec::new()); + for run in 0..5 { + for fast in [run % 2 == 0, run % 2 != 0] { + let start = Instant::now(); + let out = if fast { + quick::quick_select_using::(&sampler, canvas, black_box(&points), 180.0, &tune) + } else { + quick::quick_select_using::(&sampler, canvas, black_box(&points), 180.0, &tune) + } + .unwrap(); + let ms = start.elapsed().as_secs_f64() * 1000.0; + assert_eq!(out.bbox, old.bbox); + assert_eq!(out.mask, old.mask); + if fast { after.push(ms) } else { before.push(ms) } + } + } + before.sort_by(f64::total_cmp); + after.sort_by(f64::total_cmp); + println!("SURFACE {w}x{h} format={format:?} old_ms={before:?} new_ms={after:?} speedup={:.2}", before[2] / after[2]); + } + } +} diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index 4ecc1aa80d..3649b1c658 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -728,11 +728,20 @@ pub fn contract(m: &[f32], w: usize, h: usize, r: f32) -> Vec { expand(&inv, w, h, r).into_iter().map(|v| 1.0 - v).collect() } -/// A band of width `r` straddling the selection edge. +/// Select › Modify › Border: a soft band `2r` wide centred on the selection edge, as in +/// Photoshop. A pixel `D` away (centre to centre) from the nearest pixel on the other side of the +/// edge is selected `1 − (D − 1) / r`: fully at the edge, fading to nothing `r` pixels out on +/// either side (measured on Photoshop 27.11, Border 4: 63, 127, 191, 255 | 255, 191, 127, 63 +/// across a straight edge, the same along the inside of a corner). It used to be a hard band +/// `r` wide. pub fn border(m: &[f32], w: usize, h: usize, r: f32) -> Vec { - let outer = expand(m, w, h, r / 2.0); - let inner = contract(m, w, h, r / 2.0); - outer.iter().zip(inner).map(|(o, i)| (o - i).max(0.0)).collect() + let inside: Vec = m.iter().map(|v| *v >= 0.5).collect(); + let outside: Vec = inside.iter().map(|v| !v).collect(); + let (to_inside, to_outside) = (edt(&inside, w, h), edt(&outside, w, h)); + if r <= 0.0 || to_inside.len() != m.len() || to_outside.len() != m.len() { + return vec![0.0; m.len()]; + } + inside.iter().zip(to_inside.iter().zip(&to_outside)).map(|(&sel, (&di, &dout))| (1.0 - ((if sel { dout } else { di }) - 1.0) / r).clamp(0.0, 1.0)).collect() } /// Smooth: removes specks and rounds corners (box average of radius `round(r)`, edges @@ -741,7 +750,7 @@ pub fn smooth(m: &[f32], w: usize, h: usize, r: f32) -> Vec { crate::selection_blur::smooth(m, w, h, r) } -/// Feather: Gaussian blur of the mask with sigma = radius / 2 (kernel truncated at 3σ, zero +/// Feather: Gaussian blur of the mask with sigma = radius, as in Photoshop (kernel truncated at 3σ, zero /// beyond the canvas). Parallel, confined to the selection's bounds, and independent of the /// radius (#211). pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec { @@ -1098,6 +1107,22 @@ mod tests { assert!(b[10 * 20 + 5] > 0.5 && b[10 * 20 + 4] > 0.5); } + /// Photoshop 27.11, measured: a 32×32 rectangle at (16,16) in 64×64, Select › Modify › + /// Border 4 px, filled white. Row y = 32 for x = 8..=24, and the diagonal (16,16)..(24,24). + #[test] + fn border_matches_photoshop() { + let (w, h) = (64, 64); + let m: Vec = (0..w * h).map(|i| if (16..48).contains(&(i % w)) && (16..48).contains(&(i / w)) { 1.0 } else { 0.0 }).collect(); + let b = border(&m, w, h, 4.0); + let v = |x: usize, y: usize| (b[y * w + x] * 255.0).round() as i32; + let row: Vec = (8..=24).map(|x| v(x, 32)).collect(); + let diag: Vec = (16..=24).map(|d| v(d, d)).collect(); + let near = |got: &[i32], want: &[i32]| got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1); + let want_row = [0, 0, 0, 0, 63, 127, 191, 255, 255, 191, 127, 63, 0, 0, 0, 0, 0]; + assert!(near(&row, &want_row), "{row:?}"); + assert!(near(&diag, &[255, 191, 127, 63, 0, 0, 0, 0, 0]), "{diag:?}"); + } + #[test] fn smooth_removes_specks_and_feather_softens() { let mut m = square(20, 20); @@ -1105,7 +1130,8 @@ mod tests { let s = smooth(&m, 20, 20, 1.0); assert_eq!(s[2 * 20 + 2], 0.0); assert_eq!(s[10 * 20 + 10], 1.0); - let f = feather(&square(20, 20), 20, 20, 4.0); + // Radius 2 (σ 2) keeps the blur inside the 20×20 canvas, so no coverage is lost. + let f = feather(&square(20, 20), 20, 20, 2.0); let edge = f[10 * 20 + 5]; assert!(edge > 0.3 && edge < 0.8, "{edge}"); let sum: f32 = f.iter().sum(); diff --git a/crates/algo/src/selection/distance/tests.rs b/crates/algo/src/selection/distance/tests.rs index fe6cef83f3..28cef11910 100644 --- a/crates/algo/src/selection/distance/tests.rs +++ b/crates/algo/src/selection/distance/tests.rs @@ -80,7 +80,9 @@ fn inconsistent_and_empty_dimensions_do_not_panic() { #[test] fn selection_modifiers_keep_fractional_coverage() { - use crate::selection::{border, contract, expand}; + // Border is no longer expand − contract (it is Photoshop's soft band, checked against + // measurements in `selection::tests::border_matches_photoshop`), so only these two remain. + use crate::selection::{contract, expand}; let (w, h) = (47, 39); let m: Vec = (0..w * h).map(|i| if (8..31).contains(&(i % w)) && (5..27).contains(&(i / w)) { 0.8 } else { 0.2 }).collect(); let reference_expand = |m: &[f32], r: f32| { @@ -92,9 +94,6 @@ fn selection_modifiers_keep_fractional_coverage() { let inverse: Vec<_> = m.iter().map(|v| 1.0 - v).collect(); let contracted: Vec<_> = reference_expand(&inverse, r).iter().map(|v| 1.0 - v).collect(); assert_eq!(contract(&m, w, h, r), contracted); - let outer = reference_expand(&m, r / 2.0); - let inner: Vec<_> = reference_expand(&inverse, r / 2.0).iter().map(|v| 1.0 - v).collect(); - assert_eq!(border(&m, w, h, r), outer.iter().zip(inner).map(|(o, i)| (o - i).max(0.0)).collect::>()); } } diff --git a/crates/algo/src/selection_blur.rs b/crates/algo/src/selection_blur.rs index 1ab57dfea4..2a4100ac29 100644 --- a/crates/algo/src/selection_blur.rs +++ b/crates/algo/src/selection_blur.rs @@ -4,7 +4,7 @@ //! //! * **Smooth** is a box average with edge-replicated borders: a running sum per line, `O(1)` per //! pixel whatever the radius. -//! * **Feather** is a Gaussian (`σ = radius / 2`, kernel truncated at `⌈3σ⌉` and normalized, zero +//! * **Feather** is a Gaussian (`σ = radius`, as Photoshop's, kernel truncated at `⌈3σ⌉` and normalized, zero //! outside the canvas). Small kernels are convolved directly. Wide ones are written as a short //! cosine series over the kernel window, `k(j) ≈ a₀ + Σ aₖ cos(ωₖ j)`, and every term is a //! sliding windowed sum `Σ m[x+j]·e^{iωₖj}` updated in `O(1)` per pixel, so the cost doesn't grow @@ -155,10 +155,13 @@ fn box_line(src: &[f32], dst: &mut [f32], r: usize) { } } -/// Select › Modify › Smooth: box average of radius `round(r)` (edges replicated), then the -/// contrast curve `(v − ½)·4 + ½` that re-hardens the edge and drops specks. +/// Select › Modify › Smooth: box average of radius `round(r)` (edges replicated), then a hard +/// threshold at ½, as in Photoshop: straight edges stay exactly where they were, corners round +/// off and specks drop (measured on Photoshop 27.11, Smooth 8 on a 32 px square: every sampled +/// pixel 0 or 255, corner cut where the 17×17 average crosses ½). The contrast curve +/// `(v − ½)·4 + ½` used before left grey, half-selected pixels along every edge. pub(crate) fn smooth(m: &[f32], w: usize, h: usize, r: f32) -> Vec { - let curve = |v: f32| ((v - 0.5) * 4.0 + 0.5).clamp(0.0, 1.0); + let curve = |v: f32| if v >= 0.5 { 1.0 } else { 0.0 }; let n = w.saturating_mul(h); if m.len() != n || n == 0 { return m.iter().map(|v| curve(*v)).collect(); @@ -539,12 +542,15 @@ impl CosKernel { } } -/// Select › Modify › Feather: Gaussian blur of the mask with `σ = radius / 2`, the kernel -/// truncated at `⌈3σ⌉` and normalized, zero beyond the canvas. +/// Select › Modify › Feather: Gaussian blur of the mask with `σ = radius`, the kernel truncated +/// at `⌈3σ⌉` and normalized, zero beyond the canvas. Photoshop's feather radius is the Gaussian's +/// σ (measured on Photoshop 27.11; the same mapping the mask feather uses, fitted on the +/// psd-tools corpus in `photocraft_compose::masks::feather_sigma`). It was `radius / 2`: half +/// as wide as Photoshop's. pub(crate) fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec { let n = w.saturating_mul(h); let sigma = if radius.is_finite() { - radius.max(0.0) / 2.0 + radius.max(0.0) } else if radius > 0.0 { MAX_SIGMA } else { @@ -614,11 +620,11 @@ mod tests { } pub fn smooth(m: &[f32], w: usize, h: usize, r: f32) -> Vec { - box_blur(m, w, h, r.max(0.0).round() as usize).into_iter().map(|v| ((v - 0.5) * 4.0 + 0.5).clamp(0.0, 1.0)).collect() + box_blur(m, w, h, r.max(0.0).round() as usize).into_iter().map(|v| if v >= 0.5 { 1.0 } else { 0.0 }).collect() } pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec { - let sigma = radius.max(0.0) / 2.0; + let sigma = radius.max(0.0); if sigma < 0.1 { return m.to_vec(); } @@ -680,6 +686,27 @@ mod tests { vec![("binary", binary), ("soft", soft), ("blob", blob), ("sparse", sparse), ("rect", rect)] } + /// Photoshop 27.11, measured: a 32×32 rectangle at (16,16) in 64×64, Select › Modify › + /// Feather 4 px, filled white. Row y = 32 for x = 8..=24, then (16,16), (18,18), (20,20). + #[test] + fn feather_matches_photoshop() { + const ROW: [u8; 17] = [7, 13, 22, 34, 50, 70, 92, 115, 140, 163, 185, 205, 221, 233, 242, 248, 252]; + const DIAG: [(usize, u8); 3] = [(16, 76), (18, 134), (20, 190)]; + let (w, h) = (64, 64); + let m: Vec = (0..w * h).map(|i| if (16..48).contains(&(i % w)) && (16..48).contains(&(i / w)) { 1.0 } else { 0.0 }).collect(); + let f = feather(&m, w, h, 4.0); + let v = |x: usize, y: usize| (f[y * w + x] * 255.0).round() as i32; + for (i, want) in ROW.iter().enumerate() { + let x = 8 + i; + assert!((v(x, 32) - i32::from(*want)).abs() <= 3, "x {x}: {} vs Photoshop {want}", v(x, 32)); + } + // Photoshop's corners come out a few levels darker than a true 2-D Gaussian's; the row + // is the defining measurement. + for (d, want) in DIAG { + assert!((v(d, d) - i32::from(want)).abs() <= 4, "({d},{d}): {} vs Photoshop {want}", v(d, d)); + } + } + #[test] fn feather_matches_direct_gaussian() { for (w, h, seed) in [(97, 61, 1), (40, 150, 2), (13, 7, 3)] { @@ -692,13 +719,32 @@ mod tests { } } + /// Photoshop 27.11, measured: a 32×32 rectangle at (16,16) in 64×64, Select › Modify › + /// Smooth 8 px, filled white. The straight edge stays hard at x = 16; only the corner rounds. + #[test] + fn smooth_matches_photoshop() { + let (w, h) = (64, 64); + let m: Vec = (0..w * h).map(|i| if (16..48).contains(&(i % w)) && (16..48).contains(&(i / w)) { 1.0 } else { 0.0 }).collect(); + let s = smooth(&m, w, h, 8.0); + let row: Vec = (8..=24).map(|x| s[32 * w + x]).collect(); + let want_row: Vec = (8..=24).map(|x| if x >= 16 { 1.0 } else { 0.0 }).collect(); + assert_eq!(row, want_row); + let diag: Vec = (16..=24).map(|d| s[d * w + d]).collect(); + assert_eq!(diag, [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0]); + } + #[test] fn smooth_matches_direct_box() { for (w, h, seed) in [(97, 61, 4), (40, 150, 5), (13, 7, 6)] { for (kind, m) in masks(w, h, seed) { for radius in [0.0, 0.4, 1.0, 2.0, 5.0, 16.0, 50.0, 500.0] { - let d = max_diff(&smooth(&m, w, h, radius), &oracle::smooth(&m, w, h, radius)); - assert!(d <= 1.0 / 255.0, "{kind} {w}x{h} r {radius}: {d}"); + // The threshold is hard, so a pixel may only differ where the box average sits + // within float rounding of ½ (the running sum adds in another order). + let (fast, slow) = (smooth(&m, w, h, radius), oracle::smooth(&m, w, h, radius)); + let avg = oracle::box_blur(&m, w, h, radius.max(0.0).round() as usize); + for (i, (a, b)) in fast.iter().zip(&slow).enumerate() { + assert!(a == b || (avg[i] - 0.5).abs() < 1e-4, "{kind} {w}x{h} r {radius} pixel {i}: {a} vs {b} (average {})", avg[i]); + } } } } diff --git a/crates/algo/src/tests_iris.rs b/crates/algo/src/tests_iris.rs new file mode 100644 index 0000000000..1f930c172d --- /dev/null +++ b/crates/algo/src/tests_iris.rs @@ -0,0 +1,222 @@ +use super::*; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_raster::{Interrupt, Surface}; + +#[path = "tests_iris_reference.rs"] +mod reference; + +fn image(rect: Rect, ch: usize) -> Image { + let data = (rect.y0..rect.y1) + .flat_map(|y| { + (rect.x0..rect.x1).flat_map(move |x| { + (0..ch).map(move |c| match c { + 0 => ((x * 17 + y * 31).rem_euclid(113)) as f32 / 37.0 - 0.5, + 1 => { + if (x + y).rem_euclid(3) == 0 { + 1.0 + } else { + 0.0 + } + } + _ => ((x ^ y).unsigned_abs() as usize + c) as f32 % 23.0 / 22.0, + }) + }) + }) + .collect(); + Image { rect, ch, data } +} + +fn check(src: &Image, out: Rect, ctx: &Ctx, pins: &[IrisPin]) { + let want = reference::iris(src, out, ctx, pins); + let got = iris(src, out, ctx, pins); + assert_eq!(got.len(), want.len()); + for (i, (&got, &want)) in got.iter().zip(&want).enumerate() { + assert!(got.to_bits() == want.to_bits() || (got.is_nan() && want.is_nan()), "sample {i}: {got} != {want}, delta {}", (got - want).abs()); + } +} + +#[test] +fn iris_matches_frozen_kernel_at_transitions_and_edges() { + let bounds = Rect::new(-19, 7, 54, 66); + let mut cases = vec![vec![], vec![IrisPin::default()]]; + for blur in [0.0, 0.49, 0.5, 1.0, 15.0, 80.0, 500.0] { + for roundness in [0.0, 0.01, 37.0, 100.0] { + cases.push(vec![IrisPin { x: 0.18, y: 0.91, radius_x: 0.45, radius_y: 0.13, angle: 71.0, feather: 0.998, roundness, blur }]); + } + } + cases.push(vec![ + IrisPin { x: 0.2, blur: 30.0, angle: -43.0, ..IrisPin::default() }, + IrisPin { x: 0.7, y: 0.1, blur: 11.0, roundness: 80.0, ..IrisPin::default() }, + ]); + cases.push(vec![IrisPin { radius_x: 8.0, radius_y: 9.0, ..IrisPin::default() }]); + cases.push(vec![IrisPin { x: -5.0, ..IrisPin::default() }]); + for ch in 1..=6 { + let src = image(bounds.inflate(12), ch); + for alpha in [false, true] { + let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha }; + for pins in &cases { + check(&src, bounds, &ctx, pins); + check(&src, Rect::new(-7, 17, 15, 39), &ctx, pins); + } + } + } +} + +#[test] +fn iris_preserves_degenerate_and_nonfinite_pin_behavior() { + for bounds in [Rect::EMPTY, Rect::new(1, -1, 2, 0), Rect::new(-5, 3, 14, 4)] { + let src = image(bounds, 4); + let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true }; + for value in [0.0, -1.0, f32::MIN, f32::MAX, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + for field in 0..9 { + let mut p = IrisPin::default(); + match field { + 0 => p.x = value, + 1 => p.y = value, + 2 => p.radius_x = value, + 3 => p.radius_y = value, + 4 => p.angle = value, + 5 => p.feather = value, + 6 => p.blur = value, + 7 => p.roundness = value, + _ => { + p.radius_x = value; + p.radius_y = value; + } + } + check(&src, bounds, &ctx, &[p]); + } + } + } +} + +#[test] +fn iris_keeps_running_sum_order_for_extreme_hdr_and_signed_zero() { + let bounds = Rect::new(-13, -5, 24, 28); + let mut src = image(bounds.inflate(15), 4); + let values = [0.0, -0.0, 1e30, -1e30, 1e-30, -1e-30, 0.8]; + for (i, px) in src.data.as_chunks_mut::<4>().0.iter_mut().enumerate() { + px[0] = values[i % values.len()]; + px[1] = values[(i + 3) % values.len()]; + px[3] = [0.0, 1e-8, 0.5, 1.0][i % 4]; + } + let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true }; + for blur in [0.5, 15.0, 80.0, 300.0] { + let pins = [IrisPin { angle: 37.0, roundness: 55.0, blur, ..IrisPin::default() }]; + check(&src, bounds, &ctx, &pins); + check(&src, Rect::new(0, 0, 10, 17), &ctx, &pins); + } + for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let mut invalid_samples = src.clone(); + invalid_samples.data[19] = value; + invalid_samples.data[47] = value; + check(&invalid_samples, bounds, &ctx, &[IrisPin::default()]); + } + let pins = vec![IrisPin::default(); 4096]; + check(&src, Rect::new(0, 0, 1, 1), &ctx, &pins); + let malformed = Image { rect: bounds, ch: 4, data: vec![] }; + assert!(iris(&malformed, bounds, &ctx, &[IrisPin::default()]).is_empty()); + let no_channels = Image { rect: bounds, ch: 0, data: vec![] }; + assert!(iris(&no_channels, bounds, &ctx, &[IrisPin::default()]).is_empty()); +} + +#[test] +fn iris_cancellation_after_progress_preserves_source() { + use std::sync::atomic::{AtomicBool, Ordering}; + let bounds = Rect::new(0, 0, 512, 256); + let mut source = Surface::new(PixelFormat::RGBA8); + source.write_region(bounds, &image(bounds, 4).data); + let original = source.clone(); + let cancelled = AtomicBool::new(false); + let cancel = || cancelled.load(Ordering::Relaxed); + let progress = |p| { + if p > 0.0 { + cancelled.store(true, Ordering::Relaxed); + } + }; + let ctl = Interrupt::new(&cancel, &progress); + let params = crate::FilterParams::IrisBlur { pins: vec![IrisPin::default()] }; + assert!(crate::apply_in_with(&source, ¶ms, bounds, bounds, None, bounds, &ctl).is_none()); + assert!(cancelled.load(Ordering::Relaxed)); + assert_eq!(source, original); +} + +#[test] +fn iris_depths_models_selection_tiles_and_cancellation() { + let bounds = Rect::new(-17, 11, 96, 84); + let area = Rect::new(-9, 17, 79, 77); + let pins = vec![IrisPin { angle: 29.0, roundness: 35.0, blur: 17.0, ..IrisPin::default() }]; + let params = crate::FilterParams::IrisBlur { pins: pins.clone() }; + let mut selection = Surface::new(PixelFormat::GRAY8); + selection.fill_rect(bounds, &[0.4]); + selection.fill_rect(Rect::new(3, 19, 35, 44), &[1.0]); + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + for alpha in [false, true] { + let fmt = PixelFormat::new(mode, sample, alpha); + let mut surface = Surface::new(fmt); + surface.write_region(bounds, &image(bounds, fmt.channels()).data); + let ctl = Interrupt::cancel_only(&|| true); + assert!(crate::apply_in_with(&surface, ¶ms, area, bounds, Some(&selection), bounds, &ctl).is_none()); + let original = surface.clone(); + let ctx = Ctx { bounds, mode, alpha }; + let radius = reach(17.0).ceil() as i32 + 1; + assert_eq!(params.halo_for(bounds), crate::Halo::Radius(radius)); + for tile in [16, 47, 128] { + let got = crate::apply_tiled(&surface, ¶ms, area, bounds, Some(&selection), tile, Some(bounds)); + let mut want = surface.clone(); + for y in (area.y0..area.y1).step_by(tile as usize) { + for x in (area.x0..area.x1).step_by(tile as usize) { + let t = Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1)); + let src = Image::read_clamped(&surface, t.inflate(radius), bounds); + let mut data = reference::iris(&src, t, &ctx, &pins); + crate::mix_selection(&mut data, t, &selection, &src); + want.write_region(t, &data); + } + } + assert_eq!(got.read_region(bounds), want.read_region(bounds), "{fmt:?}, tile {tile}"); + } + assert_eq!(surface, original); + } + } + } +} + +#[test] +#[ignore = "release profiling; run alone with --nocapture"] +fn iris_profile_baseline_stages() { + for blur in [15.0, 80.0, 300.0] { + let out = Rect::new(1500, 1000, 2012, 1512); + let bounds = Rect::new(0, 0, 6000, 4000); + let src = image(out.inflate(reach(blur).ceil() as i32), 4); + let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true }; + let pin = IrisPin { blur, angle: 27.0, roundness: 60.0, ..IrisPin::default() }; + let start = std::time::Instant::now(); + let sig: Vec = (0..out.width() as usize * out.height() as usize) + .map(|i| { + let (x, y) = xy(out, i); + let (e, _, _) = pin_distance(x as f32 + 0.5, y as f32 + 0.5, bounds, pin.x, pin.y, pin.radius_x, pin.radius_y, pin.angle, 5.6); + sigma_of(blur) * smoothstep(pin.feather, 1.0, e) + }) + .collect(); + println!("blur {blur}: geometry {:?}", start.elapsed()); + std::hint::black_box(sig); + let start = std::time::Instant::now(); + let p = premul_window(&src, src.rect, ctx.alpha); + println!("blur {blur}: premul {:?}", start.elapsed()); + for k in 0..LEVELS { + let mut buf = p.clone(); + let sigma = sigma_of(blur) / 2f32.powi((LEVELS - 1 - k) as i32); + let start = std::time::Instant::now(); + gauss_blur_n(&mut buf, src.rect.width() as usize, src.rect.height() as usize, src.ch, sigma); + println!("blur {blur}: level {sigma} {:?}", start.elapsed()); + std::hint::black_box(buf); + } + let start = std::time::Instant::now(); + std::hint::black_box(reference::iris(&src, out, &ctx, std::slice::from_ref(&pin))); + println!("blur {blur}: reference whole kernel {:?}", start.elapsed()); + let start = std::time::Instant::now(); + std::hint::black_box(iris(&src, out, &ctx, &[pin])); + println!("blur {blur}: candidate whole kernel {:?}", start.elapsed()); + } +} diff --git a/crates/algo/src/tests_iris_reference.rs b/crates/algo/src/tests_iris_reference.rs new file mode 100644 index 0000000000..8b61799ff8 --- /dev/null +++ b/crates/algo/src/tests_iris_reference.rs @@ -0,0 +1,107 @@ +//! Frozen Iris Blur kernel from base 7722172585a01cbdb93c06f0f5ff2634fcb17999. +use super::*; +fn sigma_of(blur: f32) -> f32 { + blur.clamp(0.0, 500.0) * 0.6 +} + +const LEVELS: usize = 6; + +/// Blurs `src` over `out` with a per-pixel σ (`sigma(x, y)`, ≤ `smax`). +fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, sigma: impl Fn(f32, f32) -> f32) -> Vec { + let n = src.ch; + if smax < 0.3 { + return src.crop(out); + } + let win = src.rect; + let (ww, wh) = (win.width() as usize, win.height() as usize); + let (ow, oh) = (out.width() as usize, out.height() as usize); + // Level σs: 0, then smax / 2^(LEVELS-1) … smax. + let lv: Vec = std::iter::once(0.0).chain((0..LEVELS).map(|k| smax / 2f32.powi((LEVELS - 1 - k) as i32))).collect(); + let sig: Vec = (0..ow * oh) + .map(|i| { + let (x, y) = xy(out, i); + sigma(x as f32 + 0.5, y as f32 + 0.5).clamp(0.0, smax) + }) + .collect(); + // Hat-function weight of level k for σ s. + let weight = |k: usize, s: f32| -> f32 { + let c = lv[k]; + if k > 0 && s >= lv[k - 1] && s <= c { + (s - lv[k - 1]) / (c - lv[k - 1]).max(1e-6) + } else if k + 1 < lv.len() && s >= c && s <= lv[k + 1] { + (lv[k + 1] - s) / (lv[k + 1] - c).max(1e-6) + } else if k + 1 == lv.len() && s >= c { + 1.0 + } else { + 0.0 + } + }; + let p = premul_window(src, win, ctx.alpha); + let mut acc = vec![0.0f32; ow * oh * n]; + let mut buf = Vec::new(); + for (k, &lvk) in lv.iter().enumerate() { + if !sig.iter().any(|&s| weight(k, s) > 0.0) { + continue; + } + let level: &[f32] = if k == 0 { + &p + } else { + buf.clear(); + buf.extend_from_slice(&p); + gauss_blur_n(&mut buf, ww, wh, n, lvk); + &buf + }; + for (i, &s) in sig.iter().enumerate() { + let wk = weight(k, s); + if wk <= 0.0 { + continue; + } + let (x, y) = xy(out, i); + let o = (((y - win.y0) as usize) * ww + (x - win.x0) as usize) * n; + for c in 0..n { + acc[i * n + c] += level[o + c] * wk; + } + } + } + for px in acc.chunks_exact_mut(n) { + unpremul_px(px, ctx.alpha); + } + acc +} + +fn short_side(b: Rect) -> f32 { + (b.width().min(b.height()) as f32).max(1.0) +} + +/// Normalized (super)elliptical distance of a point from an iris/spin pin. +#[allow(clippy::too_many_arguments)] +fn pin_distance(x: f32, y: f32, b: Rect, px: f32, py: f32, rx: f32, ry: f32, angle: f32, m: f32) -> (f32, f32, f32) { + let ss = short_side(b); + let (cx, cy) = (b.x0 as f32 + px * b.width() as f32, b.y0 as f32 + py * b.height() as f32); + let angle_deg = if angle.is_finite() { angle } else { 0.0 }; + let (s, c) = angle_deg.to_radians().sin_cos(); + let (dx, dy) = (x - cx, y - cy); + let (u, v) = (dx * c + dy * s, -dx * s + dy * c); + let (u, v) = (u / (rx.max(1e-3) * ss), v / (ry.max(1e-3) * ss)); + let e = if (m - 2.0).abs() < 1e-3 { (u * u + v * v).sqrt() } else { (u.abs().powf(m) + v.abs().powf(m)).powf(1.0 / m) }; + if !e.is_finite() { (f32::INFINITY, 0.0, 0.0) } else { (e, u, v) } +} + +/// Iris Blur: everything outside each pin's ellipse is blurred; the sharp +/// core is `feather` of the ellipse. Sharpness from any pin wins. +pub(crate) fn iris(src: &Image, out: Rect, ctx: &Ctx, pins: &[IrisPin]) -> Vec { + let b = ctx.bounds; + let smax = pins.iter().map(|p| sigma_of(p.blur)).fold(0.0, f32::max); + if pins.is_empty() { + return src.crop(out); + } + variable_blur(src, out, ctx, smax, |x, y| { + pins.iter() + .map(|p| { + let m = 2.0 + p.roundness.clamp(0.0, 100.0) / 100.0 * 6.0; + let (e, _, _) = pin_distance(x, y, b, p.x, p.y, p.radius_x, p.radius_y, p.angle, m); + sigma_of(p.blur) * smoothstep(p.feather.clamp(0.0, 0.999), 1.0, e) + }) + .fold(f32::MAX, f32::min) + }) +} diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index a9f3c74d24..bb31945d5d 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -126,19 +126,19 @@ fn catmull_rom(t: f64) -> [f64; 4] { /// Warp `src` (its content inside `src_rect`) by `h` (source → destination document space). /// The result has the same format as `src`, with alpha added if `src` had none; pixels outside /// the warped quad are transparent. -pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp) -> Surface { +pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp) -> Result { let mut fmt = src.format(); let converted; let mut src = if fmt.alpha { src } else { fmt = PixelFormat::new(fmt.mode, fmt.sample, true); - converted = src.convert(fmt); + converted = src.try_convert(fmt)?; &converted }; let mut out = Surface::new(fmt); if src_rect.is_empty() { - return out; + return Ok(out); } let mut h = *h; let mut src_rect = src_rect; @@ -153,12 +153,12 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter h = h.mul(&Homography([1.0 / f, 0.0, 0.0, 0.0, 1.0 / f, 0.0, 0.0, 0.0, 1.0])); src_rect = crate::resample::scaled_rect(src_rect, f, f); } - let Some(inv) = h.inverse() else { return out }; + let Some(inv) = h.inverse() else { return Ok(out) }; // Destination bounds: the warped corners (plus a pixel for filter support). let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)] .map(|(x, y)| h.apply(x as f64, y as f64)); if corners.iter().any(|c| !c.0.is_finite() || !c.1.is_finite()) { - return out; + return Ok(out); } let lim = 1 << 20; let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1; @@ -168,21 +168,58 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter let dst = Rect::new(bx0, by0, bx1, by1); let n = fmt.channels(); let a = n - 1; - let tiles: Vec = dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()).collect(); + let tile_count = dst.tiles().count(); + let tile_bytes = tile_count.checked_mul(std::mem::size_of::()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?; + let mut tiles = Vec::new(); + tiles.try_reserve_exact(tile_count).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: tile_bytes })?; + tiles.extend(dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty())); let src_ref: &Surface = src; - let work = |t: &Rect| -> Option<(Rect, Vec)> { - // Source footprint of this tile (inverse-mapped corners), padded for the filter. - let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| inv.apply(x as f64, y as f64)); - let fx0 = tc.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32 - 3; - let fy0 = tc.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32 - 3; - let fx1 = tc.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32 + 3; - let fy1 = tc.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32 + 3; - let foot = Rect::new(fx0, fy0, fx1, fy1).intersect(&src_rect); + // A sampled pixel's inverse w is 1 / (forward w at its source point). While the forward w keeps one sign over the source + // (plus the margin the sampler reads), every sampled pixel lies at least `w_lo` from the inverse horizon, so clipping a tile + // there keeps its footprint exact and bounded even when a steep Distort brings that horizon into the tile (#2271). + let (sx0, sy0, sx1, sy1) = (src_rect.x0 as f64 - 1.0, src_rect.y0 as f64 - 1.0, src_rect.x1 as f64 + 1.0, src_rect.y1 as f64 + 1.0); + let fwd_w = [(sx0, sy0), (sx1, sy0), (sx1, sy1), (sx0, sy1)].map(|(x, y)| h.0[6] * x + h.0[7] * y + h.0[8]); + let side = [1.0, -1.0].into_iter().find(|&s| fwd_w.iter().all(|&c| s * c > 0.0)); + let w_lo = 1.0 / fwd_w.iter().fold(0.0f64, |m, c| m.max(c.abs())); + let work = |t: &Rect| -> Result)>, photocraft_raster::AllocationError> { + // Source footprint of this tile (inverse-mapped corners of its sampled part), padded for the filter. + let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| (x as f64, y as f64)); + let wc = tc.map(|(x, y)| inv.0[6] * x + inv.0[7] * y + inv.0[8]); + let poly = match side { + // Sutherland-Hodgman against `s * w >= w_lo`. + Some(s) => { + let d = wc.map(|w| s * w - w_lo); + let mut poly = Vec::with_capacity(5); + for (i, (&p, &dp)) in tc.iter().zip(&d).enumerate() { + let (q, dq) = (tc[(i + 1) % 4], d[(i + 1) % 4]); + if dp >= 0.0 { + poly.push(p); + } + if (dp >= 0.0) != (dq >= 0.0) { + let k = dp / (dp - dq); + poly.push((p.0 + (q.0 - p.0) * k, p.1 + (q.1 - p.1) * k)); + } + } + poly + } + None => tc.to_vec(), + }; + if poly.is_empty() { + return Ok(None); + } + let fp: Vec<(f64, f64)> = poly.iter().map(|&(x, y)| inv.apply(x, y)).collect(); + let fx0 = fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32 - 3; + let fy0 = fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32 - 3; + let fx1 = fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32 + 3; + let fy1 = fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32 + 3; + // Without a one-signed forward w, a tile straddling the inverse horizon has no bounded footprint. + let straddles = side.is_none() && wc.iter().any(|w| *w <= 0.0) && wc.iter().any(|w| *w >= 0.0); + let foot = if straddles { src_rect } else { Rect::new(fx0, fy0, fx1, fy1).intersect(&src_rect) }; if foot.is_empty() || !src_ref.has_tiles_in(foot) { - return None; + return Ok(None); } // Premultiplied source window. - let mut px = src_ref.read_region(foot); + let mut px = src_ref.try_read_region(foot)?; for p in px.chunks_exact_mut(n) { let al = p[a]; for v in &mut p[..a] { @@ -198,7 +235,11 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter } }; let w = t.width() as usize; - let mut outp = vec![0.0f32; w * t.height() as usize * n]; + let len = w.checked_mul(t.height() as usize).and_then(|v| v.checked_mul(n)).ok_or(photocraft_raster::AllocationError::SizeOverflow)?; + let bytes = len.checked_mul(std::mem::size_of::()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?; + let mut outp = Vec::new(); + outp.try_reserve_exact(len).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes })?; + outp.resize(len, 0.0f32); let mut any = false; let mut acc = [0.0f64; 8]; for y in t.y0..t.y1 { @@ -252,20 +293,34 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter outp[o + a] = al as f32; } } - any.then_some((*t, outp)) + Ok(any.then_some((*t, outp))) }; + let result_bytes = tiles.len().checked_mul(std::mem::size_of::<(Rect, Vec)>()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?; + let mut done = Vec::new(); + done.try_reserve_exact(tiles.len()).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: result_bytes })?; + let done = std::sync::Mutex::new(done); #[cfg(not(target_arch = "wasm32"))] - let done: Vec<(Rect, Vec)> = { + { use rayon::prelude::*; - tiles.par_iter().filter_map(work).collect() - }; + tiles.into_par_iter().try_for_each(|tile| -> Result<(), photocraft_raster::AllocationError> { + if let Some(result) = work(&tile)? { + done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(result); + } + Ok(()) + })?; + } #[cfg(target_arch = "wasm32")] - let done: Vec<(Rect, Vec)> = tiles.iter().filter_map(work).collect(); + for tile in tiles { + if let Some(result) = work(&tile)? { + done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(result); + } + } + let done = done.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner); for (r, v) in done { - out.write_region(r, &v); + out.try_write_region(r, &v)?; } out.prune(); - out + Ok(out) } #[cfg(test)] @@ -292,6 +347,24 @@ mod tests { assert!(Homography::rect_to_quad(r, [[0.0, 0.0]; 4]).is_none() || Homography::rect_to_quad(r, [[0.0, 0.0]; 4]).unwrap().inverse().is_none()); } + #[test] + fn steep_distort_keeps_every_pixel_inside_the_quad() { + // Corner C is almost flat, so the horizon of the inverse map crosses the 256-px tiles that + // also hold quad pixels; those tiles must still read every source pixel that lands in them. + let mut s = rgba(); + s.fill_rect(Rect::new(0, 0, 100, 100), &[1.0, 0.0, 0.0, 1.0]); + let r = s.content_bounds(); + let h = Homography::rect_to_quad([0.0, 0.0, 100.0, 100.0], [[0.0, 0.0], [300.0, 0.0], [155.0, 155.0], [0.0, 300.0]]).unwrap(); + let w = warp_surface(&s, r, &h, Interp::Nearest).unwrap(); + for (x, y) in [(20, 20), (252, 14), (252, 42), (14, 252), (42, 252)] { + let a = w.read_region(Rect::new(x, y, x + 1, y + 1))[3]; + assert!(a > 0.99, "({x},{y}) inside the quad is transparent: {a}"); + } + for (x, y) in [(200, 200), (280, 100), (100, 280)] { + assert_eq!(w.read_region(Rect::new(x, y, x + 1, y + 1))[3], 0.0, "({x},{y}) outside the quad is painted"); + } + } + #[test] fn identity_and_translation_are_exact() { let mut s = rgba(); @@ -299,7 +372,7 @@ mod tests { let r = s.content_bounds(); for interp in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] { let h = Homography([1.0, 0.0, 7.0, 0.0, 1.0, -3.0, 0.0, 0.0, 1.0]); - let o = warp_surface(&s, r, &h, interp); + let o = warp_surface(&s, r, &h, interp).unwrap(); assert_eq!(o.content_bounds(), Rect::new(11, 1, 27, 9), "{interp:?}"); assert_eq!(o.pixel(15, 5), vec![1.0, 128.0 / 255.0, 0.0, 1.0], "{interp:?}"); } @@ -315,12 +388,13 @@ mod tests { r, &Homography::rect_to_quad([0.0, 0.0, 40.0, 20.0], [[0.0, 0.0], [80.0, 0.0], [80.0, 40.0], [0.0, 40.0]]).unwrap(), Interp::Bicubic, - ); + ) + .unwrap(); let b = up.content_bounds(); assert!(b.width() >= 80 && b.width() <= 82 && b.height() >= 40 && b.height() <= 42, "{b:?}"); // 90° rotation about (20, 10): a 40×20 box becomes 20×40. let q = [[30.0, -10.0], [30.0, 30.0], [10.0, 30.0], [10.0, -10.0]]; - let rot = warp_surface(&s, r, &Homography::rect_to_quad([0.0, 0.0, 40.0, 20.0], q).unwrap(), Interp::Bilinear); + let rot = warp_surface(&s, r, &Homography::rect_to_quad([0.0, 0.0, 40.0, 20.0], q).unwrap(), Interp::Bilinear).unwrap(); let b = rot.content_bounds(); assert!(b.width().abs_diff(20) <= 2 && b.height().abs_diff(40) <= 2, "{b:?}"); let p = rot.pixel(20, 10); @@ -338,7 +412,7 @@ mod tests { } } let h = Homography::rect_to_quad([0.0, 0.0, 64.0, 64.0], [[0.0, 0.0], [8.0, 0.0], [8.0, 8.0], [0.0, 8.0]]).unwrap(); - let o = warp_surface(&s, s.content_bounds(), &h, Interp::Bicubic); + let o = warp_surface(&s, s.content_bounds(), &h, Interp::Bicubic).unwrap(); let p = o.pixel(4, 4); assert!((p[0] - 0.5).abs() < 0.1, "{p:?}"); } @@ -348,7 +422,7 @@ mod tests { let mut s = Surface::new(PixelFormat::new(photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8, false)); s.fill_rect(Rect::new(0, 0, 10, 10), &[1.0, 0.0, 0.0]); let h = Homography([1.0, 0.0, 5.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]); - let o = warp_surface(&s, Rect::new(0, 0, 10, 10), &h, Interp::Nearest); + let o = warp_surface(&s, Rect::new(0, 0, 10, 10), &h, Interp::Nearest).unwrap(); assert!(o.format().alpha); assert_eq!(o.pixel(2, 2)[3], 0.0); assert_eq!(o.pixel(7, 2), vec![1.0, 0.0, 0.0, 1.0]); diff --git a/crates/algo/src/vanishing.rs b/crates/algo/src/vanishing.rs index 46e762b7fd..5661d66c9d 100644 --- a/crates/algo/src/vanishing.rs +++ b/crates/algo/src/vanishing.rs @@ -228,22 +228,22 @@ fn quad_mask(h: &Homography, area: Rect) -> Vec { /// Pastes `src` (content in `src_rect`) onto plane `i` with its top-left at plane coordinates /// `at` (0..1) and its width `width` (fraction of the plane's width), aspect preserved in the /// plane's metric. Returns the warped image clipped to the plane (transparent elsewhere). -pub fn paste(scene: &Scene, i: usize, src: &Surface, src_rect: Rect, at: [f64; 2], width: f64) -> Option { - let (h, lu, lv) = scene.frame(i)?; +pub fn paste(scene: &Scene, i: usize, src: &Surface, src_rect: Rect, at: [f64; 2], width: f64) -> Result, photocraft_raster::AllocationError> { + let Some((h, lu, lv)) = scene.frame(i) else { return Ok(None) }; let (sw, sh) = (src_rect.width() as f64, src_rect.height() as f64); if sw <= 0.0 || sh <= 0.0 { - return None; + return Ok(None); } // Source px → plane (u, v): u spans `width`, v keeps the metric aspect. let su = width / sw; let sv = width * lu / lv / sw; let to_plane = Homography([su, 0.0, at[0] - src_rect.x0 as f64 * su, 0.0, sv, at[1] - src_rect.y0 as f64 * sv, 0.0, 0.0, 1.0]); let full = h.mul(&to_plane); - let mut out = warp_surface(src, src_rect, &full, Interp::Bicubic); + let mut out = warp_surface(src, src_rect, &full, Interp::Bicubic)?; // Clip to the plane. let b = out.content_bounds(); if b.is_empty() { - return Some(out); + return Ok(Some(out)); } let mask = quad_mask(&h, b); let fmt = out.format(); @@ -254,7 +254,7 @@ pub fn paste(scene: &Scene, i: usize, src: &Surface, src_rect: Rect, at: [f64; 2 } out.write_region(b, &data); out.prune(); - Some(out) + Ok(Some(out)) } /// Perspective clone stamp: one stroke of dabs at image points `points` on the destination @@ -384,7 +384,7 @@ mod tests { let sc = Scene::new(floor_quad(500.0, c), c, None, 800.0); let mut img = Surface::new(PixelFormat::RGBA8); img.fill_rect(Rect::new(0, 0, 100, 50), &[1.0, 0.0, 0.0, 1.0]); - let out = paste(&sc, 0, &img, Rect::new(0, 0, 100, 50), [0.25, 0.25], 0.5).unwrap(); + let out = paste(&sc, 0, &img, Rect::new(0, 0, 100, 50), [0.25, 0.25], 0.5).unwrap().unwrap(); // The pasted rectangle lands inside the plane, around plane (0.5, 0.5). let h = sc.planes[0].homography().unwrap(); let (x, y) = h.apply(0.5, 0.5); diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs index e2ca4e35d1..2a8c7a5867 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -30,31 +30,41 @@ const MAX_CELLS: usize = 512; /// through `warp` (source space) and then the affine `t` (source → document) in one resampling /// pass, an exact shift for whole-pixel translations (so conversions and re-renders are /// lossless), bicubic otherwise. Shared by the engine's re-render and PSD export's filter cache. -pub fn place_source(src: &Surface, src_rect: Rect, t: &photocraft_geom::Affine, warp: Option<&photocraft_geom::warp::Warp>) -> Surface { +pub fn place_source( + src: &Surface, + src_rect: Rect, + t: &photocraft_geom::Affine, + warp: Option<&photocraft_geom::warp::Warp>, +) -> Result { let [a, b, c, d, e, f] = t.m; if let Some(w) = warp.filter(|w| !w.is_identity()) { let map = |x: f64, y: f64| { let (u, v) = w.map(x, y); (a * u + c * v + e, b * u + d * v + f) }; - return warp_mesh_surface(src, src_rect, &map, Interp::Bicubic); + return Ok(warp_mesh_surface(src, src_rect, &map, Interp::Bicubic)); } let near = |x: f64, y: f64| (x - y).abs() < 1e-9; if near(a, 1.0) && near(b, 0.0) && near(c, 0.0) && near(d, 1.0) && near(e, e.round()) && near(f, f.round()) { - return crate::resample::translate_surface(src, e.round() as i32, f.round() as i32); + return Ok(crate::resample::translate_surface(src, e.round() as i32, f.round() as i32)); } crate::transform::warp_surface(src, src_rect, &crate::transform::Homography([a, c, e, b, d, f, 0.0, 0.0, 1.0]), Interp::Bicubic) } /// [`place_source`] through a projective map `h` (source → document; Distort, Perspective): the /// warp first (in source space), then `h`. -pub fn place_source_projective(src: &Surface, src_rect: Rect, h: &crate::transform::Homography, warp: Option<&photocraft_geom::warp::Warp>) -> Surface { +pub fn place_source_projective( + src: &Surface, + src_rect: Rect, + h: &crate::transform::Homography, + warp: Option<&photocraft_geom::warp::Warp>, +) -> Result { if let Some(w) = warp.filter(|w| !w.is_identity()) { let map = |x: f64, y: f64| { let (u, v) = w.map(x, y); h.apply(u, v) }; - return warp_mesh_surface(src, src_rect, &map, Interp::Bicubic); + return Ok(warp_mesh_surface(src, src_rect, &map, Interp::Bicubic)); } crate::transform::warp_surface(src, src_rect, h, Interp::Bicubic) } diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index e9cbca9241..afdceaead5 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -151,8 +151,7 @@ impl Headless { // alone (#1547). let ext = format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| target.extension().map(|e| e.to_string_lossy().to_ascii_lowercase())); - let layered = matches!(ext.as_deref(), Some("psd" | "psb" | "ora")) || ext.as_deref() == Some(photocraft_format::EXTENSION); - if layered { + if ext.as_deref().is_some_and(file_cmds::layered_extension) { // Saving by index must not retarget the next automation command. let previously_active = self.session.active_index(); self.session.set_active(i); diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 634b645ead..74a4c623f4 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -229,7 +229,7 @@ pub struct UiSetParams { /// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel /// ({background: bool} picks which swatch the Color panel edits), maskTarget, /// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio, - /// studioLight, classic, solarizedDark, adwaita, adwaitaDark), brushSection, brushTab, + /// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark), brushSection, brushTab, /// brushesView, brushPicker ([x, y] opens the Brush Preset picker there, null closes it), /// brushPickerName, brushPickerStroke, brushPickerTip (the picker card's parts, at least one /// on), brushPickerScale (the picker footer's card-scale slider, 0.15 to 2, 1 the standard), @@ -239,8 +239,8 @@ pub struct UiSetParams { /// allNoAdjustments, currentAndBelowNoAdjustments), eyedropperRing (bool), cropOverlay (thirds, /// grid, diagonal, triangle, goldenRatio, goldenSpiral), cropOverlayShow (auto, always, never), /// cropOverlayOrientation (0..3), cropShield (an object patching show_cropped_area, enabled, - /// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100) and - /// auto_adjust), shapeStroke (an object patching width, align, cap, join, miterLimit, + /// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100), + /// auto_adjust, classic_mode and auto_center_preview), shapeStroke (an object patching width, align, cap, join, miterLimit, /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use /// `shape.edit` names and units; colour comes from `tools.setColors`. Other fields are an error. pub fields: Value, diff --git a/crates/cms/src/builtin.rs b/crates/cms/src/builtin.rs index 1276de3494..b102562f4a 100644 --- a/crates/cms/src/builtin.rs +++ b/crates/cms/src/builtin.rs @@ -127,6 +127,13 @@ pub fn srgb() -> &'static Profile { Builtin::Srgb.profile() } +/// Gray with a linear tone curve: what a grayscale OpenEXR holds. Not a [`Builtin`], as it is +/// only assigned to such files, never offered as a working space. +pub fn linear_gray() -> &'static Profile { + static PROFILE: OnceLock = OnceLock::new(); + PROFILE.get_or_init(|| gray("Linear Gray (Photocraft)", Curve::Gamma(1.0))) +} + /// The ICC D65 used for the RGB spaces (chromaticity 0.3127, 0.3290). fn d65() -> [f64; 3] { math::xy_to_xyz(math::D65_XY) diff --git a/crates/cms/src/profile.rs b/crates/cms/src/profile.rs index 93719f2a71..6d646cc1e1 100644 --- a/crates/cms/src/profile.rs +++ b/crates/cms/src/profile.rs @@ -448,7 +448,8 @@ fn parse_profile(bytes: &[u8]) -> Result { let version = (r.u8(8)?, r.u8(9)?); let class = ProfileClass::from_sig(r.u32(12)?); let color_space = ColorSpace::from_sig(r.u32(16)?); - let pcs = match &r.u32(20)?.to_be_bytes() { + let pcs_sig = r.u32(20)?; + let pcs = match &pcs_sig.to_be_bytes() { b"Lab " => Pcs::Lab, _ => Pcs::Xyz, }; @@ -539,15 +540,18 @@ fn parse_profile(bytes: &[u8]) -> Result { bytes: None, hash: Default::default(), }; - // Validate channel counts of LUTs against the header. + // Validate channel counts of LUTs against the header. A DeviceLink connects two device + // spaces: its header PCS field holds the output colour space (ICC.1:2010 §7.2.7), so a + // CMYK→CMYK link has a 4→4 AToB0. let dev = p.color_space.channels(); + let conn = if p.class == ProfileClass::DeviceLink { ColorSpace::from_sig(pcs_sig).channels() } else { 3 }; for l in p.a2b.iter().flatten() { - if dev != 0 && l.inputs != dev || l.outputs != 3 { + if dev != 0 && l.inputs != dev || conn != 0 && l.outputs != conn { return Err(CmsError::Invalid(format!("AToB tag has {}→{} channels for a {:?} profile", l.inputs, l.outputs, p.color_space))); } } for l in p.b2a.iter().flatten() { - if l.inputs != 3 || dev != 0 && l.outputs != dev { + if conn != 0 && l.inputs != conn || dev != 0 && l.outputs != dev { return Err(CmsError::Invalid(format!("BToA tag has {}→{} channels for a {:?} profile", l.inputs, l.outputs, p.color_space))); } } diff --git a/crates/cms/src/transform.rs b/crates/cms/src/transform.rs index c9880bbc4b..d262956a78 100644 --- a/crates/cms/src/transform.rs +++ b/crates/cms/src/transform.rs @@ -510,7 +510,9 @@ fn apply_matrix(m: &Option<(usize, usize, Vec, Vec)>, v: &[f32; 16], o // ------------------------------------------------------------ parallel helpers +#[cfg(not(target_arch = "wasm32"))] const PAR_MIN_PIXELS: usize = 16 * 1024; +#[cfg(not(target_arch = "wasm32"))] const PAR_CHUNK_PIXELS: usize = 8 * 1024; #[cfg(not(target_arch = "wasm32"))] @@ -549,7 +551,9 @@ fn par_inplace(buf: &mut [f32], _stride: usize, f: impl Fn(&mut [f32]) + Sync) { /// Stages from `src` device values to `dst` device values. pub(crate) fn link_stages(src: &Profile, dst: &Profile, intent: Intent, bpc: bool) -> Result, CmsError> { for p in [src, dst] { - if matches!(p.class, ProfileClass::DeviceLink | ProfileClass::NamedColor | ProfileClass::Abstract) && p.color_space != ColorSpace::Lab { + // A Lab DeviceLink stands in for an abstract profile only when it also ends in Lab. + let lab_to_lab = p.color_space == ColorSpace::Lab && p.a2b.iter().flatten().all(|l| l.outputs == 3) && p.b2a.iter().flatten().all(|l| l.inputs == 3); + if matches!(p.class, ProfileClass::DeviceLink | ProfileClass::NamedColor | ProfileClass::Abstract) && !lab_to_lab { return Err(CmsError::Unsupported(format!("{:?} profiles cannot be used as a source or destination", p.class))); } } diff --git a/crates/cms/tests/cms.rs b/crates/cms/tests/cms.rs index ffd3184478..ba3f008fb4 100644 --- a/crates/cms/tests/cms.rs +++ b/crates/cms/tests/cms.rs @@ -699,3 +699,56 @@ fn windows_srgb_is_the_builtin_srgb() { assert_ne!(p.content_hash(), srgb().content_hash()); assert!(p.same_colors(srgb())); } + +/// A minimal DeviceLink: header plus one `A2B0` (`mft2`, 2-point grid) mapping `inputs` channels +/// of `space` to `outputs` channels of `out_space`, which the header stores in the PCS field. +fn device_link(space: &[u8; 4], out_space: &[u8; 4], inputs: usize, outputs: usize) -> Vec { + let mut lut = b"mft2\0\0\0\0".to_vec(); + lut.extend_from_slice(&[inputs as u8, outputs as u8, 2, 0]); + for k in 0..9 { + let v: i32 = if k % 4 == 0 { 0x10000 } else { 0 }; + lut.extend_from_slice(&v.to_be_bytes()); // identity matrix + } + lut.extend_from_slice(&[0, 2, 0, 2]); // 2-entry input and output tables + for _ in 0..inputs { + lut.extend_from_slice(&[0, 0, 0xff, 0xff]); + } + for _ in 0..(1 << inputs) * outputs { + lut.extend_from_slice(&[0x80, 0]); + } + for _ in 0..outputs { + lut.extend_from_slice(&[0, 0, 0xff, 0xff]); + } + let mut icc = vec![0u8; 128]; + icc[8] = 4; + icc[9] = 0x30; + icc[12..16].copy_from_slice(b"link"); + icc[16..20].copy_from_slice(space); + icc[20..24].copy_from_slice(out_space); + icc[36..40].copy_from_slice(b"acsp"); + icc.extend_from_slice(&1u32.to_be_bytes()); + icc.extend_from_slice(b"A2B0"); + icc.extend_from_slice(&144u32.to_be_bytes()); + icc.extend_from_slice(&(lut.len() as u32).to_be_bytes()); + icc.extend_from_slice(&lut); + let size = icc.len() as u32; + icc[..4].copy_from_slice(&size.to_be_bytes()); + icc +} + +/// Regression (#2337): DeviceLinks whose output isn't a 3-channel space were rejected as +/// invalid, because the LUT check assumed every AToB tag ends in the PCS. They parse now and +/// get the clear "unsupported" error when used for a conversion, including a Lab→CMYK link, +/// which must not pass for a Lab-to-Lab one. +#[test] +fn device_links_parse_and_are_reported_unsupported() { + for (space, out_space, inputs, outputs) in [(b"CMYK", b"CMYK", 4, 4), (b"RGB ", b"CMYK", 3, 4), (b"Lab ", b"CMYK", 3, 4), (b"RGB ", b"RGB ", 3, 3)] { + let p = Profile::parse(&device_link(space, out_space, inputs, outputs)).unwrap(); + assert_eq!(p.color_space.channels(), inputs); + for err in [Transform::new(&p, srgb(), Intent::Perceptual, false).err(), Transform::new(srgb(), &p, Intent::Perceptual, false).err()] { + assert!(matches!(err, Some(photocraft_cms::CmsError::Unsupported(_))), "{err:?}"); + } + } + // The output space still bounds the tag: a CMYK→CMYK link with a 4→3 table is malformed. + assert!(matches!(Profile::parse(&device_link(b"CMYK", b"CMYK", 4, 3)), Err(photocraft_cms::CmsError::Invalid(_)))); +} diff --git a/crates/codecs/src/codecs/exr.rs b/crates/codecs/src/codecs/exr.rs index 57197ee7a9..a4014db0b3 100644 --- a/crates/codecs/src/codecs/exr.rs +++ b/crates/codecs/src/codecs/exr.rs @@ -181,6 +181,77 @@ pub(crate) fn decode_part(bytes: &[u8], index: usize, limits: &Limits) -> Result layer_to_image(&read_layer(bytes, index)?) } +/// Decodes the named channels of one flat part (exact names as the file stores them, e.g. +/// `diffuse.R`) into an image: R/G/B (plus A when named) give Rgba, a lone Y gives Gray. This +/// is how a channel group renders without the part's other channels in the way. +pub(crate) fn decode_channels(bytes: &[u8], index: usize, names: &[&str], limits: &Limits) -> Result { + let meta = read_meta(bytes, limits)?; + if meta.requirements.has_deep_data { + return Err(CodecError::unsupported(F, "this file has deep parts; the deep decoder composites those (mixing flat and deep parts is not supported)")); + } + if index >= meta.headers.len() { + return Err(err(format!("part index {index} is out of range ({} parts)", meta.headers.len()))); + } + let layer = read_layer(bytes, index)?; + let (w, h) = (layer.size.0, layer.size.1); + let channels = &layer.channel_data.list; + let npx = w * h; + let mut picked: Vec<&AnyChannel> = Vec::with_capacity(names.len()); + for name in names { + let c = channels.iter().find(|c| c.name.to_string() == *name).ok_or_else(|| err(format!("the part has no channel {name}")))?; + if c.sample_data.len() != npx { + return Err(err("channel sample count mismatch (subsampled channels are unsupported)")); + } + picked.push(c); + } + let base = |c: &AnyChannel| -> String { base_channel_name(&c.name.to_string()).to_string() }; + let has = |n: &str| picked.iter().any(|c| base(c) == n); + let (layout, order): (ChannelLayout, Vec>) = if has("R") && has("G") && has("B") { + let at = |n: &str| picked.iter().position(|c| base(c) == n); + match has("A").then(|| at("A")) { + Some(a) => (ChannelLayout::Rgba, vec![at("R"), at("G"), at("B"), a]), + None => (ChannelLayout::Rgb, vec![at("R"), at("G"), at("B"), None]), + } + } else if has("Y") { + let y = picked.iter().position(|c| base(c) == "Y"); + match picked.iter().position(|c| base(c) == "A") { + Some(a) => (ChannelLayout::GrayA, vec![y, Some(a)]), + None => (ChannelLayout::Gray, vec![y]), + } + } else if picked.len() == 1 { + (ChannelLayout::Gray, vec![Some(0)]) + } else { + return Err(CodecError::unsupported(F, "the named channels are no R/G/B or Y set")); + }; + let all_f16 = picked.iter().all(|c| matches!(c.sample_data, FlatSamples::F16(_))); + let nc = layout.channels(); + let (w32, h32) = (w as u32, h as u32); + // The None slots sit past the layout's own channels (the absent A of an RGB pick) and + // are skipped, so the buffer keeps its pixel stride. + let img = if all_f16 { + let mut out = vec![f16::ZERO; npx * nc]; + for (ci, pick) in order.iter().enumerate() { + let Some(idx) = pick else { continue }; + let Some(FlatSamples::F16(v)) = picked.get(*idx).map(|c| &c.sample_data) else { continue }; + for (i, s) in v.iter().enumerate() { + out[i * nc + ci] = *s; + } + } + Image::from_f16(w32, h32, layout, &out) + } else { + let mut out = vec![0f32; npx * nc]; + for (ci, pick) in order.iter().enumerate() { + let Some(idx) = pick else { continue }; + let Some(c) = picked.get(*idx) else { continue }; + for (i, s) in c.sample_data.values_as_f32().enumerate() { + out[i * nc + ci] = s; + } + } + Image::from_f32(w32, h32, layout, &out) + }; + img.map_err(|e| CodecError::encode(F, e)) +} + pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { let img = src.converted(plan.layout, plan.sample); let (w, h) = (img.width() as usize, img.height() as usize); diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 4ad8a0ef3e..cc8d0558b2 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -163,6 +163,14 @@ pub fn decode_cryptomatte(bytes: &[u8], layer_name: &str, limits: &Limits) -> Re codecs::exr_cryptomatte::decode(bytes, layer_name, limits) } +/// Decodes the named channels of one part (exact names as the file stores them, e.g. +/// `diffuse.R`) into an image: R/G/B (plus A when named) give Rgba, a lone Y gives Gray. +/// This renders a channel group without the part's other channels in the way. +pub fn decode_exr_channels(bytes: &[u8], part: usize, names: &[&str], opts: &DecodeOptions) -> Result { + let img = exr::decode_channels(bytes, part, names, &opts.limits)?; + finish_decode(img, 1, opts) +} + /// Deep OpenEXR samples (`deepscanline`/`deeptile`): the structured per-pixel sample lists /// (colour, alpha and Z per sample), for callers that composite deep data themselves — /// [`decode_as_with`] instead flattens a deep file into a normal [`Image`] with a diff --git a/crates/codecs/src/resolution.rs b/crates/codecs/src/resolution.rs index 7cf5dcac04..049a00eaac 100644 --- a/crates/codecs/src/resolution.rs +++ b/crates/codecs/src/resolution.rs @@ -382,10 +382,36 @@ pub fn xmp_with_resolution(xmp: &str, ppi: (f32, f32)) -> Cow<'_, str> { if out == xmp { Cow::Borrowed(xmp) } else { Cow::Owned(out) } } +/// `exif` without its embedded thumbnail, or `None` when it has none (or doesn't parse). +/// +/// EXIF keeps the thumbnail in IFD1, the IFD IFD0's next-IFD pointer names. Finder, Android's +/// gallery and upload pickers show that thumbnail instead of decoding the image, so a copied one +/// keeps showing the photo as it was before it was edited (#2147, #2373). The pointer is cleared; +/// nothing else moves, so every other offset in the block stays valid. +fn exif_without_thumbnail(exif: &[u8]) -> Option> { + let body = tiff_body(exif); + let prefix = exif.len() - body.len(); + let (_, first, count, entry_bytes, big_tiff) = ifd0_entries(body)?; + let next = prefix.checked_add(first)?.checked_add(count.checked_mul(entry_bytes)?)?; + let width = if big_tiff { 8 } else { 4 }; + // In bounds: `ifd0_entries` checked that the whole pointer is present. + if exif.get(next..next.checked_add(width)?)?.iter().all(|b| *b == 0) { + return None; + } + let mut out = exif.to_vec(); + out.get_mut(next..next + width)?.fill(0); + Some(out) +} + /// The EXIF to write next to upright pixels at resolution `ppi`: Orientation 1 (see -/// [`upright_exif`]) and, when `ppi` is known, the resolution (see [`exif_with_resolution`]). +/// [`upright_exif`]), no thumbnail of the source image (see [`exif_without_thumbnail`]) and, +/// when `ppi` is known, the resolution (see [`exif_with_resolution`]). pub fn export_exif(exif: &[u8], ppi: Option<(f32, f32)>) -> Cow<'_, [u8]> { let up = upright_exif(exif); + let up = match exif_without_thumbnail(&up) { + Some(v) => Cow::Owned(v), + None => up, + }; let Some(ppi) = ppi else { return up }; if let Cow::Owned(v) = exif_with_resolution(&up, ppi) { return Cow::Owned(v); diff --git a/crates/codecs/tests/resolution.rs b/crates/codecs/tests/resolution.rs index bfe418a80a..5eb6acad3a 100644 --- a/crates/codecs/tests/resolution.rs +++ b/crates/codecs/tests/resolution.rs @@ -436,3 +436,78 @@ fn png_phys_outranks_its_exif() { let with_phys = [&png[..ihdr_end], &chunk, &png[ihdr_end..]].concat(); near(decode(&with_phys).unwrap().meta.dpi, 150.0114); } + +/// `camera_exif` plus IFD1, the embedded thumbnail a camera (or the last editor) wrote: +/// JPEGInterchangeFormat / JPEGInterchangeFormatLength naming a fake JPEG after the IFD. +fn exif_with_thumbnail(e: &Exif) -> Vec { + let mut v = camera_exif(e); + let ifd1 = v.len() as u32; + // IFD0 (4 entries) ends with its next-IFD pointer. + v[8 + 2 + 4 * 12..8 + 2 + 4 * 12 + 4].copy_from_slice(&e.u32(ifd1)); + let thumb = b"\xFF\xD8stale thumbnail\xFF\xD9"; + let thumb_at = ifd1 + 2 + 2 * 12 + 4; + v.extend_from_slice(&e.u16(2)); + for (tag, value) in [(0x0201u16, thumb_at), (0x0202, thumb.len() as u32)] { + v.extend_from_slice(&e.u16(tag)); + v.extend_from_slice(&e.u16(LONG)); + v.extend_from_slice(&e.u32(1)); + v.extend_from_slice(&e.u32(value)); + } + v.extend_from_slice(&e.u32(0)); + v.extend_from_slice(thumb); + v +} + +fn next_ifd(e: &Exif, exif: &[u8]) -> u32 { + let at = 8 + 2 + 4 * 12; + let b: [u8; 4] = exif[at..at + 4].try_into().unwrap(); + if e.big { u32::from_be_bytes(b) } else { u32::from_le_bytes(b) } +} + +/// #2147 / #2373: an export must not carry the source's EXIF thumbnail (IFD1). Finder, Android +/// and upload pickers show it instead of the image, so an edited photo kept its old thumbnail. +#[test] +fn exports_drop_the_source_exif_thumbnail() { + for e in [&LE, &BE] { + let exif = exif_with_thumbnail(e); + assert_ne!(next_ifd(e, &exif), 0); + for ppi in [None, Some((300.0, 300.0)), Some((240.0, 240.0))] { + let out = export_exif(&exif, ppi); + assert_eq!(next_ifd(e, &out), 0, "IFD1 is unlinked ({ppi:?})"); + assert_eq!(out.len(), exif.len(), "nothing moves"); + assert!(out.windows(6).any(|w| w == b"NIKON\0"), "the rest of the EXIF is kept"); + near(exif_resolution(&out), ppi.map_or(300.0, |p| p.0)); + } + // Only the pointer changes. + let out = export_exif(&exif, None); + let differing: Vec = (0..exif.len()).filter(|&i| exif[i] != out[i]).collect(); + assert!(differing.iter().all(|&i| (8 + 2 + 4 * 12..8 + 2 + 4 * 12 + 4).contains(&i)), "{differing:?}"); + // With a JPEG-style prefix too. + let prefixed = [b"Exif\0\0".as_slice(), &exif].concat(); + assert_eq!(next_ifd(e, &export_exif(&prefixed, None)[6..]), 0); + // An EXIF without a thumbnail comes through untouched. + assert!(matches!(export_exif(&camera_exif(e), None), Cow::Borrowed(_))); + // Through every encoder that writes EXIF. + let mut img = Image::from_raw(4, 4, ChannelLayout::Rgb, SampleType::U8, vec![90; 48]).unwrap(); + img.meta.exif = Some(exif.clone()); + let jpeg = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + assert_eq!(next_ifd(e, &exif_of(&jpeg)), 0, "JPEG"); + for format in [Format::Png, Format::WebP] { + let back = decode(&encode(&img, format, &EncodeOptions::default()).unwrap()).unwrap(); + let got = back.meta.exif.unwrap_or_else(|| panic!("{format:?} kept no EXIF")); + let body = got.strip_prefix(b"Exif\0\0").unwrap_or(&got); + assert_eq!(next_ifd(e, body), 0, "{format:?}"); + } + } +} + +#[test] +fn thumbnail_removal_never_panics_on_truncated_exif() { + for e in [&LE, &BE] { + let exif = exif_with_thumbnail(e); + for n in 0..exif.len() { + let _ = export_exif(&exif[..n], None); + let _ = export_exif(&exif[..n], Some((300.0, 300.0))); + } + } +} diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs index 05ab00f16f..cd8f6eac36 100644 --- a/crates/color/src/blend.rs +++ b/crates/color/src/blend.rs @@ -336,7 +336,17 @@ pub fn lum(c: [f32; 3]) -> f32 { 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2] } -fn clip_color(c: [f32; 3]) -> [f32; 3] { +/// ClipColor: moves `c` towards its grey ([`lum`]) until every channel is within `0..=top`, +/// keeping the luminosity. `top` is the brightest in-gamut value: 1, or `f32::MAX` in 32-bit +/// documents (values above 1 are valid light there, so only negatives are pulled in). +/// +/// The scale factors `l / (l − min)` and `(top − l) / (max − l)` lie in 0..1 only while the +/// luminosity itself is in range. Outside it no colour of that luminosity is in gamut: the plain +/// formula turned colours inside out (a grey of 2 as the Luminosity of (0.5, 0.25, 0.125) gave +/// (1.0, 2.32, 2.99)) and, for greys, divided by the rounding noise of `max − l`, so results were +/// arbitrary and differed between the CPU and the GPU. Such colours become their grey instead +/// (the formula's value as the luminosity reaches the bound). +fn clip_color(c: [f32; 3], top: f32) -> [f32; 3] { let l = lum(c); let n = c[0].min(c[1]).min(c[2]); let x = c[0].max(c[1]).max(c[2]); @@ -344,13 +354,13 @@ fn clip_color(c: [f32; 3]) -> [f32; 3] { if n < 0.0 { let d = l - n; for v in &mut out { - *v = if d.abs() < 1e-12 { l } else { l + (*v - l) * l / d }; + *v = if l > 0.0 && d > 0.0 { l + (*v - l) * l / d } else { l }; } } - if x > 1.0 { + if x > top { let d = x - l; for v in &mut out { - *v = if d.abs() < 1e-12 { l } else { l + (*v - l) * (1.0 - l) / d }; + *v = if l < top && d > 0.0 { l + (*v - l) * (top - l) / d } else { l }; } } out @@ -359,8 +369,13 @@ fn clip_color(c: [f32; 3]) -> [f32; 3] { /// `c` with its luminosity ([`lum`]) moved to `l`, clipped back into gamut towards grey (the /// Luminosity blend mode's SetLum + ClipColor). pub fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] { + set_lum_within(c, l, 1.0) +} + +/// [`set_lum`] clipping at `top` instead of 1 (see [`blend_rgb_within`]). +fn set_lum_within(c: [f32; 3], l: f32, top: f32) -> [f32; 3] { let d = l - lum(c); - clip_color([c[0] + d, c[1] + d, c[2] + d]) + clip_color([c[0] + d, c[1] + d, c[2] + d], top) } fn sat(c: [f32; 3]) -> f32 { @@ -382,11 +397,17 @@ fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] { /// Blend a whole RGB triple: `B(Cb, Cs)` including non-separable modes. pub fn blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] { + blend_rgb_within(mode, cb, cs, 1.0) +} + +/// [`blend_rgb`] with Hue / Saturation / Color / Luminosity clipped at `top` instead of 1: +/// `f32::MAX` keeps the values above 1 of 32-bit documents (their gamut has no upper bound). +pub fn blend_rgb_within(mode: BlendMode, cb: [f32; 3], cs: [f32; 3], top: f32) -> [f32; 3] { match mode { - BlendMode::Hue => set_lum(set_sat(cs, sat(cb)), lum(cb)), - BlendMode::Saturation => set_lum(set_sat(cb, sat(cs)), lum(cb)), - BlendMode::Color => set_lum(cs, lum(cb)), - BlendMode::Luminosity => set_lum(cb, lum(cs)), + BlendMode::Hue => set_lum_within(set_sat(cs, sat(cb)), lum(cb), top), + BlendMode::Saturation => set_lum_within(set_sat(cb, sat(cs)), lum(cb), top), + BlendMode::Color => set_lum_within(cs, lum(cb), top), + BlendMode::Luminosity => set_lum_within(cb, lum(cs), top), BlendMode::DarkerColor => { if lum(cs) < lum(cb) { cs @@ -405,9 +426,10 @@ pub fn blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] { } } -/// [`blend_rgb`] for 32-bit float documents: separable modes use [`blend_channel_hdr`]. +/// [`blend_rgb`] for 32-bit float documents: separable modes use [`blend_channel_hdr`], and the +/// non-separable ones keep values above 1 ([`blend_rgb_within`] at `f32::MAX`). pub fn blend_rgb_hdr(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] { - if mode.is_separable() { std::array::from_fn(|i| blend_channel_hdr(mode, cb[i], cs[i])) } else { blend_rgb(mode, cb, cs) } + if mode.is_separable() { std::array::from_fn(|i| blend_channel_hdr(mode, cb[i], cs[i])) } else { blend_rgb_within(mode, cb, cs, f32::MAX) } } /// Composite a straight-alpha source over a straight-alpha backdrop with blend mode and opacity. @@ -598,8 +620,9 @@ mod tests { assert_eq!(blend_channel_hdr(BlendMode::Divide, f32::MAX, 1e-30), f32::MAX); assert_eq!(blend_channel_hdr(BlendMode::LinearDodge, f32::MAX, f32::MAX), f32::MAX); assert!(blend_channel_hdr(BlendMode::LinearDodge, f32::INFINITY, 1.0).is_finite()); - // Every other mode is unchanged, and the in-range results agree. - for m in BlendMode::LAYER_MODES.into_iter().filter(|m| !matches!(m, BlendMode::LinearDodge | BlendMode::Divide)) { + // Every other separable mode is unchanged (the non-separable ones keep values above 1: + // blend_rgb_within), and the in-range results agree. + for m in BlendMode::LAYER_MODES.into_iter().filter(|m| m.is_separable() && !matches!(m, BlendMode::LinearDodge | BlendMode::Divide)) { for (cb, cs) in [(0.2, 0.3), (0.9, 0.05), (2.0, 0.5), (0.4, 1.5)] { assert_eq!(blend_rgb(m, [cb, cs, 0.5], [cs, cb, 0.25]), blend_rgb_hdr(m, [cb, cs, 0.5], [cs, cb, 0.25]), "{m:?} cb={cb} cs={cs}"); } @@ -624,6 +647,149 @@ mod tests { assert!(close(s[0], s[1]) && close(s[1], s[2])); } + /// ClipColor exactly as ISO 32000 / W3C write it (what `clip_color` was before it guarded + /// out-of-range luminosities). + fn spec_clip_color(c: [f32; 3]) -> [f32; 3] { + let l = lum(c); + let n = c[0].min(c[1]).min(c[2]); + let x = c[0].max(c[1]).max(c[2]); + let mut out = c; + if n < 0.0 { + let d = l - n; + for v in &mut out { + *v = if d.abs() < 1e-12 { l } else { l + (*v - l) * l / d }; + } + } + if x > 1.0 { + let d = x - l; + for v in &mut out { + *v = if d.abs() < 1e-12 { l } else { l + (*v - l) * (1.0 - l) / d }; + } + } + out + } + + fn spec_blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] { + let set_lum = |c: [f32; 3], l: f32| { + let d = l - lum(c); + spec_clip_color([c[0] + d, c[1] + d, c[2] + d]) + }; + match mode { + BlendMode::Hue => set_lum(set_sat(cs, sat(cb)), lum(cb)), + BlendMode::Saturation => set_lum(set_sat(cb, sat(cs)), lum(cb)), + BlendMode::Color => set_lum(cs, lum(cb)), + BlendMode::Luminosity => set_lum(cb, lum(cs)), + m => blend_rgb(m, cb, cs), + } + } + + const NON_SEPARABLE: [BlendMode; 4] = [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color, BlendMode::Luminosity]; + + #[test] + fn non_separable_modes_unchanged_on_integer_inputs() { + // 8/16-bit documents only hold 0..1, where the luminosity stays in range (up to a + // rounding step at white) and ClipColor is the plain ISO 32000 formula. + let mut edges: Vec = vec![0.0, 1.0 / 255.0, 0.5, 254.0 / 255.0, 65534.0 / 65535.0, 1.0]; + edges.extend((0..=8).map(|i| i as f32 / 8.0)); + let mut colors = Vec::new(); + for &r in &edges { + for &g in &edges { + for &b in &edges { + colors.push([r, g, b]); + } + } + } + let mut seed = 0x1234_5678u32; + let mut next = |levels: u32| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + ((seed >> 8) % (levels + 1)) as f32 / levels as f32 + }; + for _ in 0..2000 { + let levels = if next(1) > 0.5 { 255 } else { 65535 }; + colors.push([next(levels), next(levels), next(levels)]); + } + let mut worst = 0.0f32; + for (i, &cb) in colors.iter().enumerate() { + for &cs in colors.iter().skip(i % 7).step_by(7) { + for m in NON_SEPARABLE { + let (got, want) = (blend_rgb(m, cb, cs), spec_blend_rgb(m, cb, cs)); + for k in 0..3 { + worst = worst.max((got[k] - want[k]).abs()); + } + } + } + } + // Only white-ish colours whose luminosity rounds a step above 1 can differ, by a rounding step. + assert!(worst <= 1e-6, "{worst}"); + } + + #[test] + fn non_separable_modes_stable_when_luminosity_is_out_of_range() { + // The spec formula divided by `max − lum`, which is rounding noise for a grey above 1 and + // turns colours inside out when the luminosity is above 1: Luminosity of a grey of 2 over + // (0.5, 0.25, 0.125) gave (1.0, 2.32, 2.99). + let spec = spec_blend_rgb(BlendMode::Luminosity, [0.5, 0.25, 0.125], [2.0; 3]); + assert!(spec[0] < spec[2], "{spec:?}"); + let colors: [[f32; 3]; 10] = [ + [1.0, 1.0, 1.0], + [1.5, 1.5, 1.5], + [2.0, 2.0, 2.0], + [4.0, 4.0, 4.0], + [1000.0, 1000.0, 1000.0], + [2.0, 2.0, 1.999_999], + [4.0, 3.999_9, 4.0], + [3.0, 0.25, 0.125], + [1.25, 0.875, 3.75], + [-0.25, 0.5, 1.375], + ]; + for top in [1.0, f32::MAX] { + for m in NON_SEPARABLE { + for cb in colors { + for cs in colors { + let r = blend_rgb_within(m, cb, cs, top); + assert!(r.iter().all(|v| v.is_finite()), "{m:?} {cb:?} {cs:?} {top}: {r:?}"); + // The target luminosity is kept, at any depth. + let want = if m == BlendMode::Luminosity { lum(cs) } else { lum(cb) }; + if want >= 0.0 { + assert!((lum(r) - want).abs() <= 1e-4 * want.max(1.0), "{m:?} {cb:?} {cs:?} {top}: {r:?}"); + } + let (lo, hi) = (r[0].min(r[1]).min(r[2]), r[0].max(r[1]).max(r[2])); + if top == 1.0 { + // In gamut, or (a luminosity above 1 has no in-gamut colour) its grey. + assert!((lo >= 0.0 && hi <= 1.0 + E) || hi - lo <= 1e-6 * hi, "{m:?} {cb:?} {cs:?}: {r:?}"); + } else { + assert!(lo >= -1e-6 * hi.max(1.0), "{m:?} {cb:?} {cs:?}: {r:?}"); + } + } + } + } + } + // 32-bit: a grey backdrop gives a grey of its own luminosity in Hue and Saturation, a + // grey source a grey in Color, and bright colours keep their values above 1. + for g in [1.5f32, 2.0, 4.0, 1000.0] { + let grey = [g; 3]; + for cs in colors { + for m in [BlendMode::Hue, BlendMode::Saturation] { + let r = blend_rgb_within(m, grey, cs, f32::MAX); + assert!(r.iter().all(|&v| (v - lum(grey)).abs() <= 1e-5 * g), "{m:?} {g} {cs:?}: {r:?}"); + } + } + let r = blend_rgb_within(BlendMode::Luminosity, [0.5, 0.25, 0.125], grey, f32::MAX); + assert!(r[0] > 1.0 && r[0] > r[1] && r[1] > r[2], "{g}: {r:?}"); + let r = blend_rgb_within(BlendMode::Color, grey, [3.0, 0.25, 0.125], f32::MAX); + assert!(r[0] > 1.0 && r[0] > r[1] && r[1] > r[2], "{g}: {r:?}"); + // Integer documents: no colour of that luminosity fits in 0..1, so it is its grey. + let r = blend_rgb_within(BlendMode::Luminosity, [0.5, 0.25, 0.125], grey, 1.0); + assert!(r.iter().all(|&v| (v - lum(grey)).abs() <= 1e-5 * g), "{g}: {r:?}"); + } + // Continuous across near-greys: the result barely moves from the grey's. + for m in NON_SEPARABLE { + let a = blend_rgb_within(m, [2.0, 2.0, 2.0], [4.0, 4.0, 4.0], f32::MAX); + let b = blend_rgb_within(m, [2.0, 2.0, 1.999_999], [4.0, 3.999_999, 4.0], f32::MAX); + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() <= 1e-4), "{m:?}: {a:?} {b:?}"); + } + } + #[test] fn darker_lighter_color_pick_whole_pixel() { let a = [0.9, 0.1, 0.1]; diff --git a/crates/compose/examples/bench_tsly_stroke.rs b/crates/compose/examples/bench_tsly_stroke.rs new file mode 100644 index 0000000000..97ff9c85b6 --- /dev/null +++ b/crates/compose/examples/bench_tsly_stroke.rs @@ -0,0 +1,44 @@ +//! Reproduce tall-document TSL-off stroke-effect compositing from issue #2102. +//! Run in release mode: `cargo run --release -p photocraft-compose --example bench_tsly_stroke`. + +use std::error::Error; +use std::time::Instant; + +use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType}; +use photocraft_compose::render_bands; +use photocraft_doc::{Document, Effect, FxCommon, FxPaint, Layer, Size, StrokeFx, StrokePosition}; +use photocraft_geom::Rect; + +const WIDTH: u32 = 1920; +const HEIGHT: u32 = 103_675; +const BAND_ROWS: i32 = 4352; + +fn document_with_strokes(layer_count: usize) -> Result> { + let mut document = Document::new("#2102 tall TSL-off strokes", Size::new(WIDTH, HEIGHT), ColorMode::Rgb, SampleType::U8); + let step = HEIGHT / (layer_count as u32 + 1); + for i in 0..layer_count { + let y = step.saturating_mul(i as u32 + 1); + let mut layer = Layer::raster("Outside stroke", PixelFormat::RGBA8); + let surface = layer.surface_mut().ok_or("raster layer has no surface")?; + surface.fill_rect(Rect::new(360, y as i32, 1560, y as i32 + 120), &[0.2, 0.5, 0.9, 1.0]); + layer.effects.items.push(Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 6.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(Color::rgb(0.8, 0.1, 0.2)), + })); + layer.advanced.transparency_shapes = false; + document.layers.push(layer); + } + Ok(document) +} + +fn main() -> Result<(), Box> { + for layer_count in [3, 10] { + let document = document_with_strokes(layer_count)?; + let start = Instant::now(); + render_bands(&document, document.bounds(), BAND_ROWS, |_| -> Result<(), &'static str> { Ok(()) })?; + println!("{WIDTH}×{HEIGHT}, {layer_count} TSL-off 6 px outside strokes: {:.3} s", start.elapsed().as_secs_f64()); + } + Ok(()) +} diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index eeb2c97245..deca6f7d5a 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -85,12 +85,16 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth } Adjustment::Posterize { levels } => map_rgb(buf, |c| c.map(|v| posterize(v, *levels))), Adjustment::BrightnessContrast { brightness, contrast, legacy: true } => { - // Legacy: contrast scales around mid-grey, then brightness is added - // (fitted to Photoshop's rendering, exact on the corpus). + // Legacy: contrast scales around 128/255, with brightness added before it when + // contrast is raised and after it when contrast is lowered. Measured on Photoshop + // 27.11, brightness +40 with contrast +30 maps 128 to 185 (brightness first), while + // the corpus's brightness -23 with contrast -26 (psd-tools) needs contrast first; + // contrast 80 keeps 128 at 128 (a 0.5 pivot moved it to 130). let b = brightness / 255.0; let c = contrast.clamp(-100.0, 99.0); let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 }; - map_rgb(buf, |px| px.map(|v| ((v - 0.5) * k + 0.5 + b).clamp(0.0, 1.0))) + let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) }; + map_rgb(buf, |px| px.map(|v| ((v + pre - LEGACY_PIVOT) * k + LEGACY_PIVOT + post).clamp(0.0, 1.0))) } Adjustment::BrightnessContrast { brightness, contrast, .. } => { // Modern (CS3+) Brightness/Contrast, reverse-engineered from Photoshop ground truth @@ -303,12 +307,13 @@ pub fn hue_range_tables(ranges: &[HueRange; 6]) -> [Vec; 3] { } /// One Hue/Saturation evaluation: hue shift in degrees, saturation and lightness in -1..=1. -/// Colorize sets the hue and saturation instead of shifting them. +/// Colorize sets the hue and saturation instead of shifting them; at 0% it leaves only the +/// lightness, as in Photoshop (#2326). Ticking Colorize in the dialog starts saturation at 25%. pub fn hue_saturation(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool) -> [f32; 3] { let (mut hh, mut ss, ll) = rgb_to_hsl(c); if colorize { hh = hue.rem_euclid(360.0) / 360.0; - ss = s.abs().max(0.25); + ss = s.abs().min(1.0); } else { hh = (hh + hue / 360.0).rem_euclid(1.0); ss = (ss * (1.0 + s)).clamp(0.0, 1.0); @@ -820,6 +825,32 @@ mod tone_tests { Buffer { rect, px } } + /// Photoshop 27.11, measured: Brightness/Contrast with Use Legacy on, over a gray ramp + /// (x = 0, 16, …, 240, 255), one row per (brightness, contrast). + #[test] + fn legacy_brightness_contrast_matches_photoshop() { + const X: [u8; 17] = [0, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 255]; + let measured: [(f32, f32, [u8; 17]); 7] = [ + (40.0, 0.0, [40, 56, 72, 88, 104, 120, 136, 152, 168, 184, 200, 216, 232, 248, 255, 255, 255]), + (-40.0, 0.0, [0, 0, 0, 8, 24, 40, 56, 72, 88, 104, 120, 136, 152, 168, 184, 200, 215]), + (0.0, 30.0, [0, 0, 0, 14, 37, 60, 82, 105, 128, 151, 174, 196, 219, 242, 255, 255, 255]), + (0.0, -30.0, [38, 49, 61, 72, 83, 94, 106, 117, 128, 139, 150, 162, 173, 184, 195, 207, 217]), + (40.0, 30.0, [3, 25, 48, 71, 94, 117, 139, 162, 185, 208, 231, 253, 255, 255, 255, 255, 255]), + (100.0, 0.0, [100, 116, 132, 148, 164, 180, 196, 212, 228, 244, 255, 255, 255, 255, 255, 255, 255]), + (0.0, 80.0, [0, 0, 0, 0, 0, 0, 0, 48, 128, 208, 255, 255, 255, 255, 255, 255, 255]), + ]; + for (brightness, contrast, want) in measured { + let rect = Rect::new(0, 0, 17, 1); + let px = X.iter().map(|x| [f32::from(*x) / 255.0; 4]).map(|p| [p[0], p[1], p[2], 1.0]).collect(); + let mut b = Buffer { rect, px }; + apply(&Adjustment::BrightnessContrast { brightness, contrast, legacy: true }, &mut b); + for (i, (p, w)) in b.px.iter().zip(want).enumerate() { + let got = (p[0] * 255.0).round() as i32; + assert!((got - i32::from(w)).abs() <= 1, "b {brightness} c {contrast} at {}: {got} vs Photoshop {w}", X[i]); + } + } + } + #[test] fn color_lookup_with_an_overflowing_stored_size_is_the_identity() { // A stored document's size is untrusted: (2²²)³ × 3 overflows, which once wrapped past @@ -940,6 +971,30 @@ mod tone_tests { assert_eq!(b.px[2], [0.5, 0.5, 0.5, 1.0]); } + #[test] + fn colorize_at_zero_saturation_is_grey() { + // Photoshop: Colorize at 0% saturation leaves no colour, only the pixel's lightness (#2326). + let px = vec![[0.9, 0.2, 0.1, 1.0], [0.1, 0.6, 0.8, 1.0], [0.5, 0.5, 0.5, 1.0]]; + for depth in [None, Some(SampleType::U8), Some(SampleType::U16), Some(SampleType::F32)] { + for lightness in [0.0, 30.0, -30.0] { + let a = Adjustment::HueSaturation { hue: 200.0, saturation: 0.0, lightness, colorize: true, ranges: HueRange::defaults() }; + let mut b = Buffer { rect: Rect::new(0, 0, 3, 1), px: px.clone() }; + apply_depth(&a, &mut b, Transfer::Srgb, depth); + for p in &b.px { + assert!((p[0] - p[1]).abs() < 1e-4 && (p[1] - p[2]).abs() < 1e-4, "{depth:?} lightness {lightness}: {p:?}"); + } + } + // Low saturations still tint: the old 25% floor no longer hides them. + let tint = |saturation: f32| { + let a = Adjustment::HueSaturation { hue: 0.0, saturation, lightness: 0.0, colorize: true, ranges: HueRange::defaults() }; + let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[0.5, 0.5, 0.5, 1.0]] }; + apply_depth(&a, &mut b, Transfer::Srgb, depth); + b.px[0][0] - b.px[0][2] + }; + assert!(tint(10.0) > 0.05 && tint(10.0) < tint(25.0), "{depth:?}: 10% {} vs 25% {}", tint(10.0), tint(25.0)); + } + } + #[test] fn black_white_weights() { let d = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0]; @@ -962,3 +1017,7 @@ mod tone_tests { assert!(a.px.iter().zip(&b.px).any(|(x, y)| x[0] != y[0])); } } + +/// Legacy Brightness/Contrast scales contrast around 128 of 255 (contrast 80 keeps 128 at 128, +/// measured on Photoshop 27.11; a 0.5 pivot moved it to 130). +pub const LEGACY_PIVOT: f32 = 128.0 / 255.0; diff --git a/crates/compose/src/advanced_tests.rs b/crates/compose/src/advanced_tests.rs index 493ecf7d86..c4e437a4b6 100644 --- a/crates/compose/src/advanced_tests.rs +++ b/crates/compose/src/advanced_tests.rs @@ -272,6 +272,94 @@ fn inside_stroke() -> Effect { }) } +fn outside_stroke() -> Effect { + Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 6.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), + }) +} + +#[test] +fn shapeless_stroke_tile_skips_match_full_region_across_blending_settings() { + use photocraft_doc::BlendRange; + + let modes = [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Difference]; + for depth in DEPTHS { + for position in [StrokePosition::Outside, StrokePosition::Inside, StrokePosition::Center] { + for blend in modes { + for fill in [0.0, 0.5, 1.0] { + for blend_if in [false, true] { + let mut d = Document::with_background("t", Size::new(80, 768), ColorMode::Rgb, depth, Color::WHITE); + let mut l = solid(&d, "stroke", Rect::new(28, 318, 52, 342), BLUE); + l.effects.items = vec![Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 6.0, + position, + paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), + })]; + l.blend = blend; + l.fill_opacity = fill; + l.advanced.transparency_shapes = false; + if blend_if { + l.blend_if.set(0, [BlendRange::FULL, BlendRange { black: [128, 128], white: [255, 255] }]); + } + d.layers.push(l); + + let rect = Rect::new(0, 0, 80, 768); + let whole = render_tiled(&d, rect, 1024); + let tiled = render_tiled(&d, rect, 64); + assert_eq!(whole.px, tiled.px, "{depth:?}, {position:?}, {blend:?}, fill={fill}, blend_if={blend_if}"); + + let mut bands = Vec::new(); + render_bands(&d, rect, 256, |band| -> Result<(), ()> { + bands.push(band); + Ok(()) + }) + .unwrap(); + let mut band_pixels = Vec::with_capacity(whole.px.len()); + for band in bands { + band_pixels.extend(band.px); + } + assert_eq!(whole.px, band_pixels, "banded: {depth:?}, {position:?}, {blend:?}, fill={fill}, blend_if={blend_if}"); + } + } + } + } + } +} + +#[test] +fn shapeless_stroke_skip_rejects_advanced_blending_and_masks() { + use photocraft_doc::{BlendRange, Path, Subpath, VectorMask}; + + let d = doc(SampleType::U8, true); + let mut l = solid(&d, "stroke", Rect::new(2, 2, 8, 8), BLUE); + l.effects.items = vec![outside_stroke()]; + l.advanced.transparency_shapes = false; + assert!(shapeless_stroke_bounds(&l).is_some()); + + l.advanced.knockout = Knockout::Deep; + assert!(shapeless_stroke_bounds(&l).is_none(), "knockout changes the backdrop outside the content"); + l.advanced.knockout = Knockout::None; + + l.advanced.blend_interior = true; + assert!(shapeless_stroke_bounds(&l).is_none(), "Blend Interior effects remain on the uncropped path"); + l.advanced.blend_interior = false; + + l.blend_if.set(0, [BlendRange::FULL, BlendRange { black: [128, 128], white: [255, 255] }]); + assert!(shapeless_stroke_bounds(&l).is_none(), "Blend If remains on the uncropped path"); + l.blend_if = Default::default(); + + l.mask = Some(LayerMask::hide_all()); + assert!(shapeless_stroke_bounds(&l).is_none(), "enabled masks remain on the uncropped path"); + l.mask = None; + + l.vector_mask = Some(VectorMask::new(Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)])]))); + assert!(shapeless_stroke_bounds(&l).is_none(), "enabled vector masks remain on the uncropped path"); +} + #[test] fn layer_mask_hides_effects() { for depth in DEPTHS { diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index e2ae63b5b7..c52b39dadf 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -915,6 +915,16 @@ pub fn ranged_lut(contour: &Contour, range: f32) -> Option> { ) } +/// The bevel Contour element's height transfer: the contour alone. Unlike a glow's, its Range is +/// not a `v / range` stretch: that flattened a Linear contour at the default 50 % to a bevel half +/// as wide as Photoshop's (a real 18 px smooth inner bevel at 136 % depth: top-edge highlight gone +/// 15 px in, Photoshop's still 0.19 at 16 px), and dropping it lowers the bad pixels of every +/// contour oracle (bevel-inner-contour-ramp-peak 25.9 → 21.8 %, -double-ring-aa-r70 25.2 → 21.0 %, +/// bevel-pillow-chisel-hard-gloss-and-contour 11.4 → 7.2 %). What Range does instead is still open. +pub fn bevel_contour_lut(c: &photocraft_doc::BevelContour) -> Vec { + contour_lut(&c.contour).unwrap_or_else(|| (0..4096).map(|k| k as f32 / 4095.0).collect()) +} + fn apply_lut(m: Map, l: Option>) -> Map { match l { None => m, @@ -1480,9 +1490,9 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern } h }; - // Contour element: the height profile through the contour over its range. + // Contour element: the height profile through the contour. if let Some(c) = &b.contour { - h = apply_lut(h, Some(ranged_lut(&c.contour, c.range).unwrap_or_else(|| (0..4096).map(|k| k as f32 / 4095.0).collect()))); + h = apply_lut(h, Some(bevel_contour_lut(c))); } // Texture element: the pattern's luminance as extra height, scaled so a full-contrast step // at 100 % depth slopes like the bevel at its depth. @@ -2799,6 +2809,16 @@ mod tests { assert!(at(24, 12) > 0.1 && (at(24, 12) - at(24, 13)).abs() < 1e-3); } + #[test] + fn linear_bevel_contour_leaves_the_height_alone_at_any_range() { + for range in [0.5, 0.7, 1.0] { + let lut = bevel_contour_lut(&photocraft_doc::BevelContour { contour: Contour::Linear, range, anti_alias: false }); + for v in [0.0f32, 0.25, 0.5, 0.75, 1.0] { + assert!((lut_at(&lut, v) - v).abs() < 1e-3, "range {range}: {v} -> {}", lut_at(&lut, v)); + } + } + } + #[test] fn bevel_contour_and_texture_shape_the_height() { let shape = square(40, 6, 34); diff --git a/crates/compose/src/gradient_fill.rs b/crates/compose/src/gradient_fill.rs index e51d4ce467..0e7fe319e3 100644 --- a/crates/compose/src/gradient_fill.rs +++ b/crates/compose/src/gradient_fill.rs @@ -5,8 +5,7 @@ //! The mapping matches the Gradient tool's destructive drag (`photocraft_algo::paint`): a //! Linear gradient runs from the start (t = 0) to the end point (t = 1); Radial, Angle, //! Reflected and Diamond gradients are centred on the start point and reach t = 1 at the end -//! point's distance. A fill made from a drag therefore renders the same pixels as painting that -//! drag (see the tests). +//! point's distance. Fill and painted gradients share this geometry (see the tests). use photocraft_doc::{Fill, GradientStyle}; use photocraft_geom::Rect; @@ -19,8 +18,8 @@ const MIN_LEN: f32 = 0.5; /// A gradient fill's colour ramp, prepared for one render (colours converted once, while the /// document's CMYK profile is active). #[derive(Clone, Debug)] -pub struct Ramp { - color: Vec<(f32, [f32; 4])>, +pub struct Ramp { + color: Vec<(f32, [f32; N])>, mids: Vec, opacity: Vec<(f32, f32)>, } @@ -28,14 +27,17 @@ pub struct Ramp { impl Ramp { /// The ramp of a gradient fill (`None` for other fills). pub fn new(f: &Fill) -> Option { + Self::with_colors(f, |c| { + let rgb = c.to_rgb(); + [rgb[0], rgb[1], rgb[2], c.alpha] + }) + } +} + +impl Ramp { + fn with_colors(f: &Fill, convert: impl Fn(photocraft_doc::Color) -> [f32; N]) -> Option { let Fill::Gradient { stops, opacity_stops, midpoints, .. } = f else { return None }; - let mut color: Vec<(f32, [f32; 4])> = stops - .iter() - .map(|(p, c)| { - let rgb = c.to_rgb(); - (*p, [rgb[0], rgb[1], rgb[2], c.alpha]) - }) - .collect(); + let mut color: Vec<_> = stops.iter().map(|(p, c)| (*p, convert(*c))).collect(); color.sort_by(|a, b| a.0.total_cmp(&b.0)); let mut opacity = opacity_stops.clone(); opacity.sort_by(|a, b| a.0.total_cmp(&b.0)); @@ -43,10 +45,10 @@ impl Ramp { } /// Colour and alpha at `t` (`0..=1`). - pub fn sample(&self, t: f32) -> [f32; 4] { + pub fn sample(&self, t: f32) -> [f32; N] { let mut c = sample_color(&self.color, &self.mids, t); - if !self.opacity.is_empty() { - c[3] *= sample_opacity(&self.opacity, t); + if let Some(alpha) = c.last_mut() { + *alpha *= sample_opacity(&self.opacity, t); } c } @@ -65,8 +67,8 @@ pub fn midpoint_remap(u: f32, m: f32) -> f32 { } } -fn sample_color(stops: &[(f32, [f32; 4])], mids: &[f32], t: f32) -> [f32; 4] { - let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return [0.0; 4] }; +fn sample_color(stops: &[(f32, [f32; N])], mids: &[f32], t: f32) -> [f32; N] { + let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return [0.0; N] }; if t <= first.0 { return first.1; } @@ -97,29 +99,74 @@ fn sample_opacity(stops: &[(f32, f32)], t: f32) -> f32 { /// Adds one level of the shared dither noise to a colour (as the Gradient tool does). pub fn dither(c: &mut [f32; 4], x: i32, y: i32) { + dither_channels(&mut c[..3], x, y); +} + +fn dither_channels(c: &mut [f32], x: i32, y: i32) { + dither_channels_to(c, x, y, None); +} + +/// [`dither_channels`], then rounded to `quantum` steps when given. The noise is a fraction of a +/// level that only means something once the value lands on a level, as it does in a painted +/// gradient: left in float, a dark channel lifted 0.002 above black is still 0.002 to the next +/// blend, and Color Dodge under a bright layer turns that into visible speckle (#2755). +fn dither_channels_to(c: &mut [f32], x: i32, y: i32, quantum: Option) { let n = (photocraft_color::dither_noise(x, y) - 0.5) / 255.0; - for ch in c.iter_mut().take(3) { + for ch in c { *ch = (*ch + n).clamp(0.0, 1.0); + if let Some(q) = quantum { + *ch = (*ch * q + 0.5).floor() / q; + } } } /// Renders a gradient fill over `rect`, laid out in `frame` (row-major RGBA). pub fn render(f: &Fill, rect: Rect, frame: Rect) -> Vec<[f32; 4]> { - let mut px = vec![[0.0f32; 4]; rect.width() as usize * rect.height() as usize]; - let (Some(ramp), Fill::Gradient { angle, scale, style, reverse, offset, dither: dith, .. }) = (Ramp::new(f), f) else { return px }; + render_quantized(f, rect, frame, None) +} + +/// [`render`] with dithered pixels rounded to `quantum` steps (the document depth's levels, see +/// [`crate::adjustment_quantum`]), as a gradient painted into a layer of that depth stores them. +pub fn render_quantized(f: &Fill, rect: Rect, frame: Rect, quantum: Option) -> Vec<[f32; 4]> { + render_ramp(f, rect, frame, Ramp::new(f), quantum) +} + +/// Native CMYK solid/gradient pixels at document depth; patterns return `None`. +/// Use the same samples for rendering and PSD caches to avoid a lossy RGB separation. +pub fn render_cmyk_fill(f: &Fill, rect: Rect, frame: Rect, depth: photocraft_color::SampleType) -> Option { + let native = |c: photocraft_doc::Color| { + let c = c.in_mode(photocraft_color::ColorMode::Cmyk); + [c.c[0], c.c[1], c.c[2], c.c[3], c.alpha] + }; + let mut surface = photocraft_raster::Surface::new(photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Cmyk, depth, true)); + match f { + Fill::Solid(c) => surface.fill_rect(rect, &native(*c)), + Fill::Gradient { .. } => { + let px = render_ramp(f, rect, frame, Ramp::with_colors(f, native), None); + surface.write_region(rect, px.as_flattened()); + } + Fill::Pattern { .. } => return None, + } + surface.prune(); + Some(surface) +} + +fn render_ramp(f: &Fill, rect: Rect, frame: Rect, ramp: Option>, quantum: Option) -> Vec<[f32; N]> { + let mut px = vec![[0.0f32; N]; rect.width() as usize * rect.height() as usize]; + let (Some(ramp), Fill::Gradient { angle, scale, style, reverse, offset, dither: dith, .. }) = (ramp, f) else { return px }; let w = rect.width() as usize; if w == 0 { return px; } // Linear and Reflected end points on whole pixels, as Photoshop (crate::fill_layout). let (angle, scale, offset) = &crate::fill_layout::gradient_layout(*style, *angle, *scale, *offset, frame); - let row = |y: i32, out: &mut [[f32; 4]]| { + let row = |y: i32, out: &mut [[f32; N]]| { for (i, p) in out.iter_mut().enumerate() { let x = rect.x0 + i as i32; let t = crate::effects::gradient_t(*style, *angle, *scale, *reverse, *offset, frame, x as f32 + 0.5, y as f32 + 0.5); *p = ramp.sample(t); if *dith { - dither(p, x, y); + dither_channels_to(p.get_mut(..N.saturating_sub(1)).unwrap_or_default(), x, y, quantum); } } }; @@ -205,6 +252,29 @@ mod tests { const STYLES: [GradientStyle; 5] = [GradientStyle::Linear, GradientStyle::Radial, GradientStyle::Angle, GradientStyle::Reflected, GradientStyle::Diamond]; + #[test] + fn native_cmyk_ramp_interpolates_black_and_alpha_separately() { + let a = Color { mode: photocraft_color::ColorMode::Cmyk, c: [0.0, 0.2, 0.4, 0.6], alpha: 0.5 }; + let b = Color { c: [0.4, 0.6, 0.8, 1.0], alpha: 1.0, ..a }; + let mut f = Fill::gradient(vec![(1.0, b), (0.0, a)], 0.0, 1.0, GradientStyle::Linear, false); + if let Fill::Gradient { midpoints, opacity_stops, .. } = &mut f { + *midpoints = vec![0.25]; + *opacity_stops = vec![(1.0, 0.0), (0.0, 1.0)]; + } + let ramp = Ramp::with_colors(&f, |c| [c.c[0], c.c[1], c.c[2], c.c[3], c.alpha]).unwrap(); + let expected = [0.2, 0.4, 0.6, 0.8, 0.5625]; + for (v, expected) in ramp.sample(0.25).into_iter().zip(expected) { + assert!((v - expected).abs() < 1e-6); + } + let mut dithered = ramp.sample(0.25); + dither_channels(&mut dithered[..4], 3, 7); + assert_eq!(dithered[4], expected[4]); + let noise = (photocraft_color::dither_noise(3, 7) - 0.5) / 255.0; + for i in 0..4 { + assert!((dithered[i] - expected[i] - noise).abs() < 1e-6); + } + } + #[test] fn handles_round_trip() { let frame = Rect::new(10, -4, 210, 96); diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 14585bff05..712033e546 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -97,7 +97,7 @@ pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer { fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer { let tile = tile.max(1); // Lab documents mix Normal blending in CIELAB, as Photoshop does (psblend::LAB_MIX); 32-bit - // documents don't clip Add / Divide at 1 (psblend::HDR). + // documents don't clip Add / Divide or Hue / Saturation / Color / Luminosity at 1 (psblend::HDR). let lab = doc.mode == photocraft_color::ColorMode::Lab; let hdr = doc.depth == photocraft_color::SampleType::F32; // CMYK layers are read through the document's own CMYK profile (thread-local scope). @@ -734,7 +734,16 @@ fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option { LayerContent::Fill(f) => match &layer.fill_cache { // Photoshop's own rendering, valid while the fill is unchanged. Some(c) if c.fill == *f => surface_to_buffer(&c.surface, rect), - _ => render_fill(f, rect, fill_frame(layer, cx.canvas), cx.patterns), + _ if cx.mode == photocraft_color::ColorMode::Cmyk => { + let frame = fill_frame(layer, cx.canvas); + let native_rect = if matches!(f, Fill::Solid(_)) { Rect::new(0, 0, 1, 1) } else { rect }; + match gradient_fill::render_cmyk_fill(f, native_rect, frame, cx.depth) { + Some(s) if matches!(f, Fill::Solid(_)) => Buffer::filled(rect, s.rgba(0, 0)), + Some(s) => surface_to_buffer(&s, rect), + None => render_fill(f, rect, frame, cx.patterns, adjustment_quantum(cx.depth)), + } + } + _ => render_fill(f, rect, fill_frame(layer, cx.canvas), cx.patterns, adjustment_quantum(cx.depth)), }, LayerContent::Adjustment(_) => return None, _ => match layer.surface() { @@ -814,17 +823,19 @@ pub fn fill_frame(layer: &Layer, canvas: Rect) -> Rect { /// compositor does) but without applying the masks: the pixels a PSD fill layer stores. pub fn render_fill_content(layer: &Layer, f: &Fill, canvas: Rect, patterns: &[Pattern]) -> Buffer { let prepared = pattern::PreparedPatterns::new(patterns, pattern::PREPARED_PATTERN_BYTES); - render_fill(f, canvas, fill_frame(layer, canvas), &prepared) + render_fill(f, canvas, fill_frame(layer, canvas), &prepared, None) } -fn render_fill(f: &Fill, rect: Rect, canvas: Rect, patterns: &pattern::PreparedPatterns<'_>) -> Buffer { +/// `quantum`: the document depth's levels, which a dithered gradient is rounded to +/// ([`gradient_fill::render_quantized`]); `None` keeps it in float. +fn render_fill(f: &Fill, rect: Rect, canvas: Rect, patterns: &pattern::PreparedPatterns<'_>, quantum: Option) -> Buffer { match f { Fill::Solid(c) => { let rgb = c.to_rgb(); Buffer::filled(rect, [rgb[0], rgb[1], rgb[2], c.alpha]) } // Gradient geometry relative to the layer's frame, independent of the render rect. - Fill::Gradient { .. } => Buffer { rect, px: gradient_fill::render(f, rect, canvas) }, + Fill::Gradient { .. } => Buffer { rect, px: gradient_fill::render_quantized(f, rect, canvas, quantum) }, // Laid out from the layer's frame when linked; transparent if the pattern is missing. Fill::Pattern { name, scale, id, angle, link, phase } => match patterns.get(id, name) { Some(tile) => Buffer { rect, px: pattern::render(&tile, &pattern::Placement::new(canvas, *link, *phase, *scale, *angle), rect) }, @@ -842,7 +853,8 @@ fn empty_in(layer: &Layer, rect: Rect) -> bool { // Effects reach at most `margin` beyond the layer's pixels (when it is transparent // outside them): render tiles away from a small text layer skip it entirely. let canvas = Rect::new(i32::MIN / 4, i32::MIN / 4, i32::MAX / 4, i32::MAX / 4); - return transparent_outside(layer) && layer_bounds(layer, canvas).inflate(effects::margin(layer)).intersect(&rect).is_empty(); + let bounds = shapeless_stroke_bounds(layer).unwrap_or_else(|| layer_bounds(layer, canvas)); + return transparent_outside(layer) && bounds.inflate(effects::margin(layer)).intersect(&rect).is_empty(); } match &layer.content { LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_) => match layer.surface() { @@ -853,6 +865,47 @@ fn empty_in(layer: &Layer, rect: Rect) -> bool { } } +/// For the narrow `tsly=0` case where only local stroke effects are enabled, the effects cannot +/// contribute outside the stroke reach from the layer's pixels. Other `tsly=0` effects can cover +/// the full layer, and advanced blending can change the backdrop independently of those pixels. +fn shapeless_stroke_bounds(layer: &Layer) -> Option { + use photocraft_doc::Knockout; + + if layer.advanced.transparency_shapes + || layer.advanced.knockout != Knockout::None + || layer.advanced.blend_interior + || layer.clipped + || !layer.blend_if.is_default() + || layer.mask.as_ref().is_some_and(|m| m.enabled) + || layer.vector_mask.as_ref().is_some_and(|m| m.enabled) + || !transparent_outside(layer) + || !effects::maps_are_local(layer) + { + return None; + } + + if !layer.effects.enabled + || !layer.effects.items.iter().any(|e| e.enabled()) + || layer.effects.items.iter().any(|e| e.enabled() && !matches!(e, photocraft_doc::Effect::Stroke(_))) + { + return None; + } + + match &layer.content { + LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_) => { + let b = layer.surface().map_or(Rect::EMPTY, bounds::content_bounds); + // A filled shape's effects follow its outline, also where its fill is transparent (as + // in `layer_bounds`). + Some(match effect_outline(layer).and_then(|_| paint_bounds(layer)) { + Some(p) if !b.is_empty() => b.union(&p), + Some(p) => p, + None => b, + }) + } + _ => None, + } +} + /// Blending Options › Channels as per-channel weights over display RGB (1 = the layer's result, /// 0 = the backdrop's value kept), or `None` when every channel blends. RGB documents map R, G, B /// directly and a grayscale document's single channel covers all three; other modes composite in @@ -1105,7 +1158,10 @@ fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[L fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) { let rect = backdrop.rect; - if empty_in(layer, rect) { + // A TSL-off stroke base that is transparent here can still have visible clipped siblings; + // keep the full clipping path for it instead of the stroke-bounds shortcut. Other bases keep + // the usual skip (their clipped layers vanish with them). + if (clipped.is_empty() || shapeless_stroke_bounds(layer).is_none()) && empty_in(layer, rect) { return; } let opacity = layer.opacity * layer.fill_opacity; @@ -1209,12 +1265,12 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer *p = psblend::composite(BlendMode::Normal, *p, *s, 1.0); p[3] *= mask_k(&mask, i); } - blend_into(backdrop, &content, layer.blend, opacity); + blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, 1.0); return; } let Some(mut content) = render_content(layer, rect, cx) else { return }; advanced::composite_clipped(clipped, &mut content, cx); - blend_into_g(backdrop, &content, layer.blend, opacity, text_gamma(layer)); + blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, text_gamma(layer)); } /// A layer with effects (and its clipping group) onto `backdrop`. @@ -1581,6 +1637,19 @@ fn blend_into(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32 blend_into_g(backdrop, src, mode, opacity, 1.0); } +/// [`blend_into_g`] for a layer's own `opacity` and `fill`: Fill is part of the blend for +/// Photoshop's special eight ([`psblend::composite_fill`]), plain coverage otherwise. +fn blend_into_fill(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, fill: f32, gamma: f32) { + if fill >= 1.0 || !psblend::fill_is_special(mode) { + return blend_into_g(backdrop, src, mode, opacity * fill, gamma); + } + for (b, s) in backdrop.px.iter_mut().zip(&src.px) { + if s[3] > 0.0 { + *b = psblend::composite_fill(mode, *b, *s, opacity, fill, gamma); + } + } +} + /// [`blend_into`] mixing coverage in a `gamma` space (type layers). fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, gamma: f32) { let rect = backdrop.rect; diff --git a/crates/compose/src/psblend.rs b/crates/compose/src/psblend.rs index 063e32ef5a..afd8412347 100644 --- a/crates/compose/src/psblend.rs +++ b/crates/compose/src/psblend.rs @@ -76,8 +76,9 @@ thread_local! { /// (Photoshop composites Lab documents in Lab; an anti-aliased edge between two colours /// differs by up to 14 / 255 from an sRGB mix: psd-tools stroke-color-descriptors-lab). pub static LAB_MIX: std::cell::Cell = const { std::cell::Cell::new(false) }; - /// Set while rendering a 32-bit float document: Linear Dodge (Add) and Divide don't clip at 1 - /// ([`generic::blend_rgb_hdr`]), as in Photoshop's 32-bit mode; integer depths clip. + /// Set while rendering a 32-bit float document: Linear Dodge (Add) and Divide don't clip at 1, + /// and Hue, Saturation, Color and Luminosity keep values above 1 ([`generic::blend_rgb_hdr`]), + /// as in Photoshop's 32-bit mode; integer depths clip. pub static HDR: std::cell::Cell = const { std::cell::Cell::new(false) }; } @@ -120,6 +121,76 @@ pub fn blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] { } } +/// Photoshop's "special eight": the modes for which a layer's Fill isn't just less coverage. +/// Below 100% Fill, Photoshop blends at full strength a source pulled toward the mode's neutral +/// colour, the one that leaves the backdrop unchanged (Hard Mix has its own formula); Opacity +/// still mixes the result in. Measured on Photoshop 27.11 (Fill 50%, eight modes, ±1 level). +pub fn fill_is_special(mode: BlendMode) -> bool { + matches!( + mode, + BlendMode::ColorBurn + | BlendMode::LinearBurn + | BlendMode::ColorDodge + | BlendMode::LinearDodge + | BlendMode::VividLight + | BlendMode::LinearLight + | BlendMode::HardMix + | BlendMode::Difference + ) +} + +/// [`blend_rgb`] for a layer at `fill` (0..1): the special eight ([`fill_is_special`]) change +/// their blend below 100%; every other mode, or `fill >= 1`, is plain [`blend_rgb`]. +pub fn blend_rgb_fill(mode: BlendMode, cb: [f32; 3], cs: [f32; 3], fill: f32) -> [f32; 3] { + let f = fill.clamp(0.0, 1.0); + if f >= 1.0 || !fill_is_special(mode) { + return blend_rgb(mode, cb, cs); + } + let toward = |neutral: f32| -> [f32; 3] { std::array::from_fn(|i| neutral + (cs[i] - neutral) * f) }; + match mode { + // Hard Mix at Fill f: (cb − f·(1 − cs)) / (1 − f), clamped. At f = 1 it is the hard + // threshold (cb + cs ≥ 1), at f = 0 the backdrop. + BlendMode::HardMix => std::array::from_fn(|i| ((cb[i] - f * (1.0 - cs[i])) / (1.0 - f)).clamp(0.0, 1.0)), + BlendMode::ColorBurn | BlendMode::LinearBurn => blend_rgb(mode, cb, toward(1.0)), + BlendMode::VividLight | BlendMode::LinearLight => blend_rgb(mode, cb, toward(0.5)), + _ => blend_rgb(mode, cb, toward(0.0)), + } +} + +/// [`composite_gamma`] for a layer with separate `opacity` and `fill`. Normal modes take both +/// as coverage, as before. For the special eight below 100% Fill, coverage over the backdrop is +/// `opacity` and the blend is [`blend_rgb_fill`]; over transparency, Fill stays coverage, so a +/// layer over nothing looks as it did. +pub fn composite_fill(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: f32, fill: f32, gamma: f32) -> [f32; 4] { + if fill >= 1.0 || !fill_is_special(mode) { + return composite_gamma(mode, backdrop, source, opacity * fill.clamp(0.0, 1.0), gamma); + } + let ab = backdrop[3]; + let over = source[3] * opacity; + let alone = over * fill.clamp(0.0, 1.0); + if over <= 0.0 { + return backdrop; + } + let ao = ab + (1.0 - ab) * alone; + if ao <= 0.0 { + return [0.0; 4]; + } + let cb = [backdrop[0], backdrop[1], backdrop[2]]; + let cs = [source[0], source[1], source[2]]; + let b = blend_rgb_fill(mode, cb, cs, fill); + let (wb, wbs, ws) = (ab * (1.0 - over) / ao, ab * over / ao, (1.0 - ab) * alone / ao); + let mut out = [0.0f32; 4]; + for i in 0..3 { + out[i] = if gamma == 1.0 { + wb * cb[i] + wbs * b[i] + ws * cs[i] + } else { + text_decode(wb * text_encode(cb[i], gamma) + wbs * text_encode(b[i], gamma) + ws * text_encode(cs[i], gamma), gamma) + }; + } + out[3] = ao; + out +} + /// Straight-alpha source over straight-alpha backdrop (W3C/PDF general /// formula) using [`blend_rgb`]. pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: f32) -> [f32; 4] { @@ -250,6 +321,67 @@ mod tests { use super::*; + /// Photoshop 27.11, measured: Background #c89664, a layer #64b4dc at Fill 50% (128/255), + /// Opacity 100%, in each of the special eight. Values are the composite at 8 bits. + #[test] + fn special_eight_fill_matches_photoshop() { + let measured: [(BlendMode, [u8; 3]); 8] = [ + (BlendMode::ColorBurn, [176, 132, 88]), + (BlendMode::LinearBurn, [122, 112, 82]), + (BlendMode::ColorDodge, [249, 232, 176]), + (BlendMode::LinearDodge, [250, 240, 210]), + (BlendMode::VividLight, [193, 188, 157]), + (BlendMode::LinearLight, [171, 202, 192]), + (BlendMode::HardMix, [245, 225, 165]), + (BlendMode::Difference, [150, 60, 10]), + ]; + let px = |c: [u8; 3]| [f32::from(c[0]) / 255.0, f32::from(c[1]) / 255.0, f32::from(c[2]) / 255.0, 1.0]; + let (b, t) = (px([200, 150, 100]), px([100, 180, 220])); + for (mode, want) in measured { + let got = composite_fill(mode, b, t, 1.0, 128.0 / 255.0, 1.0); + for i in 0..3 { + assert!((got[i] * 255.0 - f32::from(want[i])).abs() <= 1.5, "{mode:?} channel {i}: {} vs Photoshop {}", got[i] * 255.0, want[i]); + } + // At 100% Fill it is the plain blend; at 0% the backdrop. + assert_eq!(composite_fill(mode, b, t, 1.0, 1.0, 1.0), composite(mode, b, t, 1.0), "{mode:?}"); + let none = composite_fill(mode, b, t, 1.0, 0.0, 1.0); + assert!((0..3).all(|i| (none[i] - b[i]).abs() < 1e-5), "{mode:?} at Fill 0: {none:?}"); + } + // A second measurement: Background #3c78b4, layer #d25a1e at Fill 25% (64/255) and 75% + // (191/255). Hard Mix at 75% is 3 levels off in red: dividing by 1 − f = 0.25 magnifies + // Photoshop's 8-bit rounding. + let confirm: [(BlendMode, [u8; 3], [u8; 3]); 8] = [ + (BlendMode::ColorBurn, [51, 94, 159], [30, 0, 33]), + (BlendMode::LinearBurn, [49, 79, 124], [26, 0, 11]), + (BlendMode::ColorDodge, [76, 132, 186], [156, 163, 197]), + (BlendMode::LinearDodge, [113, 143, 188], [217, 187, 202]), + (BlendMode::VividLight, [71, 109, 162], [117, 81, 80]), + (BlendMode::LinearLight, [100, 100, 130], [183, 63, 33]), + (BlendMode::HardMix, [65, 105, 166], [102, 0, 47]), + (BlendMode::Difference, [7, 97, 172], [97, 53, 158]), + ]; + let (b2, t2) = (px([60, 120, 180]), px([210, 90, 30])); + for (mode, q, tq) in confirm { + for (fill, want) in [(64.0 / 255.0, q), (191.0 / 255.0, tq)] { + let got = composite_fill(mode, b2, t2, 1.0, fill, 1.0); + let tol = if mode == BlendMode::HardMix { 3.5 } else { 1.5 }; + for i in 0..3 { + assert!( + (got[i] * 255.0 - f32::from(want[i])).abs() <= tol, + "{mode:?} fill {fill} channel {i}: {} vs Photoshop {}", + got[i] * 255.0, + want[i] + ); + } + } + } + // Other modes keep Fill as coverage, like Opacity. + assert_eq!(composite_fill(BlendMode::Multiply, b, t, 0.8, 0.5, 1.0), composite(BlendMode::Multiply, b, t, 0.4)); + // Over transparency Fill is still coverage for the special eight. + let r = composite_fill(BlendMode::LinearDodge, [0.0; 4], t, 1.0, 0.5, 1.0); + assert!((r[3] - 0.5).abs() < 1e-6 && (r[0] - t[0]).abs() < 1e-6, "{r:?}"); + } + #[test] fn vivid_light_source_extremes_win() { assert_eq!(vivid_light_ps(0.0, 1.0), 1.0); @@ -274,6 +406,20 @@ mod tests { assert!((color_dodge(0.25, 0.5) - 0.5).abs() < 1e-6); } + #[test] + fn hdr_nests_and_restores() { + let bright = |m| blend_rgb(m, [0.5, 0.5, 0.5], [1.0, 0.0, 0.0])[0]; + with_hdr(true, || { + // A 32-bit document keeps Color's red above 1. + assert!(bright(BlendMode::Color) > 1.1); + // Another tile's (or document's) scope run inside this one doesn't reset it. + with_hdr(false, || assert!(bright(BlendMode::Color) <= 1.0)); + assert!(HDR.get()); + }); + assert!(!HDR.get()); + assert!(bright(BlendMode::Color) <= 1.0); + } + #[test] fn other_modes_delegate() { for m in BlendMode::LAYER_MODES { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3aace8056c..e777ad330e 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -87,6 +87,57 @@ fn every_blend_mode_matches_reference_on_opaque_pixels() { } } +/// One opaque pixel: `top` in `mode` over `bottom`, in a document of `depth`. +fn blend_px(depth: SampleType, mode: BlendMode, bottom: [f32; 3], top: [f32; 3]) -> [f32; 4] { + let mut d = Document::new("b", Size::new(1, 1), ColorMode::Rgb, depth); + let fmt = d.pixel_format(); + let mut b = Layer::raster("b", fmt); + b.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &photocraft_raster::from_rgba(&fmt, [bottom[0], bottom[1], bottom[2], 1.0])); + let mut t = Layer::raster("t", fmt); + t.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &photocraft_raster::from_rgba(&fmt, [top[0], top[1], top[2], 1.0])); + t.blend = mode; + d.layers = vec![b, t]; + px(&d, 0, 0) +} + +#[test] +fn non_separable_modes_keep_values_above_one_in_32_bit() { + use photocraft_color::blend::{blend_rgb_within, lum}; + let modes = [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color, BlendMode::Luminosity]; + // Hue over a grey of 2: the grey (the backdrop has no saturation), not ClipColor dividing + // by the rounding noise of `max − lum` (Hue over (4, 3.9999, 4) gave 27.7 in blue). + for bg in [[2.0, 2.0, 2.0], [2.0, 2.0, 1.999_99], [4.0, 3.999_9, 4.0]] { + for m in modes { + for top in [[3.0, 0.25, 0.125], [2.0, 2.0, 2.0], [4.0, 3.999_9, 4.0]] { + let got = blend_px(SampleType::F32, m, bg, top); + let want = blend_rgb_within(m, bg, top, f32::MAX); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1e-4 * w.abs().max(1.0)), "{m:?} {bg:?} {top:?}: {got:?} vs {want:?}"); + assert!(got.iter().all(|v| v.is_finite() && *v >= -1e-5), "{m:?} {bg:?} {top:?}: {got:?}"); + } + } + } + // A saturated colour keeps its brightness above 1 in 32-bit, and is clipped into 0..1 + // (exactly as before) in 8/16-bit. + let (bg, top) = ([0.5, 0.5, 0.5], [1.0, 0.0, 0.0]); + let hdr = blend_px(SampleType::F32, BlendMode::Color, bg, top); + assert!((hdr[0] - 1.2).abs() < 1e-4 && (lum([hdr[0], hdr[1], hdr[2]]) - 0.5).abs() < 1e-4, "{hdr:?}"); + for depth in [SampleType::U8, SampleType::U16] { + for m in modes { + let got = blend_px(depth, m, bg, top); + let want = photocraft_color::blend::blend_rgb(m, bg, top); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1.0 / 255.0), "{m:?} {depth:?}: {got:?} vs {want:?}"); + } + let got = blend_px(depth, BlendMode::Color, bg, top); + assert!(got[0] <= 1.0 && got[0] > 0.9, "{depth:?}: {got:?}"); + } + // A 32-bit document's in-range colours that stay in range are unchanged. + for m in modes { + let got = blend_px(SampleType::F32, m, [0.6, 0.3, 0.2], [0.2, 0.7, 0.5]); + let want = photocraft_color::blend::blend_rgb(m, [0.6, 0.3, 0.2], [0.2, 0.7, 0.5]); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1e-5), "{m:?}: {got:?} vs {want:?}"); + } +} + #[test] fn layer_mask_hides_pixels() { let mut d = doc_white(4, 4); @@ -316,13 +367,60 @@ fn solid_and_gradient_fill_layers() { let g = Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, photocraft_doc::GradientStyle::Linear, false); let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES); - let buf = render_fill(&g, Rect::new(0, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns); + let buf = render_fill(&g, Rect::new(0, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns, None); // tile independence: a 1px render of the right edge equals the full render - let one = render_fill(&g, Rect::new(9, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns); + let one = render_fill(&g, Rect::new(9, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns, None); assert_eq!(one.px[0], buf.px[9]); assert!(buf.px[0][0] < buf.px[9][0], "left dark, right light"); } +fn dithered_gradient(stops: Vec<(f32, Color)>, angle: f32) -> Fill { + let mut f = Fill::gradient(stops, angle, 1.0, photocraft_doc::GradientStyle::Linear, false); + if let Fill::Gradient { dither, .. } = &mut f { + *dither = true; + } + f +} + +/// #2755: a dithered gradient fill under a bright Color Dodge gradient. Dodge divides by +/// `1 − cs`, so the dither's fraction of a level, left in float, came out as speckle several +/// levels high (a black channel lifted to 4/255: coloured fringes). On an 8-bit document the +/// dithered pixels sit on levels, as a painted gradient's do, so a channel at 0 stays 0. +#[test] +fn dithered_gradient_fills_under_color_dodge_keep_black_channels() { + let mut d = doc_white(128, 32); + // Red to blue: green is 0 everywhere. + let bottom = dithered_gradient(vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], 0.0); + d.layers.push(Layer::new("bottom", LayerContent::Fill(bottom))); + let top = dithered_gradient(vec![(0.0, Color::rgb(0.88, 0.88, 0.88)), (1.0, Color::rgb(0.97, 0.97, 0.97))], 90.0); + let mut l = Layer::new("top", LayerContent::Fill(top)); + l.blend = BlendMode::ColorDodge; + d.layers.push(l); + for (i, p) in flatten(&d).px.iter().enumerate() { + assert_eq!(p[1], 0.0, "pixel {i}: green lifted off black to {} levels", p[1] * 255.0); + } +} + +/// Dithered gradient pixels land on the depth's levels; float documents and undithered +/// gradients keep their float values. +#[test] +fn dithered_gradient_fill_is_rounded_to_the_document_depth() { + let stops = vec![(0.0, Color::rgb(0.1, 0.4, 0.0)), (1.0, Color::rgb(0.9, 0.2, 1.0))]; + let rect = Rect::new(0, 0, 64, 8); + let f = dithered_gradient(stops.clone(), 0.0); + for depth in [SampleType::U8, SampleType::U16] { + let q = adjustment_quantum(depth).unwrap(); + for p in gradient_fill::render_quantized(&f, rect, rect, Some(q)) { + for v in &p[..3] { + assert_eq!((v * q).round() / q, *v, "{depth:?}: {v} is between levels"); + } + } + } + assert_eq!(gradient_fill::render_quantized(&f, rect, rect, None), gradient_fill::render(&f, rect, rect)); + let plain = Fill::gradient(stops, 0.0, 1.0, photocraft_doc::GradientStyle::Linear, false); + assert_eq!(gradient_fill::render_quantized(&plain, rect, rect, Some(255.0)), gradient_fill::render(&plain, rect, rect)); +} + #[test] fn dissolve_coverage_matches_opacity() { let mut d = Document::new("d", Size::new(64, 64), ColorMode::Rgb, SampleType::U8); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1cc8502f0e..5d6ce28f67 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -937,7 +937,18 @@ impl Document { } pub fn path_of(&self, id: LayerId) -> Option { - self.walk().into_iter().find(|(_, _, l)| l.id == id).map(|(p, _, _)| p) + fn rec(layers: &[Layer], id: LayerId, path: &mut LayerPath) -> bool { + for (i, l) in layers.iter().enumerate() { + path.push(i); + if l.id == id || l.children().is_some_and(|ch| rec(ch, id, path)) { + return true; + } + path.pop(); + } + false + } + let mut path = Vec::new(); + rec(&self.layers, id, &mut path).then_some(path) } /// The locks in force on the layer at `path`: its own and those of every group around it, @@ -952,8 +963,10 @@ impl Document { } pub fn layer(&self, id: LayerId) -> Option<&Layer> { - let path = self.path_of(id)?; - self.layer_at(&path) + fn rec(layers: &[Layer], id: LayerId) -> Option<&Layer> { + layers.iter().find_map(|l| if l.id == id { Some(l) } else { l.children().and_then(|ch| rec(ch, id)) }) + } + rec(&self.layers, id) } pub fn layer_mut(&mut self, id: LayerId) -> Option<&mut Layer> { @@ -1198,6 +1211,27 @@ mod tests { assert_eq!(d.layer_count(), 4); } + /// `layer` and `path_of` search the tree directly (no `walk`): they must agree with it at + /// every depth, and miss cleanly. + #[test] + fn layer_lookup_agrees_with_walk() { + let mut d = doc(); + let format = d.pixel_format(); + let leaf = |n: &str| Layer::raster(n, format); + let deep = Layer::group("A", vec![leaf("a0"), Layer::group("B", vec![leaf("b0"), Layer::group("C", vec![leaf("c0")]), leaf("b2")]), leaf("a2")]); + d.insert_above(None, deep); + d.insert_above(None, leaf("top")); + let walk: Vec<(LayerPath, LayerId)> = d.walk().into_iter().map(|(p, _, l)| (p, l.id)).collect(); + assert_eq!(walk.len(), 10); + for (path, id) in &walk { + assert_eq!(d.path_of(*id).as_ref(), Some(path)); + assert_eq!(d.layer(*id).map(|l| l.id), Some(*id)); + assert_eq!(d.layer_mut(*id).map(|l| l.id), Some(*id)); + } + let missing = Layer::raster("elsewhere", d.pixel_format()).id; + assert!(d.path_of(missing).is_none() && d.layer(missing).is_none() && d.layer_mut(missing).is_none()); + } + #[test] fn a_locked_group_locks_its_contents() { let mut d = doc(); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 97b8a1f4cd..fa15c30986 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -44,3 +44,5 @@ photocraft-affinity = { workspace = true, features = ["synth"] } photocraft-raw = { workspace = true, features = ["testgen"] } # WebAssembly text -> binary for plug-in test modules (Apache-2.0 WITH LLVM-exception). wat = "1.228" +# Synthetic multi-part EXR fixtures for the parts-commands tests. +exr = { version = "1.73", default-features = false } diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index 30fe5c7d63..414234db24 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -1,5 +1,5 @@ //! Window › Actions, as engine commands (`actions.record` / `stop` / `play` / `list` / `get` / -//! `delete`). +//! `delete` / `move` / `rename`). //! //! The list lives on [`Session`] so the panel, the CLI, the control channel and MCP share one //! copy. Recording copies replayable journal entries (commands whose [`CommandSpec::journal`] is @@ -67,11 +67,19 @@ impl ActionState { pub fn replayable(id: &str) -> bool { // Again journals its concrete transform too; recording the wrapper would replay it twice. id == "actions.play" - || (!id.starts_with("actions.") && id != "edit.transform.again" && (shell_view_command(id) || crate::commands::find(id).is_some_and(|c| c.journal))) + || (!id.starts_with("actions.") + && id != "edit.transform.again" + && (shell_view_command(id) || shell_save_command(id) || crate::commands::find(id).is_some_and(|c| c.journal))) +} + +/// File › Save and Save As, recorded by the desktop shell once the save has a path (#2032). +/// The shell replays them; batch and droplets write to their destination folder instead. +pub fn shell_save_command(id: &str) -> bool { + matches!(id, "file.save" | "file.saveAs") } /// View-only menu commands recorded by the desktop shell. Headless playback reports -/// them as unsupported instead of silently dropping them. +/// them as unsupported instead of silently dropping them; Batch and droplets skip them. pub fn shell_view_command(id: &str) -> bool { matches!(id, "view.fitOnScreen" | "view.actualPixels" | "view.zoomIn" | "view.zoomOut") } @@ -380,6 +388,49 @@ fn delete(s: &mut Session, p: &Value) -> Result { Ok(json!({"deleted": name})) } +/// Prefix of the persisted shortcut override that plays a named action (`actions.play:`). +pub const PLAY_SHORTCUT_PREFIX: &str = "actions.play:"; + +/// Rename an action in place. Nested `actions.play` steps that call it by name, and its +/// function-key binding, follow the new name so nothing silently stops working. +fn rename(s: &mut Session, p: &Value) -> Result { + not_playing(s, "actions.rename")?; + let idx = resolve(&s.actions.list, p, "actions.rename")?; + let name = match p.get("name") { + Some(Value::String(n)) if !n.trim().is_empty() => n.clone(), + Some(Value::String(_)) => return Err(bad("actions.rename", "\"name\" is empty")), + Some(_) => return Err(bad("actions.rename", "\"name\" must be a string")), + None => return Err(bad("actions.rename", "needs \"name\"")), + }; + let old = s.actions.list.get(idx).map(|a| a.name.clone()).ok_or_else(|| bad("actions.rename", "no such action"))?; + if old == name { + return Ok(json!({"action": name, "index": idx})); + } + if s.actions.list.iter().any(|a| a.name == name) { + return Err(bad("actions.rename", "an action with this name already exists")); + } + let pending = s.actions.recording.map_or(s.journal.len(), |(start, _)| start); + let calls = s.actions.list.iter_mut().flat_map(|a| a.steps.iter_mut()).chain(s.journal.iter_mut().skip(pending)); + for (id, params) in calls { + if id == "actions.play" && params.get("action").and_then(Value::as_str) == Some(old.as_str()) { + params["action"] = json!(name); + } + } + if let Some(action) = s.actions.list.get_mut(idx) { + action.name = name.clone(); + } + s.actions.touch(); + let (from, to) = (format!("{PLAY_SHORTCUT_PREFIX}{old}"), format!("{PLAY_SHORTCUT_PREFIX}{name}")); + if s.prefs().shortcuts.contains_key(&from) { + s.edit_prefs(|prefs| { + if let Some(key) = prefs.shortcuts.remove(&from) { + prefs.shortcuts.insert(to, key); + } + }); + } + Ok(json!({"action": name, "index": idx})) +} + pub fn specs() -> Vec { vec![ CommandSpec { @@ -452,6 +503,16 @@ pub fn specs() -> Vec { run: delete, journal: false, }, + CommandSpec { + id: "actions.rename", + label: "Rename Action", + menu: &[], + shortcut: None, + params: r##"{"action":name|index, "name":str} → {action, index}. The name must not be empty or whitespace-only and must be unique. Nested Play Action steps and the action's function key follow the rename."##, + enabled: always, + run: rename, + journal: false, + }, ] } @@ -464,6 +525,30 @@ mod tests { s.active().unwrap().doc.layers[layer].surface().unwrap().pixel(x, y) } + #[test] + fn rename_keeps_calls_and_function_key_and_rejects_bad_names() { + let mut s = Session::new(); + s.actions.list.push(Action { name: "Action 1".into(), steps: vec![("layer.new.layer".into(), json!({}))] }); + s.actions.list.push(Action { name: "Caller".into(), steps: vec![("actions.play".into(), json!({"action": "Action 1"}))] }); + s.edit_prefs(|p| p.shortcuts.insert(format!("{PLAY_SHORTCUT_PREFIX}Action 1"), "F2".into())); + assert_eq!(s.execute("actions.rename", json!({"action": 0, "name": "Sepia"})).unwrap()["action"], "Sepia"); + assert_eq!(s.execute("actions.list", json!({})).unwrap()["actions"][0]["name"], "Sepia"); + assert_eq!(s.actions.list[1].steps[0].1["action"], "Sepia"); + assert_eq!(s.prefs().shortcuts.get("actions.play:Sepia").map(String::as_str), Some("F2")); + assert!(!s.prefs().shortcuts.contains_key("actions.play:Action 1")); + for bad in [ + json!({"action": 0, "name": ""}), + json!({"action": 0, "name": " "}), + json!({"action": 0, "name": "Caller"}), + json!({"action": 0, "name": 7}), + json!({"action": 9, "name": "X"}), + json!({"action": 0}), + ] { + assert!(s.execute("actions.rename", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(s.actions.list[0].name, "Sepia"); + } + fn record_red(s: &mut Session, depth: u32) { s.execute("file.new", json!({"width": 40, "height": 40, "depth": depth})).unwrap(); s.execute("actions.record", json!({"name": "Red"})).unwrap(); diff --git a/crates/engine/src/adjust_cmds/tests.rs b/crates/engine/src/adjust_cmds/tests.rs index 7c23b545ee..9f8f07db63 100644 --- a/crates/engine/src/adjust_cmds/tests.rs +++ b/crates/engine/src/adjust_cmds/tests.rs @@ -329,6 +329,22 @@ fn mask_at(s: &Session, x: i32, y: i32) -> f32 { d.doc.layer(d.active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0) } +/// #2456: Edit > Fill (and the fill keys) fill the targeted mask of an adjustment layer. +#[test] +fn fill_works_on_an_adjustment_layers_mask() { + let mut s = session(8, "rgb"); + s.execute("layer.newAdjustmentLayer.curves", json!({})).unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + let mask = json!({"target": "mask"}); + assert!(!s.is_enabled("edit.fill"), "no pixels to fill without the mask target"); + assert!(s.is_enabled_with("edit.fill", &mask)); + assert!(s.is_enabled_with("edit.fillForeground", &mask)); + s.execute("edit.fill", json!({"contents": "black", "target": "mask"})).unwrap(); + assert_eq!(mask_at(&s, 3, 3), 0.0, "the mask is filled black"); + s.execute("edit.fill", json!({"contents": "white", "target": "mask"})).unwrap(); + assert_eq!(mask_at(&s, 3, 3), 1.0); +} + /// #780: with the layer mask targeted, Invert (⌘I) inverts the mask, past the canvas too, and /// leaves the layer's pixels alone; one history step. #[test] diff --git a/crates/engine/src/adjust_params.rs b/crates/engine/src/adjust_params.rs index 7bad00f443..00fb67dd03 100644 --- a/crates/engine/src/adjust_params.rs +++ b/crates/engine/src/adjust_params.rs @@ -391,6 +391,9 @@ fn hue_saturation(r: &Reader<'_>, base: Option<&Adjustment>) -> Result Result { let l = doc.layer(ids[i]).ok_or(EngineError::NoLayer(ids[i]))?; let surf = l.surface().ok_or(EngineError::NoLayer(ids[i]))?; let b = surf.content_bounds(); - let px = if b.is_empty() { surf.clone() } else { warp_placed(surf, b, (0.0, 0.0), pl, (0.0, 0.0), interp) }; - let mask = l.mask.as_ref().map(|m| { - let mb = m.surface.content_bounds(); - if mb.is_empty() { m.surface.clone() } else { warp_placed(&m.surface, mb, (0.0, 0.0), pl, (0.0, 0.0), Interp::Bilinear) } - }); + let px = if b.is_empty() { surf.clone() } else { warp_placed(surf, b, (0.0, 0.0), pl, (0.0, 0.0), interp)? }; + let mask = match l.mask.as_ref() { + Some(m) => { + let mb = m.surface.content_bounds(); + Some(if mb.is_empty() { m.surface.clone() } else { warp_placed(&m.surface, mb, (0.0, 0.0), pl, (0.0, 0.0), Interp::Bilinear)? }) + } + None => None, + }; Ok((ids[i], px, mask)) }) .collect::>()?; diff --git a/crates/engine/src/allocation.rs b/crates/engine/src/allocation.rs new file mode 100644 index 0000000000..322e3ebd22 --- /dev/null +++ b/crates/engine/src/allocation.rs @@ -0,0 +1,70 @@ +//! Fallible buffers used by memory-intensive document operations. + +use crate::{EngineError, Result}; + +#[cfg(test)] +thread_local! { + static FAIL_NEXT: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Reserve and initialize a buffer without invoking the allocator's abort-on-OOM path. +pub(crate) fn filled(len: usize, value: T, purpose: &str) -> Result> { + let mut out = capacity(len, purpose)?; + out.resize(len, value); + Ok(out) +} + +/// Reserve a vector for a known number of operation results. +pub(crate) fn capacity(len: usize, purpose: &str) -> Result> { + let bytes = len.checked_mul(std::mem::size_of::()).ok_or_else(|| error(purpose, "requested size overflowed"))?; + #[cfg(test)] + if FAIL_NEXT.with(|flag| flag.replace(false)) { + return Err(error(purpose, "not enough memory")); + } + let mut out = Vec::new(); + out.try_reserve_exact(len).map_err(|_| error(purpose, &format!("not enough memory for {bytes} bytes")))?; + Ok(out) +} + +/// Check an operation boundary where downstream encoders/decoders allocate internally. +/// The test-only failpoint lets command tests prove their transaction boundary. +pub(crate) fn checkpoint(purpose: &str) -> Result<()> { + #[cfg(test)] + if FAIL_NEXT.with(|flag| flag.replace(false)) { + return Err(error(purpose, "not enough memory")); + } + let _ = purpose; + Ok(()) +} + +/// Copy a region into an explicitly reserved buffer. +pub(crate) fn read_region(surface: &photocraft_raster::Surface, area: photocraft_geom::Rect, purpose: &str) -> Result> { + let len = (area.width() as usize) + .checked_mul(area.height() as usize) + .and_then(|n| n.checked_mul(surface.channels())) + .ok_or_else(|| error(purpose, "requested size overflowed"))?; + let mut out = filled(len, 0.0, purpose)?; + surface.read_region_into(area, &mut out); + Ok(out) +} + +fn error(purpose: &str, detail: &str) -> EngineError { + EngineError::Other(format!("not enough memory for {purpose} ({detail}); the document was not changed")) +} + +#[cfg(test)] +pub(crate) fn fail_next_for_test() { + FAIL_NEXT.with(|flag| flag.set(true)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allocation_failure_is_reported_without_panicking() { + fail_next_for_test(); + let result = filled::(16, 0, "test image"); + assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for test image"))); + } +} diff --git a/crates/engine/src/artboard_cmds.rs b/crates/engine/src/artboard_cmds.rs index 87fa4ff1a0..b24262350e 100644 --- a/crates/engine/src/artboard_cmds.rs +++ b/crates/engine/src/artboard_cmds.rs @@ -188,7 +188,13 @@ pub(crate) fn fit_canvas(doc: &mut Document) { w = w.max(i64::from(a.rect.x1)); h = h.max(i64::from(a.rect.y1)); } - doc.size = Size::new(w.clamp(1, 300_000) as u32, h.clamp(1, 300_000) as u32); + let size = Size::new(w.clamp(1, 300_000) as u32, h.clamp(1, 300_000) as u32); + if size != doc.size { + doc.size = size; + // Shape pixels are cut at the canvas: a board moved past the old edge rendered its + // shapes against it, leaving them missing or clipped (#2389). + crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes); + } } fn next_artboard_name(doc: &Document) -> String { diff --git a/crates/engine/src/artboard_cmds/tests.rs b/crates/engine/src/artboard_cmds/tests.rs index 4f6cbf4203..e2252ec610 100644 --- a/crates/engine/src/artboard_cmds/tests.rs +++ b/crates/engine/src/artboard_cmds/tests.rs @@ -385,3 +385,44 @@ fn moving_an_artboard_past_the_canvas_grows_it() { s.undo(); assert_eq!((doc(&s).size.width, doc(&s).size.height), (100, 80)); } + +#[test] +fn moving_an_artboard_past_the_canvas_keeps_its_shapes_whole() { + // #2389: a shape's pixels are cut at the canvas; the board moved before the canvas grew, + // so its shape rendered against the old edge and stayed missing or clipped. + let shape_board = |s: &mut Session| { + s.execute("file.new", json!({"width": 100, "height": 100, "background": "transparent"})).unwrap(); + let sh = s.execute("shape.create", json!({"kind": "rect", "rect": [10, 20, 30, 30], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.new.artboardFromLayers", json!({})).unwrap(); + sh + }; + for (cmd, p, size, red, not_red) in [ + ("layer.artboard.set", json!({"x": 120}), (150, 100), [(121, 21), (130, 30), (149, 49)], None), + ("layer.translate", json!({"dx": 110, "dy": 0}), (150, 100), [(121, 21), (130, 30), (149, 49)], None), + ("layer.artboard.set", json!({"y": 120}), (100, 150), [(11, 121), (20, 130), (39, 149)], None), + // Partly past the edge: the whole shape, not just the part inside the old canvas. + ("layer.artboard.set", json!({"x": 90}), (120, 100), [(91, 21), (95, 30), (110, 30)], Some((15, 30))), + ] { + let mut s = Session::new(); + let sh = shape_board(&mut s); + s.execute(cmd, p.clone()).unwrap(); + assert_eq!((doc(&s).size.width, doc(&s).size.height), size, "{cmd} {p}"); + for (x, y) in red { + assert!(red_at(&s, x, y), "{cmd} {p}: ({x}, {y})"); + } + if let Some((x, y)) = not_red { + assert!(!red_at(&s, x, y), "{cmd} {p}: ({x}, {y}) left behind"); + } + let info = s.execute("shape.info", json!({"layer": sh})).unwrap(); + assert_eq!(info["bounds"], json!([red[0].0 - 1, red[0].1 - 1, 30, 30]), "{cmd} {p}"); + // One undo step back to the original board; redo brings the whole shape back. + s.undo(); + assert_eq!((doc(&s).size.width, doc(&s).size.height), (100, 100)); + assert!(red_at(&s, 20, 30), "{cmd} {p}: undo"); + s.redo(); + assert_eq!((doc(&s).size.width, doc(&s).size.height), size); + for (x, y) in red { + assert!(red_at(&s, x, y), "{cmd} {p}: redo ({x}, {y})"); + } + } +} diff --git a/crates/engine/src/automate_cmds.rs b/crates/engine/src/automate_cmds.rs index 0ae4d5ee5a..f26ba1add9 100644 --- a/crates/engine/src/automate_cmds.rs +++ b/crates/engine/src/automate_cmds.rs @@ -107,7 +107,14 @@ fn run_steps(s: &mut Session, steps: &[(String, Value)]) -> Vec { let mut results = Vec::new(); for (id, params) in steps { match s.execute(id, params.clone()) { - Ok(r) => results.push(json!({"command": id, "result": r})), + // A called action reports a failed step inside an `Ok`; it stops the script too. + Ok(r) => match crate::actions_cmds::nested_failure(id, &r) { + Some(error) => { + results.push(json!({"command": id, "result": r, "error": error})); + break; + } + None => results.push(json!({"command": id, "result": r})), + }, Err(e) => { results.push(json!({"command": id, "error": e.to_string()})); break; @@ -284,7 +291,9 @@ fn create_droplet(s: &mut Session, p: &Value) -> Result { let cmd = "file.automate.createDroplet"; let path = p.get("path").and_then(Value::as_str).filter(|v| !v.is_empty()).ok_or_else(|| bad(cmd, "missing \"path\" (where to save the droplet)"))?; let steps_v = p.get("steps").or_else(|| p.get("action")).ok_or_else(|| bad(cmd, "missing \"steps\" (the action to run)"))?; - let steps = parse_steps(steps_v, cmd)?; + let mut steps = parse_steps(steps_v, cmd)?; + // The droplet's output folder replaces recorded saves, and view steps don't apply, as in batch. + steps.retain(|(id, _)| !crate::actions_cmds::shell_save_command(id) && !crate::actions_cmds::shell_view_command(id)); check_known(&steps, cmd)?; let path = if path.ends_with(".pcdroplet") || path.ends_with(".json") { path.to_string() } else { format!("{path}.pcdroplet") }; let name = p.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&path)); @@ -379,9 +388,8 @@ fn statistics(s: &mut Session, p: &Value) -> Result { s.execute("edit.autoAlignLayers", json!({"projection": "auto"}))?; let name = s.active().map(|d| d.doc.name.clone()).unwrap_or_default(); s.edit("Convert to Smart Object", |doc, active| { - let children = std::mem::take(&mut doc.layers); - let group = Layer::new(name.clone(), LayerContent::Group(photocraft_doc::Group { children, expanded: true, artboard: None })); - let smart = crate::smart_cmds::layer_to_smart(doc, &group)?; + let layers = std::mem::take(&mut doc.layers); + let smart = crate::smart_cmds::layers_to_smart(doc, &name, layers)?; *active = Some(smart.id); doc.layers = vec![smart]; Ok(()) diff --git a/crates/engine/src/automate_cmds/tests.rs b/crates/engine/src/automate_cmds/tests.rs index 9a51f68082..73fb157c30 100644 --- a/crates/engine/src/automate_cmds/tests.rs +++ b/crates/engine/src/automate_cmds/tests.rs @@ -113,6 +113,21 @@ fn droplets_are_written_and_run() { assert!(s.execute("file.automate.createDroplet", json!({"path": path, "steps": [["bogus", {}]]})).is_err()); } +#[test] +fn batch_and_droplets_skip_recorded_view_steps() { + let dir = tmp("view-steps"); + images(&dir, 1, 40, 20); + let steps = json!([["view.zoomIn", {}], ["image.imageRotation.90cw", {}], ["view.fitOnScreen", {}]]); + let mut s = Session::new(); + let r = s.execute("file.automate.batch", json!({"steps": steps, "input": dir.clone(), "output": format!("{dir}/batch"), "format": "png"})).unwrap(); + assert!(r["errors"].as_array().unwrap().is_empty(), "{r}"); + let out = photocraft_codecs::decode(&std::fs::read(format!("{dir}/batch/img0.png")).unwrap()).unwrap(); + assert_eq!(out.dimensions(), (20, 40)); + let r = s.execute("file.automate.createDroplet", json!({"path": format!("{dir}/Rotate"), "steps": steps})).unwrap(); + let v: Value = serde_json::from_slice(&std::fs::read(r["path"].as_str().unwrap()).unwrap()).unwrap(); + assert_eq!(v["action"]["steps"], json!([["image.imageRotation.90cw", {}]])); +} + #[test] fn statistics_makes_a_stack_mode_smart_object() { let dir = tmp("stats"); @@ -130,6 +145,43 @@ fn statistics_makes_a_stack_mode_smart_object() { assert!(s.execute("file.scripts.statistics", json!({"mode": "average", "input": dir})).is_err()); } +#[test] +fn stacked_smart_object_holds_the_loaded_layers_at_its_top_level() { + // Like Photoshop, Load Files into Stack › Create Smart Object (Statistics without alignment) and + // Statistics with alignment embed one layer per file, bottom to top, with no wrapper group. + let dir = tmp("stack-top"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 96, "height": 72, "background": "#606060"})).unwrap(); + s.edit("texture", |doc, active| { + let surf = doc.layer_mut(active.ok_or(EngineError::NoDocument)?).and_then(Layer::surface_mut).ok_or(EngineError::NoDocument)?; + for k in 0..40 { + let (x, y, v) = ((k * 37) % 88, (k * 23) % 66, (k % 7) as f32 / 6.0); + surf.fill_rect(photocraft_geom::Rect::new(x, y, x + 3 + k % 5, y + 2 + k % 4), &[v, 1.0 - v, 0.5, 1.0]); + } + Ok(()) + }) + .unwrap(); + let source = photocraft_compose::flatten(&s.active().unwrap().doc); + let files: Vec = (0..3) + .map(|i| { + let path = format!("{dir}/img{i}.png"); + crate::file_cmds::save_doc(&s.active().unwrap().doc, &path, None).unwrap(); + path + }) + .collect(); + for align in [false, true] { + let mut s = Session::new(); + s.execute("file.scripts.statistics", json!({"mode": "median", "input": files, "align": align})).unwrap(); + let d = &s.active().unwrap().doc; + let LayerContent::Smart(so) = &d.layers[0].content else { panic!("not a smart object") }; + let photocraft_doc::SmartSource::Embedded { file_name, bytes } = &so.source else { panic!("not embedded") }; + let inner = crate::smart_cmds::decode_source(file_name, bytes).unwrap(); + let names: Vec<&str> = inner.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["img0.png", "img1.png", "img2.png"], "align {align}"); + assert_eq!(photocraft_compose::flatten(d).px, source.px, "align {align}"); + } +} + #[test] fn contact_sheet_places_thumbnails_with_captions() { let dir = tmp("contact"); @@ -162,3 +214,32 @@ fn contact_sheet_places_thumbnails_with_captions() { assert_eq!(d.layers.len(), 1); } } + +#[test] +fn a_failed_step_inside_a_called_action_fails_scripts_and_batch() { + use crate::actions_cmds::Action; + let dir = tmp("nested-failure"); + images(&dir, 1, 8, 4); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + s.actions.list.push(Action { name: "Rotate".into(), steps: vec![("image.imageRotation.90cw".into(), json!({}))] }); + s.actions.list.push(Action { name: "Bad".into(), steps: vec![("layer.delete".into(), json!({"layer": 999_999}))] }); + // A script stops at the failed call and reports it. + let r = s.execute("file.scripts.browse", json!({"steps": [["actions.play", {"action": "Bad"}], ["image.imageRotation.90cw", {}]]})).unwrap(); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(r["results"].as_array().unwrap().len(), 1); + // Batch plays called actions, and a failure inside one is a file error, not a saved result. + let batch = |s: &mut Session, action: &str| { + let p = json!({"steps": [["actions.play", {"action": action}]], "input": dir.clone(), "output": format!("{dir}/{action}"), "format": "png"}); + s.execute("file.automate.batch", p).unwrap() + }; + let r = batch(&mut s, "Rotate"); + assert!(r["errors"].as_array().unwrap().is_empty(), "{r}"); + let r = batch(&mut s, "Bad"); + assert_eq!(r["files"], json!([]), "{r}"); + assert!(r["errors"][0]["error"].as_str().unwrap().contains("no such layer"), "{r}"); + // An action that batches itself stops as a recursive call instead of overflowing the stack. + let inner = json!({"steps": [["actions.play", {"action": "Loop"}]], "input": dir.clone(), "output": format!("{dir}/inner"), "format": "png"}); + s.actions.list.push(Action { name: "Loop".into(), steps: vec![("file.automate.batch".into(), inner)] }); + batch(&mut s, "Loop"); +} diff --git a/crates/engine/src/background_cmds.rs b/crates/engine/src/background_cmds.rs new file mode 100644 index 0000000000..1da5ebc92b --- /dev/null +++ b/crates/engine/src/background_cmds.rs @@ -0,0 +1,261 @@ +//! Layer › New › Background from Layer, and the rule that keeps the Background at the bottom. +//! +//! Photoshop's Background is the locked, opaque layer at the bottom of the stack. Background from +//! Layer turns the selected layer into it: transparent pixels take the background colour and the +//! layer drops to the bottom. While a document has a Background nothing reorders it and no layer +//! can go below it (Layer › Arrange, Reverse, dragging in the Layers panel); Layer from Background +//! (`layer.new.layerFromBackground`) turns it back into a normal layer that moves freely. + +use photocraft_doc::{Document, Layer, LayerContent, LayerId}; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::extra_cmds::is_background; +use crate::{EngineError, Result, Session}; + +const CMD: &str = "layer.new.backgroundFromLayer"; + +/// The document's Background layer, if it has one (always the bottom layer). +pub(crate) fn background_id(doc: &Document) -> Option { + doc.layers.first().filter(|l| is_background(l)).map(|l| l.id) +} + +/// Refuses an edit that moved the Background `bg` (taken before the edit) off the bottom of the +/// stack, or put another layer below it. Called at the end of a `Session::edit` closure, so an +/// `Err` leaves the document and history untouched. +pub(crate) fn keep_background_at_bottom(bg: Option, doc: &Document) -> Result<()> { + match bg { + Some(id) if doc.layers.first().map(|l| l.id) != Some(id) => Err(EngineError::Other( + "the Background layer stays at the bottom of the stack (Layer › New › Layer from Background makes it a normal layer)".into(), + )), + _ => Ok(()), + } +} + +/// Can `l` become the Background? Groups and adjustment layers have no pixels of their own. +fn convertible(l: &Layer) -> std::result::Result<(), String> { + match l.content { + LayerContent::Group(_) | LayerContent::Adjustment(_) => { + Err(format!("{} {} layer can't become the Background", l.content.article(), l.content.kind_name())) + } + _ => Ok(()), + } +} + +fn can_convert(s: &Session) -> std::result::Result<(), String> { + let d = s.active().ok_or("no document open")?; + if background_id(&d.doc).is_some() { + return Err("the document already has a Background layer".into()); + } + let l = d.active_layer.and_then(|id| d.doc.layer(id)).ok_or("no active layer")?; + convertible(l) +} + +fn background_from_layer(s: &mut Session, p: &Value) -> Result { + let id = match p.get("layer") { + None | Some(Value::Null) => crate::commands::layer_param(s, p)?, + Some(v) => LayerId(v.as_u64().ok_or_else(|| EngineError::BadParams { cmd: CMD.into(), msg: "`layer` must be a layer id".into() })?), + }; + let bg = s.tools.background; + s.edit("Background From Layer", |doc, active| { + if background_id(doc).is_some() { + return Err(EngineError::Other("the document already has a Background layer".into())); + } + let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?; + convertible(l).map_err(EngineError::Other)?; + // Render the layer alone, as it looks (mask, opacity and effects applied), over the + // background colour: the Background is opaque and plain. + let mut only = l.clone(); + only.visible = true; + only.clipped = false; + let visible = l.visible; + let fmt = doc.pixel_format(); + let rest = std::mem::replace(&mut doc.layers, vec![only]); + let pixels = photocraft_compose::flatten_to_surface(doc, fmt, Some([bg[0], bg[1], bg[2]])); + doc.layers = rest; + let mut out = Layer::raster("Background", fmt); + out.id = id; + out.visible = visible; + out.locks.transparency = true; + out.locks.position = true; + *crate::pixels_mut(&mut out)? = pixels; + doc.remove(id).ok_or(EngineError::NoLayer(id))?; + doc.layers.insert(0, out); + *active = Some(id); + Ok(()) + })?; + // The Background is the one targeted layer now. + s.select_layer(id)?; + Ok(json!({"layer": id.0})) +} + +pub fn specs() -> Vec { + vec![CommandSpec { + id: CMD, + label: "Background From Layer", + menu: &["Layer", "New"], + shortcut: None, + params: r##"{"layer":id?} (the active layer by default; transparent pixels take the background colour and the layer drops to the bottom as the locked Background)"##, + enabled: can_convert, + journal: true, + run: background_from_layer, + }] +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_geom::Rect; + + /// Bottom to top: Layer 0 (the old Background, now a normal layer), A, B. A has a 4×4 red + /// square at (2, 2); the rest of it is transparent. + fn session(depth: u32) -> (Session, [LayerId; 3]) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 12, "depth": depth})).unwrap(); + s.execute("layer.new.layerFromBackground", json!({})).unwrap(); + let l0 = doc(&s).layers[0].id; + let id = |s: &mut Session, name: &str| LayerId(s.execute("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap()); + let a = id(&mut s, "A"); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 4, "height": 4})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let b = id(&mut s, "B"); + (s, [l0, a, b]) + } + + fn doc(s: &Session) -> &Document { + &s.active().unwrap().doc + } + + fn order(s: &Session) -> Vec { + doc(s).layers.iter().map(|l| l.id).collect() + } + + fn pixel(s: &Session, x: i32, y: i32) -> Vec { + doc(s).layers[0].surface().unwrap().read_region(Rect::new(x, y, x + 1, y + 1)) + } + + /// A Background (the white one `file.new` makes) with A and B above it. + fn with_background() -> (Session, [LayerId; 3]) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 12})).unwrap(); + let bg = doc(&s).layers[0].id; + let a = LayerId(s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap()); + let b = LayerId(s.execute("layer.new.layer", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap()); + (s, [bg, a, b]) + } + + #[test] + fn makes_the_layer_the_background_at_the_bottom() { + for depth in [8, 16, 32] { + let (mut s, [l0, a, b]) = session(depth); + s.tools.background = [0.0, 0.0, 1.0, 1.0]; + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + assert!(s.is_enabled(CMD), "{depth}"); + assert_eq!(s.execute(CMD, json!({})).unwrap()["layer"], a.0); + assert_eq!(order(&s), [a, l0, b], "{depth}: drops to the bottom"); + let l = &doc(&s).layers[0]; + assert!(is_background(l) && l.name == "Background", "{depth}"); + assert_eq!(s.active().unwrap().active_layer, Some(a)); + // Its pixels stay; transparency takes the background colour (blue). + let red = pixel(&s, 3, 3); + let blue = pixel(&s, 10, 10); + assert!((red[0] - 1.0).abs() < 1e-3 && red[2].abs() < 1e-3 && (red[3] - 1.0).abs() < 1e-3, "{depth}: {red:?}"); + assert!(blue[0].abs() < 1e-3 && (blue[2] - 1.0).abs() < 1e-3 && (blue[3] - 1.0).abs() < 1e-3, "{depth}: {blue:?}"); + // Now there is a Background: the item greys, Layer from Background is back. + assert!(!s.is_enabled(CMD)); + assert!(s.execute(CMD, json!({"layer": b.0})).is_err(), "{depth}: one Background per document"); + assert!(s.is_enabled("layer.new.layerFromBackground")); + // One step: undo restores the layer where it was, transparent. + assert!(s.undo()); + assert_eq!(order(&s), [l0, a, b], "{depth}"); + let l = doc(&s).layer(a).unwrap(); + assert!(!is_background(l) && l.name == "A"); + assert!(s.redo()); + assert_eq!(order(&s), [a, l0, b], "{depth}"); + assert!(is_background(&doc(&s).layers[0])); + } + } + + #[test] + fn a_layer_in_a_group_leaves_it_and_keeps_its_look() { + let (mut s, [l0, a, b]) = session(8); + s.execute("layer.select", json!({"layer": b.0})).unwrap(); + s.execute("layer.groupLayers", json!({})).unwrap(); + s.execute("layer.setProps", json!({"layer": b.0, "opacity": 0.5, "visible": false})).unwrap(); + s.execute("layer.select", json!({"layer": b.0})).unwrap(); + s.execute(CMD, json!({})).unwrap(); + let d = doc(&s); + assert_eq!(d.layers[0].id, b); + assert!(d.layers[1..].iter().all(|l| l.id != b)); + assert_eq!(d.layers[1].id, l0); + assert_eq!(d.layers[2].id, a); + let bg = &d.layers[0]; + assert!(is_background(bg) && !bg.visible, "visibility is kept"); + assert_eq!(bg.opacity, 1.0); + } + + #[test] + fn refuses_groups_adjustments_missing_layers_and_bad_params() { + // No document. + let mut s = Session::new(); + assert!(!s.is_enabled(CMD)); + assert!(s.execute(CMD, json!({})).is_err()); + // Already a Background: unavailable, for any layer. + let (mut s, [bg, a, _]) = with_background(); + assert!(!s.is_enabled(CMD)); + assert!(s.execute(CMD, json!({"layer": a.0})).is_err()); + assert!(s.execute(CMD, json!({"layer": bg.0})).is_err()); + // Bad params. + let (mut s, [_, a, _]) = session(8); + for p in [json!({"layer": "A"}), json!({"layer": -1}), json!({"layer": 1.5}), json!({"layer": 99999}), json!({"layer": [a.0]})] { + assert!(s.execute(CMD, p.clone()).is_err(), "{p}"); + } + assert!(!doc(&s).layers.iter().any(is_background)); + // A group or an adjustment layer can't become the Background. + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.groupLayers", json!({})).unwrap(); + assert!(!s.is_enabled(CMD), "the group is active"); + assert!(s.execute(CMD, json!({})).is_err()); + s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap(); + assert!(!s.is_enabled(CMD)); + assert!(s.execute(CMD, json!({})).is_err()); + assert!(!doc(&s).layers.iter().any(is_background)); + } + + #[test] + fn the_background_stays_at_the_bottom() { + let (mut s, [bg, a, b]) = with_background(); + // Arrange: the Background doesn't move, and nothing goes below it. (The menu greys these + // for the Background in the shell's enable rules; here an agent asks anyway.) + for cmd in ["layer.arrange.bringForward", "layer.arrange.bringToFront", "layer.arrange.sendBackward", "layer.arrange.sendToBack"] { + assert!(s.execute(cmd, json!({"layer": bg.0})).is_err(), "{cmd}"); + assert_eq!(order(&s), [bg, a, b], "{cmd}"); + } + assert!(s.execute("layer.arrange.sendBackward", json!({"layer": a.0})).is_err()); + s.execute("layer.arrange.sendToBack", json!({"layer": b.0})).unwrap(); + assert_eq!(order(&s), [bg, b, a], "Send to Back stops above the Background"); + // Dragging in the Layers panel (layer.moveTo). + assert!(s.execute("layer.moveTo", json!({"layer": bg.0, "target": a.0, "position": "above"})).is_err()); + assert!(s.execute("layer.moveTo", json!({"layer": a.0, "target": bg.0, "position": "below"})).is_err()); + assert!(s.execute("layer.moveTo", json!({"layer": a.0, "target": bg.0, "position": "below", "copy": true})).is_err()); + assert!(s.execute("layer.moveTo", json!({"layers": [bg.0, a.0], "target": b.0, "position": "above"})).is_err()); + assert_eq!(order(&s), [bg, b, a]); + s.execute("layer.moveTo", json!({"layer": a.0, "target": bg.0, "position": "above"})).unwrap(); + assert_eq!(order(&s), [bg, a, b], "above the Background is fine"); + // ⌥-dragging the Background copies it above; the copy is a normal layer. + s.execute("layer.moveTo", json!({"layer": bg.0, "target": b.0, "position": "above", "copy": true})).unwrap(); + assert_eq!(doc(&s).layers.len(), 4); + assert_eq!(doc(&s).layers[0].id, bg); + assert!(s.undo()); + // Reverse with the Background among the selected layers. + s.execute("layer.select", json!({"layer": bg.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + assert!(s.execute("layer.arrange.reverse", json!({})).is_err()); + assert_eq!(order(&s), [bg, a, b]); + // As a normal layer it moves freely again. + s.execute("layer.new.layerFromBackground", json!({})).unwrap(); + s.execute("layer.arrange.bringToFront", json!({"layer": bg.0})).unwrap(); + assert_eq!(order(&s), [a, b, bg]); + } +} diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 27eeefd688..a0a217c303 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -312,6 +312,9 @@ fn apply_auto_erase(brush: &mut BrushSettings, surf: &Surface, start: Option<&St /// dabs on the pixel grid), hard and at full flow, with the options-bar mode. fn pencil_brush(s: &Session, p: &Value) -> Result { let mut brush = with_blend_mode(resolve_brush(s, p, "paint.pencil")?, p); + if flag(p, "block", false) { + return block_brush(brush, p); + } brush.aliased = true; if num(p, "hardness").is_none() { brush.hardness = 1.0; @@ -322,6 +325,42 @@ fn pencil_brush(s: &Session, p: &Value) -> Result { Ok(brush) } +/// Edge of the Eraser's Block in screen pixels (Photoshop): constant on screen, so it covers +/// `BLOCK_SCREEN_PX / zoom` document pixels. +pub const BLOCK_SCREEN_PX: f64 = 16.0; + +/// The Eraser's Block edge in document pixels at view `zoom`: `16 / zoom`, rounded to whole +/// pixels (a zoom that is not a positive number counts as 100 %). +pub fn block_size(zoom: f64) -> f32 { + let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 }; + (BLOCK_SCREEN_PX / zoom).round().clamp(1.0, f64::from(photocraft_paint::brush::MAX_BRUSH_SIZE)) as f32 +} + +/// The Eraser's Block mode (`"block": true`): a hard, aliased square of +/// [`BLOCK_SCREEN_PX`] screen pixels at the stroke's `"zoom"`, i.e. `16 / zoom` document +/// pixels rounded to whole pixels. Size, opacity, flow, hardness and the brush's dynamics don't +/// apply (Photoshop); colour, erase, smoothing and the seed are the resolved brush's. +fn block_brush(b: BrushSettings, p: &Value) -> Result { + let zoom = match p.get("zoom") { + None => 1.0, + Some(v) => v.as_f64().filter(|z| z.is_finite() && *z > 0.0).ok_or_else(|| bad("paint.pencil", "`zoom` must be a positive number"))?, + }; + Ok(BrushSettings { + size: block_size(zoom), + aliased: true, + square: true, + pressure_size: false, + pressure_opacity: false, + spacing: 0.25, + color: b.color, + background: b.background, + erase: b.erase, + smoothing: b.smoothing, + seed: b.seed, + ..BrushSettings::default() + }) +} + /// Applies the options-bar blend `mode` to a brush. `"mode"` accepts any blend-mode name /// (normal|multiply|screen|…). The Eraser has no blend mode in Photoshop, so it is forced to Normal. fn with_blend_mode(mut b: BrushSettings, p: &Value) -> BrushSettings { @@ -882,7 +921,7 @@ pub fn specs() -> Vec { spec!( "paint.pencil", "Pencil", - r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…"="normal","erase":bool?,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, + r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…"="normal","erase":bool?,"block":bool=false (a hard 16×16 screen-px square, 16/zoom doc px; size, opacity, flow, hardness and dynamics ignored: the Eraser's Block mode),"zoom":view zoom=1,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, has_paintable, pencil, true diff --git a/crates/engine/src/channel_clip.rs b/crates/engine/src/channel_clip.rs new file mode 100644 index 0000000000..faee9aa47c --- /dev/null +++ b/crates/engine/src/channel_clip.rs @@ -0,0 +1,136 @@ +//! Copy and Paste with one channel targeted in the Channels panel (#2307), as in Photoshop. +//! +//! - Copy with a colour channel (Red, Green, Blue, …) or an alpha channel targeted copies that +//! channel's values as a grayscale clip, instead of the whole layer. +//! - Paste with a colour channel targeted writes the clip's luminosity into that channel of the +//! active layer, instead of making a new layer. The other channels and the layer's transparency +//! stay as they are. Pasting into an alpha channel, a layer mask or the Quick Mask goes through +//! [`crate::edit_cmds::paste_to_target`]. +//! - Cut with a colour channel targeted copies that channel and fills the cut area of that channel +//! only with the background colour (#2698). + +use photocraft_color::{ColorMode, PixelFormat}; +use photocraft_doc::{Layer, LayerId}; +use photocraft_raster::Surface; +use serde_json::{Value, json}; + +use crate::channel_cmds::ChannelTarget; +use crate::edit_cmds::Clip; +use crate::{EngineError, Result, Session}; + +/// The colour channel a paste writes into: the Channels panel targets one and the params name no +/// other target. +pub(crate) fn paste_color_target(s: &Session, p: &Value) -> Option { + if p.get("target").is_some() { + return None; + } + match s.active()?.channel_view.target { + ChannelTarget::Color(k) => Some(k), + _ => None, + } +} + +/// Copy the targeted colour or alpha channel as a grayscale clip, inside the selection (or the +/// whole canvas). `Ok(None)` when no single channel is targeted: Copy copies the layer. +pub(crate) fn copy(s: &mut Session) -> Result> { + let Some(d) = s.active() else { return Ok(None) }; + let doc = &d.doc; + let canvas = doc.bounds(); + // The surface the values come from, and which of its samples is the channel. + let (src, k) = match d.channel_view.target { + ChannelTarget::Composite => return Ok(None), + ChannelTarget::Color(k) => { + let id = d.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?; + let surf = doc.layer(id).and_then(Layer::surface).ok_or_else(|| EngineError::Other("the active layer has no pixels".into()))?; + (surf, k) + } + ChannelTarget::Alpha(i) => { + let c = doc.channels.get(i).ok_or_else(|| EngineError::Other(format!("no alpha channel {i} (document has {})", doc.channels.len())))?; + (&c.surface, 0) + } + }; + let n = src.format().channels(); + if k >= n { + return Err(EngineError::Other(format!("no colour channel {k} in this layer"))); + } + let sel = doc.selection.as_ref(); + let area = sel.map_or(canvas, |m| m.content_bounds().intersect(&canvas)); + if area.is_empty() { + return Err(EngineError::Other("Could not copy: the selected area is empty".into())); + } + let fmt = PixelFormat::new(ColorMode::Grayscale, doc.pixel_format().sample, true); + let values = src.read_region(area); + let cover = sel.map(|m| (m.read_region(area), m.format().channels().max(1))); + let mut px = Vec::with_capacity(values.len() / n * 2); + for (i, p) in values.chunks_exact(n).enumerate() { + px.push(p.get(k).copied().unwrap_or(0.0)); + px.push(cover.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0))); + } + let mut surface = Surface::new(fmt); + surface.write_region(area, &px); + surface.prune(); + let bounds = surface.content_bounds(); + if bounds.is_empty() { + return Err(EngineError::Other("Could not copy: the selected area is empty".into())); + } + s.clipboard = Some(Clip { surface, bounds, layers: None }); + Ok(Some(json!({"bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()], "channel": k}))) +} + +/// Paste the clip's luminosity into colour channel `k` of the active layer, placed as a paste +/// places it. The clip's transparency, and `limit` (Paste Into / Outside), let the old values show +/// through. One history step. +pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit: Option<&Surface>, label: &str) -> Result { + let clip = s.clipboard.clone().ok_or_else(|| EngineError::Other("the clipboard is empty".into()))?; + let d = s.active().ok_or(EngineError::NoDocument)?; + let canvas = d.doc.bounds(); + let id = d.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?; + let (dx, dy) = crate::edit_cmds::paste_offset(&clip, canvas, p, in_place); + let src = crate::edit_cmds::shifted(&clip.surface, dx, dy); + let area = src.content_bounds().intersect(&canvas); + s.edit(label, |doc, _| { + let surf = crate::commands::paint_surface(doc, id, &Value::Null)?; + let n = surf.format().channels(); + if k >= n { + return Err(EngineError::Other(format!("no colour channel {k} in this layer"))); + } + if !area.is_empty() { + let mut rgba = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize]; + src.read_rgba_into(area, &mut rgba); + let weight = limit.map(|m| (m.read_region(area), m.format().channels().max(1))); + let mut px = surf.read_region(area); + for (i, (dst, s)) in px.chunks_exact_mut(n).zip(&rgba).enumerate() { + let w = s[3] * weight.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0)); + let v = photocraft_color::convert::rgb_to_gray([s[0], s[1], s[2]]); + if let Some(c) = dst.get_mut(k) { + *c += (v - *c) * w.clamp(0.0, 1.0); + } + } + surf.write_region(area, &px); + surf.prune(); + } + doc.selection = None; + Ok(()) + })?; + Ok(json!({"layer": id.0, "channel": k, "offset": [dx, dy]})) +} + +/// Cut with colour channel `k` of layer `id` targeted (#2698): copy the channel as [`copy`] does, +/// then fill the selected area (or the canvas) of that channel only with the background colour's +/// value for it, as Photoshop does. The other channels and the layer's transparency stay as they +/// are. One history step. +pub(crate) fn cut(s: &mut Session, id: LayerId, k: usize) -> Result { + let r = copy(s)?.ok_or_else(|| EngineError::Other("no colour channel is targeted".into()))?; + let bg = s.tools.background; + s.edit("Cut Pixels", |doc, _| crate::channel_cmds::clear_color_channel(doc, id, k, bg))?; + Ok(r) +} + +/// Copy reads only: with an alpha channel targeted it needs no pixel layer. +pub(crate) fn copies_alpha_channel(s: &Session) -> bool { + s.active().is_some_and(|st| matches!(st.channel_view.target, ChannelTarget::Alpha(i) if i < st.doc.channels.len())) +} + +#[cfg(test)] +#[path = "channel_clip/tests.rs"] +mod tests; diff --git a/crates/engine/src/channel_clip/tests.rs b/crates/engine/src/channel_clip/tests.rs new file mode 100644 index 0000000000..e6fa57333a --- /dev/null +++ b/crates/engine/src/channel_clip/tests.rs @@ -0,0 +1,149 @@ +use super::*; +use photocraft_color::ColorMode; +use photocraft_geom::Rect; + +const W: i32 = 40; +const H: i32 = 20; + +/// A white document whose left half is #336699: red 0.2, green 0.4, blue 0.6 there. +fn session(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H, "depth": depth})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": W / 2, "height": H})).unwrap(); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s +} + +fn px(s: &Session, x: i32, y: i32) -> Vec { + let d = s.active().unwrap(); + d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().pixel(x, y) +} + +fn layers(s: &Session) -> usize { + s.active().unwrap().doc.walk().len() +} + +fn near(a: &[f32], b: &[f32]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3) +} + +#[test] +fn copy_red_paste_into_blue() { + for depth in [8, 16, 32] { + let mut s = session(depth); + let (left, right) = (px(&s, 5, 5), px(&s, 30, 5)); + s.execute("channel.target", json!({"channel": "red"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let clip = s.clipboard.as_ref().unwrap(); + assert_eq!(clip.surface.format().mode, ColorMode::Grayscale, "depth {depth}: a channel copies as grayscale"); + assert_eq!(clip.bounds, Rect::new(0, 0, W, H), "no selection: the whole channel"); + let n = layers(&s); + s.execute("channel.target", json!({"channel": "blue"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(layers(&s), n, "depth {depth}: no new layer"); + assert!(near(&px(&s, 5, 5), &[left[0], left[1], left[0], 1.0]), "depth {depth}: blue = old red, {:?}", px(&s, 5, 5)); + assert!(near(&px(&s, 30, 5), &right)); + assert!(s.active().unwrap().doc.selection.is_none()); + s.undo(); + assert!(near(&px(&s, 5, 5), &left), "depth {depth}: one undoable step"); + } +} + +#[test] +fn alpha_channels_copy_and_paste_into_colour_channels() { + let mut s = session(8); + // Alpha 1: white on the top half. + s.execute("select.rect", json!({"x": 0, "y": 0, "width": W, "height": H / 2})).unwrap(); + s.execute("channel.new", json!({"fill": "selection"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let clip = &s.clipboard.as_ref().unwrap().surface; + assert_eq!((clip.pixel(30, 2), clip.pixel(30, 15)), (vec![1.0, 1.0], vec![0.0, 1.0]), "the alpha channel's values"); + s.execute("channel.target", json!({"channel": "green"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert!(near(&px(&s, 30, 2), &[1.0, 1.0, 1.0, 1.0])); + assert!(near(&px(&s, 30, 15), &[1.0, 0.0, 1.0, 1.0]), "green = Alpha 1, {:?}", px(&s, 30, 15)); + // With the composite targeted, Paste still makes a layer (grey, from the grayscale clip). + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + let n = layers(&s); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(layers(&s), n + 1); + assert!(near(&px(&s, 30, 15), &[0.0, 0.0, 0.0, 1.0])); +} + +#[test] +fn copy_an_alpha_channel_without_a_pixel_layer() { + let mut s = session(8); + s.execute("channel.new", json!({"fill": "white"})).unwrap(); + s.edit("no pixel layer", |_, active| { + *active = None; + Ok(()) + }) + .unwrap(); + assert!(s.is_enabled("edit.copy"), "an alpha channel needs no pixel layer"); + s.execute("edit.copy", json!({})).unwrap(); + assert_eq!(s.clipboard.as_ref().unwrap().surface.pixel(3, 3), vec![1.0, 1.0]); +} + +#[test] +fn paste_into_a_colour_channel_keeps_to_the_selection() { + let mut s = session(16); + // Paste Into centres the paste on the selection: copy the same area so it lands in place. + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": H})).unwrap(); + s.execute("channel.target", json!({"channel": "red"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "green"})).unwrap(); + let n = layers(&s); + s.execute("edit.pasteSpecial.pasteInto", json!({})).unwrap(); + assert_eq!(layers(&s), n, "no new layer or mask"); + let (inside, outside) = (px(&s, 5, 5), px(&s, 15, 5)); + assert!(near(&inside, &[0.2, 0.2, 0.6, 1.0]), "green = red inside, {inside:?}"); + assert!(near(&outside, &[0.2, 0.4, 0.6, 1.0]), "untouched outside, {outside:?}"); +} + +#[test] +fn copying_and_pasting_channels_fail_cleanly() { + let mut s = session(8); + s.execute("channel.target", json!({"channel": "red"})).unwrap(); + // An empty selection copies nothing and leaves the clipboard alone. + s.edit("empty selection", |doc, _| { + doc.selection = Some(Surface::new(photocraft_color::PixelFormat::GRAY8)); + Ok(()) + }) + .unwrap(); + assert!(s.execute("edit.copy", json!({})).is_err()); + assert!(s.clipboard.is_none()); + // A pixel-locked layer refuses the paste and keeps its pixels. + s.execute("select.deselect", json!({})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("lock", |doc, _| { + doc.layer_mut(id).unwrap().locks.pixels = true; + Ok(()) + }) + .unwrap(); + let before = px(&s, 5, 5); + s.execute("channel.target", json!({"channel": "blue"})).unwrap(); + assert!(s.execute("edit.paste", json!({})).is_err()); + assert_eq!(px(&s, 5, 5), before); +} + +#[test] +fn cut_a_colour_channel_clears_only_that_channel() { + let mut s = session(8); + let (left, right) = (px(&s, 5, 5), px(&s, 30, 5)); + s.tools.background = [0.8, 0.1, 0.9, 1.0]; + s.execute("channel.target", json!({"channel": "red"})).unwrap(); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap(); + s.execute("edit.cut", json!({})).unwrap(); + let clip = s.clipboard.as_ref().unwrap(); + assert_eq!(clip.surface.format().mode, ColorMode::Grayscale, "the red channel copies as grayscale"); + assert!(near(&clip.surface.pixel(5, 5), &[left[0], 1.0]), "the clip holds the old red, {:?}", clip.surface.pixel(5, 5)); + // Red takes the background colour's red; green, blue and transparency stay. + assert!(near(&px(&s, 5, 5), &[0.8, left[1], left[2], 1.0]), "{:?}", px(&s, 5, 5)); + assert!(near(&px(&s, 15, 5), &left), "outside the selection"); + assert!(near(&px(&s, 30, 5), &right)); + s.undo(); + assert!(near(&px(&s, 5, 5), &left), "one undoable step"); +} diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 5ced9899fa..321d0d1a6e 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -533,12 +533,15 @@ pub(crate) fn channel_surface_for_filter<'a>(doc: &'a mut Document, layer: Optio Ok(Some(surf)) } -/// Commands that edit a targeted layer mask in place of the layer: the Image › Adjustments table -/// and the filters. A mask target enables them on any layer with a mask, so ⌘I inverts an -/// adjustment layer's mask as in Photoshop (#780). +/// Commands that edit a targeted layer mask in place of the layer: the Image › Adjustments table, +/// the filters and the fills (Edit › Fill and its keys). A mask target enables them on any layer +/// with a mask, so ⌘I inverts an adjustment layer's mask (#780) and Fill fills it (#2456) as in +/// Photoshop. fn edits_mask(id: &str) -> bool { id.strip_prefix("image.adjustments.").is_some_and(|kind| crate::adjust_params::default_for(kind, ColorMode::Rgb).is_ok()) || crate::filters::params_for(id, &Value::Null).is_some() + || id == "edit.fill" + || crate::fill_key_cmds::IDS.contains(&id) } /// The precondition of `id` when `p` targets the active layer's mask (`"target":"mask"`): the @@ -553,7 +556,8 @@ pub(crate) fn mask_target_enabled(s: &Session, id: &str, p: &Value) -> Option bool { - routed(id) + // Moving the selected pixels (⌘-drag, Move tool) moves the targeted mask's, not the layer's. + routed(id) || id == "select.float" } /// Commands whose target follows the Channels panel. @@ -643,6 +647,71 @@ pub(crate) fn restrict_to_color(before: &Document, after: &mut Document, id: Lay new.prune(); } +/// The colour channel Clear is limited to: the Channels panel targets a single one. +pub(crate) fn targeted_color(s: &Session) -> Option { + match s.active()?.channel_view.target { + ChannelTarget::Color(k) => Some(k), + _ => None, + } +} + +/// Clear (Delete) with colour channel `k` of layer `id` targeted (#2720): fill the selected area +/// (or the canvas) of that channel only with the background colour's value for it, as Photoshop +/// does. A channel has no transparency of its own, so the other channels and the layer's alpha +/// stay; partly selected pixels blend. +pub(crate) fn clear_color_channel(doc: &mut Document, id: LayerId, k: usize, background: [f32; 4]) -> Result<()> { + let canvas = doc.bounds(); + let sel = doc.selection.clone(); + let area = sel.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas)); + let surf = crate::commands::paint_surface(doc, id, &Value::Null)?; + let fmt = surf.format(); + let n = fmt.channels(); + // The background colour in the layer's colour model, so `k` picks the right component. + let Some(v) = from_rgba(&fmt, [background[0], background[1], background[2], 1.0]).get(k).copied().filter(|_| k < n) else { + return Err(EngineError::Other(format!("no colour channel {k} in this layer"))); + }; + if area.is_empty() { + return Ok(()); + } + let cover = sel.map(|m| (m.read_region(area), m.format().channels().max(1))); + let mut px = surf.read_region(area); + for (i, p) in px.chunks_exact_mut(n).enumerate() { + let w = cover.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0)); + if let Some(c) = p.get_mut(k) { + *c += (v - *c) * w.clamp(0.0, 1.0); + } + } + surf.write_region(area, &px); + surf.prune(); + Ok(()) +} + +/// The alpha channel or Quick Mask the Channels panel targets, as `"target"` params: Clear +/// (Delete) fills it instead of the layer (#2734). +pub(crate) fn targeted_channel(s: &Session) -> Option { + let st = s.active()?; + let t = match st.channel_view.target { + ChannelTarget::Alpha(i) if i < st.doc.channels.len() => json!({ "channel": i }), + ChannelTarget::Composite if st.doc.quick_mask.is_some() => json!("quickMask"), + _ => return None, + }; + Some(json!({ "target": t })) +} + +/// Clear (Delete) with an alpha channel or the Quick Mask targeted (`target` from +/// [`targeted_channel`]): fill the selected area (or the canvas) of it with the background colour, +/// as gray, as Photoshop does. Partly selected pixels blend. +pub(crate) fn clear_channel(doc: &mut Document, target: &Value, background: [f32; 4]) -> Result<()> { + let canvas = doc.bounds(); + let sel = doc.selection.clone(); + let area = sel.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas)); + if area.is_empty() { + return Ok(()); + } + let (surf, _) = target_surface(doc, None, target)?; + crate::fill_cmds::fill_color(surf, area, [background[0], background[1], background[2], 1.0], sel.as_ref()) +} + /// The active document targets an alpha channel or is in Quick Mask mode, so pixel commands /// work without a pixel layer. pub(crate) fn edits_channel(s: &Session) -> bool { diff --git a/crates/engine/src/channel_cmds/tests.rs b/crates/engine/src/channel_cmds/tests.rs index 87dca83c94..9a9b710b3a 100644 --- a/crates/engine/src/channel_cmds/tests.rs +++ b/crates/engine/src/channel_cmds/tests.rs @@ -521,3 +521,53 @@ fn apply_image_and_calculations_fail_gracefully_never_panic() { // A well-formed self-apply still works. assert!(s.execute("image.applyImage", json!({"source": {"channel": "composite"}})).is_ok()); } + +#[test] +fn clear_with_a_colour_channel_targeted_clears_only_that_channel() { + let near = |a: &[f32], b: &[f32]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3); + // The Background, then a normal layer (Delete there can't make one channel transparent). + for duplicate in [false, true] { + let mut s = session_depth(16); + rect(&mut s, 0, 0, 20, 20, "replace"); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + if duplicate { + s.execute("layer.duplicate", json!({})).unwrap(); + } + let (left, right) = (px(&s, 5, 5), px(&s, 30, 5)); + s.tools.background = [0.8, 0.1, 0.9, 1.0]; + s.execute("channel.target", json!({"channel": "red"})).unwrap(); + rect(&mut s, 2, 2, 10, 10, "replace"); + s.execute("edit.clear", json!({})).unwrap(); + // Red takes the background colour's red; green, blue and transparency stay. + assert!(near(&px(&s, 5, 5), &[0.8, left[1], left[2], 1.0]), "duplicate {duplicate}: {:?}", px(&s, 5, 5)); + assert!(near(&px(&s, 15, 5), &left), "outside the selection"); + assert!(near(&px(&s, 30, 5), &right)); + s.undo(); + assert!(near(&px(&s, 5, 5), &left), "one undoable step"); + } +} + +#[test] +fn clear_with_an_alpha_channel_or_quick_mask_targeted_fills_it_not_the_layer() { + let mut s = session_depth(16); + let layer = px(&s, 5, 5); + s.tools.background = [0.0, 0.0, 0.0, 1.0]; + // A white Alpha 1, targeted. + s.execute("channel.new", json!({"fill": "white"})).unwrap(); + rect(&mut s, 2, 2, 10, 10, "replace"); + s.execute("edit.clear", json!({})).unwrap(); + // The selected part of the channel takes the background colour; the layer stays. + assert_eq!(chan(&s, 0, 5, 5), 0.0); + assert_eq!(chan(&s, 0, 15, 5), 1.0, "outside the selection"); + assert_eq!(px(&s, 5, 5), layer, "the layer is untouched"); + s.undo(); + assert_eq!(chan(&s, 0, 5, 5), 1.0, "one undoable step"); + // In Quick Mask mode Delete fills the mask (black = masked), not the layer. + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + s.execute("edit.clear", json!({})).unwrap(); + let q = doc(&s).quick_mask.as_ref().unwrap().surface.sample_channel(5, 5, 0); + assert_eq!(q, 0.0); + assert_eq!(px(&s, 5, 5), layer, "the layer is untouched in Quick Mask mode"); +} diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index 212d55d00b..a2f50ce0db 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -16,7 +16,7 @@ use std::sync::{Arc, Mutex, Weak}; use photocraft_cms::{Builtin, ColorSpace, GamutCheck, Intent, Lut3d, Profile, SampleKind, Transform}; use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; -use photocraft_doc::{DocId, Document, Effect, Fill, FxPaint, Layer, LayerContent}; +use photocraft_doc::{DocId, Document, Duotone, Effect, Fill, FxPaint, Layer, LayerContent}; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -251,9 +251,11 @@ impl ColorState { // Compared by colour, not bytes: Photoshop's sRGB IEC61966-2.1 is our working sRGB. let mismatch = !emb.same_colors(&working); let base = json!({"embedded": emb.description, "working": working.description, "mismatch": mismatch, "policy": policy.id()}); - // 32-bit linear images (EXR/HDR are tagged linear sRGB on import) stay linear, as - // Photoshop keeps 32-bit documents in a linear version of the working space. - let linear_hdr = doc.depth == SampleType::F32 && emb.same_colors(Builtin::LinearSrgb.profile()); + // 32-bit linear images (EXR/HDR are tagged linear sRGB or linear gray on import) + // stay linear, as Photoshop keeps 32-bit documents in a linear version of the + // working space. + let linear_hdr = + doc.depth == SampleType::F32 && (emb.same_colors(Builtin::LinearSrgb.profile()) || emb.same_colors(photocraft_cms::builtin::linear_gray())); if !mismatch || linear_hdr { return merge(base, json!({"action": "kept"})); } @@ -574,23 +576,24 @@ fn convert_color(c: &mut Color, from: ColorMode, to: ColorMode, t: &Transform) { c.c = nc; } -fn convert_fill(f: &mut Fill, from: ColorMode, to: ColorMode, t: &Transform) { +fn convert_fill(f: &mut Fill, cc: &mut dyn FnMut(&mut Color)) { match f { - Fill::Solid(c) => convert_color(c, from, to, t), - Fill::Gradient { stops, .. } => stops.iter_mut().for_each(|s| convert_color(&mut s.1, from, to, t)), + Fill::Solid(c) => cc(c), + Fill::Gradient { stops, .. } => stops.iter_mut().for_each(|s| cc(&mut s.1)), Fill::Pattern { .. } => {} } } -fn convert_layer_colors(l: &mut Layer, from: ColorMode, to: ColorMode, t: &Transform) { - let mut cc = |c: &mut Color| convert_color(c, from, to, t); +/// Applies `cc` to every colour value of `l` (fill, text, shape and effect colours), recursing +/// into groups. +fn convert_layer_colors(l: &mut Layer, cc: &mut dyn FnMut(&mut Color)) { let mut touched_fx = false; for e in &mut l.effects.items { touched_fx = true; match e { Effect::DropShadow(s) | Effect::InnerShadow(s) => cc(&mut s.color), - Effect::OuterGlow(g) | Effect::InnerGlow(g) => paint_colors(&mut g.paint, &mut cc), - Effect::Stroke(s) => paint_colors(&mut s.paint, &mut cc), + Effect::OuterGlow(g) | Effect::InnerGlow(g) => paint_colors(&mut g.paint, cc), + Effect::Stroke(s) => paint_colors(&mut s.paint, cc), Effect::ColorOverlay { color, .. } => cc(color), Effect::GradientOverlay { gradient, .. } => gradient.stops.iter_mut().for_each(|s| cc(&mut s.1)), Effect::Satin(s) => cc(&mut s.color), @@ -603,7 +606,7 @@ fn convert_layer_colors(l: &mut Layer, from: ColorMode, to: ColorMode, t: &Trans } let _ = touched_fx; match &mut l.content { - LayerContent::Fill(f) => convert_fill(f, from, to, t), + LayerContent::Fill(f) => convert_fill(f, cc), LayerContent::Text(tx) => { cc(&mut tx.color); for r in &mut tx.runs { @@ -612,22 +615,22 @@ fn convert_layer_colors(l: &mut Layer, from: ColorMode, to: ColorMode, t: &Trans } LayerContent::Shape(sh) => { if let Some(f) = &mut sh.fill { - convert_fill(f, from, to, t); + convert_fill(f, cc); } if let Some(s) = &mut sh.stroke { - convert_fill(&mut s.paint, from, to, t); + convert_fill(&mut s.paint, cc); } } LayerContent::Group(g) => { for c in &mut g.children { - convert_layer_colors(c, from, to, t); + convert_layer_colors(c, cc); } } _ => {} } } -fn paint_colors(p: &mut FxPaint, cc: &mut impl FnMut(&mut Color)) { +fn paint_colors(p: &mut FxPaint, cc: &mut dyn FnMut(&mut Color)) { match p { FxPaint::Color(c) => cc(c), FxPaint::Gradient(g) => g.stops.iter_mut().for_each(|s| cc(&mut s.1)), @@ -638,9 +641,13 @@ fn paint_colors(p: &mut FxPaint, cc: &mut impl FnMut(&mut Color)) { /// Converts every pixel layer (and cached text/shape/smart pixels and fill caches) of `doc` /// from its profile to `dst`, switching the mode to `dst`'s colour space. Masks, alpha /// channels and the selection are untouched; fill, text, shape and effect colours are -/// converted. The document is tagged with `dst`. +/// converted. A Duotone document going to a colour space starts from its ink colours +/// ([`ink_duotone`]). The document is tagged with `dst`. pub fn convert_document(doc: &mut Document, dst: &Profile, intent: Intent, bpc: bool) -> Result<()> { let to_mode = space_mode(dst.color_space).ok_or_else(|| EngineError::Other(format!("cannot convert to a {:?} profile", dst.color_space)))?; + if to_mode != ColorMode::Grayscale { + ink_duotone(doc); + } let src = document_profile(doc); let from_mode = doc.pixel_format().mode; let t = Transform::new(&src, dst, intent, bpc).map_err(cms_err)?; @@ -650,7 +657,7 @@ pub fn convert_document(doc: &mut Document, dst: &Profile, intent: Intent, bpc: *surf = convert_surface(surf, from_mode, f, &t); }); for l in &mut doc.layers { - convert_layer_colors(l, from_mode, to_mode, &t); + convert_layer_colors(l, &mut |c| convert_color(c, from_mode, to_mode, &t)); // Preserved PSD blocks describing colours in the old mode no longer apply. } doc.mode = to_mode; @@ -661,6 +668,76 @@ pub fn convert_document(doc: &mut Document, dst: &Profile, intent: Intent, bpc: Ok(()) } +/// Turns a Duotone document into the sRGB document it displays as: every gray pixel and colour +/// value becomes its printed ink colour ([`Duotone::render`], sRGB like the inks), so converting +/// to a colour mode keeps the inks' look, as Photoshop converts them to their equivalents. +/// Other documents (and Duotone without inks) are left alone. +fn ink_duotone(doc: &mut Document) { + if doc.mode != ColorMode::Duotone { + return; + } + let Some(d) = doc.duotone.take_if(|d| !d.inks.is_empty()) else { return }; + crate::image_cmds::for_each_surface(&mut doc.layers, false, &mut |surf, _| *surf = ink_surface(surf, &d)); + for l in &mut doc.layers { + convert_layer_colors(l, &mut |c| { + if matches!(c.mode, ColorMode::Grayscale | ColorMode::Duotone) { + let [r, g, b] = d.render(c.c[0]); + *c = Color::rgba(r, g, b, c.alpha); + } + }); + } + doc.mode = ColorMode::Rgb; + doc.icc_profile = Some(Builtin::Srgb.profile().to_bytes()); +} + +/// The RGB surface of a gray surface's ink colours (same depth and alpha), one parallel pass +/// over the tiles. Non-gray surfaces are returned unchanged. +fn ink_surface(s: &Surface, d: &Duotone) -> Surface { + use rayon::prelude::*; + let sf = s.format(); + if sf.mode != ColorMode::Grayscale { + return s.clone(); + } + let to = PixelFormat { mode: ColorMode::Rgb, ..sf }; + let (ss, ds) = (sf.channels(), to.channels()); + let ink = |src: &[f32], out: &mut [f32]| { + let [r, g, b] = d.render(src.first().copied().unwrap_or(0.0)); + for (o, v) in out.iter_mut().zip([r, g, b].into_iter().chain(src.get(1).copied())) { + *o = v; + } + }; + let mut dp = vec![0.0f32; ds]; + ink(&s.default_pixel(), &mut dp); + let mut out = Surface::with_default(to, &dp); + let tiles: Vec<_> = s.tiles().map(|(c, t)| (*c, t.clone())).collect(); + let inked: Vec> = tiles + .par_iter() + .map(|(_, t)| { + let src = t.bytes(); + let n = src.len() / sf.bytes_per_pixel().max(1); + let mut dst = vec![0u8; n * to.bytes_per_pixel()]; + let (mut px, mut o) = ([0.0f32; 2], [0.0f32; 4]); + for i in 0..n { + for (k, v) in px.iter_mut().enumerate().take(ss) { + *v = photocraft_color::read_sample(src, sf.sample, i * ss + k); + } + ink(&px[..ss], &mut o[..ds]); + for (k, v) in o.iter().enumerate().take(ds) { + photocraft_color::write_sample(&mut dst, to.sample, i * ds + k, *v); + } + } + dst + }) + .collect(); + for ((c, _), bytes) in tiles.iter().zip(inked) { + let tile = out.tile_mut(*c).bytes_mut(); + if tile.len() == bytes.len() { + tile.copy_from_slice(&bytes); + } + } + out +} + /// Image › Mode › RGB/Grayscale/CMYK/Lab through the CMS. Params: `profile` (destination, /// default the mode's working profile), `intent` (default relative), `bpc` (default true). pub fn convert_mode(s: &mut Session, mode: ColorMode, p: &Value) -> Result { diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index e21123731a..df6671f455 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -393,13 +393,23 @@ fn build() -> Vec { "Fill…", ["Edit"], Some("Shift+F5"), - r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":index? (contents=history; default the oldest state),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|{"channel":i}|"quickMask"?}"##, + r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":index? (contents=history; default the oldest state),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?}"##, has_pixel_or_channel, crate::fill_cmds::fill ), - cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{}", has_pixel_layer, |s, p| { - let id = layer_param(s, p)?; + cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{} (a targeted channel or Quick Mask is cleared alone)", has_pixel_or_channel, |s, p| { let bg = s.tools.background; + // A targeted alpha channel or Quick Mask is filled instead (#2734). + if let Some(t) = crate::channel_cmds::targeted_channel(s) { + s.edit("Clear", |doc, _| crate::channel_cmds::clear_channel(doc, &t, bg))?; + return Ok(Value::Null); + } + let id = layer_param(s, p)?; + // A targeted colour channel clears that channel only (#2720). + if let Some(k) = crate::channel_cmds::targeted_color(s) { + s.edit("Clear", |doc, _| crate::channel_cmds::clear_color_channel(doc, id, k, bg))?; + return Ok(Value::Null); + } s.edit("Clear", |doc, _| { let sel = doc.selection.clone(); let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc.bounds()); @@ -547,7 +557,7 @@ fn build() -> Vec { |s, p| { let in_place = p.get("inPlace").and_then(Value::as_bool).unwrap_or(false); if crate::layer_multi_cmds::multi(s, p) { - return crate::layer_multi_cmds::duplicate_selected(s, in_place); + return crate::layer_multi_cmds::duplicate_selected(s, in_place, "Duplicate Layers"); } let id = layer_param(s, p)?; let nid = s.edit("Duplicate Layer", |doc, active| { @@ -891,8 +901,13 @@ fn build() -> Vec { Ok(Value::Null) }), cmd!("tools.defaultColors", "Default Foreground and Background Colors", [], Some("D"), "{}", always, |s, _| { - s.tools.foreground = [0.0, 0.0, 0.0, 1.0]; - s.tools.background = [1.0, 1.0, 1.0, 1.0]; + if s.tools.mask_targeted { + s.tools.foreground = crate::ToolState::MASK_COLORS[0]; + s.tools.background = crate::ToolState::MASK_COLORS[1]; + } else { + s.tools.foreground = [0.0, 0.0, 0.0, 1.0]; + s.tools.background = [1.0, 1.0, 1.0, 1.0]; + } Ok(Value::Null) }), // Queries (not journaled) @@ -1142,7 +1157,9 @@ fn build() -> Vec { v.extend(crate::select_extra_cmds::specs()); v.extend(crate::paint_cmds::specs()); v.extend(crate::extra_cmds::specs()); + v.extend(crate::background_cmds::specs()); v.extend(crate::file_cmds::specs()); + v.extend(crate::exr_cmds::specs()); v.extend(crate::type_extra_cmds::specs()); v.extend(crate::type_styles_cmds::specs()); v.extend(crate::type_spell_cmds::specs()); @@ -1191,6 +1208,7 @@ fn build() -> Vec { v.extend(crate::video_cmds::specs()); v.extend(crate::jobs::specs()); v.extend(crate::wia_cmds::specs()); + v.extend(crate::rasterize_style_cmds::specs()); v.extend(crate::variables_cmds::specs()); v.extend(crate::plugin_cmds::specs()); v.extend(crate::group_view_cmds::specs()); @@ -1253,6 +1271,11 @@ pub fn adjustment_kind(a: &Adjustment) -> &'static str { fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result { let id = layer_param(s, p)?; s.edit("Arrange", |doc, _| { + // The Background stays at the bottom: it doesn't move and nothing goes below it. + let bg = crate::background_cmds::background_id(doc); + if bg == Some(id) { + return Err(EngineError::Other("the Background layer can't be moved".into())); + } let steps = match delta { i32::MAX => 10_000, i32::MIN => -10_000, @@ -1264,6 +1287,10 @@ fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result { if !doc.shift(id, dir) { break; } + if crate::background_cmds::keep_background_at_bottom(bg, doc).is_err() { + doc.shift(id, -dir); + break; + } moved = true; } if moved { Ok(()) } else { Err(EngineError::Other("layer can't move further".into())) } diff --git a/crates/engine/src/display_color_tests.rs b/crates/engine/src/display_color_tests.rs index 674c572c3e..2ce0e0cfb1 100644 --- a/crates/engine/src/display_color_tests.rs +++ b/crates/engine/src/display_color_tests.rs @@ -190,6 +190,25 @@ fn exr_round_trip_is_linear() { assert!(report.get("ask").is_none()); } +#[test] +fn gray_exr_round_trip_is_linear() { + let mut s = Session::new(); + // An sGray document exported to EXR is linearised (#2386); re-opened it is tagged linear gray + // and displays as before. + let d = Document::with_background("t", Size::new(8, 8), ColorMode::Grayscale, SampleType::U8, Color::gray(128.0 / 255.0)); + let r = photocraft_io::export(&d, "x.exr", &photocraft_io::ExportOptions::default()).unwrap(); + let back = photocraft_io::import("x.exr", &r.bytes).unwrap().document; + let icc = back.icc_profile.clone().expect("tagged"); + assert_eq!(Profile::parse(&icc).unwrap().content_hash(), photocraft_cms::builtin::linear_gray().content_hash()); + let v = back.layers[0].surface().unwrap().pixel(1, 1); + assert!((v[0] - 0.2159).abs() < 1e-3, "stored linear: {v:?}"); + assert!(close(shown(&s, &back), shown(&s, &d), 1.0), "{:?} vs {:?}", shown(&s, &back), shown(&s, &d)); + // Opening keeps it linear without a mismatch prompt. + let (_, report) = s.open_document(back, None); + assert_eq!(report["action"], "kept"); + assert!(report.get("ask").is_none()); +} + #[test] fn monitor_profile_setting() { let mut s = Session::new(); diff --git a/crates/engine/src/distort_cmds.rs b/crates/engine/src/distort_cmds.rs index 77cfdd88a0..5ecb38eecb 100644 --- a/crates/engine/src/distort_cmds.rs +++ b/crates/engine/src/distort_cmds.rs @@ -309,7 +309,7 @@ fn liquify(s: &mut Session, p: &Value) -> Result { fn deform_selected(surf: &Surface, sel: Option<&Surface>, f: &dyn Fn(&Surface) -> Result) -> Result { match sel { Some(sel) => { - let (lifted, mut rest) = crate::transform_cmds::split_selected(surf, sel); + let (lifted, mut rest) = crate::transform_cmds::split_selected(surf, sel)?; let moved = f(&lifted)?; crate::transform_cmds::composite_over(&mut rest, &moved); rest.prune(); diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index 49031f7111..439bfd6b7d 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -24,6 +24,36 @@ pub(crate) use paste_target; pub struct Clip { pub surface: Surface, pub bounds: Rect, + /// The copied layer itself, when Copy took a whole layer that Paste should recreate rather + /// than rasterize (see [`whole_layer`]): a scratch document in the source's colour holding it. + /// `surface` still holds its pixels, for Paste Into, masks, channels and other apps. + pub layers: Option>, +} + +/// Photoshop's layer-based copy (CC 2018 on): with no selection, Copy takes the active layer +/// whole when it is a smart object (#2428), a type or shape layer, or a group (its whole subtree) +/// (#2670), so Paste recreates it instead of its pixels. Pixel layers, and several selected +/// layers, still copy the active layer's pixels. +fn takes_whole(d: &crate::DocState, id: LayerId) -> bool { + d.doc.selection.is_none() + && d.selected_layers().len() <= 1 + && crate::layer_copy_cmds::layered(d.doc.mode) + && d.doc + .layer(id) + .is_some_and(|l| matches!(l.content, LayerContent::Smart(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Group(_))) +} + +/// The layer Copy takes whole ([`takes_whole`]): a scratch document in the source's colour +/// holding it. +fn whole_layer(d: &crate::DocState, id: LayerId) -> Option> { + let l = d.doc.layer(id).filter(|_| takes_whole(d, id))?; + let doc = &d.doc; + let mut copies = Document::new("", doc.size, doc.mode, doc.depth); + copies.icc_profile = doc.icc_profile.clone(); + copies.resolution_dpi = doc.resolution_dpi; + copies.patterns = doc.patterns.clone(); + copies.layers.push(l.clone()); + Some(Box::new(copies)) } fn has_doc(s: &Session) -> std::result::Result<(), String> { @@ -55,6 +85,15 @@ fn has_layer_pixels(s: &Session) -> std::result::Result<(), String> { active_layer(s)?.surface().map(|_| ()).ok_or_else(|| "the active layer has no pixels".into()) } +/// Copy: a targeted alpha channel (#2307), a layer with pixels, or one taken whole (a group with +/// no selection, #2670). +fn can_copy(s: &Session) -> std::result::Result<(), String> { + if crate::channel_clip::copies_alpha_channel(s) { + return Ok(()); + } + has_layer_pixels(s).or_else(|e| s.active().and_then(|d| d.active_layer.filter(|id| takes_whole(d, *id))).map(|_| ()).ok_or(e)) +} + fn has_clip(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; s.clipboard.as_ref().map(|_| ()).ok_or_else(|| "the clipboard is empty".into()) @@ -65,7 +104,7 @@ fn active_id(s: &Session) -> Result { } /// The pixels of `src` inside the selection (or all of them), as a straight-alpha surface. -fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Clip { +fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Result { let fmt = src.format(); let with_alpha = PixelFormat::new(fmt.mode, fmt.sample, true); let area = match sel { @@ -74,34 +113,45 @@ fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Clip { }; let mut out = Surface::new(with_alpha); if area.is_empty() { - return Clip { surface: out, bounds: Rect::EMPTY }; + return Ok(Clip { surface: out, bounds: Rect::EMPTY, layers: None }); } // Only the selected area is read: converting the whole layer (a 24 MP Background gaining // alpha) cost about a second per Layer via Copy (#668). let n = with_alpha.channels(); let mut px = if fmt.alpha { - src.read_region(area) + crate::allocation::read_region(src, area, "copying pixels")? } else { - let opaque = src.read_region(area); + let opaque = crate::allocation::read_region(src, area, "copying pixels")?; let k = fmt.channels(); - let mut v = Vec::with_capacity(opaque.len() / k.max(1) * n); + let len = opaque + .len() + .checked_div(k.max(1)) + .and_then(|pixels| pixels.checked_mul(n)) + .ok_or_else(|| EngineError::Other("not enough memory for copying pixels (requested size overflowed); the document was not changed".into()))?; + let mut v = crate::allocation::filled(len, 0.0, "copying pixels")?; + let mut at = 0; for p in opaque.chunks_exact(k.max(1)) { - v.extend_from_slice(p); - v.push(1.0); + let Some(dst) = v.get_mut(at..at + k.max(1)) else { break }; + dst.copy_from_slice(p); + at += k.max(1); + if let Some(alpha) = v.get_mut(at) { + *alpha = 1.0; + } + at += 1; } v }; if let Some(m) = sel { - let mask = m.read_region(area); + let mask = crate::allocation::read_region(m, area, "applying the selection to copied pixels")?; let mk = m.format().channels().max(1); for (p, a) in px.chunks_exact_mut(n).zip(mask.chunks_exact(mk)) { p[n - 1] *= a[0]; } } - out.write_region(area, &px); + out.try_write_region(area, &px).map_err(|e| EngineError::Other(format!("not enough memory while copying pixels ({e}); the document was not changed")))?; out.prune(); let bounds = out.content_bounds(); - Clip { surface: out, bounds } + Ok(Clip { surface: out, bounds, layers: None }) } /// Merged composite of the visible document as a surface in the document's format. @@ -114,11 +164,22 @@ fn copy(s: &mut Session, merged: bool) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let canvas = d.doc.bounds(); let clip = if merged { - lift(&merged_surface(&d.doc), d.doc.selection.as_ref(), canvas) + lift(&merged_surface(&d.doc), d.doc.selection.as_ref(), canvas)? } else { let id = d.active_layer.ok_or(EngineError::Other("no active layer".into()))?; - let surf = d.doc.layer(id).and_then(Layer::surface).ok_or(EngineError::Other("the active layer has no pixels".into()))?; - lift(surf, d.doc.selection.as_ref(), canvas) + let layers = whole_layer(d, id); + match (d.doc.layer(id).and_then(Layer::surface), &layers) { + (Some(surf), _) => Clip { layers, ..lift(surf, d.doc.selection.as_ref(), canvas)? }, + // A group has no pixels of its own: the clipboard pixels are what it shows. + (None, Some(copies)) => { + let mut shown = (**copies).clone(); + for l in &mut shown.layers { + l.visible = true; + } + Clip { layers, ..lift(&merged_surface(&shown), None, canvas)? } + } + (None, None) => return Err(EngineError::Other("the active layer has no pixels".into())), + } }; if clip.bounds.is_empty() { return Err(EngineError::Other("Could not copy: the selected area is empty".into())); @@ -141,9 +202,9 @@ pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Optio let bg = crate::extra_cmds::is_background(doc.layer(id).ok_or(EngineError::NoLayer(id))?); let surf = crate::commands::paint_surface(doc, id, &Value::Null)?; if bg { - crate::pixels::fill_surface(surf, area, background, sel, true); + crate::pixels::try_fill_surface(surf, area, background, sel, true)?; } else { - crate::pixels::clear_surface(surf, area, sel); + crate::pixels::try_clear_surface(surf, area, sel)?; } surf.prune(); Ok(()) @@ -152,6 +213,7 @@ pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Optio /// Paste as a new layer (or into the targeted mask or channel: [`paste_to_target`]). `in_place` keeps the original position; otherwise the pixels are centred /// on `center` (the view centre from the UI) or the canvas, unless they already overlap the canvas. fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { + crate::allocation::checkpoint("pasting pixels")?; let clip = s.clipboard.clone().ok_or(EngineError::Other("the clipboard is empty".into()))?; let Some(d) = s.active() else { // Nothing open to paste into: the clipboard becomes a document of its own (#368). @@ -160,9 +222,16 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { if crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels { return paste_to_target(s, p, in_place, None, "Paste"); } + // A targeted colour channel takes the paste instead of a new layer (#2307). + if let Some(k) = crate::channel_clip::paste_color_target(s, p) { + return crate::channel_clip::paste(s, p, k, in_place, None, "Paste"); + } let canvas = d.doc.bounds(); let fmt = d.doc.pixel_format(); let (dx, dy) = paste_offset(&clip, canvas, p, in_place); + if let Some(layers) = clip.layers.as_deref().filter(|_| crate::layer_copy_cmds::layered(d.doc.mode)) { + return paste_layers(s, layers, dx, dy); + } let moved = shifted(&clip.surface, dx, dy); let target = PixelFormat::new(fmt.mode, fmt.sample, true); let surf = if moved.format() == target { moved } else { moved.convert(target) }; @@ -177,10 +246,33 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { Ok(json!({"layer": id.0, "offset": [dx, dy]})) } +/// Paste of a whole copied layer ([`whole_layer`]): fresh copies (a group's children too), +/// converted to the document's colour, moved as the pixels would be, above the active layer. A pasted smart object gets its +/// own contents, independent of the copied one (as across documents). +fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result { + let d = s.active().ok_or(EngineError::NoDocument)?; + let mut copies = layers.clone(); + for l in &mut copies.layers { + *l = l.duplicate(); + } + crate::layer_copy_cmds::adapt_copies(s, &mut copies, &d.doc)?; + crate::layer_copy_cmds::translate_copies(&mut copies, dx, dy); + let patterns: Vec<_> = copies.patterns.iter().filter(|pat| !d.doc.patterns.iter().any(|q| q.id == pat.id)).cloned().collect(); + let id = s.edit("Paste", |doc, active| { + doc.patterns.extend(patterns); + for l in copies.layers { + *active = Some(doc.insert_above(*active, l)); + } + doc.selection = None; + active.ok_or(EngineError::Other("nothing to paste".into())) + })?; + Ok(json!({"layer": id.0, "offset": [dx, dy]})) +} + /// How far a paste moves the clipboard pixels: not at all in place (or when they lie on the /// canvas and no `center` is given), else onto `center` (the view centre from the UI) or the /// canvas centre. -fn paste_offset(clip: &Clip, canvas: Rect, p: &Value, in_place: bool) -> (i32, i32) { +pub(crate) fn paste_offset(clip: &Clip, canvas: Rect, p: &Value, in_place: bool) -> (i32, i32) { if in_place || (clip.bounds.intersect(&canvas) == clip.bounds && p.get("center").is_none()) { return (0, 0); } @@ -190,7 +282,7 @@ fn paste_offset(clip: &Clip, canvas: Rect, p: &Value, in_place: bool) -> (i32, i ((cx - (b.x0 + b.x1) as f64 / 2.0).round() as i32, (cy - (b.y0 + b.y1) as f64 / 2.0).round() as i32) } -fn shifted(surface: &Surface, dx: i32, dy: i32) -> Surface { +pub(crate) fn shifted(surface: &Surface, dx: i32, dy: i32) -> Surface { if dx == 0 && dy == 0 { surface.clone() } else { photocraft_algo::resample::translate_surface(surface, dx, dy) } } @@ -199,6 +291,7 @@ fn shifted(surface: &Surface, dx: i32, dy: i32) -> Surface { /// over what was there. Their transparency, and `limit` (Paste Into / Outside), let it show /// through. One history step. pub(crate) fn paste_to_target(s: &mut Session, p: &Value, in_place: bool, limit: Option<&Surface>, label: &str) -> Result { + crate::allocation::checkpoint("pasting pixels")?; let clip = s.clipboard.clone().ok_or(EngineError::Other("the clipboard is empty".into()))?; let d = s.active().ok_or(EngineError::NoDocument)?; let canvas = d.doc.bounds(); @@ -208,7 +301,7 @@ pub(crate) fn paste_to_target(s: &mut Session, p: &Value, in_place: bool, limit: let area = src.content_bounds().intersect(&canvas); s.edit(label, |doc, _| { let (surf, _) = crate::channel_cmds::target_surface(doc, layer, p)?; - crate::fill_cmds::composite_over(surf, &src, area, limit); + crate::fill_cmds::composite_over(surf, &src, area, limit)?; surf.prune(); doc.selection = None; Ok(()) @@ -240,6 +333,10 @@ fn layer_via(s: &mut Session, cut: bool) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let id = active_id(s)?; let has_sel = d.doc.selection.is_some(); + if !has_sel && !cut && crate::layer_multi_cmds::multi(s, &Value::Null) { + // No selection, several layers selected: copy every one of them (Photoshop, #2777). + return crate::layer_multi_cmds::duplicate_selected(s, true, "Layer Via Copy"); + } if !has_sel && !cut { // No selection: Layer via Copy duplicates the whole layer (Photoshop). let nid = s.edit("Layer Via Copy", |doc, active| { @@ -254,7 +351,7 @@ fn layer_via(s: &mut Session, cut: bool) -> Result { return Ok(json!({"layer": nid.0})); } let surf = d.doc.layer(id).and_then(Layer::surface).ok_or(EngineError::Other("the active layer has no pixels".into()))?; - let clip = lift(surf, d.doc.selection.as_ref(), d.doc.bounds()); + let clip = lift(surf, d.doc.selection.as_ref(), d.doc.bounds())?; if clip.bounds.is_empty() { return Err(EngineError::Other("Could not complete the command: the selected area is empty".into())); } @@ -287,16 +384,11 @@ fn merge_visible(s: &mut Session) -> Result { // Composite of visible layers only (hidden ones stay where they are). let mut solo = doc.clone(); solo.layers.retain(|l| l.visible); - let buf = photocraft_compose::flatten(&solo); - let buf = if is_background { buf.over_background([1.0, 1.0, 1.0]) } else { buf }; let fmt = doc.pixel_format(); let fmt = if is_background { fmt } else { PixelFormat::new(fmt.mode, fmt.sample, true) }; - let data: Vec = buf.px.iter().flat_map(|p| photocraft_raster::from_rgba(&fmt, *p)).collect(); let mut merged = Layer::raster(base.name.clone(), fmt); merged.locks = base.locks; - let surf = crate::pixels_mut(&mut merged)?; - surf.write_region(doc.bounds(), &data); - surf.prune(); + *crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers(&solo, fmt, is_background.then_some([1.0, 1.0, 1.0])); let mid = merged.id; let mut out = Vec::with_capacity(doc.layers.len()); for (i, l) in doc.layers.drain(..).enumerate() { @@ -537,16 +629,25 @@ pub fn specs() -> Vec { }; } vec![ - spec!("edit.cut", "Cut", &["Edit"], Some("Cmd+X"), "{}", has_pixels, |s, _| { + spec!("edit.cut", "Cut", &["Edit"], Some("Cmd+X"), "{} (a targeted colour channel cuts that channel only)", has_pixels, |s, p| { // Refuse a locked layer before copying, so a refused Cut leaves the clipboard alone. let id = active_id(s)?; crate::commands::check_pixels_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; + // A targeted colour channel cuts that channel only (#2698). + if let Some(k) = crate::channel_clip::paste_color_target(s, p) { + return crate::channel_clip::cut(s, id, k); + } let r = copy(s, false)?; let bg = s.tools.background; s.edit("Cut Pixels", |doc, _| clear_selected(doc, id, bg))?; Ok(r) }), - spec!("edit.copy", "Copy", &["Edit"], Some("Cmd+C"), "{}", has_layer_pixels, |s, _| copy(s, false)), + spec!("edit.copy", "Copy", &["Edit"], Some("Cmd+C"), "{} (a targeted colour or alpha channel copies that channel, as grayscale)", can_copy, |s, _| { + match crate::channel_clip::copy(s)? { + Some(r) => Ok(r), + None => copy(s, false), + } + }), spec!("edit.copyMerged", "Copy Merged", &["Edit"], Some("Cmd+Shift+C"), "{}", has_doc, |s, _| copy(s, true)), spec!( "edit.paste", @@ -623,6 +724,104 @@ mod tests { s } + #[test] + fn injected_clear_buffer_failure_leaves_the_document_unchanged() { + let mut s = session(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let before = st.doc.layer(id).unwrap().surface().unwrap().clone(); + let revision = st.revision; + + crate::allocation::fail_next_for_test(); + let result = s.execute("edit.clear", json!({})); + + assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for erasing pixels"))); + let st = s.active().unwrap(); + assert_eq!(st.doc.layer(id).unwrap().surface(), Some(&before)); + assert_eq!(st.revision, revision); + } + + #[test] + fn injected_copy_buffer_failure_does_not_change_document_or_clipboard() { + let mut s = session(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let before = st.doc.layer(id).unwrap().surface().unwrap().clone(); + let revision = st.revision; + + crate::allocation::fail_next_for_test(); + let result = s.execute("edit.copy", json!({})); + + assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for copying pixels"))); + let st = s.active().unwrap(); + assert_eq!(st.doc.layer(id).unwrap().surface(), Some(&before)); + assert_eq!(st.revision, revision); + assert!(s.clipboard.is_none()); + } + + #[test] + fn injected_fill_result_reservation_failure_keeps_pixels_and_history() { + let mut s = session(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let before = st.doc.layer(id).unwrap().surface().unwrap().clone(); + let revision = st.revision; + let history_len = st.history.past_len(); + + crate::allocation::fail_next_for_test(); + let result = s.execute("edit.fill", json!({"color": "#00ff00"})); + + assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for blending pixels"))); + let st = s.active().unwrap(); + assert_eq!(st.doc.layer(id).unwrap().surface(), Some(&before)); + assert_eq!(st.revision, revision); + assert_eq!(st.history.past_len(), history_len); + } + + #[test] + fn injected_paste_failure_does_not_change_document_or_clipboard() { + let mut s = session(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 8, "height": 8})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let before_clip = s.clipboard.clone(); + let st = s.active().unwrap(); + let before_doc = (*st.doc).clone(); + let revision = st.revision; + let history_len = st.history.past_len(); + + crate::allocation::fail_next_for_test(); + let result = s.execute("edit.paste", json!({})); + + assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for pasting pixels"))); + let st = s.active().unwrap(); + assert_eq!(*st.doc, before_doc); + assert_eq!(st.revision, revision); + assert_eq!(st.history.past_len(), history_len); + let after_clip = s.clipboard.as_ref().expect("clipboard remains available"); + let before_clip = before_clip.as_ref().expect("clipboard was populated"); + assert_eq!(after_clip.bounds, before_clip.bounds); + assert_eq!(after_clip.surface, before_clip.surface); + } + + #[test] + fn injected_transform_failure_keeps_pixels_and_history() { + let mut s = session(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let before = st.doc.layer(id).unwrap().surface().unwrap().clone(); + let revision = st.revision; + let history_len = st.history.past_len(); + + crate::allocation::fail_next_for_test(); + let result = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 20, 30]})); + + assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for transforming pixels"))); + let st = s.active().unwrap(); + assert_eq!(st.doc.layer(id).unwrap().surface(), Some(&before)); + assert_eq!(st.revision, revision); + assert_eq!(st.history.past_len(), history_len); + } + fn active_bounds(s: &Session) -> Rect { let st = s.active().unwrap(); st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds() @@ -938,30 +1137,66 @@ mod tests { } } + /// #2777: with several layers selected and no pixel selection, Layer via Copy (Cmd+J) copies + /// every selected layer in one step, each copy above its original and the copies selected. + /// With a pixel selection it still copies the active layer's selected pixels only. + #[test] + fn layer_via_copy_duplicates_every_selected_layer() { + let mut s = session(); + let a = s.active().unwrap().active_layer.unwrap(); + let b = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "toggle"})).unwrap(); + let steps = s.active().unwrap().history.past_len(); + s.execute("layer.new.layerViaCopy", json!({})).unwrap(); + let st = s.active().unwrap(); + let ids: Vec = st.doc.layers.iter().map(|l| l.id).collect(); + assert_eq!(ids.len(), 5, "background, a, a copy, b, b copy"); + assert_eq!((ids[1], ids[3]), (a, b), "each copy sits above its original"); + let mut selected = st.selected_layers(); + selected.sort_by_key(|l| l.0); + assert_eq!(selected, vec![ids[2], ids[4]], "the copies become the selection"); + assert_eq!(st.history.past_len(), steps + 1, "one undo step"); + assert!(s.undo()); + s.execute("layer.select", json!({"layer": b.0})).unwrap(); + s.execute("layer.select", json!({"layer": a.0, "mode": "toggle"})).unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); + s.execute("layer.new.layerViaCopy", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), 4, "a pixel selection copies one layer's pixels"); + } + fn active_content(s: &Session) -> LayerContent { let st = s.active().unwrap(); st.doc.layer(st.active_layer.unwrap()).unwrap().content.clone() } - /// #541: a smart object copies what it shows, with or without a selection, and pastes as + /// #541, #2428: with no selection, Copy takes a smart object whole and Paste makes a smart + /// object again, with contents of its own; with a selection, the selected pixels paste as /// pixels. Cut edits pixels, so it's refused; Layer via Copy and Duplicate Layer copy the layer. #[test] fn copy_a_smart_object_layer() { let mut s = session(); s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let LayerContent::Smart(original) = active_content(&s) else { panic!("smart object") }; assert!(s.is_enabled("edit.copy")); assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([10, 10, 40, 20]), "no selection: the whole layer"); s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); - assert!(matches!(active_content(&s), LayerContent::Raster(_))); + let LayerContent::Smart(pasted) = active_content(&s) else { panic!("the paste is a smart object (#2428)") }; + assert_eq!(pasted.source, original.source, "the same contents"); + assert_ne!(pasted.contents_id, original.contents_id, "but not linked to the copied one"); assert_eq!(active_bounds(&s), Rect::new(10, 10, 50, 30)); - assert_eq!( - s.active().unwrap().doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().pixel(20, 20), - vec![1.0, 0.0, 0.0, 1.0] - ); + s.execute("edit.paste", json!({"center": [70, 70]})).unwrap(); + assert!(matches!(active_content(&s), LayerContent::Smart(_))); + assert_eq!(active_bounds(&s), Rect::new(50, 60, 90, 80), "placed as pasted pixels are"); + s.undo(); s.undo(); assert!(matches!(active_content(&s), LayerContent::Smart(_))); s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([10, 10, 10, 10]), "only the selected part"); + s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); + assert!(matches!(active_content(&s), LayerContent::Raster(_)), "a selection copies pixels"); + assert_eq!(active_bounds(&s), Rect::new(10, 10, 20, 20)); + s.undo(); let cut = s.execute("edit.cut", json!({})).unwrap_err().to_string(); assert!(cut.contains("not directly editable"), "{cut}"); assert!(matches!(active_content(&s), LayerContent::Smart(_))); @@ -973,6 +1208,20 @@ mod tests { assert_eq!(s.active().unwrap().doc.layers.len(), 4); } + /// #2428: a smart object copied in one document pastes into another as a smart object, in + /// that document's bit depth. + #[test] + fn paste_a_smart_object_into_another_document() { + let mut s = session(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("file.new", json!({"width": 60, "height": 40, "depth": 16})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + let LayerContent::Smart(sm) = active_content(&s) else { panic!("the paste is a smart object") }; + assert_eq!(sm.cache.map(|c| c.format().sample), Some(s.active().unwrap().doc.depth), "converted to 16 bits"); + assert_eq!(active_bounds(&s), Rect::new(10, 10, 50, 30), "on the canvas: in place"); + } + /// Type and shape layers copy their rendered pixels too; groups and adjustments have none. #[test] fn copy_reads_any_layer_that_shows_pixels() { @@ -981,9 +1230,39 @@ mod tests { assert!(!s.is_enabled("edit.cut")); assert!(s.execute("edit.copy", json!({})).is_ok()); s.execute("layer.groupLayers", json!({})).unwrap(); + assert!(s.is_enabled("edit.copy"), "no selection: the group is copied whole (#2670)"); + s.execute("select.all", json!({})).unwrap(); assert!(!s.is_enabled("edit.copy")); } + /// #2670: with no selection, Copy takes a type layer, a shape layer or a group whole and + /// Paste recreates it, into another document too (with fresh ids, in its bit depth); with a + /// selection, Copy takes pixels. + #[test] + fn copy_paste_keeps_type_shape_and_group_layers() { + let mut s = session(); + s.execute("type.create", json!({"text": "Hi", "size": 30, "x": 20, "y": 60})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); + let LayerContent::Text(t) = active_content(&s) else { panic!("the paste is a type layer") }; + assert_eq!(t.text, "Hi"); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert!(matches!(active_content(&s), LayerContent::Raster(_)), "a selection copies pixels"); + s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap(); + s.execute("layer.groupLayers", json!({})).unwrap(); + let LayerContent::Group(src) = active_content(&s) else { panic!("a group") }; + s.execute("edit.copy", json!({})).unwrap(); + s.execute("file.new", json!({"width": 60, "height": 40, "depth": 16})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + let LayerContent::Group(g) = active_content(&s) else { panic!("the paste is a group") }; + let [shape] = g.children.as_slice() else { panic!("one child") }; + assert!(matches!(shape.content, LayerContent::Shape(_)), "the shape stays a shape"); + assert_ne!(shape.id, src.children[0].id, "fresh ids"); + assert_eq!(shape.surface().map(|c| c.format().sample), Some(s.active().unwrap().doc.depth), "converted to 16 bits"); + } + #[test] fn merge_visible_keeps_hidden_layers() { let mut s = session(); diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 46f9245114..93ecd479c4 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -269,6 +269,11 @@ fn purge(s: &mut Session, what: &str) -> Result { freed = freed.saturating_add(masks); items.push("mask cache"); } + let stages = crate::smart_cmds::purge_stack_cache(); + if stages > 0 { + freed = freed.saturating_add(stages); + items.push("smart filter cache"); + } } s.edit_state.fade = None; let msg = if items.is_empty() { "nothing to purge".to_string() } else { format!("purged {}", items.join(", ")) }; @@ -286,7 +291,11 @@ fn can_purge_histories(s: &Session) -> std::result::Result<(), String> { } fn can_purge_all(s: &Session) -> std::result::Result<(), String> { can_purge_histories(s).or_else(|_| can_purge_clipboard(s)).or_else(|_| { - if photocraft_compose::effect_cache_bytes() > 0 || photocraft_compose::masks::cache_bytes() > 0 { Ok(()) } else { Err("nothing to purge".into()) } + if photocraft_compose::effect_cache_bytes() > 0 || photocraft_compose::masks::cache_bytes() > 0 || crate::smart_cmds::stack_cache_bytes() > 0 { + Ok(()) + } else { + Err("nothing to purge".into()) + } }) } @@ -572,7 +581,7 @@ fn content_aware_scale(s: &mut Session, p: &Value) -> Result { move |s, out| { s.edit(label, |doc, _| { let surf = writable_surface(doc, id)?; - crate::pixels::clear_surface(surf, region, None); + crate::pixels::try_clear_surface(surf, region, None)?; surf.write_region(dst, &out); surf.prune(); Ok(()) diff --git a/crates/engine/src/exr_cmds.rs b/crates/engine/src/exr_cmds.rs new file mode 100644 index 0000000000..c0a3310f9f --- /dev/null +++ b/crates/engine/src/exr_cmds.rs @@ -0,0 +1,265 @@ +//! EXR parts: what a multi-part or channel-grouped EXR file holds, and opening the chosen +//! parts/groups as the layers of one document (a Maya/Arnold render writes one part per +//! AOV). The query command feeds the File › Open EXR Parts… dialog, the CLI, MCP and the +//! control channel alike. + +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::file_cmds::{native, read_file, stem}; +use crate::{EngineError, Result, Session}; + +fn entries_json(path: &str) -> Result { + let bytes = read_file(path)?; + let entries = photocraft_io::exr_parts::entries(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(json!(entries + .iter() + .map(|e| json!({ + "kind": match e.kind { photocraft_io::exr_parts::EntryKind::Part => "part", photocraft_io::exr_parts::EntryKind::Group => "group", photocraft_io::exr_parts::EntryKind::Cryptomatte => "cryptomatte" }, + "name": e.name, + "view": e.view, + "width": e.width, + "height": e.height, + "channels": e.channels, + "objects": e.objects, + })) + .collect::>())) +} + +fn exr_parts(s: &mut Session, p: &Value) -> Result { + let _ = s; + let path = crate::file_cmds::str_param(p, "path", "file.exrParts")?.to_string(); + entries_json(&path) +} + +/// Collects `part/` and `group/` boolean params into the selection names. +fn selected(p: &Value, prefix: &str) -> Vec { + p.as_object() + .map(|m| m.iter().filter(|(k, v)| k.starts_with(prefix) && v.as_bool() == Some(true)).map(|(k, _)| k[prefix.len()..].to_string()).collect()) + .unwrap_or_default() +} + +fn open_exr_parts(s: &mut Session, p: &Value) -> Result { + let path = crate::file_cmds::str_param(p, "path", "file.openExrParts")?.to_string(); + let view = p.get("view").and_then(Value::as_str).unwrap_or("both"); + if !matches!(view, "both" | "left" | "right") { + return Err(EngineError::BadParams { cmd: "file.openExrParts".into(), msg: format!("view is \"{view}\"; it is \"both\", \"left\" or \"right\"") }); + } + let sel = photocraft_io::exr_parts::Selection { + parts: selected(p, "part/"), + groups: selected(p, "group/"), + view: view.to_string(), + precomp: p.get("precomp").and_then(Value::as_bool).unwrap_or(false), + }; + let bytes = read_file(&path)?; + let r = photocraft_io::exr_parts::import(&stem(&path), &bytes, &sel).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let (i, color) = s.open_document(r.document, Some(path.clone())); + Ok(json!({"document": i, "color": color, "warnings": r.warnings})) +} + +pub fn specs() -> Vec { + macro_rules! spec { + ($id:expr, $label:expr, $menu:expr, $sc:expr, $params:expr, $enabled:expr, $run:expr) => { + CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } + }; + } + vec![ + spec!("file.exrParts", "EXR Parts Info", &[], None, r##"{"path":str (the EXR file)}"##, native, exr_parts), + spec!( + "file.openExrParts", + "Open EXR Parts…", + &["File"], + None, + r##"{"path":str (the EXR file),"view":"both|left|right"="both","part/":bool (import this part as a layer),"group/":bool (import this channel group as a layer),"precomp":bool=false (add the additive beauty precomp from the AOVs)}"##, + native, + open_exr_parts + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_doc::ColorMode; + + fn session() -> Session { + Session::new() + } + + // The fixture writer mirrors the codecs tests: a small multi-part EXR via the `exr` + // crate, one part per AOV plus a single-part file with prefixed channel groups. + + fn write_multipart(path: &std::path::Path) { + use exr::prelude::*; + let (w, h) = (4usize, 4usize); + let mk = |n: String, v: f32| AnyChannel::new(n.as_str(), FlatSamples::F32(vec![v; w * h])); + let beauty = AnyChannels::sort(vec![mk("R".into(), 0.8), mk("G".into(), 0.4), mk("B".into(), 0.2), mk("A".into(), 1.0)].into_iter().collect()); + let z = AnyChannels::sort(vec![mk("Z".into(), 5.0)].into_iter().collect()); + let layer = |name: &str, chans: AnyChannels| { + Layer::new( + (w, h), + LayerAttributes::named(name), + Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + chans, + ) + }; + let image = Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer("Z", z), layer("beauty", beauty)]); + image.write().to_buffered(&mut std::io::BufWriter::new(std::fs::File::create(path).expect("create fixture"))).expect("write fixture"); + } + + fn write_grouped(path: &std::path::Path) { + use exr::prelude::*; + let (w, h) = (4usize, 4usize); + let mk = |n: String, v: f32| AnyChannel::new(n.as_str(), FlatSamples::F32(vec![v; w * h])); + // Two AOV groups (each with alpha) plus the beauty RGBA, in ONE part. + let list = vec![ + mk("diffuse.R".into(), 0.3), + mk("diffuse.G".into(), 0.3), + mk("diffuse.B".into(), 0.3), + mk("diffuse.A".into(), 0.5), + mk("specular.R".into(), 0.6), + mk("specular.G".into(), 0.6), + mk("specular.B".into(), 0.6), + mk("specular.A".into(), 0.25), + mk("R".into(), 0.9), + mk("G".into(), 0.9), + mk("B".into(), 0.9), + mk("A".into(), 1.0), + ]; + let layer = Layer::new( + (w, h), + LayerAttributes::named("main"), + Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + AnyChannels::sort(list.into_iter().collect()), + ); + let image = Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer]); + image.write().to_buffered(&mut std::io::BufWriter::new(std::fs::File::create(path).expect("create fixture"))).expect("write fixture"); + } + + #[test] + fn exr_parts_lists_parts_groups_and_views() { + let dir = std::env::temp_dir().join(format!("pcraft-exr-parts-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("aov.exr"); + write_multipart(&path); + let info = entries_json(path.to_str().unwrap()).unwrap(); + let kinds: Vec<&str> = info.as_array().unwrap().iter().map(|e| e["kind"].as_str().unwrap()).collect(); + assert_eq!(kinds, ["part", "part"], "{info}"); + assert_eq!(info[0]["name"], "Z"); + assert_eq!(info[1]["name"], "beauty"); + assert_eq!(info[1]["channels"], 4); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn open_exr_parts_imports_the_chosen_part_as_a_layer() { + let dir = std::env::temp_dir().join(format!("pcraft-exr-open-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("aov.exr"); + write_multipart(&path); + let mut s = session(); + let r = s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), "part/beauty": true, "part/Z": false})).unwrap(); + assert_eq!(r["warnings"].as_array().unwrap().len(), 0, "{r}"); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.layers.len(), 1); + assert_eq!(doc.layers[0].name, "beauty"); + // The beauty pixels survived (R 0.8 at pixel 0). + if let photocraft_doc::LayerContent::Raster(surface) = &doc.layers[0].content { + let px = surface.pixel(0, 0); + assert!((px[0] - 0.8).abs() < 1e-4, "{:?}", px); + } else { + panic!("expected a raster layer"); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn open_exr_parts_refuses_an_empty_selection_and_unknown_names() { + let dir = std::env::temp_dir().join(format!("pcraft-exr-empty-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("aov.exr"); + write_multipart(&path); + let mut s = session(); + let e = s.execute("file.openExrParts", json!({"path": path.to_str().unwrap()})).unwrap_err(); + assert!(e.to_string().contains("no parts or groups selected"), "{e}"); + let e = s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), "part/nope": true})).unwrap_err(); + assert!(e.to_string().contains("no flat part named nope"), "{e}"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn channel_groups_import_from_their_own_channels() { + let dir = std::env::temp_dir().join(format!("pcraft-exr-groups-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("grouped.exr"); + write_grouped(&path); + let info = entries_json(path.to_str().unwrap()).unwrap(); + let names: Vec<&str> = info.as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["main", "diffuse", "specular"], "{info}"); + let mut s = session(); + s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), "group/diffuse": true})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.layers.len(), 1); + assert_eq!(doc.layers[0].name, "diffuse"); + if let photocraft_doc::LayerContent::Raster(surface) = &doc.layers[0].content { + let px = surface.pixel(0, 0); + // The channels import as stored (straight here): values are untouched. + assert!((px[0] - 0.3).abs() < 1e-4, "{:?}", px); + assert!((px[3] - 0.5).abs() < 1e-4, "{:?}", px); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn precomp_sums_the_light_path_set_and_refuses_an_incomplete_one() { + let dir = std::env::temp_dir().join(format!("pcraft-exr-precomp-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("grouped.exr"); + write_grouped(&path); + let mut s = session(); + // diffuse + specular make the set incomplete: emission is missing. + let e = s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), "precomp": true, "group/diffuse": true})).unwrap_err(); + assert!(e.to_string().contains("missing") && e.to_string().contains("emission"), "{e}"); + + // A complete set: write a file holding direct/indirect diffuse+specular and emission. + let full = dir.join("full.exr"); + use exr::prelude::*; + let (w, h) = (4usize, 4usize); + let mk = |n: String, v: f32| AnyChannel::new(n.as_str(), FlatSamples::F32(vec![v; w * h])); + let aovs = [ + ("direct_diffuse", 0.10, 1.0), + ("indirect_diffuse", 0.20, 1.0), + ("direct_specular", 0.05, 1.0), + ("indirect_specular", 0.15, 1.0), + ("emission", 0.30, 1.0), + ]; + let mut list: Vec> = Vec::new(); + let mut expect = 0.0f32; + for (name, v, a) in aovs { + list.push(mk(format!("{name}.R"), v)); + list.push(mk(format!("{name}.G"), v)); + list.push(mk(format!("{name}.B"), v)); + list.push(mk(format!("{name}.A"), a)); + expect += v * a; + } + let layer = Layer::new( + (w, h), + LayerAttributes::named("main"), + Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + AnyChannels::sort(list.into_iter().collect()), + ); + let image = Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer]); + image.write().to_buffered(&mut std::io::BufWriter::new(std::fs::File::create(&full).expect("create fixture"))).expect("write fixture"); + let mut s = session(); + let r = s.execute("file.openExrParts", json!({"path": full.to_str().unwrap(), "precomp": true})).unwrap(); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("precomp")), "{r}"); + let doc = &s.active().unwrap().doc; + let precomp = doc.layers.iter().find(|l| l.name == "beauty (precomp)").expect("the precomp layer"); + if let photocraft_doc::LayerContent::Raster(surface) = &precomp.content { + let px = surface.pixel(0, 0); + assert!((px[0] - expect).abs() < 1e-3, "{:?} vs {expect}", px); + } + assert_eq!(doc.mode, ColorMode::Rgb); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 8a2e925c46..e38a9d01f4 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -68,7 +68,7 @@ pub(crate) fn style_targets(s: &Session, p: &Value, fits: impl Fn(&Layer) -> boo } /// A key that makes the sub-commands of one command share a single history step. -fn step_key(s: &Session, what: &str) -> String { +pub(crate) fn step_key(s: &Session, what: &str) -> String { format!("{what}#{}", s.active().map_or(0, |d| d.revision)) } @@ -218,7 +218,7 @@ fn stroke(s: &mut Session, p: &Value) -> Result { let surf = crate::commands::paint_surface(doc, id, &Value::Null)?; let area = band.content_bounds().intersect(&canvas); if !area.is_empty() { - crate::fill_cmds::blend_color_mask(surf, area, color, &band, mode, opacity, preserve || lock); + crate::fill_cmds::blend_color_mask(surf, area, color, &band, mode, opacity, preserve || lock)?; surf.prune(); } Ok(()) @@ -390,6 +390,10 @@ fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result { if let Some(c) = p.get("center") { params["center"] = c.clone(); } + // A targeted colour channel (#2307): the paste goes into it, only where `limit` allows. + if let Some(k) = crate::channel_clip::paste_color_target(s, p) { + return crate::channel_clip::paste(s, ¶ms, k, false, Some(&limit), label); + } if crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels { // A targeted mask or channel (#1035): the paste goes into it, only where `limit` allows. params["target"] = p.get("target").cloned().unwrap_or_default(); @@ -430,7 +434,7 @@ fn layer_from_background(s: &mut Session) -> Result { Ok(json!({"layer": id.0})) } -fn unlock_background(l: &mut Layer) { +pub(crate) fn unlock_background(l: &mut Layer) { l.name = "Layer 0".into(); l.locks.transparency = false; l.locks.position = false; @@ -613,7 +617,7 @@ fn content_pixels(doc: &Document, l: &Layer) -> photocraft_raster::Surface { s } -fn kind_of(s: &Session, id: LayerId) -> Option<&'static str> { +pub(crate) fn kind_of(s: &Session, id: LayerId) -> Option<&'static str> { let d = s.active()?; Some(match &d.doc.layer(id)?.content { LayerContent::Text(_) => "type", @@ -624,8 +628,19 @@ fn kind_of(s: &Session, id: LayerId) -> Option<&'static str> { }) } -/// Rasterize one layer; `only` restricts it to one kind. Returns false when there was nothing to do. -fn rasterize_one(s: &mut Session, id: LayerId, only: Option<&str>, key: &str) -> Result { +/// Rasterize one layer; `only` restricts it to one kind. Without `only` (Rasterize › Layer / All +/// Layers: "all vector data") a vector mask becomes a pixel mask too; layer effects stay live, as +/// in Photoshop (Rasterize › Layer Style bakes them). Returns false when there was nothing to do. +pub(crate) fn rasterize_one(s: &mut Session, id: LayerId, only: Option<&str>, key: &str) -> Result { + let vector_mask = only.is_none() && s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| l.vector_mask.is_some()); + let content = rasterize_content(s, id, only, key)?; + if vector_mask { + s.execute("layer.rasterize.vectorMask", json!({"layer": id.0, "coalesce": key}))?; + } + Ok(content || vector_mask) +} + +fn rasterize_content(s: &mut Session, id: LayerId, only: Option<&str>, key: &str) -> Result { let Some(kind) = kind_of(s, id).filter(|k| only.is_none_or(|o| o == *k)) else { return Ok(false) }; match kind { "type" => { diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index d75cd64504..9eeb98a2c2 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -115,6 +115,7 @@ pub(crate) fn list_images(dir: &str) -> Result> { } /// Extensions the batch commands pick up from a folder. +#[cfg(not(target_arch = "wasm32"))] const OPENABLE: &[&str] = &[ "pdn", "ora", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", "afpub", @@ -152,7 +153,14 @@ pub fn extension(path: &str) -> Option { /// Whether a save without a new path may write back to `path`: only layered files (PSD, PSB, /// .pcraft). A flat file goes through Save As instead, so it is never flattened over the original. pub fn saves_in_place(path: &str) -> bool { - extension(path).is_some_and(|ext| matches!(ext.as_str(), "psd" | "psb" | "pcraft" | "ora")) + extension(path).is_some_and(|ext| layered_extension(&ext)) +} + +/// Whether the lower-case extension `ext` names a layered document format (PSD, PSB, OpenRaster, +/// .pcraft). A save to one becomes the document's file; any other format is a copy for automation +/// saves, desktop and headless alike (#1547, #2579). +pub fn layered_extension(ext: &str) -> bool { + matches!(ext, "psd" | "psb" | "pcraft" | "ora") } /// Whether `path` names a document template (.psdt). A template opens as a new untitled document @@ -198,6 +206,7 @@ pub(crate) fn sanitize(name: &str) -> String { } pub(crate) fn import(name: &str, bytes: &[u8]) -> Result { + crate::allocation::checkpoint("importing document")?; let r = photocraft_io::import(name, bytes).map_err(|e| EngineError::Other(format!("{name}: {e}")))?; // Auxiliary imports return only a document and cannot surface the preview's fidelity warning. // Open has its own warning-preserving path; never silently place or process a thumbnail. @@ -238,6 +247,7 @@ impl From> for SaveOpts { /// Encodes `doc` for `path`'s extension. pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> Result<(Vec, Vec)> { + crate::allocation::checkpoint("saving document")?; let save = save.into(); let mut opts = photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, ..Default::default() }; if let Some(q) = save.quality { @@ -352,6 +362,7 @@ fn save_a_copy(s: &mut Session, p: &Value) -> Result { /// Open a file's bytes, decoding them as the format `as_ext` (Open As) when given. pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<&str>, path: Option) -> Result { + crate::allocation::checkpoint("importing document")?; let decode_name = match as_ext { Some(ext) => format!("{}.{}", stem(name), ext.trim_start_matches('.')), None => name.to_string(), @@ -389,9 +400,9 @@ fn open_as(s: &mut Session, p: &Value) -> Result { /// History label of an embedded place. pub const PLACE_EMBEDDED: &str = "Place Embedded"; -/// Place a file's bytes as a smart object layer, centred and (when larger than the canvas) -/// scaled down to fit, like Photoshop's Place with "Resize Image During Place". `linked` makes it -/// a linked smart object that refers to that path instead of embedding the bytes. +/// Place a file's bytes as a smart object layer, centred, and (with Preferences ▸ General ▸ +/// Resize Image During Place, on by default) scaled down to fit when larger than the canvas. +/// `linked` makes it a linked smart object that refers to that path instead of embedding the bytes. pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let (cw, ch) = (d.doc.size.width as f64, d.doc.size.height as f64); @@ -406,9 +417,11 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option (k / 100.0).max(1e-4), - None if p.get("fit").and_then(Value::as_bool) != Some(false) && (w > cw || h > ch) => (cw / w).min(ch / h), + None if fit && (w > cw || h > ch) => (cw / w).min(ch / h), None => 1.0, }; let center = match p.get("center").and_then(Value::as_array) { @@ -760,19 +773,36 @@ impl OutputClaims { } } -fn batch(_s: &mut Session, p: &Value) -> Result { +fn batch(s: &mut Session, p: &Value) -> Result { let cmd = "file.automate.batch"; - let steps = + let mut steps = parse_steps(p.get("steps").or_else(|| p.get("action")).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "missing \"steps\"".into() })?)?; + // Each result goes to the destination folder, which overrides the action's recorded Save / + // Save As steps (Photoshop's "Override Action 'Save As' Commands"). Recorded view steps + // (zoom, fit) don't change the output, so they are skipped (#2752). + steps.retain(|(id, _)| !crate::actions_cmds::shell_save_command(id) && !crate::actions_cmds::shell_view_command(id)); if let Some((id, _)) = steps.iter().find(|(id, _)| crate::commands::find(id).is_none()) { return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unknown command `{id}` in the action") }); } let inputs = batch_inputs(p, cmd)?; let output = str_param(p, "output", cmd)?.to_string(); let format = p.get("format").and_then(Value::as_str).unwrap_or("same").to_string(); + // Steps that call another action play it from the caller's actions; the playback stack comes + // along so an action that batches itself is caught as a recursive call. + let actions = crate::actions_cmds::ActionState { + list: s.actions.list.clone(), + playing: s.actions.playing, + playback_stack: s.actions.playback_stack.clone(), + ..Default::default() + }; let r = process_files(&inputs, &output, &format, SaveOpts::from_params(p), "", &|scratch| { + scratch.actions = actions.clone(); for (id, params) in &steps { - scratch.execute(id, params.clone())?; + let r = scratch.execute(id, params.clone())?; + // A failed step inside a called action makes this file an error, not a saved result. + if let Some(error) = crate::actions_cmds::nested_failure(id, &r) { + return Err(EngineError::Other(error)); + } } Ok(()) }); @@ -830,10 +860,10 @@ fn load_files_into_stack(s: &mut Session, p: &Value) -> Result { let n = stack.layers.len(); if p.get("createSmartObject").and_then(Value::as_bool).unwrap_or(false) { // "Create Smart Object after Loading Layers": the layers go inside one smart object, ready - // for Layer › Smart Objects › Stack Mode. - let children = std::mem::take(&mut stack.layers); - let group = Layer::new(stem(&paths[0]), LayerContent::Group(photocraft_doc::Group { children, expanded: true, artboard: None })); - let smart = crate::smart_cmds::layer_to_smart(&stack, &group)?; + // for Layer › Smart Objects › Stack Mode. Like Photoshop, they sit at the top level of the + // contents, with no wrapper group. + let layers = std::mem::take(&mut stack.layers); + let smart = crate::smart_cmds::layers_to_smart(&stack, &stem(&paths[0]), layers)?; stack.layers = vec![smart]; } let i = s.add_document(stack, None); @@ -1274,13 +1304,19 @@ pub fn specs() -> Vec { "Place Embedded…", &["File"], None, - r##"{"path":str,"scale":%? (default: fit when larger than the canvas),"fit":bool=true,"center":[x,y]?}"##, + r##"{"path":str,"scale":%? (default: fit when larger than the canvas),"fit":bool? (default: Preferences > General > Resize Image During Place),"center":[x,y]?}"##, native_doc, |s, p| place(s, p, false) ), - spec!("file.placeLinked", "Place Linked…", &["File"], None, r##"{"path":str,"scale":%?,"fit":bool=true,"center":[x,y]?}"##, native_doc, |s, p| place( - s, p, true - )), + spec!( + "file.placeLinked", + "Place Linked…", + &["File"], + None, + r##"{"path":str,"scale":%?,"fit":bool? (default: Preferences > General > Resize Image During Place),"center":[x,y]?}"##, + native_doc, + |s, p| place(s, p, true) + ), spec!( "file.fileInfo", "File Info…", diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 65cfea290f..e5b17a2005 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -91,6 +91,28 @@ fn revert_reloads_as_one_undoable_step() { assert!(s.execute("file.revert", json!({})).is_err()); } +#[test] +fn injected_import_failure_does_not_open_a_document() { + let mut s = session(8, 8, 8); + let before = s.documents().len(); + crate::allocation::fail_next_for_test(); + let result = open_bytes_as(&mut s, "image.png", b"not decoded", None, None); + assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for importing document"))); + assert_eq!(s.documents().len(), before); +} + +#[test] +fn injected_save_failure_does_not_modify_the_destination() { + let dir = tmp("allocation-save"); + let path = join(&dir, "existing.psd"); + std::fs::write(&path, b"previous file").unwrap(); + let s = session(8, 8, 8); + crate::allocation::fail_next_for_test(); + let result = save_doc(doc(&s), &path, None); + assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for saving document"))); + assert_eq!(std::fs::read(path).unwrap(), b"previous file"); +} + #[test] fn save_a_copy_keeps_path_and_dirty_state() { let dir = tmp("copy"); @@ -158,6 +180,23 @@ fn place_embedded_centres_fits_and_embeds() { } } +/// Preferences ▸ General ▸ Resize Image During Place: off, a larger image keeps its natural size +/// instead of being fitted to the canvas; an explicit `"fit"` still wins. +#[test] +fn resize_image_during_place_preference_controls_the_fit() { + let dir = tmp("place-fit-pref"); + let big = png(&dir, "big.png", 200, 100, "#ff0000"); + let mut s = session(100, 100, 8); + s.execute("prefs.set", json!({"path": "general.resizeImageDuringPlace", "value": false})).unwrap(); + let r = s.execute("file.placeEmbedded", json!({"path": big})).unwrap(); + assert!((r["scale"].as_f64().unwrap() - 100.0).abs() < 1e-9, "natural size: {r}"); + let b = r["bounds"].as_array().unwrap(); + assert!(b[2].as_f64().unwrap() > 100.0, "wider than the canvas: {b:?}"); + // An explicit "fit": true overrides the preference. + let r = s.execute("file.placeEmbedded", json!({"path": big, "fit": true})).unwrap(); + assert!((r["scale"].as_f64().unwrap() - 50.0).abs() < 1e-9, "fitted on request: {r}"); +} + /// A 40×20 JPEG (left half red, right half blue) tagged EXIF Orientation = 6: shown upright it /// is 20×40, red on top. fn rotated_jpeg(dir: &str) -> String { diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index 992c117cfd..78a28b0480 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -127,7 +127,7 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { let mut source_doc = (*source_doc).clone(); let (src, _) = crate::channel_cmds::target_surface(&mut source_doc, id, p) .map_err(|_| EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into()))?; - Source::Pixels(area, read_rgba(src, area)) + Source::Pixels(area, read_rgba(src, area, "reading Fill's history source")?) } "contentAware" => { let sel = sel.as_ref().ok_or_else(|| EngineError::Other("Content-Aware fill needs a selection".into()))?; @@ -149,23 +149,32 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { s.edit("Fill", |doc, _| { let sel = doc.selection.clone(); let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; - blend_into(surf, area, &source, sel.as_ref(), Blend { mode, opacity, keep_alpha: preserve || lock, restore }); + blend_into(surf, area, &source, sel.as_ref(), Blend { mode, opacity, keep_alpha: preserve || lock, restore })?; Ok(()) })?; Ok(out) } -fn read_rgba(src: &Surface, area: Rect) -> Vec<[f32; 4]> { - let mut px = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize]; +fn read_rgba(src: &Surface, area: Rect, purpose: &str) -> Result> { + let len = (area.width() as usize) + .checked_mul(area.height() as usize) + .ok_or_else(|| EngineError::Other(format!("not enough memory for {purpose} (requested size overflowed); the document was not changed")))?; + let mut px = crate::allocation::filled(len, [0.0f32; 4], purpose)?; src.read_rgba_into(area, &mut px); - px + Ok(px) } /// `src` over `surf` across `area` (Normal, 100 %), each pixel weighted by `limit` when given: /// a paste into a layer mask or channel, which keeps the pasted pixels' luminosity (#1035). -pub(crate) fn composite_over(surf: &mut Surface, src: &Surface, area: Rect, limit: Option<&Surface>) { - let source = Source::Pixels(area, read_rgba(src, area)); - blend_into(surf, area, &source, limit, Blend { mode: BlendMode::Normal, opacity: 1.0, keep_alpha: false, restore: false }); +pub(crate) fn composite_over(surf: &mut Surface, src: &Surface, area: Rect, limit: Option<&Surface>) -> Result<()> { + let source = Source::Pixels(area, read_rgba(src, area, "pasting pixels")?); + blend_into(surf, area, &source, limit, Blend { mode: BlendMode::Normal, opacity: 1.0, keep_alpha: false, restore: false }) +} + +/// `area` of `surf` filled with an opaque `color` (Normal, 100 %), weighted by `sel` when given: +/// Clear on a targeted alpha channel or the Quick Mask (#2734). +pub(crate) fn fill_color(surf: &mut Surface, area: Rect, color: [f32; 4], sel: Option<&Surface>) -> Result<()> { + blend_into(surf, area, &Source::Color(color), sel, Blend { mode: BlendMode::Normal, opacity: 1.0, keep_alpha: false, restore: false }) } /// Reuse Edit › Fill's compositing for Edit › Stroke. The stroke's rasterized band @@ -179,8 +188,8 @@ pub(crate) fn blend_color_mask( mode: BlendMode, opacity: f32, preserve_transparency: bool, -) { - blend_into(surf, area, &Source::Color(color), Some(coverage), Blend { mode, opacity, keep_alpha: preserve_transparency, restore: false }); +) -> Result<()> { + blend_into(surf, area, &Source::Color(color), Some(coverage), Blend { mode, opacity, keep_alpha: preserve_transparency, restore: false }) } /// The selection filled from its surroundings (`photocraft_algo::content_aware`), as straight @@ -262,7 +271,7 @@ fn lerp(d: [f32; 4], c: [f32; 4], k: f32) -> [f32; 4] { /// Blend `source` into `surf` over `area`: per pixel, the selection coverage times the opacity, /// in the blend mode. /// Works tile by tile in parallel on the encoded pixels (no full-surface f32 round trip). -fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surface>, b: Blend) { +fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surface>, b: Blend) -> Result<()> { use rayon::prelude::*; let fmt = surf.format(); // An opaque colour in Normal mode at 100 % that may replace alpha: fully covered tiles become @@ -275,24 +284,35 @@ fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surf } _ => None, }; - let tiles = surf.take_tiles(area); - let done: Vec<_> = tiles - .into_par_iter() - .map(|(tc, tile)| { - let tr = tc.rect().intersect(&area); - let mask = sel.map(|m| Mask { bytes: m.tile(tc).map_or(m.default_bytes(), |t| t.bytes()), fmt: m.format(), origin: tc.rect() }); - if let Some(s) = &solid - && tr == tc.rect() - && mask.as_ref().is_none_or(|m| m.full(tr)) - { - return (tc, s.clone()); - } - let mut tile = std::sync::Arc::unwrap_or_clone(tile); - blend_tile(tile.bytes_mut(), &fmt, tc.rect(), tr, source, mask.as_ref(), b); - (tc, std::sync::Arc::new(tile)) - }) - .collect(); + let count = area.tiles().count(); + let done = crate::allocation::capacity(count, "blending pixels")?; + let tiles = surf + .try_take_tiles(area) + .map_err(|e| EngineError::Other(format!("not enough memory while preparing pixel tiles ({e}); the document was not changed")))?; + let done = std::sync::Mutex::new(done); + tiles.into_par_iter().try_for_each(|(tc, tile)| -> Result<()> { + let tr = tc.rect().intersect(&area); + let mask = sel.map(|m| Mask { bytes: m.tile(tc).map_or(m.default_bytes(), |t| t.bytes()), fmt: m.format(), origin: tc.rect() }); + if let Some(s) = &solid + && tr == tc.rect() + && mask.as_ref().is_none_or(|m| m.full(tr)) + { + done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((tc, s.clone())); + return Ok(()); + } + let mut tile = match std::sync::Arc::try_unwrap(tile) { + Ok(tile) => tile, + Err(tile) => tile + .try_clone() + .map_err(|e| EngineError::Other(format!("not enough memory while blending a pixel tile ({e}); the document was not changed")))?, + }; + blend_tile(tile.bytes_mut(), &fmt, tc.rect(), tr, source, mask.as_ref(), b); + done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((tc, std::sync::Arc::new(tile))); + Ok(()) + })?; + let done = done.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner); surf.put_tiles(done); + Ok(()) } /// The selection's coverage over one tile (its channel 0). @@ -501,8 +521,8 @@ mod tests { let st = s.active().unwrap(); let mut surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().clone(); let t = std::time::Instant::now(); - crate::pixels::fill_surface(&mut surf, Rect::new(0, 0, 6000, 4000), [0.2, 0.4, 0.6, 1.0], None, false); - println!("{:<48} {:>8.1} ms", "old fill_surface, no selection", t.elapsed().as_secs_f64() * 1000.0); + crate::pixels::try_fill_surface(&mut surf, Rect::new(0, 0, 6000, 4000), [0.2, 0.4, 0.6, 1.0], None, false).unwrap(); + println!("{:<48} {:>8.1} ms", "fallible fill_surface, no selection", t.elapsed().as_secs_f64() * 1000.0); } #[test] diff --git a/crates/engine/src/fill_key_cmds.rs b/crates/engine/src/fill_key_cmds.rs index ed775e3fd5..6235b629bb 100644 --- a/crates/engine/src/fill_key_cmds.rs +++ b/crates/engine/src/fill_key_cmds.rs @@ -14,7 +14,7 @@ use crate::{EngineError, Result, Session}; /// The fill-key command ids (routed to the Channels panel target like `edit.fill`). pub const IDS: [&str; 4] = ["edit.fillForeground", "edit.fillBackground", "edit.fillForegroundPreserve", "edit.fillBackgroundPreserve"]; -const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|{"channel":i}|"quickMask"?}"##; +const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?}"##; fn enabled(s: &Session) -> std::result::Result<(), String> { let edit_fill = crate::commands::find("edit.fill").ok_or_else(|| "Fill is unavailable".to_string())?; diff --git a/crates/engine/src/filters/validation.rs b/crates/engine/src/filters/validation.rs index a0eb171fc6..127e92efe1 100644 --- a/crates/engine/src/filters/validation.rs +++ b/crates/engine/src/filters/validation.rs @@ -34,7 +34,7 @@ impl Rule { } // The bounds and choices are the core filters' public dialog-unit contract. Defaults remain in -// params_for; extended/gallery filters keep their own parsing until their validation is added. +// params_for; gallery filters are checked in gallery_cmds. fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> { use Rule::{Bool, Choice, Number, Unsigned}; Some(match id { @@ -92,11 +92,49 @@ fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> { }) } +// Extended filters: only their documented choices are checked for now, since `prepare` injects +// extra keys (colours, map documents) and their numeric ranges clamp in params_for. +fn choices(id: &str) -> Option<&'static [(&'static str, &'static str)]> { + Some(match id { + "filter.pixelate.mezzotint" => { + &[("type", "fineDots|mediumDots|grainyDots|coarseDots|shortLines|mediumLines|longLines|shortStrokes|mediumStrokes|longStrokes")] + } + "filter.stylize.diffuse" => &[("mode", "normal|darkenOnly|lightenOnly|anisotropic")], + "filter.stylize.extrude" => &[("type", "blocks|pyramids"), ("depthMode", "random|levelBased")], + "filter.stylize.tiles" => &[("fill", "background|foreground|inverse|unaltered")], + "filter.stylize.traceContour" => &[("edge", "lower|upper")], + "filter.stylize.wind" => &[("method", "wind|blast|stagger"), ("direction", "fromRight|fromLeft")], + "filter.distort.displace" => &[("fit", "stretch|tile"), ("undefinedAreas", "repeat|wrap")], + "filter.distort.shear" => &[("undefinedAreas", "wrap|repeat")], + "filter.distort.zigZag" => &[("style", "pondRipples|outFromCenter|aroundCenter")], + "filter.render.lensFlare" => &[("lens", "zoom|prime35|prime105|moviePrime")], + "filter.render.lightingEffects" => &[("lightType", "spot|point|infinite"), ("texture", "none|red|green|blue|alpha|luminance")], + "filter.blur.smartBlur" => &[("quality", "high|medium|low"), ("mode", "normal|edgeOnly|overlayEdge")], + "filter.blur.lensBlur" => { + &[("shape", "hexagon|triangle|square|pentagon|heptagon|octagon"), ("depthMap", "none|transparency|layerMask"), ("distribution", "uniform|gaussian")] + } + "filter.blur.shapeBlur" => &[("shape", "circle|ring|square|diamond|triangle|hexagon|star|heart|cross")], + "filter.other.hsbHsl" => &[("inputMode", "rgb|hsb|hsl"), ("rowOrder", "hsb|hsl|rgb")], + "filter.video.deInterlace" => &[("eliminate", "oddFields|evenFields"), ("createBy", "interpolation|duplication")], + _ => return None, + }) +} + /// Runs both at dispatch (before committing a floating selection) and at the shared runner -/// (direct specs/Last Filter). Filters outside this module's core family are unaffected. +/// (direct specs/Last Filter). Filters outside the core and extended families are unaffected. pub(crate) fn validate_params(id: &str, params: &Value) -> Result<()> { - let Some(fields) = fields(id) else { return Ok(()) }; let bad = |msg| EngineError::BadParams { cmd: id.into(), msg }; + if let Some(choices) = choices(id) { + for &(key, allowed) in choices { + if let Some(value) = params.get(key) + && !Rule::Choice(allowed).accepts(value) + { + return Err(bad(format!("`{key}` must be {}", Rule::Choice(allowed).expected()))); + } + } + return Ok(()); + } + let Some(fields) = fields(id) else { return crate::gallery_cmds::validate_params(id, params) }; if params.is_null() { return Ok(()); } @@ -270,6 +308,46 @@ mod tests { } } + #[test] + fn extended_filter_choices_match_their_specs_and_reject_unlisted_values() { + let mut s = session(8); + let specs = crate::filters_ext::specs(); + let ids = [ + "filter.pixelate.mezzotint", + "filter.stylize.diffuse", + "filter.stylize.extrude", + "filter.stylize.tiles", + "filter.stylize.traceContour", + "filter.stylize.wind", + "filter.distort.displace", + "filter.distort.shear", + "filter.distort.zigZag", + "filter.render.lensFlare", + "filter.render.lightingEffects", + "filter.blur.smartBlur", + "filter.blur.lensBlur", + "filter.blur.shapeBlur", + "filter.other.hsbHsl", + "filter.video.deInterlace", + ]; + for id in ids { + let doc = specs.iter().find(|spec| spec.id == id).unwrap().params; + for &(key, allowed) in choices(id).unwrap() { + // The table repeats the registry's params doc, so the two cannot drift apart. + assert!(doc.contains(&format!("\"{key}\":\"{allowed}\"")), "{id}.{key}"); + for value in [json!("zzbogus"), json!(false), json!(1), Value::Null] { + rejected(&mut s, id, key, value); + } + for value in allowed.split('|') { + validate_params(id, &json!({key:value})).unwrap(); + } + } + } + // Keys `prepare` injects, and undocumented ones, stay accepted as before. + s.execute("filter.stylize.tiles", json!({"count":4,"foreground":[1,0,0,1]})).unwrap(); + s.execute("filter.stylize.wind", json!({"direction":"fromLeft"})).unwrap(); + } + #[test] fn invalid_filters_leave_floating_selection_and_background_jobs_alone() { let mut s = session(16); diff --git a/crates/engine/src/float_cmds.rs b/crates/engine/src/float_cmds.rs index 98ea5272bd..892f754748 100644 --- a/crates/engine/src/float_cmds.rs +++ b/crates/engine/src/float_cmds.rs @@ -31,29 +31,40 @@ pub struct CutParts { pub layer: LayerId, rest: Surface, piece: Surface, + /// The pieces are the layer's mask (`"target":"mask"`), not its pixels. + mask: bool, } impl CutParts { /// Split `layer` of `doc` by its selection. With `copy` the selected pixels stay in the layer /// too (the piece is a duplicate). Without, they leave a transparent hole, or on the Background, /// which can't hold transparency, one filled with `background` (Photoshop). Errors without a - /// selection or pixels. - pub fn new(doc: &Document, layer: LayerId, copy: bool, background: [f32; 4]) -> Result { + /// selection or pixels. With `mask` the layer's mask is split instead of its pixels; the hole it + /// leaves takes the background colour (as on the Background layer). + pub fn new(doc: &Document, layer: LayerId, copy: bool, background: [f32; 4], mask: bool) -> Result { let sel = doc.selection.as_ref().ok_or_else(|| EngineError::Other("no selection".into()))?; let l = doc.layer(layer).ok_or(EngineError::NoLayer(layer))?; - let LayerContent::Raster(surf) = &l.content else { - return Err(EngineError::Other("the layer has no pixels to move".into())); + let surf = if mask { + &l.mask.as_ref().ok_or_else(|| EngineError::Other("the layer has no mask".into()))?.surface + } else { + let LayerContent::Raster(surf) = &l.content else { + return Err(EngineError::Other("the layer has no pixels to move".into())); + }; + surf }; let fmt = surf.format(); let with_alpha = PixelFormat::new(fmt.mode, fmt.sample, true); - let mut rest = if fmt == with_alpha { surf.clone() } else { surf.convert(with_alpha) }; + let full = if fmt == with_alpha { surf.clone() } else { surf.convert(with_alpha) }; + // A mask has no alpha: it keeps its format, and the cut leaves background colour. + let mut rest = if mask { surf.clone() } else { full.clone() }; let mut piece = Surface::new(with_alpha); - let fill = !copy && crate::extra_cmds::is_background(l); - let b = sel.content_bounds().intersect(&surf.content_bounds()); + let fill = !copy && (mask || crate::extra_cmds::is_background(l)); + // A mask's untouched pixels (its default, e.g. white) move too, so take the whole canvas. + let b = sel.content_bounds().intersect(&if mask { doc.bounds() } else { surf.content_bounds() }); if !b.is_empty() { let n = with_alpha.channels(); let a = n - 1; - let mut rp = rest.read_region(b); + let mut rp = full.read_region(b); let mut pp = rp.clone(); let w = b.width() as usize; for (i, (p, r)) in pp.chunks_exact_mut(n).zip(rp.chunks_exact_mut(n)).enumerate() { @@ -67,30 +78,44 @@ impl CutParts { r[a] *= 1.0 - k; } } - rest.write_region(b, &rp); + if !mask { + rest.write_region(b, &rp); + } if fill { - crate::pixels::fill_surface(&mut rest, b, background, Some(sel), true); + crate::pixels::try_fill_surface(&mut rest, b, background, Some(sel), true)?; } rest.prune(); piece.write_region(b, &pp); piece.prune(); } - Ok(Self { layer, rest, piece }) + Ok(Self { layer, rest, piece, mask }) } /// `doc` with the piece moved by whole pixels (dx, dy) over the cut-out layer. pub fn moved(&self, doc: &Document, dx: i32, dy: i32) -> Result { let mut d = doc.clone(); - let surf = d - .layer_mut(self.layer) - .ok_or(EngineError::NoLayer(self.layer))? - .surface_mut() - .ok_or_else(|| EngineError::Other("the layer has no pixels to move".into()))?; - let mut out = self.rest.clone(); - crate::transform_cmds::composite_over(&mut out, &photocraft_algo::resample::translate_surface(&self.piece, dx, dy)); - *surf = out; + self.place(&mut d, dx, dy)?; Ok(d) } + + /// Put the piece, moved by (dx, dy), over the cut-out layer (or mask) of `doc`. + fn place(&self, doc: &mut Document, dx: i32, dy: i32) -> Result<()> { + let l = doc.layer_mut(self.layer).ok_or(EngineError::NoLayer(self.layer))?; + let surf = if self.mask { + l.mask.as_mut().map(|m| &mut m.surface).ok_or_else(|| EngineError::Other("the layer has no mask".into()))? + } else { + l.surface_mut().ok_or_else(|| EngineError::Other("the layer has no pixels to move".into()))? + }; + let mut out = self.rest.clone(); + let piece = photocraft_algo::resample::translate_surface(&self.piece, dx, dy); + if self.mask { + crate::fill_cmds::composite_over(&mut out, &piece, piece.content_bounds(), None)?; + } else { + crate::transform_cmds::composite_over(&mut out, &piece); + } + *surf = out; + Ok(()) + } } /// A piece floating `offset` whole pixels from where it was cut, over document `revision`. @@ -150,7 +175,7 @@ fn float(s: &mut Session, p: &Value) -> Result { let st = s.active_mut().ok_or(EngineError::NoDocument)?; if floating(st).is_none() { let layer = st.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?; - let parts = CutParts::new(&st.doc, layer, copy, background)?; + let parts = CutParts::new(&st.doc, layer, copy, background, crate::commands::is_mask_target(p))?; st.floating = Some(Floating { layer, offset: (0, 0), revision: st.revision, parts: Arc::new(parts) }); } let f = st.floating.as_mut().ok_or(EngineError::NoDocument)?; @@ -171,9 +196,7 @@ pub fn drop_floating(s: &mut Session) -> Result { return Ok(json!({"dropped": true, "offset": [0, 0]})); } s.edit("Move", |doc, _| { - let moved = f.parts.moved(doc, dx, dy)?; - let surf = moved.layer(f.layer).and_then(|l| l.surface()).cloned().ok_or(EngineError::NoLayer(f.layer))?; - *doc.layer_mut(f.layer).ok_or(EngineError::NoLayer(f.layer))?.surface_mut().ok_or(EngineError::NoLayer(f.layer))? = surf; + f.parts.place(doc, dx, dy)?; doc.selection = doc.selection.as_ref().map(|sel| photocraft_algo::resample::translate_surface(sel, dx, dy)); Ok(()) })?; @@ -210,7 +233,7 @@ pub fn specs() -> Vec { label: "Float Selection", menu: &[], shortcut: None, - params: r##"{"dx":px=0,"dy":px=0,"copy":bool=false} → {layer, offset} (cuts the selected pixels of the active layer into a floating piece the first time, or with copy leaves them and floats a duplicate; on the Background the hole takes the background colour; then moves it by whole pixels; dropped by select.drop or any other command, put back by edit.undo)"##, + params: r##"{"dx":px=0,"dy":px=0,"copy":bool=false,"target":"pixels"|"mask"="pixels" (mask: float the selected part of the layer's mask, the hole takes the background colour)} → {layer, offset} (cuts the selected pixels of the active layer into a floating piece the first time, or with copy leaves them and floats a duplicate; on the Background the hole takes the background colour; then moves it by whole pixels; dropped by select.drop or any other command, put back by edit.undo)"##, enabled: can_float, journal: true, run: float, diff --git a/crates/engine/src/float_cmds/tests.rs b/crates/engine/src/float_cmds/tests.rs index aa9a139636..bb2ba5b91c 100644 --- a/crates/engine/src/float_cmds/tests.rs +++ b/crates/engine/src/float_cmds/tests.rs @@ -148,3 +148,44 @@ fn copy_floats_a_duplicate_and_leaves_the_original() { let doc = &s.active().unwrap().doc; assert!(alpha(doc, id, 12, 12) == 1.0 && alpha(doc, id, 42, 12) == 1.0, "dropped: both"); } + +#[test] +fn float_with_the_mask_target_moves_the_mask_not_the_layer() { + let (mut s, id) = session(); + s.edit("mask", |doc, _| { + let l = doc.layer_mut(id).unwrap(); + let mut m = photocraft_doc::LayerMask::reveal_all(); + m.surface.fill_rect(Rect::new(10, 10, 30, 30), &[0.0]); + l.mask = Some(m); + Ok(()) + }) + .unwrap(); + let mask_at = |s: &Session, x, y| s.active().unwrap().doc.layer(id).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0); + s.execute("select.float", json!({"dx": 25, "dy": 0, "copy": true, "target": "mask"})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + assert_eq!(mask_at(&s, 12, 12), 0.0, "the copy leaves the mask where it was"); + assert_eq!(mask_at(&s, 37, 12), 0.0, "the mask's selected part moved"); + assert_eq!(mask_at(&s, 60, 12), 1.0); + assert_eq!(alpha(&s.active().unwrap().doc, id, 37, 12), 0.0, "the layer's pixels did not move"); + assert_eq!(alpha(&s.active().unwrap().doc, id, 12, 12), 1.0); +} + +#[test] +fn cutting_an_untouched_part_of_the_mask_moves_it_and_leaves_the_background_colour() { + let (mut s, id) = session(); + s.edit("mask", |doc, _| { + let mut m = photocraft_doc::LayerMask::hide_all(); + m.surface.fill_rect(Rect::new(50, 10, 70, 30), &[1.0]); + doc.layer_mut(id).unwrap().mask = Some(m); + Ok(()) + }) + .unwrap(); + s.tools.background = [0.5, 0.5, 0.5, 1.0]; + let mask_at = |s: &Session, x, y| s.active().unwrap().doc.layer(id).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0); + // (10..30)² is the mask's untouched black; it moves over the white at (50..70, 10..30). + s.execute("select.float", json!({"dx": 40, "dy": 0, "target": "mask"})).unwrap(); + s.execute("select.drop", json!({})).unwrap(); + assert_eq!(mask_at(&s, 55, 12), 0.0, "the untouched black moved"); + assert!((mask_at(&s, 12, 12) - 0.5).abs() < 0.01, "the hole takes the background colour"); + assert_eq!(mask_at(&s, 40, 12), 0.0); +} diff --git a/crates/engine/src/gallery_cmds.rs b/crates/engine/src/gallery_cmds.rs index 848400d375..0474bc08a9 100644 --- a/crates/engine/src/gallery_cmds.rs +++ b/crates/engine/src/gallery_cmds.rs @@ -88,6 +88,41 @@ pub fn params_for(id: &str, p: &Value) -> Option { Some(FilterParams::FilterGallery { effects: vec![effect_from_json(f, p, &Value::Null)] }) } +/// An unknown choice name or a wrong-typed choice is a bad-params error, for one gallery filter +/// or each effect of a `filter.filterGallery` stack. Checked only for new invocations (via +/// `filters::validate_params`); stored smart filters keep decoding tolerantly. +pub(crate) fn validate_params(id: &str, p: &Value) -> Result<()> { + let bad = |msg: String| EngineError::BadParams { cmd: id.into(), msg }; + if id == "filter.filterGallery" { + for (k, item) in p.get("effects").and_then(Value::as_array).into_iter().flatten().enumerate() { + let Some(f) = item.get("filter").and_then(Value::as_str).and_then(GalleryFilter::from_key) else { continue }; + let params = item.get("params").filter(|v| v.is_object()).unwrap_or(item); + check_choices(f, params).map_err(|m| bad(format!("effect {k}: {m}")))?; + } + return Ok(()); + } + match id.strip_prefix("filter.gallery.").and_then(GalleryFilter::from_key) { + Some(f) => check_choices(f, p).map_err(bad), + None => Ok(()), + } +} + +fn check_choices(f: GalleryFilter, p: &Value) -> std::result::Result<(), String> { + for prm in f.params() { + let (Some(v), GalleryParamKind::Choice(names)) = (p.get(prm.key), &prm.kind) else { continue }; + let ok = match v { + Value::String(name) => names.iter().any(|n| n.eq_ignore_ascii_case(name)), + // A choice index, clamped as the dialog stores it. + Value::Number(_) => true, + _ => false, + }; + if !ok { + return Err(format!("`{}` must be one of {}", prm.key, names.join("|"))); + } + } + Ok(()) +} + /// Whether the command reads the foreground/background colours. pub(crate) fn uses_colours(id: &str) -> bool { id == "filter.filterGallery" || id.strip_prefix("filter.gallery.").and_then(GalleryFilter::from_key).is_some_and(GalleryFilter::uses_colours) diff --git a/crates/engine/src/gallery_cmds/tests.rs b/crates/engine/src/gallery_cmds/tests.rs index 93d848701f..064bddca3c 100644 --- a/crates/engine/src/gallery_cmds/tests.rs +++ b/crates/engine/src/gallery_cmds/tests.rs @@ -139,3 +139,33 @@ fn gallery_is_a_smart_filter_on_smart_objects() { assert_eq!(sm.smart_filters[0].command, "filter.filterGallery"); assert_eq!(sm.smart_filters[0].params["effects"][0]["filter"], json!("texturizer")); } + +#[test] +fn unknown_choice_names_are_bad_params_without_editing() { + let mut s = session(8, "rgb"); + let before = pixels(&s); + let history = s.active().unwrap().history.entries(); + let mut checked = 0; + for f in GalleryFilter::ALL.iter().copied() { + for prm in f.params() { + let GalleryParamKind::Choice(names) = prm.kind else { continue }; + let id = f.command_id(); + for v in [json!("zzbogus"), json!(true), json!([0])] { + let err = s.execute(id, json!({prm.key: v})).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{id}.{}: {err}", prm.key); + assert!(err.to_string().contains(prm.key), "{err}"); + let stack = json!({"effects": [{"filter": f.key(), "params": {prm.key: v}}]}); + assert!(matches!(s.execute("filter.filterGallery", stack), Err(EngineError::BadParams { .. })), "{id}.{}", prm.key); + } + assert_eq!(pixels(&s), before); + assert_eq!(s.active().unwrap().history.entries(), history); + // Every listed name (any case) and an index still run. + for v in names.iter().map(|n| json!(n.to_uppercase())).chain([json!(names.len() - 1)]) { + s.execute(id, json!({prm.key: v})).unwrap(); + s.undo(); + } + checked += 1; + } + } + assert!(checked >= 16, "{checked}"); +} diff --git a/crates/engine/src/history_cmds.rs b/crates/engine/src/history_cmds.rs index 652e2326b4..d9ca98f962 100644 --- a/crates/engine/src/history_cmds.rs +++ b/crates/engine/src/history_cmds.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use crate::commands::{CommandSpec, int}; -use crate::image_cmds::for_each_layer; +use crate::image_cmds::reidentify_copied_layers; use crate::{EngineError, Result, Session}; fn bad(cmd: &str, msg: impl Into) -> EngineError { @@ -66,7 +66,7 @@ fn new_document(s: &mut Session, p: &Value) -> Result { _ if !label.trim().is_empty() => label, _ => format!("{} copy", doc.name), }; - for_each_layer(&mut doc.layers, &mut |l| l.id = photocraft_doc::LayerId::fresh()); + reidentify_copied_layers(&mut doc); let name = doc.name.clone(); let index = s.add_document(doc, None); Ok(json!({ "document": index, "name": name })) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index d927ee5d27..1fa21a0b28 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -3,7 +3,7 @@ use photocraft_algo::resample::{Resample, crop_surface, resize_surface_in_canvas, translate_surface}; use photocraft_color::{ColorMode, SampleType}; -use photocraft_doc::{Document, Effect, Effects, FxPaint, Layer, LayerContent, Size}; +use photocraft_doc::{Document, Effect, Effects, FxPaint, Layer, LayerContent, LayerId, Size}; use photocraft_geom::Rect; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -65,6 +65,34 @@ pub(crate) fn for_each_layer(layers: &mut [Layer], f: &mut dyn FnMut(&mut Layer) } } +/// Assign new layer ids to a copied document and update every saved layer-comp reference. +/// +/// A document copy needs fresh identities, but its comps must still target the copied +/// layers. Deliberately leave references to already-deleted layers untouched so their +/// missing-layer warnings remain meaningful. The same applies to Last Document State. +pub(crate) fn reidentify_copied_layers(doc: &mut Document) { + let mut ids = std::collections::HashMap::new(); + for_each_layer(&mut doc.layers, &mut |layer| { + let previous = layer.id; + layer.id = LayerId::fresh(); + ids.insert(previous, layer.id); + }); + + let remap = |comp: &mut photocraft_doc::LayerComp| { + for state in &mut comp.states { + if let Some(&new_id) = ids.get(&state.layer) { + state.layer = new_id; + } + } + }; + for comp in &mut doc.layer_comps { + remap(comp); + } + if let Some(last) = &mut doc.last_document_state { + remap(last); + } +} + pub(crate) fn scale_effects(fx: &mut Effects, k: f32) { for e in &mut fx.items { match e { @@ -128,17 +156,8 @@ fn image_size(s: &mut Session, p: &Value) -> Result { let (nw, nh) = (nw.round().clamp(1.0, 300_000.0) as u32, nh.round().clamp(1.0, 300_000.0) as u32); // Each side may reach 300000 px, but resampling writes real pixels: refuse a canvas whose // raster alone would pass the budget, before allocating any of it (#1544). - let bytes = u64::from(nw).saturating_mul(u64::from(nh)).saturating_mul(d.doc.pixel_format().bytes_per_pixel() as u64); - if resample.is_some() && (nw, nh) != (ow, oh) && bytes > MAX_RESAMPLE_BYTES { - let mp = |w: u32, h: u32| u64::from(w) * u64::from(h) / 1_000_000; - let max_mp = MAX_RESAMPLE_BYTES / d.doc.pixel_format().bytes_per_pixel().max(1) as u64 / 1_000_000; - return Err(bad( - "image.imageSize", - format!( - "{nw} x {nh} px is {} megapixels; resampling at this bit depth is limited to {max_mp} megapixels. Choose a smaller size, or turn Resample off to change only the resolution", - mp(nw, nh) - ), - )); + if resample.is_some() && (nw, nh) != (ow, oh) { + check_resample_budget("image.imageSize", &d.doc, nw, nh, ", or turn Resample off to change only the resolution")?; } let dpi = p.get("resolution").and_then(Value::as_f64).map(|v| v as f32); s.edit("Image Size", |doc, _| { @@ -146,27 +165,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result { doc.resolution_dpi = r.clamp(1.0, 10_000.0); } let Some(filter) = resample else { return Ok(()) }; - let (sx, sy) = (nw as f64 / ow.max(1) as f64, nh as f64 / oh.max(1) as f64); - if (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12 { - return Ok(()); - } - // Content that reaches the canvas edge keeps it: a Background stays opaque to the border. - let canvas = Rect::new(0, 0, ow as i32, oh as i32); - for_each_surface(&mut doc.layers, true, &mut |surf, is_mask| { - *surf = resize_surface_in_canvas(surf, sx, sy, if is_mask { Resample::Bilinear } else { filter }, canvas); - }); - let k = ((sx + sy) / 2.0) as f32; - for_each_layer(&mut doc.layers, &mut |l| scale_effects(&mut l.effects, k)); - for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { - ch.surface = resize_surface_in_canvas(&ch.surface, sx, sy, Resample::Bilinear, canvas); - } - if let Some(sel) = &doc.selection { - doc.selection = Some(resize_surface_in_canvas(sel, sx, sy, Resample::Bilinear, canvas)); - } - // Vector geometry, guides and marks scale with the pixels; vectors re-render sharp. - crate::canvas_geom::transform_geometry(doc, &photocraft_geom::Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] }); - doc.size = Size::new(nw, nh); - crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::All); + resample_doc(doc, nw, nh, filter); Ok(()) })?; // The size the document has now: without resampling `width`/`height` are ignored (#1815). @@ -174,6 +173,47 @@ fn image_size(s: &mut Session, p: &Value) -> Result { Ok(json!({ "width": size.width, "height": size.height })) } +/// Refuses to resample `doc` to `nw` x `nh` px when the raster alone would pass the budget, before +/// allocating any of it (#1544). `hint` ends the message with what the caller can do instead. +fn check_resample_budget(cmd: &str, doc: &Document, nw: u32, nh: u32, hint: &str) -> Result<()> { + let bpp = doc.pixel_format().bytes_per_pixel().max(1) as u64; + let bytes = u64::from(nw).saturating_mul(u64::from(nh)).saturating_mul(bpp); + if bytes <= MAX_RESAMPLE_BYTES { + return Ok(()); + } + let mp = u64::from(nw).saturating_mul(u64::from(nh)) / 1_000_000; + let max_mp = MAX_RESAMPLE_BYTES / bpp / 1_000_000; + Err(bad(cmd, format!("{nw} x {nh} px is {mp} megapixels; resampling at this bit depth is limited to {max_mp} megapixels. Choose a smaller size{hint}"))) +} + +/// Resamples the whole document (pixels, masks, channels, selection, effects, vectors, guides) to +/// `nw` x `nh` px with `filter`: the body of Image Size, shared by the Crop tool's W x H x Resolution +/// mode. Callers check [`check_resample_budget`] first. +fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resample) { + let (ow, oh) = (doc.size.width, doc.size.height); + let (sx, sy) = (nw as f64 / ow.max(1) as f64, nh as f64 / oh.max(1) as f64); + if (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12 { + return; + } + // Content that reaches the canvas edge keeps it: a Background stays opaque to the border. + let canvas = Rect::from_xywh(0, 0, ow, oh); + for_each_surface(&mut doc.layers, true, &mut |surf, is_mask| { + *surf = resize_surface_in_canvas(surf, sx, sy, if is_mask { Resample::Bilinear } else { filter }, canvas); + }); + let k = ((sx + sy) / 2.0) as f32; + for_each_layer(&mut doc.layers, &mut |l| scale_effects(&mut l.effects, k)); + for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { + ch.surface = resize_surface_in_canvas(&ch.surface, sx, sy, Resample::Bilinear, canvas); + } + if let Some(sel) = &doc.selection { + doc.selection = Some(resize_surface_in_canvas(sel, sx, sy, Resample::Bilinear, canvas)); + } + // Vector geometry, guides and marks scale with the pixels; vectors re-render sharp. + crate::canvas_geom::transform_geometry(doc, &photocraft_geom::Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] }); + doc.size = Size::new(nw, nh); + crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::All); +} + fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } @@ -278,6 +318,16 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result { crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes); let canvas = doc.bounds(); let old = Rect::from_xywh(dx, dy, ow as u32, oh as u32); + // A Background cannot contain transparent pixels. When a transparent + // extension adds canvas area, make it a normal paintable layer first. + // A shrink or no-op resize must not silently convert the Background. + if ext[3] == 0.0 + && (nw > ow as u32 || nh > oh as u32) + && let Some(bg) = doc.layers.first_mut() + && crate::extra_cmds::is_background(bg) + { + crate::extra_cmds::unlock_background(bg); + } // The locked Background layer is extended with the extension colour. if ext[3] > 0.0 && let Some(bg) = doc.layers.first_mut() @@ -329,9 +379,14 @@ fn crop(s: &mut Session, p: &Value) -> Result { } _ => None, }; + let target = crop_target(p)?; + if let Some((tw, th)) = target.size { + let d = s.active().ok_or(EngineError::NoDocument)?; + check_resample_budget("image.crop", &d.doc, tw, th, "")?; + } if let Some(a) = crop_angle(p)? { let rect = explicit.ok_or_else(|| bad("image.crop", "an `angle` needs an explicit `x`, `y`, `width` and `height`"))?; - return rotated_crop(s, rect, a, delete); + return rotated_crop(s, rect, a, delete, &target); } // An explicit rectangle (the Crop tool) may extend past the canvas; a selection crop is clamped. let r = explicit @@ -343,9 +398,82 @@ fn crop(s: &mut Session, p: &Value) -> Result { s.edit("Crop", |doc, _| { crop_doc(doc, "image.crop", r, delete)?; doc.selection = None; + target.apply(doc); Ok(()) })?; - Ok(json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() })) + Ok(crop_result(s, json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }))) +} + +/// The Crop tool's W x H x Resolution: the size (px) the cropped area is resampled to and the +/// resolution the document gets, both optional. +struct CropTarget { + size: Option<(u32, u32)>, + resolution: Option, + filter: Resample, +} + +impl CropTarget { + /// Applies the target to a document that has just been cropped (inside the crop's history step). + fn apply(&self, doc: &mut Document) { + if let Some((w, h)) = self.size { + resample_doc(doc, w, h, self.filter); + } + if let Some(r) = self.resolution { + doc.resolution_dpi = r; + } + } +} + +/// The highest resolution (ppi) a crop may set, as for Image Size. +const MAX_CROP_RESOLUTION: f64 = 10_000.0; + +/// `image.crop`'s optional `targetWidth`/`targetHeight` (whole px, both or neither), `resolution` +/// (ppi) and `resample` (as for `image.imageSize`; "none" is refused with a target size). +fn crop_target(p: &Value) -> Result { + const CMD: &str = "image.crop"; + let side = |key: &str| -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_u64() + .and_then(|n| u32::try_from(n).ok()) + .filter(|n| (1..=MAX_CROP_SIDE as u32).contains(n)) + .map(Some) + .ok_or_else(|| bad(CMD, format!("`{key}` = {v} must be a whole number of pixels from 1 to {MAX_CROP_SIDE}"))), + } + }; + let size = match (side("targetWidth")?, side("targetHeight")?) { + (Some(w), Some(h)) => Some((w, h)), + (None, None) => None, + _ => return Err(bad(CMD, "`targetWidth` and `targetHeight` go together")), + }; + let resolution = match p.get("resolution") { + None | Some(Value::Null) => None, + Some(v) => { + let r = v.as_f64().filter(|r| r.is_finite() && (1.0..=MAX_CROP_RESOLUTION).contains(r)); + Some(r.ok_or_else(|| bad(CMD, format!("`resolution` = {v} must be a number of pixels per inch from 1 to {MAX_CROP_RESOLUTION}")))? as f32) + } + }; + let filter = match p.get("resample") { + None | Some(Value::Null) => Some(Resample::Bicubic), + Some(v) => parse_resample(v.as_str().ok_or_else(|| bad(CMD, "`resample` must be a string"))?), + }; + let filter = match (filter, size) { + (Some(f), _) => f, + (None, None) => Resample::Bicubic, + (None, Some(_)) => return Err(bad(CMD, "a `targetWidth` x `targetHeight` crop needs resampling: `resample` can't be \"none\"")), + }; + Ok(CropTarget { size, resolution, filter }) +} + +/// A crop's result: the frame it cropped to, plus the document's size and resolution afterwards. +fn crop_result(s: &Session, mut frame: Value) -> Value { + if let (Some(d), Some(o)) = (s.active(), frame.as_object_mut()) { + o.insert("documentWidth".into(), json!(d.doc.size.width)); + o.insert("documentHeight".into(), json!(d.doc.size.height)); + o.insert("resolution".into(), json!(d.doc.resolution_dpi)); + } + frame } /// The largest crop angle accepted (degrees), as for Image Rotation › Arbitrary. @@ -372,7 +500,7 @@ fn crop_angle(p: &Value) -> Result> { /// background colour, other layers keep transparency there and every pixel of theirs), which makes /// the frame upright, then cropped to it (`deleteCroppedPixels` as for an unrotated crop), all in /// one undo step. -fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool) -> Result { +fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, crop_target: &CropTarget) -> Result { const CMD: &str = "image.crop"; let size = s.active().ok_or(EngineError::NoDocument)?.doc.size; let (w, h) = (f64::from(r.width()), f64::from(r.height())); @@ -398,10 +526,11 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool) -> Result Result { surf.write_region(doc.bounds(), &vals); surf.prune(); doc.layers = vec![Layer::new("Background", LayerContent::Raster(surf))]; + // A merged-only copy has no original layers for saved comps to restore. + // Don't carry dangling comp state into a single-layer flattened document. + doc.layer_comps.clear(); + doc.last_applied_comp = None; + doc.last_document_state = None; } - for_each_layer(&mut doc.layers, &mut |l| l.id = photocraft_doc::LayerId::fresh()); + reidentify_copied_layers(&mut doc); let name = doc.name.clone(); let index = s.add_document(doc, None); Ok(json!({ "document": index, "name": name })) @@ -515,7 +649,7 @@ pub fn specs() -> Vec { "image.crop", "Crop", ["Image"], - r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true}"##, + r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi)"##, has_doc, crop ), @@ -961,6 +1095,86 @@ mod tests { assert_eq!(doc(&s).size, Size::new(300_000, 2)); } + /// #2443: the Crop tool's W x H x Resolution crops, resamples the cropped area to exactly the + /// target size and sets the resolution, in one undo step, at 8 and 16 bits. + #[test] + fn crop_to_a_target_size_and_resolution_is_one_step() { + for depth in [8, 16] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 300, "resolution": 72, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(100, 100, 200, 225), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let past = s.active().unwrap().history.past_len(); + // 4 x 5 in at 300 ppi from a 100 x 125 px frame. + let r = s + .execute("image.crop", json!({"x": 100, "y": 100, "width": 100, "height": 125, "targetWidth": 1200, "targetHeight": 1500, "resolution": 300})) + .unwrap(); + assert_eq!((r["documentWidth"].as_u64(), r["documentHeight"].as_u64()), (Some(1200), Some(1500)), "{depth}: {r}"); + assert_eq!(r["resolution"].as_f64(), Some(300.0), "{depth}"); + let d = doc(&s); + assert_eq!(d.size, Size::new(1200, 1500), "{depth}"); + assert_eq!(d.resolution_dpi, 300.0, "{depth}"); + assert_eq!(d.layers[0].surface().unwrap().format().sample, if depth == 8 { SampleType::U8 } else { SampleType::U16 }); + // The red frame filled the crop; it fills the result. + let red = d.layers[1].surface().unwrap(); + for (x, y) in [(0, 0), (600, 750), (1199, 1499)] { + assert_eq!(red.pixel(x, y), vec![1.0, 0.0, 0.0, 1.0], "{depth}: ({x}, {y})"); + } + assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{depth}: one history step"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(400, 300), 72.0), "{depth}: undo restores the original"); + } + } + + #[test] + fn crop_target_resamples_after_a_turned_crop_and_resolution_alone_keeps_the_pixels() { + let mut s = session(); + let past = s.active().unwrap().history.past_len(); + s.execute("image.crop", json!({"x": 10, "y": 0, "width": 10, "height": 20, "angle": 90, "targetWidth": 30, "targetHeight": 60, "resample": "nearest"})) + .unwrap(); + assert_eq!(doc(&s).size, Size::new(30, 60)); + assert_eq!(s.active().unwrap().history.past_len(), past + 1, "rotate, crop and resample: one step"); + let mut s = session(); + s.execute("image.crop", json!({"x": 2, "y": 3, "width": 30, "height": 10, "resolution": 150})).unwrap(); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(30, 10), 150.0)); + } + + #[test] + fn crop_target_rejects_bad_params_without_changing_the_document() { + let rect = json!({"x": 0, "y": 0, "width": 10, "height": 10}); + let bad_params = [ + json!({"targetWidth": 100}), + json!({"targetHeight": 100}), + json!({"targetWidth": 0, "targetHeight": 10}), + json!({"targetWidth": -5, "targetHeight": 10}), + json!({"targetWidth": 10.5, "targetHeight": 10}), + json!({"targetWidth": "10", "targetHeight": 10}), + json!({"targetWidth": 300_001, "targetHeight": 10}), + json!({"targetWidth": 4_294_967_306_u64, "targetHeight": 10}), + json!({"targetWidth": 300_000, "targetHeight": 300_000}), + json!({"targetWidth": 10, "targetHeight": 10, "resample": "none"}), + json!({"targetWidth": 10, "targetHeight": 10, "resample": 3}), + json!({"resolution": 0}), + json!({"resolution": -72}), + json!({"resolution": 1e9}), + json!({"resolution": "300"}), + ]; + for extra in bad_params { + let mut s = session(); + let (size, dpi, past) = (doc(&s).size, doc(&s).resolution_dpi, s.active().unwrap().history.past_len()); + let mut p = rect.clone(); + if let (Some(o), Some(e)) = (p.as_object_mut(), extra.as_object()) { + o.extend(e.clone()); + } + assert!(s.execute("image.crop", p.clone()).is_err(), "{p}"); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi, s.active().unwrap().history.past_len()), (size, dpi, past), "{p}: nothing changed"); + } + } + #[test] fn trim_based_on_top_left_colour() { let mut s = session(); diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index b469fc778e..9b612e3c4c 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -562,7 +562,7 @@ impl Session { /// Running jobs followed by the last few that ended (newest last), for `jobs.list`. pub fn jobs_with_recent(&self) -> Vec { - self.jobs.recent.iter().cloned().chain(self.jobs()).collect() + self.jobs().into_iter().chain(self.jobs.recent.iter().cloned()).collect() } /// The running job that locks document `doc`, if any. diff --git a/crates/engine/src/jobs_tests.rs b/crates/engine/src/jobs_tests.rs index 3dba16d0e6..4f631e82ef 100644 --- a/crates/engine/src/jobs_tests.rs +++ b/crates/engine/src/jobs_tests.rs @@ -237,6 +237,23 @@ fn a_running_job_locks_its_document_but_not_others() { assert_eq!(s.documents()[0].history.past_len(), 3); } +#[test] +fn jobs_list_puts_running_jobs_before_ended_ones() { + let mut s = session(64, 64); + let gate = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let done = gated_job(&mut s, &gate); + assert!(matches!(wait_event(&mut s, done).outcome, JobOutcome::Done(_))); + // A second document, so the next job doesn't wait on the first one's. + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let gate = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let running = gated_job(&mut s, &gate); + let r = s.execute("jobs.list", json!({})).unwrap(); + let listed: Vec<_> = r["jobs"].as_array().unwrap().iter().map(|j| (j["id"].clone(), j["state"].clone())).collect(); + assert_eq!(listed, [(json!(running.0), json!("running")), (json!(done.0), json!("done"))], "{r}"); + gate.store(true, std::sync::atomic::Ordering::Relaxed); + wait_event(&mut s, running); +} + #[test] fn background_job_finishes_bookkeeping_on_edited_document_after_tab_switch() { let mut s = session(64, 64); @@ -338,6 +355,27 @@ fn content_aware_fill_and_scale_run_as_jobs() { assert_eq!(v["to"][0], 120); } +#[test] +fn smart_filter_edits_run_as_jobs() { + let mut inline = session(160, 120); + let mut s = session(160, 120); + for t in [&mut inline, &mut s] { + t.execute("filter.convertForSmartFilters", json!({})).unwrap(); + t.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + t.execute("filter.blur.motionBlur", json!({"angle": 45, "distance": 8})).unwrap(); + } + inline.execute("layer.smartFilter.setVisible", json!({"index": 0})).unwrap(); + let before = pixels(&s); + let steps = s.active().unwrap().history.past_len(); + let id = job(s.start("layer.smartFilter.setVisible", json!({"index": 0})).unwrap()); + let e = wait_event(&mut s, id); + assert!(matches!(e.outcome, JobOutcome::Done(_)), "{e:?}"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one undo step"); + assert_eq!(pixels(&s), pixels(&inline), "same pixels as the synchronous command"); + assert!(s.undo()); + assert_eq!(pixels(&s), before); +} + #[test] fn content_aware_move_runs_as_a_job() { let mut inline = session(160, 120); diff --git a/crates/engine/src/layer_copy_cmds.rs b/crates/engine/src/layer_copy_cmds.rs index 59f18ad76c..fd87cb838b 100644 --- a/crates/engine/src/layer_copy_cmds.rs +++ b/crates/engine/src/layer_copy_cmds.rs @@ -51,7 +51,7 @@ fn point(p: &Value, key: &str) -> Result> { } /// Modes whose layers convert through the colour engine. -fn layered(mode: ColorMode) -> bool { +pub(crate) fn layered(mode: ColorMode) -> bool { matches!(mode, ColorMode::Rgb | ColorMode::Grayscale | ColorMode::Cmyk | ColorMode::Lab) } @@ -59,6 +59,45 @@ fn center(r: Rect) -> [f64; 2] { [(f64::from(r.x0) + f64::from(r.x1)) / 2.0, (f64::from(r.y0) + f64::from(r.y1)) / 2.0] } +/// Readies `copies` (a scratch document of the source's colour holding duplicated layers) for +/// landing in `dest`: smart objects get fresh contents ids, keeping only the sharing within the +/// batch (contents ids belong to a document, so the copies never join objects already in `dest`, +/// including another opening of the same file), and the pixels convert to `dest`'s profile and +/// bit depth. +pub(crate) fn adapt_copies(s: &Session, copies: &mut Document, dest: &Document) -> Result<()> { + let mut contents = HashMap::new(); + let smart_ids: Vec<_> = copies.walk().into_iter().filter_map(|(_, _, l)| matches!(l.content, LayerContent::Smart(_)).then_some(l.id)).collect(); + for id in smart_ids { + if let Some(l) = copies.layer_mut(id) + && let LayerContent::Smart(sm) = &mut l.content + { + sm.contents_id = *contents.entry(sm.contents_id).or_insert_with(SmartContentsId::fresh); + } + } + if (copies.mode, &copies.icc_profile) != (dest.mode, &dest.icc_profile) { + let profile = crate::color_cmds::document_profile(dest); + crate::color_cmds::convert_document(copies, &profile, s.color.settings.intent(), s.color.settings.bpc)?; + } + if copies.depth != dest.depth { + crate::image_cmds::for_each_surface(&mut copies.layers, true, &mut |surf, _| { + let f = surf.format().with_sample(dest.depth); + *surf = surf.convert(f); + }); + } + Ok(()) +} + +/// Moves every layer of `copies` by whole pixels (content, masks, vectors and smart placement). +pub(crate) fn translate_copies(copies: &mut Document, dx: i32, dy: i32) { + if dx != 0 || dy != 0 { + let snapshot = copies.clone(); + for l in &mut copies.layers { + crate::commands::translate_layer(&snapshot, l, dx, dy); + crate::vector_cmds::translate_vectors(&snapshot, l, f64::from(dx), f64::from(dy)); + } + } +} + /// `layer.copyToDocument`. fn copy_to_document(s: &mut Session, p: &Value) -> Result { let dest = doc_index(s, p, "document")?.ok_or_else(|| bad("missing `document` (the destination's index)"))?; @@ -107,27 +146,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { } copies.layers.push(l); } - // Contents IDs belong to a document. Preserve sharing within this batch, without joining - // pre-existing objects in the destination (including another opening of the same file). - let mut contents = HashMap::new(); - let smart_ids: Vec<_> = copies.walk().into_iter().filter_map(|(_, _, l)| matches!(l.content, LayerContent::Smart(_)).then_some(l.id)).collect(); - for id in smart_ids { - if let Some(l) = copies.layer_mut(id) - && let LayerContent::Smart(sm) = &mut l.content - { - sm.contents_id = *contents.entry(sm.contents_id).or_insert_with(SmartContentsId::fresh); - } - } - if (copies.mode, &copies.icc_profile) != (ddoc.mode, &ddoc.icc_profile) { - let profile = crate::color_cmds::document_profile(&ddoc); - crate::color_cmds::convert_document(&mut copies, &profile, s.color.settings.intent(), s.color.settings.bpc)?; - } - if copies.depth != ddoc.depth { - crate::image_cmds::for_each_surface(&mut copies.layers, true, &mut |surf, _| { - let f = surf.format().with_sample(ddoc.depth); - *surf = surf.convert(f); - }); - } + adapt_copies(s, &mut copies, &ddoc)?; // Placement: centred on the destination's canvas, centred on a point, or offset from where // the layers are in the source (default: the same canvas position). let bounds = copies.layers.iter().filter_map(crate::layer_multi_cmds::layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)); @@ -142,13 +161,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { point(p, "offset")?.map_or((0.0, 0.0), |o| (o[0], o[1])) }; let (dx, dy) = (dx.round() as i32, dy.round() as i32); - if dx != 0 || dy != 0 { - let snapshot = copies.clone(); - for l in &mut copies.layers { - crate::commands::translate_layer(&snapshot, l, dx, dy); - crate::vector_cmds::translate_vectors(&snapshot, l, f64::from(dx), f64::from(dy)); - } - } + translate_copies(&mut copies, dx, dy); let patterns: Vec<_> = sdoc.patterns.iter().filter(|pat| !ddoc.patterns.iter().any(|d| d.id == pat.id)).cloned().collect(); s.set_active(dest); let label = if ids.len() == 1 { "Duplicate Layer" } else { "Duplicate Layers" }; diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 4e8f75c170..5db8f52703 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -934,6 +934,7 @@ pub fn move_to(s: &mut Session, p: &Value) -> Result { } let original_active = st.active_layer; + let background = crate::background_cmds::background_id(doc); let (count, copies) = s.edit(if copy { "Duplicate Layer" } else { "Reorder Layers" }, |doc, active| { let mut moved = Vec::with_capacity(moved_ids.len()); for &id in &moved_ids { @@ -956,6 +957,7 @@ pub fn move_to(s: &mut Session, p: &Value) -> Result { siblings.splice(at..at, moved); } check_group_depth(doc, "Reorder Layer")?; + crate::background_cmds::keep_background_at_bottom(background, doc)?; *active = if copy { copies.last().copied() } else if batch.is_some() { @@ -1044,8 +1046,7 @@ fn lock_layers(s: &mut Session, p: &Value) -> Result { } fn rename_layer(s: &mut Session, p: &Value) -> Result { - let name = - p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("layer.renameLayer", "missing `name`"))?.to_string(); + let name = p.get("name").and_then(Value::as_str).filter(|n| !n.trim().is_empty()).ok_or_else(|| bad("layer.renameLayer", "missing `name`"))?.to_string(); let id = crate::commands::layer_param(s, p)?; s.edit("Rename Layer", |doc, _| { doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.name = name; @@ -1057,6 +1058,7 @@ fn rename_layer(s: &mut Session, p: &Value) -> Result { fn reverse(s: &mut Session) -> Result { let sel = selected(s); s.edit("Reverse", |doc, _| { + let background = crate::background_cmds::background_id(doc); let ids = top_level(doc, &sel); if ids.len() < 2 { return Err(EngineError::Other("select two or more layers to reverse".into())); @@ -1068,7 +1070,7 @@ fn reverse(s: &mut Session) -> Result { for (path, l) in paths.iter().zip(layers.into_iter().rev()) { *doc.layer_at_mut(path).ok_or_else(|| EngineError::Other("layer tree changed".into()))? = l; } - Ok(()) + crate::background_cmds::keep_background_at_bottom(background, doc) })?; Ok(Value::Null) } @@ -1126,15 +1128,11 @@ fn merge_layers(s: &mut Session) -> Result { if solo.layers.is_empty() { return Err(EngineError::Other("the selected layers are all hidden".into())); } - let buf = photocraft_compose::flatten(&solo); let fmt = doc.pixel_format(); let fmt = PixelFormat::new(fmt.mode, fmt.sample, true); - let data: Vec = buf.px.iter().flat_map(|p| photocraft_raster::from_rgba(&fmt, *p)).collect(); let mut merged = Layer::raster(top.name.clone(), fmt); merged.locks = top.locks; - let surf = crate::pixels_mut(&mut merged)?; - surf.write_region(doc.bounds(), &data); - surf.prune(); + *crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers(&solo, fmt, None); let mid = merged.id; // Hidden selected layers are discarded, as in Photoshop. for id in &ids[..ids.len() - 1] { @@ -1194,16 +1192,18 @@ pub fn delete_selected(s: &mut Session) -> Result { /// Duplicate Layer with several layers selected: each copy goes above its original and the /// copies become the selection. Artboard copies go beside their boards unless `in_place` -/// ([`crate::artboard_cmds::place_copy`]). -pub fn duplicate_selected(s: &mut Session, in_place: bool) -> Result { +/// ([`crate::artboard_cmds::place_copy`]). `label` names the history step (Layer via Copy reuses +/// this path, #2777). +pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Result { let sel = selected(s); let old_active = s.active().and_then(|d| d.active_layer); - let (copies, active) = s.edit("Duplicate Layers", |doc, active| { + let (copies, active) = s.edit(label, |doc, active| { let mut copies = Vec::new(); let mut new_active = None; for id in top_level(doc, &sel) { - let mut dup = doc.layer(id).ok_or(EngineError::NoLayer(id))?.duplicate(); - dup.name = doc.copy_name(&dup.name); + // Match the single-layer duplicate path: Background copies become + // ordinary layers, while normal copies retain their original locks. + let dup = crate::commands::layer_copy(doc, id)?; let nid = doc.insert_above(Some(id), dup); if !in_place { crate::artboard_cmds::place_copy(doc, nid)?; @@ -1219,7 +1219,7 @@ pub fn duplicate_selected(s: &mut Session, in_place: bool) -> Result { })?; let out = copies.iter().map(|l| l.0).collect::>(); reselect(s, copies, active); - Ok(json!({"layers": out})) + Ok(json!({"layers": out, "layer": active.map(|l| l.0)})) } /// Show/hide every selected layer in one step. @@ -1250,7 +1250,7 @@ pub fn specs() -> Vec { const ALIGN: &str = r##"{"to":"auto|layers|selection|canvas"="auto"} (auto: the selection bounds with one layer and an active selection, else the selected layers' bounds)"##; vec![ spec!("select.allLayers", "All Layers", &["Select"], Some("Cmd+Alt+A"), "{}", has_doc, |s, _| select_all_layers(s)), - spec!("select.deselectLayers", "Deselect Layers", &["Select"], None, "{}", has_layer, |s, _| { + spec!("select.deselectLayers", "Deselect Layers", &["Select"], None, "{}", has_doc, |s, _| { set_selection(s, Vec::new(), None, None)?; if let Some(st) = s.active_mut() { st.active_layer = None; @@ -1531,6 +1531,18 @@ mod tests { s.execute("select.deselectLayers", json!({})).unwrap(); assert!(sel(&s).is_empty()); assert_eq!(s.active().unwrap().history.entries().len(), history); + // Nothing selected: a no-op, not an error (#2457). + s.execute("select.deselectLayers", json!({})).unwrap(); + assert_eq!(s.active().unwrap().history.entries().len(), history); + // An explicit layer needs no active layer. + s.execute("layer.hideLayers", json!({"layer": a.0})).unwrap(); + assert!(!s.active().unwrap().doc.layer(a).unwrap().visible); + s.execute("layer.renameLayer", json!({"layer": a.0, "name": "x"})).unwrap(); + assert_eq!(s.active().unwrap().doc.layer(a).unwrap().name, "x"); + s.execute("layer.showLayers", json!({"layer": a.0})).unwrap(); + assert!(s.active().unwrap().doc.layer(a).unwrap().visible); + assert!(s.execute("layer.hideLayers", json!({"layer": 999_999})).is_err()); + assert!(s.execute("layer.hideLayers", json!({})).is_err()); assert!(!s.is_enabled("layer.align.topEdges")); } @@ -1548,6 +1560,16 @@ mod tests { assert!(s.execute("layer.renameLayer", json!({})).is_err()); } + #[test] + fn rename_layer_keeps_surrounding_spaces() { + let mut s = session(8); + let a = rect_layer(&mut s, Rect::new(0, 0, 5, 5)); + s.execute("layer.renameLayer", json!({"layer": a.0, "name": " black skin bw "})).unwrap(); + assert_eq!(doc(&s).layer(a).unwrap().name, " black skin bw "); + assert!(s.execute("layer.renameLayer", json!({"layer": a.0, "name": " "})).is_err()); + assert_eq!(doc(&s).layer(a).unwrap().name, " black skin bw "); + } + #[test] fn align_each_edge() { for depth in [8, 16, 32] { @@ -1707,6 +1729,65 @@ mod tests { } } + #[test] + fn merging_group_children_preserves_their_composite() { + for depth in [8, 16, 32] { + let mut s = session(depth); + let lower = rect_layer(&mut s, Rect::new(10, 12, 40, 42)); + let upper = rect_layer(&mut s, Rect::new(25, 20, 55, 50)); + select_all(&mut s, &[lower, upper]); + let group = LayerId(s.execute("layer.groupLayers", json!({"name": "Merge test group"})).unwrap()["layer"].as_u64().unwrap()); + let doc_before = doc(&s); + let expected = photocraft_compose::render(doc_before, doc_before.bounds()); + let children = doc_before.layer(group).unwrap().children().unwrap().iter().map(|layer| layer.id).collect::>(); + + select_all(&mut s, &children); + s.execute("layer.mergeLayers", json!({})).unwrap(); + let doc_after = doc(&s); + let actual = photocraft_compose::render(doc_after, doc_after.bounds()); + assert_eq!(actual, expected, "depth {depth}: group merge changed rendered pixels"); + assert_eq!(doc_after.layer(group).unwrap().children().unwrap().len(), 1); + } + } + + #[test] + fn merging_keeps_layer_effects_outside_the_pixels() { + // A drop shadow reaches past the layer's pixels; the merge must render that far. + for depth in [8, 16, 32] { + let mut s = session(depth); + let lower = rect_layer(&mut s, Rect::new(10, 10, 30, 30)); + let upper = rect_layer(&mut s, Rect::new(40, 20, 60, 40)); + s.execute("layer.layerStyle.dropShadow", json!({"layer": upper.0, "blend": "normal", "distance": 12, "size": 4})).unwrap(); + let before = photocraft_compose::render(doc(&s), doc(&s).bounds()); + select_all(&mut s, &[lower, upper]); + s.execute("layer.mergeLayers", json!({})).unwrap(); + let after = photocraft_compose::render(doc(&s), doc(&s).bounds()); + let worst = before.px.iter().zip(&after.px).map(|(a, b)| a.iter().zip(b).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max)).fold(0.0f32, f32::max); + assert!(worst <= 1.5 / 255.0, "depth {depth}: merge layers dropped the shadow (off by {worst})"); + } + } + + #[test] + fn merging_a_group_down_preserves_the_composite() { + for depth in [8, 16, 32] { + let mut s = session(depth); + let lower = rect_layer(&mut s, Rect::new(10, 12, 50, 52)); + let child_a = rect_layer(&mut s, Rect::new(24, 20, 64, 60)); + let child_b = rect_layer(&mut s, Rect::new(35, 28, 75, 68)); + select_all(&mut s, &[child_a, child_b]); + let group = LayerId(s.execute("layer.groupLayers", json!({"name": "Merge down group"})).unwrap()["layer"].as_u64().unwrap()); + let doc_before = doc(&s); + let expected = photocraft_compose::render(doc_before, doc_before.bounds()); + + s.execute("layer.mergeDown", json!({"layer": group.0})).unwrap(); + let doc_after = doc(&s); + let actual = photocraft_compose::render(doc_after, doc_after.bounds()); + assert_eq!(actual, expected, "depth {depth}: group merge down changed rendered pixels"); + assert!(doc_after.layer(lower).is_some()); + assert!(doc_after.layer(group).is_none()); + } + } + #[test] fn a_locked_group_locks_its_contents() { let mut s = session(8); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1a83f56887..ca09663707 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -11,14 +11,17 @@ pub mod actions_cmds; pub mod adjust_cmds; pub mod adjust_params; pub mod align_cmds; +mod allocation; pub mod analysis_cmds; pub mod artboard_cmds; pub mod automate_cmds; +pub mod background_cmds; pub mod brush_cmds; pub mod brush_key_cmds; pub mod brush_preset_cmds; pub mod build_info; mod canvas_geom; +mod channel_clip; pub mod channel_cmds; pub mod color_cmds; pub mod color_to_alpha_cmds; @@ -31,6 +34,7 @@ pub mod document_preset_cmds; pub mod edit_cmds; pub mod edit_menu_cmds; pub mod eraser_cmds; +pub mod exr_cmds; pub mod extra_cmds; pub mod file_cmds; pub mod fill_cmds; @@ -77,6 +81,7 @@ pub mod preset_store; pub mod presets; pub mod print_cmds; pub mod proof_sim; +pub mod rasterize_style_cmds; pub mod redeye_cmds; pub mod render_cmds; pub mod retouch_cmds; @@ -140,6 +145,12 @@ pub enum EngineError { pub type Result = std::result::Result; +impl From for EngineError { + fn from(error: photocraft_raster::AllocationError) -> Self { + Self::Other(format!("not enough memory for this operation ({error}); the document was not changed")) + } +} + /// The pixels of a raster layer (typically one a command just created), as an error instead /// of a panic if the layer has none. pub(crate) fn pixels_mut(l: &mut photocraft_doc::Layer) -> Result<&mut photocraft_raster::Surface> { @@ -489,9 +500,19 @@ impl Session { /// The command's own precondition for a call with `params` (their target filled in). fn precondition(&self, spec: &commands::CommandSpec, params: &Value) -> std::result::Result<(), String> { + if self.names_existing_layer(spec.id, params) { + return Ok(()); + } channel_cmds::mask_target_enabled(self, spec.id, params).unwrap_or_else(|| (spec.enabled)(self)) } + /// Whether `id` is a layer command whose `"layer"` param names an existing layer, so it needs no + /// active layer (#2457). Matches how `layer_param` resolves the layer when the command runs. + fn names_existing_layer(&self, id: &str, params: &Value) -> bool { + matches!(id, "layer.hideLayers" | "layer.showLayers" | "layer.renameLayer") + && params.get("layer").and_then(Value::as_u64).is_some_and(|l| self.active().is_some_and(|st| st.doc.layer(LayerId(l)).is_some())) + } + /// Apply an undoable edit to the active document. pub fn edit(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Option) -> Result) -> Result { let restrict = self.color_restrict; diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index f5e9c37f50..2fcd30ed4d 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -87,7 +87,7 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { if background && let Some(surf) = l.surface_mut() { // The Background stays a Background: rotated pixels over the background colour. let src = surf.content_bounds(); - let rotated = photocraft_algo::transform::warp_surface(surf, src, &h, interp); + let rotated = photocraft_algo::transform::warp_surface(surf, src, &h, interp)?; let mut base = Surface::new(fmt); let fill = photocraft_raster::from_rgba(&fmt, bg); base.write_region(canvas, &fill.repeat(canvas.width() as usize * canvas.height() as usize)); @@ -95,7 +95,7 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { base.prune(); *surf = base; if let Some(m) = l.mask.as_mut() { - m.surface = crate::transform_cmds::warp_gray(&m.surface, &h, interp); + m.surface = crate::transform_cmds::warp_gray(&m.surface, &h, interp)?; } } else { // Rotating the whole image moves locked layers too. @@ -110,10 +110,10 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { } crate::canvas_geom::transform_doc_marks(doc, &a); for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { - ch.surface = crate::transform_cmds::warp_gray(&ch.surface, &h, interp); + ch.surface = crate::transform_cmds::warp_gray(&ch.surface, &h, interp)?; } if let Some(sel) = &doc.selection { - doc.selection = Some(crate::transform_cmds::warp_gray(sel, &h, Interp::Bilinear)).filter(|s| !s.content_bounds().is_empty()); + doc.selection = Some(crate::transform_cmds::warp_gray(sel, &h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty()); } // Type, shapes and smart objects re-render from their new geometry. crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::All); diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs index 850da98f89..b7b7068fd4 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -259,3 +259,82 @@ fn modes_round_trip_through_pcraft() { assert_eq!(back.color_table, doc(&s).color_table); assert_eq!(back.mode, ColorMode::Indexed); } + +/// Leaving Duotone for a colour mode keeps the printed look (#2383): the converted pixels are +/// the displayed ink colours, through the destination profile. Grayscale drops the inks. +#[test] +fn duotone_to_colour_modes_keeps_the_ink_colours() { + use photocraft_cms::{Builtin, Transform}; + let configs = [ + json!({"type": "duotone"}), + json!({"type": "monotone", "inks": [{"name": "Orange", "color": "#ff8000", "curve": [[0, 0], [100, 100]]}]}), + json!({"type": "quadtone"}), + ]; + for depth in [8, 16, 32] { + for cfg in &configs { + for mode in ["rgb", "cmyk", "lab", "grayscale"] { + let ctx = format!("{depth}-bit {cfg} → {mode}"); + let mut s = session(16, 2, depth, "gray"); + paint(&mut s, |x, _| { + let v = x as f32 / 15.0; + [v, v, v, 1.0] + }); + s.execute("image.mode.duotone", cfg.clone()).unwrap(); + let before = s.active().unwrap().doc.clone(); + let gray: Vec = (0..16).map(|x| before.layers[0].surface().unwrap().pixel(x, 1)[0]).collect(); + let shown = photocraft_compose::render(&display_document(&before).unwrap(), Rect::from_xywh(0, 1, 16, 1)).px; + s.execute(&format!("image.mode.{mode}"), json!({})).unwrap(); + let d = doc(&s); + assert!(d.duotone.is_none(), "{ctx}"); + let surf = d.layers[0].surface().unwrap(); + let target = match mode { + "rgb" => ColorMode::Rgb, + "cmyk" => ColorMode::Cmyk, + "lab" => ColorMode::Lab, + _ => ColorMode::Grayscale, + }; + assert_eq!(d.mode, target, "{ctx}"); + let srgb = Builtin::Srgb.profile(); + let dst = crate::color_cmds::working_profile(target); + let t = Transform::new(srgb, &dst, s.color.settings.intent(), s.color.settings.bpc).unwrap(); + for x in 0..16 { + let got = surf.pixel(x, 1); + let want: Vec = if target == ColorMode::Grayscale { + vec![gray[x as usize]] + } else { + let mut o = [0.0f32; 16]; + t.eval(&shown[x as usize][..3], &mut o); + o[..target.color_channels()].to_vec() + }; + // The display's gradient map samples the inks at 33 stops; dark CMYK + // separations magnify that a little. + let tol = if target == ColorMode::Cmyk { 0.03 } else { 0.02 }; + for (g, w) in got.iter().zip(&want) { + assert!((g - w).abs() < tol, "{ctx} x={x}: got {got:?}, want {want:?}"); + } + } + // Undo brings back the inks and the gray pixels. + s.execute("edit.undo", json!({})).unwrap(); + let back = doc(&s); + assert_eq!((back.mode, &back.duotone), (ColorMode::Duotone, &before.duotone), "{ctx}"); + assert_eq!(back.layers[0].surface().unwrap().pixel(7, 1), before.layers[0].surface().unwrap().pixel(7, 1), "{ctx}"); + } + } + } +} + +/// Colours stored in a Duotone document (fill layers) take the ink colours too. +#[test] +fn duotone_to_rgb_inks_fill_layer_colours() { + let mut s = session(4, 4, 8, "gray"); + s.execute("image.mode.duotone", json!({"type": "monotone", "inks": [{"name": "Orange", "color": "#ff8000"}]})).unwrap(); + s.execute("layer.newFillLayer.solidColor", json!({"color": "#808080"})).unwrap(); + s.execute("image.mode.rgb", json!({})).unwrap(); + let d = doc(&s); + let Some(LayerContent::Fill(photocraft_doc::Fill::Solid(c))) = d.layers.last().map(|l| &l.content) else { panic!("no fill layer") }; + assert_eq!(c.mode, ColorMode::Rgb); + // Gray 0.5 is half-density orange: (1, 0.75, 0.5). + for (g, w) in c.c[..3].iter().zip([1.0, 0.75, 0.5]) { + assert!((g - w).abs() < 0.02, "{c:?}"); + } +} diff --git a/crates/engine/src/photo_cmds.rs b/crates/engine/src/photo_cmds.rs index 50255c9440..bd8c94742e 100644 --- a/crates/engine/src/photo_cmds.rs +++ b/crates/engine/src/photo_cmds.rs @@ -156,12 +156,12 @@ fn exif_focal(srcs: &[Source]) -> Option { /// Warps the content of `surf` inside `rect` by a placement shifted by `offset`; image /// coordinates are surface coordinates minus `origin`. -pub(crate) fn warp_placed(surf: &Surface, rect: Rect, origin: (f64, f64), pl: &Placement, offset: (f64, f64), interp: Interp) -> Surface { +pub(crate) fn warp_placed(surf: &Surface, rect: Rect, origin: (f64, f64), pl: &Placement, offset: (f64, f64), interp: Interp) -> Result { let pl = pl.translated(offset.0, offset.1); if let Some(h) = plane_matrix(&pl, origin) { - return warp_surface(surf, rect, &h, interp); + return Ok(warp_surface(surf, rect, &h, interp)?); } - warp_mesh_surface(surf, rect, &|x, y| pl.forward(x - origin.0, y - origin.1), interp) + Ok(warp_mesh_surface(surf, rect, &|x, y| pl.forward(x - origin.0, y - origin.1), interp)) } /// The projective matrix of a planar, distortion-free placement (surface px → panorama px). @@ -447,7 +447,7 @@ fn photomerge(s: &mut Session, p: &Value) -> Result { .map(|(k, (&i, pl))| { ctx.check()?; ctx.progress(0.35 + 0.25 * k as f32 / placed.len().max(1) as f32, ""); - Ok(warp_placed(&srcs[i].surf, Rect::new(0, 0, srcs[i].w as i32, srcs[i].h as i32), (0.0, 0.0), pl, offset, Interp::Bicubic)) + warp_placed(&srcs[i].surf, Rect::new(0, 0, srcs[i].w as i32, srcs[i].h as i32), (0.0, 0.0), pl, offset, Interp::Bicubic) }) .collect::>>()? }; @@ -758,7 +758,7 @@ fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result { let corners = f.corners(); let hq = Homography::rect_to_quad([0.0, 0.0, pw as f64, ph as f64], corners).and_then(|h| h.inverse()); let Some(hm) = hq else { continue }; - let warped = warp_surface(&flat, area, &hm, Interp::Bicubic); + let warped = warp_surface(&flat, area, &hm, Interp::Bicubic)?; let keep = Rect::new(0, 0, pw as i32, ph as i32); let mut px = Surface::new(fmt); px.write_region(keep, &warped.read_region(keep)); diff --git a/crates/engine/src/pick_cmds.rs b/crates/engine/src/pick_cmds.rs index 70d5cbef4f..9500ba3aec 100644 --- a/crates/engine/src/pick_cmds.rs +++ b/crates/engine/src/pick_cmds.rs @@ -1,6 +1,7 @@ //! Move tool Auto-Select: pick the topmost visible, not fully locked layer with pixels at a canvas //! point (Photoshop's options bar "Auto-Select: Layer | Group", ⌘-click with the Move tool, and the -//! canvas right-click layer list). +//! canvas right-click layer list). Like Photoshop, Auto-Select hits a type layer anywhere inside its +//! text's bounds, so a click between letters picks the type rather than the layer under it (#2370). use photocraft_doc::{Document, LayerContent, LayerId}; use photocraft_geom::Rect; @@ -15,6 +16,12 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { /// Layers with visible pixels at (x, y), topmost first (hidden layers and hidden groups skipped). pub fn layers_at(doc: &Document, x: i32, y: i32) -> Vec { + layers_hit(doc, x, y, false) +} + +/// [`layers_at`], but with `type_bounds` a type layer is hit anywhere inside the bounds of its +/// rendered text (the gaps between glyphs included), as Photoshop's Auto-Select does. +fn layers_hit(doc: &Document, x: i32, y: i32, type_bounds: bool) -> Vec { // A 1×1 read at i32::MAX would be an empty rect (huge `x` params saturate there). if x.checked_add(1).is_none() || y.checked_add(1).is_none() { return Vec::new(); @@ -43,6 +50,8 @@ pub fn layers_at(doc: &Document, x: i32, y: i32) -> Vec { let alpha = match &l.content { // Fill layers cover the canvas (their mask limits them). LayerContent::Fill(_) => 1.0, + // The text's pixel bounds (in document space, so they follow the layer's transform). + LayerContent::Text(t) if type_bounds && t.cache.as_ref().is_some_and(|c| c.content_bounds().contains(x, y)) => 1.0, _ => match l.surface() { Some(s) => { let mut px = [[0.0f32; 4]; 1]; @@ -72,19 +81,28 @@ fn top_group(doc: &Document, id: LayerId) -> LayerId { path.get(..depth).and_then(|p| doc.layer_at(p)).map_or(id, |l| l.id) } +/// The layer an Auto-Select click at (x, y) picks (with `group`, its outermost group): the topmost +/// hit, a type layer by its text bounds. Like Photoshop, it clicks through a fully locked layer +/// (its own Lock All or a locked group's) to the layer under it (#1641); the right-click list +/// still shows it. The click (`layer.pickAt`) and the Move tool's rollover highlight both ask +/// here, so the outline always names the layer a click takes. +pub fn auto_select_target(doc: &Document, x: i32, y: i32, group: bool) -> Option { + let hit = layers_hit(doc, x, y, true).into_iter().find(|id| !doc.effective_locks(*id).all)?; + Some(if group { top_group(doc, hit) } else { hit }) +} + fn pick(s: &mut Session, p: &Value) -> Result { let x = p.get("x").and_then(Value::as_f64).ok_or_else(|| EngineError::BadParams { cmd: "layer.pickAt".into(), msg: "missing `x`".into() })?.floor() as i32; let y = p.get("y").and_then(Value::as_f64).ok_or_else(|| EngineError::BadParams { cmd: "layer.pickAt".into(), msg: "missing `y`".into() })?.floor() as i32; let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); - let hits = layers_at(&doc, x, y); + // The right-click list names the layers with pixels under the pointer. if p.get("list").and_then(Value::as_bool).unwrap_or(false) { + let hits = layers_hit(&doc, x, y, false); let names: Vec = hits.iter().filter_map(|id| doc.layer(*id)).map(|l| json!({"layer": l.id.0, "name": l.name})).collect(); return Ok(json!({ "layers": names })); } - // Like Photoshop, Auto-Select clicks through a fully locked layer (its own Lock All or a - // locked group's) to the layer under it (#1641). The right-click list above still shows it. - let Some(&hit) = hits.iter().find(|id| !doc.effective_locks(**id).all) else { return Ok(json!({ "layer": null })) }; - let target = if p.get("target").and_then(Value::as_str) == Some("group") { top_group(&doc, hit) } else { hit }; + let group = p.get("target").and_then(Value::as_str) == Some("group"); + let Some(target) = auto_select_target(&doc, x, y, group) else { return Ok(json!({ "layer": null })) }; if p.get("select").and_then(Value::as_bool).unwrap_or(true) { let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace"); // Like Photoshop, a plain click on one of several selected layers keeps them all @@ -260,6 +278,58 @@ mod tests { assert_eq!(pick(&mut s), Value::Null); } + #[test] + fn auto_select_hits_a_type_layer_between_its_letters() { + // #2370: a click in the gap between two glyphs picked the layer under the type. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 40})).unwrap(); + let bg = s.active().unwrap().doc.layers[0].id; + let mut text = None; + s.edit("type", |doc, _| { + // Two "glyphs", (10..14)×(10..20) and (20..24)×(10..20), with a gap between them. + let mut cache = photocraft_raster::Surface::new(doc.pixel_format()); + for x in [10, 20] { + cache.fill_rect(Rect::new(x, 10, x + 4, 20), &[0.0, 0.0, 0.0, 1.0]); + } + let l = photocraft_doc::Layer::new("T", LayerContent::Text(photocraft_doc::TextLayer { cache: Some(cache), ..Default::default() })); + text = Some(l.id); + doc.layers.push(l); + Ok(()) + }) + .unwrap(); + let t = text.unwrap(); + let pick = |s: &mut Session, x: i32, y: i32| s.execute("layer.pickAt", json!({"x": x, "y": y, "select": false})).unwrap()["layer"].clone(); + assert_eq!(pick(&mut s, 12, 15), t.0, "on a glyph"); + assert_eq!(pick(&mut s, 17, 15), t.0, "between the glyphs: the type layer, not the one under it"); + assert_eq!(pick(&mut s, 17, 20), bg.0, "below the text bounds"); + // The right-click list still names only the layers with pixels under the pointer. + let list = s.execute("layer.pickAt", json!({"x": 17, "y": 15, "list": true})).unwrap(); + assert_eq!(list["layers"].as_array().unwrap().len(), 1); + } + + #[test] + fn auto_select_hits_typed_text_in_a_gap_between_letters() { + // The same through the type engine: "I I" leaves a transparent gap inside its bounds. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); + let bg = s.active().unwrap().doc.layers[0].id; + let t = s.execute("type.create", json!({"text": "I I", "size": 48, "x": 20, "y": 70})).unwrap()["layer"].as_u64().unwrap(); + let doc = s.active().unwrap().doc.clone(); + let Some(LayerContent::Text(tl)) = doc.layer(LayerId(t)).map(|l| &l.content) else { panic!("a type layer") }; + let cache = tl.cache.as_ref().expect("rendered type"); + let b = cache.content_bounds(); + // A transparent point on the middle row inside the bounds (none: no font to render with). + let y = (b.y0 + b.y1) / 2; + let gap = (b.x0..b.x1).find(|&x| { + let mut px = [[0.0f32; 4]; 1]; + cache.read_rgba_into(Rect::from_xywh(x, y, 1, 1), &mut px); + px[0][3] == 0.0 + }); + let Some(x) = gap else { return }; + assert_eq!(layers_at(&doc, x, y), vec![bg], "no type pixels at the gap"); + assert_eq!(s.execute("layer.pickAt", json!({"x": x, "y": y, "select": false})).unwrap()["layer"], t); + } + #[test] fn hostile_params_are_errors_or_misses_not_panics() { let (mut s, _, _) = two_squares(); diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index 1bf1face47..76f3c6340f 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -3,7 +3,51 @@ use photocraft_compose::{Buffer, adjust}; use photocraft_doc::{Adjustment, Document, Layer, LayerContent}; use photocraft_geom::Rect; -use photocraft_raster::{Surface, from_rgba, to_rgba}; +use photocraft_raster::{Surface, to_rgba}; + +/// Composite `layers` inside their visible composite bounds (effects included) and write the result directly into a +/// tiled surface. Large merges used to hold both a full-canvas RGBA buffer and a second converted +/// float buffer at once; banded rendering keeps only one compositor band plus the destination. +pub fn composite_layers(solo: &Document, format: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface { + let canvas = solo.bounds(); + let area = solo + .layers + .iter() + .filter(|layer| layer.visible) + // Composite bounds include layer effects (a drop shadow reaches past the pixels); `None` + // means the layer may draw anywhere. + .map(|layer| photocraft_compose::composite_bounds(layer, canvas).unwrap_or(canvas)) + .filter(|bounds| !bounds.is_empty()) + .reduce(|a, b| a.union(&b)) + .unwrap_or(Rect::EMPTY) + .intersect(&canvas); + let mut surface = Surface::new(format); + if area.is_empty() { + return surface; + } + + let channels = format.channels(); + let mut data = Vec::new(); + let _ = photocraft_compose::render_bands(solo, area, 0, |mut band| -> Result<(), ()> { + if let Some(color) = background { + for pixel in &mut band.px { + let alpha = pixel[3]; + for channel in 0..3 { + pixel[channel] = pixel[channel] * alpha + color[channel] * (1.0 - alpha); + } + pixel[3] = 1.0; + } + } + data.resize(band.px.len() * channels, 0.0); + for (pixel, encoded) in band.px.iter().zip(data.chunks_exact_mut(channels)) { + photocraft_raster::from_rgba_into(&format, *pixel, encoded); + } + surface.write_region(band.rect, &data); + Ok(()) + }); + surface.prune(); + surface +} /// Apply an adjustment destructively to a surface, weighted by an optional selection. /// Applies `adj` to a surface (any colour model and depth, via straight RGBA) through the @@ -86,10 +130,24 @@ fn adjusted(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>, } /// Fill (respecting selection coverage) with a straight RGBA colour. -pub fn fill_surface(s: &mut Surface, area: Rect, color: [f32; 4], selection: Option<&Surface>, lock_transparency: bool) { +pub(crate) fn try_fill_surface(s: &mut Surface, area: Rect, color: [f32; 4], selection: Option<&Surface>, lock_transparency: bool) -> crate::Result<()> { let fmt = s.format(); let n = fmt.channels(); - let mut region = s.read_region(area); + let mut region = crate::allocation::read_region(s, area, "filling pixels")?; + fill_region(&mut region, fmt, n, area, color, selection, lock_transparency); + s.try_write_region(area, ®ion) + .map_err(|e| crate::EngineError::Other(format!("not enough memory while writing filled pixels ({e}); the document was not changed"))) +} + +fn fill_region( + region: &mut [f32], + fmt: photocraft_color::PixelFormat, + n: usize, + area: Rect, + color: [f32; 4], + selection: Option<&Surface>, + lock_transparency: bool, +) { let w = area.width() as usize; for (i, px) in region.chunks_exact_mut(n).enumerate() { let x = area.x0 + (i % w) as i32; @@ -112,17 +170,24 @@ pub fn fill_surface(s: &mut Surface, area: Rect, color: [f32; 4], selection: Opt photocraft_raster::from_rgba_into(&fmt, o, &mut enc); px.copy_from_slice(&enc[..n]); } - s.write_region(area, ®ion); } /// Clear pixels (make transparent) within the selection. -pub fn clear_surface(s: &mut Surface, area: Rect, selection: Option<&Surface>) { +pub(crate) fn try_clear_surface(s: &mut Surface, area: Rect, selection: Option<&Surface>) -> crate::Result<()> { let fmt = s.format(); let n = fmt.channels(); if !fmt.alpha { - return; + return Ok(()); } - let mut region = s.read_region(area); + let mut region = crate::allocation::read_region(s, area, "erasing pixels")?; + clear_region(&mut region, n, area, selection); + s.try_write_region(area, ®ion) + .map_err(|e| crate::EngineError::Other(format!("not enough memory while erasing pixels ({e}); the document was not changed")))?; + s.prune(); + Ok(()) +} + +fn clear_region(region: &mut [f32], n: usize, area: Rect, selection: Option<&Surface>) { let w = area.width() as usize; for (i, px) in region.chunks_exact_mut(n).enumerate() { let k = selection.map_or(1.0, |sel| sel.sample_channel(area.x0 + (i % w) as i32, area.y0 + (i / w) as i32, 0)); @@ -132,8 +197,6 @@ pub fn clear_surface(s: &mut Surface, area: Rect, selection: Option<&Surface>) { px.fill(0.0); } } - s.write_region(area, ®ion); - s.prune(); } /// Remap a surface through a pixel-coordinate mapping (used for flips/rotations). @@ -153,20 +216,16 @@ pub fn remap_surface(s: &Surface, map: impl Fn(i32, i32) -> (i32, i32)) -> Surfa /// Merge `upper` onto `lower` producing a raster layer (Layer → Merge Down). pub fn merge_down(doc_bounds: Rect, lower: &Layer, upper: &Layer, format: photocraft_color::PixelFormat) -> Layer { - let area = [lower, upper].iter().map(|l| l.surface().map(|s| s.content_bounds()).unwrap_or(doc_bounds)).fold(Rect::EMPTY, |a, b| a.union(&b)); let stack = vec![lower.clone(), upper.clone()]; let mut tmp = Document::new("merge", doc_bounds.size(), format.mode, format.sample); tmp.layers = stack; - let buf = photocraft_compose::render(&tmp, area); - let mut s = Surface::new(format); - let data: Vec = buf.px.iter().flat_map(|p| from_rgba(&format, *p)).collect(); - if !area.is_empty() { - s.write_region(area, &data); - s.prune(); - } + let s = composite_layers(&tmp, format, None); let mut merged = Layer::new(lower.name.clone(), LayerContent::Raster(s)); merged.id = lower.id; merged.blend = lower.blend; merged.opacity = 1.0; + // Merge Down replaces the lower layer, so keep its editing restrictions. + // In particular, a merged Background must remain opaque and position-locked. + merged.locks = lower.locks; merged } diff --git a/crates/engine/src/pixels_tests.rs b/crates/engine/src/pixels_tests.rs index a58bd4c1a0..c7ee899602 100644 --- a/crates/engine/src/pixels_tests.rs +++ b/crates/engine/src/pixels_tests.rs @@ -1,5 +1,8 @@ use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, Size}; +use photocraft_raster::from_rgba; use serde_json::json; +use std::time::Instant; use super::*; @@ -32,6 +35,41 @@ fn layer(fmt: PixelFormat) -> Surface { s } +/// Release-only comparison for the 5000×7073 px group-merge report (#1561). The reference path +/// models the old full-canvas composite plus a second converted float buffer; the optimized path +/// renders only the group's visible content bounds in bands. +#[test] +#[ignore = "manual 35 MP merge benchmark; run in release with --ignored --nocapture"] +fn benchmark_large_group_merge() { + let fmt = PixelFormat::RGBA8; + let mut doc = Document::new("merge benchmark", Size::new(5000, 7073), ColorMode::Rgb, SampleType::U8); + let content = Rect::new(1700, 2200, 2700, 3200); + let mut children = Vec::new(); + for i in 0..4 { + let mut child = Layer::raster(format!("Layer {i}"), fmt); + if let Some(surface) = child.surface_mut() { + surface.fill_rect(content, &[(i as f32 + 1.0) / 5.0, 0.2, 0.4, 0.5]); + } + children.push(child); + } + doc.layers.push(Layer::group("Group", children)); + let expected_area = photocraft_compose::layer_bounds(&doc.layers[0], doc.bounds()).intersect(&doc.bounds()); + + let start = Instant::now(); + let reference = photocraft_compose::flatten(&doc); + let reference_data: Vec = reference.px.iter().flat_map(|p| photocraft_raster::from_rgba(&fmt, *p)).collect(); + let mut reference_surface = Surface::new(fmt); + reference_surface.write_region(doc.bounds(), &reference_data); + reference_surface.prune(); + let reference_time = start.elapsed(); + + let start = Instant::now(); + let optimized = composite_layers(&doc, fmt, None); + let optimized_time = start.elapsed(); + assert_eq!(reference_surface.read_region(expected_area), optimized.read_region(expected_area)); + println!("#1561 5000x7073, 4x 1000x1000 layers: full-canvas {:?}, bounded/banded {:?}", reference_time, optimized_time); +} + #[test] fn tiled_adjustment_matches_one_pass_over_the_content() { let adjustments = [ diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index ca316a1f6d..ac5f62b169 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -55,13 +55,13 @@ choice!(TypeUnit { Points = "points", Pixels = "pixels", Millimeters = "mm" } de choice!(PointSize { PostScript = "postScript", Traditional = "traditional" } default PostScript); choice!(Interpolation { BicubicAutomatic = "bicubicAutomatic", Nearest = "nearestNeighbor", Bilinear = "bilinear", Bicubic = "bicubic", BicubicSmoother = "bicubicSmoother", BicubicSharper = "bicubicSharper", PreserveDetails = "preserveDetails" } default BicubicAutomatic); choice!(ColorPicker { Adobe = "adobe", System = "system" } default Adobe); -choice!(Theme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", StudioLight = "studioLight", Classic = "classic", SolarizedDark = "solarizedDark", Adwaita = "adwaita", AdwaitaDark = "adwaitaDark" } default ProMedium); +choice!(Theme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", StudioLight = "studioLight", Classic = "classic", SolarizedDark = "solarizedDark", Adwaita = "adwaita", AdwaitaDark = "adwaitaDark", BreezeLight = "breezeLight", BreezeDark = "breezeDark" } default ProMedium); choice!(AppearanceMode { Auto = "auto", Dark = "dark", Light = "light" } default Dark); -choice!(DarkTheme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", SolarizedDark = "solarizedDark", AdwaitaDark = "adwaitaDark" } default ProMedium); -choice!(LightTheme { StudioLight = "studioLight", Classic = "classic", Adwaita = "adwaita" } default StudioLight); +choice!(DarkTheme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", SolarizedDark = "solarizedDark", AdwaitaDark = "adwaitaDark", BreezeDark = "breezeDark" } default ProMedium); +choice!(LightTheme { StudioLight = "studioLight", Classic = "classic", Adwaita = "adwaita", BreezeLight = "breezeLight" } default StudioLight); choice!(CanvasColor { Default = "default", Black = "black", DarkGray = "darkGray", MediumGray = "mediumGray", LightGray = "lightGray", Custom = "custom" } default Default); choice!(CanvasBorder { DropShadow = "dropShadow", Line = "line", None = "none" } default DropShadow); -choice!(UiScale { Auto = "auto", P75 = "75", P100 = "100", P125 = "125", P150 = "150", P175 = "175", P200 = "200", P250 = "250", P300 = "300" } default Auto); +choice!(UiScale { Auto = "auto", P75 = "75", P80 = "80", P85 = "85", P90 = "90", P95 = "95", P100 = "100", P125 = "125", P150 = "150", P175 = "175", P200 = "200", P250 = "250", P300 = "300" } default Auto); choice!( /// Graphics backend of the desktop app's window and GPU canvas (applies at next launch). /// `auto` lets PhotoCraft pick (DX12 for Intel adapters on Windows); `cpu` composites on the @@ -149,12 +149,15 @@ impl Unit { Unit::Percent => "%", } } - /// Decimal places a readout in this unit needs. + /// Decimal places a readout in this unit needs, following Photoshop: whole pixels, one place + /// for points and percent, two for millimetres, centimetres and picas, three for inches + /// (#2434). [`fmt_decimals`] rounds to this many places and drops trailing zeros. pub fn decimals(self) -> usize { match self { Unit::Pixels => 0, - Unit::Points | Unit::Percent | Unit::Millimeters => 1, - _ => 2, + Unit::Points | Unit::Percent => 1, + Unit::Millimeters | Unit::Centimeters | Unit::Picas => 2, + Unit::Inches => 3, } } } @@ -308,6 +311,8 @@ pub struct Tools { /// Round snapped vector and transform coordinates to whole pixels. pub snap_vector_tools_and_transforms_to_pixel_grid: bool, pub show_transformation_values: bool, + /// Move tool with Auto-Select: outline the layer a click would pick under the pointer. + pub show_highlight_on_rollover: bool, pub overscroll: bool, pub double_click_layer_mask_launches_select_and_mask: bool, /// What the right mouse button does on the canvas with the Brush and other painting tools. @@ -329,6 +334,7 @@ impl Default for Tools { vary_round_brush_hardness_on_hud: true, snap_vector_tools_and_transforms_to_pixel_grid: true, show_transformation_values: true, + show_highlight_on_rollover: true, overscroll: true, double_click_layer_mask_launches_select_and_mask: true, right_click_with_painting_tools: RightClickPaint::BrushPicker, @@ -698,11 +704,27 @@ impl Default for UnitsAndRulers { } } +/// Rounds `v` to `decimals` places (half away from zero) and drops trailing zeros, so a readout +/// never shows more precision than it has: 0.7995 at three places is `0.8`, never `0.799` or +/// `0.800`, and a whole number keeps no `.0` (#2434). +pub fn fmt_decimals(v: f64, decimals: usize) -> String { + let p = 10f64.powi(decimals as i32); + let r = (v * p).round() / p; + // -0.0 would format as "-0". + let r = if r == 0.0 { 0.0 } else { r }; + let s = format!("{r:.*}", decimals); + // Only a fractional part may lose zeros: "550" must not become "55". + if !s.contains('.') { + return s; + } + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + impl UnitsAndRulers { - /// Format a length in document pixels in the ruler unit, e.g. `"2.50 in"`. + /// Format a length in document pixels in the ruler unit, e.g. `"2.5 in"`. pub fn format(&self, px: f64, dpi: f64, extent: f64) -> String { let v = self.rulers.from_px(px, dpi, extent, self.point_size.per_inch()); - format!("{:.*}", self.rulers.decimals(), v) + fmt_decimals(v, self.rulers.decimals()) } } @@ -953,11 +975,9 @@ pub const SECTIONS: [(&str, &str); 18] = [ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "general.colorPicker", "general.beepWhenDone", - "general.resizeImageDuringPlace", "general.alwaysCreateSmartObjectsWhenPlacing", "general.animatedZoom", "general.zoomResizesWindows", - "interface.dynamicColorSliders", "workspace.autoCollapseIconPanels", "workspace.autoShowHiddenPanels", "workspace.enableFloatingDocumentWindowDocking", @@ -981,7 +1001,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "plugIns.showExtensionPanels", "plugIns.allowScriptsToConnect", "plugIns.generatorEnabled", - "type.smartQuotes", "type.missingGlyphProtection", "type.showFontNamesInEnglish", "type.textEngine", @@ -1348,7 +1367,7 @@ fn capitalize(s: &str) -> String { pub const TEMPORARY_TOOLS: &[(&str, &str, &str)] = &[ ("tools.temporary.hand", "Hand Tool (hold)", "Space"), ("tools.temporary.zoomIn", "Zoom In (hold, drag to scrub)", "Cmd+Space"), - ("tools.temporary.zoomOut", "Zoom Out (hold)", "Cmd+Alt+Space"), + ("tools.temporary.zoomOut", "Zoom Out (hold)", "Alt+Space"), ]; /// Every bindable id with its default: commands, then the temporary tools. @@ -1446,8 +1465,8 @@ impl Session { && let Some(theme) = interface.get("theme").and_then(Value::as_str).and_then(Theme::parse) { let (mode, slot) = match theme { - Theme::Pro | Theme::ProMedium | Theme::Studio | Theme::SolarizedDark | Theme::AdwaitaDark => ("dark", "darkTheme"), - Theme::StudioLight | Theme::Classic | Theme::Adwaita => ("light", "lightTheme"), + Theme::Pro | Theme::ProMedium | Theme::Studio | Theme::SolarizedDark | Theme::AdwaitaDark | Theme::BreezeDark => ("dark", "darkTheme"), + Theme::StudioLight | Theme::Classic | Theme::Adwaita | Theme::BreezeLight => ("light", "lightTheme"), }; interface.insert("appearanceMode".into(), json!(mode)); interface.insert(slot.into(), json!(theme.name())); @@ -1548,6 +1567,14 @@ impl Session { next.interface.dark_theme = DarkTheme::AdwaitaDark; next.interface.appearance_mode = AppearanceMode::Dark; } + Theme::BreezeDark => { + next.interface.dark_theme = DarkTheme::BreezeDark; + next.interface.appearance_mode = AppearanceMode::Dark; + } + Theme::BreezeLight => { + next.interface.light_theme = LightTheme::BreezeLight; + next.interface.appearance_mode = AppearanceMode::Light; + } } } self.prefs.edit(|p| *p = next); diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 886bd8910e..bee5a082a2 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -521,3 +521,35 @@ fn prefs_set_validates_the_pressure_curve() { } assert_eq!(p.tools.pressure_curve, vec![[0.0, 0.1], [0.5, 0.7], [1.0, 1.0]], "a refused value changes nothing"); } + +/// #2434: each unit's readout keeps Photoshop's precision, rounded half away from zero and +/// without trailing zeros, so 0.7995 in reads 0.8 rather than 0.799 or 0.800. +#[test] +fn ruler_units_round_to_their_own_decimal_places() { + use super::{Unit, fmt_decimals}; + assert_eq!(Unit::Pixels.decimals(), 0); + assert_eq!(Unit::Points.decimals(), 1); + for u in [Unit::Millimeters, Unit::Centimeters, Unit::Picas] { + assert_eq!(u.decimals(), 2, "{u:?}"); + } + assert_eq!(Unit::Inches.decimals(), 3); + // Rounded to the unit's places, then trailing zeros dropped. + assert_eq!(fmt_decimals(0.7995, 3), "0.8"); + assert_eq!(fmt_decimals(0.799, 3), "0.799"); + assert_eq!(fmt_decimals(0.799, 2), "0.8"); + assert_eq!(fmt_decimals(2.0, 3), "2"); + assert_eq!(fmt_decimals(1234.5678, 2), "1234.57"); + // Half away from zero, both signs, and no "-0". + assert_eq!(fmt_decimals(0.125, 2), "0.13"); + assert_eq!(fmt_decimals(-0.125, 2), "-0.13"); + assert_eq!(fmt_decimals(-0.004, 2), "0"); + // A whole number keeps its zeros: 550 px is not 55. + assert_eq!(fmt_decimals(550.0, 0), "550"); + // A 1500 px side at 300 dpi is 5 in / 12.7 cm / 127 mm / 360 pt / 30 pica. + let r = super::UnitsAndRulers { rulers: Unit::Inches, ..Default::default() }; + assert_eq!(r.format(1500.0, 300.0, 1500.0), "5"); + let r = super::UnitsAndRulers { rulers: Unit::Centimeters, ..Default::default() }; + assert_eq!(r.format(1500.0, 300.0, 1500.0), "12.7"); + let r = super::UnitsAndRulers { rulers: Unit::Inches, ..Default::default() }; + assert_eq!(r.format(100.0, 300.0, 1500.0), "0.333"); +} diff --git a/crates/engine/src/rasterize_style_cmds.rs b/crates/engine/src/rasterize_style_cmds.rs new file mode 100644 index 0000000000..db8af142be --- /dev/null +++ b/crates/engine/src/rasterize_style_cmds.rs @@ -0,0 +1,135 @@ +//! Layer › Rasterize › Layer Style (#2245): bake a layer's effects into its pixels. +//! +//! The layer is rendered alone by the compositor (the same path as the canvas) at 100 % opacity +//! in Normal mode, so its effects, fill opacity and masks end up in the pixels; its own opacity, +//! blend mode, knockout and Blend If stay layer properties and apply to the baked pixels. For a +//! Normal layer whose effects blend Normal the document looks the same before and after. An +//! effect in another mode (a Multiply shadow, a Screen glow) is evaluated against the +//! transparent backdrop of the isolated layer, so it can look different afterwards; no single +//! layer of pixels can blend one part Multiply and another Screen. + +use photocraft_color::BlendMode; +use photocraft_doc::{Knockout, Layer, LayerContent, LayerId}; +use photocraft_raster::{Surface, from_rgba_into}; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +const ID: &str = "layer.rasterize.layerStyle"; + +/// Whether Rasterize › Layer Style applies to `l`: visible effects on a layer whose content can +/// become pixels (not a group or an adjustment layer). +pub fn can_bake(l: &Layer) -> bool { + photocraft_compose::effects::has_effects(l) && !matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_)) +} + +/// Some selected layer (or the active one) has effects to bake. +fn enabled(s: &Session) -> std::result::Result<(), String> { + let d = s.active().ok_or("no document open")?; + let ids = d.selected_layers().into_iter().chain(d.active_layer); + if ids.filter_map(|id| d.doc.layer(id)).any(can_bake) { Ok(()) } else { Err("no selected layer has layer effects to rasterize".into()) } +} + +/// Bake one layer (type, shape, fill and Smart Object content is rasterized first, in the same +/// history step). Returns false when the layer has nothing to bake. +fn bake_one(s: &mut Session, id: LayerId, key: &str) -> Result { + let d = s.active().ok_or(EngineError::NoDocument)?; + if !d.doc.layer(id).is_some_and(can_bake) { + return Ok(false); + } + if let Some(kind) = crate::extra_cmds::kind_of(s, id) { + crate::extra_cmds::rasterize_one(s, id, Some(kind), key)?; + } + s.coalesce_request = Some(key.to_string()); + let r = s.edit("Rasterize Layer Style", |doc, _| { + let l = doc.layer(id).cloned().ok_or(EngineError::NoLayer(id))?; + if !matches!(l.content, LayerContent::Raster(_)) { + return Err(EngineError::Other("the layer could not be rasterized".into())); + } + let canvas = doc.bounds(); + let fmt = doc.pixel_format(); + // The pixels (also those past the canvas) and everything the effects reach from them. + let area = photocraft_compose::layer_bounds(&l, canvas).inflate(photocraft_compose::effects::margin(&l).max(0)); + let mut alone = l.clone(); + alone.visible = true; + alone.opacity = 1.0; + alone.blend = BlendMode::Normal; + alone.clipped = false; + // These depend on the layers beneath and stay on the layer, applied to the baked pixels. + alone.advanced.knockout = Knockout::None; + alone.blend_if = Default::default(); + alone.excluded_channels = 0; + let mut data = Vec::new(); + if !area.is_empty() { + // Render the layer on its own with the document's settings (mode, depth, global + // light, patterns), then put the real stack back. + let stack = std::mem::replace(&mut doc.layers, vec![alone]); + let buf = photocraft_compose::render(doc, area); + doc.layers = stack; + let n = fmt.channels(); + data = vec![0.0f32; buf.px.len() * n]; + for (p, out) in buf.px.iter().zip(data.chunks_exact_mut(n)) { + from_rgba_into(&fmt, *p, out); + } + } + let mut px = Surface::new(fmt); + if !area.is_empty() { + px.write_region(area, &data); + } + px.prune(); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + l.content = LayerContent::Raster(px); + l.fill_cache = None; + // Fill opacity, the masks and the effects are in the pixels now. + l.fill_opacity = 1.0; + l.mask = None; + l.vector_mask = None; + l.effects.items.clear(); + l.effects.psd_raw = None; + l.effects.reference = None; + l.effects.enabled = true; + l.advanced.blend_interior = false; + l.advanced.layer_mask_hides_effects = false; + l.advanced.vector_mask_hides_effects = false; + Ok(()) + }); + s.coalesce_request = None; + r?; + Ok(true) +} + +/// `{"layer":id?}`: with a layer, just that one; otherwise every selected layer with effects, in +/// one history step. +fn rasterize_layer_style(s: &mut Session, p: &Value) -> Result { + let ids = crate::layer_multi_cmds::targets(s, p)?; + let many = ids.len() > 1; + let key = crate::extra_cmds::step_key(s, "rasterizeLayerStyle"); + let mut done = Vec::new(); + for id in ids { + if bake_one(s, id, &key)? { + done.push(id.0); + } + } + let Some(&first) = done.first() else { + return Err(EngineError::Other(if many { "none of the selected layers has layer effects" } else { "the layer has no layer effects" }.into())); + }; + let active = s.active().and_then(|d| d.active_layer).map(|id| id.0).filter(|id| done.contains(id)); + Ok(json!({"layer": active.unwrap_or(first), "layers": done})) +} + +pub fn specs() -> Vec { + vec![CommandSpec { + id: ID, + label: "Layer Style", + menu: &["Layer", "Rasterize"], + shortcut: None, + params: r##"{"layer":id?} (no layer: every selected layer with effects; bakes the effects, fill opacity and masks into the pixels)"##, + enabled, + run: rasterize_layer_style, + journal: true, + }] +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/rasterize_style_cmds/tests.rs b/crates/engine/src/rasterize_style_cmds/tests.rs new file mode 100644 index 0000000000..9b5ef7d359 --- /dev/null +++ b/crates/engine/src/rasterize_style_cmds/tests.rs @@ -0,0 +1,165 @@ +use super::*; +use photocraft_doc::Document; + +fn doc(s: &Session) -> &Document { + &s.active().unwrap().doc +} + +fn layer(s: &Session, id: u64) -> &Layer { + doc(s).layer(LayerId(id)).unwrap() +} + +fn composite(s: &Session) -> photocraft_compose::Buffer { + let d = doc(s); + photocraft_compose::render(d, d.bounds()) +} + +fn max_diff(a: &photocraft_compose::Buffer, b: &photocraft_compose::Buffer) -> f32 { + a.px.iter().zip(&b.px).flat_map(|(p, q)| p.iter().zip(q).map(|(x, y)| (x - y).abs())).fold(0.0, f32::max) +} + +/// The quantisation step of one stored sample: the baked layer is stored at the document's depth. +fn tolerance(depth: u32) -> f32 { + match depth { + 8 => 2.5 / 255.0, + 16 => 2.0 / 32768.0 + 1e-4, + _ => 1e-4, + } +} + +/// A white Background and a red rectangle (partly off the right edge of the canvas) with a +/// Normal-mode drop shadow, outer glow, inner shadow, outside stroke and a half colour overlay. +fn scene(depth: u32) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "depth": depth})).unwrap(); + let id = s.execute("layer.new.layer", json!({"name": "a"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 14, "y": 10, "width": 56, "height": 20})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#e02010"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.layerStyle.dropShadow", json!({"blend": "normal", "distance": 6, "size": 4, "color": "#102040"})).unwrap(); + s.execute("layer.layerStyle.outerGlow", json!({"blend": "normal", "size": 5, "color": "#20c040", "add": true})).unwrap(); + s.execute("layer.layerStyle.innerShadow", json!({"blend": "normal", "distance": 3, "size": 3, "add": true})).unwrap(); + s.execute("layer.layerStyle.stroke", json!({"size": 2, "position": "outside", "color": "#0000ff", "add": true})).unwrap(); + s.execute("layer.layerStyle.colorOverlay", json!({"color": "#ffff00", "opacity": 50, "add": true})).unwrap(); + (s, id) +} + +fn bake_and_check(s: &mut Session, id: u64, depth: u32) { + let before = composite(s); + let steps = s.active().unwrap().history.past_len(); + let r = s.execute(ID, json!({})).unwrap(); + assert_eq!(r["layers"], json!([id])); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one history step"); + let l = layer(s, id); + assert!(l.effects.items.is_empty(), "effects baked"); + assert!(matches!(l.content, LayerContent::Raster(_))); + assert!(l.mask.is_none() && l.vector_mask.is_none(), "masks applied"); + assert_eq!(l.fill_opacity, 1.0); + let after = composite(s); + let d = max_diff(&before, &after); + assert!(d <= tolerance(depth), "depth {depth}: composite changed by {d}"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer(s, id).effects.items.len(), 5, "undo restores the effects"); + assert_eq!(max_diff(&before, &composite(s)), 0.0, "undo restores the picture"); +} + +#[test] +fn bakes_effects_without_changing_the_composite() { + for depth in [8, 16, 32] { + let (mut s, id) = scene(depth); + bake_and_check(&mut s, id, depth); + } +} + +#[test] +fn fill_opacity_masks_and_layer_opacity_survive_the_bake() { + for depth in [8, 16, 32] { + let (mut s, id) = scene(depth); + s.execute("layer.setProps", json!({"layer": id, "fill": 0.4, "opacity": 0.7})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 34, "height": 48})).unwrap(); + s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let square = json!({"subpaths": [{"closed": true, "knots": [[4, 4], [60, 4], [60, 26], [4, 26]]}]}); + s.execute("layer.vectorMask.add", json!({"layer": id, "path": square})).unwrap(); + bake_and_check(&mut s, id, depth); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(layer(&s, id).opacity, 0.7, "the layer's own opacity stays a property"); + } +} + +#[test] +fn keeps_the_pixels_past_the_canvas() { + let (mut s, id) = scene(8); + s.execute(ID, json!({"layer": id})).unwrap(); + let LayerContent::Raster(px) = &layer(&s, id).content else { panic!() }; + // The rectangle runs to x = 70 and its stroke and glow further, past the 64 px canvas. + assert!(px.content_bounds().x1 > 70, "{:?}", px.content_bounds()); +} + +#[test] +fn rasterizes_fill_content_first() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("layer.newFillLayer.solidColor", json!({"color": "#3366cc"})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + let square = json!({"subpaths": [{"closed": true, "knots": [[8, 8], [28, 8], [28, 20], [8, 20]]}]}); + s.execute("layer.vectorMask.add", json!({"layer": id, "path": square})).unwrap(); + s.execute("layer.layerStyle.stroke", json!({"size": 3, "position": "outside", "color": "#ff0000"})).unwrap(); + let before = composite(&s); + let steps = s.active().unwrap().history.past_len(); + s.execute(ID, json!({})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "rasterize and bake are one step"); + assert!(matches!(layer(&s, id).content, LayerContent::Raster(_))); + assert!(layer(&s, id).effects.items.is_empty()); + assert!(max_diff(&before, &composite(&s)) <= tolerance(8)); +} + +#[test] +fn rasterize_layer_turns_a_vector_mask_into_a_pixel_mask_and_keeps_effects() { + let (mut s, id) = scene(8); + // Effects alone: Rasterize › Layer has nothing to do (Rasterize › Layer Style bakes them). + assert!(s.execute("layer.rasterize.layer", json!({})).is_err()); + let square = json!({"subpaths": [{"closed": true, "knots": [[4, 4], [40, 4], [40, 40], [4, 40]]}]}); + s.execute("layer.vectorMask.add", json!({"layer": id, "path": square})).unwrap(); + let before = composite(&s); + s.execute("layer.rasterize.layer", json!({})).unwrap(); + let l = layer(&s, id); + assert!(l.vector_mask.is_none() && l.mask.is_some(), "vector mask became a pixel mask"); + assert_eq!(l.effects.items.len(), 5, "effects stay live"); + assert!(max_diff(&before, &composite(&s)) <= tolerance(8)); +} + +#[test] +fn several_selected_layers_bake_in_one_step() { + let (mut s, a) = scene(8); + let b = s.execute("layer.new.layer", json!({"name": "b"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 2, "y": 34, "width": 10, "height": 8})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#00ff00"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.layerStyle.stroke", json!({"size": 2})).unwrap(); + let plain = s.execute("layer.new.layer", json!({"name": "plain"})).unwrap()["layer"].as_u64().unwrap(); + s.active_mut().unwrap().selected_layers = vec![LayerId(a), LayerId(b), LayerId(plain)]; + let steps = s.active().unwrap().history.past_len(); + let r = s.execute(ID, json!({})).unwrap(); + assert_eq!(r["layers"], json!([a, b])); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1); + assert!(layer(&s, a).effects.items.is_empty() && layer(&s, b).effects.items.is_empty()); +} + +#[test] +fn disabled_and_bad_params_fail_gracefully() { + let mut empty = Session::new(); + assert!(empty.execute(ID, json!({})).is_err(), "no document"); + let (mut s, id) = scene(8); + let bg = doc(&s).layers[0].id.0; + assert!(s.execute(ID, json!({"layer": bg})).is_err(), "the Background has no effects"); + assert!(s.execute(ID, json!({"layer": 9999})).is_err(), "no such layer"); + assert!(s.execute(ID, json!({"layer": u64::MAX})).is_err(), "no such layer"); + assert_eq!(layer(&s, id).effects.items.len(), 5, "a bad call changes nothing"); + s.execute("layer.layerStyle.hideAllEffects", json!({})).unwrap(); + assert!(s.execute(ID, json!({"layer": id})).is_err(), "hidden effects: nothing to bake"); + s.execute("layer.layerStyle.showAllEffects", json!({})).unwrap(); + let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.layerStyle.stroke", json!({"layer": g})).unwrap(); + assert!(s.execute(ID, json!({"layer": g})).is_err(), "group styles are not baked"); +} diff --git a/crates/engine/src/render_cmds.rs b/crates/engine/src/render_cmds.rs index e2ec1d9d75..c6e4318e3d 100644 --- a/crates/engine/src/render_cmds.rs +++ b/crates/engine/src/render_cmds.rs @@ -45,6 +45,22 @@ fn normalized(p: &Value, colours: &[&str]) -> Map { m } +/// Integer fields of a spec (`u32` in [`FlameSpec`], [`TreeSpec`], [`FrameSpec`]) given as JSON +/// floats: dialogs send every number as a float (`1.0`) and agents may too, which serde refuses +/// for a `u32` ("invalid type: floating point `1.0`, expected u32", #2501). Finite floats round to +/// the nearest integer, like [`crate::commands::int`]; a negative or too-large result is then +/// still refused by `spec_from`. Strings (names) and other types are left alone. +fn whole_numbers(m: &mut Map, keys: &[&str]) { + for k in keys { + if let Some(Value::Number(n)) = m.get(*k) + && !(n.is_u64() || n.is_i64()) + && let Some(f) = n.as_f64().filter(|f| f.is_finite()) + { + m.insert((*k).to_string(), json!(f.round() as i64)); + } + } +} + fn spec_from(m: Map) -> Result { serde_json::from_value(Value::Object(m)).map_err(|e| EngineError::Other(format!("invalid parameters: {e}"))) } @@ -137,6 +153,7 @@ fn run_flame(s: &mut Session, p: &Value) -> Result { let i = ["draft", "low", "medium", "high", "fine"].iter().position(|n| *n == q).unwrap_or(2); m.insert("quality".into(), json!(i)); } + whole_numbers(&mut m, &["seed"]); let spec: FlameSpec = spec_from(m)?; let found = flame_paths(s, p); let used_path = found.is_some(); @@ -161,6 +178,7 @@ fn run_tree(s: &mut Session, p: &Value) -> Result { if m.contains_key("branchesColor") && !m.contains_key("customBranchColor") { m.insert("customBranchColor".into(), json!(true)); } + whole_numbers(&mut m, &["baseTreeType", "lightDirection", "seed"]); let spec: TreeSpec = spec_from(m)?; let prims = render2::tree(&spec, canvas(s)?); let name = render2::tree_type_names()[(spec.base_tree_type.clamp(1, 34) - 1) as usize]; @@ -170,6 +188,7 @@ fn run_tree(s: &mut Session, p: &Value) -> Result { fn run_frame(s: &mut Session, p: &Value) -> Result { let mut m = normalized(p, &["vineColor", "flowerColor", "leafColor"]); index_param(&mut m, "frame", &render2::FRAME_STYLES); + whole_numbers(&mut m, &["frame", "lines", "seed"]); let spec: FrameSpec = spec_from(m)?; let prims = render2::picture_frame(&spec, canvas(s)?); let name = render2::FRAME_STYLES[(spec.frame.clamp(1, render2::FRAME_STYLES.len() as u32) - 1) as usize]; @@ -309,6 +328,51 @@ mod tests { } } + /// #2501: the Tree and Picture Frame dialogs send every number as a JSON float, and their + /// `u32` fields (`baseTreeType`, `lightDirection`, `frame`, `lines`, `seed`) refused `1.0` + /// ("invalid type: floating point `1.0`, expected u32"). Whole floats now draw exactly what + /// the integers draw, fractional ones (a dragged 1–5 slider) round, and hostile values fail. + #[test] + fn integer_params_accept_whole_floats_and_round_fractions() { + let tree = json!({"baseTreeType": 1.0, "lightDirection": 3.0, "leavesAmount": 50.0, "leavesSize": 100.0, "branchesHeight": 100.0, + "branchesThickness": 100.0, "defaultLeaves": true, "customBranchColor": false, "flatShading": false, "seed": 0, "x": 0.5, "y": 0.95, "size": 0.8}); + let frame = + json!({"frame": "vineWithFlowers", "margin": 4.0, "size": 50.0, "arrangement": 50.0, "lines": 1.0, "thickness": 30.0, "fade": 0.0, "seed": 0}); + for (id, floats, ints) in [ + ("filter.render.tree", tree.clone(), json!({"baseTreeType": 1, "lightDirection": 3})), + ( + "filter.render.tree", + json!({"baseTreeType": 7.4, "lightDirection": 2.6, "seed": 3.0}), + json!({"baseTreeType": 7, "lightDirection": 3, "seed": 3}), + ), + ("filter.render.pictureFrame", frame.clone(), json!({"frame": 1, "lines": 1})), + ("filter.render.pictureFrame", json!({"frame": 13.0, "lines": 3.0, "seed": 2.0}), json!({"frame": 13, "lines": 3, "seed": 2})), + ("filter.render.flame", json!({"seed": 4.0}), json!({"seed": 4})), + ] { + let mut s = session(8); + let a = s.execute(id, floats.clone()).unwrap_or_else(|e| panic!("{id} {floats}: {e}")); + let drawn = pixels(&s); + s.execute("edit.undo", json!({})).unwrap(); + let b = s.execute(id, ints.clone()).unwrap(); + assert_eq!(a, b, "{id}: {floats} reports what {ints} does"); + assert_eq!(pixels(&s), drawn, "{id}: {floats} draws what {ints} draws"); + } + // Negative, too large or non-numeric integer fields are refused, never a panic. + let mut s = session(8); + for (id, p) in [ + ("filter.render.tree", json!({"baseTreeType": -1.0})), + ("filter.render.tree", json!({"lightDirection": 1e300})), + ("filter.render.tree", json!({"seed": -0.6})), + ("filter.render.pictureFrame", json!({"lines": -3.5})), + ("filter.render.pictureFrame", json!({"seed": 5e9})), + ("filter.render.pictureFrame", json!({"lines": "two"})), + ("filter.render.flame", json!({"seed": f64::MAX})), + ] { + let e = s.execute(id, p.clone()).expect_err(&format!("{id} {p}")); + assert!(e.to_string().contains("invalid parameters"), "{id} {p}: {e}"); + } + } + /// `cargo test --release -p photocraft-engine render_cmds::tests::bench_24mp -- --ignored --nocapture` #[test] #[ignore] diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index d7e58306ae..75eedc899d 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -66,6 +66,23 @@ fn point(p: &Value, k: &str) -> Option<(f64, f64)> { Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)) } +/// The brush a retouching stroke paints with: the tool's brush (`base`, its tip, hardness and +/// shape dynamics) with the shared params of `p` applied. UI previews of a stroke (the Spot Healing +/// Brush's trail) render their coverage with it, so they match what the command changes. +pub fn stroke_brush(base: &BrushSettings, p: &Value) -> BrushSettings { + let pct = |k: &str, d: f32, lo: f32, hi: f32| num(p, k, d).clamp(lo, hi) / 100.0; + BrushSettings { + size: num(p, "size", base.size).max(1.0), + hardness: pct("hardness", base.hardness * 100.0, 0.0, 100.0), + opacity: pct("opacity", 100.0, 1.0, 100.0), + flow: pct("flow", 100.0, 1.0, 100.0), + // Brush Settings › Brush Tip Shape › Spacing, as the Brush uses it (#2140). + spacing: pct("spacing", base.spacing * 100.0, 1.0, 1000.0), + erase: false, + ..base.clone() + } +} + /// Parse the shared brush params into a stroke on a layer (`None` when an alpha channel or the /// Quick Mask is targeted). fn parse_brush(s: &Session, p: &Value, cmd: &str) -> Result<(Stroke, Option)> { @@ -83,18 +100,7 @@ fn parse_brush(s: &Session, p: &Value, cmd: &str) -> Result<(Stroke, Option) -> Vec { m } -/// The filled window and the hole it fills, computed on `doc` (a snapshot). -fn run_remove(doc: &mut Document, plan: &Plan, p: &Value, ctx: &crate::jobs::JobCtx, label: &str) -> Result<(Region, Vec)> { +/// The filled window and the hole it fills, computed on `doc` (a snapshot). `None` when the +/// layer's own window is fully transparent: nothing to remove and nothing to fill from. +fn run_remove(doc: &mut Document, plan: &Plan, p: &Value, ctx: &crate::jobs::JobCtx, label: &str) -> Result)>> { let hole = hole(plan, doc.selection.as_ref()); if !hole.iter().any(|m| *m) { return Err(bad(CMD, "the stroke doesn't cover anything that can change (check the selection)")); @@ -121,13 +122,17 @@ fn run_remove(doc: &mut Document, plan: &Plan, p: &Value, ctx: &crate::jobs::Job let img = if plan.all_layers { composite_region(doc, None, SampleLayers::All, win, fmt) } else { Region::read(surf, win) }; (img, fmt) }; + // A no-op without the fill or a history step, as the other retouch tools on an empty layer. + if !plan.all_layers && alpha_index(&fmt).is_some_and(|a| img.data.chunks_exact(img.ch).all(|px| px.get(a).is_none_or(|v| *v <= 0.0))) { + return Ok(None); + } let (w, h) = (win.width() as usize, win.height() as usize); let mut data = ctx .stage(0.0, 1.0, label, |ctl| photocraft_algo::remove::remove_with(w, h, img.ch, &img.data, &hole, SEED, ctl)) .map_err(|_| EngineError::Cancelled)?; // The fill's seam blending can step slightly past the storable range. clamp_samples(&fmt, &mut data); - Ok((Region { rect: win, ch: img.ch, data }, hole)) + Ok(Some((Region { rect: win, ch: img.ch, data }, hole))) } pub(super) fn remove(s: &mut Session, p: &Value) -> Result { @@ -149,7 +154,8 @@ pub(super) fn remove(s: &mut Session, p: &Value) -> Result { run_remove(&mut work, &plan, &p, ctx, label) } }, - move |s, (out, hole)| { + move |s, filled| { + let Some((out, hole)) = filled else { return Ok(json!({"changed": false})) }; let damage = s.edit(label, |doc, _| replace(doc, Target { id, all_layers: false }, &p, &out, &hole))?; if let Some(st) = s.active_mut() { st.last_damage = Some(damage); diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index 6be113770e..6ceb14c10f 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -1430,6 +1430,19 @@ fn remove_open_strokes_and_the_selection_limit_what_changes() { assert!(!near(rgba(&s, 46, 30), bg, 0.05)); } +#[test] +fn remove_on_an_empty_layer_is_a_no_op_without_a_history_step() { + let mut s = square_session(8, "rgb"); + s.execute("layer.new.layer", json!({})).unwrap(); + let past = s.active().unwrap().history.past_len(); + let v = s.execute("paint.remove", json!({"points": [[38, 30], [54, 30]], "size": 16})).unwrap(); + assert_eq!(v, json!({"changed": false})); + assert_eq!(s.active().unwrap().history.past_len(), past); + // With Sample All Layers on, the image below is there to remove from. + s.execute("paint.remove", json!({"points": [[38, 30], [54, 30]], "size": 16, "sampleAllLayers": true})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), past + 1); +} + #[test] fn remove_thin_scratches_take_their_surroundings() { let mut s = session(80, 40, 16, "rgb"); @@ -1461,3 +1474,38 @@ fn remove_bad_params_are_errors() { let err = s.execute("paint.remove", json!({"points": [[48, 32]], "size": 3000})).unwrap_err().to_string(); assert!(err.contains("nothing is left"), "{err}"); } + +/// Flat (150, 120, 100) with Gaussian film grain of σ 12 levels (the #2104 test image). +fn film_grain(x: i32, y: i32) -> [f32; 4] { + let u = |k: u32| { + let mut z = (x as u32).wrapping_mul(0x9E37_79B9) ^ (y as u32).wrapping_mul(0x85EB_CA6B) ^ k.wrapping_mul(0xC2B2_AE35); + z = (z ^ (z >> 16)).wrapping_mul(0x7FEB_352D); + z = (z ^ (z >> 15)).wrapping_mul(0x846C_A68B); + ((z ^ (z >> 16)) as f32 + 1.0) / (u32::MAX as f32 + 2.0) + }; + let n = (-2.0 * u(1).ln()).sqrt() * (std::f32::consts::TAU * u(2)).cos() * 12.0; + [(150.0 + n) / 255.0, (120.0 + n) / 255.0, (100.0 + n) / 255.0, 1.0] +} + +#[test] +fn spot_healing_keeps_film_grain_through_overlapping_strokes() { + // #2104: on flat colour with grain the heal must carry the grain, not leave a smooth patch, + // and must not lose more with each overlapping stroke (each samples the ones before). + let red = |s: &Session, x0: i32, x1: i32, y0: i32, y1: i32| { + let v: Vec = (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).map(|(x, y)| rgba(s, x, y)[0] * 255.0).collect(); + let m = v.iter().sum::() / v.len() as f32; + (m, (v.iter().map(|a| (a - m).powi(2)).sum::() / v.len() as f32).sqrt()) + }; + for (depth, kind) in [(8u64, "contentAware"), (8, "proximityMatch"), (8, "createTexture"), (16, "createTexture")] { + let mut s = session(400, 300, depth, "rgb"); + paint_layer(&mut s, film_grain); + let (m0, sd0) = red(&s, 0, 100, 0, 100); + for i in 0..8 { + let y = 100 + 10 * i; + s.execute("paint.spotHealing", json!({"points": [[140, y], [260, y]], "size": 30, "hardness": 100, "type": kind})).unwrap(); + let (m, sd) = red(&s, 150, 250, y - 4, y + 5); + assert!(sd > 0.8 * sd0 && sd < 1.25 * sd0, "{kind} depth {depth} stroke {}: grain {sd} vs {sd0} around", i + 1); + assert!((m - m0).abs() < 3.0, "{kind} depth {depth} stroke {}: mean {m} vs {m0}", i + 1); + } + } +} diff --git a/crates/engine/src/select_extra_cmds.rs b/crates/engine/src/select_extra_cmds.rs index 9d42cb36c4..e2c9e70591 100644 --- a/crates/engine/src/select_extra_cmds.rs +++ b/crates/engine/src/select_extra_cmds.rs @@ -195,7 +195,7 @@ fn transform_selection(s: &mut Session, p: &Value) -> Result { } let selected = s.edit("Transform Selection", |doc, _| { if let Some(sel) = &doc.selection { - doc.selection = Some(crate::transform_cmds::warp_gray(sel, &h, interp)).filter(|s| !s.content_bounds().is_empty()); + doc.selection = Some(crate::transform_cmds::warp_gray(sel, &h, interp)?).filter(|s| !s.content_bounds().is_empty()); } Ok(doc.selection.is_some()) })?; diff --git a/crates/engine/src/selection_cmds.rs b/crates/engine/src/selection_cmds.rs index d9384ca3d1..948ef07632 100644 --- a/crates/engine/src/selection_cmds.rs +++ b/crates/engine/src/selection_cmds.rs @@ -386,9 +386,10 @@ fn modify(s: &mut Session, p: &Value, op: &str) -> Result { let at_bounds = op == "border" || b(p, "applyAtCanvasBounds", false); let (area, m) = current_mask(&s.active().ok_or(EngineError::NoDocument)?.doc); let (w, h) = (area.width() as usize, area.height() as usize); - // Feather reaches 3σ = 1.5 r; the others reach r. A margin past that keeps the canvas edge out of - // reach. A selection clear of the canvas edge needs none: both readings agree there. - let reach = if op == "feather" { r * 1.5 } else { r }; + // Feather reaches 3σ = 3 r (σ = r, as in Photoshop); the others reach r. A margin past that + // keeps the canvas edge out of reach. A selection clear of the canvas edge needs none: both + // readings agree there. + let reach = if op == "feather" { r * 3.0 } else { r }; let pad = if touches_edge(&m, w, h) { reach.ceil() as usize + 2 } else { 0 }; let grown = |n: usize| pad.checked_mul(2).and_then(|p| n.checked_add(p)); let (Some(pw), Some(ph)) = (grown(w), grown(h)) else { diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 3a0aa93235..6e0c043578 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -13,6 +13,9 @@ //! ──filter mask mix (unfiltered ↔ filtered)───────► cache //! ``` //! +//! The placed stage and the result after each filter are cached too (`stack_cache`), so a +//! re-render starts from the deepest stage it shares with an earlier one. +//! //! Converting a layer embeds it as an in-memory `.pcraft` bundle of a nested document (exactly //! lossless, layered, any depth/model). PSD placed layers keep their source in the preserved //! global `lnk2` block (found by uuid), and keep Photoshop's rendering until something changes, @@ -25,12 +28,14 @@ use std::sync::{Arc, Mutex}; use photocraft_algo::resample::translate_surface; use photocraft_algo::transform::Homography; use photocraft_color::{BlendMode, PixelFormat}; -use photocraft_doc::{DocId, Document, Layer, LayerContent, LayerId, LayerMask, Metadata, SmartContentsId, SmartObject, SmartSource}; +use photocraft_doc::{DocId, Document, Layer, LayerContent, LayerId, LayerMask, Metadata, SmartContentsId, SmartFilter, SmartObject, SmartSource}; use photocraft_geom::{Affine, Rect, Size}; use photocraft_raster::Surface; use serde_json::{Value, json}; +use stack_cache::{Stage, StageKey}; use crate::commands::{CommandSpec, blend_from_str, layer_param}; +use crate::jobs::JobCtx; use crate::{EngineError, Result, Session}; /// An open Edit Contents document and the shared contents it updates when saved or closed. @@ -336,9 +341,7 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> // Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters. let extent = canvas.union(&placed.content_bounds()); for f in sm.smart_filters.iter().filter(|f| f.visible) { - // Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone. - let Some(out) = crate::filters::apply_filter_to_surface(&f.command, &f.params, &cur, canvas, None, extent) else { continue }; - cur = if f.blend == BlendMode::Normal && f.opacity >= 1.0 { out } else { blend_surfaces(&cur, &out, f.blend, f.opacity.clamp(0.0, 1.0)) }; + cur = apply_smart_filter(f, &cur, canvas, extent); } match sm.filter_mask.as_ref().filter(|m| m.enabled) { Some(m) => mask_mix(placed, &cur, m), @@ -346,39 +349,105 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> } } -/// Renders a smart object from its source. `Ok(None)` when the source is unavailable (a missing -/// linked file, or a PSD placed layer without embedded data): callers keep the existing cache. -pub fn render(doc: &Document, sm: &SmartObject) -> Result> { - let Some((name, bytes)) = source_bytes(&doc.metadata, &sm.source) else { return Ok(None) }; +/// One visible smart filter over `cur`, blended with its blend mode and opacity. +fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect) -> Surface { + // Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone. + let Some(out) = crate::filters::apply_filter_to_surface(&f.command, &f.params, cur, canvas, None, extent) else { return cur.clone() }; + if f.blend == BlendMode::Normal && f.opacity >= 1.0 { out } else { blend_surfaces(cur, &out, f.blend, f.opacity.clamp(0.0, 1.0)) } +} + +/// The source placed in document pixels, before any smart filter. +fn place(name: &str, bytes: &[u8], fmt: PixelFormat, sm: &SmartObject) -> Result { // A vector source is rendered at the placement's scale, and the placement is divided by // that scale so the rendered pixels land where the source units would. - if sm.stack_mode.is_none() && sm.warp.is_none() && photocraft_io::svg::is_svg(&bytes) { + if sm.stack_mode.is_none() && sm.warp.is_none() && photocraft_io::svg::is_svg(bytes) { let k = vector_scale(sm); - let img = svg_source_image(&bytes, doc.pixel_format(), k)?; - let placed = match &sm.perspective { + let img = svg_source_image(bytes, fmt, k)?; + return Ok(match &sm.perspective { Some(p) => { let [h0, h1, h2, h3, h4, h5, h6, h7, h8] = *p; let h = Homography([h0 / k, h1 / k, h2, h3 / k, h4 / k, h5, h6 / k, h7 / k, h8]); - photocraft_algo::warp::place_source_projective(&img.surface, img.bounds, &h, None) + photocraft_algo::warp::place_source_projective(&img.surface, img.bounds, &h, None)? } None => { let [a, b, c, d, e, f] = sm.transform.m; let t = Affine { m: [a / k, b / k, c / k, d / k, e, f] }; - photocraft_algo::warp::place_source(&img.surface, img.bounds, &t, None) + photocraft_algo::warp::place_source(&img.surface, img.bounds, &t, None)? } - }; - return Ok(Some(apply_smart_filters(&placed, sm, doc.bounds()))); + }); } let img = match sm.stack_mode { - Some(mode) => stack_image(&name, &bytes, doc.pixel_format(), mode)?, - None => source_image(&name, &bytes, doc.pixel_format())?, + Some(mode) => stack_image(name, bytes, fmt, mode)?, + None => source_image(name, bytes, fmt)?, }; // Through the warp (source space) and the transform in one pass; whole-pixel moves are exact. - let placed = match &sm.perspective { - Some(p) => photocraft_algo::warp::place_source_projective(&img.surface, img.bounds, &Homography(*p), sm.warp.as_ref()), - None => photocraft_algo::warp::place_source(&img.surface, img.bounds, &sm.transform, sm.warp.as_ref()), + Ok(match &sm.perspective { + Some(p) => photocraft_algo::warp::place_source_projective(&img.surface, img.bounds, &Homography(*p), sm.warp.as_ref())?, + None => photocraft_algo::warp::place_source(&img.surface, img.bounds, &sm.transform, sm.warp.as_ref())?, + }) +} + +/// Renders a smart object from its source. `Ok(None)` when the source is unavailable (a missing +/// linked file, or a PSD placed layer without embedded data): callers keep the existing cache. +pub fn render(doc: &Document, sm: &SmartObject) -> Result> { + render_in(doc, sm, &JobCtx::new()) +} + +/// [`render`], reporting progress per smart filter to `ctx` and stopping between filters once it +/// is cancelled. Gives the same pixels as [`apply_smart_filters`] on the placed source, but +/// starts from the deepest stage of the stack already rendered (see [`stack_cache`]). +fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result> { + let Some((name, bytes)) = source_bytes(&doc.metadata, &sm.source) else { return Ok(None) }; + let (fmt, canvas) = (doc.pixel_format(), doc.bounds()); + let placed_key = stack_cache::placed_key(&bytes, fmt, sm, canvas); + let placed = || -> Result { + if let Some(stage) = stack_cache::get(&placed_key) { + return Ok(stage); + } + let surface = place(&name, &bytes, fmt, sm)?; + // Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters. + let stage = Stage { extent: canvas.union(&surface.content_bounds()), surface }; + stack_cache::put(placed_key, &stage); + Ok(stage) }; - Ok(Some(apply_smart_filters(&placed, sm, doc.bounds()))) + let filters: Vec<&SmartFilter> = sm.smart_filters.iter().filter(|f| sm.filters_enabled && f.visible).collect(); + if filters.is_empty() { + return Ok(Some(placed()?.surface)); + } + let keys: Vec = filters + .iter() + .scan(placed_key, |key, f| { + *key = stack_cache::filter_key(key, f); + Some(*key) + }) + .collect(); + let deepest = keys.iter().enumerate().rev().find_map(|(i, key)| stack_cache::get(key).map(|stage| (i + 1, stage))); + let (done, mut cur) = match deepest { + Some(hit) => hit, + None => (0, placed()?), + }; + let n = filters.len() as f32; + for (i, (f, key)) in filters.iter().zip(&keys).enumerate().skip(done) { + ctx.check()?; + ctx.progress(i as f32 / n, ""); + cur = Stage { surface: apply_smart_filter(f, &cur.surface, canvas, cur.extent), extent: cur.extent }; + stack_cache::put(*key, &cur); + } + ctx.progress(1.0, ""); + Ok(Some(match sm.filter_mask.as_ref().filter(|m| m.enabled) { + Some(m) => mask_mix(&placed()?.surface, &cur.surface, m), + None => cur.surface, + })) +} + +/// Bytes held by rendered smart filter stages. +pub fn stack_cache_bytes() -> usize { + stack_cache::bytes() +} + +/// Drop every rendered smart filter stage (Edit › Purge › All). Returns the bytes released. +pub fn purge_stack_cache() -> usize { + stack_cache::purge() } /// The smart object's pixels below smart filter `index` (the input that filter edits): the placed @@ -394,8 +463,12 @@ pub fn render_below_filter(doc: &Document, sm: &SmartObject, index: usize) -> Re /// Re-renders smart layer `l` (which lives in `doc`) in place. Returns false if its source is /// unavailable (the cache is then left alone). pub fn refresh_layer(doc: &Document, l: &mut Layer) -> Result { + refresh_layer_in(doc, l, &JobCtx::new()) +} + +fn refresh_layer_in(doc: &Document, l: &mut Layer, ctx: &JobCtx) -> Result { let LayerContent::Smart(sm) = &mut l.content else { return Ok(false) }; - match render(doc, sm)? { + match render_in(doc, sm, ctx)? { Some(px) => { sm.cache = Some(px); Ok(true) @@ -406,11 +479,15 @@ pub fn refresh_layer(doc: &Document, l: &mut Layer) -> Result { /// Re-renders the smart object `id` of `doc`. pub fn refresh(doc: &mut Document, id: LayerId) -> Result { + refresh_in(doc, id, &JobCtx::new()) +} + +fn refresh_in(doc: &mut Document, id: LayerId, ctx: &JobCtx) -> Result { let mut l = doc.layer(id).cloned().ok_or(EngineError::NoLayer(id))?; if !matches!(l.content, LayerContent::Smart(_)) { return Err(other("the layer is not a smart object")); } - let ok = refresh_layer(doc, &mut l)?; + let ok = refresh_layer_in(doc, &mut l, ctx)?; if ok && let Some(dst) = doc.layer_mut(id) { dst.content = l.content; } @@ -418,7 +495,15 @@ pub fn refresh(doc: &mut Document, id: LayerId) -> Result { } fn refresh_or_fail(doc: &mut Document, id: LayerId) -> Result<()> { - if refresh(doc, id)? { Ok(()) } else { Err(other("the smart object's contents are unavailable (missing linked file?), so it can't be re-rendered")) } + refresh_or_fail_in(doc, id, &JobCtx::new()) +} + +fn refresh_or_fail_in(doc: &mut Document, id: LayerId, ctx: &JobCtx) -> Result<()> { + if refresh_in(doc, id, ctx)? { + Ok(()) + } else { + Err(other("the smart object's contents are unavailable (missing linked file?), so it can't be re-rendered")) + } } fn smart(doc: &Document, id: LayerId) -> Result<&SmartObject> { @@ -581,7 +666,6 @@ fn any_layer(l: &Layer, f: &dyn Fn(&Layer) -> bool) -> bool { /// blend mode, clipping and label stay on the smart layer. The cache is the exact composite, so /// the document looks the same before and after. pub fn layer_to_smart(doc: &Document, l: &Layer) -> Result { - let canvas = doc.bounds(); let background = l.name == "Background" && l.locks.position; let mut inner = l.clone(); inner.visible = true; @@ -594,21 +678,42 @@ pub fn layer_to_smart(doc: &Document, l: &Layer) -> Result { if background { inner.name = "Layer 0".into(); } - let mut sub = Document::new(format!("{}.pcraft", l.name), doc.size, doc.mode, doc.depth); + let mut out = layers_to_smart(doc, &l.name, vec![inner])?; + if background { + out.name = "Layer 0".into(); + } + out.visible = l.visible; + out.opacity = l.opacity; + out.blend = if l.blend == BlendMode::PassThrough { BlendMode::Normal } else { l.blend }; + out.clipped = l.clipped; + out.label = l.label; + if !background { + out.locks = l.locks; + } + Ok(out) +} + +/// Builds a smart-object layer named `name` whose nested document holds `layers` (bottom to top) +/// at its top level, as Photoshop does when several layers are converted: no wrapper group. The +/// nested document is cropped to the rendered bounds and the cache is the exact composite (the +/// same as the layers inside a pass-through group). The smart layer is visible, opaque and Normal. +pub fn layers_to_smart(doc: &Document, name: &str, layers: Vec) -> Result { + let canvas = doc.bounds(); + let mut sub = Document::new(format!("{name}.pcraft"), doc.size, doc.mode, doc.depth); sub.resolution_dpi = doc.resolution_dpi; sub.icc_profile = doc.icc_profile.clone(); sub.global_light = doc.global_light; // Pattern fills and pattern layer effects reference document-level patterns by ID. // Preserve those resources in the embedded document before rendering or saving it. sub.patterns = doc.patterns.clone(); - if any_layer(l, &|x| matches!(x.content, LayerContent::Smart(_))) { + if layers.iter().any(|l| any_layer(l, &|x| matches!(x.content, LayerContent::Smart(_)))) { // Nested PSD placed layers find their embedded files here. sub.metadata.psd_global_blocks = doc.metadata.psd_global_blocks.clone(); } - sub.layers = vec![inner]; + let has_fx = layers.iter().any(|l| any_layer(l, &|x| x.effects.enabled && !x.effects.items.is_empty())); + let region = layers.iter().map(|l| subtree_bounds(l, canvas)).fold(canvas, |a, b| a.union(&b)).inflate(if has_fx { 256 } else { 0 }); + sub.layers = layers; - let has_fx = any_layer(l, &|x| x.effects.enabled && !x.effects.items.is_empty()); - let region = subtree_bounds(l, canvas).union(&canvas).inflate(if has_fx { 256 } else { 0 }); let buf = photocraft_compose::render(&sub, region); let w = region.width() as usize; let mut b = Rect::EMPTY; @@ -626,26 +731,14 @@ pub fn layer_to_smart(doc: &Document, l: &Layer) -> Result { cache = photocraft_algo::resample::crop_surface(&cache, b); cache.prune(); - shift_layer(&mut sub.layers[0], -b.x0, -b.y0); + sub.layers.iter_mut().for_each(|l| shift_layer(l, -b.x0, -b.y0)); sub.size = Size::new(b.width(), b.height()); let bytes = encode_source(&sub)?; // Seed the decode cache with the composite we already have. cache_put(cache_key(&bytes, fmt), SourceImage { surface: Arc::new(translate_surface(&cache, -b.x0, -b.y0)), bounds: sub.bounds() }); let source = SmartSource::Embedded { file_name: sub.name.clone(), bytes: Arc::new(bytes) }; - let mut out = Layer::new( - if background { "Layer 0".to_string() } else { l.name.clone() }, - LayerContent::Smart(SmartObject::new(source, Affine::translate(b.x0 as f64, b.y0 as f64), Some(cache))), - ); - out.visible = l.visible; - out.opacity = l.opacity; - out.blend = if l.blend == BlendMode::PassThrough { BlendMode::Normal } else { l.blend }; - out.clipped = l.clipped; - out.label = l.label; - if !background { - out.locks = l.locks; - } - Ok(out) + Ok(Layer::new(name, LayerContent::Smart(SmartObject::new(source, Affine::translate(b.x0 as f64, b.y0 as f64), Some(cache))))) } fn convert(s: &mut Session, p: &Value) -> Result { @@ -661,7 +754,7 @@ fn convert(s: &mut Session, p: &Value) -> Result { layer_to_smart(doc, l)? } else { let children = ids.iter().map(|id| doc.layer(*id).cloned().ok_or(EngineError::NoLayer(*id))).collect::>>()?; - layer_to_smart(doc, &Layer::group(l.name.clone(), children))? + layers_to_smart(doc, &l.name, children)? }; let new_id = so.id; // As with Group Layers, place the result in the top selected root's parent. Insert @@ -740,16 +833,25 @@ fn set_shared_source(s: &mut Session, id: LayerId, label: &str, keep_psd: bool, fn replace_contents(s: &mut Session, p: &Value) -> Result { let path = path_param("layer.smartObjects.replaceContents", p)?.to_string(); let bytes = photocraft_format::read_file(std::path::Path::new(&path)).map_err(|e| other(format!("can't read {path}: {e}")))?; - let name = base_name(&path); - decode_source(&name, &bytes)?; // fail before touching the document - set_source(s, p, "Replace Contents", false, |_, _| Ok(SmartSource::Embedded { file_name: name, bytes: Arc::new(bytes) })) + let id = layer_param(s, p)?; + replace_contents_bytes(s, id, &base_name(&path), bytes) +} + +/// Replace Contents from a file's name and bytes: what the desktop and web file pickers hand over. +pub fn replace_contents_bytes(s: &mut Session, id: LayerId, file_name: &str, bytes: Vec) -> Result { + decode_source(file_name, &bytes)?; // fail before touching the document + set_shared_source(s, id, "Replace Contents", false, SmartSource::Embedded { file_name: file_name.to_string(), bytes: Arc::new(bytes) }) +} + +/// The file name and bytes of smart object `id`'s contents. +pub fn contents_of(doc: &Document, id: LayerId) -> Result<(String, Arc>)> { + source_bytes(&doc.metadata, &smart(doc, id)?.source).ok_or_else(|| other("the smart object's contents are unavailable")) } fn export_contents(s: &mut Session, p: &Value) -> Result { let path = path_param("layer.smartObjects.exportContents", p)?; let id = layer_param(s, p)?; - let st = s.active().ok_or(EngineError::NoDocument)?; - let (name, bytes) = source_bytes(&st.doc.metadata, &smart(&st.doc, id)?.source).ok_or_else(|| other("the smart object's contents are unavailable"))?; + let (name, bytes) = contents_of(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; crate::file_cmds::write_file(path, &bytes)?; Ok(json!({"path": path, "fileName": name, "bytes": bytes.len()})) } @@ -856,13 +958,23 @@ fn update_all(s: &mut Session) -> Result { // ---------- smart filter editing ---------- +/// Edits the smart filters of a smart object and re-renders it. The edit itself is instant, so its +/// errors (a bad index, say) are reported at once; the re-render runs as a background job when +/// started with `Session::start`, so the window keeps drawing (with progress, cancellable) while +/// a long filter stack recomputes. fn edit_filters(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut SmartObject) -> Result<()>) -> Result { let id = layer_param(s, p)?; - s.edit(label, |doc, _| { - f(smart_mut(doc, id)?)?; - refresh_or_fail(doc, id)?; - Ok(json!({"layer": id.0})) - }) + let mut edited = smart(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?.clone(); + f(&mut edited)?; + crate::jobs::edit_job( + s, + label, + move |doc, _, ctx| { + *smart_mut(doc, id)? = edited; + refresh_or_fail_in(doc, id, ctx) + }, + move |()| json!({"layer": id.0}), + ) } fn filter_index(cmd: &str, p: &Value, sm: &SmartObject) -> Result { @@ -1156,6 +1268,7 @@ pub fn specs() -> Vec { ] } +mod stack_cache; mod unpack; #[cfg(test)] diff --git a/crates/engine/src/smart_cmds/stack_cache.rs b/crates/engine/src/smart_cmds/stack_cache.rs new file mode 100644 index 0000000000..b155e5282c --- /dev/null +++ b/crates/engine/src/smart_cmds/stack_cache.rs @@ -0,0 +1,95 @@ +//! Stages of smart object renders: the placed source and the result after each visible smart +//! filter, keyed by everything that produced them. A re-render starts from the deepest cached +//! stage, so editing filter `i` only reruns the filters from `i` up: hiding or tweaking the top +//! filter of a long stack doesn't rerun the expensive filters under it. + +use std::sync::{Mutex, PoisonError}; + +use photocraft_color::PixelFormat; +use photocraft_doc::{SmartFilter, SmartObject}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +/// blake3 of a stage's inputs: the source contents, the placement and the filters so far. +pub(super) type StageKey = [u8; 32]; + +#[derive(Clone)] +pub(super) struct Stage { + pub surface: Surface, + /// Where filters repeat edge pixels: the canvas ∪ the placed content. + pub extent: Rect, +} + +struct Entry { + key: StageKey, + stage: Stage, + bytes: usize, +} + +/// Most recently used last. +static STAGES: Mutex> = Mutex::new(Vec::new()); +const ENTRIES: usize = 64; +/// Accounted tile bytes; a stage larger than this on its own isn't kept. +pub const BUDGET: usize = 768 << 20; + +/// The first stage: what placing `bytes` (the source contents) through the smart object's +/// transform, warp or perspective gives at pixel format `fmt` on `canvas`. +pub(super) fn placed_key(bytes: &[u8], fmt: PixelFormat, sm: &SmartObject, canvas: Rect) -> StageKey { + let mut h = blake3::Hasher::new(); + h.update(bytes); + for v in sm.transform.m { + h.update(&v.to_bits().to_le_bytes()); + } + h.update(format!("{fmt:?}|{:?}|{:?}|{:?}|{canvas:?}", sm.warp, sm.perspective, sm.stack_mode).as_bytes()); + *h.finalize().as_bytes() +} + +/// The stage after applying `f` to stage `below`. +pub(super) fn filter_key(below: &StageKey, f: &SmartFilter) -> StageKey { + let mut h = blake3::Hasher::new(); + h.update(below); + h.update(format!("{}\0{}\0{:?}\0", f.command, f.params, f.blend).as_bytes()); + h.update(&f.opacity.to_bits().to_le_bytes()); + *h.finalize().as_bytes() +} + +pub(super) fn get(key: &StageKey) -> Option { + let mut c = STAGES.lock().unwrap_or_else(PoisonError::into_inner); + let i = c.iter().position(|e| e.key == *key)?; + let e = c.remove(i); + let stage = e.stage.clone(); + c.push(e); + Some(stage) +} + +pub(super) fn put(key: StageKey, stage: &Stage) { + let bytes = surface_bytes(&stage.surface); + if bytes > BUDGET { + return; + } + let mut c = STAGES.lock().unwrap_or_else(PoisonError::into_inner); + c.retain(|e| e.key != key); + c.push(Entry { key, stage: stage.clone(), bytes }); + while c.len() > ENTRIES || c.iter().map(|e| e.bytes).sum::() > BUDGET { + c.remove(0); + } +} + +/// Bytes held by cached stages. +pub fn bytes() -> usize { + STAGES.lock().unwrap_or_else(PoisonError::into_inner).iter().map(|e| e.bytes).sum() +} + +/// Drop every cached stage (Edit › Purge › All). Returns the bytes released. +pub fn purge() -> usize { + let mut c = STAGES.lock().unwrap_or_else(PoisonError::into_inner); + let freed = c.iter().map(|e| e.bytes).sum(); + c.clear(); + freed +} + +fn surface_bytes(s: &Surface) -> usize { + // Tiles shared with the document (or another stage) are charged anyway: the cache is what + // keeps them alive once the document moves on. + s.tiles().fold(s.default_bytes().len(), |n, (_, t)| n.saturating_add(t.bytes().len())) +} diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index 726404f2eb..2ee54322bf 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -1,6 +1,7 @@ use super::*; mod selection; +mod stack_cache; const DEPTHS: [u64; 3] = [8, 16, 32]; const W: i32 = 64; diff --git a/crates/engine/src/smart_cmds/tests/selection.rs b/crates/engine/src/smart_cmds/tests/selection.rs index 3cbfc0ede7..d992d3aad1 100644 --- a/crates/engine/src/smart_cmds/tests/selection.rs +++ b/crates/engine/src/smart_cmds/tests/selection.rs @@ -70,8 +70,9 @@ fn selected_pixels_become_one_editable_smart_object_in_every_mode_and_depth() { assert_eq!((inner.mode, inner.depth), (mode, depth)); assert_eq!(inner.icc_profile, original.icc_profile); assert_eq!(inner.resolution_dpi, original.resolution_dpi); - let group = &inner.layers[0]; - assert_eq!(group.children().unwrap().iter().map(|l| l.id).collect::>(), ids); + // Like Photoshop, the contents hold the selected layers at their top level, in stacking + // order, with no wrapper group. + assert_eq!(inner.layers.iter().map(|l| l.id).collect::>(), ids, "{mode:?} {depth:?}"); let sm = active_smart(&s); for source in &original.layers { let mut shifted = source.clone(); @@ -104,7 +105,7 @@ fn selected_group_and_descendant_are_embedded_once() { assert_eq!(s.active().unwrap().doc.layers.len(), 1); let inner = contents(&s); assert_eq!(inner.walk().iter().filter(|(_, _, l)| l.id == a_id).count(), 1); - assert_eq!(inner.layers[0].children().unwrap().iter().map(|l| l.id).collect::>(), vec![group_id, b_id]); + assert_eq!(inner.layers.iter().map(|l| l.id).collect::>(), vec![group_id, b_id], "a selected group stays a group"); assert_eq!(inner.layer(group_id).unwrap().children().unwrap()[0].id, a_id); } diff --git a/crates/engine/src/smart_cmds/tests/stack_cache.rs b/crates/engine/src/smart_cmds/tests/stack_cache.rs new file mode 100644 index 0000000000..7675d71843 --- /dev/null +++ b/crates/engine/src/smart_cmds/tests/stack_cache.rs @@ -0,0 +1,64 @@ +use super::*; + +/// A smart object with three smart filters, the middle one blended at half opacity. +fn filtered(depth: u64) -> Session { + let mut s = session(depth); + paint(&mut s); + s.execute("filter.convertForSmartFilters", json!({})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap(); + s.execute("filter.noise.addNoise", json!({"amount": 20, "distribution": "gaussian", "seed": 5})).unwrap(); + s.execute("filter.blur.motionBlur", json!({"angle": 30, "distance": 6})).unwrap(); + s.execute("layer.smartFilter.blendingOptions", json!({"index": 1, "blend": "multiply", "opacity": 0.5})).unwrap(); + s +} + +/// The active smart object rendered without the stage cache. +fn uncached(s: &Session) -> Surface { + let doc = &s.active().unwrap().doc; + let sm = active_smart(s); + let (name, bytes) = source_bytes(&doc.metadata, &sm.source).unwrap(); + apply_smart_filters(&place(&name, &bytes, doc.pixel_format(), &sm).unwrap(), &sm, doc.bounds()) +} + +#[test] +fn renders_from_cached_stages_match_full_renders_at_every_depth() { + for depth in DEPTHS { + let mut s = filtered(depth); + let edits = [ + ("layer.smartFilter.setVisible", json!({"index": 2, "visible": false})), + ("layer.smartFilter.setVisible", json!({"index": 2, "visible": true})), + ("layer.smartFilter.setVisible", json!({"index": 0, "visible": false})), + ("layer.smartFilter.setVisible", json!({"index": 0, "visible": true})), + ("layer.smartFilter.setParams", json!({"index": 2, "params": {"distance": 9}})), + ("layer.smartFilter.blendingOptions", json!({"index": 1, "opacity": 0.8})), + ("layer.smartFilter.move", json!({"index": 0, "to": 2})), + ("layer.smartFilter.disableSmartFilters", json!({"enabled": false})), + ("layer.smartFilter.disableSmartFilters", json!({"enabled": true})), + ("layer.smartFilter.delete", json!({"index": 1})), + ]; + for (id, params) in edits { + s.execute(id, params.clone()).unwrap(); + assert_eq!(active_smart(&s).cache.unwrap(), uncached(&s), "{id} {params} at {depth}-bit"); + } + } +} + +#[test] +fn a_cancelled_render_stops_before_an_uncached_filter() { + let s = filtered(8); + let mut sm = active_smart(&s); + // A radius no other render uses, so the stack below it is cached but this stage isn't. + sm.smart_filters[0].params["radius"] = json!(2.375); + let ctx = JobCtx::new(); + ctx.cancel(); + assert!(matches!(render_in(&s.active().unwrap().doc, &sm, &ctx), Err(EngineError::Cancelled))); +} + +#[test] +fn bad_smart_filter_edits_fail_at_once_with_start() { + let mut s = filtered(8); + let before = active_smart(&s); + let r = s.start("layer.smartFilter.setVisible", json!({"index": 9})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}"); + assert_eq!(active_smart(&s), before); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3827ad9a3e..cb997bf29b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -629,6 +629,24 @@ fn fill_layers_and_colors() { assert!(l < r); } +#[test] +fn default_colors_with_mask_targeted_resets_to_mask_colors() { + let mut s = Session::new(); + s.tools.foreground = [0.2, 0.4, 0.6, 1.0]; + s.tools.background = [0.8, 0.6, 0.4, 1.0]; + s.tools.target_mask(true); + s.execute("tools.setColors", json!({"foreground": "#112233", "background": "#445566"})).unwrap(); + s.execute("tools.defaultColors", json!({})).unwrap(); + assert_eq!(s.tools.foreground, [1.0, 1.0, 1.0, 1.0]); + assert_eq!(s.tools.background, [0.0, 0.0, 0.0, 1.0]); + s.tools.target_mask(false); + assert_eq!(s.tools.foreground, [0.2, 0.4, 0.6, 1.0]); + assert_eq!(s.tools.background, [0.8, 0.6, 0.4, 1.0]); + s.execute("tools.defaultColors", json!({})).unwrap(); + assert_eq!(s.tools.foreground, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(s.tools.background, [1.0, 1.0, 1.0, 1.0]); +} + #[test] fn fill_layer_pixel_rewrites_refresh_the_effect_maps() { // A pixel-only rewrite of a fill layer's cache (what Image › Transform and friends do) diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index b2ad24a565..88939fc7cd 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -18,7 +18,15 @@ fn bad(msg: impl Into) -> EngineError { EngineError::BadParams { cmd: "edit.transform".into(), msg: msg.into() } } -/// Document-space bounds a transform of `layer` starts from (what Free Transform frames). +/// Layers a transform moves as a whole, even with a selection: groups, and the kinds that keep the +/// transform instead of pixels (type, shapes, smart objects). Only pixel layers move just the +/// selected pixels. +pub fn moves_whole(layer: &Layer) -> bool { + matches!(layer.content, LayerContent::Group(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_)) +} + +/// Document-space bounds a transform of `layer` starts from (what Free Transform frames): the +/// content within the selection, or all of it for a layer that [`moves_whole`] (#2630). /// Content scans are cached per tile: snapping asks for every layer's bounds per Move drag. pub fn transform_bounds(doc: &Document, layer: &Layer) -> Rect { let content = match &layer.content { @@ -28,50 +36,60 @@ pub fn transform_bounds(doc: &Document, layer: &Layer) -> Rect { let content = if content.is_empty() { layer.mask.as_ref().map_or(Rect::EMPTY, |m| photocraft_compose::bounds::content_bounds(&m.surface)) } else { content }; match &doc.selection { - Some(sel) if !layer.is_group() => content.intersect(&sel.content_bounds()), + Some(sel) if !moves_whole(layer) => content.intersect(&sel.content_bounds()), _ => content, } } /// Warp a surface whose pixels outside the content read as `default` (masks, selections): /// warp the content with alpha, then flatten back onto the default value. -pub(crate) fn warp_gray(s: &Surface, h: &Homography, interp: Interp) -> Surface { +pub(crate) fn warp_gray(s: &Surface, h: &Homography, interp: Interp) -> Result { let default = s.default_pixel().first().copied().unwrap_or(0.0); let fmt = s.format(); let src = s.content_bounds(); let mut out = Surface::with_default(fmt, &[default]); if src.is_empty() { - return out; + return Ok(out); } // Content-only copy (default 0 + alpha) so the "outside" is transparent during the warp. let with_alpha = PixelFormat::new(fmt.mode, fmt.sample, true); let mut tmp = Surface::new(with_alpha); - let v = s.read_region(src); - tmp.write_region(src, &v.iter().flat_map(|g| [*g, 1.0]).collect::>()); - let w = warp_surface(&tmp, src, h, interp); + let v = crate::allocation::read_region(s, src, "transforming a mask or channel")?; + let mut lifted = + crate::allocation::filled(v.len().checked_mul(2).ok_or_else(|| bad("the mask is too large to transform"))?, 0.0, "transforming a mask or channel")?; + let (lifted_pairs, _) = lifted.as_chunks_mut::<2>(); + for (g, px) in v.iter().zip(lifted_pairs) { + px.copy_from_slice(&[*g, 1.0]); + } + tmp.try_write_region(src, &lifted)?; + let w = warp_surface(&tmp, src, h, interp)?; // Clear the old content region, then composite the warped content over it. These are written as // two sparse regions rather than one dense `src ∪ warped` block: a transform that moves the // content far away (e.g. a huge translation) would otherwise allocate a buffer spanning both and // hang/OOM. `Surface` is tile-sparse, so distant regions cost only their own tiles. - let old: Vec = vec![default; src.width() as usize * src.height() as usize]; - out.write_region(src, &old); + let old_len = (src.width() as usize).checked_mul(src.height() as usize).ok_or_else(|| bad("the mask is too large to transform"))?; + let old = crate::allocation::filled(old_len, default, "transforming a mask or channel")?; + out.try_write_region(src, &old)?; let b = w.content_bounds(); if !b.is_empty() { - let px = w.read_region(b); - let flat: Vec = px.as_chunks::<2>().0.iter().map(|p| p[0] * p[1] + default * (1.0 - p[1])).collect(); - out.write_region(b, &flat); + let px = crate::allocation::read_region(&w, b, "flattening a transformed mask or channel")?; + let mut flat = crate::allocation::filled(px.len() / 2, 0.0, "flattening a transformed mask or channel")?; + for (p, f) in px.as_chunks::<2>().0.iter().zip(&mut flat) { + *f = p[0] * p[1] + default * (1.0 - p[1]); + } + out.try_write_region(b, &flat)?; } out.prune(); - out + Ok(out) } /// Split a single-channel surface whose outside reads as its default (a mask, a channel) by a /// selection: (the selected values as grey + alpha = selection, the surface with the selected /// values replaced by the default). `None` for surfaces that aren't a single grey channel. -pub fn split_gray_selected(s: &Surface, sel: &Surface) -> Option<(Surface, Surface)> { +pub fn split_gray_selected(s: &Surface, sel: &Surface) -> Result> { let fmt = s.format(); if fmt.alpha || fmt.channels() != 1 { - return None; + return Ok(None); } let default = s.default_pixel().first().copied().unwrap_or(0.0); let mut rest = s.clone(); @@ -80,43 +98,49 @@ pub fn split_gray_selected(s: &Surface, sel: &Surface) -> Option<(Surface, Surfa // the moved region stays aligned with the moved pixels. let area = sel.content_bounds(); if area.is_empty() { - return Some((lifted, rest)); + return Ok(Some((lifted, rest))); } - let v = s.read_region(area); + let v = crate::allocation::read_region(s, area, "transforming a selected mask or channel")?; let w = area.width() as usize; - let mut lp = Vec::with_capacity(v.len() * 2); - let mut rp = Vec::with_capacity(v.len()); + let mut lp = crate::allocation::capacity( + v.len().checked_mul(2).ok_or_else(|| bad("the selected mask is too large to transform"))?, + "transforming a selected mask or channel", + )?; + let mut rp = crate::allocation::capacity(v.len(), "transforming a selected mask or channel")?; for (i, g) in v.iter().enumerate() { let (x, y) = (area.x0 + (i % w) as i32, area.y0 + (i / w) as i32); let k = sel.sample_channel(x, y, 0); lp.extend([*g, k]); rp.push(g * (1.0 - k) + default * k); } - lifted.write_region(area, &lp); + lifted.try_write_region(area, &lp)?; lifted.prune(); - rest.write_region(area, &rp); + rest.try_write_region(area, &rp)?; rest.prune(); - Some((lifted, rest)) + Ok(Some((lifted, rest))) } /// [`warp_gray`] limited to a selection: only the selected values move (the vacated area reads as /// the default), as the selected pixels of the layer do. -pub(crate) fn warp_gray_selected(s: &Surface, sel: &Surface, h: &Homography, interp: Interp) -> Surface { - let Some((lifted, mut out)) = split_gray_selected(s, sel) else { return warp_gray(s, h, interp) }; +pub(crate) fn warp_gray_selected(s: &Surface, sel: &Surface, h: &Homography, interp: Interp) -> Result { + let Some((lifted, mut out)) = split_gray_selected(s, sel)? else { return warp_gray(s, h, interp) }; let src = lifted.content_bounds(); if src.is_empty() { - return out; + return Ok(out); } - let w = warp_surface(&lifted, src, h, interp); + let w = warp_surface(&lifted, src, h, interp)?; let b = w.content_bounds(); if !b.is_empty() { - let moved = w.read_region(b); - let under = out.read_region(b); - let flat: Vec = moved.as_chunks::<2>().0.iter().zip(&under).map(|(p, u)| p[0] * p[1] + u * (1.0 - p[1])).collect(); - out.write_region(b, &flat); + let moved = crate::allocation::read_region(&w, b, "compositing transformed mask pixels")?; + let under = crate::allocation::read_region(&out, b, "compositing transformed mask pixels")?; + let mut flat = crate::allocation::filled(under.len(), 0.0, "compositing transformed mask pixels")?; + for (i, (p, u)) in moved.as_chunks::<2>().0.iter().zip(&under).enumerate() { + flat[i] = p[0] * p[1] + u * (1.0 - p[1]); + } + out.try_write_region(b, &flat)?; } out.prune(); - out + Ok(out) } /// The surface Free Transform moves by itself when the params target one (`"target"`): an @@ -165,21 +189,52 @@ pub(crate) fn group_locks(doc: &Document, id: LayerId) -> Locks { doc.path_of(id).and_then(|p| Some(doc.locks_at(p.split_last()?.1))).unwrap_or_default() } +/// The locks a transform of `l` meets: its own and `group`'s (those it inherits). The Background's +/// position lock doesn't count: Photoshop turns it into a normal layer first ([`transform_layer`]). +fn transform_locks(l: &Layer, group: Locks) -> Locks { + let mut own = l.locks; + if own.position && l.name == "Background" { + own.position = false; + own.transparency = false; + } + own.union(group) +} + +/// Would a transform of the layer `id` be refused because it, a group around it, or (for a group) +/// a layer inside it is locked in place? The same rule [`transform_layer`] applies at commit, so +/// Free Transform can refuse before its box opens, as Photoshop does. +pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool { + let Some(l) = doc.layer(id) else { return false }; + // An explicit stack rather than recursion: documents can nest deeply. + let mut stack = vec![(l, group_locks(doc, id))]; + while let Some((l, group)) = stack.pop() { + let locks = transform_locks(l, group); + if locks.position || locks.all { + return true; + } + if let LayerContent::Group(g) = &l.content { + stack.extend(g.children.iter().map(|c| (c, locks))); + } + } + false +} + /// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]). pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option, interp: Interp) -> Result<()> { + crate::allocation::checkpoint("transforming pixels")?; + let locks = transform_locks(l, group); // Photoshop turns the Background into a normal layer before transforming it. if l.locks.position && l.name == "Background" { l.locks.position = false; l.locks.transparency = false; l.name = "Layer 0".into(); } - let locks = l.locks.union(group); if locks.position || locks.all { return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); } // With a selection only the selected pixels move, and so only the same region of a linked // mask (#205). Groups, type, shapes and smart objects move whole. - let mask_sel = doc_sel.filter(|_| !matches!(l.content, LayerContent::Group(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_))); + let mask_sel = doc_sel.filter(|_| !moves_whole(l)); match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { @@ -209,10 +264,10 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut L // Fallback appearance for sources that can't be re-rendered. if let Some(c) = &mut sm.cache { let src = c.content_bounds(); - *c = warp_surface(c, src, h, interp); + *c = warp_surface(c, src, h, interp)?; } if let Some(m) = &mut sm.filter_mask { - m.surface = warp_gray(&m.surface, h, interp); + m.surface = warp_gray(&m.surface, h, interp)?; } } _ => { @@ -221,13 +276,13 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut L *surf = match doc_sel { // Only the selected pixels move: lift them, clear them, warp and paste back. Some(sel) => { - let (lifted, mut rest) = split_selected(surf, sel); - let moved = warp_surface(&lifted, src, h, interp); + let (lifted, mut rest) = split_selected(surf, sel)?; + let moved = warp_surface(&lifted, src, h, interp)?; composite_over(&mut rest, &moved); rest.prune(); rest } - None => warp_surface(surf, src, h, interp), + None => warp_surface(surf, src, h, interp)?, }; } } @@ -236,8 +291,8 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut L && m.linked { m.surface = match mask_sel { - Some(sel) => warp_gray_selected(&m.surface, sel, h, interp), - None => warp_gray(&m.surface, h, interp), + Some(sel) => warp_gray_selected(&m.surface, sel, h, interp)?, + None => warp_gray(&m.surface, h, interp)?, }; } if let Some(vm) = l.vector_mask.as_mut() @@ -250,18 +305,19 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut L } /// Split a layer surface by a selection: (selected pixels, everything else), both with alpha. -pub fn split_selected(surf: &Surface, sel: &Surface) -> (Surface, Surface) { +pub fn split_selected(surf: &Surface, sel: &Surface) -> Result<(Surface, Surface)> { let fmt = surf.format(); let with_alpha = PixelFormat::new(fmt.mode, fmt.sample, true); let mut lifted = Surface::new(with_alpha); - let mut rest = surf.convert(with_alpha); + let mut rest = surf.try_convert(with_alpha)?; let src = surf.content_bounds(); if src.is_empty() { - return (lifted, rest); + return Ok((lifted, rest)); } let n = with_alpha.channels(); - let px = rest.read_region(src); - let mut lp = px.clone(); + let px = crate::allocation::read_region(&rest, src, "transforming selected pixels")?; + let mut lp = crate::allocation::filled(px.len(), 0.0, "transforming selected pixels")?; + lp.copy_from_slice(&px); let mut rp = px; let w = src.width() as usize; // A pixel the selection fully lifts out is reset to the surface's (transparent) default; @@ -280,10 +336,10 @@ pub fn split_selected(surf: &Surface, sel: &Surface) -> (Surface, Surface) { r.iter_mut().zip(&rest_clear).for_each(|(d, c)| *d = *c); } } - lifted.write_region(src, &lp); + lifted.try_write_region(src, &lp)?; lifted.prune(); - rest.write_region(src, &rp); - (lifted, rest) + rest.try_write_region(src, &rp)?; + Ok((lifted, rest)) } pub(crate) fn refresh_text(doc: &Document, l: &mut Layer) { @@ -390,11 +446,11 @@ fn transform(s: &mut Session, p: &Value) -> Result { // A targeted unlinked mask, alpha channel or Quick Mask transforms by itself. let (surf, _) = crate::channel_cmds::target_surface(doc, id, p)?; *surf = match &sel { - Some(sel) => warp_gray_selected(surf, sel, &h, interp), - None => warp_gray(surf, &h, interp), + Some(sel) => warp_gray_selected(surf, sel, &h, interp)?, + None => warp_gray(surf, &h, interp)?, }; if let Some(sel) = &doc.selection { - doc.selection = Some(warp_gray(sel, &h, Interp::Bilinear)).filter(|s| !s.content_bounds().is_empty()); + doc.selection = Some(warp_gray(sel, &h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty()); } return Ok(()); } @@ -410,7 +466,7 @@ fn transform(s: &mut Session, p: &Value) -> Result { } // The selection outline moves with the pixels. if let Some(sel) = &doc.selection { - doc.selection = Some(warp_gray(sel, &h, Interp::Bilinear)).filter(|s| !s.content_bounds().is_empty()); + doc.selection = Some(warp_gray(sel, &h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty()); } Ok(()) })?; @@ -487,6 +543,34 @@ mod tests { assert_eq!(l.surface().unwrap().pixel(30, 30)[3], 0.0, "revealed area is transparent"); } + /// `locked_for_transform` (what Free Transform asks before its box opens, #2585) agrees with + /// what `edit.transform` refuses at commit: a position lock, its own or a group's; never the + /// Background's (it becomes Layer 0). + #[test] + fn locked_for_transform_matches_the_commit() { + let quad = json!({"quad": [[10, 10], [50, 10], [50, 30], [10, 30]]}); + let lock = |s: &mut Session, id: LayerId, on: bool| { + s.edit("lock", |doc, _| { + doc.layer_mut(id).unwrap().locks.position = on; + Ok(()) + }) + .unwrap(); + }; + let mut s = session(); + let layer = s.active().unwrap().active_layer.unwrap(); + lock(&mut s, layer, true); + assert!(locked_for_transform(&s.active().unwrap().doc, layer)); + assert!(s.execute("edit.transform", quad.clone()).is_err()); + lock(&mut s, layer, false); + let group = LayerId(s.execute("layer.groupLayers", json!({"layer": layer.0})).unwrap()["layer"].as_u64().unwrap()); + lock(&mut s, group, true); + s.execute("layer.select", json!({"layer": layer.0})).unwrap(); + assert!(locked_for_transform(&s.active().unwrap().doc, layer), "the group's lock reaches the layer"); + assert!(s.execute("edit.transform", quad.clone()).is_err()); + let bg = s.active().unwrap().doc.layers[0].id; + assert!(!locked_for_transform(&s.active().unwrap().doc, bg), "the Background transforms"); + } + #[test] fn with_selection_only_selected_pixels_move() { let mut s = session(); @@ -690,6 +774,34 @@ mod tests { let s = Surface::new(PixelFormat::RGBA8); let mut sel = Surface::new(PixelFormat::GRAY8); sel.fill_rect(Rect::new(0, 0, 4, 4), &[1.0]); - assert!(split_gray_selected(&s, &sel).is_none()); + assert!(matches!(split_gray_selected(&s, &sel), Ok(None))); + } + + /// #2630: type can't be partly transformed, so a selection doesn't narrow the frame: Free + /// Transform (and the frame-based presets) act on the whole type layer. + #[test] + fn a_selection_does_not_narrow_a_type_layers_frame() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 120})).unwrap(); + s.execute("type.create", json!({"x": 20, "y": 60, "text": "Hello", "size": 36})).unwrap(); + let whole = active_bounds(&s); + s.execute("select.rect", json!({"x": whole.x0, "y": whole.y0, "width": whole.width() / 2, "height": whole.height()})).unwrap(); + let st = s.active().unwrap(); + let l = st.doc.layer(st.active_layer.unwrap()).unwrap(); + assert_eq!(transform_bounds(&st.doc, l), whole, "the frame is the whole type layer"); + // Corners of the frame moved 10 px right: a pure translation of the whole text. + let q = [[whole.x0 + 10, whole.y0], [whole.x1 + 10, whole.y0], [whole.x1 + 10, whole.y1], [whole.x0 + 10, whole.y1]]; + s.execute("edit.transform", json!({"quad": q})).unwrap(); + let moved = active_bounds(&s); + assert!(moved.x0.abs_diff(whole.x0 + 10) <= 1 && moved.width().abs_diff(whole.width()) <= 1, "{whole:?} -> {moved:?}"); + // Rotate 180° turns it about its own centre, not the selection's. + s.execute("edit.transform.rotate180", json!({})).unwrap(); + let turned = active_bounds(&s); + assert!(turned.x0.abs_diff(moved.x0) <= 2 && turned.x1.abs_diff(moved.x1) <= 2, "{moved:?} -> {turned:?}"); + // A pixel layer still frames only the selected pixels. + let mut s = session(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 5, "height": 10})).unwrap(); + let st = s.active().unwrap(); + assert_eq!(transform_bounds(&st.doc, st.doc.layer(st.active_layer.unwrap()).unwrap()), Rect::new(10, 10, 15, 20)); } } diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index 1ca11526a7..022146f113 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -554,7 +554,7 @@ pub fn specs() -> Vec { "type.warpText", "Warp Text…", &["Type"], - r##"{"layer":id? or "layers":[id,…]? (default selected Type layers),"style":"none|arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist"="arc","bend":-100..100=50,"horizontalDistortion":-100..100=0,"verticalDistortion":-100..100=0,"orientation":"horizontal|vertical"="horizontal"}"##, + r##"{"layer":id? or "layers":[id,…]? (default selected Type layers),"style":"none|arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist"="arc","orientation":"horizontal|vertical"="horizontal","bend":-100..100=50,"horizontalDistortion":-100..100=0,"verticalDistortion":-100..100=0}"##, has_format_target, warp_text ), diff --git a/crates/engine/src/variables_cmds.rs b/crates/engine/src/variables_cmds.rs index cbc3737dee..5078610908 100644 --- a/crates/engine/src/variables_cmds.rs +++ b/crates/engine/src/variables_cmds.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use photocraft_algo::resample::{Resample, resize_surface, translate_surface}; +use photocraft_doc::text::{ParagraphRun, TextRun}; use photocraft_doc::variables::Value as VarValue; use photocraft_doc::{DataSet, DataValue, Document, LayerContent, LayerId, PixelAlign, PixelMethod, VarKind, VariableDef, Variables}; use serde_json::{Value, json}; @@ -62,7 +63,7 @@ fn kind_json(k: &VarKind) -> Value { let align = a[..1].to_lowercase() + &a[1..]; json!({ "type": "pixelReplacement", - "method": format!("{method:?}").to_lowercase(), + "method": method, "align": align, "clip": clip, }) @@ -168,9 +169,15 @@ fn apply_to_doc(doc: &mut Document, vars: &Variables, set: &DataSet) -> Result<( if let Some(l) = doc.layer_mut(def.layer) && let LayerContent::Text(t) = &mut l.content { + // As in Photoshop, the new text takes the style of the first character it + // replaces (and its paragraph's settings): clearing the runs fell back to the + // summary style and lost weight, italic, tracking, caps… (#2742). + let style = t.char_runs().into_iter().next().map(|r| r.style).unwrap_or_default(); + let para = t.paragraph_runs().into_iter().next().map(|r| r.style).unwrap_or_default(); t.text = text.clone(); - t.runs.clear(); // re-flow as one run from the summary style - t.paragraphs.clear(); + t.runs = vec![TextRun { len: t.text.len(), style }]; + t.paragraphs = vec![ParagraphRun { len: t.text.len(), style: para }]; + t.sync_summary(); // The compositor draws a type layer from its cache: clearing it left the // layer blank until some other edit re-rendered it (#990). crate::type_cmds::refresh(&snapshot, t); @@ -363,7 +370,6 @@ fn split_csv(line: &str, delim: u8) -> Vec { fn export_as_files(s: &mut Session, p: &Value) -> Result { let dir = p.get("dir").and_then(Value::as_str).ok_or_else(|| bad("file.export.dataSetsAsFiles", "need `dir`"))?; let format = p.get("format").and_then(Value::as_str).unwrap_or("png"); - std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("mkdir `{dir}`: {e}")))?; let st = s.active().ok_or(EngineError::NoDocument)?; let original = st.doc.clone(); let vars = original.variables.clone(); @@ -375,17 +381,29 @@ fn export_as_files(s: &mut Session, p: &Value) -> Result { // Filename template: `{name}` (sanitised data-set name), `{index}` (1-based). Default `{name}`. let template = p.get("naming").and_then(Value::as_str).unwrap_or("{name}"); let doc_stem = original.name.rsplit_once('.').map_or(original.name.as_str(), |(a, _)| a).to_string(); + // Plan the whole batch before writing: sanitised names or a constant template may collide. + // Match the case-insensitive comparison used by the other batch exporters. + let mut claimed = std::collections::HashMap::new(); let mut files = Vec::new(); for (n, set) in sets.iter().enumerate() { - let mut doc = (*original).clone(); - apply_to_doc(&mut doc, &vars, set)?; let safe: String = set.name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect(); let stem = template.replace("{name}", &safe).replace("{index}", &format!("{:03}", n + 1)).replace("{document}", &doc_stem); let path = format!("{dir}/{stem}.{format}"); + if let Some(first) = claimed.insert(path.to_lowercase(), &set.name) { + return Err(bad( + "file.export.dataSetsAsFiles", + format!("data sets `{first}` and `{}` resolve to the same output `{path}`; include {{index}} in `naming` to use distinct filenames", set.name), + )); + } + files.push(path); + } + std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("mkdir `{dir}`: {e}")))?; + for (set, path) in sets.iter().zip(&files) { + let mut doc = (*original).clone(); + apply_to_doc(&mut doc, &vars, set)?; let opts = photocraft_io::ExportOptions::default(); let bytes = photocraft_io::export(&doc, format, &opts).map(|r| r.bytes).map_err(|e| EngineError::Other(format!("export `{}`: {e}", set.name)))?; - crate::file_cmds::write_file(&path, &bytes)?; - files.push(path); + crate::file_cmds::write_file(path, &bytes)?; } Ok(json!({"files": files, "count": files.len()})) } @@ -446,7 +464,7 @@ pub fn specs() -> Vec { "file.export.dataSetsAsFiles", "Data Sets as Files…", &["File", "Export"], - "{dir, format?:png, dataSets?[names], naming?:\"{name}|{index}|{document}\"} → {files,count}: apply each data set and export the flattened document", + "{dir, format?:png, dataSets?[names], naming?:\"{name}|{index}|{document}\"} → {files,count}: apply each data set and export the flattened document; colliding filenames are rejected before writing (include {index} to disambiguate)", |s, p| export_as_files(s, p) ), CommandSpec { diff --git a/crates/engine/src/variables_cmds/tests.rs b/crates/engine/src/variables_cmds/tests.rs index ed14f8acfc..28fc14a36f 100644 --- a/crates/engine/src/variables_cmds/tests.rs +++ b/crates/engine/src/variables_cmds/tests.rs @@ -83,6 +83,25 @@ fn define_apply_visibility_and_text() { assert_eq!(text_of(&s, title), "Old"); } +#[test] +fn listed_pixel_methods_can_be_used_to_redefine_variables() { + for method in ["fit", "fill", "asIs", "conform"] { + let (mut s, photo, _badge, _title) = session(); + let defined = s + .execute( + "image.variables.define", + json!({"defs": [{"name": "photo", "layer": photo.0, "type": "pixelReplacement", "method": method, "align": "bottomRight", "clip": true}]}), + ) + .unwrap(); + let expected = doc(&s).variables.defs.clone(); + let listed = s.execute("variables.list", json!({})).unwrap(); + s.execute("image.variables.define", json!({"defs": listed["defs"]})).unwrap(); + assert_eq!(doc(&s).variables.defs, expected, "{method}"); + assert_eq!(defined["defs"][0]["method"], method); + assert_eq!(listed["defs"][0]["method"], method); + } +} + #[test] fn bad_params_are_errors() { let (mut s, _p, badge, _t) = session(); @@ -321,6 +340,88 @@ fn export_data_sets_as_files() { assert_eq!(text_of(&s, title), "Old"); } +fn visibility_export_session(names: &[&str]) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "white", "name": "card"})).unwrap(); + let layer = doc(&s).layers[0].id; + s.execute("image.variables.define", json!({"defs": [{"name": "show", "layer": layer.0, "type": "visibility"}]})).unwrap(); + let sets: Vec<_> = + names.iter().enumerate().map(|(i, name)| json!({"name": name, "values": [{"variable": "show", "kind": "visibility", "value": i == 0}]})).collect(); + s.execute("image.variables.dataSets", json!({"dataSets": sets})).unwrap(); + s +} + +#[test] +fn export_data_sets_rejects_collisions_before_writing() { + for (label, names, naming, collision) in [ + ("sanitized", ["unique", "a b", "a_b"], "{name}", "a_b.png"), + ("constant", ["unique", "a b", "a_b"], "{document}", "card.png"), + ("case", ["unique", "Card", "card"], "{name}", "card.png"), + ] { + let mut s = visibility_export_session(&names); + let before = s.active().unwrap().doc.clone(); + let revision = s.active().unwrap().revision; + let history = s.active().unwrap().history.entries(); + let past = s.active().unwrap().history.past_len(); + let redo = s.active().unwrap().history.can_redo(); + let dir = tmp(&format!("collision-{label}")); + let first = format!("{dir}/unique.png"); + let collided = format!("{dir}/{collision}"); + std::fs::write(&first, b"keep earlier export").unwrap(); + std::fs::write(&collided, b"keep colliding export").unwrap(); + let err = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "naming": naming})).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{err}"); + let message = err.to_string(); + assert!(message.contains(collision) && message.contains("{index}"), "{message}"); + assert_eq!(std::fs::read(&first).unwrap(), b"keep earlier export"); + assert_eq!(std::fs::read(&collided).unwrap(), b"keep colliding export"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2); + let absent = format!("{dir}/not-created"); + assert!(s.execute("file.export.dataSetsAsFiles", json!({"dir": absent, "naming": naming})).is_err()); + assert!(!std::path::Path::new(&absent).exists()); + let after = s.active().unwrap(); + assert!(Arc::ptr_eq(&before, &after.doc)); + assert_eq!(after.revision, revision); + assert_eq!(after.history.entries(), history); + assert_eq!(after.history.past_len(), past); + assert_eq!(after.history.can_redo(), redo); + std::fs::remove_dir_all(dir).unwrap(); + } +} + +#[test] +fn export_data_sets_unique_paths_preserve_variations_and_document() { + for (label, names, naming, selected, expected) in [ + ("indexed", ["a b", "a_b"], "card-{index}", None, vec!["card-001.png", "card-002.png"]), + ("distinct", ["a-b", "a_b"], "{name}", None, vec!["a-b.png", "a_b.png"]), + ("subset", ["a b", "a_b"], "{name}", Some(vec!["a b"]), vec!["a_b.png"]), + ] { + let mut s = visibility_export_session(&names); + let before = s.active().unwrap().doc.clone(); + let revision = s.active().unwrap().revision; + let history = s.active().unwrap().history.entries(); + let past = s.active().unwrap().history.past_len(); + let redo = s.active().unwrap().history.can_redo(); + let dir = tmp(&format!("unique-{label}")); + let result = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "naming": naming, "dataSets": selected})).unwrap(); + let paths: Vec<_> = expected.iter().map(|name| format!("{dir}/{name}")).collect(); + assert_eq!(result, json!({"files": paths, "count": paths.len()})); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), paths.len()); + for (i, path) in paths.iter().enumerate() { + let exported = photocraft_io::import(path, &std::fs::read(path).unwrap()).unwrap().document; + let pixel = photocraft_compose::render(&exported, Rect::new(0, 0, 1, 1)).px[0]; + assert_eq!(pixel, if i == 0 { [1.0; 4] } else { [0.0; 4] }); + } + let after = s.active().unwrap(); + assert!(Arc::ptr_eq(&before, &after.doc)); + assert_eq!(after.revision, revision); + assert_eq!(after.history.entries(), history); + assert_eq!(after.history.past_len(), past); + assert_eq!(after.history.can_redo(), redo); + std::fs::remove_dir_all(dir).unwrap(); + } +} + #[test] fn pixel_replacement_changes_the_layer() { let (mut s, photo, _b, _t) = session(); @@ -382,3 +483,107 @@ fn applied_text_is_rendered_in_the_document_and_in_exports() { assert!(dark(doc(&s)) > 0, "the applied text is drawn"); let _ = std::fs::remove_dir_all(&dir); } + +/// A text layer created bold-italic with tracking, underline, all caps and colour, bound to a +/// `headline` text variable with one data set per value. +fn styled_text_session(values: &[&str]) -> (Session, Value) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 120, "background": "white"})).unwrap(); + let id = s + .execute( + "type.create", + json!({"x": 10, "y": 60, "text": "Before", "font": "Inter", "weight": 700, "italic": true, "size": 40, + "color": "#C0392B", "tracking": 120, "underline": true, "caps": "all"}), + ) + .unwrap()["layer"] + .clone(); + s.execute("image.variables.define", json!({"defs": [{"name": "headline", "layer": id, "type": "textReplacement"}]})).unwrap(); + let sets: Vec = + values.iter().enumerate().map(|(i, v)| json!({"name": format!("s{i}"), "values": [{"variable": "headline", "kind": "text", "value": v}]})).collect(); + s.execute("image.variables.dataSets", json!({ "dataSets": sets })).unwrap(); + (s, id) +} + +fn text_layer(s: &Session, id: &Value) -> TextLayer { + match &doc(s).layer(LayerId(id.as_u64().unwrap())).unwrap().content { + LayerContent::Text(t) => t.clone(), + _ => panic!("not a text layer"), + } +} + +#[test] +fn applied_text_keeps_the_character_style() { + // Applying a text value cleared the style runs, so weight, italic, tracking, underline and + // caps fell back to the defaults (#2742). The new text takes the first character's style. + let (mut s, id) = styled_text_session(&["After"]); + let before = text_layer(&s, &id); + let style = before.char_runs()[0].style.clone(); + assert_eq!((style.weight, style.italic), (700, true), "precondition: {style:?}"); + let paragraphs = before.paragraph_runs()[0].style.clone(); + s.execute("image.applyDataSet", json!({"name": "s0"})).unwrap(); + let after = text_layer(&s, &id); + assert_eq!(after.text, "After"); + let runs = after.char_runs(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].len, "After".len()); + assert_eq!(runs[0].style, style, "the whole character style is kept"); + assert_eq!(after.paragraph_runs()[0].style, paragraphs, "paragraph settings are kept"); + assert_eq!((after.font_family.as_str(), after.size_pt, after.color), (before.font_family.as_str(), before.size_pt, before.color)); + // type.info reports the same style as before. + let info = s.execute("type.info", json!({"layer": id})).unwrap(); + assert_eq!(info["runs"][0]["style"]["weight"], 700); + assert_eq!(info["runs"][0]["style"]["italic"], true); + + // Undo restores the original text and runs. + s.execute("edit.undo", json!({})).unwrap(); + let undone = text_layer(&s, &id); + assert_eq!(undone.text, "Before"); + assert_eq!(undone.runs, before.runs); +} + +#[test] +fn applied_text_takes_the_first_runs_style_and_survives_empty_values() { + let (mut s, id) = styled_text_session(&["One style", ""]); + // Make the second half of the text regular, upright and blue: a two-run layer. + s.execute("type.edit", json!({"layer": id, "runs": [{"start": 3, "end": 6, "weight": 400, "italic": false, "color": "#0000FF"}]})).unwrap(); + let before = text_layer(&s, &id); + assert_eq!(before.char_runs().len(), 2, "precondition: two runs"); + let first = before.char_runs()[0].style.clone(); + s.execute("image.applyDataSet", json!({"name": "s0"})).unwrap(); + let after = text_layer(&s, &id); + assert_eq!(after.text, "One style"); + let runs = after.char_runs(); + assert_eq!(runs.len(), 1, "the replacement is one run"); + assert_eq!(runs[0].style, first, "in the first run's style"); + + // An empty value empties the layer without panicking and keeps the style for later text. + s.execute("image.applyDataSet", json!({"name": "s1"})).unwrap(); + let empty = text_layer(&s, &id); + assert_eq!(empty.text, ""); + assert_eq!(empty.char_runs()[0].style, first); + s.execute("image.applyDataSet", json!({"name": "s0"})).unwrap(); + assert_eq!(text_layer(&s, &id).char_runs()[0].style, first); +} + +#[test] +fn exported_data_sets_keep_the_character_style() { + // file.export.dataSetsAsFiles applies each set to a copy of the document the same way. + let (mut s, id) = styled_text_session(&["After"]); + let style = text_layer(&s, &id).char_runs()[0].style.clone(); + let dir = tmp("styled-export"); + let out = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "format": "psd"})).unwrap(); + let path = out["files"][0].as_str().unwrap(); + let exported = photocraft_io::import(path, &std::fs::read(path).unwrap()).unwrap().document; + let t = exported + .layers + .iter() + .find_map(|l| match &l.content { + LayerContent::Text(t) => Some(t.clone()), + _ => None, + }) + .expect("the export keeps the type layer"); + assert_eq!(t.text, "After"); + let got = &t.char_runs()[0].style; + assert_eq!((got.weight, got.italic, got.underline), (style.weight, style.italic, style.underline)); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index c4f3433740..fda3ba8b46 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1001,7 +1001,8 @@ fn path_fill(s: &mut Session, p: &Value) -> Result { s.edit("Fill Path", |doc, _| { let area = doc.bounds(); let r = vector::fill_rasterizer(&path, vector::DEFAULT_TOLERANCE); - let feather_pad = (feather.ceil() as i32).saturating_mul(2); + // The feather reaches 3σ = 3 × radius (σ = radius, as in Photoshop), plus a pixel. + let feather_pad = (feather.ceil() as i32).saturating_mul(3).saturating_add(1); let area = r.pixel_bounds().map_or(area, |b| b.inflate(feather_pad).intersect(&area)); if area.is_empty() { return Ok(()); @@ -1013,6 +1014,11 @@ fn path_fill(s: &mut Session, p: &Value) -> Result { if feather > 0.0 { cov = sel::feather(&cov, area.width() as usize, area.height() as usize, feather); } + // An active selection limits the fill, soft edges included, as every other fill does (#2387). + if let Some(selection) = doc.selection.as_ref() { + let mask = sel::mask_from_surface(Some(selection), area); + cov.iter_mut().zip(&mask).for_each(|(c, m)| *c *= m); + } let locks = doc.effective_locks(id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; if locks.pixels || locks.all { @@ -1075,8 +1081,13 @@ fn path_stroke(s: &mut Session, p: &Value) -> Result { let bg = s.tools.background; let dmg = s.edit("Stroke Path", |doc, _| { let sel = doc.selection.clone(); - let lock = doc.effective_locks(id).transparency; + let locks = doc.effective_locks(id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + // Pixel and full locks (the layer's own or a parent group's) refuse the stroke, as Fill Path does (#2388). + if locks.pixels || locks.all { + return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name))); + } + let lock = locks.transparency; let surf = l.surface_mut().ok_or_else(|| EngineError::Other("Stroke Path needs a pixel layer".into()))?; let mut brush = brush.clone(); if brush.erase && lock { diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 97e1b8a29e..6f41eb03c1 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -361,6 +361,42 @@ fn fill_and_stroke_path_on_pixel_layer() { assert!(s.execute("path.fill", json!({})).is_err()); } +#[test] +fn fill_path_stays_inside_the_selection() { + let alpha = |s: &Session, x, y| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y)[3] + }; + for depth in [8, 16, 32] { + // A hard rectangle, an ellipse and a feathered rectangle, all over the left half. + for extra in [json!({}), json!({"ellipse": true}), json!({"feather": 4})] { + let mut s = session(100, 100, depth); + s.execute("layer.new.layer", json!({})).unwrap(); + let mut rect = json!({"x": 0, "y": 0, "width": 50, "height": 100}); + rect.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + s.execute("select.rect", rect).unwrap(); + s.execute("path.set", json!({"path": {"subpaths": [{"closed": true, "knots": [[10, 10], [90, 10], [90, 90], [10, 90]]}]}})).unwrap(); + s.execute("path.fill", json!({"color": "#ff0000", "opacity": 100})).unwrap(); + let mask = selection_mask(&s); + assert!(alpha(&s, 30, 50) > 0.99, "{depth} {extra}: inside the selection"); + assert_eq!(alpha(&s, 70, 50), 0.0, "{depth} {extra}: outside the selection"); + // At every pixel the fill's alpha is at most the selection's coverage there. + for (x, y) in [(46, 50), (49, 50), (51, 50), (25, 12), (12, 25)] { + let m = mask[(y * 100 + x) as usize]; + assert!(alpha(&s, x, y) <= m + 0.01, "{depth} {extra} ({x},{y}): alpha {} > selection {m}", alpha(&s, x, y)); + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(alpha(&s, 30, 50), 0.0, "{depth} {extra}: undo restores"); + } + } + // Without a selection the whole path fills, as before. + let mut s = session(100, 100, 8); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("path.set", json!({"path": {"subpaths": [{"closed": true, "knots": [[10, 10], [90, 10], [90, 90], [10, 90]]}]}})).unwrap(); + s.execute("path.fill", json!({"color": "#ff0000"})).unwrap(); + assert!(alpha(&s, 30, 50) > 0.99 && alpha(&s, 70, 50) > 0.99); +} + #[test] fn clipping_path_requires_saved_path_and_tracks_rename_delete() { let mut s = session(60, 60, 8); @@ -612,3 +648,21 @@ fn path_stroke_rejects_oversized_brush_before_rendering() { let result = s.execute("path.stroke", json!({"size": 1e30})); assert!(result.is_err(), "path strokes share the bounded paint renderer"); } + +#[test] +fn shape_info_bounds_of_curved_shapes_are_exact() { + // Issue #2537: an exact 600×600 ellipse reported width 601. + for depth in [8, 16, 32] { + let mut s = session(1920, 1080, depth); + for (params, want) in [ + (json!({"kind": "ellipse", "rect": [0, 0, 100, 100], "fill": "#0000ff"}), json!([0, 0, 100, 100])), + (json!({"kind": "ellipse", "rect": [660, 240, 600, 600], "fill": "#0000ff"}), json!([660, 240, 600, 600])), + (json!({"kind": "ellipse", "rect": [10.5, 10, 100, 100], "fill": "#0000ff"}), json!([10, 10, 101, 100])), + (json!({"kind": "rect", "rect": [0, 0, 100, 100], "fill": "#0000ff"}), json!([0, 0, 100, 100])), + ] { + let id = s.execute("shape.create", params.clone()).unwrap()["layer"].as_u64().unwrap(); + let info = s.execute("shape.info", json!({"layer": id})).unwrap(); + assert_eq!(info["bounds"], want, "{params} at {depth}-bit"); + } + } +} diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 250ab86160..242a3bea18 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -207,7 +207,11 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result Result { } let surf = l.surface_mut().ok_or_else(|| EngineError::Other("Vanishing Point needs a pixel layer".into()))?; for (plane, src, rect, at, width) in &pastes { - if let Some(out) = paste(&sc, *plane, src, *rect, *at, *width) { + if let Some(out) = paste(&sc, *plane, src, *rect, *at, *width)? { over(surf, &out); } } diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index bec3e1ecb0..daecc6b9c7 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -153,7 +153,7 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, if let Some(surf) = l.surface_mut() { *surf = match doc_sel { Some(sel) => { - let (lifted, mut rest) = crate::transform_cmds::split_selected(surf, sel); + let (lifted, mut rest) = crate::transform_cmds::split_selected(surf, sel)?; let moved = warp_mesh_surface(&lifted, rect, &map, interp); crate::transform_cmds::composite_over(&mut rest, &moved); rest.prune(); @@ -164,7 +164,7 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, let fmt = surf.format(); let warped = warp_mesh_surface(surf, rect, &map, interp); let mut rest = surf.convert(photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true)); - crate::pixels::clear_surface(&mut rest, rect, None); + crate::pixels::try_clear_surface(&mut rest, rect, None)?; crate::transform_cmds::composite_over(&mut rest, &warped); rest.prune(); rest diff --git a/crates/engine/tests/background_layer_consistency.rs b/crates/engine/tests/background_layer_consistency.rs new file mode 100644 index 0000000000..b59b55c116 --- /dev/null +++ b/crates/engine/tests/background_layer_consistency.rs @@ -0,0 +1,161 @@ +//! Background-layer invariants through merge, duplication and canvas extension. +//! These three commands must agree with Layer From Background and ordinary layer locks. + +use photocraft_doc::{Layer, LayerId}; +use photocraft_engine::Session; +use serde_json::json; + +fn background(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 12, "depth": depth, "background": "white"})).unwrap(); + s +} + +fn layer<'a>(s: &'a Session, name: &str) -> &'a Layer { + s.active().unwrap().doc.layers.iter().find(|l| l.name == name).unwrap() +} + +fn active(s: &Session) -> &Layer { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap() +} + +fn pixel(s: &Session, x: i32, y: i32) -> [f32; 4] { + active(s).surface().unwrap().rgba(x, y) +} + +fn select(s: &mut Session, id: LayerId, mode: &str) { + s.execute("layer.select", json!({"layer": id.0, "mode": mode})).unwrap(); +} + +#[test] +fn merge_down_keeps_background_locked_and_clear_or_cut_fills_instead_of_erasing() { + for depth in [8, 16, 32] { + for command in ["edit.clear", "edit.cut"] { + let mut s = background(depth); + let id = active(&s).id; + s.execute("layer.new.layer", json!({"name": "Top"})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("layer.mergeDown", json!({})).unwrap(); + + assert_eq!(s.active().unwrap().doc.layers.len(), 1, "{depth}/{command}"); + assert_eq!(active(&s).id, id, "{depth}/{command}: retain lower layer id"); + assert_eq!(active(&s).name, "Background"); + assert!(active(&s).locks.transparency && active(&s).locks.position); + assert!(s.is_enabled("layer.new.layerFromBackground")); + assert_eq!(pixel(&s, 5, 4), [1.0, 0.0, 0.0, 1.0]); + let bounds = active(&s).surface().unwrap().content_bounds(); + let history = s.active().unwrap().history.past_len(); + assert!(s.execute("layer.translate", json!({"dx": 2, "dy": 1})).is_err()); + assert_eq!(active(&s).surface().unwrap().content_bounds(), bounds); + assert_eq!(s.active().unwrap().history.past_len(), history); + + s.execute("tools.setColors", json!({"background": "#00ff00"})).unwrap(); + s.execute("select.rect", json!({"x": 4, "y": 3, "width": 4, "height": 4})).unwrap(); + s.execute(command, json!({})).unwrap(); + assert_eq!(pixel(&s, 5, 4), [0.0, 1.0, 0.0, 1.0], "{depth}/{command}: fill the Background"); + assert_eq!(pixel(&s, 1, 1), [1.0, 0.0, 0.0, 1.0], "{depth}/{command}: outside selection"); + assert!(s.undo(), "{depth}/{command}: undo"); + assert_eq!(pixel(&s, 5, 4), [1.0, 0.0, 0.0, 1.0]); + assert!(s.redo(), "{depth}/{command}: redo"); + assert_eq!(pixel(&s, 5, 4), [0.0, 1.0, 0.0, 1.0]); + } + } +} + +#[test] +fn merge_down_onto_an_ordinary_layer_does_not_create_a_background() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 12, "background": "transparent"})).unwrap(); + let below = active(&s).id; + s.execute("layer.new.layer", json!({"name": "Top"})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("layer.mergeDown", json!({})).unwrap(); + assert_eq!(active(&s).id, below); + assert!(!active(&s).locks.transparency && !active(&s).locks.position); + assert!(!s.is_enabled("layer.new.layerFromBackground")); + s.execute("select.rect", json!({"x": 4, "y": 3, "width": 4, "height": 4})).unwrap(); + s.execute("edit.clear", json!({})).unwrap(); + assert_eq!(pixel(&s, 5, 4)[3], 0.0); + assert!(s.execute("layer.translate", json!({"dx": 1, "dy": 1})).is_ok()); +} + +#[test] +fn multi_layer_duplication_unlocks_only_the_background_copy() { + let mut s = background(8); + let bg = active(&s).id; + let ordinary = s.execute("layer.new.layer", json!({"name": "Other"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": ordinary, "locks": {"position": true}})).unwrap(); + select(&mut s, bg, "replace"); + select(&mut s, LayerId(ordinary), "add"); + assert_eq!(s.active().unwrap().selected_layers().len(), 2); + s.execute("layer.duplicate", json!({})).unwrap(); + + assert_eq!(s.active().unwrap().doc.layers.len(), 4); + let bg_copy = layer(&s, "Background copy"); + assert!(!bg_copy.locks.transparency && !bg_copy.locks.position && !bg_copy.locks.all); + let bg_copy_id = bg_copy.id; + assert!(layer(&s, "Background").locks.transparency && layer(&s, "Background").locks.position); + let other_copy = layer(&s, "Other copy"); + assert!(other_copy.locks.position, "ordinary layer locks must survive duplication"); + let other_copy_id = other_copy.id; + select(&mut s, other_copy_id, "replace"); + assert!(s.execute("layer.translate", json!({"dx": 1})).is_err()); + select(&mut s, bg_copy_id, "replace"); + assert!(s.execute("layer.translate", json!({"dx": 1})).is_ok()); +} + +#[test] +fn transparent_canvas_extension_converts_background_to_a_paintable_layer() { + for depth in [8, 16, 32] { + let mut s = background(depth); + let original = active(&s).id; + s.execute( + "image.canvasSize", + json!({ + "width": 24, "height": 12, "anchor": "left", "extensionColor": "transparent" + }), + ) + .unwrap(); + assert_eq!(active(&s).id, original); + assert_eq!(active(&s).name, "Layer 0"); + assert!(!active(&s).locks.transparency && !active(&s).locks.position); + assert!(!s.is_enabled("layer.new.layerFromBackground")); + assert_eq!(pixel(&s, 2, 4), [1.0, 1.0, 1.0, 1.0], "{depth}: original area"); + assert_eq!(pixel(&s, 20, 4)[3], 0.0, "{depth}: transparent extension"); + + s.execute("select.rect", json!({"x": 18, "y": 2, "width": 4, "height": 4})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + assert_eq!(pixel(&s, 20, 4), [1.0, 0.0, 0.0, 1.0], "{depth}: extension is paintable"); + assert_eq!(pixel(&s, 23, 4)[3], 0.0, "{depth}: outside selection"); + assert_eq!(pixel(&s, 2, 4), [1.0, 1.0, 1.0, 1.0]); + assert!(s.undo()); + assert_eq!(pixel(&s, 20, 4)[3], 0.0); + } +} + +#[test] +fn opaque_or_no_op_canvas_size_keeps_the_background() { + let mut s = background(8); + s.execute( + "image.canvasSize", + json!({ + "width": 24, "height": 12, "anchor": "left", "extensionColor": "#00ff00" + }), + ) + .unwrap(); + assert_eq!(active(&s).name, "Background"); + assert!(active(&s).locks.transparency && active(&s).locks.position); + assert_eq!(pixel(&s, 20, 4), [0.0, 1.0, 0.0, 1.0]); + assert!(s.undo()); + + s.execute( + "image.canvasSize", + json!({ + "width": 16, "height": 12, "anchor": "left", "extensionColor": "transparent" + }), + ) + .unwrap(); + assert_eq!(active(&s).name, "Background"); + assert!(s.is_enabled("layer.new.layerFromBackground")); +} diff --git a/crates/engine/tests/current_affinity_artboards.rs b/crates/engine/tests/current_affinity_artboards.rs new file mode 100644 index 0000000000..e3b27e856b --- /dev/null +++ b/crates/engine/tests/current_affinity_artboards.rs @@ -0,0 +1,47 @@ +//! Artboards recognized from current .af properties move with their children and undo together. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +#[path = "../../io/tests/support/current_affinity.rs"] +mod fixture; + +use fixture::{Marker, document}; +use photocraft_affinity::synth::Method; +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::{Session, file_cmds::open_bytes_as}; +use photocraft_geom::{Point, Rect}; +use serde_json::json; + +fn state(s: &Session, id: LayerId) -> (Rect, Point) { + let layer = s.active().unwrap().doc.layer(id).unwrap(); + let LayerContent::Group(group) = &layer.content else { panic!("not an artboard group") }; + let LayerContent::Shape(child) = &group.children[0].content else { panic!("not an editable child") }; + (group.artboard.as_ref().unwrap().rect, child.path.subpaths[0].knots[0].anchor) +} + +#[test] +fn imported_current_boards_move_with_their_art_and_undo_without_moving_the_other_board() { + for marker in [Marker::Legacy, Marker::Current, Marker::OtherProperties] { + let mut s = Session::new(); + let bytes = document(marker, false, Method::Zlib); + open_bytes_as(&mut s, "synthetic.af", &bytes, None, None).unwrap(); + let ids: Vec<_> = s.active().unwrap().doc.artboards().into_iter().map(|(id, _, _)| id).collect(); + if matches!(marker, Marker::OtherProperties) { + assert!(ids.is_empty()); + assert!(!s.is_enabled("file.export.artboardsToFiles")); + continue; + } + assert_eq!(ids.len(), 2); + let before = state(&s, ids[0]); + let other = state(&s, ids[1]); + s.execute("layer.artboard.set", json!({"layer": ids[0].0, "x": 3, "y": 5})).unwrap(); + let (board, child) = state(&s, ids[0]); + assert_eq!(board, Rect::new(3, 5, 19, 21)); + assert_eq!(child, Point::new(before.1.x + 3.0, before.1.y + 5.0)); + assert_eq!(state(&s, ids[1]), other); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(state(&s, ids[0]), before); + assert_eq!(state(&s, ids[1]), other); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(state(&s, ids[0]), (board, child)); + } +} diff --git a/crates/engine/tests/duplicate_layer_comps.rs b/crates/engine/tests/duplicate_layer_comps.rs new file mode 100644 index 0000000000..ecd8b0230b --- /dev/null +++ b/crates/engine/tests/duplicate_layer_comps.rs @@ -0,0 +1,149 @@ +//! Layer comps must continue targeting their layers after a copied document gets fresh ids. +//! Cover current/old history states, nested layers, Last Document State and merged-only copies. + +use photocraft_doc::{CompLayerState, Layer, LayerId}; +use photocraft_engine::Session; +use serde_json::json; + +fn scene() -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16, "background": "white"})).unwrap(); + let id = LayerId(s.execute("layer.new.layer", json!({"name": "L"})).unwrap()["layer"].as_u64().unwrap()); + (s, id) +} + +fn copied_layer(s: &Session, name: &str) -> LayerId { + s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id +} + +#[test] +fn image_duplicate_and_history_copy_keep_saved_comp_visibility() { + for command in ["image.duplicate", "history.newDocument"] { + let (mut s, source) = scene(); + let original = s.active().unwrap().doc.id; + s.execute("layerComp.new", json!({"name": "Shown"})).unwrap(); + s.execute("layer.setProps", json!({"layer": source.0, "visible": false})).unwrap(); + s.execute(command, json!({})).unwrap(); + + let id = copied_layer(&s, "L"); + let doc = &s.active().unwrap().doc; + assert_ne!(doc.id, original); + assert_ne!(id, source, "{command}: layers must get new identities"); + assert!(!doc.layer(id).unwrap().visible, "{command}: copied current layout"); + let comp = doc.layer_comps.iter().find(|c| c.name == "Shown").unwrap(); + assert!(comp.missing_layers(doc).is_empty(), "{command}: all captured layers still exist"); + assert_eq!(comp.state(id).unwrap().visible, Some(true), "{command}: preserve recorded state"); + + let applied = s.execute("layerComp.apply", json!({"comp": "Shown"})).unwrap(); + assert_eq!(applied["missingLayers"], 0, "{command}: no spurious missing layers"); + assert!(s.active().unwrap().doc.layer(id).unwrap().visible, "{command}: recorded visibility restored"); + + s.set_active(0); + assert!(!s.active().unwrap().doc.layer(source).unwrap().visible, "{command}: source untouched"); + assert_eq!(s.execute("layerComp.apply", json!({"comp": "Shown"})).unwrap()["missingLayers"], 0); + assert!(s.active().unwrap().doc.layer(source).unwrap().visible, "{command}: source comp still works"); + } +} + +#[test] +fn a_historical_state_uses_its_own_saved_comp_and_fresh_layer_ids() { + let (mut s, source) = scene(); + s.execute("layerComp.new", json!({"name": "Shown"})).unwrap(); + s.execute("layer.setProps", json!({"layer": source.0, "visible": false})).unwrap(); + let saved_state = s.active().unwrap().history.past_len(); + s.execute("layer.setProps", json!({"layer": source.0, "visible": true})).unwrap(); + + s.execute("history.newDocument", json!({"state": saved_state, "name": "From History"})).unwrap(); + assert_eq!(s.active().unwrap().doc.name, "From History"); + let id = copied_layer(&s, "L"); + assert_ne!(id, source); + assert!(!s.active().unwrap().doc.layer(id).unwrap().visible, "the historical hidden layout survives"); + assert_eq!(s.execute("layerComp.apply", json!({"comp": "Shown"})).unwrap()["missingLayers"], 0); + assert!(s.active().unwrap().doc.layer(id).unwrap().visible, "historical comp can restore visibility"); + s.set_active(0); + assert!(s.active().unwrap().doc.layer(source).unwrap().visible, "current source state remains unchanged"); +} + +#[test] +fn nested_group_states_are_remapped_but_genuinely_missing_layers_stay_missing() { + let (mut s, _) = scene(); + let (group, child) = s + .edit("Add nested group", |doc, _| { + let child = Layer::raster("Child", doc.pixel_format()); + let child_id = child.id; + let group = Layer::group("Group", vec![child]); + let group_id = group.id; + doc.layers.push(group); + Ok((group_id, child_id)) + }) + .unwrap(); + s.execute("layerComp.new", json!({"name": "Nested"})).unwrap(); + // An old comp may already refer to a deleted layer before duplication. + // Copying should not silently rewrite that warning to an unrelated new id. + let missing = LayerId(u64::MAX); + s.edit("Record deleted layer", |doc, _| { + let comp = doc.layer_comps.iter_mut().find(|c| c.name == "Nested").unwrap(); + comp.states.push(CompLayerState { layer: missing, visible: Some(false), position: None, appearance: None }); + Ok(()) + }) + .unwrap(); + s.execute("layer.setProps", json!({"layer": child.0, "visible": false})).unwrap(); + s.execute("image.duplicate", json!({})).unwrap(); + + let new_group = copied_layer(&s, "Group"); + let new_child = copied_layer(&s, "Child"); + assert_ne!(new_group, group); + assert_ne!(new_child, child); + let doc = &s.active().unwrap().doc; + let comp = doc.layer_comps.iter().find(|c| c.name == "Nested").unwrap(); + assert!(comp.state(new_group).is_some()); + assert_eq!(comp.state(new_child).unwrap().visible, Some(true)); + assert_eq!(comp.missing_layers(doc), vec![missing]); + assert_eq!(s.execute("layerComp.apply", json!({"comp": "Nested"})).unwrap()["missingLayers"], 1); + assert!(s.active().unwrap().doc.layer(new_child).unwrap().visible); +} + +#[test] +fn duplicated_last_document_state_restores_the_copied_layout() { + for command in ["image.duplicate", "history.newDocument"] { + let (mut s, source) = scene(); + s.execute("layerComp.new", json!({"name": "Shown"})).unwrap(); + s.execute("layer.setProps", json!({"layer": source.0, "visible": false})).unwrap(); + s.execute("layerComp.apply", json!({"comp": "Shown"})).unwrap(); + assert!(s.active().unwrap().doc.last_document_state.is_some()); + s.execute(command, json!({})).unwrap(); + + let new_id = copied_layer(&s, "L"); + let doc = &s.active().unwrap().doc; + assert!(doc.layer(new_id).unwrap().visible); + let backup = doc.last_document_state.as_ref().unwrap(); + assert_eq!(backup.state(new_id).unwrap().visible, Some(false)); + assert!(backup.missing_layers(doc).is_empty(), "{command}: backup references copied ids"); + + s.execute("layerComp.restoreLastDocumentState", json!({})).unwrap(); + assert!(!s.active().unwrap().doc.layer(new_id).unwrap().visible, "{command}: backup restores hidden layout"); + s.set_active(0); + assert!(s.active().unwrap().doc.layer(source).unwrap().visible, "{command}: source remains visible"); + } +} + +#[test] +fn merged_only_duplicate_does_not_carry_comps_for_removed_source_layers() { + let (mut s, source) = scene(); + s.execute("layerComp.new", json!({"name": "Original"})).unwrap(); + s.execute("layer.setProps", json!({"layer": source.0, "visible": false})).unwrap(); + s.execute("layerComp.apply", json!({})).unwrap(); + assert!(s.active().unwrap().doc.last_document_state.is_some()); + + s.execute("image.duplicate", json!({"mergedOnly": true})).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.layers.len(), 1, "merged-only copy is a single composite layer"); + assert!(d.layer_comps.is_empty(), "old comps cannot address discarded layers"); + assert_eq!(d.last_applied_comp, None); + assert!(d.last_document_state.is_none(), "no stale saved layout on a flattened copy"); + assert!(!s.is_enabled("layerComp.apply")); + + s.set_active(0); + assert_eq!(s.active().unwrap().doc.layer_comps.len(), 1, "original comps are preserved"); + assert!(s.active().unwrap().doc.last_document_state.is_some(), "original backup is preserved"); +} diff --git a/crates/engine/tests/filter_locked.rs b/crates/engine/tests/filter_locked.rs index 26574019b3..6b1717c2c8 100644 --- a/crates/engine/tests/filter_locked.rs +++ b/crates/engine/tests/filter_locked.rs @@ -1,7 +1,9 @@ //! Filters refuse a pixel-locked (or fully locked) layer and leave it unchanged, as painting //! does (#1102). +use photocraft_doc::LayerId; use photocraft_engine::Session; +use photocraft_geom::Rect; use serde_json::json; fn layered() -> Session { @@ -50,6 +52,37 @@ fn render_filters_and_fill_path_refuse_a_locked_layer() { } } +#[test] +fn stroke_path_refuses_a_locked_layer_or_group() { + for tool in ["brush", "pencil", "eraser"] { + for lock in [json!({"pixels": true}), json!({"all": true})] { + for on_group in [false, true] { + let mut s = layered(); + s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[2, 6], [38, 6]]}]}})).unwrap(); + let layer = s.active().unwrap().active_layer.unwrap().0; + if on_group { + s.execute("layer.new.groupFromLayers", json!({})).unwrap(); + } + s.execute("layer.lockLayers", lock.clone()).unwrap(); + let row = |s: &Session| s.active().unwrap().doc.layer(LayerId(layer)).unwrap().surface().unwrap().read_region(Rect::new(0, 0, 40, 30)); + let before = row(&s); + let r = s.execute("path.stroke", json!({"layer": layer, "tool": tool, "size": 6, "color": "#0000ff"})); + assert!(r.is_err(), "{tool} stroked a layer locked with {lock} (group: {on_group}): {r:?}"); + assert_eq!(row(&s), before, "{tool} {lock} group: {on_group}"); + } + } + } +} + +#[test] +fn stroke_path_still_runs_on_an_unlocked_layer() { + let mut s = layered(); + s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[2, 6], [38, 6]]}]}})).unwrap(); + let before = px(&s); + s.execute("path.stroke", json!({"tool": "pencil", "size": 6, "color": "#0000ff"})).unwrap(); + assert_ne!(px(&s), before); +} + #[test] fn filters_keep_alpha_on_a_transparency_locked_layer() { let mut s = layered(); diff --git a/crates/engine/tests/smart_filter_stages.rs b/crates/engine/tests/smart_filter_stages.rs new file mode 100644 index 0000000000..d26184542c --- /dev/null +++ b/crates/engine/tests/smart_filter_stages.rs @@ -0,0 +1,53 @@ +//! One integration-test process keeps the global smart filter stage cache isolated from parallel +//! unit tests, so these tests can tell a cached stage from a re-render. +use std::sync::Arc; + +use photocraft_doc::LayerContent; +use photocraft_engine::{Session, smart_cmds}; +use photocraft_raster::Surface; +use serde_json::json; + +fn rendered(s: &Session) -> Surface { + let d = s.active().unwrap(); + match &d.doc.layer(d.active_layer.unwrap()).unwrap().content { + LayerContent::Smart(sm) => sm.cache.clone().unwrap(), + other => panic!("not a smart object: {}", other.kind_name()), + } +} + +/// Same tiles, not just the same pixels: the surface came from the cache rather than a re-render. +fn same_tiles(a: &Surface, b: &Surface) -> bool { + a.tile_count() == b.tile_count() && a.tiles().zip(b.tiles()).all(|((ca, ta), (cb, tb))| ca == cb && Arc::ptr_eq(ta, tb)) +} + +#[test] +fn toggling_the_top_filter_reuses_the_stages_below_it_until_purged() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 96, "height": 64, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("paint.gradient", json!({"from": [0, 0], "to": [96, 64], "colors": ["#ff0000", "#0000ff"]})).unwrap(); + s.execute("filter.convertForSmartFilters", json!({})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + let blurred = rendered(&s); + s.execute("filter.noise.addNoise", json!({"amount": 12, "seed": 1})).unwrap(); + let full = rendered(&s); + + s.execute("layer.smartFilter.setVisible", json!({"index": 1, "visible": false})).unwrap(); + let hidden = rendered(&s); + assert!(same_tiles(&hidden, &blurred), "hiding the top filter shows the cached stage below it at {depth}-bit"); + s.execute("layer.smartFilter.setVisible", json!({"index": 1, "visible": true})).unwrap(); + assert!(same_tiles(&rendered(&s), &full), "showing it again reuses the full stack at {depth}-bit"); + + assert!(smart_cmds::stack_cache_bytes() > 0); + assert!(s.is_enabled("edit.purge.all")); + let purged = s.execute("edit.purge.all", json!({})).unwrap(); + assert!(purged["purged"].as_array().unwrap().contains(&json!("smart filter cache")), "{purged}"); + assert_eq!(smart_cmds::stack_cache_bytes(), 0); + + s.execute("layer.smartFilter.setVisible", json!({"index": 1, "visible": false})).unwrap(); + let rerendered = rendered(&s); + assert_eq!(rerendered, hidden, "a cold render gives the same pixels at {depth}-bit"); + assert!(!same_tiles(&rerendered, &hidden), "after a purge the stage is rendered again"); + } +} diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index a0194ea213..488b9a2f73 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -512,6 +512,266 @@ impl BezierMesh { (s, t) } + /// Pulls the surface point at normalised `(s, t)` by `d`, as dragging the grid anywhere inside + /// a patch does in other editors. `base` is the control grid at the start of the drag. + /// + /// Two parts. The surrounding grid follows loosely: every interior anchor within about two cells + /// moves by a share of `d` that fades with distance. The picture's outer border stays pinned + /// (the picture keeps filling its frame) unless the grab is within a + /// few percent of it, and then it follows more and more. Each anchor + /// takes its whole 3 x 3 block of control points along, so every grid line stays smooth through + /// its anchors and the neighbouring cells and dividers bend with the pull. Then the four inner + /// control points of the grabbed patch make up the rest, so the grabbed point lands exactly at + /// its start + `d`. Grabbing near an anchor moves that anchor more, and the inner points less. + pub fn pull(&mut self, base: &[[f64; 2]], s: f64, t: f64, d: [f64; 2]) { + /// Within this distance of the mesh border (in 0..1 box units) a grab starts to drag the + /// border with it; further in, the border stays exactly where it is. + const BORDER_ZONE: f64 = 0.12; + /// Falloff radius, in the grabbed patch's size. + const REACH: f64 = 1.8; + if base.len() != self.points.len() || !d.iter().all(|v| v.is_finite()) { + return; + } + self.points.copy_from_slice(base); + let (cols, rows) = (self.us.len() - 1, self.vs.len() - 1); + let (pi, a) = Self::locate(&self.us, s); + let (pj, b) = Self::locate(&self.vs, t); + let (ba, bb) = (bernstein(a), bernstein(b)); + let nx = self.nx(); + let anchor = |ai: usize, aj: usize| base.get(3 * aj * nx + 3 * ai).copied().unwrap_or([0.0; 2]); + let at0 = self.eval(s, t); + let target = [at0[0] + d[0], at0[1] + d[1]]; + // The grabbed patch's size and its "interior-ness": 1 in the middle, 0 on its edges. + let mut size = 1.0f64; + for k in 0..2 { + let vals = [anchor(pi, pj)[k], anchor(pi + 1, pj)[k], anchor(pi, pj + 1)[k], anchor(pi + 1, pj + 1)[k]]; + let (lo, hi) = vals.iter().fold((f64::MAX, f64::MIN), |m, v| (m.0.min(*v), m.1.max(*v))); + size = size.max(hi - lo); + } + let radius = REACH * size; + let inner_w = (ba[1] + ba[2]) * (bb[1] + bb[2]); + let interior = (inner_w / 0.5625).clamp(0.0, 1.0); + let to_border = s.min(1.0 - s).min(t.min(1.0 - t)); + let near_border = (1.0 - to_border / BORDER_ZONE).clamp(0.0, 1.0); + // How much of the pull each corner of the grabbed patch is responsible for. + // A corner on the picture's border only takes part once the grab is near that border. + // The picture's four corners never move in a pull (a grab near the border bends the + // border through its handles instead). + let mesh_corner = |ai: usize, aj: usize| (ai == 0 || ai == cols) && (aj == 0 || aj == rows); + let reach = |ai: usize, aj: usize, wa: f64, wb: f64| { + let border = ai == 0 || ai == cols || aj == 0 || aj == rows; + if mesh_corner(ai, aj) { 0.0 } else { wa * wb * if border { near_border } else { 1.0 } } + }; + let corner = [ + (pi, pj, reach(pi, pj, ba[0] + ba[1], bb[0] + bb[1])), + (pi + 1, pj, reach(pi + 1, pj, ba[2] + ba[3], bb[0] + bb[1])), + (pi, pj + 1, reach(pi, pj + 1, ba[0] + ba[1], bb[2] + bb[3])), + (pi + 1, pj + 1, reach(pi + 1, pj + 1, ba[2] + ba[3], bb[2] + bb[3])), + ]; + // Share of `d` for each anchor. + let mut share = vec![0.0f64; (cols + 1) * (rows + 1)]; + for aj in 0..=rows { + for ai in 0..=cols { + let p = anchor(ai, aj); + let x = ((p[0] - at0[0]).hypot(p[1] - at0[1]) / radius).clamp(0.0, 1.0); + let fall = 0.5 * (1.0 + (std::f64::consts::PI * x).cos()); + let border = ai == 0 || ai == cols || aj == 0 || aj == rows; + let edge = if mesh_corner(ai, aj) { + 0.0 + } else if border { + near_border + } else { + 1.0 + }; + share[aj * (cols + 1) + ai] = fall * edge; + } + } + // The grabbed patch's own corners: near one, that corner follows the pointer. + for (ai, aj, w) in corner { + let f = &mut share[aj * (cols + 1) + ai]; + *f = f.max((1.0 - interior) * w); + } + // Every control point moves with the anchor it is closest to. + for j in 0..self.ny() { + for i in 0..nx { + let (ai, aj) = (((i + 1) / 3).min(cols), ((j + 1) / 3).min(rows)); + let k = share[aj * (cols + 1) + ai]; + let p = &mut self.points[j * nx + i]; + p[0] += d[0] * k; + p[1] += d[1] * k; + } + } + // Close the gap exactly, with three groups of control points that share the work: + // - the four inner points of the grabbed patch (all of it in the middle of the patch, + // little near a grid line, where they barely reach the surface); + // - the corner blocks (an anchor with its handles, moving as a whole so lines stay smooth); + // - when the grab is near the picture's border, that border's own handles: the border + // bends into a curve and its corners stay put. + // Closing nudges the tangents across the grid lines, so they are evened out again and the + // gap re-closed. + let norm: f64 = [1, 2].iter().flat_map(|j| [1, 2].iter().map(move |i| (ba[*i] * bb[*j]).powi(2))).sum(); + let corner_norm: f64 = corner.iter().map(|c| c.2 * c.2).sum(); + // With no free corner to help (a one-patch grid, grabbed away from its border) the four + // inner points do all of it. + let inner_part = if corner_norm > 1e-12 { (inner_w / 0.15).min(1.0) } else { 1.0 }; + // Handles along the sides of the grabbed patch that lie on the picture's border. + let mut edge: Vec<(usize, f64)> = Vec::new(); + for k in [1usize, 2] { + if pi == 0 { + edge.push(((3 * pj + k) * nx + 3 * pi, ba[0] * bb[k])); + } + if pi + 1 == cols { + edge.push(((3 * pj + k) * nx + 3 * pi + 3, ba[3] * bb[k])); + } + if pj == 0 { + edge.push((3 * pj * nx + 3 * pi + k, ba[k] * bb[0])); + } + if pj + 1 == rows { + edge.push(((3 * pj + 3) * nx + 3 * pi + k, ba[k] * bb[3])); + } + } + let edge_norm: f64 = edge.iter().map(|e| e.1 * e.1).sum(); + let edge_part = if edge_norm > 1e-12 { near_border } else { 0.0 }; + let (inner_share, corner_share) = ((1.0 - edge_part) * inner_part, (1.0 - edge_part) * (1.0 - inner_part)); + for _ in 0..60 { + let now = self.eval(s, t); + let rem = [target[0] - now[0], target[1] - now[1]]; + if rem[0].hypot(rem[1]) < 1e-9 { + break; + } + if inner_share > 0.0 && norm > 0.0 { + for j in [1usize, 2] { + for i in [1usize, 2] { + let k = ba[i] * bb[j] / norm * inner_share; + let p = &mut self.points[(3 * pj + j) * nx + 3 * pi + i]; + p[0] += rem[0] * k; + p[1] += rem[1] * k; + } + } + } + if edge_part > 0.0 { + for (idx, w) in &edge { + if let Some(p) = self.points.get_mut(*idx) { + let k = w / edge_norm * edge_part; + p[0] += rem[0] * k; + p[1] += rem[1] * k; + } + } + } + if corner_share > 0.0 && corner_norm > 0.0 { + for (ai, aj, w) in corner { + let k = w / corner_norm * corner_share; + for dj in -1..=1isize { + for di in -1..=1isize { + let (i, j) = ((3 * ai) as isize + di, (3 * aj) as isize + dj); + if i >= 0 && j >= 0 && (i as usize) < nx && (j as usize) < self.ny() { + let p = &mut self.points[j as usize * nx + i as usize]; + p[0] += rem[0] * k; + p[1] += rem[1] * k; + } + } + } + } + } + for _ in 0..3 { + self.smooth_lines((3 * pi).saturating_sub(3), 3 * pi + 7, (3 * pj).saturating_sub(3), 3 * pj + 7); + } + } + } + + /// Makes the tangent equal on both sides of every interior grid line, for the control points + /// in columns `i0..i1` and rows `j0..j1` (exclusive ends, clamped to the grid): each pair of + /// handles takes the average of the two tangents it had. + fn smooth_lines(&mut self, i0: usize, i1: usize, j0: usize, j1: usize) { + let nx = self.nx(); + let (i1, j1) = (i1.min(nx), j1.min(self.ny())); + let hu = |k: usize| self.us[k + 1] - self.us[k]; + let hv = |k: usize| self.vs[k + 1] - self.vs[k]; + // (left, centre, right, span left, span right) + let mut triples = Vec::new(); + for a in 1..self.us.len() - 1 { + if 3 * a >= i0 && 3 * a < i1 { + for j in j0..j1 { + triples.push((j * nx + 3 * a - 1, j * nx + 3 * a, j * nx + 3 * a + 1, hu(a - 1), hu(a))); + } + } + } + for b in 1..self.vs.len() - 1 { + if 3 * b >= j0 && 3 * b < j1 { + for i in i0..i1 { + triples.push(((3 * b - 1) * nx + i, 3 * b * nx + i, (3 * b + 1) * nx + i, hv(b - 1), hv(b))); + } + } + } + for (l, c, r, hl, hr) in triples { + let (pl, pc, pr) = (self.points[l], self.points[c], self.points[r]); + let tan = [((pc[0] - pl[0]) / hl + (pr[0] - pc[0]) / hr) / 2.0, ((pc[1] - pl[1]) / hl + (pr[1] - pc[1]) / hr) / 2.0]; + self.points[l] = [pc[0] - tan[0] * hl, pc[1] - tan[1] * hl]; + self.points[r] = [pc[0] + tan[0] * hr, pc[1] + tan[1] * hr]; + } + } + + /// Drags control point `index` by `d` from the grid `base` it had when the drag began, keeping + /// the surface smooth across the grid lines: + /// - an anchor moves with the eight points around it, as one block; + /// - a handle moves with the two points beside it across its line, and the handle opposite + /// it on the same anchor turns the other way (with its two neighbours), so the line keeps + /// passing straight through the anchor instead of creasing there. + pub fn drag_point(&mut self, base: &[[f64; 2]], index: usize, d: [f64; 2]) { + if base.len() != self.points.len() || index >= base.len() || !d.iter().all(|v| v.is_finite()) { + return; + } + self.points.copy_from_slice(base); + let (nx, ny) = (self.nx(), self.ny()); + let (i, j) = (index % nx, index / nx); + let mut shift = |i: isize, j: isize, d: [f64; 2]| { + if i >= 0 && j >= 0 && (i as usize) < nx && (j as usize) < ny { + let p = &mut self.points[j as usize * nx + i as usize]; + p[0] += d[0]; + p[1] += d[1]; + } + }; + let (ii, jj) = (i as isize, j as isize); + // Spans either side of interior knot `k`. + let span = |knots: &[f64], k: usize| (k > 0 && k + 1 < knots.len()).then(|| (knots[k] - knots[k - 1], knots[k + 1] - knots[k])); + match (i % 3, j % 3) { + (0, 0) => { + for dj in -1..=1 { + for di in -1..=1 { + shift(ii + di, jj + dj, d); + } + } + } + // A handle along a vertical line: it takes the two points beside it across the line. + (0, r) => { + for di in -1..=1 { + shift(ii + di, jj, d); + } + // The opposite handle of the same anchor turns the other way. + if let Some((hl, hr)) = span(&self.vs, j / 3 + usize::from(r == 2)) { + let (other, k) = if r == 1 { (jj - 2, hl / hr) } else { (jj + 2, hr / hl) }; + for di in -1..=1 { + shift(ii + di, other, [-d[0] * k, -d[1] * k]); + } + } + } + // A handle along a horizontal line, likewise. + (r, 0) => { + for dj in -1..=1 { + shift(ii, jj + dj, d); + } + if let Some((hl, hr)) = span(&self.us, i / 3 + usize::from(r == 2)) { + let (other, k) = if r == 1 { (ii - 2, hl / hr) } else { (ii + 2, hr / hl) }; + for dj in -1..=1 { + shift(other, jj + dj, [-d[0] * k, -d[1] * k]); + } + } + } + // An inner point of a patch has no handle of its own. + _ => shift(ii, jj, d), + } + } + /// Bounding box of the control points (the surface lies inside it). pub fn control_bounds(&self) -> [f64; 4] { self.points.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]) @@ -651,6 +911,197 @@ mod tests { const B: [f64; 4] = [10.0, 20.0, 110.0, 70.0]; + /// The largest mismatch between the tangents on the two sides of any interior grid line. + fn tangent_defect(m: &BezierMesh) -> f64 { + let nx = m.nx(); + let mut worst: f64 = 0.0; + for a in 1..m.us.len() - 1 { + let (hl, hr) = (m.us[a] - m.us[a - 1], m.us[a + 1] - m.us[a]); + for j in 0..m.ny() { + let (l, c, r) = (m.points[j * nx + 3 * a - 1], m.points[j * nx + 3 * a], m.points[j * nx + 3 * a + 1]); + for k in 0..2 { + worst = worst.max(((c[k] - l[k]) / hl - (r[k] - c[k]) / hr).abs()); + } + } + } + for b in 1..m.vs.len() - 1 { + let (hl, hr) = (m.vs[b] - m.vs[b - 1], m.vs[b + 1] - m.vs[b]); + for i in 0..nx { + let (l, c, r) = (m.points[(3 * b - 1) * nx + i], m.points[3 * b * nx + i], m.points[(3 * b + 1) * nx + i]); + for k in 0..2 { + worst = worst.max(((c[k] - l[k]) / hl - (r[k] - c[k]) / hr).abs()); + } + } + } + worst + } + + #[test] + fn dragging_an_anchor_or_handle_keeps_the_surface_smooth_across_the_lines() { + let mut base = BezierMesh::identity([0.0, 0.0, 300.0, 300.0], 1, 1); + for s in [0.3, 0.55, 0.9] { + assert!(base.split_u(s)); + assert!(base.split_v(s)); + } + let nx = base.nx(); + // Every kind of point on a grid line, interior and border: anchors, handles each way. + for (i, j) in [(6, 6), (7, 6), (5, 6), (6, 7), (6, 5), (3, 9), (9, 3), (0, 6), (6, 0), (4, 3), (3, 4), (12, 12)] { + let mut m = base.clone(); + m.drag_point(&base.points, j * nx + i, [23.0, -31.0]); + assert_eq!(m.points[j * nx + i], [base.points[j * nx + i][0] + 23.0, base.points[j * nx + i][1] - 31.0], "({i}, {j}) lands on the pointer"); + assert!(tangent_defect(&m) < 1e-6, "({i}, {j}): {}", tangent_defect(&m)); + } + } + + /// Fraction of the surface where the map folds over itself (its Jacobian is not positive). + fn fold_fraction(m: &BezierMesh) -> f64 { + let (n, h) = (80, 1e-4); + let mut bad = 0; + for j in 0..n { + for i in 0..n { + let (s, t) = ((i as f64 + 0.5) / n as f64, (j as f64 + 0.5) / n as f64); + let q = m.eval(s, t); + let (qs, qt) = (m.eval(s + h, t), m.eval(s, t + h)); + if (qs[0] - q[0]) * (qt[1] - q[1]) - (qt[0] - q[0]) * (qs[1] - q[1]) <= 0.0 { + bad += 1; + } + } + } + bad as f64 / (n * n) as f64 + } + + #[test] + fn a_pull_within_a_quarter_cell_never_folds_the_surface() { + // 75 px cells: dragging a quarter of a cell, any direction, anywhere in the grid must not tear the picture. + let base = BezierMesh::identity([0.0, 0.0, 300.0, 300.0], 4, 4); + for (s, t) in [(0.42, 0.42), (0.75, 0.2), (0.2, 0.2), (0.5, 0.5), (0.85, 0.7), (0.3, 0.7)] { + for (dx, dy) in [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (0.7, 0.7), (-0.7, 0.7)] { + let mut m = base.clone(); + m.pull(&base.points, s, t, [dx * 19.0, dy * 19.0]); + assert_eq!(fold_fraction(&m), 0.0, "({s}, {t}) by ({dx}, {dy}) quarter cells"); + } + } + } + + #[test] + fn a_pull_in_the_middle_leaves_the_border_where_it_is() { + let mut base = BezierMesh::identity([0.0, 0.0, 600.0, 800.0], 1, 1); + for s in [0.33, 0.66] { + assert!(base.split_u(s)); + } + assert!(base.split_v(0.5)); + let (nx, ny) = (base.nx(), base.ny()); + let on_border = |i: usize, j: usize| i == 0 || j == 0 || i == nx - 1 || j == ny - 1; + let mut m = base.clone(); + m.pull(&base.points, 0.5, 0.5, [-180.0, 40.0]); + for j in 0..ny { + for i in 0..nx { + if on_border(i, j) { + let (a, b) = (m.points[j * nx + i], base.points[j * nx + i]); + assert!((a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6, "border point ({i}, {j}) moved"); + } + } + } + // The pull itself still lands exactly. + let (before, after) = (base.eval(0.5, 0.5), m.eval(0.5, 0.5)); + assert!((after[0] - before[0] + 180.0).abs() < 1e-4 && (after[1] - before[1] - 40.0).abs() < 1e-4); + } + + #[test] + fn a_pull_anywhere_away_from_the_border_never_moves_the_border() { + // The default one-patch grid and a cut one; grabs all over, none within 12% of an edge. + let one = BezierMesh::identity([0.0, 0.0, 600.0, 800.0], 1, 1); + let mut cut = one.clone(); + assert!(cut.split_u(0.4) && cut.split_u(0.7) && cut.split_v(0.35) && cut.split_v(0.8)); + for base in [one, cut] { + let (nx, ny) = (base.nx(), base.ny()); + for (s, t) in [(0.5, 0.5), (0.2, 0.5), (0.8, 0.3), (0.3, 0.85), (0.14, 0.14), (0.86, 0.5)] { + for d in [[-400.0, 0.0], [250.0, -90.0], [0.0, 300.0]] { + let mut m = base.clone(); + m.pull(&base.points, s, t, d); + for j in 0..ny { + for i in 0..nx { + if i == 0 || j == 0 || i == nx - 1 || j == ny - 1 { + let (a, b) = (m.points[j * nx + i], base.points[j * nx + i]); + assert!((a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6, "({s}, {t}) by {d:?} moved border point ({i}, {j})"); + } + } + } + let (before, after) = (base.eval(s, t), m.eval(s, t)); + assert!((after[0] - before[0] - d[0]).abs() < 1e-3 && (after[1] - before[1] - d[1]).abs() < 1e-3, "({s}, {t}) by {d:?}: {after:?}"); + } + } + } + } + + #[test] + fn pulling_on_the_border_bows_it_and_leaves_the_corners_put() { + // A one-patch grid, grabbed on the middle of its right edge and pulled 170 px out. + let base = BezierMesh::identity([0.0, 0.0, 800.0, 1200.0], 1, 1); + let mut m = base.clone(); + m.pull(&base.points, 1.0, 0.5, [170.0, 0.0]); + let at = m.eval(1.0, 0.5); + assert!((at[0] - 970.0).abs() < 1e-3 && (at[1] - 600.0).abs() < 1e-3, "{at:?}"); + for (s, t) in [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)] { + let (a, b) = (m.eval(s, t), base.eval(s, t)); + assert!((a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6, "corner ({s}, {t}) moved"); + } + // The other three edges did not move. + for k in 0..=10 { + let u = k as f64 / 10.0; + for (s, t) in [(0.0, u), (u, 0.0), (u, 1.0)] { + let (a, b) = (m.eval(s, t), base.eval(s, t)); + assert!((a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6, "edge point ({s}, {t}) moved"); + } + } + // Only the right edge's two handles carry the pull. + let nx = base.nx(); + let moved: Vec = (0..m.points.len()).filter(|k| m.points[*k] != base.points[*k]).collect(); + assert_eq!(moved, vec![nx + 3, 2 * nx + 3], "{moved:?}"); + assert_eq!(fold_fraction(&m), 0.0); + } + + #[test] + fn pull_keeps_the_surface_smooth_across_the_grid_lines() { + // A grid cut unevenly both ways, then pulled hard inside one cell and then another. + let mut base = BezierMesh::identity([0.0, 0.0, 300.0, 300.0], 1, 1); + for s in [0.3, 0.55, 0.9] { + assert!(base.split_u(s)); + assert!(base.split_v(s)); + } + assert!(tangent_defect(&base) < 1e-9, "cuts are exact"); + for (s, t, d) in [(0.42, 0.42, [60.0, -35.0]), (0.75, 0.2, [-40.0, 50.0]), (0.1, 0.95, [30.0, 30.0])] { + let mut m = base.clone(); + m.pull(&base.points, s, t, d); + assert!(tangent_defect(&m) < 1e-6, "({s}, {t}): {}", tangent_defect(&m)); + let before = base.eval(s, t); + let after = m.eval(s, t); + assert!((after[0] - before[0] - d[0]).abs() < 1e-4 && (after[1] - before[1] - d[1]).abs() < 1e-4, "{after:?}"); + } + } + + #[test] + fn pull_moves_the_grabbed_surface_point_exactly_and_stays_local() { + let base = BezierMesh::identity(B, 3, 2); + for (s, t) in [(0.5, 0.5), (0.1, 0.9), (0.4, 0.25), (0.0, 0.5), (1.0, 0.3)] { + let mut m = base.clone(); + let before = base.eval(s, t); + m.pull(&base.points, s, t, [7.0, -4.5]); + let after = m.eval(s, t); + assert!((after[0] - before[0] - 7.0).abs() < 1e-4 && (after[1] - before[1] + 4.5).abs() < 1e-4, "({s}, {t}): {after:?}"); + } + // The pull stays near the grabbed patch: the far end of the grid does not move. + let mut m = base.clone(); + m.pull(&base.points, 0.1, 0.25, [5.0, 5.0]); + let nx = base.nx(); + let moved = |k: usize| (m.points[k][0] - base.points[k][0]).abs() > 1e-6 || (m.points[k][1] - base.points[k][1]).abs() > 1e-6; + assert!((0..base.ny()).all(|j| !moved(j * nx + nx - 1)), "last column stays put"); + assert!((0..nx).any(moved), "something moved"); + // Pulling again from the same base replaces the pull (a drag is not cumulative). + m.pull(&base.points, 0.5, 0.25, [0.0, 0.0]); + assert!(m.points.iter().zip(&base.points).all(|(a, b)| (a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9)); + } + fn close(a: (f64, f64), b: (f64, f64), eps: f64) -> bool { (a.0 - b.0).abs() < eps && (a.1 - b.1).abs() < eps } diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index c06b4a8651..59677a1699 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -18,7 +18,7 @@ struct Op { opacity: f32, mask_density: f32, mask_default: f32, - _pad1: f32, + fill: f32, // layer Fill for fs_blend (psblend::composite_fill); 1 elsewhere tex_origin: vec2, tex_size: vec2, mask_origin: vec2, @@ -48,7 +48,7 @@ const F_STROKE_OUT: u32 = 1024u; // effect paint: outside stroke band const F_FIRST: u32 = 2048u; // outside strokes: nothing accumulated yet const F_CHANNELS: u32 = 4096u; // lerp: per-channel weights in p0 (channel restrictions) const F_LAB: u32 = 65536u; // Lab document: Normal mixes in CIELAB -const F_HDR: u32 = 262144u; // 32-bit float document: Add / Divide don't clip at 1 +const F_HDR: u32 = 262144u; // 32-bit float document: Add / Divide and Hue/Saturation/Color/Luminosity don't clip at 1 const F_QUANT: u32 = 32768u; // lerp: A rounded to p0.x steps (adjustment results, integer docs) const F_ADD_DIFF: u32 = 16384u; // lerp: A + (B - C) premultiplied (clips on pass-through groups) const F_TEXT_GAMMA: u32 = 8192u; // blend / atop / fx merge: type layer, mix coverage at gamma p4.w @@ -216,22 +216,24 @@ fn blend_channel(mode: i32, cb: f32, cs: f32) -> f32 { fn lum(c: vec3) -> f32 { return 0.3 * c.r + 0.59 * c.g + 0.11 * c.b; } -fn clip_color(c: vec3) -> vec3 { +// photocraft_color::blend::clip_color: towards grey until within 0..top; a luminosity outside +// that range has no in-gamut colour (and max − lum is rounding noise), so it gives its grey. +fn clip_color(c: vec3, top: f32) -> vec3 { let l = lum(c); let n = min(min(c.r, c.g), c.b); let x = max(max(c.r, c.g), c.b); var out = c; if (n < 0.0) { let d = l - n; - if (abs(d) < 1e-12) { out = vec3(l); } else { out = l + (out - l) * l / d; } + if (l > 0.0 && d > 0.0) { out = l + (out - l) * l / d; } else { out = vec3(l); } } - if (x > 1.0) { + if (x > top) { let d = x - l; - if (abs(d) < 1e-12) { out = vec3(l); } else { out = l + (out - l) * (1.0 - l) / d; } + if (l < top && d > 0.0) { out = l + (out - l) * (top - l) / d; } else { out = vec3(l); } } return out; } -fn set_lum(c: vec3, l: f32) -> vec3 { return clip_color(c + (l - lum(c))); } +fn set_lum(c: vec3, l: f32, top: f32) -> vec3 { return clip_color(c + (l - lum(c)), top); } fn sat(c: vec3) -> f32 { return max(max(c.r, c.g), c.b) - min(min(c.r, c.g), c.b); } fn set_sat(c: vec3, s: f32) -> vec3 { let mx = max(max(c.r, c.g), c.b); @@ -241,11 +243,13 @@ fn set_sat(c: vec3, s: f32) -> vec3 { } fn blend_rgb(mode: i32, cb: vec3, cs: vec3) -> vec3 { + // blend_rgb_within: 32-bit documents keep values above 1 (f32::MAX). + let top = select(1.0, F32_MAX, (op.flags & F_HDR) != 0u); switch mode { - case 24: { return set_lum(set_sat(cs, sat(cb)), lum(cb)); } // Hue - case 25: { return set_lum(set_sat(cb, sat(cs)), lum(cb)); } // Saturation - case 26: { return set_lum(cs, lum(cb)); } // Color - case 27: { return set_lum(cb, lum(cs)); } // Luminosity + case 24: { return set_lum(set_sat(cs, sat(cb)), lum(cb), top); } // Hue + case 25: { return set_lum(set_sat(cb, sat(cs)), lum(cb), top); } // Saturation + case 26: { return set_lum(cs, lum(cb), top); } // Color + case 27: { return set_lum(cb, lum(cs), top); } // Luminosity case 7: { return select(cb, cs, lum(cs) < lum(cb)); } // DarkerColor case 12: { return select(cb, cs, lum(cs) > lum(cb)); } // LighterColor default: { @@ -295,6 +299,46 @@ fn composite_g(mode: i32, b: vec4, s: vec4, opacity: f32, gamma_on: bo return vec4(text_dec(pw / ao, g), ao); } +// psblend::fill_is_special: Color Burn, Linear Burn, Color Dodge, Linear Dodge, Vivid Light, +// Linear Light, Hard Mix, Difference (mode_index 5, 6, 10, 11, 16, 17, 19, 20). +fn fill_is_special(mode: i32) -> bool { + return mode == 5 || mode == 6 || mode == 10 || mode == 11 || mode == 16 || mode == 17 || mode == 19 || mode == 20; +} + +// psblend::composite_fill, with a single blend_rgb call (fs_blend's only composite: every +// inlined blend_rgb copy grows the shader, and WARP compiles it slowly). Normal modes take Fill +// as coverage; below 100% Fill the special eight blend a source pulled toward the mode's neutral +// colour (Hard Mix: (cb - f(1 - cs)) / (1 - f)) at `opacity`, Fill staying coverage over +// transparency. Lab documents mix Normal in CIELAB; type layers mix in the text gamma space. +fn composite_fill(mode: i32, b: vec4, s: vec4, opacity: f32, fill: f32, gamma_on: bool) -> vec4 { + let f = clamp(fill, 0.0, 1.0); + let special = f < 1.0 && fill_is_special(mode); + let ab = b.a; + var over = s.a * opacity; + var alone = over * f; + if (!special) { over = alone; } + if (over <= 0.0) { return b; } + let ao = ab + (1.0 - ab) * alone; + if (ao <= 0.0) { return vec4(0.0); } + if (!special && !gamma_on && (op.flags & F_LAB) != 0u && mode == M_NORMAL && ab > 0.0) { + let m = srgb_to_lab(b.rgb) * (ab * (1.0 - over) / ao) + srgb_to_lab(s.rgb) * (over / ao); + return vec4(lab_to_srgb(m), ao); + } + var neutral = 0.0; + if (mode == 5 || mode == 6) { neutral = 1.0; } + if (mode == 16 || mode == 17) { neutral = 0.5; } + var cs = s.rgb; + if (special) { cs = vec3(neutral) + (s.rgb - vec3(neutral)) * f; } + var bl = blend_rgb(mode, b.rgb, cs); + if (special && mode == 19) { bl = clamp((b.rgb - f * (vec3(1.0) - s.rgb)) / (1.0 - f), vec3(0.0), vec3(1.0)); } + let wb = ab * (1.0 - over) / ao; + let wbs = ab * over / ao; + let ws = (1.0 - ab) * alone / ao; + if (!gamma_on) { return vec4(wb * b.rgb + wbs * bl + ws * s.rgb, ao); } + let g = op.p4.w; + return vec4(text_dec(wb * text_enc(b.rgb, g) + wbs * text_enc(bl, g) + ws * text_enc(s.rgb, g), g), ao); +} + // photocraft_color::convert::{srgb_to_lab, lab_to_srgb} (D50, Bradford to sRGB). const D50: vec3 = vec3(0.96422, 1.0, 0.82521); fn lab_f(t: f32) -> f32 { @@ -548,7 +592,7 @@ fn adjust(c: vec3) -> vec3 { return vec3(select(0.0, 1.0, round_half_up(gray(c) * 255.0) >= p0.x)); } case 3: { return vec3(posterize(c.r, p0.x), posterize(c.g, p0.x), posterize(c.b, p0.x)); } - case 4: { return clamp((c - 0.5) * p0.y + 0.5 + p0.x, vec3(0.0), vec3(1.0)); } // B/C legacy + case 4: { return clamp((c + p0.x - p0.z) * p0.y + p0.z + p0.w, vec3(0.0), vec3(1.0)); } // B/C legacy: brightness before (p0.x) or after (p0.w) contrast around compose::adjust::LEGACY_PIVOT (p0.z) case 5: { // B/C modern let b = p0.x; let ct = p0.y; return vec3(mcontrast(mbright(c.r, b), ct), mcontrast(mbright(c.g, b), ct), mcontrast(mbright(c.b, b), ct)); @@ -573,7 +617,7 @@ fn adjust(c: vec3) -> vec3 { let light = clamp(p0.z + dl, -1.0, 1.0); if (p0.w > 0.5) { hh = rem_euclid(p0.x, 360.0) / 360.0; - ss = max(abs(sat), 0.25); + ss = min(abs(sat), 1.0); } else { hh = rem_euclid(hsl.x + (p0.x + dh) / 360.0, 1.0); ss = clamp(hsl.y * (1.0 + sat), 0.0, 1.0); @@ -608,7 +652,7 @@ fn adjust(c: vec3) -> vec3 { let g = p3.y; let lin = vec3(t_decode(c.r, g), t_decode(c.g, g), t_decode(c.b, g)); var f = vec3(t_encode(dot(p0.xyz, lin), g), t_encode(dot(p1.xyz, lin), g), t_encode(dot(p2.xyz, lin), g)); - if (p3.x > 0.5) { f = set_lum(f, lum(c)); } + if (p3.x > 0.5) { f = set_lum(f, lum(c), 1.0); } return clamp(f, vec3(0.0), vec3(1.0)); } case 12: { // Black & White @@ -703,9 +747,11 @@ fn layer_texel(d: vec2) -> vec4 { if ((op.flags & F_GRADIENT) != 0u) { let t = gradient_t(d); var c = vec4(lut(0, t), lut(1, t), lut(2, t), lut(3, t)); - // p2.w: dither (gradient fills). + // p2.w: dither (gradient fills), rounded to p2.z levels on integer documents. if (op.p2.w > 0.5) { - c = vec4(clamp(c.rgb + (dither_noise(d) - 0.5) / 255.0, vec3(0.0), vec3(1.0)), c.a); + var rgb = clamp(c.rgb + (dither_noise(d) - 0.5) / 255.0, vec3(0.0), vec3(1.0)); + if (op.p2.z > 0.0) { rgb = floor(rgb * op.p2.z + 0.5) / op.p2.z; } + c = vec4(rgb, c.a); } return c; } @@ -746,10 +792,10 @@ fn fs_blend(in: VOut) -> @location(0) vec4 { var s = textureLoad(tex_b, p, 0); if (s.a <= 0.0) { return b; } if (op.mode == M_DISSOLVE) { - s.a = select(0.0, 1.0, dissolve_noise(doc_px(p)) < s.a * op.opacity); + s.a = select(0.0, 1.0, dissolve_noise(doc_px(p)) < s.a * op.opacity * op.fill); return composite(M_NORMAL, b, s, 1.0); } - return composite_g(op.mode, b, s, op.opacity, (op.flags & F_TEXT_GAMMA) != 0u); + return composite_fill(op.mode, b, s, op.opacity, op.fill, (op.flags & F_TEXT_GAMMA) != 0u); } // composite_atop(base = A, src = B): blend as if the base were opaque, keep its alpha. diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index a74ff10a46..edd4e87b6c 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -367,8 +367,8 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, if g.chisel_soft > 0.0 { b.conv(h, photocraft_compose::effects::tent_kernel(g.chisel_soft)) } else { h } }; if let Some(c) = &bv.contour { - // Contour element: the height through the contour over its range. - let lut = photocraft_compose::effects::ranged_lut(&c.contour, c.range).unwrap_or_else(|| (0..4096).map(|k| k as f32 / 4095.0).collect()); + // Contour element: the height through the contour (`bevel_contour_lut`). + let lut = photocraft_compose::effects::bevel_contour_lut(c); let mut st = stage(Kernel::MFinish, Some(h), None, [0.0, 0.0, 1.0, 0.0], 0); st.lut = Some(Arc::new(lut)); h = b.push(st); diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 5551924f04..a19e1b4042 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -170,6 +170,9 @@ struct Resident { format: PixelFormat, tiles: HashMap>, default_nonzero: bool, + /// Bit pattern of the surface's default pixel when the texture was filled. Tiles the surface + /// doesn't store hold it, so a changed default (Invert, Fill) makes the texture stale. + default_bits: Vec, doc: DocId, last_used: u64, /// Cell visit that last used it (least recently used pages are evicted first). @@ -1243,7 +1246,12 @@ impl Compositor { let format = surface.format(); let kind = TexKind::for_surface(key.1, format); let cmyk = if format.mode == photocraft_color::ColorMode::Cmyk { self.cmyk } else { 0 }; - let stale = self.residents.get(&key).is_none_or(|r| r.region != region || r.kind != kind || r.format != format || r.cmyk != cmyk); + let default_pixel = surface.default_pixel(); + let default_bits: Vec = default_pixel.iter().map(|v| v.to_bits()).collect(); + let stale = self + .residents + .get(&key) + .is_none_or(|r| r.region != region || r.kind != kind || r.format != format || r.cmyk != cmyk || r.default_bits != default_bits); if stale { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("pc_compose_layer"), @@ -1256,8 +1264,9 @@ impl Compositor { view_formats: &[], }); let view = texture.create_view(&Default::default()); - let default_nonzero = surface.default_pixel().iter().any(|v| *v != 0.0); - let mut r = Resident { texture, view, region, kind, format, tiles: HashMap::new(), default_nonzero, doc, last_used: 0, stamp: 0, cmyk }; + let default_nonzero = default_pixel.iter().any(|v| *v != 0.0); + let mut r = + Resident { texture, view, region, kind, format, tiles: HashMap::new(), default_nonzero, default_bits, doc, last_used: 0, stamp: 0, cmyk }; // A new texture starts zeroed: write the tiles that are present, and the default pixel // where they are missing if it isn't zero. Coordinates sharing a tile (a solid fill, // the default) take one upload and GPU copies (#1774). @@ -1651,7 +1660,7 @@ fn op_words(p: &plan::Pass<'_>, tex: Option<[i32; 4]>, mask: Option<[i32; 4]>, m F(p.opacity), F(density), F(mdefault), - F(0.0), + F(p.fill), I(to[0]), I(to[1]), I(ts[0]), diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 340a77f1aa..9ba5f13f54 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -171,6 +171,9 @@ pub struct Pass<'a> { pub d: Option, pub mode: BlendMode, pub opacity: f32, + /// The layer's Fill where it isn't folded into `opacity`: the final blend of a plain layer + /// (`Kernel::Blend`), so the special eight can apply it inside the blend. 1 elsewhere. + pub fill: f32, /// Layer pixels (raster / text / shape / smart cache / fill cache). pub tex: Option>, /// Colour outside `tex` (the surface's default pixel), the solid fill colour, or the effect @@ -232,6 +235,7 @@ impl<'a> Pass<'a> { d: None, mode: BlendMode::Normal, opacity: 1.0, + fill: 1.0, tex: None, color: [0.0; 4], mask: None, @@ -362,7 +366,8 @@ pub const F_FIRST: u32 = 2048; pub const F_CHANNELS: u32 = 4096; /// Lab document: Normal blending mixes in CIELAB (`psblend::LAB_MIX`). pub const F_LAB: u32 = 65536; -/// 32-bit float document: Linear Dodge (Add) and Divide don't clip at 1 (`psblend::HDR`). +/// 32-bit float document: Linear Dodge (Add) and Divide don't clip at 1, and the non-separable +/// modes clip only below 0 (`psblend::HDR`). pub const F_HDR: u32 = 262144; /// `Lerp`: A rounded to `p0.x` steps per unit (adjustment results on integer documents). pub const F_QUANT: u32 = 32768; @@ -670,7 +675,8 @@ impl<'a> Planner<'a> { p.a = Some(backdrop); p.b = Some(content); p.mode = layer.blend; - p.opacity = opacity; + p.opacity = layer.opacity; + p.fill = layer.fill_opacity; return Ok(self.emit(p)); } @@ -703,7 +709,8 @@ impl<'a> Planner<'a> { p.a = Some(if clip.is_some() { self.retain(backdrop) } else { backdrop }); p.b = Some(content); p.mode = layer.blend; - p.opacity = opacity; + p.opacity = layer.opacity; + p.fill = layer.fill_opacity; p.flags = gamma_flag(layer); p.extra[3] = photocraft_compose::text_gamma(layer); p.clip = clip; @@ -821,6 +828,10 @@ impl<'a> Planner<'a> { } fn fill(&self, p: &mut Pass<'a>, f: &Fill, frame: photocraft_geom::Rect) -> Result<(), Unsupported> { + // The RGB shader cannot interpolate or dither native ink channels. + if self.cx.mode == photocraft_color::ColorMode::Cmyk && !matches!(f, Fill::Pattern { .. }) { + return Err(Unsupported("native CMYK fill (composited on the CPU)".into())); + } match f { Fill::Solid(c) => { let rgb = c.to_rgb(); @@ -835,8 +846,11 @@ impl<'a> Planner<'a> { p.params[0] = [angle, scale, if *reverse { 1.0 } else { 0.0 }, style_index(*style)]; let c = frame; p.params[1] = [c.x0 as f32, c.y0 as f32, c.width() as f32, c.height() as f32]; - // p2.xy: centre offset; p2.w: dither (the shared position hash, see the shader). - p.params[2] = [offset.0, offset.1, 0.0, if *dither { 1.0 } else { 0.0 }]; + // p2.xy: centre offset; p2.z: the depth's levels a dithered pixel is rounded to + // (0: float, compose::gradient_fill::render_quantized); p2.w: dither (the shared + // position hash, see the shader). + let quantum = photocraft_compose::adjustment_quantum(self.cx.depth).unwrap_or(0.0); + p.params[2] = [offset.0, offset.1, quantum, if *dither { 1.0 } else { 0.0 }]; let ramp = photocraft_compose::gradient_fill::Ramp::new(f); let mut rows = vec![[0.0f32; 4096]; 4]; for k in 0..4096 { @@ -1505,7 +1519,9 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf Adjustment::BrightnessContrast { brightness, contrast, legacy: true } => { let c = contrast.clamp(-100.0, 99.0); let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 }; - p[0] = [brightness / 255.0, k, 0.0, 0.0]; + // Brightness before contrast when it is raised, after it when lowered (compose::adjust). + let (pre, post) = if c >= 0.0 { (brightness / 255.0, 0.0) } else { (0.0, brightness / 255.0) }; + p[0] = [pre, k, photocraft_compose::adjust::LEGACY_PIVOT, post]; (4, p, None) } Adjustment::BrightnessContrast { brightness, contrast, .. } => { @@ -1681,6 +1697,26 @@ mod tests { use photocraft_color::{Color, ColorMode, SampleType}; use photocraft_geom::Size; + #[test] + fn native_cmyk_fills_fall_back_only_without_a_valid_cache() { + use photocraft_doc::{FillCache, GradientStyle}; + for depth in photocraft_color::SampleType::ALL { + for fill in [Fill::Solid(Color::WHITE), Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, GradientStyle::Linear, false)] { + let mut doc = Document::new("CMYK", photocraft_geom::Size::new(3, 2), photocraft_color::ColorMode::Cmyk, depth); + doc.layers.push(Layer::new("Fill", LayerContent::Fill(fill.clone()))); + assert!(plan(&doc).unwrap_err().0.contains("native CMYK fill")); + doc.layers[0].visible = false; + assert!(plan(&doc).is_ok()); + doc.layers[0].visible = true; + doc.layers[0].fill_cache = Some(FillCache { fill, surface: photocraft_raster::Surface::new(doc.pixel_format()) }); + assert!(plan(&doc).is_ok()); + doc.layers[0].fill_cache.as_mut().unwrap().fill = Fill::Solid(Color::TRANSPARENT); + assert!(plan(&doc).is_err()); + doc.mode = photocraft_color::ColorMode::Rgb; + assert!(plan(&doc).is_ok()); + } + } + } #[test] fn outer_glow_plan_knocks_out_see_through_fill() { use photocraft_doc::{Contour, FxCommon, GlowSource, GlowTechnique}; diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 342e901b7f..742ba92677 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -170,6 +170,37 @@ fn blend_modes() { } } +/// Photoshop's special eight apply Fill inside the blend (psblend::composite_fill): the GPU's +/// blend pass takes Fill separately and must match the CPU at low and middle Fill, with Opacity, +/// over transparency and over an opaque backdrop. +#[test] +fn special_eight_fill_matches_cpu() { + let Some(mut g) = gpu() else { return }; + let special = [ + BlendMode::ColorBurn, + BlendMode::LinearBurn, + BlendMode::ColorDodge, + BlendMode::LinearDodge, + BlendMode::VividLight, + BlendMode::LinearLight, + BlendMode::HardMix, + BlendMode::Difference, + ]; + for mode in special { + for fill in [0.3, 0.6] { + let mut d = base_doc(64, 48); + let mut l = noise_layer("top", PixelFormat::RGBA8, Rect::new(4, 3, 60, 45), 2, 0.0); + l.blend = mode; + l.opacity = 0.7; + l.fill_opacity = fill; + d.layers.push(l); + check(&mut g, &d, &format!("{mode:?} fill {fill}")); + d.layers[0] = noise_layer("bg", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 64, 48), 5, 1.0); + check(&mut g, &d, &format!("{mode:?} fill {fill} opaque")); + } + } +} + #[test] fn xor_rounding_on_half_float_targets() { let Some((_adapter, device, queue, _lock)) = any_device() else { return }; @@ -220,6 +251,7 @@ fn adjustments() -> Vec { }, Adjustment::HueSaturation { hue: 40.0, saturation: 30.0, lightness: -10.0, colorize: false, ranges: HueRange::defaults() }, Adjustment::HueSaturation { hue: 200.0, saturation: 50.0, lightness: 20.0, colorize: true, ranges: HueRange::defaults() }, + Adjustment::HueSaturation { hue: 30.0, saturation: 0.0, lightness: -15.0, colorize: true, ranges: HueRange::defaults() }, Adjustment::HueSaturation { hue: -10.0, saturation: 10.0, lightness: 5.0, colorize: false, ranges: hue_ranges() }, Adjustment::Vibrance { vibrance: 50.0, saturation: -20.0 }, Adjustment::Vibrance { vibrance: -60.0, saturation: 0.0 }, @@ -552,6 +584,22 @@ fn fills_and_dissolve() { d.layers.push(clip); d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))); check(&mut g, &d, "opaque gradient over everything"); + // Stacked dithered gradients, the top one in Color Dodge (#2755): the dither is rounded to + // the document's levels on both sides, so the dodge doesn't blow it up into speckle. + let mut d = base_doc(64, 48); + for (name, stops, angle, blend) in [ + ("under", vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], 0.0, BlendMode::Normal), + ("dodge", vec![(0.0, Color::rgb(0.88, 0.88, 0.88)), (1.0, Color::rgb(0.97, 0.97, 0.97))], 90.0, BlendMode::ColorDodge), + ] { + let mut f = Fill::gradient(stops, angle, 1.0, GradientStyle::Linear, false); + if let Fill::Gradient { dither, .. } = &mut f { + *dither = true; + } + let mut l = Layer::new(name, LayerContent::Fill(f)); + l.blend = blend; + d.layers.push(l); + } + check(&mut g, &d, "dithered gradients under Color Dodge"); let mut d = base_doc(64, 48); let mut l = noise_layer("dis", PixelFormat::RGBA8, Rect::new(0, 0, 64, 48), 41, 0.2); l.blend = BlendMode::Dissolve; @@ -608,6 +656,27 @@ fn incremental_updates_follow_the_document() { check(&mut g, &d, "opacity"); } +/// #2422: a mask whose default pixel changes without touching its tiles (Invert on a sparse mask) +/// must re-upload the absent tiles between its stored ones, not keep the old default on the GPU. +#[test] +fn changed_mask_default_updates_absent_tiles() { + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(600, 300); + d.layers.push(noise_layer("top", PixelFormat::RGBA8, Rect::new(0, 0, 600, 300), 61, 0.5)); + // Tiles in opposite corners: the resident texture spans the absent tiles between them. + let corners = [Rect::new(0, 0, 70, 70), Rect::new(530, 230, 600, 300)]; + let data = vec![0.5f32; 70 * 70]; + for default in [0.0, 1.0] { + let mut m = LayerMask::reveal_all(); + m.surface = photocraft_raster::Surface::with_default(PixelFormat::GRAY8, &[default]); + for r in corners { + m.surface.write_region(r, &data); + } + d.layers[1].mask = Some(m); + check(&mut g, &d, &format!("mask default {default}")); + } +} + /// #1774: tiles shared across coordinates (a solid fill, a cleared area, the default pixel of a /// mask) upload once and are copied on the GPU, at 8 and 16 bits, in RGB and grayscale, on a new /// layer page and on an incremental update. @@ -1427,6 +1496,72 @@ fn blend_mode_extremes() { } } +/// Colours for the non-separable modes: in gamut, greys and near-greys above 1 (lum ≈ max, where +/// ClipColor divided by rounding noise), saturated colours above 1, negatives and mixes. All are +/// exact in half floats (the GPU's layer textures of 16/32-bit documents), so both compositors +/// see the same inputs. +const NONSEP_COLORS: [[f32; 3]; 12] = [ + [0.25, 0.5, 0.75], + [1.0, 1.0, 1.0], + [0.0, 0.0, 0.0], + [1.5, 1.5, 1.5], + [2.0, 2.0, 2.0], + [4.0, 4.0, 4.0], + [2.0, 2.0, 1.996_093_8], + [4.0, 3.992_187_5, 4.0], + [3.0, 0.25, 0.125], + [0.125, 2.5, 0.375], + [1.25, 0.875, 3.75], + [-0.25, 0.5, 1.375], +]; + +/// Every pair of `colors` (backdrop column, source row) as one opaque pixel, in `mode`. +fn nonsep_doc(mode: BlendMode, depth: SampleType, colors: &[[f32; 3]]) -> Document { + let n = colors.len() as u32; + let mut d = Document::new("x", Size::new(n, n), ColorMode::Rgb, depth); + let fmt = d.pixel_format(); + let mut bg = Layer::raster("bg", fmt); + let mut top = Layer::raster("top", fmt); + for (i, b) in colors.iter().enumerate() { + for (j, s) in colors.iter().enumerate() { + let r = Rect::from_xywh(i as i32, j as i32, 1, 1); + bg.surface_mut().unwrap().fill_rect(r, &photocraft_raster::from_rgba(&fmt, [b[0], b[1], b[2], 1.0])); + top.surface_mut().unwrap().fill_rect(r, &photocraft_raster::from_rgba(&fmt, [s[0], s[1], s[2], 1.0])); + } + } + top.blend = mode; + d.layers.push(bg); + d.layers.push(top); + d +} + +const NONSEP_MODES: [BlendMode; 6] = + [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color, BlendMode::Luminosity, BlendMode::DarkerColor, BlendMode::LighterColor]; + +#[test] +fn non_separable_modes_on_hdr_values() { + // 32-bit documents hold values above 1 (and below 0): Hue / Saturation / Color / Luminosity + // used to divide by the rounding noise of `max − lum` in ClipColor there, which the CPU and + // GPU round differently (Hue over a grey of 2: CPU 1.0, GPU 2.82). + let Some(mut g) = gpu() else { return }; + let mut failures = Vec::new(); + for mode in NONSEP_MODES { + if let Err(e) = diff_rect(&mut g, &nonsep_doc(mode, SampleType::F32, &NONSEP_COLORS), Rect::new(0, 0, 12, 12), &format!("hdr {mode:?}")) { + failures.push(e); + } + } + // Integer documents store 0..1 only. + let in_gamut: Vec<[f32; 3]> = NONSEP_COLORS.iter().map(|c| c.map(|v| v.clamp(0.0, 1.0))).collect(); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for mode in NONSEP_MODES { + if let Err(e) = diff_rect(&mut g, &nonsep_doc(mode, depth, &in_gamut), Rect::new(0, 0, 12, 12), &format!("in gamut {mode:?} {depth:?}")) { + failures.push(e); + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + #[test] fn float_documents_blend_past_white() { // 32-bit documents hold values above 1; Linear Dodge (Add) and Divide don't clip them there diff --git a/crates/io/src/exr_parts.rs b/crates/io/src/exr_parts.rs new file mode 100644 index 0000000000..93b8e85f36 --- /dev/null +++ b/crates/io/src/exr_parts.rs @@ -0,0 +1,335 @@ +//! Multi-part EXR and channel groups as layers (a Maya/Arnold render writes one part per +//! AOV, or a single part with prefixed channel sets like `diffuse.R`): the entries of a +//! file listed for a chooser ([`entries`]), and the chosen ones imported as the layers of +//! one document ([`import`]), with an optional additive beauty precomp from the AOVs. + +use photocraft_cms::Builtin; +use photocraft_codecs::{self as codecs, ChannelLayout, DecodeOptions, Image, SampleType}; +use photocraft_color::{ColorMode, SampleType as DocSample}; +use photocraft_doc::{Document, Layer, LayerContent, Size}; +use photocraft_raster::Surface; + +use crate::{ImportResult, IoError, flat::image_surface}; + +/// One selectable line of the chooser: a whole part, a prefixed channel group inside a +/// part, or a Cryptomatte layer (listed for information; its samples are not image layers). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Entry { + pub kind: EntryKind, + /// The name as shown and selected: the part name, the channel-group prefix, or the + /// Cryptomatte layer name. + pub name: String, + /// The part's `view` attribute (stereo files), when it has one. + pub view: Option, + pub width: u32, + pub height: u32, + /// Image channels that would import (4 for RGBA, 3 for RGB); Cryptomatte: stream count. + pub channels: usize, + /// Cryptomatte only: objects in the parsed manifest. + pub objects: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EntryKind { + Part, + Group, + Cryptomatte, +} + +/// One channel group: where it lives and which exact channels make up its RGB(A). +#[derive(Clone, Debug)] +struct Group { + /// The prefix before the last dot (`diffuse` of `diffuse.R`). + prefix: String, + part: usize, + channels: Vec, + alpha: Option, +} + +/// The name an AOV normalizes to for the precomp check (`Direct_Diffuse` and +/// `directDiffuse` are the same AOV). +fn normalize(name: &str) -> String { + name.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase() +} + +/// The additive AOVs a beauty precomp is built from: the light-path set Arnold writes. +const PRECOMP_REQUIRED: [&str; 5] = ["directdiffuse", "indirectdiffuse", "directspecular", "indirectspecular", "emission"]; +const PRECOMP_OPTIONAL: [&str; 3] = ["transmission", "sss", "volume"]; + +/// The base channel name (`R` of `diffuse.R`, `R` of `R`). +fn channel_base(name: &str) -> &str { + name.rsplit('.').next().unwrap_or(name) +} + +/// The prefixed RGB(A) channel sets of every flat part (`diffuse.R/G/B` make the group +/// `diffuse`). Unprefixed channels are no group, and a prefix ending in digits is a +/// Cryptomatte stream (`crypto_asset00`), not a group. Each prefix is listed once. +fn groups(bytes: &[u8]) -> Result, IoError> { + let mut out: Vec = Vec::new(); + for part in codecs::exr_info(bytes, &Default::default())? { + if part.deep { + continue; + } + let mut found: Vec = Vec::new(); + for c in &part.channels { + let Some((prefix, base)) = channel_base_split(&c.name) else { continue }; + if prefix.chars().last().is_some_and(|ch| ch.is_ascii_digit()) { + continue; + } + if found.iter_mut().all(|g| g.prefix != prefix) { + found.push(Group { prefix: prefix.to_string(), part: part.index, channels: Vec::new(), alpha: None }); + } + let Some(group) = found.iter_mut().find(|g| g.prefix == prefix) else { continue }; + match base { + "R" | "G" | "B" => group.channels.push(c.name.clone()), + "A" => group.alpha = Some(c.name.clone()), + _ => {} + } + } + found.retain(|g| g.channels.len() == 3); + out.extend(found); + } + Ok(out) +} + +/// `(prefix, base)` when the channel name carries a dot (`diffuse.R`); `None` unprefixed. +fn channel_base_split(name: &str) -> Option<(&str, &str)> { + let (prefix, base) = name.rsplit_once('.')?; + (!prefix.is_empty()).then_some((prefix, base)) +} + +/// Lists the entries of a multi-part or channel-grouped EXR for a chooser: flat parts in +/// file order, then the channel groups, then the Cryptomatte layers as information. A file +/// whose parts are all deep is refused (the deep decoder composites those). +pub fn entries(bytes: &[u8]) -> Result, IoError> { + let info = codecs::exr_info(bytes, &Default::default())?; + if info.iter().all(|p| p.deep) { + return Err(IoError::Unsupported("this EXR holds only deep parts; it opens through the deep decoder".into())); + } + let mut out = Vec::new(); + for part in &info { + if part.deep { + continue; + } + out.push(Entry { + kind: EntryKind::Part, + name: part.name.clone().unwrap_or_else(|| format!("part {}", part.index)), + view: part.view.clone(), + width: part.width, + height: part.height, + channels: part.channels.iter().filter(|c| ["R", "G", "B", "A", "Y"].contains(&channel_base(&c.name))).count(), + objects: None, + }); + } + for group in groups(bytes)? { + out.push(Entry { + kind: EntryKind::Group, + name: group.prefix.clone(), + view: None, + width: 0, + height: 0, + channels: 3 + usize::from(group.alpha.is_some()), + objects: None, + }); + } + for layer in codecs::cryptomatte_layers(bytes, &Default::default())? { + out.push(Entry { + kind: EntryKind::Cryptomatte, + name: layer.name.clone(), + view: None, + width: 0, + height: 0, + channels: layer.channels.len(), + objects: Some(layer.manifest.len()), + }); + } + Ok(out) +} + +/// What [`import`] turns into layers. +pub struct Selection { + /// Part names to import as whole layers. + pub parts: Vec, + /// Channel-group prefixes to import as layers. + pub groups: Vec, + /// `"both"`, `"left"` or `"right"`: which stereo views of the parts to consider. + pub view: String, + /// Add a `beauty (precomp)` layer summed from the file's AOV groups (see + /// [`PRECOMP_REQUIRED`]); fails naming the missing AOVs when the set is incomplete. + pub precomp: bool, +} + +/// An image as an importable layer: F16 becomes F32 (lossless, warned once by the caller), +/// the layout matches the document's, the surface is built a band of rows at a time. The +/// returned mode tells the caller whether this layer is grayscale. +fn layer_surface(img: &Image, max: &mut (u32, u32)) -> Result<(Surface, ColorMode), IoError> { + let (layout, mode) = if matches!(img.layout(), ChannelLayout::Gray | ChannelLayout::GrayA) { + (ChannelLayout::GrayA, ColorMode::Grayscale) + } else { + (ChannelLayout::Rgba, ColorMode::Rgb) + }; + max.0 = max.0.max(img.width()); + max.1 = max.1.max(img.height()); + let converted = img.convert(layout, SampleType::F32); + image_surface(&converted, layout, SampleType::F32).map(|s| (s, mode)) +} + +/// Imports the chosen entries as the layers of one document: parts whole, groups from their +/// own channels, F16 kept losslessly as F32, the linear-light EXR values tagged linear sRGB. +/// `precomp` adds the additive sum of the file's AOV groups as a top layer and fails, +/// naming the missing AOVs, when the light-path set is incomplete. +pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result { + let opts = DecodeOptions::default(); + let info = codecs::exr_info(bytes, &opts.limits)?; + if info.is_empty() { + return Err(IoError::Unsupported("the EXR has no parts".into())); + } + let all_groups = groups(bytes)?; + let view_passes = |view: &Option| view.as_ref().is_none_or(|v| sel.view.eq_ignore_ascii_case("both") || v.eq_ignore_ascii_case(&sel.view)); + let part_by_name = + |want: &str| info.iter().find(|p| !p.deep && p.name.as_deref() == Some(want)).ok_or_else(|| IoError::Unsupported(format!("no flat part named {want}"))); + let mut chosen_parts: Vec<&codecs::ExrPartInfo> = Vec::new(); + for want in &sel.parts { + let p = part_by_name(want)?; + if !view_passes(&p.view) { + return Err(IoError::Unsupported(format!("the part {want} is of the {:?} view, the filter is {}", p.view, sel.view))); + } + if !chosen_parts.iter().any(|c| c.index == p.index) { + chosen_parts.push(p); + } + } + let mut chosen_groups: Vec<&Group> = Vec::new(); + for want in &sel.groups { + let g = all_groups.iter().find(|g| g.prefix == *want).ok_or_else(|| IoError::Unsupported(format!("no channel group named {want}")))?; + let part_view = info.get(g.part).and_then(|p| p.view.clone()); + if !view_passes(&part_view) { + return Err(IoError::Unsupported(format!("the group {} is of the {:?} view, the filter is {}", want, part_view, sel.view))); + } + if !chosen_groups.iter().any(|c| c.prefix == g.prefix) { + chosen_groups.push(g); + } + } + if chosen_parts.is_empty() && chosen_groups.is_empty() && !sel.precomp { + return Err(IoError::Unsupported("no parts or groups selected".into())); + } + + // Decode each needed whole part once; groups come from their part's named channels. + let mut part_pixels: Vec> = vec![None; info.len()]; + let mut layers: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + let mut gray_only = true; + let mut max = (1u32, 1u32); + let mut f16_seen = false; + + for part in &chosen_parts { + let img = match &part_pixels[part.index] { + Some(img) => img.clone(), + None => { + let img = codecs::decode_exr_part(bytes, part.index, &opts)?; + part_pixels[part.index] = Some(img.clone()); + img + } + }; + if img.sample_type() == SampleType::F16 { + f16_seen = true; + } + let (surface, mode) = layer_surface(&img, &mut max)?; + gray_only &= mode == ColorMode::Grayscale; + layers.push(Layer::new(part.name.clone().unwrap_or_else(|| format!("part {}", part.index)), LayerContent::Raster(surface))); + } + for group in &chosen_groups { + let mut names = group.channels.clone(); + names.extend(group.alpha.clone()); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + let img = codecs::decode_exr_channels(bytes, group.part, &refs, &opts)?; + if img.sample_type() == SampleType::F16 { + f16_seen = true; + } + let (surface, mode) = layer_surface(&img, &mut max)?; + gray_only &= mode == ColorMode::Grayscale; + layers.push(Layer::new(group.prefix.clone(), LayerContent::Raster(surface))); + } + + if sel.precomp { + let layer = precomp_layer(bytes, &all_groups, &opts, &mut max, &mut warnings)?; + gray_only = false; // the precomp is an RGB layer by construction + layers.push(layer); + } + + if layers.is_empty() { + return Err(IoError::Unsupported("nothing imported".into())); + } + if f16_seen { + warnings.push("16-bit float samples are stored as 32-bit float".to_string()); + } + let mode = if gray_only { ColorMode::Grayscale } else { ColorMode::Rgb }; + let mut doc = Document::new(name, Size::new(max.0, max.1), mode, DocSample::F32); + doc.layers = layers; + if mode == ColorMode::Rgb { + // OpenEXR holds linear, scene-referred values: tag them linear sRGB, as File › Open does. + doc.icc_profile = Some(Builtin::LinearSrgb.profile().to_bytes()); + } + Ok(ImportResult { document: doc, warnings, source_read_only: false, preview_only: false }) +} + +/// The additive beauty from the file's AOV groups: premultiplied RGB summed over the +/// light-path set, alpha the maximum coverage, straightened back. Fails naming the missing +/// AOVs when the required set is incomplete; AOVs beyond the set are ignored. +fn precomp_layer(bytes: &[u8], all_groups: &[Group], opts: &DecodeOptions, max: &mut (u32, u32), warnings: &mut Vec) -> Result { + let have: Vec = all_groups.iter().map(|g| normalize(&g.prefix)).collect(); + let missing: Vec<&str> = PRECOMP_REQUIRED.iter().copied().filter(|r| !have.iter().any(|h| h == *r)).collect(); + if !missing.is_empty() { + return Err(IoError::Unsupported(format!("the AOV set is incomplete for a beauty precomp; missing: {}", missing.join(", ")))); + } + let mut sum_rgb: Vec = Vec::new(); + let mut alpha: Vec = Vec::new(); + let mut size = (0u32, 0u32); + for group in all_groups { + let n = normalize(&group.prefix); + if !PRECOMP_REQUIRED.contains(&n.as_str()) && !PRECOMP_OPTIONAL.contains(&n.as_str()) { + continue; + } + let mut names = group.channels.clone(); + names.extend(group.alpha.clone()); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + let img = codecs::decode_exr_channels(bytes, group.part, &refs, opts)?; + let rgba = img.convert(ChannelLayout::Rgba, SampleType::F32); + let (w, h) = rgba.dimensions(); + if size == (0, 0) { + size = (w, h); + sum_rgb = vec![0.0; w as usize * h as usize * 3]; + alpha = vec![0.0; w as usize * h as usize]; + } else if size != (w, h) { + return Err(IoError::Unsupported("the AOV groups differ in size; a beauty precomp needs one common extent".into())); + } + let px = rgba.to_f32_samples().unwrap_or_default(); + for p in 0..(w as usize * h as usize) { + // AOV channels are premultiplied by their coverage, as renderers write them: + // the sum adds the stored RGB as-is, and alpha is the maximum coverage. + let (r, g, b, a) = (px[p * 4], px[p * 4 + 1], px[p * 4 + 2], px[p * 4 + 3]); + sum_rgb[p * 3] += r; + sum_rgb[p * 3 + 1] += g; + sum_rgb[p * 3 + 2] += b; + alpha[p] = alpha[p].max(a); + } + } + let (w, h) = size; + if w == 0 || h == 0 { + return Err(IoError::Unsupported("the AOV groups are empty".into())); + } + let mut out = vec![0f32; w as usize * h as usize * 4]; + for p in 0..(w as usize * h as usize) { + let a = alpha[p]; + let (r, g, b) = if a > 0.0 { (sum_rgb[p * 3] / a, sum_rgb[p * 3 + 1] / a, sum_rgb[p * 3 + 2] / a) } else { (0.0, 0.0, 0.0) }; + out[p * 4] = r; + out[p * 4 + 1] = g; + out[p * 4 + 2] = b; + out[p * 4 + 3] = a; + } + let img = Image::from_f32(w, h, ChannelLayout::Rgba, &out)?; + max.0 = max.0.max(w); + max.1 = max.1.max(h); + let (surface, _) = layer_surface(&img, max)?; + warnings.push("beauty (precomp) is the additive sum of the file's AOVs; alpha is the maximum coverage".to_string()); + Ok(Layer::new("beauty (precomp)", LayerContent::Raster(surface))) +} diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 52f37fe777..a64af9c71d 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -21,15 +21,89 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result { } let img = codecs::decode(bytes)?; let mut r = image_to_document(name, &img)?; + if codecs::detect(bytes) == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba { + tga_alpha_to_channel(&mut r.document)?; + } // OpenEXR and Radiance HDR hold linear, scene-referred values (Rec. 709 primaries unless - // stated otherwise): tag them linear sRGB so they display and convert correctly. + // stated otherwise, and an OpenEXR `Y` is linear luminance): tag them linear sRGB or linear + // gray so they display and convert correctly. let d = &mut r.document; - if d.icc_profile.is_none() && d.mode == ColorMode::Rgb && matches!(codecs::detect(bytes), Some(Format::OpenExr | Format::Hdr)) { - d.icc_profile = Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes()); + if d.icc_profile.is_none() && matches!(codecs::detect(bytes), Some(Format::OpenExr | Format::Hdr)) { + d.icc_profile = match d.mode { + ColorMode::Rgb => Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes()), + ColorMode::Grayscale => Some(photocraft_cms::builtin::linear_gray().to_bytes()), + _ => None, + }; } Ok(r) } +/// Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" channel holding the +/// fourth byte, even when the header declares no alpha bits, and keeps the stored colours (#2225; +/// measured on Photoshop 27.11). Layer transparency would hide those colours and lose the channel +/// on the next save, where only an alpha channel becomes the Targa's alpha. +fn tga_alpha_to_channel(doc: &mut Document) -> Result<(), IoError> { + let canvas = doc.bounds(); + let depth = doc.depth; + let Some(layer) = doc.layers.first_mut() else { return Ok(()) }; + let LayerContent::Raster(s) = &mut layer.content else { return Ok(()) }; + let ch = s.format().channels(); + if !s.format().alpha || ch < 2 { + return Ok(()); + } + let mut alpha = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)); + // A band of rows at a time: no full-size float copy of the image. + let rows = (BAND_BYTES / (canvas.width().max(1) as usize * ch * 4)).max(1) as i32; + let mut y = canvas.y0; + while y < canvas.y1 { + let r = Rect::new(canvas.x0, y, canvas.x1, y.saturating_add(rows).min(canvas.y1)); + let mut px = s.read_region(r); + let a: Vec = px.chunks_exact_mut(ch).map(|p| p.last_mut().map_or(1.0, |a| std::mem::replace(a, 1.0))).collect(); + s.write_region(r, &px); + alpha.write_region(r, &a); + y = r.y1; + } + s.prune(); + alpha.prune(); + layer.name = "Background".into(); + layer.locks.transparency = true; + layer.locks.position = true; + doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", alpha)); + Ok(()) +} + +/// Undoes [`tga_alpha_to_channel`] for a document a Targa just opened as: the "Alpha 1" channel +/// becomes the layer's transparency again. For image sequences, which treat a frame's alpha as +/// transparency, as video footage does (Photoshop's Interpret Footage). Anything else is left +/// alone. +pub fn tga_alpha_channel_to_transparency(doc: &mut Document) { + let canvas = doc.bounds(); + let ([layer], [channel]) = (&mut doc.layers[..], &doc.channels[..]) else { return }; + let LayerContent::Raster(s) = &mut layer.content else { return }; + let ch = s.format().channels(); + if !s.format().alpha || ch < 2 || channel.spot.is_some() { + return; + } + let rows = (BAND_BYTES / (canvas.width().max(1) as usize * ch * 4)).max(1) as i32; + let mut y = canvas.y0; + while y < canvas.y1 { + let r = Rect::new(canvas.x0, y, canvas.x1, y.saturating_add(rows).min(canvas.y1)); + let mut px = s.read_region(r); + for (p, a) in px.chunks_exact_mut(ch).zip(channel.surface.read_region(r)) { + if let Some(last) = p.last_mut() { + *last = a; + } + } + s.write_region(r, &px); + y = r.y1; + } + s.prune(); + layer.name = "Layer 0".into(); + layer.locks.transparency = false; + layer.locks.position = false; + doc.channels.clear(); +} + /// Opens one page of a TIFF or BigTIFF file: `None` is the page Photoshop opens (the first /// full-resolution one), `Some(i)` an index into [`codecs::tiff_info`]'s pages (every IFD and /// SubIFD). A page with Photoshop layer data opens layered. @@ -72,21 +146,7 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result Result Result { + let mode = match target_layout { + ChannelLayout::Gray | ChannelLayout::GrayA => ColorMode::Grayscale, + ChannelLayout::Cmyk | ChannelLayout::CmykA => ColorMode::Cmyk, + _ => ColorMode::Rgb, + }; + let depth = match csample { + CSample::U8 => SampleType::U8, + CSample::U16 => SampleType::U16, + _ => SampleType::F32, + }; + let fmt = PixelFormat::new(mode, depth, true); + let (w, h) = img.dimensions(); + let mut s = Surface::new(fmt); + if img.layout() == target_layout && img.sample_type() == csample { + s.write_interleaved(Rect::new(0, 0, w as i32, h as i32), img.data()); + } else { + let row = img.data().len() / (h.max(1) as usize); + let band = (BAND_BYTES / row.max(1)).max(1); + for (i, rows) in img.data().chunks(row.max(1) * band).enumerate() { + let n = (rows.len() / row.max(1)) as u32; + let part = Image::from_raw(w, n, img.layout(), img.sample_type(), rows.to_vec())?.convert(target_layout, csample); + let y0 = (i * band) as i32; + s.write_interleaved(Rect::new(0, y0, w as i32, y0 + n as i32), part.data()); + } + } + Ok(s) +} + /// `Some(surface)` when the document is exactly one visible, unmasked, /// normal, fully opaque raster layer: its pixels can be written natively /// (keeping CMYK / depth exactly) instead of going through the compositor. @@ -311,9 +402,12 @@ pub fn export_flat(doc: &Document, format: Format, opts: &ExportOptions) -> Resu warnings.push(format!("CMYK converted to sRGB for {format:?} through the document's colour profile")); } if matches!(format, Format::OpenExr | Format::Hdr) { - // OpenEXR and Radiance HDR store linear light (read back as linear sRGB, see [`import_flat`]). + // OpenEXR and Radiance HDR store linear light (read back as linear sRGB or linear gray, + // see [`import_flat`]). if let Some(linear) = convert_rgb(&img, Builtin::LinearSrgb.profile(), Intent::RelativeColorimetric, false, CSample::F32)? { img = linear; + } else if let Some(linear) = convert_gray(&img, photocraft_cms::builtin::linear_gray(), CSample::F32)? { + img = linear; } } else if !format.caps().icc { // Untagged files read back as sRGB: convert to it (as Quick Export does) rather than @@ -429,16 +523,40 @@ fn matte_over_white(img: &Image) -> Result { /// RGB pixels converted from their profile (sRGB when untagged) to `dst`, unclamped, untagged and /// stored as `sample`. `None` when the image isn't RGB or already holds `dst`'s colours. fn convert_rgb(img: &Image, dst: &photocraft_cms::Profile, intent: photocraft_cms::Intent, bpc: bool, sample: CSample) -> Result, IoError> { - use photocraft_cms::{Builtin, ColorSpace, Profile, Transform}; + use photocraft_cms::{Builtin, ColorSpace, Profile}; if !img.layout().is_rgb() { return Ok(None); } let src = img.icc.as_ref().and_then(|b| Profile::parse(b).ok()).filter(|p| p.color_space == ColorSpace::Rgb).unwrap_or_else(|| Builtin::Srgb.profile().clone()); + convert_from(img, &src, dst, intent, bpc, sample) +} + +/// Gray pixels converted from their profile (sGray when untagged) to `dst`, unclamped, untagged +/// and stored as `sample`. `None` when the image isn't gray or already holds `dst`'s colours. +fn convert_gray(img: &Image, dst: &photocraft_cms::Profile, sample: CSample) -> Result, IoError> { + use photocraft_cms::{Builtin, ColorSpace, Intent, Profile}; + if !matches!(img.layout(), ChannelLayout::Gray | ChannelLayout::GrayA) { + return Ok(None); + } + let src = + img.icc.as_ref().and_then(|b| Profile::parse(b).ok()).filter(|p| p.color_space == ColorSpace::Gray).unwrap_or_else(|| Builtin::SGray.profile().clone()); + convert_from(img, &src, dst, Intent::RelativeColorimetric, false, sample) +} + +/// `img`'s pixels converted from `src` to `dst` (see [`convert_rgb`]). +fn convert_from( + img: &Image, + src: &photocraft_cms::Profile, + dst: &photocraft_cms::Profile, + intent: photocraft_cms::Intent, + bpc: bool, + sample: CSample, +) -> Result, IoError> { if src.same_colors(dst) { return Ok(None); } - let t = Transform::new(&src, dst, intent, bpc).map_err(|e| IoError::Unsupported(e.to_string()))?; + let t = photocraft_cms::Transform::new(src, dst, intent, bpc).map_err(|e| IoError::Unsupported(e.to_string()))?; let stride = img.layout().channels(); let out = map_bands(img, img.layout(), sample, |mut vals| { t.apply(&mut vals, stride); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index c9971c7e3c..2379edb55b 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -38,6 +38,7 @@ pub mod blocks; mod channel_map; pub mod comps_map; pub mod effects_map; +pub mod exr_parts; mod flat; mod gradient_bake; pub mod linked; @@ -62,7 +63,7 @@ use photocraft_doc::Document; use photocraft_psd::{PsdError, PsdFile}; pub use adjust_map::ADJUSTMENT_KEYS; -pub use flat::{document_to_image, import_tiff_page}; +pub use flat::{document_to_image, import_tiff_page, tga_alpha_channel_to_transparency}; pub use psd_export::{PsdExportOptions, document_to_psd, document_to_psd_with}; pub use psd_import::{psd_to_document, psd_to_document_with}; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index c72f99d645..b872019c90 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -504,7 +504,12 @@ impl Ex { let keep = raw.iter().any(|(k, d)| matches!(k, b"SoCo" | b"GdFl" | b"PtFl") && blocks::parse_fill(k, d).as_ref() == Some(f)); if !keep { raw.retain(|(k, _)| !matches!(k, b"SoCo" | b"GdFl" | b"PtFl")); - regenerated.push(blocks::write_fill(f)); + // Photoshop pads layer-level blocks to a multiple of 4 inside the length (every + // one in its own files is). A fill descriptor of 2 mod 4 bytes, as a pattern + // name of the wrong length gives, left the record's extra data unaligned. + let (key, mut data) = blocks::write_fill(f); + data.resize(data.len().next_multiple_of(4), 0); + regenerated.push((key, data)); } }; match &l.content { @@ -715,7 +720,13 @@ impl Ex { } None => photocraft_algo::warp::place_source(&img, img_bounds, &sm.transform, sm.warp.as_ref()), }; - crate::smart_map::feid_item(&placed, &unfiltered, sm.filter_mask.as_ref(), bounds, self.fmt) + match unfiltered { + Ok(unfiltered) => crate::smart_map::feid_item(&placed, &unfiltered, sm.filter_mask.as_ref(), bounds, self.fmt), + Err(e) => { + warnings.push(format!("smart filter mask was not re-rendered for PSD export ({e})")); + None + } + } } _ => None, }; @@ -891,6 +902,12 @@ impl Ex { { return c.surface.clone(); } + if self.cmyk + && let Some(s) = + photocraft_compose::gradient_fill::render_cmyk_fill(f, self.canvas, photocraft_compose::fill_frame(l, self.canvas), self.fmt.sample) + { + return s; + } // In the frame the layer's masks give it, like the compositor (masks are stored apart). // Readers composite these pixels (ours keeps them as the fill's rendering), so a pattern // fill must be rendered from the document's patterns, not left transparent (#1907). @@ -1320,7 +1337,8 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32) - ex.text_index.insert(l.id, index); } } - ex.emit(&doc.layers); + let space = photocraft_compose::cmyk_space(doc); + photocraft_color::convert::with_cmyk_space(space.as_ref(), || ex.emit(&doc.layers)); // Merged composite, rendered and encoded in bands (no full-size float composite). Matting // against white only changes pixels with alpha < 1; if some are slightly translucent but all @@ -1457,6 +1475,12 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32) - if txt2.is_some() && key == *b"Txt2" { continue; } + // A kept layer block (one the import could not read, #2700) describes layers the + // document doesn't have. The document's own layers are written below, and a file holds + // one layer list: a second `Lr16`/`Lr32` would contradict it. + if !ex.records.is_empty() && matches!(&key, b"Lr16" | b"Lr32" | b"Layr") { + continue; + } let mut tb = TaggedBlock::new(key, data.to_vec()); tb.signature = sig; // Photoshop pads document-level (global) blocks to a multiple of 4, and readers such as @@ -1566,6 +1590,22 @@ mod tests { Document::new("size estimate", photocraft_geom::Size::new(width, height), ColorMode::Rgb, SampleType::U8) } + #[test] + fn unreadable_lr16_warns_on_import_and_is_not_saved_twice() { + // #2700: a 16-bit file whose Lr16 block claims five layers but holds none. + let mut file = photocraft_psd::testgen::merged_only(Version::Psd, PsdMode::Rgb, 16, Compression::Raw, 8, 6); + file.global_layer_mask = Some(GlobalLayerMask::default()); + file.global_blocks.push(TaggedBlock::new(*b"Lr16", vec![0, 5, 0, 0])); + let imported = crate::import("broken.psd", &file.to_bytes().unwrap()).unwrap(); + assert!(imported.warnings.iter().any(|w| w.contains("Lr16") && w.contains("could not be read")), "{:?}", imported.warnings); + + let saved = PsdFile::from_bytes(&document_to_psd(&imported.document).to_bytes().unwrap()).unwrap(); + let ours = matches!(saved.layer_info_placement, LayerInfoPlacement::GlobalBlock { key, .. } if key == *b"Lr16"); + let kept = saved.global_blocks.iter().filter(|b| b.key == *b"Lr16").count(); + assert_eq!((ours, kept), (true, 0), "exactly one Lr16 block: the document's own layers"); + assert_eq!(saved.layers().len(), 1); + } + #[test] fn photo_filter_psd_is_four_byte_aligned_and_round_trips() { let color = [60000u16, 30000, 0].map(|x| x as f32 / 65535.0); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index a61c1909b6..67f62cf473 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -608,6 +608,15 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) for b in &file.global_blocks { doc.metadata.psd_global_blocks.push((b.signature, b.key, Arc::new(b.data.clone()))); } + // A 16/32-bit file whose layer block can't be parsed would otherwise open as a flattened + // file without a word (#2700). The raw block is kept, but a PSD save writes the document's + // own layers in its place (see `psd_export`). + if let Some((b, e)) = file.unreadable_layer_block() { + warnings.push(format!( + "the layers ({} block) could not be read ({e}); only the merged image was opened. Saving as PSD replaces the unreadable layer data", + b.key_str() + )); + } doc.patterns = crate::pattern_map::from_global_blocks(&doc); // Notes (`Anno`) and the measurement scale (resource 1074); raw data stays for verbatim export. doc.notes = crate::annotations_map::notes_from_blocks(&doc); diff --git a/crates/io/tests/affinity_artboards.rs b/crates/io/tests/affinity_artboards.rs new file mode 100644 index 0000000000..67e3739043 --- /dev/null +++ b/crates/io/tests/affinity_artboards.rs @@ -0,0 +1,57 @@ +//! Current .af artboards stay separate, editable and clipped through native/PSD saves. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +#[path = "support/current_affinity.rs"] +mod fixture; + +use fixture::{Marker, document}; +use photocraft_affinity::synth::Method; +use photocraft_doc::Document; +use photocraft_geom::Rect; +use photocraft_io::{ExportOptions, export, import}; + +fn boards(doc: &Document) -> Vec<(String, Rect)> { + doc.artboards().into_iter().map(|(_, name, a)| (name.to_string(), a.rect)).collect() +} + +fn pixels(doc: &Document) -> Vec { + let bytes = export(doc, "boards.png", &ExportOptions::default()).unwrap().bytes; + photocraft_codecs::decode(&bytes).unwrap().data().to_vec() +} + +#[test] +fn current_property_artboards_keep_bounds_clipping_and_roundtrips() { + for marker in [Marker::Legacy, Marker::Current] { + for method in [Method::Stored, Method::Zlib, Method::Zstd] { + let loaded = import("synthetic.af", &document(marker, false, method)).unwrap(); + assert!(!loaded.preview_only && loaded.source_read_only); + let d = &loaded.document; + assert_eq!(boards(d), [("Board 100".into(), Rect::new(0, 0, 16, 16)), ("Board 200".into(), Rect::new(24, 0, 40, 16))]); + let expected = pixels(d); + assert_eq!(expected.len(), 40 * 16 * 4); + for (i, px) in expected.as_chunks::<4>().0.iter().enumerate() { + let x = i % 40; + assert_eq!(px, if (16..24).contains(&x) { &[0, 0, 0, 0] } else { &[255, 0, 0, 255] }, "overflow must be clipped at the board edges"); + } + for name in ["copy.pcraft", "copy.psd"] { + let bytes = export(d, name, &ExportOptions::default()).unwrap().bytes; + let reopened = import(name, &bytes).unwrap().document; + assert_eq!(boards(&reopened), boards(d), "{name}"); + assert_eq!(pixels(&reopened), expected, "{name}"); + } + } + } +} + +#[test] +fn current_properties_mark_converted_curve_artboards_too() { + let loaded = import("curves.af", &document(Marker::Current, true, Method::Stored)).unwrap(); + assert_eq!(loaded.document.artboards().len(), 2); + assert!(loaded.warnings.iter().any(|w| w.contains("converted artboard outline"))); +} + +#[test] +fn other_properties_do_not_turn_ordinary_shapes_into_artboards() { + let d = import("shapes.af", &document(Marker::OtherProperties, false, Method::Stored)).unwrap().document; + assert!(!d.has_artboards()); +} diff --git a/crates/io/tests/color.rs b/crates/io/tests/color.rs index 958498cad7..cb8acbd2e7 100644 --- a/crates/io/tests/color.rs +++ b/crates/io/tests/color.rs @@ -83,3 +83,99 @@ fn cmyk_to_png_is_colour_managed_and_tagged_srgb() { let back = import("l.png", &r.bytes).unwrap().document; assert_eq!(Profile::parse(back.icc_profile.as_ref().unwrap()).unwrap().color_space, ColorSpace::Rgb); } + +#[test] +fn cmyk_fills_survive_psd_roundtrip_at_every_depth() { + use photocraft_doc::{Color, Document, Fill, GradientStyle, Layer, LayerContent}; + let icc = photocraft_cms::synth::cmyk_profile(&photocraft_cms::synth::CmykParams { + tvi: [0.26, 0.26, 0.26, 0.3], + grid_a2b: 5, + grid_b2a: 9, + ..Default::default() + }) + .to_bytes(); + let a = Color { mode: ColorMode::Cmyk, c: [0.2, 0.4, 0.6, 0.3], alpha: 0.7 }; + let b = Color { mode: ColorMode::Cmyk, c: [0.6, 0.2, 0.3, 0.1], alpha: 0.4 }; + for depth in SampleType::ALL { + for profile in [None, Some(icc.clone())] { + for style in [ + None, + Some(GradientStyle::Linear), + Some(GradientStyle::Radial), + Some(GradientStyle::Angle), + Some(GradientStyle::Reflected), + Some(GradientStyle::Diamond), + ] { + let mut fill = style.map_or(Fill::Solid(a), |s| Fill::gradient(vec![(1.0, b), (0.0, a)], 35.0, 0.8, s, true)); + if let Fill::Gradient { midpoints, opacity_stops, dither, offset, .. } = &mut fill { + *midpoints = vec![0.25]; + *opacity_stops = vec![(0.0, 0.6), (1.0, 0.9)]; + *offset = (0.1, -0.2); + *dither = true; + } + let mut doc = Document::new("CMYK fill", photocraft_geom::Size::new(9, 6), ColorMode::Cmyk, depth); + doc.icc_profile = profile.clone(); + doc.layers.push(Layer::new("Fill", LayerContent::Fill(fill))); + let expected = photocraft_compose::flatten(&doc).px; + assert!(max_diff(&expected, &photocraft_compose::render_tiled(&doc, doc.bounds(), 3).px) <= 1e-6); + let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes; + let back = import("x.psd", &bytes).unwrap().document; + let actual = photocraft_compose::flatten(&back).px; + let diff = max_diff(&expected, &actual); + assert!(diff <= 1e-6, "{depth:?} {style:?}: round-trip error {diff}"); + assert_eq!(back.icc_profile, doc.icc_profile); + if style.is_none() { + let native = photocraft_raster::Surface::with_default(doc.pixel_format(), &[a.c[0], a.c[1], a.c[2], a.c[3], a.alpha]); + let actual = back.layers[0].fill_cache.as_ref().unwrap().surface.read_region(doc.bounds()); + for (actual, expected) in actual.iter().zip(native.read_region(doc.bounds())) { + let tolerance = if depth == SampleType::F32 { 1e-6 } else { 0.0 }; + assert!((actual - expected).abs() <= tolerance, "{depth:?}: {actual} vs {expected}"); + } + } + } + } + } +} + +#[cfg(feature = "corpus")] +#[test] +fn uncached_cmyk_gradient_corpus_roundtrip() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd-tools/colormodes/4x4_16bit_cmyk.psd"); + let bytes = std::fs::read(path).expect("run cargo xtask corpus --all"); + let doc = import("x.psd", &bytes).unwrap().document; + assert!(doc.layers.iter().any(|l| matches!(l.content, photocraft_doc::LayerContent::Fill(_)) && l.fill_cache.is_none())); + let expected = photocraft_compose::flatten(&doc).px; + let saved = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes; + let back = import("x.psd", &saved).unwrap().document; + let diff = max_diff(&expected, &photocraft_compose::flatten(&back).px); + eprintln!("uncached 16-bit CMYK gradient round-trip max error: {diff:.8}"); + assert!(diff <= 1e-6, "round-trip error {diff}"); + assert_eq!(back.icc_profile, doc.icc_profile); +} + +#[test] +fn rgb_fill_colours_separate_through_the_document_cmyk_profile() { + use photocraft_cms::{Intent, Transform, synth}; + use photocraft_doc::{Color, Document, Fill, Layer, LayerContent}; + let profile = synth::cmyk_profile(&synth::CmykParams { tvi: [0.26, 0.26, 0.26, 0.3], grid_a2b: 5, grid_b2a: 9, ..Default::default() }); + let profile = Profile::parse(&profile.to_bytes()).unwrap(); + let rgb = [0.2, 0.4, 0.6]; + let mut expected = [0.0; 4]; + Transform::new(Builtin::Srgb.profile(), &profile, Intent::RelativeColorimetric, true).unwrap().eval_fast(&rgb, &mut expected); + for depth in SampleType::ALL { + let mut doc = Document::new("RGB fill", photocraft_geom::Size::new(1, 1), ColorMode::Cmyk, depth); + doc.icc_profile = Some(profile.to_bytes()); + doc.layers.push(Layer::new("Fill", LayerContent::Fill(Fill::Solid(Color::rgb(rgb[0], rgb[1], rgb[2]))))); + let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes; + let back = import("x.psd", &bytes).unwrap().document; + let samples = back.layers[0].fill_cache.as_ref().unwrap().surface.read_region(back.bounds()); + let tolerance = match depth { + SampleType::U8 => 1.0 / 255.0, + SampleType::U16 => 1.0 / 65535.0, + SampleType::F32 => 1e-6, + }; + for (actual, expected) in samples.iter().zip(expected) { + assert!((actual - expected).abs() <= tolerance, "{depth:?}: {actual} vs {expected}"); + } + } +} diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/corpus.rs index 728f235ee1..d45956dba8 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/corpus.rs @@ -152,7 +152,7 @@ const MIXED: Source = Source { label: "io corpus", dir: "corpus/psd", group_dept /// The full psd-tools test set (309 files at the pinned commit; see `xtask/psd-tools-corpus.sha256`): /// 226 vs the merged image + 10 vs the thumbnail (Advanced Blending knockouts: +7). -const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-tools", group_depth: 0, pass_floor: 236, roundtrip_floor: 307 }; +const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-tools", group_depth: 0, pass_floor: 236, roundtrip_floor: 308 }; /// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus), /// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/`). diff --git a/crates/io/tests/flat.rs b/crates/io/tests/flat.rs index 28a2c04a1b..6fb4dd958e 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/flat.rs @@ -43,7 +43,6 @@ codec_rt!(png_rgb8_opaque, "png", ColorMode::Rgb, SampleType::U8, false, 0.0); codec_rt!(png_rgba16, "png", ColorMode::Rgb, SampleType::U16, true, 0.0); codec_rt!(png_gray8, "png", ColorMode::Grayscale, SampleType::U8, false, 0.0); codec_rt!(png_graya16, "png", ColorMode::Grayscale, SampleType::U16, true, 0.0); -codec_rt!(tga_rgba8, "tga", ColorMode::Rgb, SampleType::U8, true, 0.0); codec_rt!(tga_rgb8_opaque, "tga", ColorMode::Rgb, SampleType::U8, false, 0.0); codec_rt!(tga_gray8, "tga", ColorMode::Grayscale, SampleType::U8, false, 0.0); codec_rt!(tiff_rgba8, "tiff", ColorMode::Rgb, SampleType::U8, true, 0.0); @@ -74,6 +73,53 @@ fn exr_rgba32() { assert!((a[3] - b[3]).abs() < 1e-6, "alpha kept"); } +/// An opaque document filled with `#808080`. +fn mid_gray(mode: ColorMode, depth: SampleType) -> photocraft_doc::Document { + let mut d = photocraft_doc::Document::new("g", photocraft_geom::Size::new(4, 4), mode, depth); + let fmt = d.pixel_format(); + let mut s = photocraft_raster::Surface::new(fmt); + let v = 128.0 / 255.0; + s.fill_rect(d.bounds(), &photocraft_raster::from_rgba(&fmt, [v, v, v, 1.0])); + d.layers.push(photocraft_doc::Layer::new("Background", photocraft_doc::LayerContent::Raster(s))); + d +} + +/// OpenEXR and Radiance HDR store linear sRGB in every mode: an sGray `#808080` is written as +/// its linear value, as an RGB `#808080` is, rather than as the display-encoded 0.5 (#2386). +#[test] +fn gray_exr_and_hdr_store_linear_light() { + let linear = photocraft_color::convert::srgb_to_linear(128.0 / 255.0); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for (ext, tol) in [("exr", 2e-5), ("hdr", 2e-3)] { + for mode in [ColorMode::Grayscale, ColorMode::Rgb] { + let r = export(&mid_gray(mode, depth), ext, &ExportOptions::default()).expect("export"); + let img = photocraft_codecs::decode(&r.bytes).expect("decode"); + let samples = img.to_normalized(); + let worst = samples.iter().map(|v| (v - linear).abs()).fold(0.0f32, f32::max); + assert!(worst <= tol, "{mode:?} {depth:?} .{ext}: stored samples differ from {linear} by {worst}"); + } + } + } +} + +/// A grayscale OpenEXR holds linear luminance, so it opens as grayscale tagged with a linear gray +/// profile, in which its samples are the colours saved. +#[test] +fn gray_exr_round_trips() { + use photocraft_cms::{Builtin, ColorSpace, Intent, Profile, Transform}; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let r = export(&mid_gray(ColorMode::Grayscale, depth), "exr", &ExportOptions::default()).expect("export"); + let back = import("x.exr", &r.bytes).expect("import").document; + assert_eq!(back.mode, ColorMode::Grayscale, "{depth:?}"); + let profile = Profile::parse(back.icc_profile.as_deref().expect("tagged")).expect("profile"); + assert_eq!(profile.color_space, ColorSpace::Gray, "{depth:?}"); + let t = Transform::new(&profile, Builtin::SGray.profile(), Intent::RelativeColorimetric, false).expect("transform"); + let mut v = [back.layers[0].surface().expect("raster").pixel(1, 1)[0]]; + t.apply(&mut v, 1); + assert!((v[0] - 128.0 / 255.0).abs() < 1e-4, "{depth:?}: reopened as {} in sGray", v[0]); + } +} + fn smooth(mode: ColorMode) -> photocraft_doc::Document { let mut d = photocraft_doc::Document::new("s", photocraft_geom::Size::new(32, 16), mode, SampleType::U8); let fmt = d.pixel_format(); @@ -478,9 +524,71 @@ fn tga_writes_the_alpha_channel_as_32_bit_alpha() { let (va, vb) = (sa.read_region(want.bounds()), sb.read_region(back.bounds())); for (i, (a, b)) in va.as_chunks::<4>().0.iter().zip(vb.as_chunks::<4>().0.iter()).enumerate() { assert!(a[..3].iter().zip(&b[..3]).all(|(p, q)| (p - q).abs() <= 1.0 / 255.0), "{depth:?} colour {i}: {a:?} {b:?}"); - // Round trip: the alpha opens back as the layer's transparency. - assert!((b[3] - alpha_value(i as u32 % 9)).abs() <= 0.5 / 255.0, "{depth:?} alpha {i}: {}", b[3]); + assert_eq!(b[3], 1.0, "{depth:?} the Background stays opaque"); } + // Round trip, as in Photoshop: the alpha opens back as the "Alpha 1" channel (#2225). + let [ch] = &back.channels[..] else { panic!("{depth:?}: one alpha channel, got {}", back.channels.len()) }; + assert_eq!(ch.name, "Alpha 1"); + for (i, a) in ch.surface.read_region(back.bounds()).iter().enumerate() { + assert!((a - alpha_value(i as u32 % 9)).abs() <= 0.5 / 255.0, "{depth:?} alpha {i}: {a}"); + } + } +} + +/// A 64×64 uncompressed Targa (bottom-left origin), byte for byte like the files the Photoshop +/// measurements in #2225 opened: red everywhere, alpha (when `bpp` is 32) 255 on the left half and +/// 0 on the right, with `alpha_bits` in the descriptor. +fn tga_file(bpp: u8, alpha_bits: u8) -> Vec { + let mut b = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 0, 64, 0, bpp, alpha_bits]; + for _y in 0..64 { + for x in 0..64 { + b.extend_from_slice(&[0, 0, 255]); + if bpp == 32 { + b.push(if x < 32 { 255 } else { 0 }); + } + } + } + b +} + +/// #2225, measured on Photoshop 27.11: a 32-bit Targa opens as an opaque, locked Background plus +/// an "Alpha 1" channel holding the fourth byte (also when the descriptor declares 0 alpha bits), +/// and the stored colours stay visible where the alpha is 0. A 24-bit Targa has no channel. +#[test] +fn tga_32_bit_opens_its_alpha_as_a_channel_like_photoshop() { + for alpha_bits in [8, 0] { + let d = import("a.tga", &tga_file(32, alpha_bits)).unwrap().document; + let [bg] = &d.layers[..] else { panic!("one layer") }; + assert_eq!(bg.name, "Background", "alpha bits {alpha_bits}"); + assert!(bg.locks.transparency && bg.locks.position); + let px = bg.surface().unwrap().read_region(d.bounds()); + for (i, p) in px.as_chunks::<4>().0.iter().enumerate() { + assert_eq!(*p, [1.0, 0.0, 0.0, 1.0], "alpha bits {alpha_bits}: pixel {i} keeps its red, opaque"); + } + let [ch] = &d.channels[..] else { panic!("alpha bits {alpha_bits}: one channel, got {}", d.channels.len()) }; + assert_eq!((ch.name.as_str(), ch.spot), ("Alpha 1", None)); + let a = ch.surface.read_region(d.bounds()); + assert_eq!((a[64 * 32 + 10], a[64 * 32 + 54]), (1.0, 0.0), "alpha bits {alpha_bits}"); + } + let d = import("a.tga", &tga_file(24, 0)).unwrap().document; + assert!(d.channels.is_empty()); + assert_eq!(d.layers[0].name, "Background"); +} + +/// Layer transparency is still written as the Targa's alpha when there is no alpha channel (a +/// choice left to the maintainers in #2225); the file opens back as Photoshop opens it, with the +/// alpha as "Alpha 1" and the colours of transparent pixels kept. +#[test] +fn tga_transparency_reopens_as_alpha_1() { + let d = single(ColorMode::Rgb, SampleType::U8, true); + let r = export(&d, "a.tga", &ExportOptions::default()).unwrap(); + let back = import("a.tga", &r.bytes).unwrap().document; + let src = d.layers[0].surface().unwrap().read_region(d.bounds()); + let got = back.layers[0].surface().unwrap().read_region(back.bounds()); + let alpha = back.channels.first().expect("Alpha 1").surface.read_region(back.bounds()); + for (i, ((s, g), a)) in src.as_chunks::<4>().0.iter().zip(got.as_chunks::<4>().0.iter()).zip(&alpha).enumerate() { + assert_eq!(&g[..3], &s[..3], "colour {i}"); + assert_eq!((g[3], *a), (1.0, s[3]), "alpha {i}"); } } diff --git a/crates/io/tests/psd_block_alignment.rs b/crates/io/tests/psd_block_alignment.rs new file mode 100644 index 0000000000..534fdc257b --- /dev/null +++ b/crates/io/tests/psd_block_alignment.rs @@ -0,0 +1,53 @@ +//! Every layer-level tagged block of an exported PSD/PSB is a multiple of 4 bytes long. +//! +//! Photoshop pads layer-level blocks to 4 inside the length field; all of its own files do (567 +//! fixtures checked), and a regenerated block that stopped at an even but unaligned size left the +//! layer record's extra data unaligned: a Pattern Fill's `PtFl` came out 2 bytes short of the +//! boundary for half of all pattern names. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Fill, Layer, LayerContent, Pattern}; +use photocraft_geom::{Rect, Size}; +use photocraft_io::*; +use photocraft_psd::PsdFile; +use photocraft_raster::Surface; + +fn pattern(name: &str) -> Pattern { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 8, 8), &[0.8, 0.8, 0.8, 1.0]); + s.fill_rect(Rect::new(0, 0, 4, 4), &[0.2, 0.4, 0.9, 1.0]); + Pattern { id: "059289fb-0000-4000-8000-000000000001".into(), name: name.into(), width: 8, height: 8, surface: s } +} + +/// One Pattern Fill layer per name; the name's length moves the descriptor's size by two bytes +/// per character, so consecutive lengths cover every residue modulo 4. +fn doc(depth: SampleType, names: &[&str]) -> Document { + let mut d = Document::new("p", Size::new(40, 24), ColorMode::Rgb, depth); + for name in names { + let p = pattern(name); + let fill = Fill::Pattern { name: (*name).into(), scale: 1.0, id: p.id.clone(), angle: 0.0, link: true, phase: (0.0, 0.0) }; + d.patterns.push(p); + d.layers.push(Layer::new(format!("Fill {name}"), LayerContent::Fill(fill))); + } + d +} + +#[test] +fn layer_blocks_are_four_byte_aligned() { + let names = ["A", "Ab", "Abc", "Abcd", "Dots", "Checker", "Wood grain"]; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for path in ["x.psd", "x.psb"] { + let bytes = export(&doc(depth, &names), path, &ExportOptions::default()).expect("export").bytes; + let file = PsdFile::from_bytes(&bytes).expect("reparse"); + let mut fills = 0; + for layer in file.layers() { + for b in &layer.blocks { + let key = String::from_utf8_lossy(&b.key).into_owned(); + assert_eq!(b.data.len() % 4, 0, "{depth:?} {path}: layer {:?} block {key} is {} bytes", layer.name(), b.data.len()); + } + fills += usize::from(layer.block(b"PtFl").is_some()); + } + assert_eq!(fills, names.len(), "{depth:?} {path}: every pattern fill keeps its PtFl block"); + } + } +} diff --git a/crates/io/tests/support/current_affinity.rs b/crates/io/tests/support/current_affinity.rs new file mode 100644 index 0000000000..73588f9aa9 --- /dev/null +++ b/crates/io/tests/support/current_affinity.rs @@ -0,0 +1,74 @@ +//! Original synthetic reader fixtures. No private document bytes or artwork are used. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use photocraft_affinity::synth::{self, F, Method, tag}; + +#[derive(Clone, Copy)] +pub enum Marker { + Legacy, + Current, + OtherProperties, +} + +fn solid(id: u32) -> F { + F::Def( + id, + vec![tag(b"FDsc")], + vec![( + tag(b"FDeF"), + F::Def( + id + 1, + vec![tag(b"FilS")], + vec![( + tag(b"Colr"), + F::Def(id + 2, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([1f32, 0.0, 0.0, 1.0].into_iter().flat_map(f32::to_le_bytes).collect()))]), + )], + ), + )], + ) +} + +fn board(id: u32, x: f64, marker: Marker, curve: bool) -> F { + let child = F::Def( + id + 10, + vec![tag(b"ShpN")], + vec![ + (tag(b"Desc"), F::Str("Overflow".into())), + (tag(b"Shpe"), F::Def(id + 11, vec![tag(b"ShNR")], vec![])), + (tag(b"ShpB"), F::F64s(vec![-4.0, -4.0, 20.0, 20.0])), + (tag(b"BFFl"), F::Shared(vec![solid(id + 12)])), + ], + ); + let mut fields = vec![ + (tag(b"Desc"), F::Str(format!("Board {id}"))), + (tag(b"Shpe"), F::Def(id + 1, vec![tag(b"ShNR")], vec![])), + (tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 16.0, 16.0])), + (tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, x, 0.0, 1.0, 0.0])), + (tag(b"Chld"), F::Shared(vec![child])), + ]; + match marker { + Marker::Legacy => fields.push((tag(b"ABEn"), F::Bool(true))), + Marker::Current | Marker::OtherProperties => { + fields.push((tag(b"ABEn"), F::Bool(false))); + fields.push((tag(b"phrp"), F::Def(id + 2, vec![tag(if matches!(marker, Marker::Current) { b"aprp" } else { b"prps" })], vec![]))); + } + } + F::Def(id, vec![tag(if curve { b"PCrv" } else { b"ShpN" })], fields) +} + +pub fn document(marker: Marker, curve: bool, method: Method) -> Vec { + let spread = F::Def( + 2, + vec![tag(b"Sprd")], + vec![ + (tag(b"SprB"), F::F64s(vec![0.0, 0.0, 40.0, 16.0])), + (tag(b"SprT"), F::Bool(true)), + (tag(b"Chld"), F::Shared(vec![board(100, 0.0, marker, curve), board(200, 24.0, marker, curve)])), + ], + ); + let stream = synth::stream(&[ + (tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(144.0))])), + (tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))])), + ]); + synth::container(&[("doc.dat", &stream, method)], None) +} diff --git a/crates/io/tests/text_corpus.rs b/crates/io/tests/text_corpus.rs index efa8c775a2..a2efe1aec7 100644 --- a/crates/io/tests/text_corpus.rs +++ b/crates/io/tests/text_corpus.rs @@ -144,6 +144,31 @@ fn corpus_tysh_lossless() { assert_eq!(find(&bytes), find(&out)); } +/// An unedited save of a Photoshop-authored type file writes its `Txt2` back byte-for-byte +/// (#2374: a regenerated `Txt2` made Photoshop refuse the re-saved file). +#[cfg(feature = "corpus")] +#[test] +fn corpus_photoshop_txt2_survives_an_unedited_save() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop/text"); + assert!(root.is_dir(), "{} is missing: run `cargo xtask corpus --photoshop`", root.display()); + let mut files = Vec::new(); + collect(&root, &mut files); + files.sort(); + let txt2 = |doc: &photocraft_doc::Document| doc.metadata.psd_global_blocks.iter().find(|b| &b.1 == b"Txt2").map(|b| b.2.clone()); + let mut checked = 0; + for f in &files { + let name = f.file_name().unwrap().to_string_lossy().to_string(); + let doc = photocraft_io::import(&name, &std::fs::read(f).unwrap()).unwrap().document; + let Some(before) = txt2(&doc) else { continue }; + let out = photocraft_io::export(&doc, &name, &Default::default()).unwrap().bytes; + let back = photocraft_io::import(&name, &out).unwrap().document; + assert!(txt2(&back) == Some(before), "{name}: Txt2 changed on an unedited save"); + checked += 1; + } + assert!(checked > 0, "no Photoshop type files with Txt2 in {}", root.display()); + println!("Txt2 kept byte-for-byte in {checked} Photoshop type files"); +} + /// A text layer created in Photocraft (no PSD data) exports as an editable type layer and /// imports back with the same model. #[test] diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index 7e19d36dfc..25ebea5ef2 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -485,6 +485,10 @@ pub struct BrushSettings { pub flip_y: bool, /// Pencil: no anti-aliasing (coverage is 0 or 1). pub aliased: bool, + /// With `aliased`, a square tip: every pixel of the dab's whole-pixel square + /// ([`crate::grid_square`]), whatever its angle or roundness. The Eraser's Block mode. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub square: bool, // --- dynamics sections --- pub shape_dynamics: ShapeDynamics, pub scattering: Scattering, @@ -533,6 +537,7 @@ impl Default for BrushSettings { flip_x: false, flip_y: false, aliased: false, + square: false, shape_dynamics: ShapeDynamics::default(), scattering: Scattering::default(), texture: Texture::default(), diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 04ac59f74a..86f76eaee8 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -185,6 +185,30 @@ fn pencil_dabs_sit_on_the_pixel_grid() { assert_eq!(s.rgba(9, 5)[3], 1.0); } +#[test] +fn pencil_1px_line_is_a_clean_8_connected_staircase() { + // #2139: a 1 px Pencil line is one pixel per column (or row, when steep) on the ideal line + // between the end pixels: no doubled pixels at the steps. + let b = BrushSettings { aliased: true, hardness: 1.0, size: 1.0, ..brush() }; + let cases = [((2.5, 2.5), (22.5, 9.5)), ((3.5, 30.5), (10.5, 5.5)), ((1.2, 1.7), (25.8, 4.1)), ((5.5, 5.5), (17.5, 17.5)), ((20.3, 3.6), (4.9, 12.2))]; + for ((ax, ay), (bx, by)) in cases { + let s = paint(&b, &[StrokePoint::new(ax, ay, 1.0), StrokePoint::new(bx, by, 1.0)], 40, 40); + let painted: Vec<(i32, i32)> = (0..40).flat_map(|y| (0..40).map(move |x| (x, y))).filter(|&(x, y)| s.rgba(x, y)[3] > 0.0).collect(); + let (a, e) = ((ax.floor(), ay.floor()), (bx.floor(), by.floor())); + let steep = (e.1 - a.1).abs() > (e.0 - a.0).abs(); + let major = |p: (f64, f64)| if steep { (p.1, p.0) } else { (p.0, p.1) }; + let ((m0, n0), (m1, n1)) = (major(a), major(e)); + assert_eq!(painted.len() as f64, (m1 - m0).abs() + 1.0, "{a:?}->{e:?}: one pixel per step, got {painted:?}"); + for &(x, y) in &painted { + let (m, n) = major((f64::from(x), f64::from(y))); + let ideal = n0 + (n1 - n0) * (m - m0) / (m1 - m0); + assert!((n - ideal).abs() <= 0.5 + 1e-9, "{a:?}->{e:?}: ({x},{y}) is off the line"); + } + let columns: std::collections::BTreeSet = painted.iter().map(|&(x, y)| major((f64::from(x), f64::from(y))).0 as i64).collect(); + assert_eq!(columns.len(), painted.len(), "{a:?}->{e:?}: a column holds two pixels"); + } +} + // ---------- spacing ---------- #[test] @@ -546,6 +570,23 @@ fn dual_brush_intersects() { assert!(s.rgba(35, 30)[3] > 0.9); } +#[test] +fn wet_edges_shape_the_stroke_not_each_dab() { + // #2088: Wet Edges darkens the rim of the whole stroke. Along the centre of a straight stroke + // of overlapping dabs the paint is even (no chain of per-dab rings), and the stroke's sides are + // darker than its centre. + let b = BrushSettings { size: 40.0, hardness: 0.8, wet_edges: true, ..brush() }; + let s = paint(&b, &line(20.0, 220.0, 50.0), 240, 100); + let centre: Vec = (60..180).map(|x| s.rgba(x, 50)[3]).collect(); + let (lo, hi) = centre.iter().fold((f32::MAX, f32::MIN), |(lo, hi), &a| (lo.min(a), hi.max(a))); + assert!(hi - lo < 0.02, "centre line is even: {lo}..{hi}"); + assert!(hi < 0.6, "wet interior is lighter: {hi}"); + for x in [80, 120, 160] { + let side = s.rgba(x, 50 + 16)[3]; + assert!(side > hi + 0.15, "the side at x={x} is darker than the centre: {side} vs {hi}"); + } +} + #[test] fn wet_edges_and_noise() { let b = BrushSettings { size: 40.0, hardness: 0.0, wet_edges: true, ..brush() }; @@ -1098,3 +1139,15 @@ fn the_tip_falls_off_from_the_hard_core_to_zero_at_the_edge() { assert!(v.windows(2).all(|w| w[1] <= w[0]), "{v:?}"); assert!((tip_falloff(5.0, 10.0, 0.0) - 0.75f32.powi(4)).abs() < 1e-6); } + +#[test] +fn half_coverage_radius_is_the_tips_50_percent_contour() { + use crate::{half_coverage_radius, tip_falloff}; + // The Normal Brush Tip cursor ring (#2744): where the soft falloff crosses 50 %. + for h in [0.0, 0.25, 0.5, 0.75, 0.99] { + let d = half_coverage_radius(10.5, h); + assert!((tip_falloff(d, 10.5, h) - 0.5).abs() < 1e-3, "hardness {h}: ring at {d}"); + } + assert_eq!(half_coverage_radius(10.5, 1.0), 10.5); + assert!(half_coverage_radius(10.5, f32::NAN).is_finite()); +} diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs index 3318ca39cc..5e8b225914 100644 --- a/crates/paint/src/dynamics.rs +++ b/crates/paint/src/dynamics.rs @@ -19,6 +19,9 @@ pub const SMOOTHING_STEP_MS: f64 = 16.0; /// space their dabs further apart (Photoshop: "the speed of the cursor determines the spacing"). pub const SPEED_SPACING_MS: f64 = 8.0; +/// Longest pixel-grid segment (in pixels) before falling back to distance spacing. +const MAX_GRID_STEPS: f64 = 1e7; + /// Densest speed spacing, in pixels between dabs (a slow, long stroke never floods the buffer). const MIN_SPEED_STEP: f64 = 0.5; /// Maximum airbrush catch-up dabs for one input segment. @@ -182,6 +185,9 @@ pub struct PathWalker { /// Speed spacing (Spacing unchecked): one step per this many ms of stroke time. speed: Option, speed_acc: f64, + /// Pixel-grid stepping for small aliased tips: the dab-centre offset inside a pixel (see + /// [`PathWalker::pixel_grid`]). + grid: Option, } impl PathWalker { @@ -197,9 +203,41 @@ impl PathWalker { time_acc: 0.0, speed: None, speed_acc: 0.0, + grid: None, } } + /// Step pixel by pixel instead of by distance: one step per pixel along the segment's major + /// axis, on the line between the pixels holding its ends, each at the pixel's dab centre + /// (`offset` 0.5 for odd tip diameters, 0 for even ones, as `render::grid_center` snaps). A + /// small aliased tip (the Pencil) then draws a clean 8-connected staircase: stepping by + /// distance put two dabs in a row wherever the line crossed a pixel corner (#2139). + pub fn pixel_grid(mut self, offset: f64) -> Self { + self.grid = Some(offset); + self + } + + /// Emits the pixel steps from `a` to `p` (see [`PathWalker::pixel_grid`]). False, emitting + /// nothing, when the span is not finite or implausibly long: distance spacing takes over. + fn grid_steps(&mut self, a: &StrokePoint, p: &StrokePoint, offset: f64, out: &mut impl FnMut(StepInput)) -> bool { + let cell = |v: f64| if offset > 0.0 { v.floor() } else { v.round() }; + let (x0, y0) = (cell(a.x), cell(a.y)); + let (dx, dy) = (cell(p.x) - x0, cell(p.y) - y0); + let n = dx.abs().max(dy.abs()); + if !n.is_finite() || n > MAX_GRID_STEPS { + return false; + } + let steps = n as u64; + for i in 1..=steps { + let i = i as f64; + let mut q = lerp_pt(a, p, i / n); + q.x = x0 + (dx * i / n).round() + offset; + q.y = y0 + (dy * i / n).round() + offset; + self.emit(q, out); + } + true + } + /// Space steps by stroke time instead of distance ([`SPEED_SPACING_MS`]): faster movement /// spreads them out. Segments without timestamps are spaced by distance. pub fn speed_spacing(mut self, interval_ms: f64) -> Self { @@ -237,12 +275,14 @@ impl PathWalker { let dt = p.time - a.time; match self.speed.filter(|_| dt > 0.0 && dt.is_finite()) { None => { - while self.next_at <= len + 1e-9 { - let q = lerp_pt(&a, &p, (self.next_at / len).min(1.0)); - self.emit(q, out); - self.next_at += step_len(&q).max(0.25); + if !self.grid.is_some_and(|g| self.grid_steps(&a, &p, g, out)) { + while self.next_at <= len + 1e-9 { + let q = lerp_pt(&a, &p, (self.next_at / len).min(1.0)); + self.emit(q, out); + self.next_at += step_len(&q).max(0.25); + } + self.next_at -= len; } - self.next_at -= len; } Some(iv) => { self.speed_acc += dt; @@ -632,9 +672,14 @@ impl DabGenerator { let brush = brush.bounded_for_render(); let interval = brush.build_up.then(|| 1000.0 / f64::from(brush.build_up_rate.clamp(0.1, 1000.0))); let dual_on = brush.dual_brush.enabled; + let mut walker = if brush.spacing_enabled { PathWalker::new(interval) } else { PathWalker::new(interval).speed_spacing(SPEED_SPACING_MS) }; + // A small aliased tip whose dabs are at most a pixel apart steps pixel by pixel (#2139). + if brush.aliased && brush.size * brush.spacing.max(0.01) <= 1.0 { + walker = walker.pixel_grid(f64::from(crate::render::grid_center(0.0, 0.0, brush.size).0)); + } Self { smoother: Smoother::new(&brush.smoothing, zoom), - walker: if brush.spacing_enabled { PathWalker::new(interval) } else { PathWalker::new(interval).speed_spacing(SPEED_SPACING_MS) }, + walker, dual_walker: dual_on.then(|| PathWalker::new(None)), builder: DabBuilder::new(&brush), dual: dual_on.then(|| DualBuilder::new(&brush)), diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index 51707201de..b81a2ae85c 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -155,6 +155,16 @@ pub fn tip_falloff(d: f32, radius: f32, hardness: f32) -> f32 { s2 * s2 } +/// Distance from the centre where [`tip_falloff`] drops to 50 %: the contour Preferences › +/// Cursors › Normal Brush Tip outlines. It inverts `(1 - t²)⁴ = ½`, so `t = √(1 - ½^¼) ≈ 0.399` +/// of the way from the hard core (`hardness · radius`) to the edge. +#[inline] +pub fn half_coverage_radius(radius: f32, hardness: f32) -> f32 { + let h = if hardness.is_nan() { 0.0 } else { hardness.clamp(0.0, 1.0) }; + let t = (1.0 - 0.5f32.sqrt().sqrt()).sqrt(); + radius * (h + t * (1.0 - h)) +} + /// Coverage of a round dab at distance `d` from its centre (anti-aliased edge, hardness falloff). #[inline] pub fn dab_coverage(d: f32, radius: f32, hardness: f32) -> f32 { diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 386c5c0a81..9d1d7fa5fa 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -54,6 +54,13 @@ fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 { t * t * (3.0 - 2.0 * t) } +/// Wet Edges: how much paint stays at a pixel whose nearest dab reaches it at `rn` (0 at a dab's +/// centre, 1 at its rim). The interior keeps half the paint, the rim all of it. +#[inline] +fn wet_factor(rn: f32) -> f32 { + 0.5 + 0.5 * smoothstep(0.5, 1.0, rn) +} + /// Combine brush coverage `v` with a mask value `t` (texture/dual tip) at `depth`. /// Every mode maps `v = 0` to 0 (a mask never adds paint where the brush has none). pub fn mask_combine(mode: MaskMode, v: f32, t: f32, d: f32) -> f32 { @@ -164,14 +171,25 @@ impl BrushContext { } /// Rasterise a dab over `rect` into `out` (tip shape × noise × per-tip texture × wet edges × flow). + /// Wet Edges shapes this one dab; a stroke applies it to its accumulated coverage instead. pub fn rasterize(&self, d: &Dab, dual: bool, rect: Rect, out: &mut Vec) { + self.rasterize_into(d, dual, rect, out, None); + } + + /// [`rasterize`](Self::rasterize); with `depth`, Wet Edges is left out of `out` and each + /// painted pixel's depth inside the dab (`1 − rn`) goes to `depth`, for the stroke to apply it. + fn rasterize_into(&self, d: &Dab, dual: bool, rect: Rect, out: &mut Vec, mut depth: Option<&mut Vec>) { let (w, h) = (rect.width() as usize, rect.height() as usize); out.clear(); out.resize(w * h, 0.0); + if let Some(dp) = depth.as_deref_mut() { + dp.clear(); + dp.resize(w * h, 0.0); + } let b = &self.brush; let (hardness, mips) = if dual { (b.dual_brush.hardness, self.dual_tip.as_deref()) } else { (b.hardness, self.tip.as_deref()) }; let aliased = b.aliased && !dual; - let wet = b.wet_edges && !dual; + let wet = b.wet_edges && !dual && depth.is_none(); let noise = b.noise && !dual; let tex_tip = !dual && b.texture.enabled && b.texture.each_tip && self.texture.is_some(); let (cx, cy) = if aliased { grid_center(d.center.x, d.center.y, 2.0 * d.radius) } else { (d.center.x as f32, d.center.y as f32) }; @@ -219,6 +237,13 @@ impl BrushContext { let dx = x as f32 + 0.5 - cx; let (u, v) = to_tip(dx, dy); let (mut val, rn) = match mips { + // Block: the whole-pixel square, hard (the Eraser's Block mode). + _ if aliased && b.square => { + if dx.abs() >= r || dy.abs() >= r { + continue; + } + (1.0, 0.0) + } None if small => { let d2 = (u * ro).powi(2) + v * v; if d2 >= small_reach2 { @@ -270,7 +295,10 @@ impl BrushContext { val = mask_combine(b.texture.mode, val, self.texture_at(x, y), d.depth); } if wet { - val *= 0.5 + 0.5 * smoothstep(0.5, 1.0, rn); + val *= wet_factor(rn); + } + if let Some(slot) = depth.as_deref_mut().and_then(|dp| dp.get_mut(yy * w + xx)) { + *slot = (1.0 - rn).clamp(0.0, 1.0); } out[yy * w + xx] = val * d.alpha; } @@ -288,8 +316,18 @@ impl BrushContext { /// Stroke-level masks applied to accumulated coverage `c` at a pixel. #[inline] pub fn stroke_mask(&self, c: f32, dual: Option, x: i32, y: i32) -> f32 { + self.stroke_mask_wet(c, None, dual, x, y) + } + + /// [`stroke_mask`](Self::stroke_mask) with Wet Edges from the stroke's depth at the pixel + /// (the deepest any dab reaches it, see [`CoverageMap::depth`]). + #[inline] + fn stroke_mask_wet(&self, c: f32, depth: Option, dual: Option, x: i32, y: i32) -> f32 { let b = &self.brush; - let mut m = c; + let mut m = match depth { + Some(dp) => c * wet_factor(1.0 - dp), + None => c, + }; if let Some(dv) = dual { m = mask_combine(b.dual_brush.mode, m, dv, 1.0); } @@ -307,6 +345,9 @@ impl BrushContext { #[derive(Clone, Debug)] struct CovTile { cov: Vec, + /// Wet Edges: the deepest any dab reaches each pixel (`1 − rn` of the nearest dab), empty + /// without Wet Edges. The stroke's interior is deep, its rim shallow, whatever the dabs' overlap. + depth: Vec, /// Premultiplied native colour channels (`nc` per pixel), empty without per-dab colour. col: Vec, } @@ -333,9 +374,17 @@ impl CoverageMap { self.tiles.get(&(tx, ty)).map_or(0.0, |t| t.cov[((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize]) } + /// Wet Edges depth at a pixel (see [`CovTile::depth`]); `None` when the stroke has none. + #[inline] + pub fn depth(&self, x: i32, y: i32) -> Option { + let (tx, ty) = (x.div_euclid(COV_TILE), y.div_euclid(COV_TILE)); + self.tiles.get(&(tx, ty)).and_then(|t| t.depth.get(((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize).copied()) + } + /// Accumulate dab values over `rect`: flow builds up towards the dab's opacity ceiling - /// (`c ← c + v·(ceil − c)`), or with `max` (wet edges). `color` = native colour for per-dab colour. - pub fn accumulate(&mut self, rect: Rect, vals: &[f32], ceil: f32, use_max: bool, color: Option<&[f32]>) { + /// (`c ← c + v·(ceil − c)`). `color` = native colour for per-dab colour. `depth` (Wet Edges, one + /// value per pixel of `rect`) keeps the deepest any dab reaches each pixel. + pub fn accumulate(&mut self, rect: Rect, vals: &[f32], ceil: f32, color: Option<&[f32]>, depth: Option<&[f32]>) { if rect.is_empty() { return; } @@ -361,11 +410,15 @@ impl CoverageMap { } let tile = self.tiles.entry((tx, ty)).or_insert_with(|| CovTile { cov: vec![0.0; (COV_TILE * COV_TILE) as usize], + depth: Vec::new(), col: if want_col { vec![0.0; (COV_TILE * COV_TILE) as usize * nc] } else { Vec::new() }, }); if want_col && tile.col.is_empty() { tile.col = vec![0.0; (COV_TILE * COV_TILE) as usize * nc]; } + if depth.is_some() && tile.depth.is_empty() { + tile.depth = vec![0.0; (COV_TILE * COV_TILE) as usize]; + } self.dirty.insert((tx, ty)); for y in tr.y0..tr.y1 { let row = (y - rect.y0) as usize * w; @@ -376,14 +429,11 @@ impl CoverageMap { continue; } let ti = trow + (x - tx * COV_TILE) as usize; + if let (Some(dv), Some(slot)) = (depth.and_then(|dp| dp.get(row + (x - rect.x0) as usize)), tile.depth.get_mut(ti)) { + *slot = slot.max(*dv); + } let c = tile.cov[ti]; - let nv = if use_max { - c.max(v * ceil) - } else if ceil > c { - c + v * (ceil - c) - } else { - c - }; + let nv = if ceil > c { c + v * (ceil - c) } else { c }; if nv <= c { continue; } @@ -417,6 +467,13 @@ impl CoverageMap { self.dirty.insert(key); continue; }; + if target.depth.is_empty() { + target.depth.clone_from(&source.depth); + } else { + for (dst, src) in target.depth.iter_mut().zip(&source.depth) { + *dst = dst.max(*src); + } + } for (index, (dst, src)) in target.cov.iter_mut().zip(&source.cov).enumerate() { if *src > *dst { *dst = *src; @@ -447,6 +504,8 @@ pub struct StrokeRenderer { per_dab_color: bool, dabs_done: usize, scratch: Vec, + /// Wet Edges depth of the dab being accumulated (see [`CovTile::depth`]). + depth_scratch: Vec, dab_buf: Vec, dual_buf: Vec, all_dabs: Option>, @@ -490,6 +549,7 @@ impl StrokeRenderer { per_dab_color, dabs_done: 0, scratch: Vec::new(), + depth_scratch: Vec::new(), dab_buf: Vec::new(), dual_buf: Vec::new(), all_dabs: None, @@ -519,7 +579,9 @@ impl StrokeRenderer { let mut native = [0.0f32; 8]; for d in &dabs { let rect = self.ctx.dab_rect(d, false); - self.ctx.rasterize(d, false, rect, &mut self.scratch); + // Wet Edges acts on the whole stroke (#2088): the dab records how deep it reaches each + // pixel and the stroke darkens its own rim when composited, not every dab's. + self.ctx.rasterize_into(d, false, rect, &mut self.scratch, wet.then_some(&mut self.depth_scratch)); let col = match (self.per_dab_color, self.fmt) { (true, Some(f)) => { from_rgba_into(&f, d.color, &mut native); @@ -527,7 +589,7 @@ impl StrokeRenderer { } _ => None, }; - self.cov.accumulate(rect, &self.scratch, d.opacity, wet, col); + self.cov.accumulate(rect, &self.scratch, d.opacity, col, wet.then_some(&self.depth_scratch[..])); // Bounds track the full dab rectangle (even fully transparent parts), like the damage. self.cov.bounds = self.cov.bounds.union(&rect); } @@ -535,7 +597,7 @@ impl StrokeRenderer { for d in &duals { let rect = self.ctx.dab_rect(d, true); self.ctx.rasterize(d, true, rect, &mut self.scratch); - dm.accumulate(rect, &self.scratch, 1.0, false, None); + dm.accumulate(rect, &self.scratch, 1.0, None, None); } } self.dabs_done += dabs.len(); @@ -602,6 +664,7 @@ impl StrokeRenderer { per_dab_color: self.per_dab_color, dabs_done: self.dabs_done, scratch: Vec::new(), + depth_scratch: Vec::new(), dab_buf: dabs, dual_buf: duals, all_dabs: None, @@ -653,7 +716,7 @@ impl StrokeRenderer { if c <= 0.0 { return 0.0; } - self.ctx.stroke_mask(c, self.dual.as_ref().map(|d| d.get(x, y)), x, y) + self.ctx.stroke_mask_wet(c, self.cov.depth(x, y), self.dual.as_ref().map(|d| d.get(x, y)), x, y) } /// Dense final coverage over the stroke bounds. @@ -665,9 +728,11 @@ impl StrokeRenderer { let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&b); for y in tr.y0..tr.y1 { for x in tr.x0..tr.x1 { - let c = t.cov[((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize]; + let ti = ((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize; + let c = t.cov[ti]; if c > 0.0 { - out[(y - b.y0) as usize * w + (x - b.x0) as usize] = self.ctx.stroke_mask(c, self.dual.as_ref().map(|d| d.get(x, y)), x, y); + out[(y - b.y0) as usize * w + (x - b.x0) as usize] = + self.ctx.stroke_mask_wet(c, t.depth.get(ti).copied(), self.dual.as_ref().map(|d| d.get(x, y)), x, y); } } } @@ -722,7 +787,7 @@ impl StrokeRenderer { continue; } let i = (y - tr.y0) as usize * w + (x - tr.x0) as usize; - let m = self.ctx.stroke_mask(c, self.dual.as_ref().map(|d| d.get(x, y)), x, y); + let m = self.ctx.stroke_mask_wet(c, tile.depth.get(ti).copied(), self.dual.as_ref().map(|d| d.get(x, y)), x, y); let s = sel.as_ref().map_or(1.0, |(sc, v)| v[i * sc]); let mut k = (m * opacity * s).min(1.0); if k <= 0.0 { diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index ef15813885..7425dc8a94 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -207,6 +207,18 @@ impl PsdFile { self.resources.iter().find(|r| r.id == id) } + /// The global `Lr16` / `Lr32` / `Layr` block that should have held the layers but could not + /// be parsed, with the parse error. [`Self::from_bytes`] keeps such a block verbatim in + /// [`Self::global_blocks`] and leaves [`Self::layer_info`] `None`, so the file reads as + /// flattened; this tells a reader that its layers were lost rather than absent. + pub fn unreadable_layer_block(&self) -> Option<(&TaggedBlock, PsdError)> { + if self.layer_info.is_some() { + return None; + } + let block = self.global_blocks.iter().find(|b| matches!(&b.key, b"Lr16" | b"Lr32" | b"Layr"))?; + LayerInfo::read_body(&block.data, self.header.version).err().map(|e| (block, e)) + } + /// First global block with the given key. pub fn global_block(&self, key: &[u8; 4]) -> Option<&TaggedBlock> { self.global_blocks.iter().find(|b| &b.key == key) diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index e4378031f5..64c7476864 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -11,11 +11,30 @@ mod interrupt; pub use interrupt::{Cancelled, Interrupt}; use std::collections::BTreeMap; +use std::fmt; use std::sync::Arc; use photocraft_color::{ColorMode, PixelFormat, SampleType, read_sample, write_sample}; use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +/// A checked surface allocation could not be represented or reserved. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AllocationError { + SizeOverflow, + NotEnoughMemory { bytes: usize }, +} + +impl fmt::Display for AllocationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SizeOverflow => f.write_str("requested surface allocation is too large"), + Self::NotEnoughMemory { bytes } => write!(f, "not enough memory to allocate {} bytes", bytes), + } + } +} + +impl std::error::Error for AllocationError {} + /// Pixel storage for one tile: `TILE_SIZE² × bytes_per_pixel`, row-major, interleaved channels. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Tile { @@ -23,6 +42,29 @@ pub struct Tile { } impl Tile { + fn try_filled(format: &PixelFormat, pixel: &[u8]) -> Result { + let n = (TILE_SIZE * TILE_SIZE) as usize; + let bytes = n.checked_mul(format.bytes_per_pixel()).ok_or(AllocationError::SizeOverflow)?; + let mut data = Vec::new(); + data.try_reserve_exact(bytes).map_err(|_| AllocationError::NotEnoughMemory { bytes })?; + data.resize(bytes, 0); + if pixel.iter().any(|&b| b != 0) { + for px in data.chunks_exact_mut(pixel.len()) { + px.copy_from_slice(pixel); + } + } + Ok(Tile { data: data.into_boxed_slice() }) + } + + /// Clone this tile's pixel buffer with a fallible reservation. + pub fn try_clone(&self) -> Result { + let bytes = self.data.len(); + let mut data = Vec::new(); + data.try_reserve_exact(bytes).map_err(|_| AllocationError::NotEnoughMemory { bytes })?; + data.extend_from_slice(&self.data); + Ok(Tile { data: data.into_boxed_slice() }) + } + fn filled(format: &PixelFormat, pixel: &[u8]) -> Self { let n = (TILE_SIZE * TILE_SIZE) as usize; let mut data = vec![0u8; n * format.bytes_per_pixel()]; @@ -55,6 +97,47 @@ impl PartialEq for Surface { } impl Surface { + /// Reads a region into a newly allocated buffer, returning an error instead of aborting if + /// the requested pixel buffer cannot be represented or reserved. + pub fn try_read_region(&self, r: Rect) -> Result, AllocationError> { + let len = (r.width() as usize).checked_mul(r.height() as usize).and_then(|n| n.checked_mul(self.channels())).ok_or(AllocationError::SizeOverflow)?; + let bytes = len.checked_mul(std::mem::size_of::()).ok_or(AllocationError::SizeOverflow)?; + let mut out = Vec::new(); + out.try_reserve_exact(len).map_err(|_| AllocationError::NotEnoughMemory { bytes })?; + out.resize(len, 0.0); + self.read_region_into(r, &mut out); + Ok(out) + } + + /// Reads a region as encoded interleaved bytes with fallible allocation. + pub fn try_to_interleaved(&self, r: Rect) -> Result, AllocationError> { + let bpp = self.format.bytes_per_pixel(); + let len = (r.width() as usize).checked_mul(r.height() as usize).and_then(|n| n.checked_mul(bpp)).ok_or(AllocationError::SizeOverflow)?; + let mut out = Vec::new(); + out.try_reserve_exact(len).map_err(|_| AllocationError::NotEnoughMemory { bytes: len })?; + out.resize(len, 0); + let w = r.width() as usize; + for tc in r.tiles() { + let tr = tc.rect().intersect(&r); + let span = tr.width() as usize * bpp; + for y in tr.y0..tr.y1 { + let dst = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * bpp; + match self.tiles.get(&tc) { + Some(t) => { + let src = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * bpp; + out[dst..dst + span].copy_from_slice(&t.data[src..src + span]); + } + None => { + for px in out[dst..dst + span].chunks_exact_mut(bpp) { + px.copy_from_slice(&self.default_pixel); + } + } + } + } + } + Ok(out) + } + /// Transparent/zero surface. pub fn new(format: PixelFormat) -> Self { Self { format, default_pixel: vec![0u8; format.bytes_per_pixel()].into_boxed_slice(), tiles: BTreeMap::new() } @@ -94,6 +177,44 @@ impl Surface { Arc::make_mut(arc) } + /// Fallible counterpart to [`Surface::tile_mut`]. Pixel storage is reserved before changing + /// the surface, and a shared tile is copied only after its replacement has been allocated. + pub fn try_tile_mut(&mut self, c: TileCoord) -> Result<&mut Tile, AllocationError> { + if !self.tiles.contains_key(&c) { + let tile = Arc::new(Tile::try_filled(&self.format, &self.default_pixel)?); + self.tiles.insert(c, tile); + } else if self.tiles.get(&c).is_some_and(|t| Arc::strong_count(t) > 1) { + let original = self.tiles.get(&c).ok_or(AllocationError::SizeOverflow)?; + let tile = Arc::new(original.try_clone()?); + self.tiles.insert(c, tile); + } + self.tiles.get_mut(&c).and_then(Arc::get_mut).ok_or(AllocationError::SizeOverflow) + } + + /// Writes a region using fallible pixel-tile allocations. Callers that need transactional + /// document updates should perform this on their staged document copy and commit on success. + pub fn try_write_region(&mut self, r: Rect, data: &[f32]) -> Result<(), AllocationError> { + let n = self.channels(); + let sample = self.format.sample; + let expected = (r.width() as usize).checked_mul(r.height() as usize).and_then(|v| v.checked_mul(n)).ok_or(AllocationError::SizeOverflow)?; + if data.len() != expected { + return Err(AllocationError::SizeOverflow); + } + for tc in r.tiles() { + let tr = tc.rect().intersect(&r); + let t = self.try_tile_mut(tc)?; + let span = tr.width() as usize * n; + for y in tr.y0..tr.y1 { + let src = (((y - r.y0) as usize) * r.width() as usize + (tr.x0 - r.x0) as usize) * n; + let dst = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; + for (i, v) in data[src..src + span].iter().enumerate() { + write_sample(&mut t.data, sample, dst + i, *v); + } + } + } + Ok(()) + } + /// Coarse bounds: union of allocated tile rects. pub fn tile_bounds(&self) -> Rect { self.tiles.keys().fold(Rect::EMPTY, |r, c| r.union(&c.rect())) @@ -292,6 +413,23 @@ impl Surface { *d = [g, g, g, s[1] as f32 / 255.0]; } } + (ColorMode::Cmyk, SampleType::U8, _) => { + // Identical ink bytes share the exact ICC result; alpha is independent. + let mut previous: Option<([u8; 4], [f32; 3])> = None; + let src = &t.data[base..base + dst.len() * n]; + for (d, s) in dst.iter_mut().zip(src.chunks_exact(n)) { + let ink = [s[0], s[1], s[2], s[3]]; + let rgb = match previous { + Some((key, rgb)) if key == ink => rgb, + _ => { + let rgb = photocraft_color::convert::cmyk_to_rgb(ink.map(|v| v as f32 / 255.0)); + previous = Some((ink, rgb)); + rgb + } + }; + *d = [rgb[0], rgb[1], rgb[2], s.get(4).map_or(1.0, |v| *v as f32 / 255.0)]; + } + } _ => { let mut px = [0.0f32; 8]; for (i, d) in dst.iter_mut().enumerate() { @@ -412,6 +550,24 @@ impl Surface { .collect() } + /// Fallible counterpart to [`Surface::take_tiles`]. Reserves the complete result and any + /// default tile before removing a tile, so a reservation failure leaves the surface intact. + pub fn try_take_tiles(&mut self, r: Rect) -> Result)>, AllocationError> { + let count = r.tiles().count(); + let bytes = count.checked_mul(std::mem::size_of::<(TileCoord, Arc)>()).ok_or(AllocationError::SizeOverflow)?; + let mut out = Vec::new(); + out.try_reserve_exact(count).map_err(|_| AllocationError::NotEnoughMemory { bytes })?; + let has_missing = r.tiles().any(|c| !self.tiles.contains_key(&c)); + let blank = if has_missing { Some(Arc::new(Tile::try_filled(&self.format, &self.default_pixel)?)) } else { None }; + for c in r.tiles() { + let tile = self.tiles.remove(&c).or_else(|| blank.clone()); + if let Some(tile) = tile { + out.push((c, tile)); + } + } + Ok(out) + } + /// Put tiles back (see [`Surface::take_tiles`]); each replaces the tile at its coordinate. pub fn put_tiles(&mut self, tiles: impl IntoIterator)>) { for (c, t) in tiles { @@ -573,6 +729,29 @@ impl Surface { out } + /// Convert a surface while reserving each replacement tile fallibly. + pub fn try_convert(&self, to: PixelFormat) -> Result { + let mut out = Surface::with_default(to, &convert_pixel(&self.format, &to, &self.default_pixel())); + let from_n = self.channels(); + let mut src = Vec::new(); + src.try_reserve_exact(from_n).map_err(|_| AllocationError::NotEnoughMemory { bytes: from_n * std::mem::size_of::() })?; + src.resize(from_n, 0.0f32); + for (c, t) in &self.tiles { + let dst = out.try_tile_mut(*c)?; + let px_count = (TILE_SIZE * TILE_SIZE) as usize; + for i in 0..px_count { + for (k, v) in src.iter_mut().enumerate() { + *v = read_sample(&t.data, self.format.sample, i * from_n + k); + } + let px = convert_pixel(&self.format, &to, &src); + for (k, v) in px.iter().enumerate() { + write_sample(&mut dst.data, to.sample, i * to.channels() + k, *v); + } + } + } + Ok(out) + } + /// Composite-friendly RGBA read (converts from the surface's model). pub fn rgba(&self, x: i32, y: i32) -> [f32; 4] { let p = self.pixel(x, y); @@ -730,6 +909,22 @@ mod tests { } } + #[test] + fn fallible_region_reads_report_size_overflow() { + let surface = Surface::new(PixelFormat::RGBA8); + let huge = Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX); + assert_eq!(surface.try_read_region(huge), Err(AllocationError::SizeOverflow)); + assert_eq!(surface.try_to_interleaved(huge), Err(AllocationError::SizeOverflow)); + } + + #[test] + fn fallible_region_write_rejects_bad_input_without_mutating_surface() { + let mut surface = Surface::new(PixelFormat::RGBA8); + let before = surface.clone(); + assert_eq!(surface.try_write_region(Rect::new(0, 0, 2, 2), &[1.0]), Err(AllocationError::SizeOverflow)); + assert_eq!(surface, before); + } + #[test] fn fill_rect_shares_covered_tiles_and_edits_stay_local() { // A 300000² background used to materialize ~360 GB of tiles (#705). @@ -839,6 +1034,27 @@ mod tests { assert!((v[0] - v[2]).abs() < 1e-6 && (v[3] - 1.0).abs() < 1e-6); } + #[test] + fn repeated_cmyk_reads_match_individual_pixels_bitwise() { + let rect = Rect::new(-2, -1, 515, 2); + for depth in SampleType::ALL { + for alpha in [false, true] { + let fmt = PixelFormat::new(ColorMode::Cmyk, depth, alpha); + let mut s = Surface::with_default(fmt, &[0.2, 0.4, 0.6, 0.3, 0.7]); + for (i, x) in (-1..514).enumerate() { + let c = if i / 2 % 256 == 0 { -0.0 } else { (i / 2 % 256) as f32 / 255.0 }; + s.write_pixel(x, 0, &[c, 0.4, 0.6, 0.3, (i % 9) as f32 / 8.0]); + } + let mut out = vec![[0.0; 4]; (rect.width() * rect.height()) as usize]; + s.read_rgba_into(rect, &mut out); + for (i, actual) in out.iter().enumerate() { + let expected = s.rgba(rect.x0 + (i % rect.width() as usize) as i32, rect.y0 + (i / rect.width() as usize) as i32); + assert_eq!(actual.map(f32::to_bits), expected.map(f32::to_bits), "{depth:?}, alpha={alpha}, pixel={i}"); + } + } + } + } + #[test] fn translated_matches_reading_the_moved_pixels() { // Any offset (whole tiles, negative, across tile edges), every depth, a non-zero default. diff --git a/crates/raw/Cargo.toml b/crates/raw/Cargo.toml index d02c7e2868..1395da72b4 100644 --- a/crates/raw/Cargo.toml +++ b/crates/raw/Cargo.toml @@ -10,13 +10,18 @@ rust-version.workspace = true default = [] # Synthetic raw-file generators (DNG / CR2 / TIFF-EP writers and a lossless-JPEG # encoder) for tests and fuzz seeds. -testgen = [] +testgen = ["dep:image"] # Real-file corpus tests (opt-in; `cargo xtask test-corpus`). Fail when the corpus is missing. corpus = [] [dependencies] # Adobe DNG compression 8: zlib (RFC 1950) with the TIFF predictor (pure-Rust backend). flate2 = { workspace = true } +# Adobe DNG compression 34892: baseline JPEG tiles (lossy), decoded by zune-jpeg. +zune-jpeg = { workspace = true } +# Encodes the lossy-JPEG tiles of the testgen fixtures (feature `testgen`). +image = { workspace = true, features = ["jpeg"], optional = true } +zune-core = { workspace = true } # Parallel bands on native; wasm builds stay single-threaded. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/raw/src/sensor.rs b/crates/raw/src/sensor.rs index 309512b53b..fe6e0ff868 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -212,8 +212,7 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou return Err(RawError::unsupported("planar raw data")); } match compression { - 1 | 7 | 8 | JPEG_XL => {} - 34892 => return Err(RawError::unsupported("lossy-compressed DNG")), + 1 | 7 | 8 | 34892 | JPEG_XL => {} 34713 => return Err(RawError::unsupported("Nikon compressed NEF")), 32767 => return Err(RawError::unsupported("Sony compressed ARW")), 65535 => return Err(RawError::unsupported("Pentax compressed PEF")), @@ -255,6 +254,32 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou v.resize(s.w * s.h * samples, 0); Ok(v) } + 34892 => { + // Adobe DNG compression 34892: baseline JPEG tiles (lossy, 8-bit). The tile is + // a complete JPEG stream: a CFA part decodes to gray samples, a 3-sample + // LinearRaw part holds YCbCr, which zune-jpeg converts to RGB. The header is + // decoded and size-checked before any pixel allocation, so a hostile tile + // cannot size the decoder's buffers (up to 16384x16384) with a fake SOF. + let out = match samples { + 1 => zune_core::colorspace::ColorSpace::Luma, + 3 => zune_core::colorspace::ColorSpace::RGB, + n => return Err(RawError::unsupported(format!("lossy DNG with {n} samples per pixel (1 or 3 are supported)"))), + }; + let options = zune_core::options::DecoderOptions::default().jpeg_set_out_colorspace(out); + let mut dec = zune_jpeg::JpegDecoder::new_with_options(zune_core::bytestream::ZCursor::new(src), options); + dec.decode_headers().map_err(|e| RawError::malformed(format!("lossy JPEG tile header: {e:?}")))?; + let f = dec.info().ok_or_else(|| RawError::malformed("lossy JPEG tile has no header"))?; + if (f.width as usize, f.height as usize) != (s.w, rows) { + return Err(RawError::malformed(format!("lossy JPEG tile is {}x{}, expected {}x{}", f.width, f.height, s.w, rows))); + } + let decoded = dec.decode().map_err(|e| RawError::malformed(format!("lossy JPEG tile: {e:?}")))?; + let need = s.w * rows * samples; + if decoded.len() != need { + return Err(RawError::malformed(format!("lossy JPEG tile holds {} samples, expected {need}", decoded.len()))); + } + // The 8-bit samples widen to the document's 16-bit range (0xFF -> 0xFFFF). + Ok(decoded.into_iter().map(|b| u16::from(b) * 257).collect()) + } _ => { let need = s.w * rows * samples; let max = s.w.saturating_mul(s.h).saturating_mul(samples).saturating_mul(4); diff --git a/crates/raw/src/testgen.rs b/crates/raw/src/testgen.rs index a372ee6418..1ba316d0e7 100644 --- a/crates/raw/src/testgen.rs +++ b/crates/raw/src/testgen.rs @@ -368,6 +368,9 @@ fn urat(v: f64) -> (u32, u32) { pub enum DngStorage { /// One strip per `rows` rows, uncompressed at `bits` per sample (8, 12, 16…). Strips { rows: usize }, + /// Adobe DNG compression 34892: baseline JPEG tiles, 8-bit luma at tile size (the reader + /// decodes and widens to 16 bits). Quality 60 leaves visible-but-mild artifacts. + LossyJpegTiles { width: usize, height: usize }, /// Adobe DNG compression 8: zlib streams with the TIFF predictor (1 = none, 2 = horizontal /// differencing) applied before compressing. DeflateStrips { rows: usize, predictor: u16 }, @@ -560,6 +563,39 @@ impl DngSpec { raw.push((273, Val::Blobs(offs))); raw.push((279, Val::Long(lens))); } + DngStorage::LossyJpegTiles { width: tw, height: th } => { + let mut offs = Vec::new(); + let mut lens = Vec::new(); + let s = self.samples; + for ty in (0..self.height).step_by(th) { + for tx in (0..self.width).step_by(tw) { + // Edge tiles are padded by repeating the last row / column. A 3-sample + // (LinearRaw) spec writes RGB tiles, a CFA spec gray ones. + let mut tile = vec![0u8; tw * th * s]; + for y in 0..th { + for x in 0..tw { + let sy = (ty + y).min(self.height - 1); + let sx = (tx + x).min(self.width - 1); + for c in 0..s { + tile[(y * tw + x) * s + c] = (self.data[(sy * self.width + sx) * s + c] >> 8) as u8; + } + } + } + let colour = if s == 3 { image::ExtendedColorType::Rgb8 } else { image::ExtendedColorType::L8 }; + let mut j = Vec::new(); + let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut j, 60); + use image::ImageEncoder as _; + enc.write_image(&tile, tw as u32, th as u32, colour).expect("jpeg tile"); + lens.push(j.len() as u32); + offs.push(t.blob(j)); + } + } + raw.push((259, Val::Short(vec![34892]))); + raw.push((322, Val::Long(vec![tw as u32]))); + raw.push((323, Val::Long(vec![th as u32]))); + raw.push((324, Val::Blobs(offs))); + raw.push((325, Val::Long(lens))); + } DngStorage::Lj92Strips { rows } => { let mut offs = Vec::new(); let mut lens = Vec::new(); diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index f0ce247f50..9b19d31b47 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -560,3 +560,88 @@ fn profile_gain_table_map_is_reported() { spec.profile_gain_table_map = Some(vec![0; 64]); assert!(gain_table(&sensor(&spec.build()))); } + +/// Adobe DNG compression 34892 (lossy baseline JPEG, 8-bit luma tiles): decodes to the tile +/// samples widened to 16 bits, within the JPEG quality band (Q60 artifacts are mild but real). +#[test] +fn lossy_jpeg_tiles_decode() { + let (w, h) = (32, 32); + // A smooth ramp: baseline JPEG at Q60 keeps it within a few quantisation steps. + let data: Vec = (0..w * h).map(|i| 4096 + (i / w) as u16 * 16).collect(); + let mut spec = DngSpec::cfa(w, h, data.clone()); + spec.storage = DngStorage::LossyJpegTiles { width: 16, height: 16 }; + let s = sensor(&spec.build()); + assert_eq!((s.width, s.height, s.samples), (w, h, 1)); + let mut worst = 0u32; + for (got, want) in s.data.iter().zip(&data) { + // The tile was written 8-bit (>>8), so the quantised expectation is (want>>8)<<8. + let quant = u32::from(*want >> 8) << 8; + worst = worst.max(u32::from(got.abs_diff(quant as u16))); + } + assert!(worst <= 4 * 256, "worst deviation from the 8-bit quantisation: {worst}"); +} + +/// A lossy JPEG tile cut mid-stream fails cleanly. +#[test] +fn lossy_jpeg_tiles_hostile() { + let (w, h) = (32, 32); + let data: Vec = (0..w * h).map(|_i| 8192u16).collect(); + let mut spec = DngSpec::cfa(w, h, data); + spec.storage = DngStorage::LossyJpegTiles { width: 32, height: 32 }; + let bytes = spec.build(); + let at = bytes.windows(2).position(|w| w == [0xFF, 0xD8]).expect("jpeg SOI") + 2; + let cut = &bytes[..at + (bytes.len() - at) / 2]; + assert!(decode(cut, &Limits::default()).is_err(), "a cut JPEG tile must not decode"); +} + +/// A tile JPEG whose header lies about its size fails the header check, before any pixel +/// decode (a hostile SOF must not be able to size the decoder's buffers first). +#[test] +fn lossy_jpeg_tile_dimension_mismatch_errors() { + use image::ImageEncoder as _; + let (w, h) = (32, 32); + let data: Vec = (0..w * h).map(|i| 4096 + (i / w) as u16 * 16).collect(); + let mut spec = DngSpec::cfa(w, h, data); + spec.storage = DngStorage::LossyJpegTiles { width: 32, height: 32 }; + let mut bytes = spec.build(); + // Swap the 32x32 tile JPEG for a 16x16 one, inside the same segment (the trailing bytes + // after its EOI stay; the JPEG decoder stops at EOI). + let small = { + let img = image::GrayImage::from_fn(16, 16, |x, _| image::Luma([(x as u16 * 16) as u8])); + let mut j = Vec::new(); + let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut j, 60); + enc.write_image(img.as_raw(), 16, 16, image::ExtendedColorType::L8).unwrap(); + j + }; + let soi = bytes.windows(3).position(|w| w == [0xFF, 0xD8, 0xFF]).expect("SOI"); + let eoi = soi + bytes[soi..].windows(2).position(|w| w == [0xFF, 0xD9]).expect("EOI") + 2; + bytes.splice(soi..eoi, small); + let err = decode(&bytes, &Limits::default()).unwrap_err().to_string(); + assert!(err.contains("16x16") && err.contains("32x32"), "{err}"); +} + +/// A 3-sample LinearRaw lossy DNG (YCbCr on disk, as real camera files are) decodes to RGB +/// tiles widened to 16 bits, within the JPEG quality band; a LinearizationTable maps the +/// decoded samples in the develop stage, so the sensor sees the raw widened values. +#[test] +fn lossy_jpeg_rgb_linearraw_decodes() { + let (w, h) = (32, 32); + // Channel offsets of 64 8-bit steps survive JPEG chroma quantisation at Q60. + let data: Vec = (0..w * h * 3).map(|i| 4096 + ((i / (w * 3)) as u16 * 16) + (i % 3) as u16 * 64 * 256).collect(); + let mut spec = DngSpec::cfa(w, h, data.clone()); + spec.samples = 3; + spec.storage = DngStorage::LossyJpegTiles { width: 16, height: 16 }; + let s = sensor(&spec.build()); + assert_eq!((s.width, s.height, s.samples), (w, h, 3)); + let mut worst = 0u32; + for (got, want) in s.data.iter().zip(&data) { + let quant = u32::from(*want >> 8) << 8; + worst = worst.max(u32::from(got.abs_diff(quant as u16))); + } + assert!(worst <= 8 * 256, "worst deviation from the 8-bit quantisation: {worst}"); + // Each channel carries its own level: R and B stay apart everywhere. + for p in 0..w * h { + let (r, b) = (s.data[p * 3], s.data[p * 3 + 2]); + assert!(r.abs_diff(b) >= 64 * 256 - 8 * 256, "channels collapsed at pixel {p}"); + } +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 76994673bb..23d1af5f66 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -31,6 +31,10 @@ use crate::fonts::FontDb; /// readable hierarchy for every font instead of silently rendering lowercase text unchanged. const SYNTHETIC_SMALL_CAPS_SCALE: f32 = 0.7; +/// Photoshop's default tab stops: every half inch, measured from the anchor of point text or the +/// left edge of a paragraph box. PSD type carries no custom tab stops, so these are the only ones. +pub const DEFAULT_TAB_STOP_PT: f32 = 36.0; + /// The rendering path for a `SmallCaps` character style. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SmallCapsMode { @@ -400,6 +404,10 @@ struct KernSlot { size: f32, rtl: bool, blank: bool, + /// Advance along the line (px, horizontal scale included). + advance: f32, + /// A tab, shaped as a space; it advances to the next tab stop. + tab: bool, /// Vertical type, upright glyph (its outline doesn't run along the column). upright: bool, } @@ -488,6 +496,9 @@ impl Layouter { // They have the same UTF-8 length as their uppercase form, so the layer's byte-based // run and caret offsets remain unchanged. let mut synthetic_small_caps: Vec<(Range, f32)> = Vec::new(); + // Offsets in `ptext` of the spaces standing in for tabs. + let mut tabs: Vec = Vec::new(); + let tab_px = DEFAULT_TAB_STOP_PT * k; for (i, ch) in content.char_indices() { // A forced line break ends the line but not the paragraph. The line breaker knows // it as a newline, which has the same length, so text offsets don't move. @@ -497,8 +508,10 @@ impl Layouter { } // Imported PSD text can contain literal tab controls. Font shaping may // render those as .notdef boxes; a space preserves the one-byte source - // and style/caret offsets while supplying a real whitespace advance. + // and style/caret offsets while supplying a real whitespace advance. The space + // is widened to its tab stop after line breaking (`tab_px` below). if ch == '\t' { + tabs.push(ptext.len()); ptext.push(' '); continue; } @@ -523,8 +536,9 @@ impl Layouter { // fetches it (`served`); it joins the fallback stack once it arrives. crate::served::request_for_text(&ptext); let fallback: Vec = fonts.fallback_stack().map(str::to_string).collect(); - let mut layout: Layout = { - let mut b = self.lcx.ranged_builder(&mut fonts.fcx, &ptext, 1.0, false); + // Shapes the paragraph, each tab widened by its letter spacing in `tab_spacing` (px). + let build = |lcx: &mut LayoutContext, fcx: &mut parley::FontContext, tab_spacing: &[f32]| -> Layout { + let mut b = lcx.ranged_builder(fcx, &ptext, 1.0, false); // Paragraph-start style as the default (covers the direction mark and empty // paragraphs), then every run piece intersecting this paragraph. let si0 = style_at(prange.start); @@ -571,20 +585,48 @@ impl Layouter { } } } - for (range, size) in synthetic_small_caps { - b.push(StyleProperty::FontSize(size), range); + for (range, size) in &synthetic_small_caps { + b.push(StyleProperty::FontSize(*size), range.clone()); + } + for (&at, &spacing) in tabs.iter().zip(tab_spacing) { + b.push(StyleProperty::LetterSpacing(spacing), at..at + 1); } b.build(&ptext) }; let indent_start = ps.start_indent_pt * k; let indent_end = ps.end_indent_pt * k; - if ps.first_line_indent_pt != 0.0 { - layout.set_text_indent(ps.first_line_indent_pt * k, IndentOptions::default()); - } + let first_indent = ps.first_line_indent_pt * k; // Box extent along the lines: width, or height for vertical type (columns). let (line_origin, line_len) = if vertical { (box_rect.1, box_rect.3) } else { (box_rect.0, box_rect.2) }; let avail = if is_box { Some((line_len - indent_start - indent_end).max(1.0)) } else { None }; - layout.break_all_lines(avail); + let break_lines = |layout: &mut Layout| { + if first_indent != 0.0 { + layout.set_text_indent(first_indent, IndentOptions::default()); + } + layout.break_all_lines(avail); + }; + // Paragraph text: a tab is never wider than a space plus one tab interval, so breaking + // lines at that width keeps the expanded tabs inside the box, but can wrap early. + // Break again with the tab widths that layout gives until they agree; if they don't + // settle, keep the safe layout. + let wide = if is_box { tab_px } else { 0.0 }; + let mut layout = build(&mut self.lcx, &mut fonts.fcx, &vec![wide; tabs.len()]); + break_lines(&mut layout); + if is_box && !tabs.is_empty() { + let first = tab_widths(&layout, &tabs, &out.styles, indent_start, first_indent, tab_px); + let mut want: Vec = first.iter().map(|t| t.width).collect(); + for _ in 0..4 { + let spacing: Vec = first.iter().zip(&want).map(|(t, w)| w / t.scale - (t.advance - tab_px)).collect(); + let mut next = build(&mut self.lcx, &mut fonts.fcx, &spacing); + break_lines(&mut next); + let got: Vec = tab_widths(&next, &tabs, &out.styles, indent_start, first_indent, tab_px).iter().map(|t| t.width).collect(); + if got.iter().zip(&want).all(|(a, b)| (a - b).abs() < 0.01) { + layout = next; + break; + } + want = got; + } + } let alignment = if is_box { match ps.align { TextAlign::Left => Alignment::Left, @@ -669,6 +711,7 @@ impl Layouter { continue; } line_runs.push(run.index()); + let hs = out.styles.get(gr.style().brush.0 as usize).map_or(1.0, |st| if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 }); for c in run.visual_clusters() { let mut gl = c.glyphs(); let first = gl.next().map(|g| g.id); @@ -684,6 +727,8 @@ impl Layouter { rtl: c.is_rtl(), blank: first.is_none() || c.is_space_or_nbsp() || c.text_range().end <= prefix.len(), upright: vertical && c.first_style().brush.1 != VClass::Rotate as u8, + advance: c.advance() * hs, + tab: tabs.binary_search(&c.text_range().start).is_ok(), }); } } @@ -722,7 +767,22 @@ impl Layouter { } kern_px[j] = units / 1000.0 * a.size; } - let line_kern: f32 = kern_px.iter().sum(); + // Tabs: the "kerning" after a tab takes the pen to the next stop, measured from + // the anchor or the box edge as if the line were left-aligned (alignment then + // moves the whole line). + let mut pos = indent_start + if li == 0 { first_indent } else { 0.0 }; + let mut trailing_tabs = 0.0f32; + for (j, s) in slots.iter().enumerate() { + if s.tab { + kern_px[j] = next_tab_stop(pos, tab_px) - pos - s.advance; + // Trailing whitespace is outside the line's extent (`adv`). + if slots[j + 1..].iter().all(|n| n.blank) { + trailing_tabs += kern_px[j]; + } + } + pos += s.advance + kern_px.get(j).copied().unwrap_or(0.0); + } + let line_kern: f32 = kern_px.iter().sum::() - trailing_tabs; // Per run on the line: (run index, glyph → slot, glyphs emitted, first slot whose // kerning isn't applied yet). let mut cursors: Vec<(usize, Vec, usize, usize)> = Vec::new(); @@ -926,7 +986,7 @@ impl Layouter { range: map(lr.start)..map(lr.end).min(content_end), baseline, x0, - x1: x0 + adv + extra, + x1: x0 + adv + extra - trailing_tabs, ascent, descent, paragraph: pi, @@ -938,6 +998,52 @@ impl Layouter { } } +/// The first tab stop after `pos` (px from the anchor or box edge); stops are `interval` apart. +fn next_tab_stop(pos: f32, interval: f32) -> f32 { + if interval > 0.0 && pos.is_finite() { ((pos + 1e-3) / interval).floor() * interval + interval } else { pos } +} + +/// A tab as line breaking saw it: its shaped advance (before horizontal scale), the horizontal +/// scale, and the width that takes it to its tab stop on its line. +struct TabWidth { + advance: f32, + scale: f32, + width: f32, +} + +/// The width of each tab (offsets `tabs` in the paragraph text) on the broken lines of `layout`, +/// before kerning and alignment. +fn tab_widths(layout: &Layout, tabs: &[usize], styles: &[CharStyle], indent: f32, first_indent: f32, interval: f32) -> Vec { + let mut out: Vec = tabs.iter().map(|_| TabWidth { advance: interval, scale: 1.0, width: interval }).collect(); + for (li, line) in layout.lines().enumerate() { + let mut pos = indent + if li == 0 { first_indent } else { 0.0 }; + let mut seen: Vec = Vec::new(); + for item in line.items() { + let PositionedLayoutItem::GlyphRun(gr) = item else { + continue; + }; + let run = gr.run(); + if seen.contains(&run.index()) { + continue; + } + seen.push(run.index()); + let scale = styles.get(gr.style().brush.0 as usize).map_or(1.0, |st| if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 }); + for c in run.visual_clusters() { + let advance = c.advance(); + match tabs.binary_search(&c.text_range().start).ok().and_then(|i| out.get_mut(i)) { + Some(t) => { + let stop = next_tab_stop(pos, interval); + *t = TabWidth { advance, scale, width: stop - pos }; + pos = stop; + } + None => pos += advance * scale, + } + } + } + } + out +} + /// Byte ranges of paragraphs (each including its `\r`, `\n` or `\r\n` terminator). Text ending /// with a break yields a final empty paragraph, like Photoshop's trailing empty line. pub fn split_paragraphs(text: &str) -> Vec> { diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 6c9973085c..39255b79f4 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -740,41 +740,64 @@ pub const MAX_TEXT_INDEX: i32 = 0xFFFF; /// `previous` is the block this replaces (the file's own). A kept object — one whose text is /// unchanged — keeps everything the file held beyond the two regenerated keys (its model's text /// and style runs): Photoshop stores glyph pen positions under `/21 /1` there, and a save with -/// no text edit must not drop them. A changed text invalidates those extras along with the old -/// runs, so they are dropped rather than left stale. +/// no text edit must not drop them. Its own style runs keep every style key too: only their +/// auto-kern mode is set (split where a mode changes inside a run). A changed text invalidates +/// those extras along with the old runs, so they are dropped rather than left stale. Everything +/// else the file's block held — the other keys beside the text objects, objects no layer names — +/// is kept, and a block whose content comes out unchanged is returned byte for byte (#2374: +/// Photoshop refused files whose `Txt2` an unedited save had stripped). pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec { let prev = previous.and_then(parse_txt2); let prev_objects: &[E] = prev.as_ref().and_then(|p| p.path(&["1", "1"]).and_then(E::as_array)).unwrap_or(&[]); - let mut slots: Vec = Vec::new(); + let mut slots: Vec = prev_objects.to_vec(); for (index, layer) in objects { if !(0..=MAX_TEXT_INDEX).contains(index) { continue; } let text = format!("{}\r", layer.text.replace('\n', "\r")); - let runs: Vec = txt2_mode_runs(layer) - .into_iter() - .map(|(mode, len)| { - // `<< /0 << /0 << /0 () /6 << /0 0 /11 mode >> >> >> /1 len >>`. - let style = E::Dict(vec![("0".into(), E::Int(0)), ("11".into(), E::Int(mode))]); - let data = E::Dict(vec![("0".into(), E::String(String::new())), ("6".into(), style)]); - E::Dict(vec![("0".into(), E::Dict(vec![("0".into(), data)])), ("1".into(), E::Int(len as i64))]) - }) - .collect(); + let modes = txt2_mode_runs(layer); let at = *index as usize; let kept = prev_objects.get(at).filter(|o| o.get("0").and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str())); let mut object = kept.cloned().unwrap_or_else(E::dict); let mut model = object.get("0").cloned().filter(|m| matches!(m, E::Dict(_))).unwrap_or_else(E::dict); + let file_runs = kept.and_then(|_| model.path(&["6", "0"]).and_then(E::as_array)).and_then(|runs| with_modes(runs, &modes)); + let runs = file_runs.unwrap_or_else(|| { + modes + .iter() + .map(|&(mode, len)| { + // `<< /0 << /0 << /0 () /6 << /0 0 /11 mode >> >> >> /1 len >>`. + let style = E::Dict(vec![("0".into(), E::Int(0)), ("11".into(), E::Int(mode))]); + let data = E::Dict(vec![("0".into(), E::String(String::new())), ("6".into(), style)]); + E::Dict(vec![("0".into(), E::Dict(vec![("0".into(), data)])), ("1".into(), E::Int(len as i64))]) + }) + .collect() + }); model.set("0", E::String(text)); - model.set("6", E::Dict(vec![("0".into(), E::Array(runs))])); + match model.get_mut("6") { + Some(style_run @ E::Dict(_)) => style_run.set("0", E::Array(runs)), + _ => model.set("6", E::Dict(vec![("0".into(), E::Array(runs))])), + } object.set("0", model); if slots.len() <= at { slots.resize(at + 1, E::dict()); } - slots[at] = object; + if let Some(slot) = slots.get_mut(at) { + *slot = object; + } + } + // The outer dictionary keeps whatever the block held (`/98` version, block extras, …), and + // `/1` whatever it holds beside the text objects. + let mut outer = prev.clone().unwrap_or_else(|| E::Dict(vec![("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), ("0".into(), E::dict())])); + match outer.get_mut("1") { + Some(objs @ E::Dict(_)) => objs.set("1", E::Array(slots)), + _ => outer.set("1", E::Dict(vec![("1".into(), E::Array(slots))])), + } + // Nothing changed: the file's own bytes (writing them back would re-encode strings and numbers). + if let (Some(bytes), Some(p)) = (previous, &prev) + && *p == outer + { + return bytes.to_vec(); } - // The outer dictionary keeps whatever the block held (`/98` version, block extras, …). - let mut outer = prev.unwrap_or_else(|| E::Dict(vec![("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), ("0".into(), E::dict())])); - outer.set("1", E::Dict(vec![("1".into(), E::Array(slots))])); match outer { E::Dict(items) => ed::write_bare(&items), // Unreachable: `parse_txt2` and the fallback above both yield a dictionary. @@ -782,6 +805,51 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec } } +/// The file's own `Txt2` style runs `runs` with the auto-kern modes `modes` (`(mode, UTF-16 +/// length)`, see [`txt2_mode_runs`]) set at `/0 /0 /6 /11`, keeping every other style key. A +/// run a mode change falls inside is split there. `None` when the runs don't cover the text or +/// lack a style dictionary: the caller writes its own runs instead. +fn with_modes(runs: &[E], modes: &[(i64, usize)]) -> Option> { + let lens: Vec = runs.iter().map(|r| r.get("1").and_then(E::as_i64).and_then(|l| usize::try_from(l).ok())).collect::>()?; + let total = modes.iter().try_fold(0usize, |a, m| a.checked_add(m.1))?; + if lens.iter().try_fold(0usize, |a, l| a.checked_add(*l))? != total { + return None; + } + let mut out = Vec::with_capacity(runs.len()); + let (mut mi, mut mode_end) = (0usize, modes.first()?.1); + let mut at = 0usize; + for (run, len) in runs.iter().zip(lens) { + if len == 0 { + out.push(run.clone()); + continue; + } + let end = at + len; + while at < end { + while at >= mode_end && mi + 1 < modes.len() { + mi += 1; + mode_end = mode_end.saturating_add(modes.get(mi)?.1); + } + let piece_end = end.min(mode_end); + if piece_end <= at { + return None; + } + let mut piece = run.clone(); + if piece_end - at != len { + piece.set("1", E::Int(i64::try_from(piece_end - at).ok()?)); + } + let style = piece.get_mut("0").and_then(|v| v.get_mut("0")).and_then(|v| v.get_mut("6")).filter(|v| matches!(v, E::Dict(_)))?; + let mode = modes.get(mi)?.0; + // Keep the file's value (and its number type) when the mode is the same. + if style.get("11").and_then(E::as_i64) != Some(mode) { + style.set("11", E::Int(mode)); + } + out.push(piece); + at = piece_end; + } + } + Some(out) +} + /// The layer's text-object number in the document's `Txt2` block, when its `TySh` names one /// within `0..=`[`MAX_TEXT_INDEX`]. PSD export keeps a preserved number — its regenerated `Txt2` /// object sits at that slot, and [`apply_txt2`] looks the slot up — and numbers layers without diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 46c1e26f08..3f656c21af 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -60,6 +60,94 @@ fn literal_psd_tabs_shape_as_whitespace_without_shifting_text_offsets() { assert!(l.caret(2).0 > l.caret(1).0, "caret moves across the tab"); } +/// Pen position of the cluster starting at byte `at`. +fn cluster_x(l: &crate::TextLayout, at: usize) -> f32 { + l.clusters.iter().find(|c| c.range.start == at).map(|c| c.x).unwrap() +} + +/// #1881: a tab advances to Photoshop's next default tab stop (every 36 pt, from the anchor or +/// the box's left edge), not one space width. +#[test] +fn tabs_advance_to_the_next_default_tab_stop() { + let mut e = TextEngine::new(); + let near = |a: f32, b: f32| (a - b).abs() < 0.05; + let l = e.layout(&point("a\tb", 20.0), 72.0); + assert!(l.glyphs.iter().all(|g| g.id != 0), "no .notdef"); + assert!(near(cluster_x(&l, 2), 36.0), "{}", cluster_x(&l, 2)); + assert!(near(l.caret(2).0, 36.0), "the caret after the tab sits on the stop"); + // Stops are in points: twice the pixels at 144 dpi. + let l = e.layout(&point("a\tb", 20.0), 144.0); + assert!(near(cluster_x(&l, 2), 72.0), "{}", cluster_x(&l, 2)); + // Each tab moves to the following stop; text past a stop skips to the next one. + let l = e.layout(&point("a\t\tb", 20.0), 72.0); + assert!(near(cluster_x(&l, 3), 72.0), "{}", cluster_x(&l, 3)); + let l = e.layout(&point("MMM\tb", 20.0), 72.0); + assert!(cluster_x(&l, 3) > 36.0); + assert!(near(cluster_x(&l, 4), 72.0), "{}", cluster_x(&l, 4)); + // A tab at the start of a line goes to the first stop; the line extent includes it. + let l = e.layout(&point("\tb", 20.0), 72.0); + assert!(near(cluster_x(&l, 1), 36.0), "{}", cluster_x(&l, 1)); + assert!(l.lines[0].x1 > 36.0); + // Paragraph text measures from the box's left edge. + let mut t = point("a\tb", 20.0); + t.shape = TextShape::Box { x: 10.0, y: 0.0, width: 300.0, height: 100.0 }; + let l = e.layout(&t, 72.0); + assert!(near(cluster_x(&l, 2), 46.0), "{}", cluster_x(&l, 2)); +} + +/// The tab stays a tab in the document: PSD type round-trips it unchanged. +#[test] +fn psd_round_trips_tabs() { + for shape in [TextShape::Point, TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 100.0 }] { + let t = TextLayer { shape, ..point("Name\tValue\n\tIndented", 20.0) }; + let back = crate::psd::text_layer_from_tysh(&crate::psd::build_tysh(&t, 72.0, None), 72.0).unwrap(); + assert_eq!(back.text, t.text, "{shape:?}"); + } +} + +/// Expanded tabs never push paragraph text past the box's right edge. +#[test] +fn tabs_wrap_inside_the_box() { + let mut e = TextEngine::new(); + let mut t = point("a\tb\tc\td\te\tf\tg", 20.0); + t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 100.0, height: 1000.0 }; + let l = e.layout(&t, 72.0); + assert!(l.lines.len() > 1, "{}", l.lines.len()); + // Lines break at the real tab widths: "c" (stop 72 px) still fits, "d" (stop 108 px) wraps. + let line_of = |at: usize| l.clusters.iter().find(|c| c.range.start == at).map(|c| c.line).unwrap(); + assert_eq!((line_of(4), line_of(6)), (0, 1)); + assert!((cluster_x(&l, 6) - 0.0).abs() < 0.05, "a wrapped line starts at the box edge"); + for c in &l.clusters { + let ch = &t.text[c.range.clone()]; + if ch != "\t" { + assert!(c.x + c.advance <= 100.0 + 1e-3, "{ch:?} at {} overflows", c.x); + } + } +} + +/// Right-aligned and centred point text keep their alignment around the expanded tab. +#[test] +fn tabs_keep_point_text_alignment() { + let mut e = TextEngine::new(); + let r = e.layout(&with_para(point("a\tb", 20.0), ParagraphStyle { align: TextAlign::Right, ..Default::default() }), 72.0); + assert!(r.lines[0].x1.abs() < 0.05, "{:?}", r.lines[0]); + let b = r.clusters.iter().find(|c| c.range.start == 2).unwrap(); + assert!((b.x + b.advance).abs() < 0.05, "b ends at the anchor: {b:?}"); + assert!(r.lines[0].x0 < -36.0); + // Paragraph text: the line still ends at the box's right edge; a trailing tab is + // whitespace outside the line's extent. + for text in ["a\tb", "a\tb\t"] { + let t = with_para( + TextLayer { shape: TextShape::Box { x: 0.0, y: 0.0, width: 200.0, height: 100.0 }, ..point(text, 20.0) }, + ParagraphStyle { align: TextAlign::Right, ..Default::default() }, + ); + let l = e.layout(&t, 72.0); + let b = l.clusters.iter().find(|c| c.range.start == 2).unwrap(); + assert!((b.x + b.advance - 200.0).abs() < 0.5, "{text:?}: {b:?}"); + assert!((l.lines[0].x1 - 200.0).abs() < 0.5, "{text:?}: {:?}", l.lines[0]); + } +} + #[test] fn metrics_are_stable_and_scale_with_dpi() { let mut e = TextEngine::new(); @@ -648,6 +736,92 @@ fn txt2_keeps_a_kept_objects_extras_and_drops_them_with_its_text() { assert!(object.path(&["0", "keep"]).is_none(), "model extras of another text are dropped"); } +/// A `Txt2` shaped like Photoshop's: document resources and settings beside the text objects +/// (`/0`, `/1 /0`, `/1 /2`), style runs carrying a full style (font, size, leading) besides the +/// auto-kern mode, a Latin-1 string without a byte-order mark, reals, and a second object no +/// layer names. `modes` are the object's `(UTF-16 length, auto-kern mode)` runs for "AVA\r". +fn photoshop_txt2(modes: &[(usize, i64)]) -> Vec { + let mut v = b"\n\n/98 << /0 14 >> /0 << /1 << /0 [ << /0 << /0 (MyriadPro-Regular) /2 0 >> >> ] >> >> /1 << /0 << /0 1 /1 .5 >> /1 [ << /0 << /0 (\xfe\xff\0A\0V\0A\0\r) /6 << /0 [ ".to_vec(); + for (len, m) in modes { + v.extend_from_slice(format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /6 << /0 0 /1 12.0 /2 14.5 /11 {m} >> >> >> /1 {len} >> ").as_bytes()); + } + v.extend_from_slice( + b"] >> /7 << /0 1 >> >> /21 << /1 [ 1.25 2.5 ] >> >> << /0 << /0 (\xfe\xff\0B\0\r) >> /21 << /1 [ 3.0 ] >> >> ] /2 << /0 7 >> >> /3 << /0 .25 >>", + ); + v +} + +/// #2374: an unedited save hands Photoshop back its own `Txt2`, byte for byte. Rebuilding it +/// dropped `/1 /0` and `/1 /2`, emptied unnamed objects and cut every style run down to its +/// auto-kern mode, and Photoshop refused to open the file. +#[test] +fn unchanged_txt2_is_written_back_byte_for_byte() { + use photocraft_doc::text::Kerning; + let prev = photoshop_txt2(&[(2, 1), (2, 1)]); + let tysh = with_text_index(&crate::psd::build_tysh(&styled("AVA", CharStyle::default()), 72.0, None), 0); + let mut t = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + crate::psd::apply_txt2(&mut t, &tysh, &crate::psd::parse_txt2(&prev).unwrap()); + assert!(t.char_runs().iter().all(|r| r.style.kerning == Kerning::Metrics)); + assert_eq!(crate::psd::build_txt2(&[(0, &t)], Some(&prev)), prev); + // An optical run reads back and writes back the same way. + let prev = photoshop_txt2(&[(1, 2), (3, 1)]); + crate::psd::apply_txt2(&mut t, &tysh, &crate::psd::parse_txt2(&prev).unwrap()); + assert_eq!(t.char_runs().first().map(|r| r.style.kerning), Some(Kerning::Optical)); + assert_eq!(crate::psd::build_txt2(&[(0, &t)], Some(&prev)), prev); +} + +/// An edited auto-kern mode changes only `/11` (splitting the run it falls inside); the file's +/// style keys, the other `/1` keys and the objects no layer names all survive. +#[test] +fn edited_txt2_keeps_the_files_styles_and_objects() { + use crate::engine_data::Value as E; + use photocraft_doc::text::Kerning; + let prev = photoshop_txt2(&[(4, 1)]); + let style = |kerning: Kerning| CharStyle { kerning, ..Default::default() }; + let t = runs_of("AVA", &[(1, style(Kerning::Optical)), (2, style(Kerning::Metrics))]); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); + let before = crate::psd::parse_txt2(&prev).unwrap(); + for key in [&["0"][..], &["1", "0"], &["1", "2"], &["3"]] { + assert_eq!(out.path(key), before.path(key), "/{} survives", key.join(" /")); + } + let objects = out.path(&["1", "1"]).and_then(E::as_array).unwrap(); + assert_eq!(objects.len(), 2, "the object no layer names is kept"); + assert_eq!(objects.get(1), before.path(&["1", "1"]).and_then(E::as_array).unwrap().get(1)); + let object = &objects[0]; + assert_eq!(object.path(&["0", "7", "0"]).and_then(E::as_i64), Some(1), "model extras survive"); + assert!(object.path(&["21", "1"]).is_some(), "pen positions survive an unchanged text"); + let runs = object.path(&["0", "6", "0"]).and_then(E::as_array).unwrap(); + let got: Vec<(i64, i64)> = + runs.iter().map(|r| (r.get("1").and_then(E::as_i64).unwrap(), r.path(&["0", "0", "6", "11"]).and_then(E::as_i64).unwrap())).collect(); + assert_eq!(got, vec![(1, 2), (3, 1)], "the run is split where the mode changes"); + for r in runs { + assert_eq!(r.path(&["0", "0", "6", "1"]).and_then(E::as_f64), Some(12.0), "the font size survives"); + assert_eq!(r.path(&["0", "0", "6", "2"]).and_then(E::as_f64), Some(14.5), "the leading survives"); + } + // And it reads back with the edited modes. + let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, None), 0); + let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + crate::psd::apply_txt2(&mut back, &tysh, &out); + let modes: Vec<(usize, Kerning)> = back.char_runs().iter().map(|r| (r.len, r.style.kerning)).collect(); + assert_eq!(modes, vec![(1, Kerning::Optical), (2, Kerning::Metrics)]); +} + +/// The file's style runs are only reused when they cover the text: runs of another length (or +/// hostile lengths) fall back to generated runs, without panicking. +#[test] +fn txt2_runs_that_dont_cover_the_text_are_regenerated() { + use crate::engine_data::Value as E; + let t = runs_of("AVA", &[(3, CharStyle::default())]); + for modes in [&[(3, 1)][..], &[(9, 1)], &[(i64::MAX as usize, 1), (2, 1)], &[]] { + let prev = photoshop_txt2(modes); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); + let runs = out.path(&["1", "1"]).and_then(E::as_array).unwrap()[0].path(&["0", "6", "0"]).and_then(E::as_array).unwrap(); + let total: i64 = runs.iter().filter_map(|r| r.get("1").and_then(E::as_i64)).sum(); + assert_eq!(total, 4, "{modes:?}"); + assert!(out.path(&["1", "2"]).is_some()); + } +} + /// A file-controlled `TextIndex` must not size the save: out-of-range numbers are ignored (the /// slot array is sized by real text objects, never by a file's numbers) and reading one back is /// a no-op, so a hostile file degrades instead of allocating gigabytes on export. diff --git a/crates/text/src/warp.rs b/crates/text/src/warp.rs index 13a5c8a394..8ad433e5cf 100644 --- a/crates/text/src/warp.rs +++ b/crates/text/src/warp.rs @@ -37,6 +37,14 @@ pub fn psd_style(id: &str) -> Option<&'static str> { STYLES.iter().find(|(psd, short)| psd.to_ascii_lowercase() == l || short.to_ascii_lowercase() == l).map(|(psd, _)| *psd) } +/// Short id (`flag`, `none`) for a PSD style name (`warpFlag`, `warpNone`): the reverse of [`psd_style`]. +pub fn short_style(psd: &str) -> Option<&'static str> { + if psd == "warpNone" { + return Some("none"); + } + STYLES.iter().find(|(p, _)| *p == psd).map(|(_, short)| *short) +} + /// A warp prepared for one layout: the shared style math of /// [`photocraft_geom::warp::StyleWarp`] (also used by Edit › Transform › Warp) over the text box. #[derive(Clone, Copy, Debug)] @@ -71,6 +79,18 @@ mod tests { Warp::new(&w, [0.0, -40.0, 200.0, 10.0]).unwrap() } + /// #218: the Warp Text dialog shows a layer's PSD style by its short id. + #[test] + fn short_ids_round_trip_with_psd_styles() { + assert_eq!(short_style("warpFlag"), Some("flag")); + assert_eq!(short_style("warpNone"), Some("none")); + assert_eq!(short_style("warpCustom"), None); + for (psd, short) in STYLES { + assert_eq!(short_style(psd), Some(short)); + assert_eq!(psd_style(short_style(psd).unwrap()), Some(psd)); + } + } + #[test] fn styles_resolve_and_none_is_identity() { assert_eq!(psd_style("Arc"), Some("warpArc")); diff --git a/crates/ui-egui/examples/lasso_bench.rs b/crates/ui-egui/examples/lasso_bench.rs index b9c3760a2e..73b1420717 100644 --- a/crates/ui-egui/examples/lasso_bench.rs +++ b/crates/ui-egui/examples/lasso_bench.rs @@ -75,7 +75,7 @@ fn screen(h: &H, x: f64, y: f64) -> Pos2 { let idx = app.session.active_index().unwrap_or(0); let v = &app.ui.views[idx]; let rect = photocraft_ui_egui::rulers::content_rect(app, app.last_canvas_rect); - ViewXform { rect, zoom: v.zoom, center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation }.to_screen(x as f32, y as f32) + ViewXform { rect, zoom: v.zoom, center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation, aspect: 1.0 }.to_screen(x as f32, y as f32) } fn stats(name: &str, mut v: Vec) { diff --git a/crates/ui-egui/examples/layout_bench.rs b/crates/ui-egui/examples/layout_bench.rs index e1b625570f..a846415423 100644 --- a/crates/ui-egui/examples/layout_bench.rs +++ b/crates/ui-egui/examples/layout_bench.rs @@ -86,7 +86,7 @@ fn screen(h: &H, x: f64, y: f64) -> Pos2 { let idx = app.session.active_index().unwrap_or(0); let v = &app.ui.views[idx]; let rect = photocraft_ui_egui::rulers::content_rect(app, app.last_canvas_rect); - ViewXform { rect, zoom: v.zoom, center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation }.to_screen(x as f32, y as f32) + ViewXform { rect, zoom: v.zoom, center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation, aspect: 1.0 }.to_screen(x as f32, y as f32) } fn select(h: &mut H, id: LayerId) { diff --git a/crates/ui-egui/examples/parity.rs b/crates/ui-egui/examples/parity.rs index 6548204a9d..b618dd254e 100644 --- a/crates/ui-egui/examples/parity.rs +++ b/crates/ui-egui/examples/parity.rs @@ -1,7 +1,7 @@ //! Print Photoshop menu parity and optionally write the Markdown report. //! //! ```sh -//! cargo run -p photocraft-ui-egui --example parity -- [--write docs/parity.md] [--json] +//! cargo run -p photocraft-ui-egui --example parity -- [--write docs/parity-checklist.md] [--json] //! ``` //! Usually run through `cargo xtask parity`. diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index 67ac9f59e6..6bc528231a 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -1,7 +1,7 @@ //! Actions panel: record and replay command sequences. //! //! The list and the recording flag live on the engine session (`actions.record` / `stop` / -//! `play` / `list` / `get` / `delete` / `move`), so the panel, the CLI and MCP share them. This module +//! `play` / `list` / `get` / `delete` / `move` / `rename`), so the panel, the CLI and MCP share them. This module //! keeps only which row is selected and which rows are expanded. use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; @@ -23,6 +23,16 @@ pub struct ActionsUi { pub expanded: Vec, #[serde(skip)] reveal_recording: Option<(usize, usize)>, + /// The action whose name is being edited in place (double-click on its name). + #[serde(skip)] + renaming: Option, +} + +#[derive(Clone, Debug, PartialEq)] +struct RenameAction { + index: usize, + text: String, + focused: bool, } fn label_of(id: &str) -> String { @@ -54,7 +64,7 @@ pub fn action_steps(action: &Action) -> Vec { /// Named action bindings use the existing persisted shortcut overrides. Keeping the /// name in the key means selecting or deleting another row cannot retarget F6. -pub(crate) const SHORTCUT_PREFIX: &str = "actions.play:"; +pub(crate) const SHORTCUT_PREFIX: &str = actions_cmds::PLAY_SHORTCUT_PREFIX; pub(crate) fn shortcut_id(name: &str) -> String { format!("{SHORTCUT_PREFIX}{name}") @@ -88,6 +98,13 @@ pub(crate) fn play(app: &mut PhotocraftApp, params: &Value) -> Result Option { + let mut r = app.ui.actions.renaming.take().filter(|r| r.index == index)?; + let id = egui::Id::new(("action-rename-field", index)); + let te = ui.put(rect, egui::TextEdit::singleline(&mut r.text).id(id).font(egui::FontId::proportional(12.0))); + if !r.focused { + te.request_focus(); + // Like Photoshop, the whole name starts selected so typing replaces it. + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) { + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(r.text.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), id); + } + r.focused = true; + app.ui.actions.renaming = Some(r); + return None; + } + let (enter, tab, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Tab), i.key_pressed(egui::Key::Escape))); + if esc { + None + } else if enter || tab || te.lost_focus() || !te.has_focus() { + Some(r.text) + } else { + app.ui.actions.renaming = Some(r); + None + } +} + +fn rename_action(app: &mut PhotocraftApp, index: usize, name: String) { + if let Err(e) = app.run("actions.rename", json!({"action": index, "name": name})) { + app.ui.status = format!("Could not rename the action: {e}"); + app.ui.status_error = true; + } +} + pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let recording = app.session.actions.recording; @@ -265,6 +318,10 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if app.ui.actions.expanded.len() > rows.len() { app.ui.actions.expanded.truncate(rows.len()); } + if app.ui.actions.renaming.as_ref().is_some_and(|r| r.index >= rows.len()) { + app.ui.actions.renaming = None; + } + let mut renamed = None; let max_h = (ui.available_height() - 70.0).clamp(80.0, 320.0); let mut play_idx = None; let mut moved = None; @@ -320,7 +377,16 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::icons::paint(ui, Rect::from_center_size(pos2(rect.left() + 30.0, rect.center().y), vec2(16.0, 16.0)), "play", 11.0, t.icon); let recording_this = recording.is_some_and(|(_, r)| r == i); let nsteps = steps.len(); - ui.painter().text(pos2(rect.left() + 44.0, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text); + let name_rect = if app.ui.actions.renaming.as_ref().is_some_and(|r| r.index == i) { + let field = + Rect::from_min_max(pos2(rect.left() + 40.0, rect.top() + 3.0), pos2((rect.right() - 80.0).max(rect.left() + 120.0), rect.bottom() - 3.0)); + if let Some(text) = rename_field(app, ui, i, field) { + renamed = Some((i, text)); + } + field + } else { + ui.painter().text(pos2(rect.left() + 44.0, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text) + }; ui.painter().text( pos2(rect.right() - 8.0, rect.center().y), Align2::RIGHT_CENTER, @@ -358,7 +424,12 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } }); if resp.double_clicked() { - play_idx = Some(i); + // Photoshop renames in place when the name itself is double-clicked. + if resp.interact_pointer_pos().is_some_and(|p| name_rect.expand2(vec2(4.0, 13.0)).contains(p)) { + app.ui.actions.renaming = Some(RenameAction { index: i, text: name.clone(), focused: false }); + } else { + play_idx = Some(i); + } } if expanded { for (step, (id, params)) in steps.iter().enumerate() { @@ -386,6 +457,9 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } }); + if let Some((index, name)) = renamed { + rename_action(app, index, name); + } if let Some((source, to)) = moved { move_row(app, source, to); } @@ -436,6 +510,40 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + #[test] + fn double_clicking_an_action_name_renames_it_in_place() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.session.actions.list.push(Action { name: "Action 1".into(), steps: vec![("layer.new.layer".into(), json!({}))] }); + let layers = app.session.active().unwrap().doc.layers.len(); + // 60 fps steps, so two clicks a frame apart count as a double-click. + let mut h = Harness::builder().with_size(vec2(320.0, 260.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app| panel(app, ui), app); + h.run_steps(3); + let rename = |h: &mut Harness<'_, PhotocraftApp>, text: &str| { + let name = h.get_by_label("Action 1").rect().left_center() + vec2(52.0, 0.0); + h.event(egui::Event::PointerMoved(name)); + // Wait out the double-click window of the previous attempt. + h.run_steps(30); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: name, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + }; + // Whitespace keeps the old name and reports why. + rename(&mut h, " "); + assert_eq!(h.state().session.actions.list[0].name, "Action 1"); + assert!(h.state().ui.status_error); + rename(&mut h, "Sepia"); + assert_eq!(h.state().session.actions.list[0].name, "Sepia"); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), layers, "renaming must not play the action"); + } + #[test] fn play_button_targets_first_action_if_nothing_is_selected() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -623,7 +731,7 @@ mod tests { let idx = app.session.active_index().unwrap(); assert!(app.ui.views[idx].fit_pending); let doc = &app.session.active().unwrap().doc; - crate::canvas::fit_view(&mut app.ui.views[idx], doc, vec2(100.0, 100.0), 1.0); + crate::canvas::fit_view(&mut app.ui.views[idx], doc, vec2(100.0, 100.0), 1.0, 1.0); assert_eq!(app.ui.views[idx].zoom, 0.75); assert_eq!(app.ui.views[idx].center, [40.0, 30.0]); assert!(!app.ui.views[idx].fit_pending); @@ -680,4 +788,36 @@ mod tests { assert_eq!(d.layers.len(), 2); assert_eq!(d.layers[1].surface().unwrap().pixel(5, 5), vec![1.0, 0.0, 0.0, 1.0]); } + + #[test] + fn recorded_save_as_and_save_replay_and_write_the_file() { + let written = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let log = written.clone(); + let services = crate::Services { + export: Some(Box::new(|doc, path, _| { + photocraft_io::export(doc, path, &Default::default()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) + })), + write: Some(Box::new(move |path, _| { + log.borrow_mut().push(path.to_string()); + Ok(()) + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + app.run("actions.record", json!({})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": "out/result.psd"})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + app.run("actions.stop", json!({})).unwrap(); + let steps = &app.session.actions.list[0].steps; + assert_eq!(steps, &[("layer.new.layer".into(), json!({})), ("file.saveAs".into(), json!({"path": "out/result.psd"})), ("file.save".into(), json!({}))]); + written.borrow_mut().clear(); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + let played = app.run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(*written.borrow(), ["out/result.psd", "out/result.psd"]); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some("out/result.psd")); + } } diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index e1b863ae96..a4b831ed2f 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -189,6 +189,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map Ed "vibrance" => sliders(ui, v, &[("vibrance", "Vibrance", -100.0, 100.0, 0.0, "%"), ("saturation", "Saturation", -100.0, 100.0, 0.0, "%")]), "hueSaturation" => hue_saturation(ui, v, cx), "colorBalance" => color_balance(ui, v, cx), - "blackWhite" => black_white(ui, v), + "blackWhite" => black_white(ui, v, cx), "photoFilter" => photo_filter(ui, v, cx), "channelMixer" => channel_mixer(ui, v, cx), "posterize" => sliders(ui, v, &[("levels", "Levels", 2.0, 255.0, 4.0, "")]), @@ -200,11 +203,17 @@ fn sliders(ui: &mut egui::Ui, v: &mut Value, rows: &[(&str, &str, f32, f32, f32, e } -fn gradient_slider(ui: &mut egui::Ui, text: &str, x: &mut f32, range: std::ops::RangeInclusive, unit: &str, from: Color32, to: Color32) -> Edit { - let r = widgets::slider_row(ui, text, x, range, unit, Some(&[from, to])); +fn gradient_slider(ui: &mut egui::Ui, text: &str, x: &mut f32, range: std::ops::RangeInclusive, unit: &str, stops: [Color32; 2], dynamic: bool) -> Edit { + let r = widgets::slider_row(ui, text, x, range, unit, dynamic.then_some(&stops)); Edit::of(&r) } +/// Preferences ▸ Interface ▸ Dynamic Color Sliders: the colour track for a slider, or the plain +/// track when it is off. +fn track_stops<'a>(cx: &EditorCx, stops: &'a [Color32]) -> Option<&'a [Color32]> { + cx.dynamic_color_sliders.then_some(stops) +} + // --------------------------------------------------------------------------------------------- // Brightness/Contrast @@ -763,7 +772,7 @@ fn hue_saturation(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { if range == 0 { let (hlo, hhi, slo) = if colorize { (0.0, 360.0, 0.0) } else { (-180.0, 180.0, -100.0) }; let mut h = num(v, "hue", 0.0).clamp(hlo, hhi); - let r = widgets::slider_row(ui, tl!("Hue"), &mut h, hlo..=hhi, "°", Some(&hue_grad)); + let r = widgets::slider_row(ui, tl!("Hue"), &mut h, hlo..=hhi, "°", track_stops(cx, &hue_grad)); if r.changed() { v["hue"] = json!(h.round()); } @@ -771,14 +780,14 @@ fn hue_saturation(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { // The Saturation and Lightness tracks follow the current hue, as in Photoshop (UI-217-16). let sat_track = saturation_track(h); let mut s = num(v, "saturation", 0.0).clamp(slo, 100.0); - let r = widgets::slider_row(ui, tl!("Saturation"), &mut s, slo..=100.0, "%", Some(&sat_track)); + let r = widgets::slider_row(ui, tl!("Saturation"), &mut s, slo..=100.0, "%", track_stops(cx, &sat_track)); if r.changed() { v["saturation"] = json!(s.round()); } e.add(Edit::of(&r)); let light_track = lightness_track(h); let mut l = num(v, "lightness", 0.0).clamp(-100.0, 100.0); - let r = widgets::slider_row(ui, tl!("Lightness"), &mut l, -100.0..=100.0, "%", Some(&light_track)); + let r = widgets::slider_row(ui, tl!("Lightness"), &mut l, -100.0..=100.0, "%", track_stops(cx, &light_track)); if r.changed() { v["lightness"] = json!(l.round()); } @@ -791,7 +800,7 @@ fn hue_saturation(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let mut o = v[key].clone(); let mut sub = Edit::default(); let mut h = num(&o, "hue", 0.0); - let r = widgets::slider_row(ui, tl!("Hue"), &mut h, -180.0..=180.0, "°", Some(&hue_grad)); + let r = widgets::slider_row(ui, tl!("Hue"), &mut h, -180.0..=180.0, "°", track_stops(cx, &hue_grad)); if r.changed() { o["hue"] = json!(h.round()); } @@ -946,13 +955,14 @@ fn color_balance(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { // --------------------------------------------------------------------------------------------- // Black & White -fn black_white(ui: &mut egui::Ui, v: &mut Value) -> Edit { +fn black_white(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let mut e = Edit::default(); let colors = [[230, 50, 50], [235, 215, 40], [50, 200, 70], [40, 200, 220], [50, 90, 235], [220, 50, 210]]; for (i, key) in HUE_RANGES.iter().enumerate() { let mut x = num(v, key, adjust_params::BW_DEFAULTS[i]).clamp(-200.0, 300.0); let c = colors[i]; - let r = gradient_slider(ui, RANGE_LABELS[i], &mut x, -200.0..=300.0, "%", Color32::BLACK, Color32::from_rgb(c[0], c[1], c[2])); + let r = + gradient_slider(ui, RANGE_LABELS[i], &mut x, -200.0..=300.0, "%", [Color32::BLACK, Color32::from_rgb(c[0], c[1], c[2])], cx.dynamic_color_sliders); if r.changed { v[*key] = json!(x.round()); } @@ -1050,7 +1060,7 @@ fn channel_mixer(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let mut e = Edit::default(); let mut changed = false; for (i, (text, c)) in [(tl!("Red"), [225, 50, 50]), (tl!("Green"), [50, 190, 70]), (tl!("Blue"), [60, 100, 235])].iter().enumerate() { - let r = gradient_slider(ui, text, &mut row[i], -200.0..=200.0, "%", Color32::BLACK, Color32::from_rgb(c[0], c[1], c[2])); + let r = gradient_slider(ui, text, &mut row[i], -200.0..=200.0, "%", [Color32::BLACK, Color32::from_rgb(c[0], c[1], c[2])], cx.dynamic_color_sliders); changed |= r.changed; e.add(r); } @@ -1352,7 +1362,15 @@ pub fn layer_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj }; let gray = app.session.active().is_some_and(|s| is_gray(s.doc.mode)); let hist = needs_histogram(kind).then(|| tone::histograms(app, HistSource::BelowLayer(id), space_of(&values))); - let cx = EditorCx { mem: layer_mem(id), hist, gray, swatches: swatches(app), gradients: gradient_presets(app, kind), dialog: false }; + let cx = EditorCx { + mem: layer_mem(id), + hist, + gray, + swatches: swatches(app), + gradients: gradient_presets(app, kind), + dialog: false, + dynamic_color_sliders: app.session.prefs().interface.dynamic_color_sliders, + }; let e = editor(ui, kind, &mut values, &cx); if e.changed { app.live_adjust = Some((id, values.clone())); @@ -1388,6 +1406,23 @@ mod tests { assert_eq!(lightness_track(200.0), [Color32::BLACK, hue_color(200.0), Color32::WHITE]); } + /// Preferences ▸ Interface ▸ Dynamic Color Sliders: off, the colour sliders get plain tracks. + #[test] + fn dynamic_color_sliders_switch_the_tracks_off() { + let cx = |dynamic: bool| EditorCx { + mem: egui::Id::NULL, + hist: None, + gray: false, + swatches: [[0.0; 3]; 2], + gradients: Vec::new(), + dialog: false, + dynamic_color_sliders: dynamic, + }; + let stops = [Color32::BLACK, Color32::RED]; + assert_eq!(track_stops(&cx(true), &stops), Some(&stops[..])); + assert_eq!(track_stops(&cx(false), &stops), None); + } + #[test] fn hue_range_drag_canonicalizes_bounds_before_clamping() { let moved = move_hue_range_handle([0.0, 350.0, 100.0, 200.0], 2, 354.0); diff --git a/crates/ui-egui/src/alt_click_tests.rs b/crates/ui-egui/src/alt_click_tests.rs index 6db030220b..d4a20b04ae 100644 --- a/crates/ui-egui/src/alt_click_tests.rs +++ b/crates/ui-egui/src/alt_click_tests.rs @@ -40,6 +40,7 @@ fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation, + aspect: app.ui.view.display_aspect(), }; xf.to_screen(x, y) } diff --git a/crates/ui-egui/src/analysis_ui.rs b/crates/ui-egui/src/analysis_ui.rs index 5ac30a4ee0..2359fd9773 100644 --- a/crates/ui-egui/src/analysis_ui.rs +++ b/crates/ui-egui/src/analysis_ui.rs @@ -456,9 +456,16 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo if crate::icons::button(ui, eye, 22.0, false, tl!("Toggle count group visibility")).clicked() { let _ = app.run("count.setGroup", json!({"group": gi, "visible": !g.visible})); } - let mut rgb = g.color.to_rgb(); - if crate::widgets::color_edit_button_rgb(ui, &mut rgb).changed() { - let _ = app.run("count.setGroup", json!({"group": gi, "color": [rgb[0], rgb[1], rgb[2]], "coalesce": "count-color"})); + let rgb = g.color.to_rgb(); + // PhotoCraft's Color Picker (sRGB), as everywhere else (#2144). + if crate::widgets::color_swatch_button(ui, color32(rgb), tl!("Color")).clicked() { + crate::color_picker_ui::open_for_command( + app, + &crate::color_picker_ui::title_for(tl!("Color")), + rgb, + "count.setGroup", + json!({"group": gi}), + ); } if crate::icons::button(ui, "trash", 22.0, false, tl!("Delete count group")).clicked() { let _ = app.run("count.deleteGroup", json!({"group": gi})); @@ -488,7 +495,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo ui.label(RichText::new(tl!("Author:")).color(t.text_dim).size(11.0)); ui.add(egui::TextEdit::singleline(&mut app.ui.analysis.note_author).desired_width(120.0)); ui.label(RichText::new(tl!("Color:")).color(t.text_dim).size(11.0)); - crate::widgets::color_edit_button_rgb(ui, &mut app.ui.analysis.note_color); + let rgb = app.ui.analysis.note_color; + if crate::widgets::color_swatch_button(ui, color32(rgb), tl!("Color")).clicked() { + crate::color_picker_ui::open_for_target(app, "noteColor", &crate::color_picker_ui::title_for(tl!("Color")), rgb); + } if ui.add_enabled_ui(!doc.notes.is_empty(), |ui| crate::widgets::secondary_button(ui, tl!("Clear All"), 0.0)).inner.clicked() { let _ = app.run("notes.delete", json!({"all": true})); app.ui.analysis.note_selected = None; diff --git a/crates/ui-egui/src/artboard_ui.rs b/crates/ui-egui/src/artboard_ui.rs index 3ea482c7c6..1ff452a829 100644 --- a/crates/ui-egui/src/artboard_ui.rs +++ b/crates/ui-egui/src/artboard_ui.rs @@ -82,6 +82,8 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { let t = Tokens::get(ui.ctx()); let key = |k: &str| format!("artboard-{}-{k}", layer.id.0); let mut edit: Option = None; + // The custom background swatch was clicked: its colour, for the Color Picker. + let mut pick: Option<[f32; 3]> = None; ui.label(egui::RichText::new(tl!("Artboard")).font(crate::theme::semibold(12.0)).color(t.text)); ui.add_space(4.0); let mut preset = a.preset.clone(); @@ -120,7 +122,12 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { }); ui.horizontal(|ui| { let mut bg = a.background.name().to_string(); - let opts = [("white".to_string(), tl!("White")), ("black".to_string(), tl!("Black")), ("transparent".to_string(), tl!("Transparent")), ("custom".to_string(), tl!("Other…"))]; + let opts = [ + ("white".to_string(), tl!("White")), + ("black".to_string(), tl!("Black")), + ("transparent".to_string(), tl!("Transparent")), + ("custom".to_string(), tl!("Other…")), + ]; if crate::widgets::dropdown(ui, &key("bg"), &mut bg, &opts, 120.0) { let mut params = json!({"layer": layer.id.0, "background": bg}); if bg == "custom" { @@ -137,9 +144,9 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { } if let ArtboardBackground::Custom(c) = a.background { let [r8, g8, b8, _] = c.to_rgba8(); - let mut rgb = [r8, g8, b8]; - if crate::widgets::color_edit_button_srgb(ui, &mut rgb).changed() { - edit = Some(json!({"layer": layer.id.0, "background": "custom", "color": format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]), "coalesce": key("color")})); + // PhotoCraft's Color Picker, as everywhere else (#2144): OK sets the background. + if crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(r8, g8, b8), tl!("Color")).clicked() { + pick = Some(c.to_rgb()); } } }); @@ -149,6 +156,10 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { app.ui.status = e; app.ui.status_error = true; } + if let Some(rgb) = pick { + let title = crate::color_picker_ui::title_for(tl!("Color")); + crate::color_picker_ui::open_for_command(app, &title, rgb, "layer.artboard.set", json!({"layer": layer.id.0, "background": "custom"})); + } } #[cfg(test)] @@ -169,7 +180,14 @@ mod tests { app.run("layer.new.artboard", json!({"rect": [0, 0, 40, 50]})).unwrap(); app.run("layer.new.artboard", json!({"rect": [60, 10, 40, 30]})).unwrap(); let doc = app.session.active().unwrap().doc.clone(); - let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, egui::vec2(100.0, 50.0)), zoom: 1.0, center: [50.0, 25.0], flip: false, rotation: 0.0 }; + let xf = ViewXform { + rect: Rect::from_min_size(Pos2::ZERO, egui::vec2(100.0, 50.0)), + zoom: 1.0, + center: [50.0, 25.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; let rects = pasteboard_rects(&xf, &doc); let area: f32 = rects.iter().map(|r| r.area()).sum(); // 100×50 canvas − 40×50 − 40×30 boards. diff --git a/crates/ui-egui/src/blend_preview/tests.rs b/crates/ui-egui/src/blend_preview/tests.rs index 0ea840d723..4eb407aad0 100644 --- a/crates/ui-egui/src/blend_preview/tests.rs +++ b/crates/ui-egui/src/blend_preview/tests.rs @@ -104,3 +104,23 @@ fn hovering_the_layers_panel_blend_list_previews_and_a_click_commits() { assert_eq!(steps(h.state()), undo + 1, "choosing records one step"); assert_eq!(previewed(&mut h), None); } + +#[test] +fn moving_between_modes_keeps_a_preview_across_the_gaps_between_them() { + // #2553: the pointer between two rows used to hover neither, flashing the document back. + let (s, _) = session(); + let mut h = harness(s); + let dropdown = h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).max_by(|a, b| a.top().total_cmp(&b.top())).unwrap(); + click(&mut h, dropdown.center()); + let (dissolve, darken) = (h.get_by_label("Dissolve").rect(), h.get_by_label("Darken").rect()); + assert!(darken.top() > dissolve.bottom(), "the rows have a gap between them"); + let multiply = h.get_by_label("Multiply").rect().center(); + let mut y = dissolve.center().y; + while y <= multiply.y { + h.hover_at(Pos2::new(multiply.x, y)); + h.run_steps(1); + assert!(previewed(&mut h).is_some(), "a mode previews at y = {y}"); + y += 0.5; + } + assert_eq!(previewed(&mut h), Some(BlendMode::Multiply)); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8c1c0efa85..7de7b1de95 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -128,7 +128,7 @@ pub struct Drag { pub reposition: bool, /// A marquee or lasso drag that started inside the selection moves it instead of drawing: /// `Some(false)` moves the outline, `Some(true)` moves the floating piece (`select.float`), as - /// every Move-tool drag with a selection does (`move_ui::moves_selected_pixels`). + /// every Move-tool drag with a selection does (`move_ui::moves_selected_pixels_with`). pub sel_move: Option, pub lasso: Option, } @@ -198,14 +198,18 @@ pub fn marquee_corners(o: &crate::state::ToolOptions, d: &Drag, end: [f64; 2]) - } /// The pixel rectangle a Rectangular/Elliptical Marquee drag selects so far: shown live (ants -/// and readout) exactly as the release commits it, on whole pixel edges as in Photoshop. -pub(crate) fn marquee_preview_px(o: &crate::state::ToolOptions, d: &Drag) -> Option<[f64; 4]> { - if !matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee) { +/// and readout) exactly as the release commits it, on whole pixel edges as in Photoshop. `None` +/// while the gesture is still a click (it never left the press point by more than a few screen +/// pixels at `zoom`, points per document pixel) or the rectangle has no area: a release then +/// deselects. A one-pixel row or column is a selection (#2391). +pub(crate) fn marquee_preview_px(o: &crate::state::ToolOptions, d: &Drag, zoom: f32) -> Option<[f64; 4]> { + if !matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee) || crate::shape_dialog::is_click(d.start, &d.points, zoom) { return None; } let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); let (a, b) = marquee_corners(o, d, last); - Some(marquee_px(a, b)) + let r = marquee_px(a, b); + (r[2] > r[0] && r[3] > r[1]).then_some(r) } /// The pixel rectangle `[x0, y0, x1, y1]` a marquee between two corners selects. @@ -213,11 +217,95 @@ pub fn marquee_px(a: [f64; 2], b: [f64; 2]) -> [f64; 4] { [a[0].min(b[0]).floor(), a[1].min(b[1]).floor(), a[0].max(b[0]).ceil(), a[1].max(b[1]).ceil()] } +/// The one-pixel line `[x0, y0, x1, y1]` a Single Row (Single Column) Marquee at `p` selects: the +/// pixel row (column) under `p`, kept on the canvas, across the whole `size`. `None` for other +/// tools, an empty canvas or a point that is not a number. +pub(crate) fn single_line_px(tool: Tool, size: [u32; 2], p: [f64; 2]) -> Option<[f64; 4]> { + let [w, h] = size.map(f64::from); + if w < 1.0 || h < 1.0 || !p[0].is_finite() || !p[1].is_finite() { + return None; + } + match tool { + Tool::SingleRowMarquee => { + let y = p[1].floor().clamp(0.0, h - 1.0); + Some([0.0, y, w, y + 1.0]) + } + Tool::SingleColumnMarquee => { + let x = p[0].floor().clamp(0.0, w - 1.0); + Some([x, 0.0, x + 1.0, h]) + } + _ => None, + } +} + +/// The line a Single Row / Single Column Marquee gesture `d` selects so far: it follows the +/// pointer until the release. +fn single_line_drag_px(app: &PhotocraftApp, d: &Drag) -> Option<[f64; 4]> { + let st = app.session.active()?; + let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); + single_line_px(d.tool, [st.doc.size.width, st.doc.size.height], last) +} + +/// The marching ants of a marquee drag: its rectangle, or the ellipse inscribed in it, built in +/// document space and mapped point by point through the view, so the hinting lands where the +/// release commits the selection. +/// +/// A rotated view projects the rectangle onto a rotated one, and an axis-aligned screen box +/// through two of its corners is a different shape in a different place: the commit was always +/// right, only the hinting was drawn elsewhere (#2512). Every point is snapped to the half-pixel +/// grid like every other ants outline (`marching_ants_segments`), so the lines stay crisp at any +/// zoom (#172). +pub(crate) fn marquee_ants(xf: &ViewXform, tool: Tool, a: [f64; 2], b: [f64; 2]) -> Vec { + let (x0, x1) = (a[0].min(b[0]), a[0].max(b[0])); + let (y0, y1) = (a[1].min(b[1]), a[1].max(b[1])); + let at = |x: f64, y: f64| { + let p = xf.to_screen(x as f32, y as f32); + pos2(p.x.round() + 0.5, p.y.round() + 0.5) + }; + if tool != Tool::EllipseMarquee { + return vec![at(x0, y0), at(x1, y0), at(x1, y1), at(x0, y1)]; + } + // As many vertices as the same ellipse would get drawn on screen (`ellipse_points`). + let scr = Rect::from_points(&[xf.to_screen(x0 as f32, y0 as f32), xf.to_screen(x1 as f32, y1 as f32)]); + let n = (((scr.width() + scr.height()) * 0.25) as usize).clamp(16, 256); + let (cx, cy, rx, ry) = ((x0 + x1) * 0.5, (y0 + y1) * 0.5, (x1 - x0) * 0.5, (y1 - y0) * 0.5); + (0..n) + .map(|i| { + let t = i as f64 / n as f64 * std::f64::consts::TAU; + at(cx + rx * t.cos(), cy + ry * t.sin()) + }) + .collect() +} + +/// The ants of the marquee being drawn: `marquee` when the drag has one (the options-bar style +/// and the modifiers applied), the raw press and pointer corners otherwise. This is the whole of +/// what the hinting draws for the selection tools, so the test in `marquee_tests` checks it +/// against the selection the release commits (#2512). +pub(crate) fn marquee_drag_ants(d: &Drag, xf: &ViewXform, marquee: Option<[f64; 4]>) -> Vec { + let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); + let (a, b) = marquee.map_or((d.start, last), |[x0, y0, x1, y1]| ([x0, y0], [x1, y1])); + marquee_ants(xf, d.tool, a, b) +} + /// The size readout shown beside the cursor while dragging a marquee: the width and height values. pub fn marquee_readout(r: [f64; 4]) -> [String; 2] { [format!("{} px", r[2] - r[0]), format!("{} px", r[3] - r[1])] } +/// [`marquee_readout`] in the Units & Rulers unit, as Photoshop shows it (cm when the rulers are in +/// centimeters), from the active document's resolution; pixels without a document. +pub fn size_readout(app: &PhotocraftApp, r: [f64; 4]) -> [String; 2] { + let Some(d) = app.session.active() else { return marquee_readout(r) }; + let (dpi, size) = (f64::from(d.doc.resolution_dpi), d.doc.size); + readout_in(&app.session.prefs().units_and_rulers, dpi, [size.width as f64, size.height as f64], r) +} + +/// `r`'s width and height formatted in `ur`'s ruler unit at `dpi` (percent of `extent`). +pub fn readout_in(ur: &photocraft_engine::prefs::UnitsAndRulers, dpi: f64, extent: [f64; 2], r: [f64; 4]) -> [String; 2] { + let s = ur.rulers.suffix(); + [format!("{} {s}", ur.format(r[2] - r[0], dpi, extent[0])), format!("{} {s}", ur.format(r[3] - r[1], dpi, extent[1]))] +} + /// Draw the marquee size readout below-right of the cursor (kept on screen), like Photoshop's: /// two rows, `W:` / `H:` labels on the left and the values right-aligned. fn draw_marquee_readout(ctx: &egui::Context, cursor: Pos2, values: [String; 2]) { @@ -338,6 +426,10 @@ fn stroke_params(app: &PhotocraftApp, tool: Tool, erase: bool, points: &[Vec Option<&'static str> { } } -/// The command a live-stroking tool commits: the Pencil's `paint.pencil`, else `paint.stroke`. -pub(crate) fn stroke_command(tool: Tool) -> &'static str { - if tool == Tool::Pencil { "paint.pencil" } else { "paint.stroke" } +/// The command a live-stroking tool commits: the Pencil's (and the Pencil- or Block-mode +/// Eraser's) `paint.pencil`, else `paint.stroke`. +pub(crate) fn stroke_command(app: &PhotocraftApp, tool: Tool) -> &'static str { + if tool == Tool::Pencil || crate::eraser_ui::pencil_mode(app, tool) || crate::eraser_ui::block_mode(app, tool) { "paint.pencil" } else { "paint.stroke" } } /// Windows' crosshair cursor inverts the pixels under it, so over mid-grey (the pasteboard, many @@ -384,6 +477,16 @@ pub(crate) fn pencil_cursor_rect(xf: &ViewXform, doc: [f64; 2], size: f32, ppp: Rect::from_min_max(pos2(snap(r.min.x), snap(r.min.y)), pos2(snap(r.max.x), snap(r.max.y))) } +/// Whether an aliased (Pencil) dab of `size` pixels is round rather than its whole +/// [`pencil_cursor_rect`] square. The dab fills the pixels whose centres lie within its radius of +/// the grid centre (`paint::grid_center`), so up to 3 px the corner pixels are in and the dab is +/// a square; from 4 px on they fall outside and it is a disc (#2662). +pub(crate) fn pencil_tip_is_round(size: f32) -> bool { + let n = if size.is_finite() { size.round().clamp(1.0, 100_000.0) } else { 1.0 }; + let h = n / 2.0; + (h - 0.5) * std::f32::consts::SQRT_2 > h +} + /// Whether a brush-tip circle draws its centre mark. /// Clone Stamp and Healing Brush stay an empty circle until Option is held, the source-point /// cursor. Other brushes follow the cursor preference, the large-tip mark, or the Background Eraser. @@ -462,7 +565,7 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option { EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?) } else { let p = stroke_params(app, d.tool, d.erase, &app.stylus.stroke_points(&d.points)); - EngineStroke::Brush(Box::new(photocraft_engine::brush_cmds::LiveStroke::begin_with(&app.session, stroke_command(d.tool), &p).ok()?)) + EngineStroke::Brush(Box::new(photocraft_engine::brush_cmds::LiveStroke::begin_with(&app.session, stroke_command(app, d.tool), &p).ok()?)) }; let n = STROKES.fetch_add(1, std::sync::atomic::Ordering::Relaxed) & 0xff_ffff; let damage = vec![stroke.bounds()]; @@ -501,6 +604,19 @@ fn feed_live_stroke(app: &mut PhotocraftApp) { } } +/// A pen tap (a drag still at its one press point when released) paints its dab at the highest +/// pressure the pen reached during the contact, not just the first sample's (#1798). A mouse is +/// unchanged at full pressure. +fn tap_at_peak_pressure(app: &mut PhotocraftApp) { + if app.stylus.sample().is_none() { + return; + } + let peak = f64::from(app.stylus.tap_pressure()); + if let Some(p) = app.drag.as_mut().filter(|d| d.points.len() == 1).and_then(|d| d.points.first_mut()) { + p[2] = p[2].max(peak); + } +} + /// While a stroke is held still, its point repeats this often (in ms) so time-based brush /// features (airbrush build-up, smoothing catch-up) keep going, as when the pen moves. const HOLD_REPEAT_MS: f64 = 16.0; @@ -587,6 +703,9 @@ pub enum ToolEvent { Up { x: f64, y: f64 }, } +/// View › Show › Layer Edges: the colour of the active layer's outline. +pub(crate) const LAYER_EDGES: Color32 = Color32::from_rgb(0x2d, 0x8c, 0xeb); + /// Document ↔ screen mapping for a canvas rect and a view. #[derive(Clone, Copy, Debug)] pub struct ViewXform { @@ -598,6 +717,10 @@ pub struct ViewXform { /// Camera rotation in degrees around [`Self::center`]. Clockwise (Y-down) is positive; applied /// before flip. pub rotation: f32, + /// View › Pixel Aspect Ratio Correction: a document pixel is drawn this many times wider than + /// tall (1 = square). Applied in document space, before rotation, so a rotated view still + /// shows the stretched image turned as a whole. + pub aspect: f32, } impl ViewXform { @@ -610,6 +733,7 @@ impl ViewXform { center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation, + aspect: app.ui.view.display_aspect(), }) } @@ -622,16 +746,27 @@ impl ViewXform { } } - /// Unflip then unrotate a screen-space vector (no zoom). Hand pan and zoom-about use this. + /// Document pixel width over height on screen: [`Self::aspect`], or 1 when it is unusable. + fn sx(self) -> f32 { + if self.aspect.is_finite() && self.aspect > 0.0 { self.aspect } else { 1.0 } + } + + /// Screen points per document pixel along the document's x axis. + pub fn zoom_x(&self) -> f32 { + self.zoom * self.sx() + } + + /// Unflip, unrotate and unstretch a screen-space vector (no zoom). Hand pan and zoom-about + /// use this. pub fn unmap_vec(self, d: Vec2) -> Vec2 { let d = if self.flip { vec2(-d.x, d.y) } else { d }; let (c, s) = self.sincos(); - vec2(d.x * c + d.y * s, -d.x * s + d.y * c) + vec2((d.x * c + d.y * s) / self.sx(), -d.x * s + d.y * c) } pub fn to_screen(&self, x: f32, y: f32) -> Pos2 { let (c, s) = self.sincos(); - let dx = x - self.center[0]; + let dx = (x - self.center[0]) * self.sx(); let dy = y - self.center[1]; let rx = dx * c - dy * s; let ry = dx * s + dy * c; @@ -670,10 +805,12 @@ pub fn doc_point_at(app: &PhotocraftApp, pos: Pos2) -> Option<[f64; 2]> { } /// Fit the document into `area` (egui points) for a display with `ppp` physical pixels per -/// point; `View::zoom` is stored in physical pixels per document pixel. -pub fn fit_view(view: &mut View, doc: &Document, area: Vec2, ppp: f32) { +/// point; `View::zoom` is stored in physical pixels per document pixel. `aspect` is the +/// displayed pixel aspect ratio ([`ViewXform::aspect`]): the image is that much wider on screen. +pub fn fit_view(view: &mut View, doc: &Document, area: Vec2, ppp: f32, aspect: f32) { let (w, h) = (doc.size.width as f32, doc.size.height as f32); - let points = ((area.x - 40.0) / w).min((area.y - 40.0) / h).min(1.0); + let shown_w = w * if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 }; + let points = ((area.x - 40.0) / shown_w).min((area.y - 40.0) / h).min(1.0); let zoom = crate::zoom_levels::clamp(points * ppp, [doc.size.width, doc.size.height]); view.zoom = zoom; view.center = [w / 2.0, h / 2.0]; @@ -683,9 +820,10 @@ pub fn fit_view(view: &mut View, doc: &Document, area: Vec2, ppp: f32) { /// Centre the document and zoom until it fills the canvas. One axis can extend beyond the /// viewport, matching the Hand tool's Fill Screen action. -pub fn fill_view(view: &mut View, doc: &Document, area: Vec2, ppp: f32) { +pub fn fill_view(view: &mut View, doc: &Document, area: Vec2, ppp: f32, aspect: f32) { let (w, h) = (doc.size.width as f32, doc.size.height as f32); - let points = (area.x / w).max(area.y / h); + let shown_w = w * if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 }; + let points = (area.x / shown_w).max(area.y / h); let zoom = crate::zoom_levels::clamp(points * ppp, [doc.size.width, doc.size.height]); view.zoom = zoom; view.center = [w / 2.0, h / 2.0]; @@ -774,6 +912,9 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar if let Some(shown) = crate::type_transform::display_doc(app, idx) { return shown; } + if let Some(shown) = crate::type_font_preview::display_doc(app, idx) { + return shown; + } // Puppet / Perspective Warp previews hide the layer they draw on a mesh. if let Some(shown) = crate::distort_ui::display_doc(app, idx) { return shown; @@ -1021,6 +1162,13 @@ pub(crate) fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), no { return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }); } + // Between hovered fonts (and the document): the text layer's old/new ink bounds. + if seen.0 == now.0 + && let Some(st) = app.session.documents().get(idx) + && let Some(r) = crate::type_font_preview::damage(app, st.doc.id, now.0, seen.1 ^ display_key, now.1) + { + return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }); + } let l = live_stroke(app, idx).filter(|l| seen.0 == now.0 && l.display_key() == now.1)?; let r = l.since(seen.1 ^ display_key)?; Some(if r.is_empty() { r } else { r.inflate(effect_reach(&l.stroke.doc().layers)) }) @@ -1167,7 +1315,7 @@ fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx: /// (`filter_preview_worker`); otherwise (web, tests, `PHOTOCRAFT_INLINE_JOBS`) it keeps the /// deterministic inline path (#1676), which also lets headless/CPU tests inspect previews /// without a GPU texture. -fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &egui::Context) -> Option<(u32, u64, [u32; 2])> { +pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &egui::Context) -> Option<(u32, u64, [u32; 2])> { if let Some(held) = committed_filter_preview(app, idx) { return held; } @@ -1591,7 +1739,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { } crate::icons::paint(ui, xr, "x", 11.0, if xresp.hovered() { t.text } else { t.text_faint }); let resp = if cut { resp.on_hover_text(name) } else { resp }; - if xresp.clicked() { + if tab_close_clicked(&resp, &xresp) { close = Some(i); } else if resp.clicked() { activate = Some(i); @@ -1679,6 +1827,14 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { TabStrip { rect: frame.response.rect, tabs: doc_tabs } } +/// Whether a document tab was asked to close: its × was clicked (#619), or the tab was clicked +/// with the middle button (#2570), as in a browser. A middle click over the × counts too, so the +/// whole tab behaves the same way. Both strips share this rule; the caller routes it through the +/// guarded `file.close`, so a document with unsaved changes still gets the save prompt. +fn tab_close_clicked(resp: &egui::Response, xresp: &egui::Response) -> bool { + xresp.clicked() || xresp.middle_clicked() || resp.middle_clicked() +} + /// All tab actions go through the same guarded File commands as the menu bar, including the /// unsaved-changes prompt. The clicked tab is explicit even when another document is active. fn tab_context_items(index: usize, count: usize, has_path: bool) -> [(&'static str, &'static str, serde_json::Value, bool); 4] { @@ -1686,7 +1842,7 @@ fn tab_context_items(index: usize, count: usize, has_path: bool) -> [(&'static s ("Close", "file.close", json!({"document": index}), true), ("Close Others", "file.closeOthers", json!({"document": index}), count > 1), ("Close All", "file.closeAll", json!({}), true), - ("Reveal in Finder", "file.revealInFinder", json!({"document": index}), has_path), + (crate::menus::reveal_label(), "file.revealInFinder", json!({"document": index}), has_path), ] } @@ -1835,7 +1991,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { ui.painter().galley_with_override_text_color(at, g, if sel { t.text } else { t.text_faint }); let cut = natural.get(i).copied().unwrap_or(0.0) > r.width() + 0.5; let resp = if cut { resp.on_hover_text(title.as_str()) } else { resp }; - if xresp.clicked() { + if tab_close_clicked(&resp, &xresp) { close = Some(i); } else if resp.clicked() { activate = Some(i); @@ -2083,7 +2239,9 @@ const HOME_RECENT: usize = 6; /// Height of one Home-screen recent-file row. const HOME_RECENT_ROW: f32 = 30.0; -/// The Home screen's "Recent" list: file name, its folder on the right, click to open. +/// The Home screen's "Recent" list: file name, its folder on the right, click to open. The +/// hovered row shows a × that takes it off the list, and right-click offers Remove from Recent +/// and Clear Recent Files (#2691). fn home_recent(app: &mut PhotocraftApp, ui: &mut egui::Ui, recent: &[String]) { let t = crate::theme::Tokens::get(ui.ctx()); let width = 380.0; @@ -2094,27 +2252,60 @@ fn home_recent(app: &mut PhotocraftApp, ui: &mut egui::Ui, recent: &[String]) { }); ui.add_space(4.0); let mut open = None; + let mut remove = None; + let mut clear = false; for path in recent { let name = crate::file_open::display_name(path); let folder = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(); let (row, resp) = ui.allocate_exact_size(egui::vec2(width, HOME_RECENT_ROW), Sense::click()); - if resp.hovered() { + // The × sits on top of the row, so the row counts as hovered while the pointer is on it. + let hovered = ui.rect_contains_pointer(row); + if hovered { ui.painter().rect_filled(row, t.radius_sm, t.hover); } + let close = Rect::from_center_size(egui::pos2(row.right() - 16.0, row.center().y), egui::vec2(22.0, 22.0)); + let right = if hovered { close.left() - 4.0 } else { row.right() - 10.0 }; let icon = Rect::from_center_size(egui::pos2(row.left() + 16.0, row.center().y), egui::vec2(16.0, 16.0)); crate::icons::paint(ui, icon, "file", 14.0, t.text_dim); let x = row.left() + 32.0; let name_g = crate::tab_strip::elided(ui, &name, egui::FontId::proportional(12.5), t.text, 170.0); let name_w = name_g.size().x; ui.painter().galley(egui::pos2(x, row.center().y - name_g.size().y / 2.0), name_g, t.text); - let folder_max = (row.right() - 10.0 - (x + name_w + 14.0)).max(0.0); + let folder_max = (right - (x + name_w + 14.0)).max(0.0); if folder_max > 20.0 && !folder.is_empty() { let fg = crate::tab_strip::elided(ui, &folder, egui::FontId::proportional(11.5), t.text_faint, folder_max); - ui.painter().galley(egui::pos2(row.right() - 10.0 - fg.size().x, row.center().y - fg.size().y / 2.0), fg, t.text_faint); + ui.painter().galley(egui::pos2(right - fg.size().x, row.center().y - fg.size().y / 2.0), fg, t.text_faint); } - if resp.on_hover_text(path).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); + let resp = resp.on_hover_text(path).on_hover_cursor(egui::CursorIcon::PointingHand); + if hovered { + let x = ui.interact(close, resp.id.with("remove"), Sense::click()); + let label = crate::i18n::fmt(tl!("Remove {name} from Recent"), &[("name", &name)]); + x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); + let color = crate::icons::button_chrome(ui, close, false, x.hovered()); + crate::icons::paint(ui, close, "x", 12.0, color); + if x.on_hover_text(tl!("Remove from Recent")).clicked() { + remove = Some(path.clone()); + } + } + if resp.clicked() && remove.is_none() { open = Some(path.clone()); } + resp.context_menu(|ui| { + if ui.button(tl!("Remove from Recent")).clicked() { + remove = Some(path.clone()); + ui.close(); + } + if ui.button(tl!("Clear Recent Files")).clicked() { + clear = true; + ui.close(); + } + }); + } + if clear { + app.clear_recent(); + } else if let Some(path) = remove { + app.remove_recent(&path); } if let Some(path) = open && let Err(e) = app.open_path(&path) @@ -2168,6 +2359,11 @@ fn hdr_preview(app: &PhotocraftApp, doc: &photocraft_doc::Document) -> Option<[f /// Draw one canvas view and handle its input. `primary` = main window (tools active). pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Rect, mut view: View, primary: bool) -> View { let ctx = ui.ctx().clone(); + // The camera as the app holds it before this frame's input. The canvas works on its own copy + // (`view`) and writes it back at the end; a tool that moves the camera itself meanwhile (the + // Crop tool's default mode pans and turns the view with the image, `crop_mode`) must not be + // undone by that write-back. + let camera = if primary { app.ui.views.get(idx).map(|v| (v.center, v.rotation)) } else { None }; // `View::zoom` is device pixels per document pixel; this canvas's geometry is in its own // viewport's egui points. A document window can sit on another display with a different // scale, so only the primary canvas records `app.ppp` for the frame's shared helpers @@ -2195,20 +2391,22 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } view.doc_size = size; } + // View › Pixel Aspect Ratio Correction: document pixels are shown this much wider. + let aspect = app.ui.view.display_aspect(); if view.fit_pending && rect.width() > 50.0 { - fit_view(&mut view, &doc, rect.size(), ppp); + fit_view(&mut view, &doc, rect.size(), ppp, aspect); } if view.fill_pending && rect.width() > 50.0 { - fill_view(&mut view, &doc, rect.size(), ppp); + fill_view(&mut view, &doc, rect.size(), ppp, aspect); } // Preferences › Tools › Overscroll off: clamp before anything is drawn (scrollbars.rs). - if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view(&mut view, rect.size(), ppp) { + if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view(&mut view, rect.size() / vec2(aspect, 1.0), ppp) { ctx.request_repaint(); } let flip = app.ui.view.flip_horizontal; // Canvas geometry (ViewXform, ViewParams, painter rects) is in egui points. let point_zoom = view.zoom / ppp; - let xf = ViewXform { rect, zoom: point_zoom, center: view.center, flip, rotation: view.rotation }; + let xf = ViewXform { rect, zoom: point_zoom, center: view.center, flip, rotation: view.rotation, aspect }; let pixel_grid = app.ui.view.shows(app.ui.view.show.pixel_grid); let response = ui.allocate_rect(rect, Sense::click_and_drag()); let painter = ui.painter_at(rect); @@ -2230,9 +2428,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // Live adjustment previews on big documents use a downsampled proxy (see proxy.rs), unless // Preferences › Performance › Low Resolution Previews is off. let mut on_gpu = false; - // A flipped view draws through the CPU path (the GPU canvas shader has no mirroring). - // Rotation stays on the GPU: it is a view uniform, not a CPU blit. - let gpu_ok = crate::gpu_canvas::gpu_view_ok(flip, view.rotation); + // A flipped or pixel-aspect-corrected view draws through the CPU path (the GPU canvas shader + // has no mirroring or non-square pixels). Rotation stays on the GPU: it is a view uniform. + let gpu_ok = crate::gpu_canvas::gpu_view_ok(flip, view.rotation, aspect); if app.gpu.is_some() && gpu_ok && let Some((k, key, preview_size)) = @@ -2367,14 +2565,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::pixel_grid::paint(app, &painter, &xf, idx, output, visible_doc_rect(&xf)); } - // View › Show › Layer Edges: the active layer's content bounds. - if app.ui.view.shows(app.ui.view.show.layer_edges) - && let Some(st) = app.session.documents().get(idx) - && let Some(b) = st.active_layer.and_then(|id| st.doc.layer(id)).and_then(|l| l.surface()).map(photocraft_compose::bounds::content_bounds) - && !b.is_empty() - { - painter.rect_stroke(xf.doc_rect(b), 0, Stroke::new(1.0, Color32::from_rgb(0x2d, 0x8c, 0xeb)), egui::StrokeKind::Outside); - } + // View › Show › Layer Edges: drawn at this depth, but only once this frame's tool events are + // in (`layer_edges_rect`), so a press that Auto-Selects a layer outlines that layer. + let layer_edges = painter.add(egui::Shape::Noop); // Selection outline: true boundary, animated marching ants (cached per revision). if let Some(sel) = doc.selection.as_ref().filter(|_| app.ui.view.shows(app.ui.view.show.selection_edges) && !polygon_replaces_selection(app)) { @@ -2479,7 +2672,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // A Magnetic Lasso border left behind by a tool or document switch is dropped. crate::magnetic_lasso_ui::frame(app); // Held keys (hold_keys.rs): Space repositions a crop frame, marquee, lasso or shape being - // drawn; otherwise Space is the Hand and ⌘Space / ⌘⌥Space the Zoom tool while held. + // drawn; otherwise Space is the Hand and ⌘Space / ⌥Space the Zoom tool while held. let reposition = crate::hold_keys::reposition_held(app, &ctx); crate::crop_ui::set_space(app, reposition); // ⌘ (Ctrl) held: a temporary Straighten with the Crop tool. @@ -2551,7 +2744,13 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::color_range_ui::canvas_eyedropper(app, &ctx, p, press); } } - if tool == Tool::Hand && response.dragged() { + // The selected Hand yields to an open Free Transform box in this (primary) window: the box + // owns the pointer, so the tool behind it cannot steal the resize drag (#2302). The held Hand + // (Space) and a middle-button drag still pan, and a secondary window's Hand never stops, as the + // box is only drawn and driven on the primary canvas. + let transform_owns_pointer = primary && app.ui.transform.is_some() && temporary.is_none() && !middle; + let hand_pan = tool == Tool::Hand && !transform_owns_pointer; + if hand_pan && response.dragged() { let d = xf.unmap_vec(response.drag_delta()) / (view.zoom / ppp); view.center[0] -= d.x; view.center[1] -= d.y; @@ -2570,10 +2769,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if tool == Tool::Crop && response.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down()) && crate::crop_ui::press(app) { ctx.request_repaint(); } - // Capture temporary Type transforms at the actual press, before egui's drag threshold. - // Releasing Command before the first recognised move must not turn it into text selection. - let type_press = egui::Id::new("type-pointer-press-modifiers"); - if tool.is_type() + // Capture intent at the press, before egui's drag threshold: Type transforms keep + // Command, and marquee add/subtract must not become a live Shift/Alt constraint. + let capture_press = tool.is_type() || matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::SingleRowMarquee | Tool::SingleColumnMarquee); + let tool_press = egui::Id::new(("tool-pointer-press-modifiers", idx)); + if capture_press && let Some((p, press_mods)) = ui.input(|i| { i.events.iter().find_map(|e| match e { egui::Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers } if rect.contains(*pos) => { @@ -2584,14 +2784,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: }) { let press_mods = crate::workspace_ui::sticky_mods(app, press_mods); - ctx.data_mut(|d| d.insert_temp(type_press, press_mods)); - if press_mods.command && app.ui.text_edit.is_some() { + ctx.data_mut(|d| d.insert_temp(tool_press, press_mods)); + if tool.is_type() && press_mods.command && app.ui.text_edit.is_some() { let d = xf.to_doc(p); tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, press_mods); } } // Right-click while transforming: switch the box's mode (Free Transform, Scale, Rotate, - // Skew, Distort, Perspective). + // Skew, Distort, Perspective, Warp). let transforming = app.ui.transform.as_ref().is_some_and(|t| t.warp.is_none()); if response.secondary_clicked() && transforming @@ -2605,7 +2805,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Tool::PolygonLasso => polygon_retract(app), _ => false, }; - if tool == Tool::Lasso { + // The Lasso reads its own presses and drops the moves of a drag it didn't start, so an + // open Free Transform box takes the usual drag path instead (#2153). + if tool == Tool::Lasso && !transform_owns_pointer { crate::lasso_ui::canvas_input(app, &ctx, &xf, &response); (buttons.started, buttons.dragged, buttons.stopped, buttons.clicked) = (false, false, false, false); } @@ -2618,7 +2820,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: { let d = xf.to_doc(p); app.ui.canvas_tool_menu = None; - crate::layer_pick_ui::open(app, [p.x, p.y], d[0], d[1]); + // No layers there (the pasteboard, with the Move tool): its colour menu instead. + if !crate::layer_pick_ui::open(app, [p.x, p.y], d[0], d[1]) && !mods.command && crate::canvas_tool_menu::pasteboard_at(app, tool, d) { + crate::canvas_tool_menu::open_pasteboard(app, tool, [p.x, p.y]); + } } if response.secondary_clicked() && !lasso_retracted @@ -2626,10 +2831,16 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: && !crate::layer_pick_ui::is_gesture(tool, mods) && let Some(p) = response.interact_pointer_pos() { - crate::canvas_tool_menu::open(app, tool, [p.x, p.y]); + // Outside the image: the pasteboard colour menu (#2462), else the tool's own. + if crate::canvas_tool_menu::pasteboard_at(app, tool, xf.to_doc(p)) { + crate::canvas_tool_menu::open_pasteboard(app, tool, [p.x, p.y]); + } else { + crate::canvas_tool_menu::open(app, tool, [p.x, p.y]); + } } - // The (temporary) Hand pans above; its gestures never reach the tool underneath. - if tool == Tool::Hand { + // The (temporary) Hand pans above; its gestures never reach the tool underneath. While a + // Free Transform box is open the Hand does not pan, so the box gets these events (#2302). + if hand_pan { (buttons.started, buttons.dragged, buttons.stopped) = (false, false, false); } // Rotate View: drag around the view centre. Compass clicks are not a rotate drag. @@ -2675,7 +2886,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: begin_transform_controls_at(app, &ctx, &xf, p); } let d = xf.to_doc(p); - let press_mods = if tool.is_type() { ctx.data(|d| d.get_temp(type_press)).unwrap_or(mods) } else { mods }; + let press_mods = if capture_press { ctx.data(|d| d.get_temp(tool_press)).unwrap_or(mods) } else { mods }; tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, press_mods); } // A stroke started on the press takes its moves from the press on, not only once egui @@ -2747,6 +2958,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } let p = response.interact_pointer_pos().map(|p| xf.to_doc(p)).or_else(|| app.drag.as_ref().and_then(|d| d.points.last().map(|q| [q[0], q[1]]))); if let Some(d) = p { + tap_at_peak_pressure(app); tool_event(app, ToolEvent::Up { x: d[0], y: d[1] }, mods); } } @@ -2758,6 +2970,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if let Some(d) = p && app.drag.is_some() { + tap_at_peak_pressure(app); tool_event(app, ToolEvent::Up { x: d[0], y: d[1] }, mods); } } @@ -2791,6 +3004,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // If this tool was not handled on pointer-down, fall back to a // click-sized gesture while preserving pen pressure (mouse stays at 1). tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, click_mods); + tap_at_peak_pressure(app); tool_event(app, ToolEvent::Up { x: d[0], y: d[1] }, click_mods); } } @@ -2831,6 +3045,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::zoom_tool::draw(&ctx, &painter); let resizing = crate::brush_resize::draw(app, &painter, &xf); draw_transform_controls(app, &painter, &xf); + crate::move_hover::draw(app, &painter, &xf, idx, response.hover_pos()); crate::layer_pick_ui::show(app, &ctx); crate::canvas_tool_menu::show(app, &ctx); crate::snap_ui::draw(app, &painter, &xf); @@ -2849,13 +3064,18 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::gradient_ui::draw_overlay(app, &painter, &xf); crate::slice_ui::draw_overlay(app, &painter, &xf); // Tool cursors (Photoshop-style). - let guide_hover = response.hover_pos().filter(|_| tool == Tool::Move).and_then(|p| { + let guide_hover = response.hover_pos().and_then(|p| { let d = xf.to_doc(p); - crate::rulers::guide_at(app, d[0], d[1]) + if tool == Tool::Move { + return crate::rulers::guide_at(app, d[0], d[1]); + } + command_guide_at(app, tool, d, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers))) }); if let Some((vertical, _)) = guide_hover { ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); - } else if let Some(c) = response.hover_pos().and_then(|p| crate::transform_tool::cursor(app, xf.to_doc(p), ui.input(|i| i.modifiers.alt))) { + } else if let Some(c) = + response.hover_pos().and_then(|p| crate::transform_tool::cursor(app, xf.to_doc(p), ui.input(|i| i.modifiers.alt || i.modifiers.command))) + { ui.ctx().set_cursor_icon(c); } else if let Some(c) = response.hover_pos().filter(|_| tool.is_type()).and_then(|p| { let mods = crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)); @@ -2926,18 +3146,34 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let painting = app.drag.is_some(); let brush = &app.session.tools.brush; let full = (brush.size / 2.0 * xf.zoom).max(1.0); - let r = if cur.painting == PaintingCursor::NormalTip { (full * (0.5 + 0.5 * brush.hardness.clamp(0.0, 1.0))).max(1.0) } else { full }; + let r = if cur.painting == PaintingCursor::NormalTip { + photocraft_paint::half_coverage_radius(full, brush.hardness).max(1.0) + } else { + full + }; match cur.painting { PaintingCursor::Standard => egui::CursorIcon::Default, PaintingCursor::Precise => crate::tool_cursor::crosshair(&painter, p, 6.0, 0.0), _ if painting && cur.show_only_crosshair_while_painting => crate::tool_cursor::crosshair(&painter, p, 5.0, 0.0), - // The Pencil: the square of whole pixels its dab fills, on the pixel grid. - _ if tool == Tool::Pencil => { + // The Pencil (and the Eraser in Pencil mode): the outline of the + // whole pixels its dab fills, on the pixel grid. A round tip is a + // disc, a tiny one the whole square (#2662). The Block-mode Eraser: + // its square, 16 screen pixels at any zoom (#2770). + _ if tool == Tool::Pencil || crate::eraser_ui::pencil_mode(app, tool) || crate::eraser_ui::block_mode(app, tool) => { + let block = crate::eraser_ui::block_mode(app, tool); + let size = if block { crate::eraser_ui::block_size(app) } else { brush.size }; let ppp = painter.ctx().pixels_per_point(); - let sq = pencil_cursor_rect(&xf, xf.to_doc(p), brush.size, ppp); + let sq = pencil_cursor_rect(&xf, xf.to_doc(p), size, ppp); let px = 1.0 / ppp; - painter.rect_stroke(sq, 0.0, Stroke::new(px, Color32::from_black_alpha(160)), egui::StrokeKind::Outside); - painter.rect_stroke(sq, 0.0, Stroke::new(px, Color32::from_white_alpha(230)), egui::StrokeKind::Inside); + let (dark, light) = (Stroke::new(px, Color32::from_black_alpha(160)), Stroke::new(px, Color32::from_white_alpha(230))); + if !block && pencil_tip_is_round(size) { + let r = sq.width() / 2.0; + painter.circle_stroke(sq.center(), r + px / 2.0, dark); + painter.circle_stroke(sq.center(), (r - px / 2.0).max(0.0), light); + } else { + painter.rect_stroke(sq, 0.0, dark, egui::StrokeKind::Outside); + painter.rect_stroke(sq, 0.0, light, egui::StrokeKind::Inside); + } // Too small to see where it is: the hotspot as well. if cur.show_crosshair_in_brush_tip || sq.width() < 6.0 { crate::tool_cursor::crosshair(&painter, p, 4.0, 0.0); @@ -3007,11 +3243,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } } app.tool_override = None; + if let Some(b) = layer_edges_rect(app, idx) { + painter.set(layer_edges, egui::Shape::rect_stroke(xf.doc_rect(b), 0, Stroke::new(1.0, LAYER_EDGES), egui::StrokeKind::Outside)); + } // Scrollbars (scrollbars.rs): drawn over the canvas edges, they take the pointer there. let t0 = crate::gpu_canvas::now_ms(); let before = view.center; let over_bars = app.ui.view.shows_scrollbars() - && crate::scrollbars::show(ui, rect, &mut view, flip, app.session.prefs().tools.overscroll, egui::Id::new(("pc-canvas", idx))); + && crate::scrollbars::show(ui, rect, &mut view, flip, aspect, app.session.prefs().tools.overscroll, egui::Id::new(("pc-canvas", idx))); ctx.data_mut(|d| d.insert_temp(bars_id, over_bars)); if view.center != before { ctx.request_repaint(); @@ -3025,6 +3264,17 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } crate::rotate_view::overlay(app, &ctx, &painter, &xf, &mut view, &response); if primary { + // The camera the tools set this frame wins over the copy's. + if let (Some((center, rotation)), Some(now)) = (camera, app.ui.views.get(idx)) { + if now.center != center { + view.center = now.center; + ctx.request_repaint(); + } + if now.rotation != rotation { + view.rotation = now.rotation; + ctx.request_repaint(); + } + } app.ui.views[idx] = view.clone(); } view @@ -3312,11 +3562,26 @@ fn draw_beyond_canvas(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie let square = app.session.prefs().transparency_and_gamut.square(); let checker_id = square.map(|_| checker(app, &ctx)); let around = ring(c.extent.union(&fr), doc.bounds()); + // A turned view (Rotate View, or the Crop tool's default mode turning the image behind its + // box) draws each band as its turned quad: its screen bounding box would stretch the band's + // texture and spread the checkerboard past the band. + let turned = xf.rotation != 0.0; + let checker_uv = |p: Pos2, sq: f32| ((p - canvas.min) / (2.0 * sq)).to_pos2(); for &b in &around { + if turned { + let q = doc_quad(xf, b); + match square.zip(checker_id) { + Some((sq, id)) => textured_quad(painter, id, q, q.map(|p| checker_uv(p, sq))), + None => { + painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::WHITE, Stroke::NONE)); + } + } + continue; + } let r = under_canvas(xf.doc_rect(b), canvas); match square.zip(checker_id) { Some((sq, id)) => { - let uv = Rect::from_min_max(((r.min - canvas.min) / (2.0 * sq)).to_pos2(), ((r.max - canvas.min) / (2.0 * sq)).to_pos2()); + let uv = Rect::from_min_max(checker_uv(r.min, sq), checker_uv(r.max, sq)); painter.image(id, r, uv, Color32::WHITE); } None => { @@ -3327,6 +3592,12 @@ fn draw_beyond_canvas(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie for (b, t, tex) in &c.bands { let part = |a: i32, o: i32, len: u32| (i64::from(a) - i64::from(o)) as f32 / len.max(1) as f32; let (mut u0, mut u1) = (part(b.x0, t.x0, t.width()), part(b.x1, t.x0, t.width())); + if turned { + // The quad's corners are the band's own (flip included), so no swap. + let (v0, v1) = (part(b.y0, t.y0, t.height()), part(b.y1, t.y0, t.height())); + textured_quad(painter, tex.id(), doc_quad(xf, *b), [pos2(u0, v0), pos2(u1, v0), pos2(u1, v1), pos2(u0, v1)]); + continue; + } if xf.flip { (u0, u1) = (u1, u0); } @@ -3339,13 +3610,43 @@ fn draw_beyond_canvas(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie !around.is_empty() } +/// Document rect `b`'s corners on screen, clockwise from its top-left (a parallelogram in a turned +/// view). +fn doc_quad(xf: &ViewXform, b: DRect) -> [Pos2; 4] { + [(b.x0, b.y0), (b.x1, b.y0), (b.x1, b.y1), (b.x0, b.y1)].map(|(x, y)| xf.to_screen(x as f32, y as f32)) +} + +/// Texture `tex` drawn over the quad `q`, `uv` at its corners. +fn textured_quad(painter: &egui::Painter, tex: egui::TextureId, q: [Pos2; 4], uv: [Pos2; 4]) { + if !q.iter().chain(uv.iter()).all(|p| p.x.is_finite() && p.y.is_finite()) { + return; + } + let mut mesh = egui::Mesh::with_texture(tex); + for (pos, uv) in q.into_iter().zip(uv) { + mesh.vertices.push(egui::epaint::Vertex { pos, uv, color: Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(egui::Shape::mesh(mesh)); +} + /// Overlays that persist between gestures: polygonal or magnetic lasso in progress, pending crop box. fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, hover: Option) { crate::magnetic_lasso_ui::draw(app, painter, xf, hover); if !app.ui.polygon.is_empty() { let mut pts: Vec = app.ui.polygon.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect(); if let Some(h) = hover { - pts.push(h); + // The rubber-band preview must use the same constrained point as the click, + // including when the canvas is zoomed, rotated or flipped. + let mods = painter.ctx().input(|i| i.modifiers); + if app.ui.tool == Tool::PolygonLasso && mods.shift && !mods.alt { + let raw = xf.to_doc(h); + let last = app.ui.polygon.last().copied().unwrap_or(raw); + let snapped = constrained_polygon_point(last, raw); + pts.push(xf.to_screen(snapped[0] as f32, snapped[1] as f32)); + } else { + pts.push(h); + } } // Just the outline and its rubber band: the vertices aren't handles to grab. crate::tool_feedback::draw_ants(painter, &pts, false); @@ -3366,11 +3667,28 @@ fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, draw_readout(painter.ctx(), "crop-angle-readout", h, ["Angle:"], [format!("{a:.1}°")]); } else if let (Some([w, h]), Some(at)) = (crate::crop_ui::sizing(app), hover) { // Its W × H while it is drawn or resized (#1919), like the marquee's. - draw_marquee_readout(painter.ctx(), at, marquee_readout([0.0, 0.0, w, h])); + draw_marquee_readout(painter.ctx(), at, size_readout(app, [0.0, 0.0, w, h])); } } } +/// View › Show › Layer Edges: the active layer's content bounds (cached per tile) where the canvas +/// shows them. During a Move drag that is at the offset the image was drawn with this frame, so +/// the outline moves in lockstep with the layer's pixels (#2413). +pub(crate) fn layer_edges_rect(app: &PhotocraftApp, idx: usize) -> Option { + if !app.ui.view.shows(app.ui.view.show.layer_edges) { + return None; + } + let st = app.session.documents().get(idx)?; + let id = st.active_layer?; + let b = st.doc.layer(id)?.surface().map(photocraft_compose::bounds::content_bounds)?; + if b.is_empty() { + return None; + } + let (dx, dy) = crate::move_ui::shown_offset(app, st.doc.id, st.revision, id).unwrap_or((0, 0)); + Some(b.translate(dx, dy)) +} + /// Move tool › Show Transform Controls: the active layer's transform bounds. fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Option { if app.ui.tool != Tool::Move || !app.ui.tool_options.move_show_transform || app.ui.transform.is_some() || app.drag.is_some() { @@ -3434,9 +3752,9 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View return; } let last = d.points.last().map(|p| [p[0], p[1]]).unwrap_or(d.start); - let marquee = marquee_preview_px(&app.ui.tool_options, d); + let marquee = marquee_preview_px(&app.ui.tool_options, d, app.point_zoom()); if let Some(r) = marquee { - draw_marquee_readout(painter.ctx(), xf.to_screen(last[0] as f32, last[1] as f32), marquee_readout(r)); + draw_marquee_readout(painter.ctx(), xf.to_screen(last[0] as f32, last[1] as f32), size_readout(app, r)); } match d.tool { // The canvas shows the live stroke itself (`LiveStroke`); a stroke that couldn't start one @@ -3455,20 +3773,28 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View let size = [st.doc.size.width, st.doc.size.height]; let doc_rect = xf.doc_rect(st.doc.bounds()); let trail = app.trail.get_or_insert_with(|| crate::stroke_trail::Trail::new(size)); - trail.feed(&d.points, app.session.tools.brush.size); + if t == Tool::SpotHealing { + // What the heal covers, dab by dab: soft edges, pressure and shape dynamics (#2364). + trail.feed_dabs(&d.points, || photocraft_engine::retouch_cmds::stroke_brush(&app.session.tools.brush, &json!({}))); + } else { + trail.feed(&d.points, app.session.tools.brush.size); + } trail.draw(painter, doc_rect, xf.flip, col); } t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::draw_shape_preview(app, painter, xf, t, d.start, last, d.live), + Tool::SingleRowMarquee | Tool::SingleColumnMarquee => { + // Mapped corner by corner like the other marquees, so a rotated view shows the line + // where the release commits it (#2512). + if let Some([x0, y0, x1, y1]) = single_line_drag_px(app, d) { + crate::tool_feedback::draw_ants(painter, &marquee_ants(xf, d.tool, [x0, y0], [x1, y1]), true); + } + } + // Nothing selected yet: no outline, as the release would deselect. + Tool::RectMarquee | Tool::EllipseMarquee if marquee.is_none() => {} Tool::RectMarquee | Tool::EllipseMarquee | Tool::ObjectSelection => { - // Marching ants, visible on any pixels (#172). - let (a, b) = marquee.map_or((d.start, last), |[x0, y0, x1, y1]| ([x0, y0], [x1, y1])); - let r = Rect::from_two_pos(xf.to_screen(a[0] as f32, a[1] as f32), xf.to_screen(b[0] as f32, b[1] as f32)); - let r = Rect::from_min_max(r.min.round() + vec2(0.5, 0.5), r.max.round() + vec2(0.5, 0.5)); - let pts = if d.tool == Tool::EllipseMarquee { - crate::tool_feedback::ellipse_points(r) - } else { - vec![r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()] - }; + // Marching ants, visible on any pixels (#172): built in document space and mapped + // corner by corner, so a rotated view shows them where the release commits (#2512). + let pts = marquee_drag_ants(d, xf, marquee); crate::tool_feedback::draw_ants(painter, &pts, true); } // Patch / Content-Aware Move dragging the selection: its outline follows the pointer. @@ -3503,8 +3829,9 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View } // The layers themselves follow the pointer (`move_ui`); the arrow only when they can't. Tool::Move if !crate::move_ui::showing(app) => { - let off = vec2(((last[0] - d.start[0]) as f32) * xf.zoom, ((last[1] - d.start[1]) as f32) * xf.zoom); - painter.arrow(xf.to_screen(d.start[0] as f32, d.start[1] as f32), off, Stroke::new(2.0, crate::theme::Tokens::get(painter.ctx()).accent)); + let from = xf.to_screen(d.start[0] as f32, d.start[1] as f32); + let off = xf.to_screen(last[0] as f32, last[1] as f32) - from; + painter.arrow(from, off, Stroke::new(2.0, crate::theme::Tokens::get(painter.ctx()).accent)); } _ => {} } @@ -3625,10 +3952,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) // View › Snap / Snap To and smart guides (snap_ui.rs). let raw = ev; // A press anywhere but on the floating piece (or with ⇧ / ⌥, to draw) drops it first; the - // Move tool drags it from anywhere. + // Move tool (⌘ with a painting tool too, `event_tool`) drags it from anywhere. if let ToolEvent::Down { x, y, .. } = raw && app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some()) - && !crate::move_ui::moves_selected_pixels(app) + && !crate::move_ui::moves_selected_pixels_with(app, event_tool(app, mods)) && selection_drag_kind(app, app.ui.tool, [x, y], mods) != Some(true) { let _ = app.run("select.drop", json!({})); @@ -3681,6 +4008,14 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) if crate::gradient_ui::pointer(app, ev, mods) { return; } + // ⌘ with a selection tool over a guide takes the guide (#2690); the guide drag below moves it. + if let ToolEvent::Down { x, y, .. } = raw + && let Some((vertical, i)) = command_guide_at(app, app.active_tool(), [x, y], mods) + { + app.guide_drag = Some(crate::rulers::GuideDrag { vertical, index: Some(i), pos: if vertical { x } else { y } }); + crate::snap_ui::begin_guide(app, [x, y]); + return; + } if crate::lasso_ui::pointer(app, ev, mods) { return; } @@ -3688,14 +4023,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) if crate::magnetic_lasso_ui::pointer(app, ev, mods) { return; } - // ⌘ held is the Move tool (`hold_keys::cmd_moves`). The canvas resolves the held key before - // the event (`tool_override`); automation and tests send the modifier with the event. - let tool = match app.active_tool() { - t if app.tool_override.is_none() && mods.command && crate::hold_keys::cmd_moves(t) && app.ui.transform.is_none() && app.ui.text_edit.is_none() => { - Tool::Move - } - t => t, - }; + let tool = event_tool(app, mods); if tool == Tool::Eyedropper { match ev { ToolEvent::Down { x, y, .. } | ToolEvent::Move { x, y, .. } => { @@ -3733,10 +4061,15 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } // Auto-Select (or ⌘-click while it is off) picks the layer under the pointer first // (not when the selected pixels move: those are the active layer's). - if !crate::move_ui::moves_selected_pixels(app) && app.ui.tool_options.move_auto_select != mods.command { + if !crate::move_ui::moves_selected_pixels_with(app, tool) && app.ui.tool_options.move_auto_select != mods.command { let target = app.ui.tool_options.move_target.clone(); let mode = if mods.shift { "add" } else { "replace" }; + let before = app.session.active().map(|st| st.selected_layers()); let _ = app.run("layer.pickAt", json!({"x": x, "y": y, "target": target, "mode": mode})); + // Snapping (and the drag's box) started before the pick: point it at what moves. + if app.session.active().map(|st| st.selected_layers()) != before { + crate::snap_ui::retarget_move(app, [x, y], mods); + } } // A locked layer: no drag, and Photoshop's message once the pointer moves (`move_lock`). if crate::move_lock::blocked(app, tool, [x, y], mods) { @@ -3745,7 +4078,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } // With a selection: cut the selected pixels (⌥ copies them) and drag them as a floating // piece, from anywhere, as a marquee ⌘-drag does. - if crate::move_ui::moves_selected_pixels(app) { + if crate::move_ui::moves_selected_pixels_with(app, tool) { if crate::move_ui::float_selected(app, mods.alt, 0.0, 0.0) { let mut d = Drag::new(tool, [x, y], vec![[x, y, pressure as f64]], mods, false); d.sel_move = Some(true); @@ -3990,6 +4323,18 @@ pub fn selection_drag_kind(app: &PhotocraftApp, tool: Tool, p: [f64; 2], mods: e (!clicky && !mods.command && selection_mode(app, mods) == "replace").then_some(false) } +/// The tool a pointer event with `mods` goes to: ⌘ held is the Move tool (`hold_keys::cmd_moves`). +/// The canvas resolves the held key before the event (`tool_override`); automation and tests send +/// the modifier with the event. +pub(crate) fn event_tool(app: &PhotocraftApp, mods: egui::Modifiers) -> Tool { + match app.active_tool() { + t if app.tool_override.is_none() && mods.command && crate::hold_keys::cmd_moves(t) && app.ui.transform.is_none() && app.ui.text_edit.is_none() => { + Tool::Move + } + t => t, + } +} + /// Does a ⌘ (⌘⌥) press with selection tool `tool` at `p` move the whole layer (a duplicate with /// ⌥), as the Move tool would? Outside the selection or without one; inside it, ⌘ drags the /// selected pixels instead (`selection_drag_kind`). @@ -3999,6 +4344,15 @@ pub(crate) fn command_moves_layer(app: &PhotocraftApp, tool: Tool, p: [f64; 2], selection_tool && mods.command && !mods.shift && !floating && app.ui.polygon.is_empty() && !inside_selection(app, p) } +/// The guide a ⌘ press with selection tool `tool` at `p` drags (vertical?, index), as the Move +/// tool's press would (#2690): ⌘ is the Move tool, and a guide under the pointer comes before the +/// layer or the selected pixels. Not while a polygon or lasso outline is being drawn. +pub(crate) fn command_guide_at(app: &PhotocraftApp, tool: Tool, p: [f64; 2], mods: egui::Modifiers) -> Option<(bool, usize)> { + let selection_tool = matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::Lasso | Tool::PolygonLasso | Tool::MagicWand); + let idle = app.ui.polygon.is_empty() && !crate::lasso_ui::active(app) && app.ui.transform.is_none(); + (selection_tool && mods.command && idle).then(|| crate::rulers::guide_at(app, p[0], p[1])).flatten() +} + /// Photoshop's cursor over a selection: what a press (or the drag under way) would do. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SelCursor { @@ -4017,7 +4371,7 @@ pub enum SelCursor { /// The cursor at document point `p` with `tool` in effect and `mods` held (`None`: not over a /// selection the press would move). The Move tool moves the selected pixels from anywhere -/// (`move_ui::moves_selected_pixels`); a selection tool from inside the selection, or on the +/// (`move_ui::moves_selected_pixels_with`); a selection tool from inside the selection, or on the /// floating piece (`selection_drag_kind`). pub fn selection_cursor(app: &PhotocraftApp, tool: Tool, p: [f64; 2], mods: egui::Modifiers) -> Option { if let Some(d) = &app.drag { @@ -4117,7 +4471,7 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { } // The canvas already shows the stroke: let the commit's damage rect refresh it rather // than recompositing the whole document. - if app.run(stroke_command(d.tool), p).is_ok() + if app.run(stroke_command(app, d.tool), p).is_ok() && let Some(l) = live { // Raw preview key 0 = the document itself (its colour display folded in). @@ -4125,18 +4479,25 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { } } Tool::RectMarquee | Tool::EllipseMarquee => { - let (a, b) = marquee_corners(&app.ui.tool_options, &d, [end[0], end[1]]); - let [x0, y0, x1, y1] = marquee_px(a, b); - if x1 - x0 < 2.0 || y1 - y0 < 2.0 { + // Exactly what the drag showed (`marquee_preview_px`); a click deselects. + let Some([x0, y0, x1, y1]) = marquee_preview_px(&app.ui.tool_options, &d, app.point_zoom()) else { if app.session.is_enabled("select.deselect") { let _ = app.run("select.deselect", json!({})); } return; - } + }; let mode = selection_mode(app, d.modifiers); let (aa, feather) = (app.ui.tool_options.anti_alias, app.ui.tool_options.feather); let _ = app.run("select.rect", json!({"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0, "mode": mode, "ellipse": d.tool == Tool::EllipseMarquee, "antiAlias": aa, "feather": feather})); } + // A click (or the end of a drag) selects the line under the pointer, never deselects. + Tool::SingleRowMarquee | Tool::SingleColumnMarquee => { + let Some([x0, y0, x1, y1]) = single_line_drag_px(app, &d) else { return }; + let mode = selection_mode(app, d.modifiers); + let feather = app.ui.tool_options.feather; + let _ = + app.run("select.rect", json!({"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0, "mode": mode, "antiAlias": false, "feather": feather})); + } Tool::Patch if crate::retouch_ui::patch_drags_selection(app, d.start, d.modifiers) => crate::retouch_ui::finish_patch(app, d.start, [end[0], end[1]]), Tool::ContentAwareMove if crate::retouch_ui::patch_drags_selection(app, d.start, d.modifiers) => { crate::retouch_ui::finish_content_aware_move(app, d.start, [end[0], end[1]]) @@ -4209,6 +4570,22 @@ pub(crate) fn selection_mode(app: &PhotocraftApp, m: egui::Modifiers) -> &'stati crate::tool_feedback::document_selection_mode(app, Tool::Lasso, m) } +/// Shift constrains a Polygonal Lasso segment to Photoshop's 45° directions. +/// Work in document coordinates so zoom, view rotation and mirroring cannot skew it. +pub(crate) fn constrained_polygon_point(from: [f64; 2], point: [f64; 2]) -> [f64; 2] { + let dx = point[0] - from[0]; + let dy = point[1] - from[1]; + if !dx.is_finite() || !dy.is_finite() { + return point; + } + let length = dx.hypot(dy); + if length == 0.0 { + return point; + } + let angle = (dy.atan2(dx) / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4; + [from[0] + length * angle.cos(), from[1] + length * angle.sin()] +} + /// A polygonal lasso click adds a vertex; clicking near the first vertex closes the polygon. The /// first click fixes the selection mode; a new-selection polygon hides the old outline while it is /// drawn, and replaces it in one history step when it closes. @@ -4228,7 +4605,10 @@ fn polygon_click(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) let intent = if nothing_selected { egui::Modifiers { alt: false, ..mods } } else { mods }; app.ui.polygon_mode = selection_mode(app, intent).into(); } - app.ui.polygon.push([x, y]); + // Only constrain segments after the first vertex; the first click still sets selection intent. + // Alt-drag is freehand and must not snap to an angle. + let point = if mods.shift && !mods.alt { app.ui.polygon.last().copied().map_or([x, y], |last| constrained_polygon_point(last, [x, y])) } else { [x, y] }; + app.ui.polygon.push(point); } /// Remove the Polygonal Lasso's last vertex (⌫, Delete or a right-click while drawing, #1229); @@ -4265,6 +4645,8 @@ pub fn commit_crop(app: &mut PhotocraftApp) { // Never another document's frame (#1918). crate::crop_ui::cancel_stale(app); let Some(r) = app.ui.crop_rect.take() else { return }; + // The crop's default mode leaves the view upright (`crop_mode`). + crate::crop_mode::upright(app); // The untouched default frame around the whole canvas crops nothing (Photoshop's ↵ on it does // nothing); one framing the selection's bounds crops to them (#1789). if std::mem::take(&mut app.crop.default_frame) { @@ -4284,6 +4666,8 @@ pub fn commit_crop(app: &mut PhotocraftApp) { if angle != 0.0 { p["angle"] = json!(angle); } + // W x H x Resolution (#2443): resample to that size and set the resolution. + crate::crop_size::commit_params(app, &mut p); if app.run("image.crop", p).is_ok() && let Some(i) = app.session.active_index() { @@ -4601,6 +4985,12 @@ mod tests { assert!(items.iter().all(|(_, id, _, _)| photocraft_engine::commands::find(id).is_some())); } + #[test] + fn tab_context_reveal_names_the_platform_file_manager() { + let (label, id, _, _) = &tab_context_items(0, 1, true)[3]; + assert_eq!((*label, *id), (crate::menus::reveal_label(), "file.revealInFinder")); + } + /// Photoshop's document tab × is after the title on Windows and Linux, before it on macOS (#619). #[test] fn document_tab_close_button_sits_on_the_platform_side() { @@ -4633,6 +5023,64 @@ mod tests { } } + /// Middle-clicking a document tab closes it (#2570), in both tab strips, and the tab under the + /// pointer is the one that goes even when another document is active. + #[test] + fn middle_clicking_a_tab_closes_that_document() { + use egui_kittest::kittest::Queryable; + for kind in [crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::Pro] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + for name in ["keep", "doomed", "active"] { + app.run("file.new", json!({"width": 8, "height": 8, "name": name})).unwrap(); + } + app.sync_views(); + assert_eq!(app.session.active_index(), Some(2)); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(700.0, 40.0)).build_ui_state( + |ui, (app, ready): &mut (PhotocraftApp, bool)| { + if *ready { + tabs(app, ui); + } + }, + (app, false), + ); + PhotocraftApp::setup_context(&h.ctx, kind); + h.state_mut().1 = true; + h.run_steps(2); + h.get_by_label_contains("doomed").click_button(egui::PointerButton::Middle); + h.run_steps(2); + let names: Vec = h.state().0.session.documents().iter().map(|d| d.doc.name.clone()).collect(); + assert_eq!(names, ["keep", "active"], "{kind:?}: the middle-clicked tab closed"); + // A clean document closes straight away; a dirty one would park in `discard`. + assert!(h.state().0.discard.is_none(), "{kind:?}: nothing to prompt about"); + } + } + + /// A middle click goes through the same guarded `file.close` as the ×, so unsaved changes + /// still get the Save / Don’t Save / Cancel prompt instead of being thrown away (#2570). + #[test] + fn middle_clicking_an_edited_tab_prompts_instead_of_discarding() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8, "name": "edited"})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.sync_views(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(700.0, 40.0)).build_ui_state( + |ui, (app, ready): &mut (PhotocraftApp, bool)| { + if *ready { + tabs(app, ui); + } + }, + (app, false), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro); + h.state_mut().1 = true; + h.run_steps(2); + h.get_by_label_contains("edited").click_button(egui::PointerButton::Middle); + h.run_steps(2); + assert!(h.state().0.discard.is_some(), "the unsaved-changes prompt must come up"); + assert_eq!(h.state().0.session.documents().len(), 1, "and the document is still open"); + } + #[test] fn spectrum_document_tabs_meet_canvas_without_a_gap() { for kind in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::ProMedium] { @@ -4756,7 +5204,8 @@ mod tests { app.run("cloneSource.set", json!({"source": [16,16]})).unwrap(); app.ui.tool = Tool::CloneStamp; app.session.tools.brush.size = 20.0; - let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0 }; + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }; let revision = app.session.active().unwrap().revision; let mut output = ctx.run_ui(Default::default(), |ui| { let ctx = ui.ctx(); @@ -4798,6 +5247,55 @@ mod tests { assert!(hint.ends_with(&format!("paste a copied image with {}.", crate::notices::paste_hint(&wayland))), "{hint}"); } + /// The Home screen's × takes a file off Recent without opening it, and its right-click menu + /// removes one entry or clears the list; the preferences keep the change (#2691). + #[test] + fn home_recent_entries_can_be_removed() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + for name in ["c.tif", "b.psd", "a.png"] { + app.push_recent(&format!("/tmp/photocraft-2691/{name}")); + } + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 760.0)).build_ui_state( + |ui, (app, ready): &mut (PhotocraftApp, bool)| { + if *ready { + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| document_area(app, ui)); + } + }, + (app, false), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default()); + h.state_mut().1 = true; + h.run_steps(3); + let listed = |h: &egui_kittest::Harness<'_, (PhotocraftApp, bool)>| { + let app = &h.state().0; + assert_eq!(app.session.prefs().file_handling.recent_files, app.ui.recent_files, "stored in the preferences"); + app.ui.recent_files.iter().map(|p| crate::file_open::display_name(p)).collect::>() + }; + assert_eq!(listed(&h), ["a.png", "b.psd", "c.tif"]); + assert!(h.query_by_label("Remove b.psd from Recent").is_none(), "the × shows on the hovered row only"); + + h.get_by_label("b.psd").hover(); + h.run_steps(2); + h.get_by_label("Remove b.psd from Recent").click(); + h.run_steps(2); + assert_eq!(listed(&h), ["a.png", "c.tif"]); + assert!(h.state().0.session.documents().is_empty(), "the file was not opened"); + + h.get_by_label("c.tif").click_secondary(); + h.run_steps(2); + h.get_by_label("Remove from Recent").click(); + h.run_steps(2); + assert_eq!(listed(&h), ["a.png"]); + + h.get_by_label("a.png").click_secondary(); + h.run_steps(2); + h.get_by_label("Clear Recent Files").click(); + h.run_steps(2); + assert!(listed(&h).is_empty()); + assert!(h.state().0.session.documents().is_empty()); + } + #[test] fn pointer_moves_are_bounded_by_the_press_and_release() { use egui::{Event, PointerButton, pos2}; @@ -4860,7 +5358,14 @@ mod tests { #[test] fn view_transform_roundtrip() { for flip in [false, true] { - let xf = ViewXform { rect: Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0)), zoom: 2.5, center: [320.0, 240.0], flip, rotation: 0.0 }; + let xf = ViewXform { + rect: Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0)), + zoom: 2.5, + center: [320.0, 240.0], + flip, + rotation: 0.0, + aspect: 1.0, + }; let s = xf.to_screen(10.0, 20.0); let d = xf.to_doc(s); assert!((d[0] - 10.0).abs() < 1e-3 && (d[1] - 20.0).abs() < 1e-3); @@ -5254,7 +5759,7 @@ mod tests { let ctx = ui.ctx(); let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); let painter = ctx.layer_painter(egui::LayerId::background()).with_clip_rect(rect); - let xf = ViewXform { rect, zoom, center: [60.0, 30.0], flip: false, rotation: 0.0 }; + let xf = ViewXform { rect, zoom, center: [60.0, 30.0], flip: false, rotation: 0.0, aspect: 1.0 }; draw_drag_preview(app, &painter, &xf); }); let egui::FullOutput { mut textures_delta, shapes, .. } = out; @@ -5341,6 +5846,50 @@ mod tests { assert!((step.x - 0.5).abs() < 1e-3, "{step:?}"); } + #[test] + fn a_secondary_canvas_hand_pans_while_a_transform_is_open() { + // #2302 review: `app.ui.transform` is global, but only the primary canvas draws and drives + // the box. A secondary window's Hand must still pan while the main window transforms. + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.run("view.actualPixels", json!({})).unwrap(); + app.ui.tool = Tool::Hand; + app.sync_views(); + crate::transform_tool::begin(&mut app, &ctx).expect("a transform starts"); + let mut view0 = app.ui.views[0].clone(); + view0.zoom = 1.0; + view0.center = [200.0, 150.0]; + view0.fit_pending = false; + view0.fill_pending = false; + let center0 = view0.center; + let rect = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)); + let frame = |app: &mut PhotocraftApp, events: Vec| -> View { + let input = egui::RawInput { screen_rect: Some(rect), events, ..Default::default() }; + let mut seen = view0.clone(); + let mut out = ctx.run_ui(input, |ui| { + seen = canvas_view(app, ui, 0, rect, view0.clone(), false); + }); + out.textures_delta.clear(); + seen + }; + let start = Pos2::new(200.0, 150.0); + let press = + |pos: Pos2, pressed: bool| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(&mut app, vec![egui::Event::PointerMoved(start)]); + frame(&mut app, vec![press(start, true)]); + for dx in [10.0, 20.0, 40.0] { + frame(&mut app, vec![egui::Event::PointerMoved(start + vec2(dx, 0.0))]); + } + let dragged = frame(&mut app, vec![egui::Event::PointerMoved(start + vec2(60.0, 0.0))]); + frame(&mut app, vec![press(start + vec2(60.0, 0.0), false)]); + assert_ne!(dragged.center, center0, "a secondary window's Hand pans while transforming"); + } + #[test] fn brush_drags_paint_no_stand_in_shape_over_the_canvas() { // #189: v0.1 drew the stroke being dragged as a foreground-coloured egui polyline as wide @@ -5372,8 +5921,9 @@ mod tests { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); app.run("file.new", json!({"width": 120, "height": 60})).unwrap(); app.run("tools.setBrush", json!({"brush": {"size": 30}})).unwrap(); - // Spot Healing: the dab tools draw their stroke live instead of a trail. - app.ui.tool = Tool::SpotHealing; + // Remove: the dab tools draw their stroke live instead of a trail, and Spot Healing's + // trail is its dabs' coverage. + app.ui.tool = Tool::Remove; tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); let mut pts = vec![[20.0, 30.0]]; for i in 1..80 { @@ -5405,6 +5955,38 @@ mod tests { assert!(app.trail.is_none(), "the trail ends with the drag"); } + #[test] + fn spot_healing_trail_is_the_coverage_the_heal_uses() { + // #2364: a soft brush with pen-pressure size showed as a hard footprint at full size. + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 120, "height": 60})).unwrap(); + let brush = json!({"size": 30, "hardness": 0.0, "shapeDynamics": {"enabled": true, "size": {"control": "penPressure"}}}); + app.run("tools.setBrush", json!({ "brush": brush })).unwrap(); + app.ui.tool = Tool::SpotHealing; + tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 30.0, pressure: 0.5 }, egui::Modifiers::NONE); + for x in [40.0, 60.0, 80.0] { + tool_event(&mut app, ToolEvent::Move { x, y: 30.0, pressure: 0.5 }, egui::Modifiers::NONE); + drag_preview_shapes(&mut app, &ctx, 12.0); + } + // The stroke the heal gets on release, and its coverage. + let points = app.drag.as_ref().unwrap().points.iter().map(|p| photocraft_paint::StrokePoint::new(p[0], p[1], p[2] as f32)).collect(); + let brush = photocraft_engine::retouch_cmds::stroke_brush(&app.session.tools.brush, &json!({})); + let (b, cov) = photocraft_paint::retouch::stroke_coverage(&photocraft_paint::Stroke { brush, points }); + let trail = app.trail.as_ref().unwrap(); + let mut soft = 0; + for y in 0..60 { + for x in 0..120 { + let want = if b.contains(x, y) { cov[(y - b.y0) as usize * b.width() as usize + (x - b.x0) as usize] } else { 0.0 }; + let got = trail.coverage(x as usize, y as usize); + assert!((f32::from(got) - want * 255.0).abs() <= 1.0, "({x}, {y}): trail {got}, heal {want}"); + soft += usize::from(got > 0 && got < 200); + } + } + assert!(soft > 50, "a soft brush shows a soft edge"); + assert_eq!(trail.coverage(50, 30 + 10), 0, "half pressure halves the 30 px tip"); + } + #[test] fn layer_style_dialog_previews_live_and_cancel_restores() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); @@ -5434,7 +6016,7 @@ mod tests { let doc = app.session.active().unwrap().doc.clone(); let mut view = View { fill_pending: true, ..View::default() }; - fill_view(&mut view, &doc, vec2(600.0, 600.0), 1.0); + fill_view(&mut view, &doc, vec2(600.0, 600.0), 1.0, 1.0); assert_eq!(view.zoom, 3.0, "the short edge fills the available height"); assert_eq!(view.center, [200.0, 100.0]); @@ -5442,15 +6024,15 @@ mod tests { // A scaled display stores the physical factor: the same point zoom is 2× the device px. let mut view = View { fill_pending: true, ..View::default() }; - fill_view(&mut view, &doc, vec2(600.0, 600.0), 2.0); + fill_view(&mut view, &doc, vec2(600.0, 600.0), 2.0, 1.0); assert_eq!(view.zoom, 6.0); // Fit never magnifies past the 1 point-per-pixel cap; on a scaled display the cap is 2. let mut view = View { fit_pending: true, ..View::default() }; - fit_view(&mut view, &doc, vec2(600.0, 600.0), 1.0); + fit_view(&mut view, &doc, vec2(600.0, 600.0), 1.0, 1.0); assert_eq!(view.zoom, 1.0); let mut view = View { fit_pending: true, ..View::default() }; - fit_view(&mut view, &doc, vec2(600.0, 600.0), 2.0); + fit_view(&mut view, &doc, vec2(600.0, 600.0), 2.0, 1.0); assert_eq!(view.zoom, 2.0); } @@ -5480,12 +6062,12 @@ mod tests { let p = pos2(600.0, 200.0); for ppp in [1.0f32, 2.0] { // `View::zoom` is physical; the transform works in points. - let xf = ViewXform { rect, zoom: 1.0 / ppp, center: [400.0, 300.0], flip: false, rotation: 0.0 }; + let xf = ViewXform { rect, zoom: 1.0 / ppp, center: [400.0, 300.0], flip: false, rotation: 0.0, aspect: 1.0 }; let doc = xf.to_doc(p); // Off: the document point under the pointer stays under it. let mut view = View { zoom: 1.0, center: [400.0, 300.0], ..Default::default() }; zoom_about(&mut view, &xf, p, 2.0, false, ppp); - let after = ViewXform { rect, zoom: 2.0 / ppp, center: view.center, flip: false, rotation: 0.0 }; + let after = ViewXform { rect, zoom: 2.0 / ppp, center: view.center, flip: false, rotation: 0.0, aspect: 1.0 }; let s = after.to_screen(doc[0] as f32, doc[1] as f32); assert!((s - p).length() < 0.5, "ppp {ppp}: {s:?} vs {p:?}"); // On: the clicked point is the view centre. @@ -5528,7 +6110,8 @@ mod transform_controls_tests { app.ui.tool = Tool::Move; app.ui.tool_options.move_show_transform = true; - let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0 }; + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; let r = transform_controls_rect(&app, &xf).expect("shape layers have transform bounds"); assert!(transform_controls_hit(r, r.right_bottom())); @@ -5538,6 +6121,47 @@ mod transform_controls_tests { assert_eq!(t.rect, [20.0, 30.0, 100.0, 70.0]); } + /// #2585: Free Transform on a position-locked layer is refused before the box opens, with + /// Photoshop's alert, from ⌘T and from the Move tool's transform controls alike. It used to + /// open, preview every drag and fail only at commit. + #[test] + fn free_transform_on_a_locked_layer_is_refused_up_front() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.session.execute("shape.create", json!({"kind": "rect", "rect": [20, 30, 80, 40], "fill": "#ff0000"})).unwrap(); + app.session + .edit("lock", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().locks.position = true; + Ok(()) + }) + .unwrap(); + app.sync_views(); + let errors = |app: &PhotocraftApp| -> Vec { + app.ui + .dialogs + .iter() + .filter(|d| d.kind == crate::state::DialogKind::Error) + .filter_map(|d| d.fields.get("message")?.as_str().map(String::from)) + .collect() + }; + let ctx = egui::Context::default(); + // ⌘T. + let r = crate::menus::invoke(&mut app, &ctx, "edit.freeTransform", json!({})); + assert_eq!(r, Err(crate::transform_tool::LOCKED.to_string())); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "no box opened"); + assert_eq!(errors(&app), [crate::transform_tool::LOCKED]); + app.ui.dialogs.clear(); + // The Move tool's transform controls. + app.ui.tool = Tool::Move; + app.ui.tool_options.move_show_transform = true; + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let r = transform_controls_rect(&app, &xf).unwrap(); + assert!(!begin_transform_controls_at(&mut app, &ctx, &xf, r.right_bottom())); + assert!(app.ui.transform.is_none()); + assert_eq!(errors(&app), [crate::transform_tool::LOCKED]); + } + #[test] fn transform_control_hit_keeps_move_interior_free_and_has_rotation_band() { let r = Rect::from_min_max(pos2(20.0, 30.0), pos2(100.0, 70.0)); diff --git a/crates/ui-egui/src/canvas/tabs_tests.rs b/crates/ui-egui/src/canvas/tabs_tests.rs index 6d8a7adfa6..4cc2bca0f0 100644 --- a/crates/ui-egui/src/canvas/tabs_tests.rs +++ b/crates/ui-egui/src/canvas/tabs_tests.rs @@ -112,3 +112,57 @@ fn saved_document_tabs_track_file_identity() { assert!(h.state().session.active().unwrap().is_dirty()); } } + +/// What a Ctrl press reports: on Windows and Linux Ctrl is also the command key, on macOS it isn't. +fn ctrl() -> egui::Modifiers { + egui::Modifiers { ctrl: true, command: !cfg!(target_os = "macos"), ..egui::Modifiers::NONE } +} + +/// #2340: Ctrl+Tab shows the next document's tab and Ctrl+Shift+Tab the previous one, wrapping +/// around at either end, as in Photoshop. Plain Tab still hides the panels instead. +#[test] +fn ctrl_tab_cycles_through_the_document_tabs() { + let mut h = harness(3, ThemeKind::Pro); + let active = |h: &Harness<'_, PhotocraftApp>| h.state().session.active_index(); + assert_eq!(active(&h), Some(2), "the last new document is active"); + let press = |h: &mut Harness<'_, PhotocraftApp>, shift: bool| { + h.key_press_modifiers(if shift { ctrl() | egui::Modifiers::SHIFT } else { ctrl() }, egui::Key::Tab); + h.run_steps(2); + }; + press(&mut h, false); + assert_eq!(active(&h), Some(0), "Ctrl+Tab on the last tab wraps to the first"); + press(&mut h, false); + assert_eq!(active(&h), Some(1)); + press(&mut h, true); + assert_eq!(active(&h), Some(0), "Ctrl+Shift+Tab goes back"); + press(&mut h, true); + assert_eq!(active(&h), Some(2), "and wraps to the last tab"); + assert!(h.state().ui.panels.dock, "Ctrl+Tab doesn't hide the panels"); + // Tab order is document order, so a dragged tab is cycled through at its new place. + h.state_mut().session.move_document(0, 2); + h.state_mut().session.set_active(1); + press(&mut h, false); + assert_eq!(h.state().session.active().unwrap().doc.name, "ch1-p01.png"); + h.key_press(egui::Key::Tab); + h.run_steps(2); + assert_eq!(active(&h), Some(2), "plain Tab doesn't switch documents"); + assert!(!h.state().ui.panels.dock, "plain Tab hides the panels"); +} + +/// One document: Ctrl+Tab stays on it quietly; a focused widget or text field doesn't keep the key. +#[test] +fn ctrl_tab_with_one_document_or_a_focused_field() { + let mut h = harness(1, ThemeKind::Pro); + h.key_press_modifiers(ctrl(), egui::Key::Tab); + h.run_steps(2); + assert_eq!(h.state().session.active_index(), Some(0)); + assert!(crate::shortcut_dispatch::take_log(&h.ctx).iter().all(|(_, o)| *o == crate::shortcut_dispatch::Outcome::Ran), "no disabled-shortcut report"); + use crate::shortcut_dispatch::Focus; + for (key, sc) in [("Ctrl+Tab", "window.nextDocument"), ("Ctrl+Shift+Tab", "window.previousDocument")] { + let parsed = crate::shortcuts::parse(key).unwrap(); + assert!(Focus::Widget.allows(&parsed) && Focus::Text.allows(&parsed), "{key} reaches {sc} from a focused field"); + let bound = crate::shortcut_dispatch::bindings(h.state()).into_iter().find(|(_, s)| *s == parsed).map(|(id, _)| id); + assert_eq!(bound.as_deref(), Some(sc)); + } + assert!(!Focus::Widget.allows(&crate::shortcuts::parse("Tab").unwrap()), "a focused widget keeps plain Tab"); +} diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index 02c5bb85f8..70da790845 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -1,9 +1,11 @@ -//! Canvas context menus for the selection tools, the Pen and an active Free Transform box. +//! Canvas context menus for the selection tools, the Pen, an active Free Transform box and the +//! pasteboard (outside the image). use egui::Context; use photocraft_doc::LayerContent; +use photocraft_engine::prefs::{self, CanvasColor}; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{Value, json}; use crate::{PhotocraftApp, state::Tool}; @@ -19,6 +21,9 @@ pub struct CanvasToolMenu { /// Opened over a Free Transform box: its modes instead of the tool's actions. #[serde(default)] pub transform: bool, + /// Opened outside the image: the pasteboard colour instead of the tool's actions. + #[serde(default)] + pub pasteboard: bool, } /// One menu row: label and command id. `None` is a separator. @@ -32,8 +37,26 @@ pub const TRANSFORM_MENU: &[Row] = &[ Some(("Skew", "edit.transform.skew")), Some(("Distort", "edit.transform.distort")), Some(("Perspective", "edit.transform.perspective")), + Some(("Warp", "edit.transform.warp")), ]; +/// Photoshop's pasteboard menu (right-click outside the image): the pasteboard colour, the choice +/// Preferences › Interface also offers. The ids are this menu's own: [`choose`] turns them into +/// `prefs.set` (`pasteboard.`) or opens the Color Picker. +pub const PASTEBOARD_MENU: &[Row] = &[ + Some(("Default", "pasteboard.default")), + Some(("Black", "pasteboard.black")), + Some(("Dark Gray", "pasteboard.darkGray")), + Some(("Medium Gray", "pasteboard.mediumGray")), + Some(("Light Gray", "pasteboard.lightGray")), + Some(("Custom", "pasteboard.custom")), + None, + Some(("Select Custom Color…", PASTEBOARD_PICK)), +]; + +/// The pasteboard menu's Select Custom Color… row. +pub const PASTEBOARD_PICK: &str = "pasteboard.selectCustomColor"; + /// Photoshop's selection-tool context menu with an active selection, in its order. Its Generative /// Fill row has no PhotoCraft command and is left out. Rows stay visible and grey out exactly like /// their menu-bar twins. @@ -112,7 +135,16 @@ pub const PEN_MENU: &[Row] = &[ pub fn applies(tool: Tool) -> bool { matches!( tool, - Tool::RectMarquee | Tool::EllipseMarquee | Tool::Lasso | Tool::PolygonLasso | Tool::MagneticLasso | Tool::MagicWand | Tool::ObjectSelection | Tool::Pen + Tool::RectMarquee + | Tool::EllipseMarquee + | Tool::SingleRowMarquee + | Tool::SingleColumnMarquee + | Tool::Lasso + | Tool::PolygonLasso + | Tool::MagneticLasso + | Tool::MagicWand + | Tool::ObjectSelection + | Tool::Pen ) } @@ -123,7 +155,9 @@ pub fn selection_rows(has_selection: bool) -> &'static [Row] { /// The rows of an open menu. pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { - if menu.transform { + if menu.pasteboard { + PASTEBOARD_MENU + } else if menu.transform { TRANSFORM_MENU } else if menu.tool == Tool::Pen { PEN_MENU @@ -138,10 +172,14 @@ pub fn available(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> b } /// Is `command` the transform box's current mode (checked in the transform menu)? -/// Scale and Rotate are Free Transform's mode, so only Free Transform shows it checked. -fn mode_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { +/// Scale and Rotate are Free Transform's mode, so only Free Transform shows it checked. Warp is +/// not a box mode (it is its own session), and `for_command` would read it as Free. +pub(crate) fn mode_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { + if menu.pasteboard { + return pasteboard_choice(command) == Some(app.session.prefs().interface.canvas_color); + } menu.transform - && !matches!(command, "edit.transform.scale" | "edit.transform.rotate") + && matches!(command, "edit.freeTransform" | "edit.transform.skew" | "edit.transform.distort" | "edit.transform.perspective") && app.ui.transform.as_ref().is_some_and(|t| t.mode == crate::state::TransformMode::for_command(command)) } @@ -152,11 +190,67 @@ pub fn open_transform(app: &mut PhotocraftApp, pos: [f32; 2]) -> bool { } app.ui.brush_picker = None; app.ui.layer_menu = None; - app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool: app.ui.tool, has_selection: false, has_path: false, path_name: None, transform: true }); + app.ui.canvas_tool_menu = + Some(CanvasToolMenu { pos, tool: app.ui.tool, has_selection: false, has_path: false, path_name: None, transform: true, pasteboard: false }); true } +/// The pasteboard colour a pasteboard menu row picks (`None` for Select Custom Color… or a row +/// of another menu). +fn pasteboard_choice(command: &str) -> Option { + command.strip_prefix("pasteboard.").and_then(CanvasColor::parse) +} + +/// The engine command (and params) a pasteboard row runs, for the automation policy: every row +/// changes a preference. +pub fn engine_call(menu: &CanvasToolMenu, command: &str) -> (String, Value) { + match pasteboard_choice(command) { + Some(c) if menu.pasteboard => ("prefs.set".into(), json!({"values": {"interface.canvasColor": c.name()}})), + _ if menu.pasteboard => ("prefs.set".into(), json!({})), + _ => (command.into(), json!({})), + } +} + +/// Does a right-click with `tool` at document point `doc` open the pasteboard menu? Outside the +/// image, for the tools whose right-click has no job there: not the painting tools (their Brush +/// Preset picker or right-button erase), the Pen (its path menu) or text being edited. +pub fn pasteboard_at(app: &PhotocraftApp, tool: Tool, doc: [f64; 2]) -> bool { + let Some(st) = app.session.active() else { return false }; + let (w, h) = (f64::from(st.doc.size.width), f64::from(st.doc.size.height)); + let outside = doc.iter().all(|v| v.is_finite()) && (doc[0] < 0.0 || doc[1] < 0.0 || doc[0] >= w || doc[1] >= h); + outside && tool != Tool::Pen && !crate::paint_mouse::has_brush_picker(tool) && !crate::paint_mouse::right_erases(app, tool) && app.ui.text_edit.is_none() +} + +/// Right-click on the pasteboard: its colour menu at screen point `pos`. +pub fn open_pasteboard(app: &mut PhotocraftApp, tool: Tool, pos: [f32; 2]) -> bool { + if !pos.iter().all(|v| v.is_finite()) { + return false; + } + app.ui.brush_picker = None; + app.ui.layer_menu = None; + app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool, has_selection: false, has_path: false, path_name: None, transform: false, pasteboard: true }); + true +} + +/// A pasteboard row: set the colour, or open the Color Picker on the custom one. +fn choose_pasteboard(app: &mut PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> Result<(), String> { + if command == PASTEBOARD_PICK { + let rgb = prefs::parse_hex(&app.session.prefs().interface.canvas_custom_color).map_or([0.5; 3], |c| c.map(|v| f32::from(v) / 255.0)); + crate::color_picker_ui::open_for_target(app, "pasteboard", "Color Picker (Custom Canvas Color)", rgb); + return Ok(()); + } + let (command, params) = engine_call(menu, command); + app.run(&command, params).map(|_| ()) +} + pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { + if menu.pasteboard { + return PASTEBOARD_MENU.iter().flatten().any(|(_, id)| *id == command); + } + // Warp needs a layer or selection box: a lone mask, channel or path keeps the plain box. + if menu.transform && command == "edit.transform.warp" { + return app.ui.transform.as_ref().is_some_and(|t| t.warp.is_none() && t.target.is_none() && t.path.is_none()); + } if menu.transform || menu.tool != Tool::Pen { return crate::menus::is_enabled(app, command); } @@ -204,7 +298,7 @@ pub fn open(app: &mut PhotocraftApp, tool: Tool, pos: [f32; 2]) -> bool { let has_selection = app.session.active().is_some_and(|s| s.doc.selection.is_some()); let has_path = crate::vector_ui::active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); let path_name = crate::vector_ui::active_path_name(app); - app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name, transform: false }); + app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name, transform: false, pasteboard: false }); true } @@ -213,7 +307,9 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { if !available(app, &menu, command) { return; } - let result = if menu.tool == Tool::Pen && !menu.transform { + let result = if menu.pasteboard { + choose_pasteboard(app, &menu, command) + } else if menu.tool == Tool::Pen && !menu.transform { choose_pen(app, ctx, &menu, command) } else if command == "select.toWorkPath" { // Photoshop's Make Work Path… asks for the tolerance first. @@ -415,6 +511,36 @@ mod tests { assert!(h.state().ui.canvas_tool_menu.is_none(), "command right-click belongs to layer picker"); } + #[test] + fn right_click_outside_the_image_picks_the_pasteboard_color() { + let mut app = app(); + app.ui.tool = Tool::RectMarquee; + let mut h = harness(app); + let rect = h.state().last_canvas_rect; + let outside = rect.min + vec2(5.0, 5.0); + right_click(&mut h, rect.center(), Modifiers::NONE); + assert!(h.state().ui.canvas_tool_menu.as_ref().is_some_and(|m| !m.pasteboard), "inside: the tool's own menu"); + h.state_mut().ui.canvas_tool_menu = None; + right_click(&mut h, outside, Modifiers::NONE); + let menu = h.state().ui.canvas_tool_menu.clone().unwrap(); + assert!(menu.pasteboard && rows(&menu) == PASTEBOARD_MENU, "outside: the pasteboard menu"); + let ctx = h.ctx.clone(); + choose(h.state_mut(), &ctx, "pasteboard.black"); + assert_eq!(crate::prefs_ui::pasteboard_color(h.state()), Some(egui::Color32::BLACK)); + // Select Custom Color… opens the Color Picker; OK makes its colour the custom pasteboard. + right_click(&mut h, outside, Modifiers::NONE); + choose(h.state_mut(), &ctx, PASTEBOARD_PICK); + let mut f = h.state().ui.dialogs.last().unwrap().fields.clone(); + f.insert("color".into(), json!("#123456")); + crate::color_picker_ui::confirm(h.state_mut(), &f).unwrap(); + assert_eq!(crate::prefs_ui::pasteboard_color(h.state()), Some(egui::Color32::from_rgb(0x12, 0x34, 0x56))); + // A painting tool keeps its Brush Preset picker there. + h.state_mut().ui.tool = Tool::Brush; + h.run_steps(2); + right_click(&mut h, outside, Modifiers::NONE); + assert!(h.state().ui.canvas_tool_menu.is_none() && h.state().ui.brush_picker.is_some()); + } + fn labels(rows: &[Row]) -> Vec> { rows.iter().map(|row| row.map(|(label, _)| label)).collect() } diff --git a/crates/ui-egui/src/channel_view.rs b/crates/ui-egui/src/channel_view.rs index 92a1d8832d..520994e0b0 100644 --- a/crates/ui-egui/src/channel_view.rs +++ b/crates/ui-egui/src/channel_view.rs @@ -113,8 +113,10 @@ pub fn render(doc: &Document, v: &ChannelView, r: Rect, factor: u32, in_color: b } for (k, x) in native[..colors].iter_mut().enumerate() { if !v.color_visible(k) { - // Hidden channels contribute nothing (Lab: neutral a/b). - *x = if mode == ColorMode::Lab && k > 0 { 0.5 } else { 0.0 }; + // Hidden channels contribute nothing (Lab: neutral a/b, and a neutral + // Lightness under Show Channels in Color, so an isolated a/b channel + // tints over mid gray instead of black, as Photoshop does). + *x = if mode == ColorMode::Lab && (k > 0 || in_color) { 0.5 } else { 0.0 }; } } let c = to_rgba(&fmt, &native); @@ -205,7 +207,10 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio // otherwise an opaque grayscale mask hides the stroke until it is committed (#2078). // The plain-composite path above needs no preview work. let (doc, preview_key) = crate::canvas::display_doc(app, idx); - let in_color = false; + // Interface ▸ Show Channels in Color tints single colour channel views on the canvas too, + // not just the panel thumbnails (#2385). The view key carries the flag, so toggling it + // rebuilds the texture. + let in_color = app.session.prefs().interface.show_channels_in_color; let key = view_key(&view, in_color); // Never exceed what the GPU accepts (egui panics on oversized textures). let max_side = (ctx.input(|i| i.max_texture_side) as u32).clamp(256, MAX_SIDE); @@ -351,6 +356,64 @@ mod tests { } } + /// Interface ▸ Show Channels in Color (#2385): a solo RGB / CMYK channel tints the canvas + /// instead of staying gray, like the panel thumbnails wired in #1986. + #[test] + fn show_channels_in_color_tints_the_canvas() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb"})).unwrap(); + s.execute("edit.fill", json!({"color": "#4080c0"})).unwrap(); + s.execute("channel.target", json!({"channel": "green"})).unwrap(); + let st = s.active().unwrap(); + let b = st.doc.bounds(); + assert_eq!(render(&st.doc, &st.channel_view, b, 1, false).unwrap()[0], Color32::from_gray(128)); + assert_eq!(render(&st.doc, &st.channel_view, b, 1, true).unwrap()[0], Color32::from_rgb(0, 128, 0)); + + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "cmyk"})).unwrap(); + s.execute("edit.fill", json!({"color": "#4080c0"})).unwrap(); + s.execute("channel.target", json!({"channel": "cyan"})).unwrap(); + let st = s.active().unwrap(); + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, true).unwrap()[0]; + assert!(px.r() < 100 && px.g() > 150 && px.b() > 150, "cyan ink, not gray: {px:?}"); + let gray = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, false).unwrap()[0]; + assert_eq!(gray.r(), gray.g(), "off stays grayscale"); + } + + /// Lab: a and b tint over a neutral Lightness; Lightness itself stays gray (Adobe's exception). + #[test] + fn show_channels_in_color_lab_lightness_stays_gray() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "lab"})).unwrap(); + s.execute("edit.fill", json!({"color": "#4080c0"})).unwrap(); + let b = s.active().unwrap().doc.bounds(); + s.execute("channel.target", json!({"channel": {"color": 0}})).unwrap(); + let st = s.active().unwrap(); + let px = render(&st.doc, &st.channel_view, b, 1, true).unwrap()[0]; + let spread = px.r().max(px.g().max(px.b())) as i32 - px.r().min(px.g().min(px.b())) as i32; + assert!(spread <= 3, "Lightness stays gray: {px:?}"); + s.execute("channel.target", json!({"channel": {"color": 1}})).unwrap(); + let st = s.active().unwrap(); + let px = render(&st.doc, &st.channel_view, b, 1, true).unwrap()[0]; + assert!(px.r() != px.g() || px.g() != px.b(), "a channel is tinted, not gray: {px:?}"); + } + + /// The canvas texture cache is keyed by the preference, so toggling it rebuilds the view. + #[test] + fn ensure_honours_show_channels_in_color() { + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + app.sync_views(); + app.run("channel.target", json!({"channel": "green"})).unwrap(); + let ctx = egui::Context::default(); + assert!(ensure(&mut app, &ctx, 0).is_some()); + let key_off = app.channel_views.values().next().unwrap().key; + app.session.execute("prefs.set", json!({"values": {"interface.showChannelsInColor": true}})).unwrap(); + assert!(ensure(&mut app, &ctx, 0).is_some()); + let key_on = app.channel_views.values().next().unwrap().key; + assert_ne!(key_off, key_on, "the preference changes the cached view key"); + } + #[test] fn solo_alpha_is_grayscale_and_colour_channels_split() { let mut s = session(); diff --git a/crates/ui-egui/src/channels_panel.rs b/crates/ui-egui/src/channels_panel.rs index ba1051cdd2..26c6a412f2 100644 --- a/crates/ui-egui/src/channels_panel.rs +++ b/crates/ui-egui/src/channels_panel.rs @@ -293,6 +293,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ctx.data_mut(|d| d.insert_temp(thumbs_id(), drawn)); if let Some(on) = mask_click { app.ui.mask_target = on; + app.sync_mask_targets(); } for (id, p) in actions { if id == "ui.renameChannel" { diff --git a/crates/ui-egui/src/chrome_ui.rs b/crates/ui-egui/src/chrome_ui.rs index b69d71b98e..4d2cdf5b7f 100644 --- a/crates/ui-egui/src/chrome_ui.rs +++ b/crates/ui-egui/src/chrome_ui.rs @@ -75,6 +75,10 @@ pub fn document_sizes(doc: &Document) -> (u64, u64) { /// The status bar text for `key` (Photoshop wording). pub fn status_info_text(doc: &Document, key: &str, tool: &str, profile: &str) -> String { + status_info_text_with_units(doc, key, tool, profile, &Default::default()) +} + +fn status_info_text_with_units(doc: &Document, key: &str, tool: &str, profile: &str, units: &photocraft_engine::prefs::UnitsAndRulers) -> String { let bits = doc.depth.bits(); match key { "sizes" => { @@ -93,10 +97,18 @@ pub fn status_info_text(doc: &Document, key: &str, tool: &str, profile: &str) -> let n = doc.layer_count(); crate::i18n::trn(crate::i18n::current(), n as u64, "{n} Layer", "{n} Layers") } - _ => crate::i18n::fmt( + _ if units.rulers == photocraft_engine::prefs::Unit::Pixels => crate::i18n::fmt( tl!("{w} px x {h} px ({ppi} ppi)"), &[("w", &doc.size.width.to_string()), ("h", &doc.size.height.to_string()), ("ppi", &widgets::fmt_num(doc.resolution_dpi as f64))], ), + _ => { + let dpi = doc.resolution_dpi as f64; + let (w, h) = (doc.size.width as f64, doc.size.height as f64); + crate::i18n::fmt( + tl!("{w} {unit} x {h} {unit} ({ppi} ppi)"), + &[("w", &units.format(w, dpi, w)), ("h", &units.format(h, dpi, h)), ("unit", units.rulers.suffix()), ("ppi", &widgets::fmt_num(dpi))], + ) + } } } @@ -128,7 +140,13 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) { status_message(app, ui, &t); return; }; - let text = status_info_text(&st.doc, &app.ui.chrome.status_info, tl!(app.ui.tool.label()), &profile_name(&st.doc)); + let text = status_info_text_with_units( + &st.doc, + &app.ui.chrome.status_info, + tl!(app.ui.tool.label()), + &profile_name(&st.doc), + &app.session.prefs().units_and_rulers, + ); let mut pct = app.ui.views[i].zoom * 100.0; if widgets::value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", 64.0).changed() { app.ui.views[i].zoom = crate::zoom_levels::clamp(pct / 100.0, app.ui.views[i].doc_size); @@ -143,6 +161,7 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) { icons::paint(ui, r, "chevron-right", 11.0, t.text_dim); let resp = resp.on_hover_text(tl!("Show")); egui::Popup::menu(&resp).show(|ui| { + crate::widgets::style_spectrum_popup_menu(ui); ui.set_min_width(200.0); for (key, label) in STATUS_INFO { let on = app.ui.chrome.status_info == *key; @@ -240,6 +259,65 @@ mod tests { assert!(status_info_text(&d, "profile", "", "sRGB IEC61966-2.1").ends_with("(8bpc)")); } + #[test] + fn status_dimensions_follow_ruler_units_and_document_resolution() { + use photocraft_engine::prefs::{PointSize, Unit, UnitsAndRulers}; + let mut d = doc(); + d.resolution_dpi = 300.0; + for (unit, expected) in [ + (Unit::Pixels, "2400 px x 1500 px (300 ppi)"), + (Unit::Inches, "8 in x 5 in (300 ppi)"), + (Unit::Centimeters, "20.32 cm x 12.7 cm (300 ppi)"), + (Unit::Millimeters, "203.2 mm x 127 mm (300 ppi)"), + (Unit::Points, "576 pt x 360 pt (300 ppi)"), + (Unit::Picas, "48 pica x 30 pica (300 ppi)"), + (Unit::Percent, "100 % x 100 % (300 ppi)"), + ] { + let units = UnitsAndRulers { rulers: unit, ..Default::default() }; + assert_eq!(status_info_text_with_units(&d, "dimensions", "", "", &units), expected); + for (key, _) in STATUS_INFO.iter().filter(|(key, _)| *key != "dimensions") { + assert_eq!(status_info_text_with_units(&d, key, "Brush Tool", "sRGB", &units), status_info_text(&d, key, "Brush Tool", "sRGB")); + } + } + d.resolution_dpi = 240.0; + let units = UnitsAndRulers { rulers: Unit::Points, point_size: PointSize::Traditional, ..Default::default() }; + assert_eq!(status_info_text_with_units(&d, "dimensions", "", "", &units), "722.7 pt x 451.7 pt (240 ppi)"); + } + + #[test] + fn status_bar_updates_dimensions_after_a_ruler_preference_change() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut failures = Vec::new(); + for theme in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::ProMedium] { + for width in [800.0, 1200.0] { + let builder = Harness::builder().with_size(vec2(width, 600.0)).with_max_steps(64); + let mut h = builder.build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, theme); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("prefs.set", json!({"path": "interface.theme", "value": theme.id()})).unwrap(); + app.ui.theme = theme; + app + }); + h.state_mut().run("file.new", json!({"width": 1920, "height": 1080, "resolution": 72})).unwrap(); + h.state_mut().sync_views(); + h.run_steps(4); + assert_eq!(h.state().ui.theme, theme, "the fixture renders the requested theme"); + assert!(h.query_by_label("1920 px x 1080 px (72 ppi)").is_some()); + h.state_mut().run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "inches"})).unwrap(); + h.run_steps(4); + if h.query_by_label("26.667 in x 15 in (72 ppi)").is_none() || h.query_by_label("1920 px x 1080 px (72 ppi)").is_some() { + failures.push(format!("{theme:?}, width={width}: inches")); + } + h.state_mut().run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "cm"})).unwrap(); + h.run_steps(4); + if h.query_by_label("67.73 cm x 38.1 cm (72 ppi)").is_none() { + failures.push(format!("{theme:?}, width={width}: centimeters")); + } + } + } + assert!(failures.is_empty(), "the status bar must follow the preference immediately: {failures:?}"); + } + #[test] fn status_profile_describes_rgb_gray_and_custom_profiles_without_mutating_the_document() { let mut rgb = doc(); diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index 04d303f4b7..c781b6b750 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -235,16 +235,63 @@ const REQUEST: &str = "__pickColor"; /// swatch, Edit › Stroke's swatch). The dialog loop opens it ([`open_requested`]), since that /// needs the dialog's id. pub fn request(f: &mut Map, title: &str) { - f.insert(REQUEST.into(), json!(title)); + request_field(f, "color", title); } -/// Opens the Color Picker dialog `id`'s body asked for ([`request`]), on its colour: OK sets that -/// dialog's `color` field (not the foreground), Cancel leaves it as it was. +/// [`request`] for another `"#rrggbb"` field of the dialog: a top-level key (`vineColor`), or a +/// JSON pointer into the fields (`/values/guidesGridAndSlices/guideColor`). +pub fn request_field(f: &mut Map, field: &str, title: &str) { + f.insert(REQUEST.into(), json!({ "title": title, "field": field })); +} + +/// The picker's title for the colour called `name`: "Color Picker (Vine Color)". +pub fn title_for(name: &str) -> String { + crate::i18n::fmt(tl!("Color Picker ({name})"), &[("name", name)]) +} + +/// The `"#rrggbb"` at `field` of `f` (a key or a JSON pointer). +fn field_value<'a>(f: &'a Map, field: &str) -> Option<&'a str> { + match field.strip_prefix('/') { + Some(rest) => { + let (first, tail) = rest.split_once('/').unwrap_or((rest, "")); + let v = f.get(first)?; + if tail.is_empty() { v.as_str() } else { v.pointer(&format!("/{tail}"))?.as_str() } + } + None => f.get(field)?.as_str(), + } +} + +/// Sets `field` of `f` (a key or a JSON pointer to an existing value) to `value`. +fn set_field(f: &mut Map, field: &str, value: Value) -> Result<(), String> { + match field.strip_prefix('/') { + Some(rest) => { + let (first, tail) = rest.split_once('/').unwrap_or((rest, "")); + let v = f.get_mut(first).ok_or("the dialog has no such colour")?; + let slot = if tail.is_empty() { v } else { v.pointer_mut(&format!("/{tail}")).ok_or("the dialog has no such colour")? }; + *slot = value; + } + None => { + f.insert(field.into(), value); + } + } + Ok(()) +} + +/// Opens the Color Picker dialog `id`'s body asked for ([`request`], [`request_field`]), on its +/// colour: OK sets that field of the dialog (not the foreground), Cancel leaves it as it was. pub fn open_requested(app: &mut PhotocraftApp, id: u64) { let Some(d) = app.ui.dialog_mut(id) else { return }; - let Some(title) = d.fields.remove(REQUEST).and_then(|v| v.as_str().map(str::to_string)) else { return }; - let rgb = d.fields.get("color").and_then(Value::as_str).and_then(parse_hex).unwrap_or([0.0; 3]); - open_for_field(app, &title, rgb, id, "color"); + let (title, field) = match d.fields.remove(REQUEST) { + Some(Value::String(title)) => (title, "color".to_string()), + Some(Value::Object(o)) => { + let get = |k: &str| o.get(k).and_then(Value::as_str).map(str::to_string); + let (Some(title), Some(field)) = (get("title"), get("field")) else { return }; + (title, field) + } + _ => return, + }; + let rgb = field_value(&d.fields, &field).and_then(parse_hex).unwrap_or([0.0; 3]); + open_for_field(app, &title, rgb, id, &field); } pub fn owns(f: &Map) -> bool { @@ -659,13 +706,22 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1800.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); + setup(&mut app); + app + }); + h.run_steps(6); + h +} + +/// Clicks the swatch called `label` (a colour button), answers the Color Picker it opens with +/// [`PICKED`], and returns the picker's title. Panics when no Color Picker opens. +fn pick(h: &mut Harness<'_, PhotocraftApp>, label: &str) -> String { + // Panels scroll: bring the swatch into view first. + h.get_by_role_and_label(Role::ColorWell, label).scroll_to_me(); + h.run_steps(2); + h.get_by_role_and_label(Role::ColorWell, label).click(); + answer(h, label) +} + +/// Answers the Color Picker a swatch click just opened with [`PICKED`]; returns its title. +fn answer(h: &mut Harness<'_, PhotocraftApp>, label: &str) -> String { + let fg = h.state().session.tools.foreground; + h.run_steps(4); + let id = crate::color_picker_ui::top(h.state()).unwrap_or_else(|| panic!("the {label} swatch opens the Color Picker")); + assert!(!egui::Popup::is_any_open(&h.ctx), "{label}: no egui colour popup"); + let title = h.state().ui.dialogs.iter().find(|d| d.id == id).and_then(|d| d.fields.get("__label")).and_then(Value::as_str).unwrap_or("").to_string(); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("color".into(), json!(PICKED)); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + h.run_steps(3); + assert!(crate::color_picker_ui::top(h.state()).is_none(), "{label}: OK closes the picker"); + assert_eq!(h.state().session.tools.foreground, fg, "{label}: the foreground is untouched"); + title +} + +fn close(a: [f32; 3], b: [f32; 3]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1.0 / 255.0) +} + +#[test] +fn filter_dialog_colours_open_the_color_picker() { + for (cmd, key, label) in [("filter.other.colorToAlpha", "color", "Color"), ("filter.render.pictureFrame", "vineColor", "Vine Color")] { + let mut h = harness(|_| {}); + let ctx = h.ctx.clone(); + let id = crate::menus::invoke(h.state_mut(), &ctx, cmd, json!({})).unwrap()["dialog"].as_u64().unwrap(); + h.run_steps(4); + let title = pick(&mut h, label); + assert_eq!(title, format!("Color Picker ({label})"), "{cmd}"); + let d = h.state().ui.dialogs.iter().find(|d| d.id == id).expect("the filter dialog stays open"); + assert_eq!(d.fields.get(key), Some(&json!(PICKED)), "{cmd}: {key}"); + } +} + +#[test] +fn preferences_colours_open_the_color_picker() { + let mut h = harness(|_| {}); + let id = crate::prefs_ui::open_preferences(h.state_mut(), "guidesGridAndSlices"); + h.run_steps(4); + let values = |h: &Harness<'_, PhotocraftApp>| h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields["values"]["guidesGridAndSlices"].clone(); + let before = values(&h); + h.get_all_by_role(Role::ColorWell).next().expect("a colour swatch").click(); + answer(&mut h, "the first Preferences colour"); + let after = values(&h); + let changed: Vec<&str> = after.as_object().unwrap().iter().filter(|(k, v)| before.get(k.as_str()) != Some(v)).map(|(k, _)| k.as_str()).collect(); + assert_eq!(changed.len(), 1, "one colour changed: {changed:?}"); + assert_eq!(after[changed[0]], json!(PICKED)); +} + +#[test] +fn note_and_count_colours_open_the_color_picker() { + let mut h = harness(|app| app.ui.tool = Tool::Note); + pick(&mut h, "Color"); + assert!(close(h.state().ui.analysis.note_color, PICKED_RGB), "note colour {:?}", h.state().ui.analysis.note_color); + + let mut h = harness(|app| { + app.ui.tool = Tool::Count; + app.run("count.newGroup", json!({})).unwrap(); + }); + pick(&mut h, "Color"); + let st = h.state().session.active().unwrap(); + let m = &st.doc.measurement; + let g = &m.count_groups[m.active_count_group]; + assert!(close(g.color.to_rgb(), PICKED_RGB), "count group colour {:?}", g.color.to_rgb()); +} + +#[test] +fn an_artboard_background_opens_the_color_picker() { + let mut h = harness(|app| { + app.run("layer.new.artboard", json!({"x": 10, "y": 10, "width": 100, "height": 80})).unwrap(); + app.run("layer.artboard.set", json!({"background": "custom", "color": "#ff0000"})).unwrap(); + }); + pick(&mut h, "Color"); + let st = h.state().session.active().unwrap(); + let l = st.doc.layer(st.active_layer.unwrap()).unwrap(); + let photocraft_doc::LayerContent::Group(g) = &l.content else { panic!("an artboard") }; + let bg = g.artboard.as_ref().expect("an artboard").background; + let photocraft_doc::ArtboardBackground::Custom(c) = bg else { panic!("custom background: {bg:?}") }; + assert!(close(c.to_rgb(), PICKED_RGB), "artboard background {:?}", c.to_rgb()); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index cc3a0a6fa3..f84969e9a5 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -13,7 +13,7 @@ //! - `ui.dialog.open {kind, fields?}` (kinds: newDocument, about, layerStyle {effect?}, colorPicker {target: foreground|background}, command {command}) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog, wait?}` / `ui.dialog.cancel {dialog}` //! - `ui.dialog.apply {dialog}`: commit Preferences changes without closing the dialog //! - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows -//! - `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], modifiers?, button?}`: drive the active tool in document coordinates (`button: "secondary"` opens the tool's canvas context menu or Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase) +//! - `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], modifiers?, button?}`: drive the active tool in document coordinates (`button: "secondary"` opens the tool's canvas context menu (the pasteboard colour menu outside the image) or Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase) //! - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points //! (`count` at most [`MAX_CLICKS`]) //! - `ui.key {key, command?, shift?, alt?, ctrl?}` / `ui.type {text}`: synthetic keyboard input @@ -41,6 +41,19 @@ use crate::state::{DialogKind, Tool}; pub type ControlResponse = Value; +/// The control server's per-request reply deadline. +pub const REPLY_DEADLINE: std::time::Duration = std::time::Duration::from_secs(60); + +/// The error both timeout sites return when a request's [`REPLY_DEADLINE`] passes: the +/// transport thread waiting for a reply, and the UI thread rejecting a request it is only +/// now draining. Built from the same constant, so the text cannot drift from the deadline. +pub fn timeout_error() -> String { + format!( + "timeout: no reply in {}s — the command may still be running, or the app was not drawing frames (display asleep, window fully occluded, or a long operation blocked the frame loop)", + REPLY_DEADLINE.as_secs() + ) +} + pub struct ControlRequest { pub method: String, pub params: Value, @@ -218,6 +231,26 @@ fn screen_point(app: &PhotocraftApp, x: f64, y: f64) -> [f32; 2] { [p.x, p.y] } +/// Modifier flags of a request, top-level or grouped under `modifiers`. +/// +/// Off the Mac, Ctrl is the shortcut key, so egui wants `ctrl` and `command` to carry the same +/// value (`egui::Modifiers::command`: "On Windows and Linux, set this to the same value as +/// `ctrl`") and shortcuts are matched on `command`. A real Ctrl press arrives with both set, so +/// a request that sets only one of them gets both here and reaches the same shortcuts. On the Mac +/// they are different keys and stay apart: `command` is the ⌘ key, `ctrl` the Control key. +fn modifiers_from(p: &Value) -> egui::Modifiers { + let m = p.get("modifiers").unwrap_or(p); + let flag = |k: &str| m.get(k).and_then(Value::as_bool).unwrap_or(false); + let (ctrl, command) = (flag("ctrl"), flag("command")); + egui::Modifiers { + shift: flag("shift"), + alt: flag("alt"), + command: if cfg!(target_os = "macos") { command } else { command || ctrl }, + mac_cmd: cfg!(target_os = "macos") && command, + ctrl: if cfg!(target_os = "macos") { ctrl } else { command || ctrl }, + } +} + /// The `dialog` id of the shell's own dialog (`workspace_ui`) in `ui.inspect` and `ui.dialog.*`. const SHELL_DIALOG: &str = "shell"; @@ -243,8 +276,10 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) if !crate::canvas_tool_menu::available(app, menu, id) { return err("context action is unavailable"); } + // A pasteboard row changes a preference: the policy sees the `prefs.set` it runs. + let (command, params) = crate::canvas_tool_menu::engine_call(menu, id); if let Some(authorize) = app.services.automation_command.as_ref() - && let Err(error) = authorize(id, &json!({})) + && let Err(error) = authorize(&command, ¶ms) { return err(error); } @@ -521,10 +556,12 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) if let Some(m) = mask_target { app.ui.mask_target = m; app.ui.vector_mask_target &= !m; + app.sync_mask_targets(); } if let Some(m) = vector_mask_target { app.ui.vector_mask_target = m; app.ui.mask_target &= !m; + app.sync_mask_targets(); } // Selection tools' options-bar mode: 0 New, 1 Add, 2 Subtract, 3 Intersect. if let Some(m) = selection_mode { @@ -745,22 +782,14 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } "ui.pointer" => { let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") }; - // Modifier flags may be top-level or grouped under "modifiers". - let m = p.get("modifiers").unwrap_or(p); - let flag = |k: &str| m.get(k).and_then(Value::as_bool).unwrap_or(false); - let mods = egui::Modifiers { - shift: flag("shift"), - alt: flag("alt"), - command: flag("command"), - mac_cmd: cfg!(target_os = "macos") && flag("command"), - ctrl: flag("ctrl"), - }; + let mods = modifiers_from(p); if let Some(t) = s("tool").and_then(Tool::from_name) { app.ui.tool = t; } // Space held: the Crop tool moves the frame being drawn, a marquee, lasso or shape - // being drawn moves instead of growing (hold_keys.rs). - let space = flag("space"); + // being drawn moves instead of growing (hold_keys.rs). Like a modifier flag, it may be + // top-level or grouped under "modifiers". + let space = p.get("modifiers").unwrap_or(p).get("space").and_then(Value::as_bool).unwrap_or(false); crate::crop_ui::set_space(app, space); for e in events { let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0); @@ -810,6 +839,12 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } continue; } + if crate::canvas_tool_menu::pasteboard_at(app, app.ui.tool, [x, y]) { + if down { + crate::canvas_tool_menu::open_pasteboard(app, app.ui.tool, screen_point(app, x, y)); + } + continue; + } if crate::canvas_tool_menu::applies(app.ui.tool) { if down { crate::canvas_tool_menu::open(app, app.ui.tool, screen_point(app, x, y)); @@ -830,6 +865,19 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) if let Some(d) = app.drag.as_mut().filter(|d| crate::hold_keys::repositions(d.tool)) { d.reposition = space; } + // A Crop drag in the default mode turns and pans the view with the image: its + // points are read as the view showed them at the press (`crop_mode`). + let ev = match ev { + ToolEvent::Move { x, y, pressure } => { + let [x, y] = crate::crop_mode::control_point(app, [x, y]); + ToolEvent::Move { x, y, pressure } + } + ToolEvent::Up { x, y } => { + let [x, y] = crate::crop_mode::control_point(app, [x, y]); + ToolEvent::Up { x, y } + } + down => down, + }; tool_event(app, ev, mods); } app.stylus.feed.set(None); @@ -860,16 +908,7 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) "ui.key" => { let Some(name) = s("key") else { return err("missing `key`") }; let Some(key) = egui::Key::from_name(name) else { return err(format!("unknown key `{name}`")) }; - // Modifier flags may be top-level or grouped under "modifiers". - let m = p.get("modifiers").unwrap_or(p); - let flag = |k: &str| m.get(k).and_then(Value::as_bool).unwrap_or(false); - let modifiers = egui::Modifiers { - command: flag("command"), - mac_cmd: cfg!(target_os = "macos") && flag("command"), - shift: flag("shift"), - alt: flag("alt"), - ctrl: flag("ctrl"), - }; + let modifiers = modifiers_from(p); app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); ctx.request_repaint(); @@ -987,6 +1026,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { json!({ "pos": menu.pos, "tool": menu.tool, + "pasteboard": menu.pasteboard, "entries": crate::canvas_tool_menu::rows(menu).iter().map(|row| match row { Some((label, id)) => json!({"label": label, "id": id, "enabled": crate::canvas_tool_menu::entry_enabled(app, menu, id)}), None => json!({"separator": true}), @@ -1238,13 +1278,25 @@ mod tests { retry.deadline = Some(Instant::now() + Duration::from_secs(60)); tx.send(retry).unwrap(); app.drain_control(&egui::Context::default()); - assert_eq!(expired_reply.try_recv().unwrap(), json!({"ok": false, "error": "timeout"})); + assert_eq!(expired_reply.try_recv().unwrap(), json!({"ok": false, "error": super::timeout_error()})); assert_eq!(retry_reply.try_recv().unwrap()["ok"], true); let after = app.session.active().unwrap(); assert_eq!(after.doc.layers.len(), layers + 1, "only the live retry adds a layer"); assert_eq!(after.history.past_len(), steps + 1, "the expired edit adds no undo step"); } + #[test] + fn timeout_errors_name_the_likely_causes() { + // #2489: the reply must tell "wait" from "check the machine", and its number + // must come from the deadline it reports. + let error = super::timeout_error(); + assert!(error.starts_with("timeout"), "machines match on the prefix"); + assert!(error.contains(&format!("{}s", super::REPLY_DEADLINE.as_secs())), "the text matches the deadline: {error}"); + assert!(error.contains("may still be running"), "the wait-it-out cause"); + assert!(error.contains("display asleep"), "the check-the-display cause"); + assert!(error.contains("frame"), "the frame-loop cause"); + } + #[test] fn deadline_free_requests_run_even_after_the_receiver_is_dropped() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -1774,6 +1826,9 @@ mod tests { let r = call(&mut app, &ctx, "app.save", json!({"path": "out.png"})); assert_eq!(r["result"], json!({"path": "out.png", "warnings": ["Layers were flattened"]}), "{r}"); assert_eq!(*written.borrow(), vec!["out.png".to_string()]); + // A flat export is a copy: the document keeps its name and file (#2579). + let st = app.session.active().unwrap(); + assert_eq!((st.doc.name.as_str(), st.path.as_deref()), ("warn.psd", Some("in/warn.psd"))); // Without `path`, only a layered file is written back, like File › Save (#416). call(&mut app, &ctx, "app.open", json!({"path": "in/flat.jpg"})); let r = call(&mut app, &ctx, "app.save", json!({})); @@ -1996,4 +2051,39 @@ mod tests { app.synthetic.clear(); } } + + /// A key event's modifiers, as `ui.key` queued them. + fn key_modifiers(app: &mut PhotocraftApp, ctx: &egui::Context, params: Value) -> egui::Modifiers { + app.synthetic.clear(); + let (req, _rx) = ControlRequest::new("ui.key", params.clone()); + assert!(matches!(handle(app, ctx, &req), Outcome::AfterInput), "{params}"); + match app.synthetic.first() { + Some(egui::Event::Key { modifiers, .. }) => *modifiers, + other => panic!("{params}: expected a key event, got {other:?}"), + } + } + + #[test] + fn ui_key_ctrl_reaches_shortcuts_like_a_real_ctrl_press() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let ctrl = key_modifiers(&mut app, &ctx, json!({"key":"N","ctrl":true})); + let command = key_modifiers(&mut app, &ctx, json!({"key":"N","command":true})); + let grouped = key_modifiers(&mut app, &ctx, json!({"key":"N","modifiers":{"ctrl":true}})); + assert_eq!(ctrl, grouped, "a grouped flag must match the top-level one"); + if cfg!(target_os = "macos") { + // Two different keys: Control is not ⌘, and only ⌘ matches a shortcut. + assert!(ctrl.ctrl && !ctrl.command && !ctrl.mac_cmd, "{ctrl:?}"); + assert!(command.command && command.mac_cmd && !command.ctrl, "{command:?}"); + } else { + // One key under two names, and shortcuts are matched on `command`, so asking for + // either must reach them: Ctrl+N has to open New Document, not do nothing. + assert_eq!(ctrl, command, "ctrl and command are the same key off the Mac"); + assert!(ctrl.command && ctrl.ctrl && !ctrl.mac_cmd, "{ctrl:?}"); + } + let plain = key_modifiers(&mut app, &ctx, json!({"key":"N"})); + assert_eq!(plain, egui::Modifiers::default(), "{plain:?}"); + let shift_alt = key_modifiers(&mut app, &ctx, json!({"key":"N","shift":true,"alt":true})); + assert!(shift_alt.shift && shift_alt.alt && !shift_alt.command && !shift_alt.ctrl, "{shift_alt:?}"); + } } diff --git a/crates/ui-egui/src/crop_mode.rs b/crates/ui-egui/src/crop_mode.rs new file mode 100644 index 0000000000..5250d1403b --- /dev/null +++ b/crates/ui-egui/src/crop_mode.rs @@ -0,0 +1,772 @@ +//! The Crop tool's two modes (Photoshop's gear menu, "Set additional Crop options", #1919). +//! +//! - **Default mode** (Photoshop's default, Use Classic Mode off): the crop box stays upright on +//! screen and the image turns behind it. A drag outside the box turns the image about the box's +//! centre (⇧ in 15° steps; the readout beside the pointer is the image's turn, clockwise +//! positive), a drag inside moves the image under the box (the box stays put), the arrow keys +//! nudge the image, and the handles resize the box on screen as usual. With **Auto Center +//! Preview** on (the default), a box being resized keeps its centre where it is on screen (also +//! after a Space pan) while the handle follows the pointer, a drawn box moves to the middle of +//! the canvas on release, and X re-centres it. A drag inside never re-centres the box +//! (Photoshop 25.1). +//! - **Classic Mode** (P toggles it while the Crop tool shows a box): the box moves and turns over +//! a fixed image (`crop_ui`). Show Cropped Area and Auto Center Preview are greyed out in it, and +//! the area outside the box shows whatever Show Cropped Area says (Photoshop 25.1). +//! +//! The crop model is the same in both: `UiState::crop_rect` is the frame in document px and +//! `UiState::crop_angle` its turn, so ↵ commits the same `image.crop {…, angle}` (one undo step) +//! whichever mode drew it. Default mode only changes the view: while the Crop tool shows a frame, +//! the view's camera rotation is the frame's turn undone (`View::rotation = -crop_angle`), so the +//! box shows upright. In Classic Mode the camera is upright (rotation 0) and the frame shows with +//! its own turn on the image. As in Photoshop 25.1, picking the tool in either mode turns a Rotate +//! View (R) turn upright at once, about the view's centre (the frame stays on the same image +//! area), and the view stays upright after ↵ or Esc and when P switches modes (the R turn isn't +//! restored). +//! +//! Both options live in `ToolOptions::crop_shield` (`classic_mode`, `auto_center_preview`), which +//! `ui.inspect` reports and `ui.set {cropShield: {...}}` patches. + +use egui::{Rect, vec2}; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::state::Tool; + +/// Use Classic Mode is on: the box turns and moves over the image. +pub fn classic(app: &PhotocraftApp) -> bool { + app.ui.tool_options.crop_shield.classic_mode +} + +fn finite_frame(app: &PhotocraftApp) -> Option<[f64; 4]> { + app.ui.crop_rect.filter(|r| r.iter().all(|v| v.is_finite())) +} + +/// The Crop tool drives the view: it shows a frame (either mode; [`sync`]). +pub fn turns_view(app: &PhotocraftApp) -> bool { + app.ui.tool == Tool::Crop && finite_frame(app).is_some() && app.session.active().is_some() +} + +/// Gestures move and turn the image rather than the box (default mode with the Crop tool). +pub fn moves_image(app: &PhotocraftApp) -> bool { + app.ui.tool == Tool::Crop && !classic(app) +} + +/// The view centre that keeps document point `c` where it is on screen when the camera rotation +/// goes from `from` to `to` degrees (zoom and flip unchanged). `center` itself when anything isn't +/// finite. +pub fn pivot_center(center: [f32; 2], c: [f64; 2], from: f32, to: f32, flip: bool) -> [f32; 2] { + let a = ViewXform { rect: Rect::ZERO, zoom: 1.0, center, flip, rotation: from, aspect: 1.0 }; + // c's offset from the view centre on screen (at zoom 1), then the same offset in the new view. + let off = a.to_screen(c[0] as f32, c[1] as f32).to_vec2(); + let u = ViewXform { rotation: to, ..a }.unmap_vec(off); + let n = [c[0] as f32 - u.x, c[1] as f32 - u.y]; + if n.iter().all(|v| v.is_finite()) { n } else { center } +} + +/// The active document's view rotation in degrees (0 without a view). +pub fn view_rotation(app: &PhotocraftApp) -> f32 { + app.session.active_index().and_then(|i| app.ui.views.get(i)).map_or(0.0, |v| v.rotation) +} + +/// Turns the active view so the frame shows upright while default mode drives it, pivoting about +/// document point `pivot` so that point stays put on screen (the view's own centre when `None`: +/// picking the tool, a commit or a cancel only straighten the view). +pub fn sync(app: &mut PhotocraftApp, pivot: Option<[f64; 2]>) { + if !turns_view(app) { + return; + } + let Some(i) = app.session.active_index() else { return }; + // Classic Mode: an upright camera, the frame turned on it. Default mode: the frame upright. + let want = if classic(app) { 0.0 } else { crate::rotate_view::wrap_deg(-crate::crop_ui::angle(app) as f32) }; + let flip = app.ui.view.flip_horizontal; + let Some(v) = app.ui.views.get_mut(i) else { return }; + if v.rotation != want { + if let Some(p) = pivot.filter(|p| p.iter().all(|v| v.is_finite())) { + v.center = pivot_center(v.center, p, v.rotation, want, flip); + } + v.rotation = want; + } +} + +/// [`sync`] about the frame's centre: the box stays put on screen while the image turns. +pub fn sync_frame(app: &mut PhotocraftApp) { + let c = finite_frame(app).map(crate::crop_ui::center); + sync(app, c); +} + +/// A commit or cancel in default mode: the view is left upright (Photoshop 25.1 doesn't bring +/// back a Rotate View turn). +pub fn upright(app: &mut PhotocraftApp) { + if app.ui.tool != Tool::Crop { + return; + } + if let Some(v) = app.session.active_index().and_then(|i| app.ui.views.get_mut(i)) { + v.rotation = 0.0; + } +} + +/// The image's turn the default mode shows beside the pointer: clockwise on screen is positive, +/// the frame's angle on the image undone. +pub fn image_turn(frame_angle: f64) -> f64 { + let a = crate::crop_ui::normalized_angle(-frame_angle); + // Never "-0.0°". + if a == 0.0 { 0.0 } else { a } +} + +/// A live-centred resize ([`live_resize`]): the frame `rect` and handle (`hx`, `hy`) at the press, +/// grabbed at document point `start`; the pointer now at `q` (in the press view); the frame's turn +/// `deg`, the aspect `ratio` to hold and ⌥ (resize about the centre). +#[derive(Clone, Copy, Debug)] +pub struct LiveResize { + pub rect: [f64; 4], + pub deg: f64, + pub hx: i8, + pub hy: i8, + pub start: [f64; 2], + pub q: [f64; 2], + pub ratio: Option, + pub alt: bool, +} + +/// Auto Center Preview during a handle drag: the box stays centred on screen where it was at the +/// press and grows or shrinks symmetrically there, so the handle stays under the pointer, while on +/// the image the opposite edge stays put (the centre with ⌥), as in a plain resize. Everything is +/// computed from the press, so the view following the frame can't feed back into the drag. +pub fn live_resize(app: &mut PhotocraftApp, g: LiveResize, press: PressView) { + let LiveResize { rect, deg, hx, hy, start, q, ratio, alt } = g; + let c0 = crate::crop_ui::center(rect); + let (w0, h0) = (rect[2] - rect[0], rect[3] - rect[1]); + // The pointer's offset from where it grabbed the handle, in the frame's own axes; the handle + // moves by it on screen, so each half extent grows by it. + let d = crate::crop_ui::turn([q[0] - start[0], q[1] - start[1]], [0.0, 0.0], -deg); + let local = [-w0 / 2.0, -h0 / 2.0, w0 / 2.0, h0 / 2.0]; + let n = crate::crop_ui::resized(local, hx, hy, d, ratio, true); + let (w1, h1) = (n[2] - n[0], n[3] - n[1]); + // On the image the opposite edge stays put (with ⌥ the centre), so the centre moves. + let shift = if alt { [0.0, 0.0] } else { [f64::from(hx) * (w1 - w0) / 2.0, f64::from(hy) * (h1 - h0) / 2.0] }; + let s = crate::crop_ui::turn(shift, [0.0, 0.0], deg); + let c1 = [c0[0] + s[0], c0[1] + s[1]]; + let f = [c1[0] - w1 / 2.0, c1[1] - h1 / 2.0, c1[0] + w1 / 2.0, c1[1] + h1 / 2.0]; + if !f.iter().all(|v| v.is_finite()) { + return; + } + app.ui.crop_rect = Some(f); + // The view follows the centre, so the box stays where it was on screen. + if let Some(v) = app.session.active_index().and_then(|i| app.ui.views.get_mut(i)) { + let c = [press.center[0] + s[0] as f32, press.center[1] + s[1] as f32]; + if c.iter().all(|x| x.is_finite()) { + v.center = c; + } + } +} + +/// Auto Center Preview while a handle is dragged in default mode. +pub fn live_center(app: &PhotocraftApp) -> bool { + moves_image(app) && app.ui.tool_options.crop_shield.auto_center_preview +} + +/// Moves the image under the box by document vector `d` as the view shows it: the frame moves by +/// `-d` in the document and the view with it, so the box stays put on screen. +pub fn shift_image(app: &mut PhotocraftApp, d: [f64; 2]) { + let Some(r) = finite_frame(app) else { return }; + if !(d[0].is_finite() && d[1].is_finite()) { + return; + } + let n = [r[0] - d[0], r[1] - d[1], r[2] - d[0], r[3] - d[1]]; + if !n.iter().all(|v| v.is_finite()) { + return; + } + app.ui.crop_rect = Some(n); + if let Some(v) = app.session.active_index().and_then(|i| app.ui.views.get_mut(i)) { + let c = [v.center[0] - d[0] as f32, v.center[1] - d[1] as f32]; + if c.iter().all(|x| x.is_finite()) { + v.center = c; + } + } +} + +/// The active view's camera (rotation and centre) when a default-mode drag began: the drag reads +/// its points in it, so it doesn't matter that the view turns and pans with the image meanwhile. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PressView { + rotation: f32, + center: [f32; 2], +} + +/// The active view's camera now. +pub fn press_view(app: &PhotocraftApp) -> Option { + app.session.active_index().and_then(|i| app.ui.views.get(i)).map(|v| PressView { rotation: v.rotation, center: v.center }) +} + +/// Document point `p` as seen through camera `from` re-read through camera `to`: the point that +/// `to` shows at the screen position where `from` shows `p` (zoom and flip are the same). `p` +/// itself when anything isn't finite. +fn remap(p: [f64; 2], from: PressView, to: PressView, flip: bool) -> [f64; 2] { + let at = |v: PressView| ViewXform { rect: Rect::ZERO, zoom: 1.0, center: v.center, flip, rotation: v.rotation, aspect: 1.0 }; + let q = at(to).to_doc(at(from).to_screen(p[0] as f32, p[1] as f32)); + if q.iter().all(|v| v.is_finite()) { q } else { p } +} + +/// A pointer point `p` the canvas mapped through the view as it is now, read in the press-time +/// view `press` (unchanged without one). +pub fn to_press(app: &PhotocraftApp, press: Option, p: [f64; 2]) -> [f64; 2] { + match (press, press_view(app)) { + (Some(press), Some(now)) => remap(p, now, press, app.ui.view.flip_horizontal), + _ => p, + } +} + +/// The control channel's `ui.pointer` gives document points as the view showed them at the press +/// (it can't know how the view turns and pans with the image): during a default-mode drag, the +/// point the canvas would report for the same screen position now. +pub fn control_point(app: &PhotocraftApp, p: [f64; 2]) -> [f64; 2] { + let press = match app.crop.drag { + Some( + crate::crop_ui::CropDrag::MoveImage { view, .. } | crate::crop_ui::CropDrag::TurnImage { view, .. } | crate::crop_ui::CropDrag::Resize { view, .. }, + ) => view, + _ => None, + }; + match (press, press_view(app)) { + (Some(press), Some(now)) if app.ui.tool == Tool::Crop => remap(p, press, now, app.ui.view.flip_horizontal), + _ => p, + } +} + +/// A default-mode drag inside the box: the image has moved by document vector `d` (in the press +/// view) since the press, when the frame was `rect` and the view `press`. The frame moves by `-d` +/// on the image and the view's centre with it, so the box stays put on screen. +pub fn offset_image(app: &mut PhotocraftApp, rect: [f64; 4], press: Option, d: [f64; 2]) { + let n = [rect[0] - d[0], rect[1] - d[1], rect[2] - d[0], rect[3] - d[1]]; + if !n.iter().all(|v| v.is_finite()) { + return; + } + app.ui.crop_rect = Some(n); + let (Some(press), Some(v)) = (press, app.session.active_index().and_then(|i| app.ui.views.get_mut(i))) else { return }; + let c = [press.center[0] - d[0] as f32, press.center[1] - d[1] as f32]; + if c.iter().all(|x| x.is_finite()) { + v.center = c; + } +} + +/// A screen vector `s` (points, at the current zoom ignored) as a document vector of the same +/// length: undoes the view's flip and rotation. +pub fn screen_to_doc_vec(app: &PhotocraftApp, s: [f64; 2]) -> [f64; 2] { + let xf = ViewXform { rect: Rect::ZERO, zoom: 1.0, center: [0.0, 0.0], flip: app.ui.view.flip_horizontal, rotation: view_rotation(app), aspect: 1.0 }; + let u = xf.unmap_vec(vec2(s[0] as f32, s[1] as f32)); + [f64::from(u.x), f64::from(u.y)] +} + +/// Auto Center Preview: in default mode, pans the view so the box sits in the middle of the canvas +/// (the view turns about its centre, so the box stays upright). +pub fn auto_center(app: &mut PhotocraftApp) { + if !turns_view(app) || classic(app) || !app.ui.tool_options.crop_shield.auto_center_preview { + return; + } + let Some(c) = finite_frame(app).map(crate::crop_ui::center) else { return }; + let c = [c[0] as f32, c[1] as f32]; + if let (Some(v), true) = (app.session.active_index().and_then(|i| app.ui.views.get_mut(i)), c.iter().all(|x| x.is_finite())) { + v.center = c; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::canvas::{ToolEvent, tool_event}; + use crate::crop_ui::{CropDrag, corners}; + use egui::{Modifiers, Pos2, pos2}; + use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; + use serde_json::json; + + const NONE: Modifiers = Modifiers::NONE; + + /// A 200 × 100 document in a 800 × 600 canvas at 100 %, centred, the Crop tool in default mode. + fn app() -> PhotocraftApp { + let doc = Document::with_background("crop", Size::new(200, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.sync_views(); + app.ui.tool = Tool::Crop; + app.ui.extras.snap = false; + app.ui.extras.rulers = false; + app.ui.view.show.smart_guides = false; + app.last_canvas_rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let v = &mut app.ui.views[0]; + (v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [100.0, 50.0], false, 0.0); + assert!(!classic(&app), "Photoshop's default mode is the default"); + app + } + + fn xf(app: &PhotocraftApp) -> ViewXform { + ViewXform::active(app).unwrap() + } + + /// A drag through screen points, each mapped through the view as it is at that moment (as the + /// canvas does every frame). + fn screen_drag(app: &mut PhotocraftApp, pts: &[Pos2], mods: Modifiers) { + let at = |app: &PhotocraftApp, p: Pos2| xf(app).to_doc(p); + let d = at(app, pts[0]); + tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: 1.0 }, mods); + for p in &pts[1..] { + let d = at(app, *p); + tool_event(app, ToolEvent::Move { x: d[0], y: d[1], pressure: 1.0 }, mods); + } + let d = at(app, pts[pts.len() - 1]); + tool_event(app, ToolEvent::Up { x: d[0], y: d[1] }, mods); + } + + fn screen_box(app: &PhotocraftApp) -> [Pos2; 4] { + let x = xf(app); + corners(app.ui.crop_rect.unwrap(), crate::crop_ui::angle(app)).map(|p| x.to_screen(p[0] as f32, p[1] as f32)) + } + + fn assert_upright(q: [Pos2; 4]) { + let ok = (q[0].y - q[1].y).abs() < 0.01 && (q[2].y - q[3].y).abs() < 0.01 && (q[0].x - q[3].x).abs() < 0.01 && (q[1].x - q[2].x).abs() < 0.01; + assert!(ok && q[1].x > q[0].x && q[3].y > q[0].y, "not an upright box on screen: {q:?}"); + } + + fn close(a: [Pos2; 4], b: [Pos2; 4]) -> bool { + a.iter().zip(b).all(|(p, q)| p.distance(q) < 0.02) + } + + /// A real (not default) frame [40, 30, 100, 70], centred at (70, 50) = screen (370, 300). + fn with_frame(app: &mut PhotocraftApp) { + crate::crop_ui::ensure_frame(app); + app.ui.crop_rect = Some([40.0, 30.0, 100.0, 70.0]); + app.crop.default_frame = false; + } + + #[test] + fn dragging_outside_turns_the_image_and_the_box_stays_upright() { + let mut app = app(); + with_frame(&mut app); + let before = screen_box(&app); + // From straight right of the box centre to straight below it: a quarter turn clockwise + // on screen, so the image turns clockwise and the frame turns -90° on it. + screen_drag(&mut app, &[pos2(450.0, 300.0), pos2(440.0, 320.0), pos2(370.0, 380.0)], NONE); + assert!((crate::crop_ui::angle(&app) - -90.0).abs() < 0.01, "{}", app.ui.crop_angle); + assert!((view_rotation(&app) - 90.0).abs() < 0.01, "the view turned with it"); + assert_upright(screen_box(&app)); + assert!(close(screen_box(&app), before), "the box doesn't move on screen: {:?} vs {before:?}", screen_box(&app)); + assert_eq!(app.ui.crop_rect, Some([40.0, 30.0, 100.0, 70.0]), "turning keeps the frame"); + // ⇧ steps of 15°, still upright. + let a = 22.0f32.to_radians(); + screen_drag(&mut app, &[pos2(450.0, 300.0), pos2(370.0 + 80.0 * a.cos(), 300.0 + 80.0 * a.sin())], Modifiers::SHIFT); + assert!((crate::crop_ui::angle(&app) - -105.0).abs() < 0.01, "{}", app.ui.crop_angle); + assert_upright(screen_box(&app)); + assert!(close(screen_box(&app), before)); + assert!(crate::crop_ui::turning(&app).is_none()); + } + + /// Even with Auto Center Preview, a drag inside an off-centre box (after a pan, say) leaves the + /// box where it is on screen: only the image moves (Photoshop 25.1). + #[test] + fn a_move_never_recentres_the_box() { + let mut app = app(); + assert!(app.ui.tool_options.crop_shield.auto_center_preview); + with_frame(&mut app); + assert!(mid_of(screen_box(&app)).distance(pos2(370.0, 300.0)) < 0.01); + screen_drag(&mut app, &[pos2(370.0, 300.0), pos2(380.0, 300.0), pos2(390.0, 300.0)], NONE); + assert!(mid_of(screen_box(&app)).distance(pos2(370.0, 300.0)) < 0.01, "{:?}", mid_of(screen_box(&app))); + let r = app.ui.crop_rect.unwrap(); + assert!(r.iter().zip([20.0, 30.0, 80.0, 70.0]).all(|(a, b)| (a - b).abs() < 1e-3), "{r:?}"); + } + + fn mid_of(q: [Pos2; 4]) -> Pos2 { + pos2((q[0].x + q[2].x) / 2.0, (q[0].y + q[2].y) / 2.0) + } + + #[test] + fn dragging_inside_moves_the_image_under_the_box() { + let mut app = app(); + with_frame(&mut app); + let before = screen_box(&app); + screen_drag(&mut app, &[pos2(370.0, 300.0), pos2(380.0, 305.0), pos2(390.0, 310.0)], NONE); + // The image followed the pointer 20, 10 to the right and down: the frame lies that much + // further up-left on it. + let r = app.ui.crop_rect.unwrap(); + assert!(r.iter().zip([20.0, 20.0, 80.0, 60.0]).all(|(a, b)| (a - b).abs() < 1e-3), "{r:?}"); + assert!(close(screen_box(&app), before), "the box stays put on screen"); + // Turned 30° first: the move follows the pointer on screen, along the turned image. + screen_drag(&mut app, &[pos2(450.0, 300.0), pos2(370.0 + 80.0 * 30f32.to_radians().cos(), 300.0 + 80.0 * 30f32.to_radians().sin())], NONE); + assert!((crate::crop_ui::angle(&app) - -30.0).abs() < 0.01); + let (before, c0) = (screen_box(&app), crate::crop_ui::center(app.ui.crop_rect.unwrap())); + let img = |app: &PhotocraftApp| xf(app).to_screen(0.0, 0.0); + let origin = img(&app); + screen_drag(&mut app, &[pos2(370.0, 300.0), pos2(400.0, 300.0)], NONE); + assert!(close(screen_box(&app), before), "box put"); + assert!((img(&app) - origin - vec2(30.0, 0.0)).length() < 0.05, "the image moved 30 right on screen"); + let c1 = crate::crop_ui::center(app.ui.crop_rect.unwrap()); + assert!(((c1[0] - c0[0]).hypot(c1[1] - c0[1]) - 30.0).abs() < 1e-3); + } + + #[test] + fn the_readout_is_the_images_turn_clockwise_positive() { + let mut app = app(); + with_frame(&mut app); + // A 30° clockwise sweep on screen, held mid-drag. + let d = |app: &PhotocraftApp, p: Pos2| xf(app).to_doc(p); + let a = 30f32.to_radians(); + let (p0, p1) = (pos2(450.0, 300.0), pos2(370.0 + 80.0 * a.cos(), 300.0 + 80.0 * a.sin())); + let s = d(&app, p0); + tool_event(&mut app, ToolEvent::Down { x: s[0], y: s[1], pressure: 1.0 }, NONE); + let m = d(&app, p1); + tool_event(&mut app, ToolEvent::Move { x: m[0], y: m[1], pressure: 1.0 }, NONE); + let shown = crate::crop_ui::turning(&app).unwrap(); + assert!((shown - 30.0).abs() < 0.01, "clockwise is positive: {shown}"); + assert!((crate::crop_ui::angle(&app) - -30.0).abs() < 0.01, "the frame turns the other way on the image"); + let m = d(&app, p1); + tool_event(&mut app, ToolEvent::Up { x: m[0], y: m[1] }, NONE); + assert_eq!(image_turn(0.0).to_string(), "0", "never -0"); + // Classic Mode shows the frame's own turn, clockwise positive as before. + let mut app = self::app(); + app.ui.tool_options.crop_shield.classic_mode = true; + with_frame(&mut app); + let s = d(&app, p0); + tool_event(&mut app, ToolEvent::Down { x: s[0], y: s[1], pressure: 1.0 }, NONE); + let m = d(&app, p1); + tool_event(&mut app, ToolEvent::Move { x: m[0], y: m[1], pressure: 1.0 }, NONE); + assert!((crate::crop_ui::turning(&app).unwrap() - 30.0).abs() < 0.01); + } + + #[test] + fn auto_center_keeps_the_box_centred_while_a_handle_is_dragged() { + for auto in [true, false] { + let mut app = app(); + app.ui.tool_options.crop_shield.auto_center_preview = auto; + with_frame(&mut app); + // Turn 90° clockwise on screen first: the frame is at -90° in the image. + screen_drag(&mut app, &[pos2(450.0, 300.0), pos2(370.0, 380.0)], NONE); + let q = screen_box(&app); + assert_upright(q); + let mid = |q: [Pos2; 4]| pos2((q[0].x + q[2].x) / 2.0, (q[0].y + q[2].y) / 2.0); + // The box's right edge on screen, dragged 10 then 20 further right. + let right = pos2(q[1].x, (q[1].y + q[2].y) / 2.0); + assert_eq!(crate::crop_ui::cursor(&app, xf(&app).to_doc(right)), Some(egui::CursorIcon::ResizeHorizontal), "screen-space arrow"); + let at = |app: &PhotocraftApp, p: Pos2| xf(app).to_doc(p); + let s = at(&app, right); + tool_event(&mut app, ToolEvent::Down { x: s[0], y: s[1], pressure: 1.0 }, NONE); + for dx in [10.0, 20.0] { + let m = at(&app, right + vec2(dx, 0.0)); + tool_event(&mut app, ToolEvent::Move { x: m[0], y: m[1], pressure: 1.0 }, NONE); + let n = screen_box(&app); + assert_upright(n); + assert!((n[1].x - (right.x + dx)).abs() < 0.02, "auto={auto} dx={dx}: the handle follows the pointer: {n:?}"); + if auto { + assert!(mid(n).distance(mid(q)) < 0.02, "dx={dx}: live, the box stays centred: {:?} vs {:?}", mid(n), mid(q)); + assert!(((n[1].x - n[0].x) - (q[1].x - q[0].x) - 2.0 * dx).abs() < 0.02, "it grows on both sides"); + } else { + assert!((n[0].x - q[0].x).abs() < 0.02, "the left edge stays put"); + } + assert!(((n[3].y - n[0].y) - (q[3].y - q[0].y)).abs() < 0.02); + } + let m = at(&app, right + vec2(20.0, 0.0)); + tool_event(&mut app, ToolEvent::Up { x: m[0], y: m[1] }, NONE); + let n = screen_box(&app); + if auto { + assert!(mid(n).distance(mid(q)) < 0.05, "on release it stays where its centre was: {:?} vs {:?}", mid(n), mid(q)); + // On the image, the opposite edge stayed put: the frame grew 40 px from it. + let r = app.ui.crop_rect.unwrap(); + assert!(((r[2] - r[0]) - 100.0).abs() < 1e-3 && ((r[3] - r[1]) - 40.0).abs() < 1e-3, "{r:?}"); + let left = corners(r, crate::crop_ui::angle(&app))[0]; + let was = corners([40.0, 30.0, 100.0, 70.0], -90.0)[0]; + assert!((left[0] - was[0]).abs() < 1e-3 && (left[1] - was[1]).abs() < 1e-3, "{left:?} vs {was:?}"); + } else { + assert!(mid(n).distance(pos2(380.0, 300.0)) < 0.05, "{:?}", mid(n)); + } + } + // ⌥: about the centre, and a collapsed drag puts frame and view back. + let mut app = app(); + with_frame(&mut app); + let q = screen_box(&app); + let center = app.ui.views[0].center; + let right = pos2(q[1].x, (q[1].y + q[2].y) / 2.0); + screen_drag(&mut app, &[right, right + vec2(10.0, 0.0)], Modifiers::ALT); + let r = app.ui.crop_rect.unwrap(); + assert!(((r[0] + r[2]) / 2.0 - 70.0).abs() < 1e-3 && ((r[2] - r[0]) - 80.0).abs() < 1e-3, "{r:?}"); + let mut app = self::app(); + with_frame(&mut app); + screen_drag(&mut app, &[right, pos2(370.0, 300.0)], NONE); + assert_eq!(app.ui.crop_rect, Some([40.0, 30.0, 100.0, 70.0]), "too small: the frame is back"); + assert_eq!(app.ui.views[0].center, center, "and the view"); + } + + /// The same frame and angle commit the same crop in both modes, and in both the view stays + /// upright after a commit or a cancel (Photoshop 25.1 doesn't restore a Rotate View turn). + #[test] + fn commit_is_the_same_in_both_modes_and_the_view_stays_upright() { + let mut out = Vec::new(); + for classic in [true, false] { + let mut app = app(); + app.run("select.rect", json!({"x": 70, "y": 50, "width": 2, "height": 2})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.ui.tool_options.crop_shield.classic_mode = classic; + app.ui.views[0].rotation = 20.0; + with_frame(&mut app); + app.ui.crop_angle = 30.0; + app.crop.editing = true; + crate::crop_ui::ensure_frame(&mut app); + let want = if classic { 0.0 } else { -30.0 }; + assert!((view_rotation(&app) - want).abs() < 1e-4, "classic={classic}: {}", view_rotation(&app)); + if !classic { + assert_upright(screen_box(&app)); + } + let past = app.session.active().unwrap().history.past_len(); + crate::canvas::commit_crop(&mut app); + assert_eq!(view_rotation(&app), 0.0, "classic={classic}: after ↵"); + crate::crop_ui::ensure_frame(&mut app); + assert_eq!(view_rotation(&app), 0.0, "classic={classic}: with the next frame"); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), past + 1, "one undo step"); + let surf = st.doc.layers[0].surface().unwrap(); + let px: Vec<[f32; 4]> = + (0..st.doc.size.width as i32).flat_map(|x| (0..st.doc.size.height as i32).map(move |y| (x, y))).map(|(x, y)| surf.rgba(x, y)).collect(); + out.push((st.doc.size, px)); + // Esc. + let mut app = self::app(); + app.ui.tool_options.crop_shield.classic_mode = classic; + app.ui.views[0].rotation = -45.0; + with_frame(&mut app); + screen_drag(&mut app, &[pos2(500.0, 300.0), pos2(370.0, 420.0)], NONE); + crate::crop_ui::cancel(&mut app); + crate::crop_ui::ensure_frame(&mut app); + assert_eq!(view_rotation(&app), 0.0, "classic={classic}: Esc"); + } + assert_eq!(out[0].0, out[1].0, "same size"); + assert!(out[0].1 == out[1].1, "same pixels"); + } + + #[test] + fn picking_the_crop_tool_turns_a_rotated_view_upright() { + let mut app = app(); + app.ui.tool = Tool::Brush; + app.ui.views[0].rotation = 30.0; + let center = app.ui.views[0].center; + app.ui.tool = Tool::Crop; + crate::crop_ui::ensure_frame(&mut app); + assert!(app.crop.default_frame && !app.crop.editing, "no click needed"); + assert_eq!(view_rotation(&app), 0.0); + assert_eq!(app.ui.views[0].center, center, "about the view's centre"); + // Leaving the tool doesn't bring the turn back. + app.ui.tool = Tool::Brush; + crate::crop_ui::ensure_frame(&mut app); + assert_eq!(view_rotation(&app), 0.0); + // Classic Mode too (Photoshop 25.1): the camera goes upright about the view's centre and + // the frame stays on the same image area, keeping its own turn. + let mut app = self::app(); + app.ui.tool_options.crop_shield.classic_mode = true; + app.ui.tool = Tool::Brush; + app.ui.views[0].rotation = 30.0; + app.ui.crop_rect = Some([40.0, 30.0, 100.0, 70.0]); + app.ui.crop_angle = 12.0; + app.ui.tool = Tool::Crop; + crate::crop_ui::ensure_frame(&mut app); + assert_eq!(view_rotation(&app), 0.0); + assert_eq!(app.ui.views[0].center, center, "about the view's centre"); + assert_eq!((app.ui.crop_rect, app.ui.crop_angle), (Some([40.0, 30.0, 100.0, 70.0]), 12.0), "the frame is unchanged"); + // P switches modes without bringing the R turn back: default mode turns the view with the + // frame, Classic Mode puts the camera upright again. + let press_p = |app: &mut PhotocraftApp| { + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.events.push(egui::Event::Key { key: egui::Key::P, physical_key: None, pressed: true, repeat: false, modifiers: NONE })); + assert!(crate::crop_shield::keys(app, &ctx)); + }; + press_p(&mut app); + assert!(!classic(&app)); + assert!((view_rotation(&app) - -12.0).abs() < 1e-4, "{}", view_rotation(&app)); + press_p(&mut app); + assert!(classic(&app)); + assert_eq!(view_rotation(&app), 0.0); + crate::crop_ui::cancel(&mut app); + crate::crop_ui::ensure_frame(&mut app); + assert_eq!(view_rotation(&app), 0.0, "Esc in Classic Mode: still upright"); + } + + fn key(app: &mut PhotocraftApp, key: egui::Key, modifiers: Modifiers) { + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers })); + crate::shortcuts::handle(app, &ctx); + } + + #[test] + fn arrows_nudge_the_image_shift_by_ten_and_x_recentres() { + let mut app = app(); + with_frame(&mut app); + app.crop.editing = true; + app.ui.crop_angle = -90.0; + crate::crop_ui::ensure_frame(&mut app); + let before = screen_box(&app); + let img = |app: &PhotocraftApp| xf(app).to_screen(0.0, 0.0); + let origin = img(&app); + key(&mut app, egui::Key::ArrowRight, NONE); + assert!(close(screen_box(&app), before), "the box stays put"); + assert!((img(&app) - origin - vec2(1.0, 0.0)).length() < 0.02, "the image moved 1 px right on screen"); + key(&mut app, egui::Key::ArrowDown, Modifiers::SHIFT); + assert!((img(&app) - origin - vec2(1.0, 10.0)).length() < 0.02, "⇧: 10 px"); + assert!(close(screen_box(&app), before)); + assert!(crate::crop_ui::swap_orientation(&mut app)); + assert_upright(screen_box(&app)); + let q = screen_box(&app); + assert!(pos2((q[0].x + q[2].x) / 2.0, (q[0].y + q[2].y) / 2.0).distance(pos2(400.0, 300.0)) < 0.05, "Auto Center Preview"); + } + + /// Photoshop 25.1 greys out Show Cropped Area and Auto Center Preview in Classic Mode. Show + /// Cropped Area then acts as on (an assumption) and H does nothing; the stored choice stays. + #[test] + fn classic_mode_shows_the_cropped_area_and_h_does_nothing() { + let mut app = app(); + crate::crop_ui::ensure_frame(&mut app); + app.ui.tool_options.crop_shield.show_cropped_area = false; + assert!(crate::crop_shield::hides_outside(&app)); + app.ui.tool_options.crop_shield.classic_mode = true; + assert!(!crate::crop_shield::hides_outside(&app)); + let canvas = egui::Color32::from_rgb(40, 40, 40); + assert_ne!(crate::crop_shield::fill(&app.ui.tool_options.crop_shield, canvas, false), Some(canvas), "the shield, not the opaque canvas"); + key(&mut app, egui::Key::H, NONE); + assert_eq!(app.ui.tool, Tool::Crop, "H is taken, not the Hand tool"); + assert!(!app.ui.tool_options.crop_shield.show_cropped_area, "and changes nothing"); + app.ui.tool_options.crop_shield.classic_mode = false; + assert!(crate::crop_shield::hides_outside(&app), "the stored choice is back"); + key(&mut app, egui::Key::H, NONE); + assert!(app.ui.tool_options.crop_shield.show_cropped_area); + } + + #[test] + fn p_toggles_classic_mode_while_the_crop_tool_shows_a_box() { + let mut app = app(); + crate::crop_ui::ensure_frame(&mut app); + let press = |app: &mut PhotocraftApp| { + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.events.push(egui::Event::Key { key: egui::Key::P, physical_key: None, pressed: true, repeat: false, modifiers: NONE })); + crate::shortcuts::handle(app, &ctx); + }; + press(&mut app); + assert!(classic(&app) && app.ui.tool == Tool::Crop, "P doesn't pick the Pen tool"); + press(&mut app); + assert!(!classic(&app)); + // Mid-drag P is left alone; without a box P is the Pen key. + app.crop.drag = Some(CropDrag::Move { start: [0.0, 0.0], rect: [0.0, 0.0, 10.0, 10.0] }); + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.events.push(egui::Event::Key { key: egui::Key::P, physical_key: None, pressed: true, repeat: false, modifiers: NONE })); + assert!(!crate::crop_shield::keys(&mut app, &ctx)); + assert!(!classic(&app)); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.tool = Tool::Crop; + press(&mut app); + assert_ne!(app.ui.tool, Tool::Crop); + assert!(!classic(&app)); + } + + #[test] + fn straighten_turns_the_image_so_the_line_is_level_on_screen() { + let mut app = app(); + crate::crop_ui::ensure_frame(&mut app); + crate::crop_straighten::toggle(&mut app); + // A horizon falling to the right, drawn on screen. + let (a, b) = (pos2(320.0, 290.0), pos2(480.0, 310.0)); + let (da, db) = (xf(&app).to_doc(a), xf(&app).to_doc(b)); + screen_drag(&mut app, &[a, b], NONE); + assert!(crate::crop_ui::angle(&app) > 5.0, "{}", app.ui.crop_angle); + let (sa, sb) = (xf(&app).to_screen(da[0] as f32, da[1] as f32), xf(&app).to_screen(db[0] as f32, db[1] as f32)); + assert!((sa.y - sb.y).abs() < 0.01, "the line is level: {sa:?} {sb:?}"); + assert_upright(screen_box(&app)); + } + + /// `ui.pointer` points are read in the view at the press, so an `up` where the last `move` was + /// doesn't move or turn the image a second time. + #[test] + fn ui_pointer_drives_the_default_mode_in_press_coordinates() { + let mut app = app(); + app.ui.tool_options.crop_shield.auto_center_preview = false; + with_frame(&mut app); + let ctx = egui::Context::default(); + let pointer = |app: &mut PhotocraftApp, pts: [[f64; 2]; 2]| { + let ev = json!([ + {"kind": "down", "x": pts[0][0], "y": pts[0][1]}, + {"kind": "move", "x": pts[1][0], "y": pts[1][1]}, + {"kind": "up", "x": pts[1][0], "y": pts[1][1]}, + ]); + let (req, _rx) = crate::control::ControlRequest::new("ui.pointer", json!({"events": ev})); + let crate::control::Outcome::Done(v) = crate::control::handle(app, &ctx, &req) else { panic!("ui.pointer didn't finish") }; + assert_eq!(v["ok"], true, "{v}"); + }; + pointer(&mut app, [[70.0, 50.0], [80.0, 55.0]]); + let r = app.ui.crop_rect.unwrap(); + assert!(r.iter().zip([30.0, 25.0, 90.0, 65.0]).all(|(a, b)| (a - b).abs() < 1e-3), "{r:?}"); + assert!((app.ui.views[0].center[0] - 90.0).abs() < 1e-3 && (app.ui.views[0].center[1] - 45.0).abs() < 1e-3); + // Outside: from right of the centre (60, 45) to below it, a quarter turn of the image. + pointer(&mut app, [[140.0, 45.0], [60.0, 125.0]]); + assert!((crate::crop_ui::angle(&app) - -90.0).abs() < 0.01, "{}", app.ui.crop_angle); + assert_upright(screen_box(&app)); + } + + #[test] + fn degenerate_input_never_panics() { + let mut app = app(); + for frame in + [[0.0, 0.0, 0.0, 0.0], [5.0, 5.0, 5.0, 5.0], [f64::NAN, 0.0, 10.0, 10.0], [f64::INFINITY, 0.0, 1e300, 1e300], [-1e300, -1e300, 1e300, 1e300]] + { + for angle in [0.0, f64::NAN, f64::INFINITY, 1e300, -720.5] { + app.ui.crop_rect = Some(frame); + app.ui.crop_angle = angle; + app.crop.default_frame = false; + app.crop.editing = true; + crate::crop_ui::ensure_frame(&mut app); + for p in [pos2(400.0, 300.0), pos2(10.0, 10.0), pos2(f32::NAN, 3.0), pos2(1e30, -1e30)] { + screen_drag(&mut app, &[p, p + vec2(15.0, 7.0)], NONE); + screen_drag(&mut app, &[p, p + vec2(15.0, 7.0)], Modifiers::SHIFT | Modifiers::ALT); + } + crate::crop_ui::nudge(&mut app, f64::NAN, 1.0); + crate::crop_ui::nudge(&mut app, 1.0, 1.0); + crate::crop_ui::swap_orientation(&mut app); + auto_center(&mut app); + shift_image(&mut app, [f64::NAN, f64::INFINITY]); + let press = PressView { rotation: f32::NAN, center: [f32::INFINITY, 0.0] }; + let g = LiveResize { rect: frame, deg: angle, hx: 1, hy: -1, start: [0.0, f64::NAN], q: [1e300, -1e300], ratio: Some(f64::NAN), alt: false }; + live_resize(&mut app, g, press); + assert!(app.ui.crop_rect.is_none_or(|r| r.iter().all(|v| v.is_finite()) || r == frame), "{frame:?} {angle}"); + let v = &app.ui.views[0]; + assert!(v.rotation.is_finite() && v.center.iter().all(|c| c.is_finite()), "{frame:?} {angle}: {v:?}"); + crate::crop_ui::cancel(&mut app); + } + } + assert_eq!(pivot_center([1.0, 2.0], [f64::NAN, 0.0], 0.0, 90.0, false), [1.0, 2.0]); + assert_eq!(pivot_center([1.0, 2.0], [0.0, 0.0], f32::NAN, 90.0, true), [1.0, 2.0]); + // No view at all (no document). + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.tool = Tool::Crop; + app.ui.crop_rect = Some([0.0, 0.0, 10.0, 10.0]); + app.crop.editing = true; + sync(&mut app, None); + auto_center(&mut app); + shift_image(&mut app, [1.0, 1.0]); + upright(&mut app); + } + + #[test] + fn the_options_default_to_photoshops_and_older_settings_load() { + let s = crate::crop_shield::CropShield::default(); + assert!(!s.classic_mode && s.auto_center_preview); + let s: crate::crop_shield::CropShield = serde_json::from_str(r#"{"enabled": false}"#).unwrap(); + assert!(!s.classic_mode && s.auto_center_preview); + let s: crate::crop_shield::CropShield = serde_json::from_str(r#"{"classic_mode": true, "auto_center_preview": false}"#).unwrap(); + assert!(s.classic_mode && !s.auto_center_preview); + } + + #[test] + fn pivoting_keeps_the_point_on_screen() { + for flip in [false, true] { + for (from, to) in [(0.0, 30.0), (45.0, -90.0), (170.0, -170.0)] { + let center = [100.0f32, 50.0]; + let c = [70.0, 20.0]; + let a = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), zoom: 2.0, center, flip, rotation: from, aspect: 1.0 }; + let n = pivot_center(center, c, from, to, flip); + let b = ViewXform { center: n, rotation: to, ..a }; + let (p, q) = (a.to_screen(70.0, 20.0), b.to_screen(70.0, 20.0)); + assert!(p.distance(q) < 0.01, "{flip} {from} {to}: {p:?} vs {q:?}"); + } + } + } +} diff --git a/crates/ui-egui/src/crop_shield.rs b/crates/ui-egui/src/crop_shield.rs index e97bff2d10..aef70e5afb 100644 --- a/crates/ui-egui/src/crop_shield.rs +++ b/crates/ui-egui/src/crop_shield.rs @@ -2,8 +2,8 @@ //! options", #1919): whether the area outside the crop box shows at all (Show Cropped Area, H), //! and the shield dimming it (Enable Crop Shield: Match Canvas or a custom colour, an opacity, and //! Auto Adjust Opacity, which lightens it while the box is dragged). Photoshop's defaults: all on, -//! Match Canvas, 75 %. PhotoCraft always works like Photoshop's Classic Mode, so the menu has no -//! Use Classic Mode or Auto Center Preview. +//! Match Canvas, 75 %. The menu also holds the crop mode: Use Classic Mode (P; off by default) and +//! Auto Center Preview (on by default, default mode only), see `crop_mode`. //! //! The state lives in `ToolOptions::crop_shield` ([`CropShield`]), which `ui.inspect` reports and //! `ui.set {cropShield: {...}}` patches. [`fill`] resolves it to the colour the canvas paints over @@ -35,6 +35,12 @@ pub enum ShieldColor { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct CropShield { + /// Use Classic Mode (P): the box turns and moves over the image instead of the image turning + /// and moving behind an upright box (`crop_mode`). Off by default, as in Photoshop. + pub classic_mode: bool, + /// Auto Center Preview: in default mode, drawing or resizing the box re-pans the view so the + /// box sits in the middle of the canvas. + pub auto_center_preview: bool, /// Show Cropped Area (H): off hides everything outside the box behind the canvas colour. pub show_cropped_area: bool, /// Enable Crop Shield: off shows the area outside the box undimmed. @@ -50,11 +56,26 @@ pub struct CropShield { impl Default for CropShield { fn default() -> Self { - Self { show_cropped_area: true, enabled: true, color: ShieldColor::MatchCanvas, custom_color: [0, 0, 0], opacity: DEFAULT_OPACITY, auto_adjust: true } + Self { + classic_mode: false, + auto_center_preview: true, + show_cropped_area: true, + enabled: true, + color: ShieldColor::MatchCanvas, + custom_color: [0, 0, 0], + opacity: DEFAULT_OPACITY, + auto_adjust: true, + } } } impl CropShield { + /// Show Cropped Area as it acts: always on in Classic Mode, where Photoshop greys the option + /// out (the stored choice is kept for the default mode). + pub fn shows_cropped_area(&self) -> bool { + self.classic_mode || self.show_cropped_area + } + /// The opacity as a fraction in 0..=1: a non-finite value (a hand-edited settings file) is /// the default, an out-of-range one is clamped. pub fn opacity(&self) -> f32 { @@ -68,7 +89,7 @@ impl CropShield { /// shield when it is disabled (or fully transparent), otherwise its colour at its opacity. pub fn fill(s: &CropShield, canvas: Color32, editing: bool) -> Option { let [r, g, b, _] = canvas.to_srgba_unmultiplied(); - if !s.show_cropped_area { + if !s.shows_cropped_area() { return Some(Color32::from_rgb(r, g, b)); } if !s.enabled { @@ -95,18 +116,29 @@ pub fn current(app: &PhotocraftApp, ctx: &egui::Context) -> Option { /// Show Cropped Area is off with the Crop tool showing a box: nothing outside it shows. pub fn hides_outside(app: &PhotocraftApp) -> bool { - app.ui.tool == Tool::Crop && app.ui.crop_rect.is_some() && !app.ui.tool_options.crop_shield.show_cropped_area + app.ui.tool == Tool::Crop && app.ui.crop_rect.is_some() && !app.ui.tool_options.crop_shield.shows_cropped_area() } -/// H with the Crop tool showing a box and no drag in progress toggles Show Cropped Area. Returns -/// true when the key was used; otherwise H stays the Hand tool key. +/// H with the Crop tool showing a box and no drag in progress toggles Show Cropped Area (in +/// Classic Mode, where the option is greyed out, H is taken and does nothing), and P Use Classic +/// Mode. Returns true when a key was used; otherwise H stays the Hand tool key and P the Pen's. pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if app.ui.tool != Tool::Crop || app.ui.crop_rect.is_none() || app.crop.drag.is_some() { return false; } if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::H)) { let s = &mut app.ui.tool_options.crop_shield; - s.show_cropped_area = !s.show_cropped_area; + if !s.classic_mode { + s.show_cropped_area = !s.show_cropped_area; + } + return true; + } + if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::P)) { + let s = &mut app.ui.tool_options.crop_shield; + s.classic_mode = !s.classic_mode; + // The view follows at once, about its own centre: default mode turns it with the frame, + // Classic Mode puts the camera upright (never back to a Rotate View turn). + crate::crop_mode::sync(app, None); return true; } false @@ -120,9 +152,23 @@ pub fn options_button(s: &mut CropShield, ui: &mut egui::Ui) -> bool { egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { ui.set_min_width(240.0); ui.set_max_width(300.0); + // Photoshop 25.1's order; Classic Mode greys out the other two (Show Cropped Area then + // shows as on, which is how it acts there). ui.horizontal(|ui| { - crate::widgets::checkbox(ui, &mut s.show_cropped_area, tl!("Show Cropped Area")); - ui.weak(crate::shortcuts::pretty("H")); + crate::widgets::checkbox(ui, &mut s.classic_mode, tl!("Use Classic Mode")); + ui.weak(crate::shortcuts::pretty("P")); + }); + let classic = s.classic_mode; + ui.add_enabled_ui(!classic, |ui| { + ui.horizontal(|ui| { + if classic { + crate::widgets::checkbox(ui, &mut true, tl!("Show Cropped Area")); + } else { + crate::widgets::checkbox(ui, &mut s.show_cropped_area, tl!("Show Cropped Area")); + } + ui.weak(crate::shortcuts::pretty("H")); + }); + crate::widgets::checkbox(ui, &mut s.auto_center_preview, tl!("Auto Center Preview")); }); ui.separator(); crate::widgets::checkbox(ui, &mut s.enabled, tl!("Enable Crop Shield")); diff --git a/crates/ui-egui/src/crop_size.rs b/crates/ui-egui/src/crop_size.rs new file mode 100644 index 0000000000..d6203bad77 --- /dev/null +++ b/crates/ui-egui/src/crop_size.rs @@ -0,0 +1,450 @@ +//! The Crop tool's W x H x Resolution mode (#2443), as in Photoshop's options bar. +//! +//! The preset dropdown's "W x H x Resolution" (`crop_ratio` = [`WHR`]) shows a width and a height +//! field, each a length with its unit ("4 in", "10 cm", "25 mm", "1024 px"; a bare number keeps the +//! field's unit, pixels for an empty field), and a resolution with its unit (px/in or px/cm). +//! +//! - While the frame is drawn or resized it holds W : H ([`ratio`]). +//! - Committing (`canvas::commit_crop`) passes `targetWidth`/`targetHeight` (px) and `resolution` +//! (ppi) to `image.crop` ([`commit_params`]): the cropped area is resampled to exactly W x H px and +//! the document takes the resolution, in one undo step. Physical lengths convert at the typed +//! resolution, or at the document's own when the resolution field is empty (which then leaves +//! the resolution alone). With W or H empty the crop is not resampled (a free crop); a +//! resolution alone still sets the document's resolution. +//! - The dropdown's built-in presets (4 x 5 in 300 ppi, …) fill the fields; Front Image (or `I` +//! with the Crop tool, [`keys`]) fills them with the active document's size in px and its +//! resolution; Clear empties them; ⇄ and X swap W and H. + +use egui::{Modifiers, Stroke, StrokeKind, vec2}; +use photocraft_engine::prefs::Unit; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::state::{Tool, ToolOptions}; +use crate::theme::Tokens; + +/// `crop_ratio` key of the W x H x Resolution mode. +pub const WHR: &str = "whr"; +/// Dropdown key of Front Image (applied at once, never kept in `crop_ratio`). +pub const FRONT: &str = "front"; +/// Resolution units of the resolution field. +pub const PX_PER_IN: &str = "px/in"; +pub const PX_PER_CM: &str = "px/cm"; +/// Points per inch for pt / pica lengths (PostScript, Photoshop's default). +const POINTS_PER_INCH: f64 = 72.0; +/// The largest number a length or resolution field keeps (well past any canvas or ppi limit, +/// which `image.crop` enforces with its own message). +const MAX_FIELD: f64 = 1e9; + +/// Photoshop's built-in crop presets: (dropdown key, label, width, height, resolution in px/in). +pub const PRESETS: &[(&str, &str, &str, &str, &str)] = &[ + ("preset:4x5in", "4 x 5 in 300 ppi", "4 in", "5 in", "300"), + ("preset:8.5x11in", "8.5 x 11 in 300 ppi", "8.5 in", "11 in", "300"), + ("preset:1024x768px", "1024 x 768 px 92 ppi", "1024 px", "768 px", "92"), + ("preset:1280x800px", "1280 x 800 px 113 ppi", "1280 px", "800 px", "113"), + ("preset:1366x768px", "1366 x 768 px 135 ppi", "1366 px", "768 px", "135"), + ("preset:4x6in", "4 x 6 in 300 ppi", "4 in", "6 in", "300"), + ("preset:5x7in", "5 x 7 in 300 ppi", "5 in", "7 in", "300"), + ("preset:8x10in", "8 x 10 in 300 ppi", "8 in", "10 in", "300"), +]; + +/// The preset dropdown: Ratio, W x H x Resolution, the ratio presets, the size presets, Front Image. +pub fn dropdown_options() -> Vec<(String, &'static str)> { + let ratios = crate::chrome_ui::CROP_RATIOS; + let mut v: Vec<(String, &'static str)> = Vec::with_capacity(ratios.len() + PRESETS.len() + 2); + let mut rest = ratios.iter(); + if let Some((k, l)) = rest.next() { + v.push((k.to_string(), l)); + } + v.push((WHR.to_string(), tl!("W x H x Resolution"))); + v.extend(rest.map(|(k, l)| (k.to_string(), *l))); + v.extend(PRESETS.iter().map(|(k, l, ..)| (k.to_string(), *l))); + v.push((FRONT.to_string(), tl!("Front Image"))); + v +} + +/// The unit a suffix names (`in`, `cm`, `mm`, `px`, `pt`, `pica`, and their long forms). +fn unit_of(suffix: &str) -> Option { + Some(match suffix.to_ascii_lowercase().as_str() { + "px" | "pixel" | "pixels" => Unit::Pixels, + "in" | "inch" | "inches" | "\"" => Unit::Inches, + "cm" | "centimeter" | "centimeters" | "centimetre" | "centimetres" => Unit::Centimeters, + "mm" | "millimeter" | "millimeters" | "millimetre" | "millimetres" => Unit::Millimeters, + "pt" | "point" | "points" => Unit::Points, + "pica" | "picas" => Unit::Picas, + _ => return None, + }) +} + +/// A typed length: a positive number and an optional unit ("4 in", "4in", "10,5 cm", "1024"). +/// A bare number takes `fallback`. `None` for anything else (empty, zero, negative, unknown unit). +pub fn parse_length(text: &str, fallback: Unit) -> Option<(f64, Unit)> { + let text = text.trim(); + if text.is_empty() || text.len() > 64 { + return None; + } + let split = text.find(|c: char| c.is_alphabetic() || c == '"').unwrap_or(text.len()); + let (num, suffix) = (text.get(..split)?.trim(), text.get(split..)?.trim()); + let v = crate::widgets::parse_num(&num.replace(',', "."))?; + let unit = if suffix.is_empty() { fallback } else { unit_of(suffix)? }; + (v.is_finite() && v > 0.0 && v <= MAX_FIELD).then_some((v, unit)) +} + +/// "4 in", "1024 px", "8.5 in", "2.54 cm": the number (up to its unit's decimals) and the unit. +pub fn format_length(v: f64, unit: Unit) -> String { + let n = format!("{v:.*}", unit.decimals().max(2)); + let n = if n.contains('.') { n.trim_end_matches('0').trim_end_matches('.') } else { &n }; + format!("{n} {}", unit.suffix()) +} + +/// The unit a stored field value is in (pixels when it has none). +fn field_unit(stored: &str) -> Unit { + parse_length(stored, Unit::Pixels).map_or(Unit::Pixels, |(_, u)| u) +} + +/// What a length field keeps once typed: the normalised length, empty for an empty field, and the +/// previous value for something that isn't a length. +pub fn normalize_length(typed: &str, previous: &str) -> String { + if typed.trim().is_empty() { + return String::new(); + } + match parse_length(typed, field_unit(previous)) { + Some((v, u)) => format_length(v, u), + None => previous.to_string(), + } +} + +/// What the resolution field keeps once typed: a positive number, empty, or the previous value. +pub fn normalize_resolution(typed: &str, previous: &str) -> String { + if typed.trim().is_empty() { + return String::new(); + } + match crate::widgets::parse_num(&typed.trim().replace(',', ".")).filter(|v| v.is_finite() && *v > 0.0 && *v <= MAX_FIELD) { + Some(v) => crate::widgets::fmt_num2(v), + None => previous.to_string(), + } +} + +/// The resolution field in pixels per inch, `None` when empty or not a positive number. +pub fn resolution_ppi(o: &ToolOptions) -> Option { + let v = crate::widgets::parse_num(&o.crop_resolution.trim().replace(',', ".")).filter(|v| v.is_finite() && *v > 0.0)?; + Some(if o.crop_resolution_unit == PX_PER_CM { v * 2.54 } else { v }) +} + +/// W x H x Resolution's target: the size in pixels the crop is resampled to (unrounded) and the +/// resolution it sets, when the mode is on and both W and H hold lengths. Physical lengths convert +/// at the typed resolution, or at `doc_dpi` when there is none. +pub fn target(o: &ToolOptions, doc_dpi: f64) -> Option<(f64, f64, Option)> { + if o.crop_ratio != WHR { + return None; + } + let res = resolution_ppi(o); + let dpi = res.unwrap_or(doc_dpi); + let px = |text: &str| { + let (v, u) = parse_length(text, Unit::Pixels)?; + let px = u.to_px(v, dpi, 0.0, POINTS_PER_INCH); + (px.is_finite() && px > 0.0).then_some(px) + }; + Some((px(&o.crop_width)?, px(&o.crop_height)?, res)) +} + +/// The ratio W x H x Resolution holds while the frame is drawn or resized (width / height). +pub fn ratio(o: &ToolOptions, doc_dpi: f64) -> Option { + let (w, h, _) = target(o, doc_dpi)?; + Some(w / h).filter(|k| k.is_finite() && *k > 0.0) +} + +/// `image.crop`'s target params for the pending crop: `targetWidth`/`targetHeight` (whole px) when +/// W and H are set, and `resolution` (ppi) when the resolution is; the Image Interpolation +/// preference resamples. +pub fn commit_params(app: &PhotocraftApp, p: &mut Value) { + let o = &app.ui.tool_options; + if o.crop_ratio != WHR { + return; + } + let doc_dpi = app.session.active().map_or(72.0, |st| f64::from(st.doc.resolution_dpi)); + let px = |v: f64| v.round().clamp(1.0, MAX_FIELD) as u64; + if let Some((w, h, _)) = target(o, doc_dpi) { + p["targetWidth"] = json!(px(w)); + p["targetHeight"] = json!(px(h)); + p["resample"] = json!(crate::sizing::pref_resample(app.session.prefs().general.image_interpolation)); + } + if let Some(r) = resolution_ppi(o) { + p["resolution"] = json!(r); + } +} + +/// Fills W, H (px) and the resolution from the active document, in W x H x Resolution mode. +pub fn front_image(app: &mut PhotocraftApp) { + let Some(d) = app.session.active().map(|st| (st.doc.size, f64::from(st.doc.resolution_dpi))) else { return }; + let (size, dpi) = d; + let o = &mut app.ui.tool_options; + o.crop_ratio = WHR.into(); + o.crop_width = format_length(f64::from(size.width), Unit::Pixels); + o.crop_height = format_length(f64::from(size.height), Unit::Pixels); + let res = if o.crop_resolution_unit == PX_PER_CM { dpi / 2.54 } else { dpi }; + o.crop_resolution = crate::widgets::fmt_num2(res); +} + +/// Clear in W x H x Resolution mode: empties W, H and the resolution (the mode stays). +pub fn clear(o: &mut ToolOptions) { + o.crop_width.clear(); + o.crop_height.clear(); + o.crop_resolution.clear(); +} + +/// Swaps W and H (the ⇄ button, X). False outside W x H x Resolution mode. +pub fn swap(o: &mut ToolOptions) -> bool { + if o.crop_ratio != WHR { + return false; + } + std::mem::swap(&mut o.crop_width, &mut o.crop_height); + true +} + +/// After the preset dropdown chose `crop_ratio`: a size preset fills the fields and Front Image +/// reads the document, both leaving W x H x Resolution mode on. +pub fn chosen(app: &mut PhotocraftApp) { + let key = app.ui.tool_options.crop_ratio.clone(); + if key == FRONT { + app.ui.tool_options.crop_ratio = WHR.into(); + front_image(app); + } else if let Some((_, _, w, h, r)) = PRESETS.iter().find(|p| p.0 == key) { + let o = &mut app.ui.tool_options; + o.crop_ratio = WHR.into(); + o.crop_width = (*w).into(); + o.crop_height = (*h).into(); + o.crop_resolution = (*r).into(); + o.crop_resolution_unit = PX_PER_IN.into(); + } +} + +/// I with the Crop tool (no text field focused, which the caller checks): Front Image. Returns +/// true when the key was used; otherwise I stays the Eyedropper tool key. +pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + if app.ui.tool != Tool::Crop || app.crop.drag.is_some() || app.session.active().is_none() { + return false; + } + if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, egui::Key::I)) { + front_image(app); + return true; + } + false +} + +/// A text field for a length or the resolution: what is typed is kept while it has focus and +/// normalised into `value` (by `normalize(typed, previous)`) when it loses it. Returns true then. +fn text_field(ui: &mut egui::Ui, id: &str, value: &mut String, hint: &str, width: f32, normalize: fn(&str, &str) -> String) -> bool { + let t = Tokens::get(ui.ctx()); + let id = ui.id().with(id); + let mut buf = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| value.clone()); + let (rect, _) = ui.allocate_exact_size(vec2(width, 24.0), egui::Sense::hover()); + crate::widgets::surface(ui, rect, t.field, false); + if !t.bevel { + ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + } + let inner = rect.shrink2(vec2(4.0, 2.0)); + let r = ui.put(inner, egui::TextEdit::singleline(&mut buf).id(id).char_limit(32).hint_text(hint).frame(egui::Frame::NONE).font(crate::theme::mono(12.0))); + if r.has_focus() { + ui.data_mut(|d| d.insert_temp(id, buf)); + return false; + } + ui.data_mut(|d| d.remove::(id)); + if r.lost_focus() { + let new = normalize(&buf, value); + let changed = new != *value; + *value = new; + return changed; + } + false +} + +/// The W x H x Resolution fields of the options bar: W, ⇄, H, the resolution and its unit. +pub fn fields(o: &mut ToolOptions, ui: &mut egui::Ui) { + text_field(ui, "crop-width", &mut o.crop_width, tl!("Width"), 64.0, normalize_length); + if crate::icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked() { + swap(o); + } + text_field(ui, "crop-height", &mut o.crop_height, tl!("Height"), 64.0, normalize_length); + text_field(ui, "crop-resolution", &mut o.crop_resolution, tl!("Resolution"), 64.0, normalize_resolution); + let units = [(PX_PER_IN.to_string(), "px/in"), (PX_PER_CM.to_string(), "px/cm")]; + let before = o.crop_resolution_unit.clone(); + if crate::widgets::dropdown(ui, "crop-resolution-unit", &mut o.crop_resolution_unit, &units, 64.0) && before != o.crop_resolution_unit { + // The same resolution in the other unit, as in Photoshop. + if let Some(v) = crate::widgets::parse_num(&o.crop_resolution.trim().replace(',', ".")).filter(|v| v.is_finite() && *v > 0.0) { + let v = if o.crop_resolution_unit == PX_PER_CM { v / 2.54 } else { v * 2.54 }; + o.crop_resolution = crate::widgets::fmt_num2(v); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; + + fn app(depth: SampleType) -> PhotocraftApp { + let mut doc = Document::with_background("crop", Size::new(400, 300), ColorMode::Rgb, depth, Color::WHITE); + doc.resolution_dpi = 72.0; + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.ui.tool = Tool::Crop; + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + app + } + + fn whr(w: &str, h: &str, res: &str, unit: &str) -> ToolOptions { + ToolOptions { + crop_ratio: WHR.into(), + crop_width: w.into(), + crop_height: h.into(), + crop_resolution: res.into(), + crop_resolution_unit: unit.into(), + ..ToolOptions::default() + } + } + + #[test] + fn lengths_parse_with_their_units() { + assert_eq!(parse_length("4 in", Unit::Pixels), Some((4.0, Unit::Inches))); + assert_eq!(parse_length("4in", Unit::Pixels), Some((4.0, Unit::Inches))); + assert_eq!(parse_length(" 10 cm ", Unit::Pixels), Some((10.0, Unit::Centimeters))); + assert_eq!(parse_length("25 MM", Unit::Pixels), Some((25.0, Unit::Millimeters))); + assert_eq!(parse_length("1024 px", Unit::Inches), Some((1024.0, Unit::Pixels))); + assert_eq!(parse_length("8,5 in", Unit::Pixels), Some((8.5, Unit::Inches))); + assert_eq!(parse_length("12", Unit::Centimeters), Some((12.0, Unit::Centimeters)), "a bare number keeps the field's unit"); + for bad in ["", "in", "0 in", "-4 in", "4 furlongs", "NaN", "1e400 px", "4 in 5"] { + assert_eq!(parse_length(bad, Unit::Pixels), None, "{bad}"); + } + assert_eq!(normalize_length("4in", ""), "4 in"); + assert_eq!(normalize_length("8.50 in", ""), "8.5 in"); + assert_eq!(normalize_length("7", "5 cm"), "7 cm", "bare numbers keep the unit typed before"); + assert_eq!(normalize_length("7", ""), "7 px"); + assert_eq!(normalize_length("nonsense", "5 cm"), "5 cm", "something else keeps the previous value"); + assert_eq!(normalize_length(" ", "5 cm"), ""); + assert_eq!(normalize_resolution("300", ""), "300"); + assert_eq!(normalize_resolution("-3", "72"), "72"); + assert_eq!(normalize_resolution("", "72"), ""); + } + + #[test] + fn target_converts_physical_units_at_the_resolution_or_the_documents() { + let close = |a: f64, b: f64| (a - b).abs() < 1e-6; + let (w, h, r) = target(&whr("4 in", "5 in", "300", PX_PER_IN), 72.0).unwrap(); + assert!(close(w, 1200.0) && close(h, 1500.0) && r == Some(300.0)); + let (w, h, r) = target(&whr("10 cm", "25 mm", "100", PX_PER_CM), 72.0).unwrap(); + assert!(close(w, 1000.0) && close(h, 250.0), "{w} {h}"); + assert!(close(r.unwrap(), 254.0), "100 px/cm is 254 ppi"); + // No resolution: inches convert at the document's, and none is set. + let (w, h, r) = target(&whr("2 in", "100 px", "", PX_PER_IN), 72.0).unwrap(); + assert!(close(w, 144.0) && close(h, 100.0) && r.is_none()); + // W or H missing, or another mode: no target (a free crop). + assert_eq!(target(&whr("2 in", "", "300", PX_PER_IN), 72.0), None); + assert_eq!(target(&ToolOptions { crop_ratio: "1:1".into(), ..whr("2 in", "3 in", "", PX_PER_IN) }, 72.0), None); + } + + #[test] + fn the_frame_holds_w_by_h() { + let mut app = app(SampleType::U8); + app.ui.tool_options = whr("4 in", "5 in", "300", PX_PER_IN); + assert!((ratio(&app.ui.tool_options, 72.0).unwrap() - 0.8).abs() < 1e-9); + let mods = Modifiers::NONE; + use crate::canvas::{ToolEvent, tool_event}; + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, mods); + tool_event(&mut app, ToolEvent::Move { x: 110.0, y: 300.0, pressure: 1.0 }, mods); + tool_event(&mut app, ToolEvent::Up { x: 110.0, y: 300.0 }, mods); + let r = app.ui.crop_rect.unwrap(); + assert!(((r[2] - r[0]) / (r[3] - r[1]) - 0.8).abs() < 1e-9, "{r:?}"); + } + + #[test] + fn presets_front_image_clear_and_swap_fill_the_fields() { + let mut app = app(SampleType::U8); + app.ui.tool_options.crop_ratio = "preset:8.5x11in".into(); + chosen(&mut app); + let o = &app.ui.tool_options; + assert_eq!((o.crop_ratio.as_str(), o.crop_width.as_str(), o.crop_height.as_str(), o.crop_resolution.as_str()), (WHR, "8.5 in", "11 in", "300")); + assert!(swap(&mut app.ui.tool_options)); + assert_eq!((app.ui.tool_options.crop_width.as_str(), app.ui.tool_options.crop_height.as_str()), ("11 in", "8.5 in")); + app.ui.tool_options.crop_ratio = FRONT.into(); + chosen(&mut app); + let o = &app.ui.tool_options; + assert_eq!((o.crop_ratio.as_str(), o.crop_width.as_str(), o.crop_height.as_str(), o.crop_resolution.as_str()), (WHR, "400 px", "300 px", "72")); + clear(&mut app.ui.tool_options); + let o = &app.ui.tool_options; + assert_eq!((o.crop_ratio.as_str(), o.crop_width.as_str(), o.crop_height.as_str(), o.crop_resolution.as_str()), (WHR, "", "", "")); + // px/cm shows the document's resolution per centimetre. + app.ui.tool_options.crop_resolution_unit = PX_PER_CM.into(); + front_image(&mut app); + assert_eq!(app.ui.tool_options.crop_resolution, "28.35"); + // Outside the mode ⇄ is the ratio's. + app.ui.tool_options.crop_ratio = "16:9".into(); + assert!(!swap(&mut app.ui.tool_options)); + assert!(dropdown_options().iter().any(|(k, _)| k == WHR) && dropdown_options().iter().any(|(k, _)| k == FRONT)); + } + + #[test] + fn x_swaps_w_and_h_with_the_frame() { + let mut app = app(SampleType::U8); + app.ui.tool_options = whr("4 in", "6 in", "300", PX_PER_IN); + app.ui.crop_rect = Some([0.0, 0.0, 40.0, 60.0]); + assert!(crate::crop_ui::swap_orientation(&mut app)); + let o = &app.ui.tool_options; + assert_eq!((o.crop_ratio.as_str(), o.crop_width.as_str(), o.crop_height.as_str()), (WHR, "6 in", "4 in")); + } + + #[test] + fn i_with_the_crop_tool_is_front_image() { + let mut app = app(SampleType::U8); + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.events.push(egui::Event::Key { key: egui::Key::I, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE })); + assert!(keys(&mut app, &ctx)); + assert_eq!((app.ui.tool_options.crop_ratio.as_str(), app.ui.tool_options.crop_width.as_str()), (WHR, "400 px")); + // Another tool keeps I. + let mut app2 = self::app(SampleType::U8); + app2.ui.tool = Tool::Brush; + ctx.input_mut(|i| i.events.push(egui::Event::Key { key: egui::Key::I, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE })); + assert!(!keys(&mut app2, &ctx)); + } + + /// Committing a W x H x Resolution crop resamples the frame's area to exactly W x H px and sets + /// the resolution, in one undo step, at 8 and 16 bits. + #[test] + fn commit_resamples_to_the_target_size_and_resolution() { + for depth in [SampleType::U8, SampleType::U16] { + let mut app = app(depth); + app.ui.tool_options = whr("4 in", "5 in", "300", PX_PER_IN); + app.ui.crop_rect = Some([10.0, 10.0, 110.0, 135.0]); + let mut p = json!({}); + commit_params(&app, &mut p); + assert_eq!((p["targetWidth"].as_u64(), p["targetHeight"].as_u64(), p["resolution"].as_f64()), (Some(1200), Some(1500), Some(300.0)), "{p}"); + let past = app.session.active().unwrap().history.past_len(); + crate::canvas::commit_crop(&mut app); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.size, st.doc.resolution_dpi), (Size::new(1200, 1500), 300.0), "{depth:?}"); + assert_eq!(st.doc.pixel_format().sample, depth); + assert_eq!(st.history.past_len(), past + 1, "{depth:?}: one undo step"); + } + // Empty resolution: inches at the document's 72 ppi, which stays. + let mut app = app(SampleType::U8); + app.ui.tool_options = whr("2 in", "1 in", "", PX_PER_IN); + app.ui.crop_rect = Some([0.0, 0.0, 100.0, 50.0]); + crate::canvas::commit_crop(&mut app); + let d = &app.session.active().unwrap().doc; + assert_eq!((d.size, d.resolution_dpi), (Size::new(144, 72), 72.0)); + // W empty: a plain crop. + let mut app = self::app(SampleType::U8); + app.ui.tool_options = whr("", "1 in", "", PX_PER_IN); + app.ui.crop_rect = Some([0.0, 0.0, 100.0, 50.0]); + crate::canvas::commit_crop(&mut app); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(100, 50)); + } + + #[test] + fn older_tool_options_load_without_the_new_fields() { + let o: ToolOptions = serde_json::from_str(r#"{"crop_ratio": "1:1"}"#).unwrap(); + assert_eq!((o.crop_width.as_str(), o.crop_resolution_unit.as_str()), ("", PX_PER_IN)); + } +} diff --git a/crates/ui-egui/src/crop_straighten.rs b/crates/ui-egui/src/crop_straighten.rs index 286f04253a..fc9ac3ab3b 100644 --- a/crates/ui-egui/src/crop_straighten.rs +++ b/crates/ui-egui/src/crop_straighten.rs @@ -8,9 +8,9 @@ //! - Holding ⌘ (Ctrl off the Mac) with the Crop tool is a temporary Straighten: a ⌘-drag draws the //! line; releasing ⌘ gives the normal crop gestures back. //! -//! While the line is drawn its angle shows beside the pointer. As the Crop tool works in -//! Photoshop's Classic Mode (the frame turns over a fixed image, `crop_ui`), Straighten sets the -//! frame's angle and fits the frame to the image ([`fitted`]). It is only a pending frame edit: +//! While the line is drawn its angle shows beside the pointer. Straighten sets the frame's angle +//! and fits the frame to the image ([`fitted`]); in Classic Mode the frame then shows turned over +//! the image, in the default mode the image turns behind the upright box (`crop_mode`). It is only a pending frame edit: //! ↵ commits it and Esc cancels it as any crop. A line shorter than [`MIN_LINE_PX`] screen points //! does nothing. @@ -183,6 +183,9 @@ mod tests { s.add_document(doc, None); let mut app = PhotocraftApp::new(s, crate::Services::default()); app.ui.tool = Tool::Crop; + // Classic Mode: the frame turns over the image (the default mode's Straighten is tested in + // `crop_mode`). + app.ui.tool_options.crop_shield.classic_mode = true; app.ui.extras.snap = false; app.ui.view.show.smart_guides = false; ensure_frame(&mut app); diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index dc1a240301..1c8d3db827 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -1,5 +1,7 @@ -//! Crop tool gestures, Photoshop "Classic Mode" style (the frame moves and turns over a fixed -//! image): +//! Crop tool gestures. In Photoshop's "Classic Mode" (Use Classic Mode, P) the frame moves and +//! turns over a fixed image, as described below; in its default mode (`crop_mode`) the box stays +//! upright on screen and the same gestures inside and outside it move and turn the image behind +//! it instead (the view turns with the frame, so hit-testing in document space is unchanged): //! //! - with no frame, or inside the untouched default frame, a drag draws a new frame; ⇧ makes it //! square, ⌥ draws it from the centre, and holding Space while drawing repositions the frame @@ -16,15 +18,12 @@ //! - with a pending frame and no gesture, the arrow keys nudge it 1 document px (⇧ 10 px) instead //! of the selection or layer, and X swaps it between portrait and landscape about its centre //! (keeping its angle, and swapping an options-bar ratio with it) instead of the colours -//! ([`keys`], #1919). These are Photoshop's Classic Mode keys: in its default mode the arrows -//! move the image under the box. +//! ([`keys`], #1919). In the default mode the arrows move the image under the box instead. //! //! ↵ commits (`image.crop`, see `canvas::commit_crop`, with the turn as its `angle`: the document //! is rotated so the frame is upright, then cropped to it) and Esc cancels. The pending frame lives //! in `UiState::crop_rect` (the frame before its turn) and `UiState::crop_angle` (degrees, -//! clockwise on screen), so the control channel reads both. Photoshop's default mode keeps the box -//! upright and turns the image behind it; the result is the same, but here the frame turns over -//! the image (its Classic Mode), which the canvas draws without rotating the view. +//! clockwise on screen), so the control channel reads both. Both modes commit the same crop. //! //! As in Photoshop, from the first press until the crop is committed or cancelled the canvas also //! shows the layers' pixels past its edges (kept by a crop with Delete Cropped Pixels off, or moved @@ -69,10 +68,20 @@ pub enum CropDrag { /// Moving the frame `rect` grabbed at `start`. Move { start: [f64; 2], rect: [f64; 4] }, /// Dragging an edge or corner: `hx`/`hy` are -1 (left/top), 1 (right/bottom) or 0 (untouched). - Resize { hx: i8, hy: i8, start: [f64; 2], rect: [f64; 4] }, + /// `view` is the view at the press when Auto Center Preview keeps the box's centre in place + /// while it is resized (default mode, `crop_mode::live_resize`), else `None`. + Resize { hx: i8, hy: i8, start: [f64; 2], rect: [f64; 4], view: Option }, /// Turning the frame about `center`: the pointer's direction from it at the press (radians) /// and the frame's angle then (degrees). Rotate { center: [f64; 2], grab: f64, from: f64 }, + /// Default mode, inside the box: the image moves under it, so the document point grabbed at + /// `start` stays under the pointer. `rect` is the frame and `view` the view at the press; + /// pointer points are read in that view (`crop_mode::to_press`). + MoveImage { start: [f64; 2], rect: [f64; 4], view: Option }, + /// Default mode, outside the box: the image turns about the frame's `center` (which stays put + /// on screen). `grab` is the pointer's direction from it at the press (radians, in the press + /// `view`), `from` the frame's angle then (degrees). + TurnImage { center: [f64; 2], grab: f64, from: f64, view: Option }, } /// What the pointer is over, relative to a crop frame. @@ -190,8 +199,12 @@ fn size_ok(r: [f64; 4]) -> bool { r[2] - r[0] >= MIN_FRAME && r[3] - r[1] >= MIN_FRAME } -/// The options-bar ratio preset as width / height. +/// The options-bar ratio preset as width / height (W : H in W x H x Resolution mode). fn preset_ratio(app: &PhotocraftApp) -> Option { + if app.ui.tool_options.crop_ratio == crate::crop_size::WHR { + let dpi = app.session.active().map_or(72.0, |s| f64::from(s.doc.resolution_dpi)); + return crate::crop_size::ratio(&app.ui.tool_options, dpi); + } let size = app.session.active().map_or((1.0, 1.0), |s| (s.doc.size.width as f64, s.doc.size.height as f64)); crate::chrome_ui::crop_ratio(&app.ui.tool_options.crop_ratio, size.0, size.1).map(|(w, h)| w / h).filter(|k| k.is_finite() && *k > 0.0) } @@ -250,8 +263,14 @@ pub fn set_space(app: &mut PhotocraftApp, down: bool) { } /// As in Photoshop, the Crop tool always shows a frame: on picking the tool, and after a crop is -/// cancelled or committed, it frames the selection's bounds, or the whole canvas. +/// cancelled or committed, it frames the selection's bounds, or the whole canvas. In default mode +/// the view then follows the frame being edited (`crop_mode::sync`). pub fn ensure_frame(app: &mut PhotocraftApp) { + frame(app); + crate::crop_mode::sync(app, None); +} + +fn frame(app: &mut PhotocraftApp) { if app.ui.tool != Tool::Crop { app.crop.editing = false; crate::crop_straighten::reset(app); @@ -299,6 +318,7 @@ pub fn active(app: &PhotocraftApp) -> bool { /// Cancel the pending crop: the frame, any gesture on it and the default-frame state. With the /// Crop tool still picked, [`ensure_frame`] then makes the active document a new default frame. pub fn cancel(app: &mut PhotocraftApp) { + crate::crop_mode::upright(app); app.ui.crop_rect = None; app.ui.crop_angle = 0.0; app.crop.drag = None; @@ -412,9 +432,14 @@ pub fn commits_at(app: &PhotocraftApp, p: [f64; 2]) -> bool { && app.ui.crop_rect.filter(|r| r.iter().all(|v| v.is_finite())).is_some_and(|r| hit_turned(r, angle(app), p, tolerance(app)) == Hit::Inside) } -/// The frame is being turned: its angle, for the readout beside the pointer. +/// The frame is being turned: the angle for the readout beside the pointer. Classic Mode shows +/// the frame's turn; the default mode the image's (clockwise positive, as Photoshop 25.1 does). pub fn turning(app: &PhotocraftApp) -> Option { - matches!(app.crop.drag, Some(CropDrag::Rotate { .. })).then(|| angle(app)) + match app.crop.drag { + Some(CropDrag::Rotate { .. }) => Some(angle(app)), + Some(CropDrag::TurnImage { .. }) => Some(crate::crop_mode::image_turn(angle(app))), + _ => None, + } } /// The size ↵ would crop frame `r` to: its own width and height (before any turn) in whole @@ -451,9 +476,20 @@ fn keyed(app: &mut PhotocraftApp, r: [f64; 4]) { } /// Moves the pending frame by (`dx`, `dy`) document px (a turned frame moves on the page, keeping -/// its angle). Returns false when the Crop tool doesn't take the keys ([`takes_keys`]). +/// its angle); in default mode the image moves that way on screen under the box instead. Returns +/// false when the Crop tool doesn't take the keys ([`takes_keys`]). pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64) -> bool { let Some(r) = app.ui.crop_rect.filter(|_| takes_keys(app)) else { return false }; + if crate::crop_mode::moves_image(app) { + if dx.is_finite() && dy.is_finite() { + app.crop.default_frame = false; + app.crop.editing = true; + crate::crop_mode::sync_frame(app); + let d = crate::crop_mode::screen_to_doc_vec(app, [dx, dy]); + crate::crop_mode::shift_image(app, d); + } + return true; + } if dx.is_finite() && dy.is_finite() { keyed(app, [r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy]); } @@ -480,10 +516,15 @@ pub fn swapped_ratio(key: &str, doc_w: f64, doc_h: f64) -> Option { pub fn swap_orientation(app: &mut PhotocraftApp) -> bool { let Some(r) = app.ui.crop_rect.filter(|_| takes_keys(app)) else { return false }; let size = app.session.active().map_or((0.0, 0.0), |s| (f64::from(s.doc.size.width), f64::from(s.doc.size.height))); - if let Some(k) = swapped_ratio(&app.ui.tool_options.crop_ratio, size.0, size.1) { + // W x H x Resolution swaps W and H instead of a ratio. + if !crate::crop_size::swap(&mut app.ui.tool_options) + && let Some(k) = swapped_ratio(&app.ui.tool_options.crop_ratio, size.0, size.1) + { app.ui.tool_options.crop_ratio = k; } keyed(app, swapped(r)); + crate::crop_mode::sync_frame(app); + crate::crop_mode::auto_center(app); true } @@ -522,17 +563,39 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool // The Straighten button, or ⌘ held: the drag draws a line to level the frame on. if crate::crop_straighten::pointer(app, ev, mods) { claim(app); + // Default mode: the image turns so the line ends up level (the press keeps its point). + match ev { + ToolEvent::Down { .. } => crate::crop_mode::sync(app, Some(p)), + _ => crate::crop_mode::sync_frame(app), + } return true; } match ev { ToolEvent::Down { .. } => { claim(app); app.crop.editing = true; + // Default mode: the view is upright for the frame (picking the tool already made it so; + // this catches a frame set over the control channel), about the pressed point. + crate::crop_mode::sync(app, Some(p)); let frame = app.ui.crop_rect.filter(|r| r.iter().all(|v| v.is_finite())); let deg = angle(app); + let image = crate::crop_mode::moves_image(app); app.crop.drag = Some(match frame.map(|r| (r, hit_turned(r, deg, p, tolerance(app)))) { - Some((rect, Hit::Handle(hx, hy))) => CropDrag::Resize { hx, hy, start: p, rect }, + Some((rect, Hit::Handle(hx, hy))) => { + let view = if crate::crop_mode::live_center(app) { crate::crop_mode::press_view(app) } else { None }; + CropDrag::Resize { hx, hy, start: p, rect, view } + } + // Default mode: inside the box the image moves, the box staying where it is on + // screen (Photoshop doesn't re-centre it). Inside the untouched frame a drag still + // draws a new box. + Some((rect, Hit::Inside)) if !app.crop.default_frame && image => { + CropDrag::MoveImage { start: p, rect, view: crate::crop_mode::press_view(app) } + } Some((rect, Hit::Inside)) if !app.crop.default_frame => CropDrag::Move { start: p, rect }, + Some((rect, Hit::Outside)) if image => { + let c = center(rect); + CropDrag::TurnImage { center: c, grab: (p[1] - c[1]).atan2(p[0] - c[0]), from: deg, view: crate::crop_mode::press_view(app) } + } // Outside the frame a drag turns it, as in Photoshop (#1792). Some((rect, Hit::Outside)) => { let c = center(rect); @@ -550,17 +613,27 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool let Some(drag) = app.crop.drag.take() else { return true }; let ok = app.ui.crop_rect.is_some_and(size_ok); // A click outside the untouched default frame (no turn) leaves it the default. - let changed = !matches!(drag, CropDrag::Rotate { from, .. } if from == angle(app)); + let changed = !matches!(drag, CropDrag::Rotate { from, .. } | CropDrag::TurnImage { from, .. } if from == angle(app)) + && !matches!(drag, CropDrag::MoveImage { rect, .. } if app.ui.crop_rect == Some(rect)); if ok && changed { app.crop.default_frame = false; } match drag { CropDrag::Draw { prev, .. } if !ok => app.ui.crop_rect = prev, - CropDrag::Move { rect, .. } | CropDrag::Resize { rect, .. } if !ok => app.ui.crop_rect = Some(rect), + CropDrag::Move { rect, .. } | CropDrag::Resize { rect, view: None, .. } if !ok => app.ui.crop_rect = Some(rect), + // A live-centred resize collapsed: the frame and the view it moved go back. + CropDrag::Resize { rect, view: Some(view), .. } if !ok => crate::crop_mode::offset_image(app, rect, Some(view), [0.0, 0.0]), + // Auto Center Preview: a box drawn in default mode moves to the middle. (A resized + // one stays where its centre was: `crop_mode::live_resize`.) + CropDrag::Draw { .. } if ok => { + crate::crop_mode::sync_frame(app); + crate::crop_mode::auto_center(app); + } _ => {} } } } + crate::crop_mode::sync_frame(app); true } @@ -569,9 +642,38 @@ fn update(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) { let ratio = preset_ratio(app); let space = app.crop.space; let deg = angle(app); - if let Some(CropDrag::Rotate { center, grab, from }) = app.crop.drag { - app.ui.crop_angle = turned_angle(center, grab, from, p, mods.shift); - return; + match app.crop.drag { + Some(CropDrag::Rotate { center, grab, from }) => { + app.ui.crop_angle = turned_angle(center, grab, from, p, mods.shift); + return; + } + Some(CropDrag::TurnImage { center, grab, from, view }) => { + // The image turns with the pointer's sweep on screen, so the frame turns against it. + let q = crate::crop_mode::to_press(app, view, p); + let a = from - ((q[1] - center[1]).atan2(q[0] - center[0]) - grab).to_degrees(); + if a.is_finite() { + app.ui.crop_angle = normalized_angle(if mods.shift { (a / 15.0).round() * 15.0 } else { a }); + crate::crop_mode::sync_frame(app); + } + return; + } + Some(CropDrag::MoveImage { start, rect, view }) => { + // The image follows the pointer: the frame moves the other way on it, and the view + // with the frame, so the box stays put. + let q = crate::crop_mode::to_press(app, view, p); + crate::crop_mode::offset_image(app, rect, view, [q[0] - start[0], q[1] - start[1]]); + return; + } + Some(CropDrag::Resize { hx, hy, start, rect, view: Some(view) }) => { + // Auto Center Preview: the box stays centred where it was while the handle follows + // the pointer. + let q = crate::crop_mode::to_press(app, Some(view), p); + let keep = (rect[2] - rect[0] > 0.0 && rect[3] - rect[1] > 0.0).then(|| (rect[2] - rect[0]) / (rect[3] - rect[1])); + let ratio = ratio.or(if mods.shift { keep } else { None }); + crate::crop_mode::live_resize(app, crate::crop_mode::LiveResize { rect, deg, hx, hy, start, q, ratio, alt: mods.alt }, view); + return; + } + _ => {} } let Some(drag) = app.crop.drag.as_mut() else { return }; let rect = match drag { @@ -594,7 +696,7 @@ fn update(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) { let (dx, dy) = (p[0] - start[0], p[1] - start[1]); [rect[0] + dx, rect[1] + dy, rect[2] + dx, rect[3] + dy] } - CropDrag::Resize { hx, hy, start, rect } => { + CropDrag::Resize { hx, hy, start, rect, .. } => { let keep = || { let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); (w > 0.0 && h > 0.0).then(|| w / h) @@ -602,7 +704,7 @@ fn update(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) { let ratio = ratio.or_else(|| if mods.shift { keep() } else { None }); resized_turned(*rect, deg, *hx, *hy, [p[0] - start[0], p[1] - start[1]], ratio, mods.alt) } - CropDrag::Rotate { .. } => return, + CropDrag::Rotate { .. } | CropDrag::TurnImage { .. } | CropDrag::MoveImage { .. } => return, }; app.ui.crop_rect = Some(rect); } @@ -617,14 +719,15 @@ pub fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option { } let deg = angle(app); let h = match app.crop.drag { - Some(CropDrag::Move { .. }) => Hit::Inside, + Some(CropDrag::Move { .. } | CropDrag::MoveImage { .. }) => Hit::Inside, Some(CropDrag::Resize { hx, hy, .. }) => Hit::Handle(hx, hy), Some(CropDrag::Draw { .. }) => return Some(CursorIcon::Crosshair), - Some(CropDrag::Rotate { .. }) => Hit::Outside, + Some(CropDrag::Rotate { .. } | CropDrag::TurnImage { .. }) => Hit::Outside, None => hit_turned(app.ui.crop_rect?, deg, p, tolerance(app)), }; Some(match h { - Hit::Handle(hx, hy) => resize_icon(hx, hy, deg), + // The arrow as the handle faces on screen: the frame's turn plus the view's. + Hit::Handle(hx, hy) => resize_icon(hx, hy, deg + f64::from(crate::crop_mode::view_rotation(app))), Hit::Inside => CursorIcon::Move, // Photoshop's curved two-headed arrow (`draw_turn_cursor`, from `turn_cursor_dir`). Hit::Outside => CursorIcon::None, @@ -641,7 +744,7 @@ pub fn turn_cursor_dir(app: &PhotocraftApp, p: [f64; 2]) -> Option<[f64; 2]> { let r = app.ui.crop_rect.filter(|r| r.iter().all(|v| v.is_finite()))?; let deg = angle(app); let turning = match app.crop.drag { - Some(CropDrag::Rotate { .. }) => true, + Some(CropDrag::Rotate { .. } | CropDrag::TurnImage { .. }) => true, Some(_) => false, None => hit_turned(r, deg, p, tolerance(app)) == Hit::Outside, }; @@ -715,6 +818,9 @@ mod tests { s.add_document(doc, None); let mut app = PhotocraftApp::new(s, crate::Services::default()); app.ui.tool = Tool::Crop; + // These tests pin Classic Mode (the frame moves and turns over the image); the default + // mode's are in `crop_mode`. + app.ui.tool_options.crop_shield.classic_mode = true; app.ui.extras.snap = false; app.ui.view.show.smart_guides = false; app @@ -1193,6 +1299,7 @@ mod tests { "file.new", "file.open", "file.openAs", + "file.openExrParts", "file.closeAll", "file.revert", "file.export.exportAs", diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dc64c25222..c4a700aef7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -67,6 +67,22 @@ pub fn free_press(ctx: &egui::Context, canvas: egui::Rect) -> Option /// scrolls. const DIALOG_CHROME: f32 = 150.0; +/// Draw a dialog body that opens on its first number, selected, as Photoshop's value dialogs do: +/// typing replaces it and Enter applies it (#1757). Once per dialog `id`, on the first laid-out +/// frame. +fn first_field_focused(ui: &mut egui::Ui, id: egui::Id, body: impl FnOnce(&mut egui::Ui) -> R) -> R { + let ctx = ui.ctx().clone(); + let focused = id.with("first-field"); + let first = !ui.is_sizing_pass() && !ctx.data(|m| m.get_temp::(focused).unwrap_or(false)); + if first { + ctx.data_mut(|m| m.insert_temp(focused, true)); + } + crate::widgets::focus_first_field(&ctx, first); + let r = body(ui); + crate::widgets::focus_first_field(&ctx, false); + r +} + /// Where a dialog's last position is remembered: per command for command dialogs (each filter has /// its own), per kind for the rest. fn place_id(d: &Dialog) -> egui::Id { @@ -113,6 +129,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if color_picker { modal = modal.frame(egui::Frame::popup(&ctx.global_style()).inner_margin(16)); } + // Tab / ⇧Tab walk the topmost dialog's text fields, not every widget (field_tab.rs). + let tab = if top == Some(d.id) && !egui::Popup::is_any_open(ctx) { crate::field_tab::take_step(ctx) } else { 0 }; // Photoshop doesn't dim the window behind dialogs: previews must be judged at true contrast. let modal = modal.show(ctx, |ui| { if !interactive { @@ -157,8 +175,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.add_space(4.0); crate::widgets::hairline(ui); ui.add_space(8.0); + crate::field_tab::begin(ui.ctx()); match d.kind { - DialogKind::NewDocument => crate::new_doc_ui::body(app, ui, &mut fields), + DialogKind::NewDocument => { + if crate::new_doc_ui::body(app, ui, &mut fields) { + outcome = Some(true); + } + } DialogKind::About if fields.get("systemInfo").and_then(Value::as_bool) == Some(true) => { let lines = crate::gpu_status::system_info(app); for l in &lines { @@ -201,7 +224,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { DialogKind::Command if crate::color_range_ui::owns(&fields) => crate::color_range_ui::body(app, ui, &mut fields), DialogKind::Command if crate::prefs_ui::owns(&fields) => crate::prefs_ui::body(app, ui, &mut fields), DialogKind::Command if fields.contains_key("__export") => crate::export_dialog::body(app, ui, &mut fields), - DialogKind::Command if fields.contains_key("__sizing") => crate::sizing::body(ui, &mut fields), + // Image Size and Canvas Size open on Width, selected (#2329). + DialogKind::Command if fields.contains_key("__sizing") => first_field_focused(ui, id, |ui| crate::sizing::body(ui, &mut fields)), DialogKind::Command if crate::adjust_dialog::owns(&fields) => crate::adjust_dialog::body(app, ui, &mut fields), DialogKind::Command if fields.contains_key("__filter") => { // Color Settings: the monitor profile in use can change while it is open. @@ -211,16 +235,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // Long parameter lists (Flame, Lighting Effects) scroll, so the title and the // OK / Cancel buttons stay inside a small window. let room = (ctx.content_rect().height() - DIALOG_CHROME).max(120.0); - // Photoshop opens a value dialog on its first number, selected: typing - // replaces it and Enter applies it (#1757). Once, on the first laid-out frame. - let focused = id.with("first-field"); - let first = !ui.is_sizing_pass() && !ctx.data(|m| m.get_temp::(focused).unwrap_or(false)); - if first { - ctx.data_mut(|m| m.insert_temp(focused, true)); - } - crate::widgets::focus_first_field(ctx, first); - egui::ScrollArea::vertical().id_salt(id.with("body")).max_height(room).show(ui, |ui| crate::filter_dialog::body(ui, &mut fields)); - crate::widgets::focus_first_field(ctx, false); + first_field_focused(ui, id, |ui| { + egui::ScrollArea::vertical().id_salt(id.with("body")).max_height(room).show(ui, |ui| crate::filter_dialog::body(ui, &mut fields)); + }); } DialogKind::Command if fields.contains_key("__form") => crate::view_cmds::form_body(ui, &mut fields), DialogKind::Command => {} @@ -229,6 +246,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.label(fields.get("message").and_then(Value::as_str).unwrap_or("Error")); } } + crate::field_tab::end(ui.ctx(), tab); if crate::color_picker_ui::owns(&fields) { if interactive && outcome.is_none() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { outcome = Some(true); diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index a2e86bd59d..252a4b4755 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -44,15 +44,19 @@ const DISCARDING: &[(&str, Reach)] = &[ (EXIT, Reach::All), ]; +fn reach(id: &str) -> Option { + DISCARDING.iter().find(|(c, _)| *c == id).map(|(_, r)| *r) +} + /// The command's target document and the unsaved documents it would discard (empty for commands /// that discard nothing). fn discarded(app: &PhotocraftApp, id: &str, params: &Value) -> (Option, Vec) { let docs = app.session.documents(); let target = params.get("document").and_then(Value::as_u64).map(|i| i as usize).or(app.session.active_index()); - let affected: Vec = match DISCARDING.iter().find(|(c, _)| *c == id) { - Some((_, Reach::Target)) => target.into_iter().collect(), - Some((_, Reach::AllButTarget)) => (0..docs.len()).filter(|&i| Some(i) != target).collect(), - Some((_, Reach::All)) => (0..docs.len()).collect(), + let affected: Vec = match reach(id) { + Some(Reach::Target) => target.into_iter().collect(), + Some(Reach::AllButTarget) => (0..docs.len()).filter(|&i| Some(i) != target).collect(), + Some(Reach::All) => (0..docs.len()).collect(), None => Vec::new(), }; let dirty = affected.into_iter().filter_map(|i| docs.get(i)).filter(|d| d.is_dirty()).map(|d| d.doc.id).collect(); @@ -67,9 +71,9 @@ pub fn intercept(app: &mut PhotocraftApp, id: &str, params: &Value) -> bool { } let prompt = Prompt { id: id.to_string(), params: params.clone(), target, docs }; match &app.discard { - None => app.discard = Some(prompt), + None => ask(app, prompt), // Quitting overrides whatever is pending: it covers every document, so nothing is lost. - Some(open) if id == EXIT && open.id != EXIT => app.discard = Some(prompt), + Some(open) if id == EXIT && open.id != EXIT => ask(app, prompt), // Repeated quit requests (the X pressed again) keep the prompt, and the answers so far. Some(open) if open.id == id => {} Some(_) => { @@ -80,6 +84,22 @@ pub fn intercept(app: &mut PhotocraftApp, id: &str, params: &Value) -> bool { true } +/// Puts up `prompt`. One over several documents shows the document it asks about first; each +/// later one is shown as it comes up (see [`advance`]). +fn ask(app: &mut PhotocraftApp, prompt: Prompt) { + if !matches!(reach(&prompt.id), Some(Reach::Target)) { + show_tab(app, prompt.docs.first().copied()); + } + app.discard = Some(prompt); +} + +/// Makes `doc` the active tab, if it is still open. +fn show_tab(app: &mut PhotocraftApp, doc: Option) { + if let Some(i) = doc.and_then(|d| index_of(app, d)) { + app.session.set_active(i); + } +} + impl PhotocraftApp { /// Whether closing now would lose work: a document has unsaved changes and closing wasn't /// confirmed. The web build has the browser ask before leaving the page while this holds (#1380). @@ -118,10 +138,21 @@ pub fn guard_window_close(app: &mut PhotocraftApp, ctx: &egui::Context) { /// Moves on to the next document, or runs the parked action once none are left. fn advance(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(p) = app.discard.as_mut() else { return }; - if !p.docs.is_empty() { - p.docs.remove(0); + let answered = (!p.docs.is_empty()).then(|| p.docs.remove(0)); + let (next, one_by_one) = (p.docs.first().copied(), !matches!(reach(&p.id), Some(Reach::Target))); + // Close All, Close Others and quitting close each document once it is answered, as Photoshop + // does, and show the next one asked about: otherwise the tab just answered stays up and it + // looks like it is being asked about again (#2547). Cancel then keeps only the unanswered ones. + if one_by_one { + if let Some(i) = answered.and_then(|d| index_of(app, d)) + && let Err(e) = crate::menus::invoke_unguarded(app, ctx, "file.close", json!({"document": i})) + { + app.ui.status = e; + app.ui.status_error = true; + } + show_tab(app, next); } - if !p.docs.is_empty() { + if next.is_some() { return; } let Some(Prompt { id, mut params, target, .. }) = app.discard.take() else { return }; @@ -132,8 +163,12 @@ fn advance(app: &mut PhotocraftApp, ctx: &egui::Context) { } if let Some(target) = target { // The tab may have moved since the command was issued; aim it at the same document. - let Some(i) = index_of(app, target) else { return }; - params = json!({"document": i}); + params = match index_of(app, target) { + Some(i) => json!({"document": i}), + // Close All may have closed its target above already; it still closes the rest. + None if matches!(reach(&id), Some(Reach::All)) => json!({}), + None => return, + }; } if let Err(e) = crate::menus::invoke_unguarded(app, ctx, &id, params) { app.ui.status = e; @@ -344,11 +379,27 @@ mod tests { make_dirty(&mut app, 2); crate::menus::invoke(&mut app, &ctx, "file.closeAll", json!({})).unwrap(); advance(&mut app, &ctx); - assert_eq!(app.session.documents().len(), 3, "still waiting on the second document"); + assert_eq!(app.session.documents().len(), 2, "the answered document closes; still waiting on the second"); advance(&mut app, &ctx); assert!(app.session.documents().is_empty()); } + #[test] + fn close_others_closes_each_answered_document_and_keeps_its_target() { + let mut app = app_with_docs(3); + let ctx = egui::Context::default(); + (0..3).for_each(|i| make_dirty(&mut app, i)); + let kept = doc_id(&app, 1); + crate::menus::invoke(&mut app, &ctx, "file.closeOthers", json!({"document": 1})).unwrap(); + assert_eq!(app.session.active_index(), Some(0), "the first one asked about is shown"); + advance(&mut app, &ctx); + assert_eq!(app.session.documents().len(), 2); + assert_eq!(app.session.active().map(|d| d.doc.id), Some(doc_id(&app, 1)), "the next one asked about is shown"); + advance(&mut app, &ctx); + assert!(app.discard.is_none()); + assert_eq!(app.session.documents().iter().map(|d| d.doc.id).collect::>(), [kept]); + } + #[test] fn the_parked_close_still_hits_its_document_after_tabs_move() { let mut app = app_with_docs(3); @@ -468,10 +519,66 @@ mod tests { h.key_press(Key::Escape); h.run_steps(2); assert!(h.state().discard.is_none()); - assert_eq!(h.state().session.documents().len(), 2, "Cancel closed nothing"); + assert_eq!(h.state().session.documents().len(), 1, "Cancel keeps the unanswered document"); } } + /// Whether the prompt is asking about the document called `name`. + fn asks_about(h: &Prompted, name: &str) -> bool { + use egui_kittest::kittest::Queryable; + h.query_by_label_contains(&format!("“{name}”")).is_some() + } + + /// No on one of several documents closes it and asks about the next one (#2547). + #[test] + fn answering_no_closes_that_document_and_asks_about_the_next() { + for command in [EXIT, "file.closeAll"] { + let mut app = app_with_docs(2); + make_dirty(&mut app, 0); + make_dirty(&mut app, 1); + let names: Vec = app.session.documents().iter().map(|d| d.doc.name.clone()).collect(); + let second = doc_id(&app, 1); + let first = doc_id(&app, 0); + assert_eq!(app.session.active_index(), Some(1), "the last new document is active"); + let mut h = prompt_for(app, command, egui::os::OperatingSystem::Windows); + assert!(asks_about(&h, &names[0]), "{command}"); + assert_eq!(h.state().session.active().map(|d| d.doc.id), Some(first), "{command}: the first tab asked about is shown"); + h.key_press(Key::N); + h.run_steps(2); + assert_eq!(h.state().session.documents().len(), 1, "{command}: No closed the first document"); + assert_eq!(doc_id(h.state(), 0), second); + assert_eq!(h.state().session.active_index(), Some(0), "{command}: the tab asked about is shown"); + assert!(asks_about(&h, &names[1]), "{command}: then about the second"); + assert!(!h.state().allow_close); + h.key_press(Key::N); + h.run_steps(2); + assert!(h.state().discard.is_none()); + if command == EXIT { + assert!(h.state().allow_close, "answering the last document lets the app quit"); + } else { + assert!(h.state().session.documents().is_empty()); + } + } + } + + #[test] + fn cancel_after_no_keeps_the_unanswered_documents_and_the_app_open() { + let mut app = app_with_docs(2); + make_dirty(&mut app, 0); + make_dirty(&mut app, 1); + let second = doc_id(&app, 1); + let mut h = prompt_for(app, EXIT, egui::os::OperatingSystem::Windows); + h.key_press(Key::N); + h.run_steps(2); + h.key_press(Key::Escape); + h.run_steps(2); + assert!(h.state().discard.is_none()); + assert!(!h.state().allow_close, "Cancel aborts the quit"); + assert_eq!(h.state().session.documents().len(), 1); + assert_eq!(doc_id(h.state(), 0), second); + assert!(h.state().session.documents()[0].is_dirty(), "the unanswered document keeps its changes"); + } + #[test] fn macos_hides_mnemonics_but_other_platforms_keep_them() { assert_eq!(button_label(true, "Don't Save", Some(Key::D)), tl!("Don't Save")); @@ -491,7 +598,7 @@ mod tests { h.key_press(Key::C); h.run_steps(2); assert!(h.state().discard.is_none()); - assert_eq!(h.state().session.documents().len(), 2, "Cancel closed nothing"); + assert_eq!(h.state().session.documents().len(), 1, "Cancel keeps the unanswered document"); } #[test] diff --git a/crates/ui-egui/src/discard_ui/tests/tiff_tests.rs b/crates/ui-egui/src/discard_ui/tests/tiff_tests.rs index 78dc063101..73571fa751 100644 --- a/crates/ui-egui/src/discard_ui/tests/tiff_tests.rs +++ b/crates/ui-egui/src/discard_ui/tests/tiff_tests.rs @@ -143,7 +143,7 @@ fn close_all_waits_for_each_layered_tiff_write() { for (i, doc) in [first, second].into_iter().enumerate() { h.key_press(Key::Y); h.run_steps(3); - assert_eq!(h.state().session.documents().len(), 2); + assert_eq!(h.state().session.documents().len(), 2 - i, "each saved document closes once written"); assert_eq!(writes.borrow().len(), i); assert_eq!(docs_left(&h), Some(2 - i)); h.key_press(Key::Enter); diff --git a/crates/ui-egui/src/doc_props_ui.rs b/crates/ui-egui/src/doc_props_ui.rs index 544ce7cf36..2383590c79 100644 --- a/crates/ui-egui/src/doc_props_ui.rs +++ b/crates/ui-egui/src/doc_props_ui.rs @@ -94,16 +94,22 @@ fn field_label(ui: &mut egui::Ui, s: &str, w: f32) { } /// A number field that only reports a committed value (drag released, Enter, focus lost). -fn committed_field(ui: &mut egui::Ui, id: &str, current: f32, suffix: &str) -> Option { +fn committed_field(ui: &mut egui::Ui, id: &str, current: f32, u: crate::layer_props_ui::LenUnit) -> Option { let key = egui::Id::new(("doc-props-field", id)); - let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or(current); - let r = widgets::value_field(ui, &mut v, 1.0..=300_000.0, suffix, 74.0); + // Shown in the ruler unit (cm when the rulers are in centimeters); 100% is the current size. + let extent = f64::from(current); + let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| u.shown(extent, extent)); + let r = widgets::value_field(ui, &mut v, 0.01..=300_000.0, u.unit.suffix(), 74.0); if r.dragged() || r.has_focus() { ui.data_mut(|d| d.insert_temp(key, v)); return None; } ui.data_mut(|d| d.remove::(key)); - (r.drag_stopped() || r.lost_focus() || r.changed()).then_some(v).filter(|v| (*v - current).abs() >= 0.5) + if !(r.drag_stopped() || r.lost_focus() || r.changed()) { + return None; + } + let px = (u.px(v, extent) as f32).clamp(1.0, 300_000.0); + ((px - current).abs() >= 0.5).then_some(px) } /// A disabled-looking field (Photoshop greys Canvas X/Y for documents without artboards). @@ -119,6 +125,7 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let Some(st) = app.session.active() else { return }; let (w, h, mode, depth, dpi) = (st.doc.size.width, st.doc.size.height, st.doc.mode, st.doc.depth, st.doc.resolution_dpi); let has_bg = st.doc.layers.first().is_some_and(|l| is_background(&st.doc, l)); + let lu = crate::layer_props_ui::LenUnit::of(app); let mut run: Vec<(String, Value)> = Vec::new(); ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); @@ -144,7 +151,7 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.vertical(|ui| { ui.horizontal(|ui| { field_label(ui, "W", 18.0); - if let Some(v) = committed_field(ui, "w", w as f32, "px") { + if let Some(v) = committed_field(ui, "w", w as f32, lu) { run.extend(canvas_resize_params((w, h), Some(v), None, linked).map(|p| ("image.canvasSize".to_string(), p))); } field_label(ui, "X", 22.0); @@ -152,7 +159,7 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); ui.horizontal(|ui| { field_label(ui, "H", 18.0); - if let Some(v) = committed_field(ui, "h", h as f32, "px") { + if let Some(v) = committed_field(ui, "h", h as f32, lu) { run.extend(canvas_resize_params((w, h), None, Some(v), linked).map(|p| ("image.canvasSize".to_string(), p))); } field_label(ui, "Y", 22.0); diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 25cfa4ff2c..69056fbcc4 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -3,8 +3,9 @@ //! A group's height never follows its content: content taller than the group scrolls inside //! it. The last expanded group (Layers by default) fills what the others leave. Drag the gap //! between two groups to resize them, double-click a tab (or use the panel menu) to collapse a -//! group to its tab strip, and drag a tab strip to move the group up or down the column -//! (unless Window › Workspace › Lock Workspace is on). +//! group to its tab strip, drag a tab strip (outside its tabs, or a tab dragged off the strip) to +//! move the group up or down the column, and drag a tab along its strip to reorder the group's +//! tabs (#2272); Window › Workspace › Lock Workspace stops all three moves. //! //! The layout is [`DockLayout`] in `UiState::dock` (serialisable, drivable with `ui.set`), saved //! with Window › Workspace › New Workspace…, reset by Reset Workspace, and remembered across @@ -110,9 +111,10 @@ impl Group { } } - /// Tabs that lay out their own scrolling list and footer (they fill the group). + /// Tabs that lay out their own scrolling list and footer, or size themselves to the group + /// (the Navigator's preview grows with it, as in Photoshop: #2726). pub fn scrolls_itself(self, tab: usize) -> bool { - matches!((self, tab), (Group::Layers, 0) | (Group::History, 0)) + matches!((self, tab), (Group::Layers, 0) | (Group::History, 0) | (Group::Navigator, 0)) } pub(crate) fn tab_mut(self, tabs: &mut DockTabs) -> &mut usize { @@ -168,6 +170,23 @@ pub struct DockLayout { /// Names of tabs hidden by Close (not positions: Color/Swatches swap with the theme). #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub hidden_tabs: BTreeMap>, + /// Tab order per group, left to right, by name like `hidden_tabs` (#2272). A group missing + /// here keeps its default order; unknown names are ignored and tabs missing from a list + /// follow it in their default order. Anything unreadable falls back to the defaults. + #[serde(rename = "tabOrder", alias = "tab_order", deserialize_with = "lenient_tab_order")] + pub tab_order: BTreeMap>, +} + +/// Read `tabOrder` keeping what makes sense: unknown groups, non-list values and non-string names +/// are dropped instead of failing the whole saved layout. +fn lenient_tab_order<'de, D: serde::Deserializer<'de>>(d: D) -> Result>, D::Error> { + let v = Value::deserialize(d)?; + let mut out = BTreeMap::new(); + for (k, names) in v.as_object().into_iter().flatten() { + let (Some(g), Some(names)) = (Group::from_key(k), names.as_array()) else { continue }; + out.insert(g, names.iter().filter_map(Value::as_str).take(16).map(str::to_owned).collect()); + } + Ok(out) } /// Gap between groups; it is also the splitter's grab area. @@ -176,10 +195,49 @@ pub const GAP: f32 = 6.0; const MAX_HEIGHT: f32 = 4000.0; impl DockLayout { - /// Visible tabs, retaining their original indices for commands and panel bodies. + /// Every tab of `group` in the user's order (#2272), with its original index (the one + /// `dockTabs`, commands and panel bodies use). + pub fn ordered_tabs(&self, group: Group, pro: bool) -> Vec<(usize, &'static str)> { + let all: Vec<(usize, &'static str)> = group.tabs(pro).iter().copied().enumerate().collect(); + let mut out: Vec<(usize, &'static str)> = Vec::with_capacity(all.len()); + for name in self.tab_order.get(&group).into_iter().flatten() { + if let Some(&tab) = all.iter().find(|(_, n)| *n == name.as_str()) + && !out.contains(&tab) + { + out.push(tab); + } + } + for tab in all { + if !out.contains(&tab) { + out.push(tab); + } + } + out + } + + /// Visible tabs in display order, retaining their original indices for commands and panel + /// bodies. pub fn visible_tabs(&self, group: Group, pro: bool) -> Vec<(usize, &'static str)> { let hidden = self.hidden_tabs.get(&group); - group.tabs(pro).iter().copied().enumerate().filter(|(_, name)| !hidden.is_some_and(|xs| xs.iter().any(|x| x.as_str() == *name))).collect() + self.ordered_tabs(group, pro).into_iter().filter(|(_, name)| !hidden.is_some_and(|xs| xs.iter().any(|x| x.as_str() == *name))).collect() + } + + /// Move tab `tab` (an original index) of `group` so it is drawn just before tab `before` + /// (or last when `None` or not a tab of the group). The default order is stored as no entry. + pub fn move_tab(&mut self, group: Group, tab: usize, before: Option, pro: bool) { + if before == Some(tab) { + return; + } + let mut order = self.ordered_tabs(group, pro); + let Some(at) = order.iter().position(|(i, _)| *i == tab) else { return }; + let moved = order.remove(at); + let to = before.and_then(|b| order.iter().position(|(i, _)| *i == b)).unwrap_or(order.len()); + order.insert(to, moved); + if order.iter().map(|(i, _)| *i).eq(0..order.len()) { + self.tab_order.remove(&group); + } else { + self.tab_order.insert(group, order.into_iter().map(|(_, n)| n.to_owned()).collect()); + } } /// Hide one tab; it can be reopened through Window › Panel. @@ -354,6 +412,7 @@ enum Action { Close(Group), CloseTab(Group, usize), Move(Group, Option), + MoveTab(Group, usize, Option), } /// Rects of the groups drawn last frame (screen points), for tests and automation. @@ -412,13 +471,20 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod let indices: Vec = visible.iter().map(|(i, _)| *i).collect(); let tabs: Vec<&str> = visible.iter().map(|(_, name)| *name).collect(); let mut sel = indices.iter().position(|i| *i == before).unwrap_or(0); - let resp = widgets::card_ex(&mut child, g.key(), &tabs, &mut sel, collapsed, |ui, shown_tab| { + let resp = widgets::card_ex(&mut child, g.key(), &tabs, &mut sel, collapsed, !locked, |ui, shown_tab| { let Some(&tab) = indices.get(shown_tab) else { return }; let inner = ui.available_height().max(0.0); if g.scrolls_itself(tab) { ui.set_min_height(inner); body(app, ui, g, tab); } else { + // The pro Color field stretches to the group's height instead of a fixed 120 pt. + if t.pro && g == Group::Color && tab == 0 { + ui.data_mut(|d| d.insert_temp(crate::panels::color_field_fill_id(), inner)); + } + // No scroll fade: it darkens real colours, swatches and previews at the edge + // (#2726); the scroll bar already shows there is more. + ui.spacing_mut().scroll.fade.strength = 0.0; egui::ScrollArea::vertical() .id_salt(("dock-scroll", g.key(), tab)) .max_height(inner) @@ -456,12 +522,21 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod if resp.strip.double_clicked() || resp.tab_double_clicked || (collapsed && resp.tab_clicked) { actions.push(Action::ToggleCollapse(g)); } - if !locked && resp.strip.dragged() { + // The strip uses visible indices; reorder in original ones. + if !locked + && let Some((from, before)) = resp.tab_reorder + && let Some(&tab) = indices.get(from) + { + actions.push(Action::MoveTab(g, tab, before.and_then(|b| indices.get(b).copied()))); + } + // A tab dragged off its strip moves the whole group, as the strip itself does. + if !locked && app.ui.docking.is_none() && (resp.strip.dragged() || resp.tab_dragging_out) { dragging = Some(g); } if !locked - && resp.strip.drag_stopped() - && let Some(p) = ui.ctx().pointer_interact_pos() + && app.ui.docking.is_none() + && (resp.strip.drag_stopped() || resp.tab_dropped_out) + && let Some(p) = ui.ctx().pointer_interact_pos().or_else(|| ui.ctx().pointer_latest_pos()) { actions.push(Action::Move(g, drop_before(&order, &rects, g, p.y))); } @@ -469,6 +544,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); } egui::Popup::menu(&resp.menu).show(|ui| { + crate::widgets::style_spectrum_popup_menu(ui); ui.set_min_width(170.0); if tabs.get(sel) == Some(&"Layers") { crate::layer_row_ui::panel_menu(app, ui); @@ -500,7 +576,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod ui.close(); } }); - // Splitter in the gap below this group: resizes it against the next expanded group. + // Splitter in the gap below this group: resizes it, pushing the neighbouring groups. if i + 1 < rects.len() && !collapsed && rects.iter().skip(i + 1).any(|(n, _)| !app.ui.dock.is_collapsed(*n)) { let gap = Rect::from_min_size(pos2(rect.left(), rect.bottom()), vec2(rect.width(), GAP)).expand2(vec2(0.0, 2.0)); let sresp = ui.interact(gap, ui.id().with(("dock-splitter", g.key())), Sense::drag()); @@ -535,18 +611,21 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod } Action::Close(g) => *g.shown_mut(&mut app.ui.panels) = false, Action::CloseTab(g, tab) => { + let shown_before = app.ui.dock.visible_tabs(g, t.pro); app.ui.dock.hide_tab(g, tab, t.pro); let remaining = app.ui.dock.visible_tabs(g, t.pro); if remaining.is_empty() { *g.shown_mut(&mut app.ui.panels) = false; } else if *g.tab_mut(&mut app.ui.dock_tabs) == tab { - // Prefer the next tab, or the previous one when closing the last. - let next = remaining.iter().find(|(i, _)| *i > tab).or_else(|| remaining.last()); + // Prefer the next tab on the strip, or the previous one when closing the last. + let at = shown_before.iter().position(|(i, _)| *i == tab).unwrap_or(0); + let next = shown_before.iter().skip(at + 1).find(|x| remaining.contains(x)).or_else(|| remaining.last()); if let Some(&(next, _)) = next { *g.tab_mut(&mut app.ui.dock_tabs) = next; } } } + Action::MoveTab(g, tab, before) => app.ui.dock.move_tab(g, tab, before, t.pro), Action::Move(g, before) => { if before != Some(g) { app.ui.dock.move_group(g, before); @@ -582,16 +661,24 @@ fn drop_before(order: &[Group], rects: &[(Group, Rect)], dragged: Group, y: f32) } } -/// Splitter `i` (below group `i`) dragged by `dy`: group `i` grows or shrinks against the next -/// expanded group (or the filler, which absorbs the difference). +/// Splitter `i` (below group `i`) dragged by `dy`. Like Photoshop, the groups on the side the +/// splitter moves into shrink in turn, nearest first, each down to its minimum (#2573), and the +/// group on the other side grows by what they gave. The filler absorbs its share implicitly, +/// so the column height stays the same. fn resize(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize, dy: f32) { if !dy.is_finite() || dy == 0.0 { return; } - let Some(&(g, h)) = heights.get(i) else { return }; - let filler = heights.iter().rposition(|(g, _)| !layout.is_collapsed(*g)); - let Some(j) = heights.iter().enumerate().skip(i + 1).find(|(_, (n, _))| !layout.is_collapsed(*n)).map(|(j, _)| j) else { return }; - let Some(&(n, nh)) = heights.get(j) else { return }; + let expanded = |k: &usize| heights.get(*k).is_some_and(|(g, _)| !layout.is_collapsed(*g)); + let filler = (0..heights.len()).rev().find(expanded); + let Some(j) = (i + 1..heights.len()).find(expanded) else { return }; + if !expanded(&i) { + return; + } + // Dragging down grows group `i` at the expense of the groups below; dragging up grows the + // next expanded group at the expense of `i` and the groups above it. + let (grow, shrink): (usize, Vec) = + if dy > 0.0 { (i, (j..heights.len()).filter(expanded).collect()) } else { (j, (0..=i).rev().filter(expanded).collect()) }; // The first drag pins the other groups at the heights they show, so groups still at their // defaults (which give way to the filler) don't shift while this one is resized. for (k, (o, oh)) in heights.iter().enumerate() { @@ -599,10 +686,22 @@ fn resize(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize, dy: f32) layout.heights.entry(*o).or_insert(*oh); } } - let new_h = (h + dy).clamp(g.min_height(), (h + nh - n.min_height()).max(g.min_height())); - layout.heights.insert(g, new_h); - if Some(j) != filler { - layout.heights.insert(n, (nh - (new_h - h)).max(n.min_height())); + let mut left = dy.abs(); + for k in shrink { + if left <= 0.0 { + break; + } + let Some(&(o, oh)) = heights.get(k) else { continue }; + let give = (oh - o.min_height()).max(0.0).min(left); + left -= give; + if Some(k) != filler { + layout.heights.insert(o, oh - give); + } + } + if let Some(&(o, oh)) = heights.get(grow) + && Some(grow) != filler + { + layout.heights.insert(o, oh + (dy.abs() - left)); } } @@ -644,7 +743,10 @@ pub fn apply(app: &mut PhotocraftApp, v: &Value) { app.ui.docking_generation = app.ui.docking_generation.wrapping_add(1); app.ui.docking_hidden = v.get("dockingHidden").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); app.ui.docking = v.get("docking").and_then(|d| serde_json::from_value(d.clone()).ok()).filter(crate::panel_docking::valid); - if let Some(p) = v.get("panels").and_then(|p| serde_json::from_value(p.clone()).ok()) { + if let Some(mut p) = v.get("panels").and_then(|p| serde_json::from_value::(p.clone()).ok()) { + // The embedding host owns these flags, including for layouts that serialize them. + p.menu_bar = app.ui.panels.menu_bar; + p.rail = app.ui.panels.rail; app.ui.panels = p; } if let Some(t) = v.get("dockTabs").and_then(|t| serde_json::from_value(t.clone()).ok()) { diff --git a/crates/ui-egui/src/dock_tests.rs b/crates/ui-egui/src/dock_tests.rs index 3d629c9850..d1417aef71 100644 --- a/crates/ui-egui/src/dock_tests.rs +++ b/crates/ui-egui/src/dock_tests.rs @@ -158,14 +158,17 @@ fn dragging_the_splitter_resizes_and_survives_a_ui_state_round_trip() { assert!((layers2.top() - (layers.top() - 60.0)).abs() < 2.0, "{layers:?} -> {layers2:?}"); assert_eq!(rect_of(&h, Group::Color), color, "the group above keeps its place"); assert_eq!(layers2.bottom(), layers.bottom()); - let stored = h.state().ui.dock.heights.get(&Group::Properties).copied().unwrap(); - // Dragging past the minimum stops at it. + // Dragging past the minimum pushes the group above down to its minimum too (#2573), then stops. let split = Pos2::new(props2.center().x, props2.bottom() + GAP / 2.0); drag(&mut h, split, split - vec2(0.0, 600.0)); assert_eq!(rect_of(&h, Group::Properties).height(), Group::Properties.min_height()); + assert_eq!(rect_of(&h, Group::Color).height(), Group::Color.min_height()); + assert_eq!(rect_of(&h, Group::Layers).bottom(), layers.bottom()); + // Dragging back down grows Properties alone; Color stays where it was pushed. let from = Pos2::new(split.x, rect_of(&h, Group::Properties).bottom() + GAP / 2.0); drag(&mut h, from, split); - assert!((h.state().ui.dock.heights[&Group::Properties] - stored).abs() < 2.0); + assert!((rect_of(&h, Group::Properties).bottom() - props2.bottom()).abs() < 2.0); + assert_eq!(rect_of(&h, Group::Color).height(), Group::Color.min_height()); // Save and restore the UI state (what `ui.inspect` / `ui.set` and workspaces carry). let saved = serde_json::to_value(&h.state().ui).unwrap(); @@ -190,6 +193,116 @@ fn dragging_the_splitter_resizes_and_survives_a_ui_state_round_trip() { assert_eq!(app4.ui.dock, DockLayout::default()); } +/// `resize` on `shown` laid out in a column `avail` tall, then the new layout. +fn resized(l: &mut DockLayout, shown: &[Group], avail: f32, i: usize, dy: f32) -> Vec<(Group, f32)> { + let hs = l.heights_for(shown, avail, 28.0); + resize(l, &hs, i, dy); + l.heights_for(shown, avail, 28.0) +} + +fn total(hs: &[(Group, f32)]) -> f32 { + hs.iter().map(|(_, h)| h).sum::() + GAP * hs.len().saturating_sub(1) as f32 +} + +#[test] +fn dragging_a_splitter_down_pushes_every_group_below_in_turn() { + let shown = [Group::Color, Group::Properties, Group::Navigator, Group::Layers]; + let mut l = DockLayout::default(); + let before = l.heights_for(&shown, 1200.0, 28.0); + // Properties gives way first, then Navigator, then the filler; the column stays 1200. + let hs = resized(&mut l, &shown, 1200.0, 0, 300.0); + assert_eq!(hs[0].1, before[0].1 + 300.0, "{hs:?}"); + assert_eq!(hs[1].1, Group::Properties.min_height()); + assert_eq!(hs[2].1, before[2].1 - (300.0 - (before[1].1 - Group::Properties.min_height()))); + assert_eq!(hs[3].1, before[3].1, "the filler is untouched until the groups above it are at minimum"); + assert!((total(&hs) - 1200.0).abs() < 1e-3); + // Far past the end: everything below is at its minimum and the drag stops there. + let hs = resized(&mut l, &shown, 1200.0, 0, 5000.0); + assert_eq!(hs[1].1, Group::Properties.min_height()); + assert_eq!(hs[2].1, Group::Navigator.min_height()); + assert_eq!(hs[3].1, Group::Layers.min_height()); + assert!((total(&hs) - 1200.0).abs() < 1e-3, "{hs:?}"); + let full = hs[0].1; + assert_eq!(resized(&mut l, &shown, 1200.0, 0, 50.0)[0].1, full); +} + +#[test] +fn dragging_a_splitter_up_pushes_every_group_above_in_turn() { + let shown = [Group::Color, Group::Properties, Group::Navigator, Group::Layers]; + let mut l = DockLayout::default(); + let before = l.heights_for(&shown, 1200.0, 28.0); + // The splitter under Navigator: Navigator shrinks first, then Properties, then Color. + let hs = resized(&mut l, &shown, 1200.0, 2, -200.0); + assert_eq!(hs[2].1, Group::Navigator.min_height()); + assert_eq!(hs[1].1, before[1].1 - (200.0 - (before[2].1 - Group::Navigator.min_height()))); + assert_eq!(hs[0].1, before[0].1); + assert_eq!(hs[3].1, before[3].1 + 200.0, "the filler takes the room"); + let hs = resized(&mut l, &shown, 1200.0, 2, -5000.0); + assert!(hs[..3].iter().all(|(g, h)| *h == g.min_height()), "{hs:?}"); + assert!((total(&hs) - 1200.0).abs() < 1e-3); + // The group grown when the filler isn't next: the splitter under Color grows Properties. + let mut l = DockLayout::default(); + let hs = resized(&mut l, &shown, 1200.0, 0, -500.0); + assert_eq!(hs[0].1, Group::Color.min_height()); + assert_eq!(hs[1].1, before[1].1 + (before[0].1 - Group::Color.min_height())); + assert_eq!(hs[2].1, before[2].1); +} + +#[test] +fn pushing_skips_collapsed_groups_and_respects_the_filler() { + let shown = [Group::Color, Group::Properties, Group::Navigator, Group::History, Group::Layers]; + let mut l = DockLayout::default(); + l.set_collapsed(Group::Properties, true); + l.set_collapsed(Group::Layers, true); + let before = l.heights_for(&shown, 1200.0, 28.0); + // History is the filler now; Properties keeps its strip and Layers stays collapsed. + let hs = resized(&mut l, &shown, 1200.0, 0, 5000.0); + assert_eq!(hs[1].1, 28.0); + assert_eq!(hs[4].1, 28.0); + assert_eq!(hs[2].1, Group::Navigator.min_height()); + assert_eq!(hs[3].1, Group::History.min_height()); + assert_eq!(hs[0].1, before[0].1 + (before[2].1 - Group::Navigator.min_height()) + (before[3].1 - Group::History.min_height())); + assert!((total(&hs) - 1200.0).abs() < 1e-3); + assert!(!l.heights.contains_key(&Group::History), "the filler's height is never stored"); + // Dragging up from Navigator over the collapsed Properties shrinks Color. + let hs = resized(&mut l, &shown, 1200.0, 2, -5000.0); + assert_eq!(hs[0].1, Group::Color.min_height()); + assert_eq!(hs[1].1, 28.0); + assert_eq!(hs[2].1, Group::Navigator.min_height()); + assert!((total(&hs) - 1200.0).abs() < 1e-3); + // Nothing to resize against, bad deltas and out-of-range splitters are ignored. + let snapshot = l.clone(); + for (i, dy) in [(3, 50.0), (4, 50.0), (9, 50.0), (0, f32::NAN), (0, f32::INFINITY), (0, 0.0)] { + let hs = l.heights_for(&shown, 1200.0, 28.0); + resize(&mut l, &hs, i, dy); + assert_eq!(l, snapshot, "splitter {i} by {dy}"); + } +} + +#[test] +fn dragging_swatches_down_pushes_properties_then_layers() { + let (app, _, _) = app_with_layers(); + let mut h = harness(app, vec2(1200.0, 800.0), ThemeKind::ProMedium); + let color = rect_of(&h, Group::Color); + let props = rect_of(&h, Group::Properties); + let layers = rect_of(&h, Group::Layers); + let room = (props.height() - Group::Properties.min_height()) + (layers.height() - Group::Layers.min_height()); + let split = Pos2::new(color.center().x, color.bottom() + GAP / 2.0); + // Past Properties' minimum: Layers gives way too instead of the drag hard-stopping. + let dy = props.height() - Group::Properties.min_height() + 40.0; + drag(&mut h, split, split + vec2(0.0, dy)); + assert!((rect_of(&h, Group::Color).height() - (color.height() + dy)).abs() < 2.0, "{color:?} -> {:?}", rect_of(&h, Group::Color)); + assert_eq!(rect_of(&h, Group::Properties).height(), Group::Properties.min_height()); + assert!((rect_of(&h, Group::Layers).height() - (layers.height() - 40.0)).abs() < 2.0); + assert_eq!(rect_of(&h, Group::Layers).bottom(), layers.bottom()); + // All the way: everything below at its minimum. + let split = Pos2::new(color.center().x, rect_of(&h, Group::Color).bottom() + GAP / 2.0); + drag(&mut h, split, split + vec2(0.0, 2000.0)); + assert!((rect_of(&h, Group::Color).height() - (color.height() + room)).abs() < 2.0); + assert_eq!(rect_of(&h, Group::Layers).height(), Group::Layers.min_height()); + assert_eq!(rect_of(&h, Group::Layers).bottom(), layers.bottom()); +} + #[test] fn reset_workspace_restores_the_default_layout_and_new_workspaces_keep_theirs() { let (app, _, _) = app_with_layers(); @@ -593,7 +706,7 @@ fn tab_strips_never_overlap_the_menu_button() { return; } let mut s = sel; - let r = crate::widgets::card_ex(ui, "t", tabs, &mut s, false, |ui, _| { + let r = crate::widgets::card_ex(ui, "t", tabs, &mut s, false, false, |ui, _| { ui.label("body"); }); *out = Some((r.tabs, r.menu.rect, r.chevron, ui.max_rect())); @@ -660,7 +773,7 @@ fn dock_strips_fit_and_the_chevron_menu_switches_tabs() { if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { return; } - let _ = crate::widgets::card_ex(ui, "color", &["Color", "Swatches", "Gradients", "Patterns"], sel, false, |ui, _| { + let _ = crate::widgets::card_ex(ui, "color", &["Color", "Swatches", "Gradients", "Patterns"], sel, false, false, |ui, _| { ui.label("body"); }); }, @@ -707,3 +820,181 @@ fn tab_hides_all_panels_and_shift_tab_only_the_dock() { v.as_object_mut().unwrap().remove("dock"); assert!(serde_json::from_value::(v).unwrap().dock); } + +/// #2726: the Navigator sizes its own preview to the group (no outer scroll area, so no scroll +/// fade over the thumbnail), keeping the document's aspect, with the zoom controls below it. +#[test] +fn navigator_preview_fills_its_group_and_keeps_aspect() { + assert!(Group::Navigator.scrolls_itself(0), "the Navigator tab lays itself out"); + assert!(!Group::Navigator.scrolls_itself(1) && !Group::Navigator.scrolls_itself(2), "Histogram and Info still scroll"); + for theme in [ThemeKind::ProMedium, ThemeKind::Studio] { + let mut sizes = Vec::new(); + for tall in [150.0, 260.0, 420.0] { + let mut h = Harness::builder().with_size(vec2(1200.0, 1000.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, theme); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.theme = theme; + app.ui.panels.navigator = true; + app.ui.dock.heights.insert(Group::Navigator, tall); + app.run("file.new", json!({"width": 200, "height": 300})).unwrap(); + app.sync_views(); + app + }); + h.run_steps(8); + let group = rect_of(&h, Group::Navigator); + let preview = crate::panels::last_navigator_preview(&h.ctx).unwrap_or_else(|| panic!("{theme:?} {tall}: no preview drawn")); + assert!((group.height() - tall).abs() < 1.0, "{theme:?}: group {group:?}"); + assert!(group.contains_rect(preview), "{theme:?} {tall}: preview {preview:?} outside {group:?}"); + assert!((preview.width() / preview.height() - 2.0 / 3.0).abs() < 0.02, "{theme:?} {tall}: aspect {preview:?}"); + // The zoom controls stay inside the group, below the preview. + let controls = crate::panels::last_navigator_controls(&h.ctx).unwrap_or_else(|| panic!("{theme:?} {tall}: no controls")); + assert!(controls.top() >= preview.bottom() && controls.bottom() <= group.bottom() + 0.5, "{theme:?} {tall}: {controls:?} {preview:?} {group:?}"); + sizes.push(preview); + } + // A portrait document grows with every extra point of height the group gets. + for pair in sizes.windows(2) { + assert!(pair[1].height() > pair[0].height() + 80.0, "{theme:?}: {sizes:?}"); + } + } +} + +fn strip_tabs(h: &Harness<'static, PhotocraftApp>, g: Group) -> Vec<(usize, Rect)> { + last_strips(&h.ctx).into_iter().find(|s| s.group == g).map(|s| s.tabs).unwrap_or_else(|| panic!("{g:?} strip not drawn")) +} + +fn tab_rect(h: &Harness<'static, PhotocraftApp>, g: Group, tab: usize) -> Rect { + strip_tabs(h, g).into_iter().find(|(i, _)| *i == tab).map(|(_, r)| r).unwrap_or_else(|| panic!("{g:?} tab {tab} not on the strip")) +} + +fn click(h: &mut Harness<'static, PhotocraftApp>, p: Pos2) { + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +#[test] +fn move_tab_reorders_by_name_and_bad_saved_orders_fall_back() { + let mut l = DockLayout::default(); + let names = |l: &DockLayout, g, pro| l.visible_tabs(g, pro).into_iter().map(|(_, n)| n).collect::>(); + l.move_tab(Group::Layers, 2, Some(0), true); + assert_eq!(l.visible_tabs(Group::Layers, true), vec![(2, "Paths"), (0, "Layers"), (1, "Channels")]); + l.move_tab(Group::Layers, 2, None, true); + assert_eq!(names(&l, Group::Layers, true), ["Layers", "Channels", "Paths"]); + assert!(l.tab_order.is_empty(), "the default order is stored as nothing"); + // Out-of-range tabs and moves onto themselves change nothing; an unknown `before` is last. + for (tab, before) in [(9, Some(0)), (usize::MAX, None), (1, Some(1)), (2, Some(9))] { + let mut m = l.clone(); + m.move_tab(Group::Layers, tab, before, true); + assert_eq!(names(&m, Group::Layers, true), ["Layers", "Channels", "Paths"], "{tab} {before:?}"); + } + // Names, not positions: the order survives the Pro/Studio swap of Color and Swatches. + l.move_tab(Group::Color, 3, Some(0), true); + assert_eq!(names(&l, Group::Color, true), ["Patterns", "Color", "Swatches", "Gradients"]); + assert_eq!(names(&l, Group::Color, false), ["Patterns", "Color", "Swatches", "Gradients"]); + // Hidden tabs keep their place for when they come back. + l.hide_tab(Group::Color, 1, true); + assert_eq!(names(&l, Group::Color, true), ["Patterns", "Color", "Gradients"]); + // Garbage in a saved layout falls back per group, and never loses the rest of the layout. + for bad in [ + json!(5), + json!("x"), + json!(null), + json!({"layers": "paths"}), + json!({"nope": ["Paths"]}), + json!({"layers": [1, null, {"a": 1}]}), + json!({"layers": ["Nope", "Paths", "Paths"]}), + ] { + let back: DockLayout = serde_json::from_value(json!({"order": ["layers"], "tabOrder": bad})).unwrap(); + assert_eq!(back.order().first(), Some(&Group::Layers), "{bad}"); + let layers = names(&back, Group::Layers, true); + assert!(layers == ["Layers", "Channels", "Paths"] || layers == ["Paths", "Layers", "Channels"], "{bad}: {layers:?}"); + assert_eq!(back.visible_tabs(Group::Color, true).len(), 4); + } + let back: DockLayout = serde_json::from_value(json!({"tabOrder": {"layers": ["Channels"]}})).unwrap(); + assert_eq!(names(&back, Group::Layers, true), ["Channels", "Layers", "Paths"], "missing tabs follow in their default order"); +} + +/// #2272: dragging a tab along its strip reorders the group's tabs; the order persists in the +/// UI state, the remembered layout and saved workspaces, and clicking still selects. +#[test] +fn dragging_a_tab_reorders_the_group_and_the_order_persists() { + for theme in [ThemeKind::ProMedium, ThemeKind::Studio] { + let (app, _, _) = app_with_layers(); + let mut h = harness(app, vec2(1200.0, 800.0), theme); + let order = h.state().ui.dock.order(); + let rects = last_rects(&h.ctx); + let paths = tab_rect(&h, Group::Layers, 2); + let layers = tab_rect(&h, Group::Layers, 0); + drag(&mut h, paths.center(), layers.left_center() + vec2(4.0, 0.0)); + let strip: Vec = strip_tabs(&h, Group::Layers).iter().map(|(i, _)| *i).collect(); + assert_eq!(strip, [2, 0, 1], "{theme:?}: Paths moved before Layers"); + assert_eq!(h.state().ui.dock.order(), order, "{theme:?}: a tab drag along the strip leaves the groups put"); + assert_eq!(last_rects(&h.ctx), rects); + // The dragged tab is brought to the front; dockTabs keeps its original indices. + assert_eq!(h.state().ui.dock_tabs.layers, 2, "{theme:?}"); + + // A plain click still selects, and selects the tab under the pointer. + let layers = tab_rect(&h, Group::Layers, 0).center(); + click(&mut h, layers); + assert_eq!(h.state().ui.dock_tabs.layers, 0, "{theme:?}"); + assert_eq!(strip_tabs(&h, Group::Layers).iter().map(|(i, _)| *i).collect::>(), [2, 0, 1]); + + // UI state round trip (the control channel's ui.get / ui.set). + let dock = serde_json::to_value(&h.state().ui.dock).unwrap(); + assert_eq!(dock["tabOrder"], json!({"layers": ["Paths", "Layers", "Channels"]})); + let ctx = h.ctx.clone(); + let (req, _rx) = crate::control::ControlRequest::new("ui.set", json!({"dock": {"tabOrder": {"layers": ["Channels"]}}})); + crate::control::handle(h.state_mut(), &ctx, &req); + assert_eq!(h.state().ui.dock.visible_tabs(Group::Layers, is_pro(theme))[0], (1, "Channels")); + let (req, _rx) = crate::control::ControlRequest::new("ui.set", json!({"dock": dock})); + crate::control::handle(h.state_mut(), &ctx, &req); + assert_eq!(h.state().ui.dock.visible_tabs(Group::Layers, is_pro(theme))[0], (2, "Paths")); + + // Remembered across launches (prefs panelLayout) ... + h.state_mut().session.prefs.edit(|p| p.workspace.remember_workspace_changes = true); + persist(h.state_mut(), &ctx); + let saved = h.state().session.prefs().panel_layout.clone(); + let (mut fresh, _, _) = app_with_layers(); + fresh.session.prefs.edit(|p| { + p.workspace.remember_workspace_changes = true; + p.panel_layout = saved.clone(); + }); + restore(&mut fresh); + assert_eq!(fresh.ui.dock.visible_tabs(Group::Layers, is_pro(theme))[0], (2, "Paths"), "{theme:?}"); + + // ... and in a saved workspace, which Essentials resets and selecting brings back. + crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.newWorkspace", json!({"name": "Paths First"})).unwrap(); + crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.essentials", json!({})).unwrap(); + assert!(h.state().ui.dock.tab_order.is_empty()); + crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.select", json!({"name": "Paths First"})).unwrap(); + h.run_steps(3); + assert_eq!(strip_tabs(&h, Group::Layers).iter().map(|(i, _)| *i).collect::>(), [2, 0, 1], "{theme:?}"); + } +} + +/// Dragging a tab off its strip moves the whole group (as dragging the strip does); a locked +/// workspace keeps both the tab and group order. +#[test] +fn a_tab_dragged_off_the_strip_moves_the_group_and_locking_stops_reordering() { + let (app, _, _) = app_with_layers(); + let mut h = harness(app, vec2(1200.0, 800.0), ThemeKind::ProMedium); + let paths = tab_rect(&h, Group::Layers, 2); + let to = rect_of(&h, Group::Color).left_top() + vec2(120.0, 10.0); + drag(&mut h, paths.center(), to); + assert_eq!(h.state().ui.dock.order().first(), Some(&Group::Layers)); + assert!(h.state().ui.dock.tab_order.is_empty(), "the tabs kept their order"); + + h.state_mut().session.prefs.edit(|p| p.workspace_locked = true); + h.run_steps(2); + let order = h.state().ui.dock.order(); + let paths = tab_rect(&h, Group::Layers, 2); + let layers = tab_rect(&h, Group::Layers, 0).left_center(); + drag(&mut h, paths.center(), layers + vec2(4.0, 0.0)); + assert!(h.state().ui.dock.tab_order.is_empty()); + let paths = tab_rect(&h, Group::Layers, 2); + drag(&mut h, paths.center(), paths.center() + vec2(0.0, 500.0)); + assert_eq!(h.state().ui.dock.order(), order); +} diff --git a/crates/ui-egui/src/enable_rules.rs b/crates/ui-egui/src/enable_rules.rs index 88f2236ed9..05cf7b6ab0 100644 --- a/crates/ui-egui/src/enable_rules.rs +++ b/crates/ui-egui/src/enable_rules.rs @@ -42,10 +42,12 @@ fn kind_blocks(id: &str, l: &Layer) -> bool { "layer.rasterize.fillContent" => !matches!(c, LayerContent::Fill(_)), "layer.rasterize.smartObject" => !matches!(c, LayerContent::Smart(_)), "layer.rasterize.vectorMask" => l.vector_mask.is_none(), - // What the command converts: type, shape, fill and Smart Object layers (a raster layer with a - // vector mask has Rasterize › Vector Mask; the command used to be enabled for layers with - // effects or a vector mask and then fail with "nothing to rasterize"). - "layer.rasterize.layer" => !matches!(c, LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Fill(_) | LayerContent::Smart(_)), + // What the command converts: type, shape, fill and Smart Object layers, and vector masks + // ("all vector data"). Effects stay live: Rasterize › Layer Style bakes them. + "layer.rasterize.layer" => { + l.vector_mask.is_none() && !matches!(c, LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Fill(_) | LayerContent::Smart(_)) + } + "layer.rasterize.layerStyle" => !photocraft_engine::rasterize_style_cmds::can_bake(l), "layer.layerStyle.copyLayerStyle" | "layer.layerStyle.clear" => l.effects.items.is_empty(), _ => id.starts_with("layer.combineShapes.") && !matches!(c, LayerContent::Shape(_)), } @@ -84,9 +86,12 @@ enum Targets { /// layers happened to be active). fn selection_targets(id: &str) -> Option { Some(match id { - "layer.rasterize.layer" | "layer.rasterize.type" | "layer.rasterize.shape" | "layer.rasterize.fillContent" | "layer.rasterize.smartObject" => { - Targets::Any - } + "layer.rasterize.layer" + | "layer.rasterize.layerStyle" + | "layer.rasterize.type" + | "layer.rasterize.shape" + | "layer.rasterize.fillContent" + | "layer.rasterize.smartObject" => Targets::Any, "layer.releaseClippingMask" | "layer.layerStyle.pasteLayerStyle" | "layer.layerStyle.clear" => Targets::Any, "layer.createClippingMask" => Targets::AllButLowest, "layer.layerMask.revealAll" | "layer.layerMask.hideAll" | "layer.layerMask.revealSelection" | "layer.layerMask.hideSelection" => Targets::WithoutMask, @@ -116,6 +121,16 @@ pub fn disabled(app: &crate::PhotocraftApp, id: &str) -> bool { if transform && crate::vector_ui::free_transform_path(app).is_some() { return false; } + // Editable type can be scaled, rotated and skewed but not distorted (#2630): Photoshop greys + // Distort and Perspective until the type is rasterized. A targeted unlinked mask or channel of + // the type layer is pixels, and can be. + if matches!(id, "edit.transform.distort" | "edit.transform.perspective") + && active.is_some_and(|l| matches!(l.content, LayerContent::Text(_))) + && photocraft_engine::transform_cmds::lone_target(&st.doc, st.active_layer, &serde_json::json!({ "target": crate::canvas::paint_target(app) })) + .is_ok_and(|t| t.is_none()) + { + return true; + } // Only these commands depend on the selection; skip walking the layers for the rest. if selection_targets(id).is_some() { return disabled_for_selection(&st.doc, &st.selected_layers(), active, id); @@ -194,7 +209,7 @@ mod tests { } } - /// A document with a Background, plain layers, one with a mask, a type layer, a shape, a Smart + /// A document with a Background, plain layers, one with a mask, one with a vector mask, a type layer, a shape, a Smart /// Object, an adjustment layer and one with a drop shadow; `select` picks a selection (the last /// layer is active) and `copied` has a layer style on the clipboard. fn world(select: &[&str], copied: bool) -> photocraft_engine::Session { @@ -206,6 +221,8 @@ mod tests { ids.insert("R1", id(s.execute("layer.new.layer", json!({"name": "R1"})).unwrap())); ids.insert("R2", id(s.execute("layer.new.layer", json!({"name": "R2"})).unwrap())); s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + ids.insert("V", id(s.execute("layer.new.layer", json!({"name": "V"})).unwrap())); + s.execute("layer.vectorMask.revealAll", json!({})).unwrap(); ids.insert("T", id(s.execute("type.create", json!({"text": "T", "size": 12, "x": 2, "y": 12})).unwrap())); ids.insert("S", id(s.execute("shape.create", json!({"kind": "rect", "rect": [4, 4, 6, 6]})).unwrap())); s.execute("layer.new.layer", json!({"name": "M"})).unwrap(); @@ -224,14 +241,16 @@ mod tests { s } - /// What a layer command can change: kind, clipping, mask and style of every layer. + /// What a layer command can change: kind, clipping, masks and style of every layer. fn fingerprint(s: &photocraft_engine::Session) -> Vec { s.active() .unwrap() .doc .walk() .iter() - .map(|(_, _, l)| format!("{:?}|{}|{}|{}", std::mem::discriminant(&l.content), l.clipped, l.mask.is_some(), l.effects.items.len())) + .map(|(_, _, l)| { + format!("{:?}|{}|{}|{}|{}", std::mem::discriminant(&l.content), l.clipped, l.mask.is_some(), l.vector_mask.is_some(), l.effects.items.len()) + }) .collect() } @@ -244,6 +263,7 @@ mod tests { // (command, an enabled item must really change something) let commands = [ ("layer.rasterize.layer", true), + ("layer.rasterize.layerStyle", true), ("layer.createClippingMask", true), ("layer.layerMask.revealAll", true), ("layer.layerMask.hideAll", true), @@ -272,6 +292,10 @@ mod tests { &["R1"], &["BG"], &["FX"], + &["V", "R1"], + &["R1", "V"], + &["V"], + &["V", "FX", "T"], ]; let mut checked = 0; for sel in selections { diff --git a/crates/ui-egui/src/eraser_ui.rs b/crates/ui-egui/src/eraser_ui.rs index fed6ade74a..59fc4d4926 100644 --- a/crates/ui-egui/src/eraser_ui.rs +++ b/crates/ui-egui/src/eraser_ui.rs @@ -53,6 +53,31 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]]) - true } +/// Whether `tool` is the Eraser in Pencil mode: it erases hard, aliased pixels through +/// `paint.pencil` (`"erase": true`) and shows the Pencil's pixel-grid cursor (#2662). +pub fn pencil_mode(app: &PhotocraftApp, tool: Tool) -> bool { + tool == Tool::Eraser && app.ui.tool_options.eraser_mode == "pencil" +} + +/// Whether `tool` is the Eraser in Block mode: it erases a hard square of 16 screen pixels +/// (`16 / zoom` document pixels) through `paint.pencil` (`"block": true`); Size, Hardness, +/// Opacity and Flow don't apply (#2770). +pub fn block_mode(app: &PhotocraftApp, tool: Tool) -> bool { + tool == Tool::Eraser && app.ui.tool_options.eraser_mode == "block" +} + +/// The Block's edge in document pixels at the current zoom (what the engine erases). +pub fn block_size(app: &PhotocraftApp) -> f32 { + photocraft_engine::brush_cmds::block_size(f64::from(app.point_zoom())) +} + +/// The Eraser's Mode dropdown (Brush / Pencil / Block). Returns whether it changed. +pub fn mode_dropdown(ui: &mut egui::Ui, mode: &mut String) -> bool { + opt(ui, tl!("Mode:")); + let modes = [("brush".to_string(), tl!("Brush")), ("pencil".to_string(), tl!("Pencil")), ("block".to_string(), tl!("Block"))]; + widgets::dropdown(ui, "eraser-mode", mode, &modes, 96.0) +} + fn opt(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(tl!(&text)).color(t.text_dim).size(12.0)); diff --git a/crates/ui-egui/src/field_tab.rs b/crates/ui-egui/src/field_tab.rs new file mode 100644 index 0000000000..467c7d8b50 --- /dev/null +++ b/crates/ui-egui/src/field_tab.rs @@ -0,0 +1,228 @@ +//! Photoshop-style Tab in dialogs: Tab and ⇧Tab cycle a dialog's text and number fields only +//! (Name → Width → Height → Resolution in New Document, Hue → Saturation → Lightness in +//! Hue/Saturation), never its sliders, dropdowns, checkboxes or buttons, and a field reached this +//! way has its text selected so typing replaces it. +//! +//! egui's own Tab walks every focusable widget in creation order (the title bar, the preset +//! cards, each slider…). So the dialog frame (`dialogs::show`) takes the key before egui's focus +//! machinery acts on it ([`take_step`]), the fields register themselves as the body is laid out +//! ([`register`], from `widgets::number_edit` and the dialogs' text fields), and the frame then +//! hands focus to the next one ([`end`]). + +use egui::{Context, Id, Key, Modifiers}; + +fn list_id() -> Id { + Id::new("pc-field-tab-order") +} + +fn collecting_id() -> Id { + Id::new("pc-field-tab-collecting") +} + +fn pending_id() -> Id { + Id::new("pc-field-tab-pending") +} + +/// Give `id` the focus from the next frame on. Focusing a widget after it was laid out would +/// never read as "gained focus" to it (egui compares with the focus at the start of the frame), +/// and that is what selects a field's text; so the move waits for [`take_step`] next frame. +pub fn focus(ctx: &Context, id: Id) { + ctx.data_mut(|d| d.insert_temp(pending_id(), Some(id))); +} + +/// Tab (+1) or ⇧Tab (−1) pressed this frame, taken from the input so nothing else acts on it; +/// 0 when neither was. Also applies a pending [`focus`]. Call before the dialog is laid out. +pub fn take_step(ctx: &Context) -> i32 { + if let Some(id) = ctx.data_mut(|d| d.remove_temp::>(pending_id())).flatten() { + ctx.memory_mut(|m| m.request_focus(id)); + } + let step = ctx.input_mut(|i| { + if i.consume_key(Modifiers::SHIFT, Key::Tab) { + -1 + } else if i.consume_key(Modifiers::NONE, Key::Tab) { + 1 + } else { + 0 + } + }); + if step != 0 { + // egui read the key before this frame's widgets were laid out: cancel its own focus move. + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + step +} + +/// Start collecting the fields of a dialog body. +pub fn begin(ctx: &Context) { + ctx.data_mut(|d| { + d.insert_temp(collecting_id(), true); + d.insert_temp(list_id(), Vec::::new()); + }); +} + +/// A text or number field, in layout order. Outside a dialog body this does nothing. +pub fn register(ctx: &Context, id: Id) { + ctx.data_mut(|d| { + if d.get_temp::(collecting_id()).unwrap_or(false) { + d.get_temp_mut_or_default::>(list_id()).push(id); + } + }); +} + +/// End of the body: move focus by `step` through the registered fields, wrapping around (from +/// nothing focused, Tab starts at the first and ⇧Tab at the last). Returns the field focused. +pub fn end(ctx: &Context, step: i32) -> Option { + let fields: Vec = ctx.data_mut(|d| { + d.insert_temp(collecting_id(), false); + d.get_temp(list_id()).unwrap_or_default() + }); + if step == 0 || fields.is_empty() { + return None; + } + let n = fields.len() as i32; + let at = ctx.memory(|m| m.focused()).and_then(|f| fields.iter().position(|&id| id == f)).map(|i| i as i32); + let next = match at { + Some(i) => (i + step).rem_euclid(n), + None if step > 0 => 0, + None => n - 1, + }; + let id = fields.get(next as usize).copied()?; + focus(ctx, id); + Some(id) +} + +/// Select all of a text field's text (so typing replaces it), as Photoshop does when a field is +/// reached with Tab or stepped with the arrow keys. +pub fn select_all(ctx: &Context, id: Id, text: &str) { + let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::default(), egui::text::CCursor::new(text.chars().count())))); + state.store(ctx, id); +} + +#[cfg(test)] +mod tests { + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::{Value, json}; + + use crate::PhotocraftApp; + use crate::state::{DialogKind, UiState}; + + fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h + } + + fn fields(h: &Harness<'static, PhotocraftApp>) -> serde_json::Map { + h.state().ui.dialogs.first().map(|d| d.fields.clone()).expect("the dialog is open") + } + + fn num(h: &Harness<'static, PhotocraftApp>, key: &str) -> f64 { + fields(h).get(key).and_then(Value::as_f64).unwrap_or(f64::NAN) + } + + fn tab(h: &mut Harness<'static, PhotocraftApp>) { + h.key_press(egui::Key::Tab); + h.run_steps(2); + } + + fn arrow(h: &mut Harness<'static, PhotocraftApp>, key: egui::Key, m: egui::Modifiers) { + h.key_press_modifiers(m, key); + h.run_steps(2); + } + + fn type_text(h: &mut Harness<'static, PhotocraftApp>, text: &str) { + for c in text.chars() { + h.event(egui::Event::Text(c.to_string())); + h.run_steps(1); + } + } + + /// Hue/Saturation: Tab goes Hue → Saturation → Lightness (never the dropdown or the + /// sliders), ⇧Tab back, and ↑ / ↓ step the focused field by 1 (⇧: 10). + #[test] + fn hue_saturation_tabs_through_its_fields_and_arrows_step_them() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let mut h = harness(PhotocraftApp::new(s, crate::Services::default())); + crate::adjust_dialog::open(h.state_mut(), "image.adjustments.hueSaturation").unwrap(); + h.run_steps(3); + assert_eq!(h.ctx.memory(|m| m.focused()), None, "nothing is focused to begin with"); + tab(&mut h); + assert!(h.query_all_by_role(Role::SpinButton).next().is_some_and(|n| n.is_focused()), "the first Tab lands on Hue"); + arrow(&mut h, egui::Key::ArrowUp, egui::Modifiers::NONE); + assert_eq!(num(&h, "hue"), 1.0); + arrow(&mut h, egui::Key::ArrowUp, egui::Modifiers::SHIFT); + assert_eq!(num(&h, "hue"), 11.0); + arrow(&mut h, egui::Key::ArrowDown, egui::Modifiers::NONE); + assert_eq!(num(&h, "hue"), 10.0); + // The text stays selected: typing replaces it. + type_text(&mut h, "-5"); + assert_eq!(num(&h, "hue"), -5.0); + tab(&mut h); + arrow(&mut h, egui::Key::ArrowUp, egui::Modifiers::NONE); + assert_eq!((num(&h, "hue"), num(&h, "saturation")), (-5.0, 1.0), "the second Tab is Saturation"); + tab(&mut h); + arrow(&mut h, egui::Key::ArrowDown, egui::Modifiers::SHIFT); + assert_eq!(num(&h, "lightness"), -10.0, "the third is Lightness"); + tab(&mut h); + arrow(&mut h, egui::Key::ArrowUp, egui::Modifiers::NONE); + assert_eq!(num(&h, "hue"), -4.0, "Tab wraps around to Hue"); + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab); + h.run_steps(2); + arrow(&mut h, egui::Key::ArrowUp, egui::Modifiers::NONE); + assert_eq!(num(&h, "lightness"), -9.0, "⇧Tab goes back to Lightness"); + // The range clamps. + for _ in 0..12 { + arrow(&mut h, egui::Key::ArrowDown, egui::Modifiers::SHIFT); + } + assert_eq!(num(&h, "lightness"), -100.0); + assert!(h.state().ui.dialogs.len() == 1, "Tab never closed the dialog"); + } + + /// New Document opens with the name selected; Tab goes Width → Height → Resolution and wraps. + #[test] + fn new_document_opens_on_the_name_and_tabs_to_width_then_height() { + let mut h = harness(PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())); + h.state_mut().ui.open_dialog(DialogKind::NewDocument, UiState::new_document_fields()); + h.run_steps(3); + assert!(h.get_by_role(Role::TextInput).is_focused(), "the name field has focus"); + type_text(&mut h, "Poster"); + assert_eq!(fields(&h)["name"], "Poster", "typing replaced the selected name"); + tab(&mut h); + type_text(&mut h, "512"); + tab(&mut h); + type_text(&mut h, "300"); + tab(&mut h); + type_text(&mut h, "150"); + let f = fields(&h); + assert_eq!((f["width"].as_u64(), f["height"].as_u64(), f["resolution"].as_f64()), (Some(512), Some(300), Some(150.0)), "{f:?}"); + // Arrows step a size field too. + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab); + h.run_steps(2); + arrow(&mut h, egui::Key::ArrowUp, egui::Modifiers::SHIFT); + assert_eq!(fields(&h)["height"].as_u64(), Some(310)); + // Tab from the last field wraps to the name. + tab(&mut h); + tab(&mut h); + assert!(h.get_by_role(Role::TextInput).is_focused(), "Tab wrapped to the name"); + type_text(&mut h, "Flyer"); + assert_eq!(fields(&h)["name"], "Flyer"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let d = &h.state().session.active().expect("a new document").doc; + assert_eq!((d.name.as_str(), d.size.width, d.size.height), ("Flyer", 512, 310)); + } + + /// Tab is the dialog's: the modal underneath never sees it, and a dialog with no fields swallows it. + #[test] + fn tab_in_a_dialog_without_fields_does_nothing() { + let mut h = harness(PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())); + h.state_mut().ui.open_dialog(DialogKind::About, Default::default()); + h.run_steps(3); + tab(&mut h); + assert_eq!(h.ctx.memory(|m| m.focused()), None); + assert_eq!(h.state().ui.dialogs.len(), 1); + } +} diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index 99e44f4bfc..b314aba318 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -12,6 +12,7 @@ //! //! One dialog at a time: asking while one is open is refused. +use std::path::Path; use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; use photocraft_doc::DocId; @@ -37,6 +38,8 @@ pub enum FileDialogAnswer { Paths(Vec), /// A chosen file's name and contents (the web, where a page gets contents rather than paths). Contents(String, Vec), + /// Several chosen files' names and contents, in order (the web's multi-select File › Open). + ContentsMany(Vec<(String, Vec)>), /// The path to save to (desktop), or the download's name (web). SaveTo(String), } @@ -106,17 +109,35 @@ impl PhotocraftApp { /// Ask where to save: `then` gets the chosen path, with a lowercase extension when /// Preferences ▸ File Handling › Lowercase Extension is on (its default). + /// + /// A bare file name (export dialogs, an untitled document) starts in the folder the active + /// document was last saved or exported to through a dialog, else beside the document + /// (#1826, #1866). A caller's explicit directory is kept. The chosen folder is remembered for + /// the document the dialog was asked for. pub(crate) fn pick_save(&mut self, suggested: &str, then: impl FnOnce(&mut Self, String) -> Result + 'static) -> Result { let mut suggested = std::path::PathBuf::from(suggested); - // Export dialogs usually provide only a file name. Start beside the source document, - // while preserving a caller's explicit directory and the untitled-document fallback. + let doc = self.session.active().map(|d| d.doc.id); + // Closed documents' folders are no longer needed. + let open: Vec<_> = self.session.documents().iter().map(|d| d.doc.id).collect(); + self.save_dirs.retain(|id, _| open.contains(id)); if suggested.parent().is_some_and(|p| p.as_os_str().is_empty()) - && let Some(dir) = self.session.active().and_then(|d| d.path.as_deref()).and_then(|p| std::path::Path::new(p).parent()) + && let Some(dir) = doc + .and_then(|d| self.save_dirs.get(&d).cloned()) + .or_else(|| self.session.active().and_then(|d| d.path.as_deref()).and_then(|p| std::path::Path::new(p).parent()).map(Path::to_path_buf)) { suggested = dir.join(suggested); } self.ask_file(FileDialogRequest::Save { suggested: suggested.to_string_lossy().into_owned() }, move |app, answer| match answer { - FileDialogAnswer::SaveTo(path) => then(app, Self::lowercased_extension(app, path)), + FileDialogAnswer::SaveTo(path) => { + let path = Self::lowercased_extension(app, path); + // The web answers a download name, which has no folder to remember. + if let Some(doc) = doc + && let Some(dir) = Path::new(&path).parent().filter(|p| !p.as_os_str().is_empty()) + { + app.save_dirs.insert(doc, dir.to_path_buf()); + } + then(app, path) + } _ => Err(UNEXPECTED.into()), }) } @@ -163,17 +184,23 @@ impl PhotocraftApp { FileDialogAnswer::Paths(paths) => { app.open_paths(&paths); } - FileDialogAnswer::Contents(name, bytes) => { - if let Err(e) = app.open_bytes(&name, &bytes) { - app.open_failed(&name, &e); - } - } + FileDialogAnswer::Contents(name, bytes) => app.open_contents(vec![(name, bytes)]), + FileDialogAnswer::ContentsMany(files) => app.open_contents(files), FileDialogAnswer::SaveTo(_) => return Err(UNEXPECTED.into()), } Ok(Value::Null) }) } + /// Opens each picked file's contents as its own document, reporting each failure. + fn open_contents(&mut self, files: Vec<(String, Vec)>) { + for (name, bytes) in files { + if let Err(e) = self.open_bytes(&name, &bytes) { + self.open_failed(&name, &e); + } + } + } + /// Run `next` on the result of the open dialog's action once it is answered; its result /// replaces the action's. False when no dialog is open. pub(crate) fn after_file_dialog(&mut self, next: impl FnOnce(&mut Self, Result) -> Result + 'static) -> bool { @@ -248,6 +275,7 @@ fn last_used_dir(recent: &[String]) -> Option { fn read_picked(answer: FileDialogAnswer) -> Result<(String, Vec), String> { match answer { FileDialogAnswer::Contents(name, bytes) => Ok((name, bytes)), + FileDialogAnswer::ContentsMany(files) => files.into_iter().next().ok_or_else(|| CANCELLED.into()), FileDialogAnswer::Paths(paths) => { let path = paths.into_iter().next().ok_or(CANCELLED)?; let bytes = photocraft_format::read_file(std::path::Path::new(&path)).map_err(|e| format!("{}: {e}", display_name(&path)))?; diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index b0c356ada3..dc4db4c51a 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -144,7 +144,8 @@ fn layered_save_respects_explicit_jpeg_choices() { answer(&open, Some(FileDialogAnswer::SaveTo("/exports/chosen.JPEG".into()))); app.poll_file_dialog(&ctx, None); assert_eq!(*written.borrow(), ["/exports/chosen.jpeg"]); - assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/exports/chosen.jpeg")); + // The JPEG can't hold the layers, so it is a copy and the document keeps its file (#2550). + assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/pics/My image.JPEG")); // Explicit command paths carry the same choice and bypass the picker. for command in ["file.save", "file.saveAs"] { let (mut app, open, written) = jpeg_app(Some("/pics/My image.JPEG"), true); @@ -186,6 +187,18 @@ fn filtered_picker_requests_only_the_given_extensions() { )); } +#[test] +fn scripts_browse_lists_only_script_files() { + let (mut app, open, _) = app(); + menus::invoke(&mut app, &egui::Context::default(), "file.scripts.browse", json!({})).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + assert!(matches!( + open.borrow().first().map(|(r, _)| r), + Some(FileDialogRequest::Open { multiple: false, extensions: Some(exts), .. }) + if exts.iter().map(String::as_str).collect::>() == ["psjs", "ccjs", "jsx", "js", "jsxbin", "json", "txt"] + )); +} + #[test] fn last_used_dir_takes_the_most_recent_parent() { assert_eq!(last_used_dir(&[]), None); @@ -279,6 +292,84 @@ fn save_and_export_dialogs_start_beside_the_document() { } } +/// The path `pick_save` suggests for `name` in `dir` on this platform: it joins with `Path::join`, +/// which uses `\` on Windows, so the expected values must be built the same way (#2745). +fn joined(dir: &str, name: &str) -> String { + std::path::Path::new(dir).join(name).to_string_lossy().into_owned() +} + +/// #1826: after the user navigates a save or export dialog elsewhere, the document's next dialog +/// starts there. Save As of a saved document still suggests its own path; other documents and +/// closed documents are unaffected. +#[test] +fn save_dialogs_remember_where_each_document_was_last_saved() { + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + let first = app.session.active().unwrap().doc.id; + app.session.active_mut().unwrap().path = Some("/proj/original.psd".into()); + let suggested = |open: &Open| match &open.borrow()[0].0 { + FileDialogRequest::Save { suggested } => suggested.clone(), + other => panic!("save dialog expected, got {other:?}"), + }; + // An export starts beside the document; the user saves it under /renders instead. + app.pick_save("result.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), joined("/proj", "result.png")); + answer(&open, Some(FileDialogAnswer::SaveTo("/renders/result.png".into()))); + app.poll_file_dialog(&ctx, None); + // The next export starts in /renders; Save As still offers the document's own path. + app.pick_save("again.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), joined("/renders", "again.png")); + answer(&open, None); + app.poll_file_dialog(&ctx, None); + menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), "/proj/original.psd"); + answer(&open, None); + app.poll_file_dialog(&ctx, None); + // A cancelled dialog and an explicit directory change nothing. + app.pick_save("/elsewhere/x.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), "/elsewhere/x.png"); + answer(&open, None); + app.poll_file_dialog(&ctx, None); + app.pick_save("third.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), joined("/renders", "third.png")); + answer(&open, None); + app.poll_file_dialog(&ctx, None); + // Another document has its own memory: untitled, it starts wherever the shell defaults. + app.run("file.new", json!({"width": 4, "height": 4})).unwrap(); + let second = app.session.active().unwrap().doc.id; + assert_ne!(first, second); + app.pick_save("fresh.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), "fresh.png"); + answer(&open, Some(FileDialogAnswer::SaveTo("/other/fresh.png".into()))); + app.poll_file_dialog(&ctx, None); + assert_eq!(app.save_dirs.get(&second).map(|p| p.to_string_lossy().into_owned()).as_deref(), Some("/other")); + assert_eq!(app.save_dirs.get(&first).map(|p| p.to_string_lossy().into_owned()).as_deref(), Some("/renders")); + // The folder is remembered for the document the dialog was asked for, not the active one. + app.session.set_active(0); + app.pick_save("swap.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), joined("/renders", "swap.png")); + app.session.set_active(1); + answer(&open, Some(FileDialogAnswer::SaveTo("/moved/swap.png".into()))); + app.poll_file_dialog(&ctx, None); + assert_eq!(app.save_dirs.get(&first).map(|p| p.to_string_lossy().into_owned()).as_deref(), Some("/moved")); + assert_eq!(app.save_dirs.get(&second).map(|p| p.to_string_lossy().into_owned()).as_deref(), Some("/other")); + // Closing a document forgets its folder at the next dialog. + app.run("file.close", json!({"document": 1})).unwrap(); + app.pick_save("last.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), joined("/moved", "last.png")); + assert!(!app.save_dirs.contains_key(&second)); + answer(&open, None); + app.poll_file_dialog(&ctx, None); +} + #[test] fn save_suggestions_preserve_explicit_directories_and_untitled_names() { let (mut app, open, _) = app(); @@ -405,3 +496,93 @@ fn an_answer_of_the_wrong_kind_is_an_error_not_a_crash() { app.poll_file_dialog(&ctx, None); assert_eq!(app.session.documents().len(), 1); } + +/// The app with a painted layer converted to a smart object and a duplicate instance of it. +fn smart_app() -> (PhotocraftApp, Open, Rc>>, [u64; 2]) { + let (mut app, open, written) = app(); + app.run("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 4, 4), &[0.0, 0.0, 1.0, 1.0]); + Ok(()) + }) + .unwrap(); + let a = app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap(); + let b = app.run("layer.duplicate", json!({"layer": a})).unwrap()["layer"].as_u64().unwrap(); + app.session.select_layer(photocraft_doc::LayerId(a)).unwrap(); + (app, open, written, [a, b]) +} + +fn smart_source(app: &PhotocraftApp, id: u64) -> photocraft_doc::SmartSource { + match &app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + photocraft_doc::LayerContent::Smart(sm) => sm.source.clone(), + other => panic!("not a smart object: {}", other.kind_name()), + } +} + +#[test] +fn replace_contents_picks_a_file_for_every_instance() { + let (mut app, open, _, ids) = smart_app(); + let ctx = egui::Context::default(); + let before = ids.map(|id| smart_source(&app, id)); + // Cancelling changes nothing. + menus::invoke(&mut app, &ctx, "layer.smartObjects.replaceContents", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Open { multiple: false, .. }, _)])); + answer(&open, None); + app.poll_file_dialog(&ctx, None); + assert_eq!(ids.map(|id| smart_source(&app, id)), before); + // A picked file (by contents, as on the web) replaces the contents of both instances. + let png = photocraft_io::export(&app.session.active().unwrap().doc, "png", &Default::default()).unwrap().bytes; + menus::invoke(&mut app, &ctx, "layer.smartObjects.replaceContents", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::Contents("art.png".into(), png))); + app.poll_file_dialog(&ctx, None); + assert!(!app.ui.status_error, "{}", app.ui.status); + for id in ids { + assert!(matches!(smart_source(&app, id), photocraft_doc::SmartSource::Embedded { ref file_name, .. } if file_name == "art.png")); + } + // A file that isn't an image is an error, and the contents stay. + menus::invoke(&mut app, &ctx, "layer.smartObjects.replaceContents", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::Contents("notes.png".into(), b"not an image".to_vec()))); + app.poll_file_dialog(&ctx, None); + assert!(app.ui.status_error); + assert!(matches!(smart_source(&app, ids[1]), photocraft_doc::SmartSource::Embedded { ref file_name, .. } if file_name == "art.png")); +} + +#[test] +fn export_contents_suggests_the_contents_file_name() { + let (mut app, open, written, _) = smart_app(); + let ctx = egui::Context::default(); + menus::invoke(&mut app, &ctx, "layer.smartObjects.exportContents", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + assert!( + matches!(open.borrow().as_slice(), [(FileDialogRequest::Save { suggested }, _)] if suggested == "Layer 1.pcraft"), + "{:?}", + open.borrow().first().map(|(r, _)| r.clone()) + ); + answer(&open, Some(FileDialogAnswer::SaveTo("/out/contents.pcraft".into()))); + app.poll_file_dialog(&ctx, None); + assert_eq!(*written.borrow(), ["/out/contents.pcraft"]); +} + +#[test] +fn convert_to_linked_asks_where_to_write_the_contents() { + let (mut app, open, _, ids) = smart_app(); + let ctx = egui::Context::default(); + let dir = std::env::temp_dir().join(format!("pcraft-convert-linked-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("linked.pcraft").to_string_lossy().into_owned(); + menus::invoke(&mut app, &ctx, "layer.smartObjects.convertToLinked", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Save { .. }, _)])); + answer(&open, Some(FileDialogAnswer::SaveTo(path.clone()))); + app.poll_file_dialog(&ctx, None); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert!(std::fs::metadata(&path).is_ok_and(|m| m.len() > 0)); + std::fs::remove_dir_all(&dir).ok(); + for id in ids { + assert_eq!(smart_source(&app, id), photocraft_doc::SmartSource::Linked { path: path.clone() }); + } +} diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index ad3651d6db..995eef15b3 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -161,6 +161,18 @@ fn the_web_hands_over_contents_rather_than_paths() { assert_eq!((st.doc.name.as_str(), st.path.as_deref()), ("cat.png", None)); } +#[test] +fn the_web_opens_every_selected_file() { + let files = vec![("one.png".into(), b"x".to_vec()), ("broken.png".into(), b"bad".to_vec()), ("two.jpg".into(), b"x".to_vec())]; + let (mut app, _) = app_with(vec![Some(FileDialogAnswer::ContentsMany(files))]); + menus::invoke(&mut app, &egui::Context::default(), "file.open", json!({})).unwrap(); + answer(&mut app); + // Each good file is its own tab; the broken one is reported without stopping the rest. + let names: Vec<_> = app.session.documents().iter().map(|st| st.doc.name.as_str()).collect(); + assert_eq!(names, ["one.png", "two.jpg"]); + assert!(app.ui.notices.iter().any(|n| n.error && n.title.contains("broken.png")), "{:?}", app.ui.notices); +} + #[test] fn pcraft_documents_save_in_place_but_flat_files_ask() { let (mut app, written) = app_with(vec![None]); diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 8e1c8eb95b..e7c85e6ac6 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -39,6 +39,9 @@ fn form(app: &mut PhotocraftApp, command: &str, label: &str, fields: Value, choi json!({"dialog": app.ui.open_dialog(DialogKind::Command, f)}) } +/// File › Scripts › Browse… lists these script files (plus the JSON/text the engine parses). +const SCRIPT_EXTENSIONS: &[&str] = &["psjs", "ccjs", "jsx", "js", "jsxbin", "json", "txt"]; + /// Menu items fronted here (dialogs before the engine command). `None` when not ours. pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { if !no_params(params) { @@ -86,7 +89,7 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &V form(app, id, "Image Statistics", json!({"mode": "median", "input": dir, "align": false}), json!({"mode": modes})) } "file.scripts.browse" => { - return Some(app.pick_file_bytes(|app, name, bytes| { + return Some(app.pick_file_bytes_filtered(SCRIPT_EXTENSIONS, |app, name, bytes| { let r = app.run("file.scripts.browse", json!({"script": String::from_utf8_lossy(&bytes)})); if r.is_ok() { app.ui.status = format!("Ran script {name}"); diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 40380077f2..d0c79d4faa 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -155,34 +155,81 @@ fn is_pixel_param(key: &str) -> bool { | "size" | "blur" | "speed" + | "shadowRadius" + | "highlightRadius" ) } +/// [`is_pixel_param`] for `key` of `command`. Some commands use those names for relative values: +/// Tree's and Picture Frame's `size` (a fraction of the canvas, an ornament scale) and Lens +/// Correction's perspective `horizontal` / `vertical` (-100..100). Scaled by the proxy factor, +/// their preview drew a quarter-size tree and a quarter of the perspective (#2063). +fn is_pixel_param_of(command: &str, key: &str) -> bool { + is_pixel_param(key) + && !matches!((command, key), ("filter.render.tree" | "filter.render.pictureFrame", "size") | ("filter.lensCorrection", "horizontal" | "vertical")) +} + /// Filters whose features have a fixed size in document pixels that no dialog parameter scales: -/// per-pixel noise and dots (Diffuse, Mezzotint, Fibers), fixed wavelengths and brush widths -/// (Ripple, Oil Paint) and pixel-sized geometry (Flame's length, width and interval). On a -/// reduced proxy those features come out k times too large, so these preview at full resolution: -/// what the preview shows is what OK applies, as in Photoshop (#2063). -const FULL_RESOLUTION_PREVIEW: &[&str] = - &["filter.distort.ripple", "filter.stylize.diffuse", "filter.stylize.oilPaint", "filter.render.flame", "filter.render.fibers", "filter.pixelate.mezzotint"]; +/// per-pixel noise and dots (Add Noise, Diffuse, Mezzotint, Fibers), pixel kernels and scan lines +/// (Custom, Reduce Noise, De-Interlace, Trace Contour's one-pixel lines), fixed lengths and +/// widths (Ripple, Oil Paint, Wind's streaks, Extrude's depth, Flame's length, width and +/// interval) and Tree, whose branches are built from 6 px segments. On a reduced proxy those +/// features come out k times too large (or a different tree grows), so these preview at full +/// resolution: what the preview shows is what OK applies, as in Photoshop (#2063). +const FULL_RESOLUTION_PREVIEW: &[&str] = &[ + "filter.distort.ripple", + "filter.stylize.diffuse", + "filter.stylize.oilPaint", + "filter.render.flame", + "filter.render.fibers", + "filter.pixelate.mezzotint", + "filter.noise.addNoise", + "filter.noise.reduceNoise", + "filter.other.custom", + "filter.video.deInterlace", + "filter.stylize.traceContour", + "filter.stylize.wind", + "filter.stylize.extrude", + "filter.render.tree", +]; + +/// Whether `command` with `params` previews at full resolution: [`FULL_RESOLUTION_PREVIEW`], every +/// Filter Gallery filter (its strokes, grain and textures are sized in pixels; the settings are +/// abstract levels, not lengths) and the dithered colour-mode conversions (Indexed Color, Bitmap). +fn full_resolution_preview(command: &str, params: &Value) -> bool { + let choice = |key: &str| params.get(key).and_then(Value::as_str); + FULL_RESOLUTION_PREVIEW.contains(&command) + || command.starts_with("filter.gallery.") + || (command == "image.mode.indexedColor" && choice("dither") != Some("none")) + || (command == "image.mode.bitmap" && !matches!(choice("method"), Some("threshold" | "threshold50"))) +} /// Proxy factor for a live preview of `command` with `params`, given the document's reduced factor -/// `k`: 1 for [`FULL_RESOLUTION_PREVIEW`] filters, otherwise the largest factor up to `k` at which +/// `k`: 1 for [`full_resolution_preview`] commands, otherwise the largest factor up to `k` at which /// no pixel-sized parameter, divided by it, drops below the command's minimum. A clamped value /// would change the feature size: Pointillize's 5 px cells at k = 4 became the 3 px minimum, i.e. /// 12 px cells in the preview (#2063). pub fn preview_factor(command: &str, params: &Value, k: u32) -> u32 { - if k <= 1 || FULL_RESOLUTION_PREVIEW.contains(&command) { + if k <= 1 || full_resolution_preview(command, params) { return 1; } let Some(spec) = photocraft_engine::commands::find(command) else { return k }; - parse_spec(spec.params).into_iter().fold(k, |k, p| match p.kind { - Kind::Range { min, .. } if min > 0.0 && is_pixel_param(&p.key) => match params.get(&p.key).and_then(Value::as_f64) { + let k = parse_spec(spec.params).into_iter().fold(k, |k, p| match p.kind { + Kind::Range { min, .. } if min > 0.0 && is_pixel_param_of(command, &p.key) => match params.get(&p.key).and_then(Value::as_f64) { Some(v) if v.is_finite() => k.min((v / f64::from(min)).floor().clamp(1.0, f64::from(k)) as u32), _ => k, }, _ => k, - }) + }); + // Mosaic rounds its cells to whole pixels: 10 px cells at k = 4 became 3 proxy px = 12 px. + // Divide the cell size evenly instead. + match params.get("cellSize").and_then(Value::as_f64) { + Some(v) if command == "filter.pixelate.mosaic" && v.is_finite() && v >= 1.0 => { + let cell = v.round().min(f64::from(u32::MAX)) as u32; + (1..=k).rev().find(|d| cell.is_multiple_of(*d)).unwrap_or(1) + } + _ => k, + } } /// Commands outside `filter.*` that get the schema dialog *with* live preview. @@ -200,6 +247,7 @@ pub const PREVIEWED: &[&str] = &[ "layer.matting.defringe", "layer.matting.colorDecontaminate", "layer.layerStyle.scaleEffects", + "type.warpText", ]; pub fn has_dialog(command: &str) -> bool { @@ -292,6 +340,9 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { if command == "image.rotation.arbitrary" { straighten_defaults(app, &mut fields); } + if command == "type.warpText" { + warp_defaults(app, &mut fields); + } // Photoshop's Pattern Name dialog starts from the name the pattern would get anyway. if command == "edit.definePattern" { fields.insert("name".into(), json!(photocraft_engine::pattern_cmds::default_name(&app.session))); @@ -317,6 +368,28 @@ fn straighten_defaults(app: &PhotocraftApp, fields: &mut Map) { fields.insert("direction".into(), json!(if rot < 0.0 { "ccw" } else { "cw" })); } +/// Warp Text edits the type layers it opened on (the selected ones, or the one being edited) and +/// starts from the warp the shown layer has, or None, never from remembered values (#218). +fn warp_defaults(app: &PhotocraftApp, fields: &mut Map) { + if let Some(Value::Object(targets)) = crate::type_tool::formatting_params(app) { + fields.extend(targets); + } + let warp = crate::type_tool::target_text(app).and_then(|t| t.warp.clone()); + let w = warp.unwrap_or(photocraft_doc::text::TextWarp { + style: "warpNone".into(), + value: 50.0, + horizontal_distortion: 0.0, + vertical_distortion: 0.0, + horizontal: true, + }); + // A style without a short id (from a PSD) shows as None, with the layer's values. + fields.insert("style".into(), json!(photocraft_text::warp::short_style(&w.style).unwrap_or("none"))); + fields.insert("bend".into(), json!(w.value)); + fields.insert("horizontalDistortion".into(), json!(w.horizontal_distortion)); + fields.insert("verticalDistortion".into(), json!(w.vertical_distortion)); + fields.insert("orientation".into(), json!(if w.horizontal { "horizontal" } else { "vertical" })); +} + /// A filter dialog with live preview for `command` whose parameters follow `spec` (registry /// notation) instead of the command's own; `fixed` params (e.g. a plug-in id) are passed through. pub fn open_with_spec(app: &mut PhotocraftApp, command: &str, label: &str, spec: &str, fixed: Map) -> u64 { @@ -390,7 +463,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { f.insert(p.key, json!(count)); continue; } - let unit = if is_pixel_param(&p.key) { + let unit = if is_pixel_param_of(&cmd, &p.key) { "px" } else if p.key == "angle" { "°" @@ -484,19 +557,25 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { let set = f.get(&p.key).and_then(Value::as_str).and_then(crate::color_picker_ui::parse_hex); let rgb = set.or(default).unwrap_or([0.0; 3]); let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - let mut bytes = rgb.map(q); - let mut changed = false; + let bytes = rgb.map(q); + let name = label(&p.key); + let mut clicked = false; ui.horizontal(|ui| { - ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim)); + ui.label(egui::RichText::new(&name).color(t.text_dim)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - changed = crate::widgets::color_edit_button_srgb(ui, &mut bytes).changed(); + // PhotoCraft's Color Picker, as everywhere else (#2144). + clicked = crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(bytes[0], bytes[1], bytes[2]), &name).clicked(); ui.label(egui::RichText::new(crate::color_picker_ui::hex(rgb)).color(t.text_dim).monospace()); }); }); - if changed { - f.insert(p.key, json!(crate::color_picker_ui::hex(bytes.map(|b| f32::from(b) / 255.0)))); - } else if set.is_none() && default.is_some() { - f.insert(p.key, json!(crate::color_picker_ui::hex(rgb))); + if set.is_none() && default.is_some() { + f.insert(p.key.clone(), json!(crate::color_picker_ui::hex(rgb))); + } + if clicked { + if !f.contains_key(&p.key) { + f.insert(p.key.clone(), json!(crate::color_picker_ui::hex(rgb))); + } + crate::color_picker_ui::request_field(f, &p.key, &crate::color_picker_ui::title_for(&name)); } } Kind::Int { default } => { @@ -543,7 +622,10 @@ pub fn preview_document_with( k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>, ) -> Option { - let proxy = crate::proxy::proxy_document(doc, k); + // Warp Text renders the type again from the layer model (font size, position), which a + // reduced proxy doesn't scale: it runs at full size and the result is reduced (#218). + let (run_k, reduce_k) = if command == "type.warpText" { (1, k) } else { (k, 1) }; + let proxy = crate::proxy::proxy_document(doc, run_k); let mut s = photocraft_engine::Session::new(); s.set_inline_job_ctx(cancel.cloned()); s.add_document(proxy, None); @@ -551,12 +633,12 @@ pub fn preview_document_with( s.select_layer(id).ok()?; } let mut p = params.clone(); - if k > 1 + if run_k > 1 && let Some(o) = p.as_object_mut() { let spec = photocraft_engine::commands::find(command).map(|c| parse_spec(c.params)).unwrap_or_default(); for (key, v) in o.iter_mut() { - if is_pixel_param(key) + if is_pixel_param_of(command, key) && let Some(x) = v.as_f64() { // Match the engine's minimum after scaling (e.g. Box Blur 1, Mosaic 2). @@ -566,12 +648,15 @@ pub fn preview_document_with( _ => None, }); let floor = minimum.unwrap_or(0.0).max(if key == "cellSize" { 1.0 } else { 0.1 }); - *v = json!((x / k as f64).max(floor)); + // Negative lengths (Offset's left/up shift, Displace's inverted scale) keep their + // sign: the floor that keeps a radius positive turned them into +0.1 (#2063). + let x = x / run_k as f64; + *v = json!(if x > 0.0 { x.max(floor) } else { minimum.map_or(x, |m| x.max(m)) }); } } } s.execute(command, p).ok()?; - s.active().map(|d| (*d.doc).clone()) + s.active().map(|d| crate::proxy::proxy_document(&d.doc, reduce_k)) } /// Cached preview state on the app. @@ -803,6 +888,53 @@ mod tests { } } + /// A serde type error from numbers the dialog sends as JSON floats (`1.0`) for an integer + /// field ("invalid type: floating point `1.0`, expected u32"), as opposed to a legitimate + /// refusal such as "no selection" or "needs a map". + fn float_type_error(e: &str) -> bool { + e.contains("invalid type: floating point") + } + + /// #2501: every schema dialog, opened with its default fields and drawn once, runs its command + /// (OK) and its live preview without a type error from integer fields sent as floats. + #[test] + fn every_dialog_default_runs_without_float_type_errors() { + let ids: Vec<&str> = photocraft_engine::command_specs().iter().map(|c| c.id).filter(|id| has_dialog(id)).collect(); + assert!(ids.len() > 100, "{} dialogs", ids.len()); + let mut failures = Vec::new(); + for id in ids { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 24, "height": 24})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let Some(dialog) = open(&mut app, id) else { continue }; + let mut f = app.ui.dialogs.iter().find(|d| d.id == dialog).unwrap().fields.clone(); + egui::Context::default().run_ui(Default::default(), |ui| body(ui, &mut f)).textures_delta.clear(); + let params = params_of(&f); + let st = app.session.active().unwrap(); + let (doc, active) = ((*st.doc).clone(), st.active_layer); + if let Err(e) = app.session.execute(id, params.clone()) { + let e = e.to_string(); + if float_type_error(&e) { + failures.push(format!("{id} OK {params}: {e}")); + } + } + // The preview swallows the error; rerun its params at full size to see it. + if preview_document(&doc, active, id, ¶ms, 1).is_none() { + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + if let Some(a) = active { + s.select_layer(a).unwrap(); + } + if let Err(e) = s.execute(id, params.clone()).map_err(|e| e.to_string()) + && float_type_error(&e) + { + failures.push(format!("{id} preview {params}: {e}")); + } + } + } + assert!(failures.is_empty(), "{failures:#?}"); + } + #[test] fn colour_params_parse_with_and_without_a_default() { let p = parse_spec(r##"{"color":color=#ffffff,"vineColor":color,"bad":color=#zz}"##); @@ -949,13 +1081,15 @@ mod tests { a.px.iter().zip(&b.px).map(|(p, q)| (0..3).map(|c| (p[c] - q[c]).abs()).sum::() / 3.0).sum::() / a.px.len() as f32 } - /// #2063: Ripple, Diffuse, Oil Paint, Flame, Fibers and Mezzotint drew their features in - /// proxy pixels, so a 1/4 preview showed them 4x larger than OK applied them. Their preview - /// now runs at full resolution and matches the applied result. + /// #2063: these filters drew their features in proxy pixels (noise, dots, dither, strokes, + /// kernels, scan lines, contour lines, streaks, depths, a tree's segments), so a 1/4 preview + /// showed them 4x larger than OK applied them. Their preview now runs at full resolution and + /// matches the applied result. #[test] fn fixed_size_filters_preview_at_the_scale_they_apply() { - let doc = waves(256); - let bg = doc.layers[0].id; + let doc = sharp(256); + let gray = grayscale(&doc); + let edge = json!([0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, -1, 4, -1, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0, 0]); for (cmd, p) in [ ("filter.distort.ripple", json!({"amount": 300.0, "size": "large"})), ("filter.stylize.diffuse", json!({"mode": "normal"})), @@ -963,15 +1097,92 @@ mod tests { ("filter.render.flame", json!({})), ("filter.render.fibers", json!({})), ("filter.pixelate.mezzotint", json!({"type": "mediumDots"})), + ("filter.noise.addNoise", json!({"amount": 12.5})), + ("filter.noise.reduceNoise", json!({})), + ("filter.other.custom", json!({"kernel": edge})), + ("filter.video.deInterlace", json!({})), + ("filter.stylize.traceContour", json!({})), + ("filter.stylize.wind", json!({"method": "blast"})), + ("filter.stylize.extrude", json!({})), + ("filter.render.tree", json!({"baseTreeType": 1})), + ("filter.gallery.graphicPen", json!({})), + ("filter.gallery.stainedGlass", json!({"cellSize": 10})), + ("image.mode.indexedColor", json!({"colors": 16})), + ("image.mode.bitmap", json!({"method": "diffusion"})), ] { - let applied = preview_document(&doc, Some(bg), cmd, &p, 1).unwrap(); - let old = preview_document(&doc, Some(bg), cmd, &p, 4).unwrap(); + let doc = if cmd == "image.mode.bitmap" { &gray } else { &doc }; + let bg = doc.layers[0].id; + let applied = preview_document(doc, Some(bg), cmd, &p, 1).unwrap(); + let old = preview_document(doc, Some(bg), cmd, &p, 4).unwrap(); assert!(diff_at_quarter(&applied, &old) > 0.005, "{cmd}: a 1/4 proxy differs from the result"); let k = preview_factor(cmd, &p, 4); assert_eq!(k, 1, "{cmd}"); - let shown = preview_document(&doc, Some(bg), cmd, &p, k).unwrap(); + let shown = preview_document(doc, Some(bg), cmd, &p, k).unwrap(); assert!(diff_at_quarter(&applied, &shown) < 1e-4, "{cmd}: the preview is what OK applies"); } + // Without dithering, the colour-mode conversions keep the proxy. + assert_eq!(preview_factor("image.mode.indexedColor", &json!({"dither": "none"}), 4), 4); + assert_eq!(preview_factor("image.mode.bitmap", &json!({"method": "threshold"}), 4), 4); + } + + /// #2063: proxy previews scaled what isn't a length (Tree's canvas fraction, Lens Correction's + /// perspective), clamped negative lengths to +0.1 (Offset left/up), left Shadows/Highlights' + /// radii in document pixels and rounded Mosaic's 10 px cells to 3 proxy px (12 px). + #[test] + fn proxy_previews_scale_only_lengths() { + let doc = waves(256); + let bg = doc.layers[0].id; + let error = |cmd: &str, p: Value| { + let k = preview_factor(cmd, &p, 4); + let applied = preview_document(&doc, Some(bg), cmd, &p, 1).unwrap(); + let shown = preview_document(&doc, Some(bg), cmd, &p, k).unwrap(); + (k, mean_abs(&every_kth(&applied, k), &every_kth(&shown, 1))) + }; + for (cmd, p) in [ + ("filter.lensCorrection", json!({"vertical": -100.0, "horizontal": 60.0})), + ("filter.other.offset", json!({"horizontal": -40, "vertical": -24, "undefinedAreas": "wrap"})), + ( + "image.adjustments.shadowsHighlights", + json!({"shadowAmount": 50.0, "shadowRadius": 30.0, "highlightAmount": 50.0, "highlightRadius": 30.0, "blackClip": 0.0, "whiteClip": 0.0}), + ), + ] { + let (k, diff) = error(cmd, p); + assert_eq!(k, 4, "{cmd} keeps the proxy"); + assert!(diff < 0.01, "{cmd}: the preview shows what OK applies ({diff})"); + } + // Mosaic's cells divide evenly: 10 px at k = 2 is 5 proxy px; 40 px keeps k = 4. + assert_eq!(preview_factor("filter.pixelate.mosaic", &json!({"cellSize": 10.0}), 4), 2); + assert_eq!(preview_factor("filter.pixelate.mosaic", &json!({"cellSize": 40.0}), 4), 4); + assert_eq!(preview_factor("filter.pixelate.mosaic", &json!({"cellSize": 7.0}), 4), 1); + assert_eq!(preview_factor("filter.pixelate.mosaic", &json!({"cellSize": "x"}), 4), 4); + let (k, diff) = error("filter.pixelate.mosaic", json!({"cellSize": 10.0})); + assert!(k == 2 && diff < 0.01, "Mosaic: {diff}"); + // Tree's and Picture Frame's sizes are relative, not pixels (and not shown as "px"). + assert!(!is_pixel_param_of("filter.render.tree", "size") && !is_pixel_param_of("filter.render.pictureFrame", "size")); + assert!(is_pixel_param_of("filter.stylize.extrude", "size")); + } + + /// #218: Warp Text renders the type again from the layer model, which a reduced proxy doesn't + /// scale: the preview showed the text k times too large. It is the full-size result, reduced. + #[test] + fn warp_text_preview_matches_the_full_size_result_on_a_reduced_proxy() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 4000, "height": 3000})).unwrap(); + s.execute("type.create", json!({"x": 300, "y": 1600, "text": "Warp", "size": 400})).unwrap(); + let st = s.active().unwrap(); + let (doc, layer) = ((*st.doc).clone(), st.active_layer.unwrap()); + let p = json!({"style": "arc", "bend": 40.0}); + let k = preview_factor("type.warpText", &p, crate::proxy::preview_factor(&doc, true)); + assert!(k > 1, "the preview runs on a reduced proxy"); + let cache = |d: &Document| match &d.layer(layer).unwrap().content { + photocraft_doc::LayerContent::Text(t) => t.cache.clone().unwrap(), + _ => panic!("a type layer"), + }; + let full = crate::proxy::proxy_document(&preview_document(&doc, Some(layer), "type.warpText", &p, 1).unwrap(), k); + let shown = preview_document(&doc, Some(layer), "type.warpText", &p, k).unwrap(); + assert_eq!(shown.size, full.size); + assert_eq!(cache(&shown).content_bounds(), cache(&full).content_bounds()); + assert_eq!(photocraft_compose::flatten(&shown).px, photocraft_compose::flatten(&full).px); } /// #2063: a pixel-sized parameter divided by the proxy factor was clamped to the command's @@ -994,7 +1205,7 @@ mod tests { for c in previewed { for p in parse_spec(c.params) { let Kind::Range { min, default, .. } = p.kind else { continue }; - if min <= 0.0 || !is_pixel_param(&p.key) { + if min <= 0.0 || !is_pixel_param_of(c.id, &p.key) { continue; } for v in [f64::from(min), f64::from(default), f64::from(min) * 2.5] { @@ -1004,4 +1215,243 @@ mod tests { } } } + + /// Sharp structure at every scale: [`waves`] with hard squares from 2 to 32 px, a fine + /// per-pixel texture and an interlaced band (odd rows inverted) at the bottom. + fn sharp(n: u32) -> Document { + let mut doc = waves(n); + let s = doc.layers[0].surface_mut().unwrap(); + let mut x0 = 4; + for (i, size) in [2, 3, 4, 6, 8, 12, 16, 24, 32].into_iter().enumerate() { + for y0 in (4..n as i32 * 3 / 4 - size).step_by((size * 3) as usize) { + let v = if (i + y0 as usize / 7).is_multiple_of(2) { 0.05 } else { 0.95 }; + s.fill_rect(photocraft_geom::Rect::new(x0, y0, x0 + size, y0 + size), &[v, v * 0.8, 1.0 - v, 1.0]); + } + x0 += size + 6; + } + for y in 0..n as i32 { + for x in 0..n as i32 { + let mut px = s.pixel(x, y); + if (x * 7 + y * 13) % 5 == 0 { + px.iter_mut().take(3).for_each(|c| *c = (*c + 0.08).min(1.0)); + } + if y >= n as i32 * 3 / 4 && y % 2 == 1 { + px[0] = 1.0 - px[0]; + } + s.fill_rect(photocraft_geom::Rect::new(x, y, x + 1, y + 1), &px); + } + } + doc + } + + /// [`waves`] with strong per-pixel noise, the input of noise reduction. + fn noisy(n: u32) -> Document { + let mut doc = waves(n); + let s = doc.layers[0].surface_mut().unwrap(); + let hash = |x: i32, y: i32| ((x as u32).wrapping_mul(73_856_093) ^ (y as u32).wrapping_mul(19_349_663)).wrapping_mul(2_654_435_761) >> 8; + for y in 0..n as i32 { + for x in 0..n as i32 { + let mut px = s.pixel(x, y); + for (c, v) in px.iter_mut().take(3).enumerate() { + *v = (*v + (hash(x * 3 + c as i32, y) as f32 / (1u32 << 24) as f32 - 0.5) * 0.3).clamp(0.0, 1.0); + } + s.fill_rect(photocraft_geom::Rect::new(x, y, x + 1, y + 1), &px); + } + } + doc + } + + /// How a preview at factor `k` compares with what OK applies (factor 1) on a document. + #[derive(Debug)] + struct PreviewError { + /// Mean absolute RGB difference at the pixels the proxy samples. + diff: f32, + /// Feature scale of the preview shown at document size, relative to the result: 1 when + /// the features match, 1/k when the preview draws them k times too large. + scale: f32, + /// How much the preview changes the image relative to how much OK does (sampled); NaN + /// when OK barely changes it. + change: f32, + } + + /// Mean absolute difference of neighbouring pixels at the document's own resolution. + fn detail(d: &Document) -> f32 { + let b = photocraft_compose::flatten(d); + let w = b.rect.width() as usize; + let mut sum = 0.0; + for (i, p) in b.px.iter().enumerate() { + for q in [b.px.get(i + 1).filter(|_| (i + 1) % w != 0), b.px.get(i + w)].into_iter().flatten() { + sum += (0..3).map(|c| (p[c] - q[c]).abs()).sum::() / 3.0; + } + } + sum / b.px.len().max(1) as f32 + } + + /// Pixels of `d` at every `k`-th row and column: the document pixels a factor-`k` proxy + /// samples, so a preview at `k` compares with them one to one. + fn every_kth(d: &Document, k: u32) -> Vec<[f32; 4]> { + let b = photocraft_compose::flatten(d); + let w = b.rect.width() as usize; + let k = k as usize; + let h = b.px.len() / w.max(1); + (0..h.div_ceil(k)) + .flat_map(|y| (0..w.div_ceil(k)).map(move |x| (x * k, y * k))) + .map(|(x, y)| b.px.get(y * w + x).copied().unwrap_or([f32::NAN; 4])) + .collect() + } + + fn mean_abs(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 { + a.iter().zip(b).map(|(p, q)| (0..3).map(|c| (p[c] - q[c]).abs()).sum::() / 3.0).sum::() / a.len().max(1) as f32 + } + + /// `None` when the command can't run on `doc` at all (e.g. Displace without a map). + fn preview_error(doc: &Document, command: &str, p: &Value, k: u32) -> Option { + let bg = doc.layers[0].id; + let applied = preview_document(doc, Some(bg), command, p, 1)?; + let shown = preview_document(doc, Some(bg), command, p, k).unwrap_or_else(|| panic!("{command} {p}: the k = {k} preview failed")); + let (a, s) = (every_kth(&applied, k), every_kth(&shown, 1)); + let (src, src_k) = (every_kth(doc, k), every_kth(&crate::proxy::proxy_document(doc, k), 1)); + let applied_change = mean_abs(&a, &src); + Some(PreviewError { + diff: mean_abs(&a, &s), + scale: detail(&shown) / k as f32 / detail(&applied).max(1e-5), + change: if applied_change > 0.01 { mean_abs(&s, &src_k) / applied_change } else { f32::NAN }, + }) + } + + /// The parameter sets to preview `command` with: the dialog's defaults, and each pixel-sized + /// value at its minimum and at a large value (whole numbers as integers: Tree and Picture + /// Frame read some ranges as `u32`). + fn sweep_params(app: &mut PhotocraftApp, command: &str) -> Vec<(String, Value)> { + let Some(id) = open(app, command) else { return Vec::new() }; + let base = params_of(&app.ui.dialog_mut(id).unwrap().fields); + let mut out = vec![("default".to_string(), base.clone())]; + for p in parse_spec(photocraft_engine::commands::find(command).unwrap().params) { + if !is_pixel_param_of(command, &p.key) { + continue; + } + let values = match p.kind { + Kind::Int { .. } => vec![-40.0, 40.0], + Kind::Range { min, max, default } => [min, (default * 4.0).clamp(min, max.min(64.0))].into_iter().filter(|v| *v != default).collect(), + _ => continue, + }; + for v in values { + let mut q = base.clone(); + q[p.key.as_str()] = json!(v); + out.push((format!("{}={v}", p.key), q)); + } + } + for (_, p) in &mut out { + for v in p.as_object_mut().into_iter().flat_map(|o| o.values_mut()) { + if let Some(x) = v.as_f64().filter(|x| x.fract() == 0.0 && x.abs() < 1e9) { + *v = json!(x as i64); + } + } + } + out + } + + /// Every previewed command with its parameter sets. + fn previewed_cases() -> Vec<(&'static str, String, Value)> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ids: Vec<&'static str> = photocraft_engine::command_specs() + .iter() + .map(|c| c.id) + .filter(|id| has_dialog(id) && (id.starts_with("filter.") || PREVIEWED.contains(id))) + .collect(); + ids.into_iter().flat_map(|id| sweep_params(&mut app, id).into_iter().map(move |(label, p)| (id, label, p))).collect() + } + + fn grayscale(d: &Document) -> Document { + let mut s = photocraft_engine::Session::new(); + s.add_document(d.clone(), None); + s.execute("image.mode.grayscale", json!({})).unwrap(); + s.active().map(|d| (*d.doc).clone()).unwrap() + } + + /// Smooth, sharp and noisy test images, each in RGB and grayscale (for the modes that need it). + fn test_images(n: u32) -> [(&'static str, Document, Document); 3] { + let with_gray = |d: Document| (grayscale(&d), d); + let ((ws, w), (ss, s), (ns, no)) = (with_gray(waves(n)), with_gray(sharp(n)), with_gray(noisy(n))); + [("smooth", w, ws), ("sharp", s, ss), ("noisy", no, ns)] + } + + fn image_for<'a>(command: &str, rgb: &'a Document, gray: &'a Document) -> &'a Document { + if command == "image.mode.bitmap" || command == "image.mode.duotone" { gray } else { rgb } + } + + /// Previews that only approximate the result at a proxy although their features have the + /// right size: dots and cells placed on the proxy's pixel grid (Color Halftone, Pointillize) + /// and tile edges resampled (Tiles). Two more only differ on a proxy as small as this test's + /// (64 x 64 px): Picture Frame's minimum line widths, and Shadows/Highlights' 0.01 % black and + /// white clips, which pick single pixels out of 4096 (its radii are checked in + /// `proxy_previews_scale_only_lengths`). + const APPROXIMATE_AT_PROXY: &[&str] = &[ + "filter.pixelate.colorHalftone", + "filter.pixelate.pointillize", + "filter.stylize.tiles", + "filter.render.pictureFrame", + "image.adjustments.shadowsHighlights", + ]; + + /// Why a preview doesn't show what OK applies, if it doesn't: a clearly different result or + /// features at another size on the smooth image, or (on the sharp and noisy images, where a + /// sub-pixel radius can't show on a proxy) a filter that acts more strongly in the preview, + /// i.e. over a k times larger area. + fn mismatch(image: &str, e: &PreviewError) -> Option { + let off = if image == "smooth" { e.diff > 0.03 || (!(0.6..=1.6).contains(&e.scale) && e.diff > 0.002) } else { e.change > 1.25 }; + off.then(|| format!("{e:?}")) + } + + /// #2063, every previewed command: the live preview at the factor [`preview_factor`] picks + /// for a 1/4 proxy shows what OK applies: the same features at the same size, on smooth, + /// sharp and noisy images, at the defaults and at small and large pixel sizes. + #[test] + fn every_preview_shows_what_ok_applies() { + let cases: Vec<_> = previewed_cases().into_iter().filter(|(id, _, p)| preview_factor(id, p, 4) > 1 && !APPROXIMATE_AT_PROXY.contains(id)).collect(); + // One thread per test image: about 270 filter runs at 256 x 256. + let bad: Vec = std::thread::scope(|scope| { + let runs: Vec<_> = test_images(256) + .into_iter() + .map(|(image, rgb, gray)| { + let cases = &cases; + scope.spawn(move || { + let mut bad = Vec::new(); + for (id, label, p) in cases { + let k = preview_factor(id, p, 4); + if let Some(why) = preview_error(image_for(id, &rgb, &gray), id, p, k).and_then(|e| mismatch(image, &e)) { + bad.push(format!("{id} ({label}) at k = {k} on the {image} image: {why}")); + } + } + bad + }) + }) + .collect(); + runs.into_iter().flat_map(|r| r.join().unwrap()).collect() + }); + assert!(bad.is_empty(), "previews that differ from the result:\n{}", bad.join("\n")); + } + + /// The table behind [`every_preview_shows_what_ok_applies`]: every previewed command at a + /// plain 1/4 proxy and at its chosen factor, on each test image. + /// `cargo test -p photocraft-ui-egui --lib filter_dialog::tests::sweep_all_previews -- --ignored --nocapture` + #[test] + #[ignore] + fn sweep_all_previews() { + let images = test_images(256); + println!("command | params | image | k | diff k=4 | scale k=4 | change k=4 | diff k | scale k | change k"); + for (id, label, p) in previewed_cases() { + let k = preview_factor(id, &p, 4); + for (image, rgb, gray) in &images { + let doc = image_for(id, rgb, gray); + match (preview_error(doc, id, &p, 4), preview_error(doc, id, &p, k)) { + (Some(a), Some(b)) => println!( + "{id} | {label} | {image} | {k} | {:.4} | {:.2} | {:.2} | {:.4} | {:.2} | {:.2}", + a.diff, a.scale, a.change, b.diff, b.scale, b.change + ), + _ => println!("{id} | {label} | {image} | {k} | fails"), + } + } + } + } } diff --git a/crates/ui-egui/src/font_preview.rs b/crates/ui-egui/src/font_preview.rs index f736dd07cb..417b7fd8cd 100644 --- a/crates/ui-egui/src/font_preview.rs +++ b/crates/ui-egui/src/font_preview.rs @@ -9,35 +9,53 @@ use photocraft_doc::TextLayer; use photocraft_geom::Affine; const SAMPLE: &str = "AaBbCc"; +/// Alef, lam, meem and ain: a face lacking any of them is not treated as an Arabic face. +const ARABIC_CORE: [char; 4] = ['\u{627}', '\u{644}', '\u{645}', '\u{639}']; +/// Short, recognisable, and joins letters, so it shows the face's shaping. +const ARABIC_SAMPLE: &str = "أبجد هوز"; const WIDTH: f32 = 100.0; +const ARABIC_WIDTH: f32 = 72.0; +const GAP: f32 = 6.0; const HEIGHT: f32 = 24.0; const CACHE_LIMIT: usize = 128; +const CACHE_ID: &str = "font-menu-previews"; /// Family, display scale, and the font database generations, so a family installed or fetched /// (served fonts) after its first miss gets a sample once it is available. type Key = (String, u32, (u64, u64)); +#[derive(Clone)] +struct Samples { + latin: TextureHandle, + arabic: Option, +} #[derive(Clone, Default)] -struct Cache(VecDeque<(Key, Option)>); +struct Cache(VecDeque<(Key, Option)>); -fn image(engine: &mut photocraft_text::TextEngine, family: &str, scale: u32) -> Option { - // Unknown/unreadable families keep their plain menu label, rather than a misleading fallback. - if !engine.fonts.has_family(family) { - return None; +struct SampleImages { + latin: ColorImage, + arabic: Option, +} + +/// The Latin sample text, and the Arabic one for a face that has both scripts. `chars` is the +/// face's ascending charmap. Arabic-only and symbol faces show their own glyphs instead of the +/// Latin sample and get no second sample. +fn sample_texts(chars: &[char]) -> (String, Option<&'static str>) { + let has = |c: char| chars.binary_search(&c).is_ok(); + if !SAMPLE.chars().all(has) { + return (chars.iter().copied().filter(|c| !c.is_whitespace()).take(6).collect(), None); } - let chars = engine.fonts.charmap(family, 400, false); - let text = if SAMPLE.chars().all(|c| chars.binary_search(&c).is_ok()) { - SAMPLE.into() - } else { - // Symbol/script-only faces must show their own glyphs, not the Latin fallback font. - chars.into_iter().filter(|c| !c.is_whitespace()).take(6).collect::() - }; + let arabic = ARABIC_CORE.into_iter().chain(ARABIC_SAMPLE.chars().filter(|c| !c.is_whitespace())).all(has); + (SAMPLE.into(), arabic.then_some(ARABIC_SAMPLE)) +} + +fn render(engine: &mut photocraft_text::TextEngine, family: &str, text: &str, slot: f32, scale: u32) -> Option { if text.is_empty() { return None; } - let layer = TextLayer { text, font_family: family.into(), size_pt: 18.0 * scale as f32, ..Default::default() }; + let layer = TextLayer { text: text.into(), font_family: family.into(), size_pt: 18.0 * scale as f32, ..Default::default() }; let layout = engine.layout(&layer, 72.0); let bounds = layout.bounds()?; - let width = (WIDTH as u32 * scale) as usize; + let width = (slot as u32 * scale) as usize; let height = (HEIGHT as u32 * scale) as usize; let transform = Affine::translate(f64::from(-bounds[0]), f64::from((height as f32 - (bounds[3] - bounds[1])) / 2.0 - bounds[1])); // Reject oversized ink before the rasterizer allocates a surface (malformed installed fonts). @@ -51,34 +69,74 @@ fn image(engine: &mut photocraft_text::TextEngine, family: &str, scale: u32) -> Some(ColorImage::from_rgba_unmultiplied([width, height], &rgba.into_iter().flat_map(|p| [255, 255, 255, p[3]]).collect::>())) } +fn images(engine: &mut photocraft_text::TextEngine, family: &str, scale: u32) -> Option { + // Unknown/unreadable families keep their plain menu label, rather than a misleading fallback. + if !engine.fonts.has_family(family) { + return None; + } + let (latin, arabic) = sample_texts(&engine.fonts.charmap(family, 400, false)); + let latin = render(engine, family, &latin, WIDTH, scale)?; + let arabic = arabic.and_then(|text| render(engine, family, text, ARABIC_WIDTH, scale)); + Some(SampleImages { latin, arabic }) +} + +fn key(ctx: &egui::Context, family: &str) -> Key { + let scale = ctx.pixels_per_point().ceil().clamp(1.0, 4.0) as u32; + (family.to_owned(), scale, (photocraft_text::fonts::generation(), photocraft_text::served::generation())) +} + +/// Width a row reserves for its samples: wider once a painted row showed the family covers +/// Arabic, so the label truncates before the samples. Never renders, so rows scrolled out of +/// view cost nothing. +pub(crate) fn slot_width(ctx: &egui::Context, family: &str) -> f32 { + let key = key(ctx, family); + let arabic = ctx.data_mut(|d| { + let cache = d.get_temp_mut_or_default::(egui::Id::new(CACHE_ID)); + cache.0.iter().any(|(k, samples)| k == &key && samples.as_ref().is_some_and(|s| s.arabic.is_some())) + }); + if arabic { WIDTH + GAP + ARABIC_WIDTH } else { WIDTH } +} + pub(crate) fn paint(ui: &egui::Ui, family: &str, row: Rect) { let ctx = ui.ctx(); let scale = ctx.pixels_per_point().ceil().clamp(1.0, 4.0) as u32; - let key = (family.to_owned(), scale, (photocraft_text::fonts::generation(), photocraft_text::served::generation())); - let id = egui::Id::new("font-menu-previews"); + let key = key(ctx, family); + let id = egui::Id::new(CACHE_ID); let cached = ctx.data_mut(|d| { let cache = d.get_temp_mut_or_default::(id); let index = cache.0.iter().position(|(k, _)| k == &key)?; let entry = cache.0.remove(index)?; - let texture = entry.1.clone(); + let samples = entry.1.clone(); cache.0.push_back(entry); - Some(texture) + Some(samples) }); - let texture = cached.unwrap_or_else(|| { - let texture = image(&mut photocraft_text::shared().lock().unwrap_or_else(PoisonError::into_inner), family, scale) - .map(|img| ctx.load_texture(format!("font-sample-{family}"), img, TextureOptions::LINEAR)); + let samples = cached.unwrap_or_else(|| { + let samples = images(&mut photocraft_text::shared().lock().unwrap_or_else(PoisonError::into_inner), family, scale).map(|img| { + let load = |image| ctx.load_texture(format!("font-sample-{family}"), image, TextureOptions::LINEAR); + Samples { latin: load(img.latin), arabic: img.arabic.map(load) } + }); + if samples.as_ref().is_some_and(|s| s.arabic.is_some()) { + // The row reserved only the Latin width until now. + ctx.request_repaint(); + } ctx.data_mut(|d| { let cache = d.get_temp_mut_or_default::(id); if cache.0.len() >= CACHE_LIMIT { cache.0.pop_front(); } - cache.0.push_back((key, texture.clone())); + cache.0.push_back((key, samples.clone())); }); - texture + samples }); - if let Some(texture) = texture { - let rect = Rect::from_min_size(pos2(row.right() - WIDTH - 8.0, row.center().y - HEIGHT / 2.0), vec2(WIDTH, HEIGHT)); - ui.painter().image(texture.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), crate::theme::Tokens::get(ctx).text); + if let Some(samples) = samples { + let tint = crate::theme::Tokens::get(ctx).text; + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + let latin = Rect::from_min_size(pos2(row.right() - WIDTH - 8.0, row.center().y - HEIGHT / 2.0), vec2(WIDTH, HEIGHT)); + ui.painter().image(samples.latin.id(), latin, uv, tint); + if let Some(arabic) = samples.arabic { + let rect = Rect::from_min_size(pos2(latin.left() - GAP - ARABIC_WIDTH, latin.top()), vec2(ARABIC_WIDTH, HEIGHT)); + ui.painter().image(arabic.id(), rect, uv, tint); + } } } @@ -86,6 +144,16 @@ pub(crate) fn paint(ui: &egui::Ui, family: &str, row: Rect) { mod tests { use super::*; + fn image(engine: &mut photocraft_text::TextEngine, family: &str, scale: u32) -> Option { + images(engine, family, scale).map(|i| i.latin) + } + + fn charmap(text: &str) -> Vec { + let mut chars: Vec = text.chars().collect(); + chars.sort_unstable(); + chars + } + #[test] fn previews_use_the_requested_face_and_scale() { let mut engine = photocraft_text::TextEngine::new(); @@ -97,4 +165,46 @@ mod tests { assert_eq!(image(&mut engine, "Inter", 2).unwrap().size, [200, 48]); assert!(image(&mut engine, "Missing font", 1).is_none()); } + + #[test] + fn latin_only_families_get_no_arabic_sample() { + let mut engine = photocraft_text::TextEngine::new(); + for family in ["Inter", "JetBrains Mono"] { + assert!(images(&mut engine, family, 1).expect("bundled").arabic.is_none(), "{family}"); + } + assert!(images(&mut engine, "", 1).is_none()); + assert!(images(&mut engine, "Missing font", 1).is_none()); + } + + #[test] + fn only_faces_with_both_scripts_get_the_arabic_sample() { + let latin = format!("{SAMPLE} "); + let arabic = format!("{ARABIC_SAMPLE}{}", ARABIC_CORE.iter().collect::()); + let (text, second) = sample_texts(&charmap(&format!("{latin}{arabic}"))); + assert_eq!((text.as_str(), second), (SAMPLE, Some(ARABIC_SAMPLE))); + assert_eq!(sample_texts(&charmap(&latin)), (SAMPLE.into(), None)); + let (own_glyphs, none) = sample_texts(&charmap(&arabic)); + assert_eq!((own_glyphs.chars().count(), none), (6, None)); + assert!(own_glyphs.chars().all(|c| arabic.contains(c))); + assert_eq!(sample_texts(&[]), (String::new(), None)); + for missing in ARABIC_CORE.into_iter().chain(ARABIC_SAMPLE.chars().filter(|c| !c.is_whitespace())) { + let partial: String = format!("{latin}{arabic}").chars().filter(|&c| c != missing).collect(); + assert_eq!(sample_texts(&charmap(&partial)).1, None, "without U+{:04X}", missing as u32); + } + } + + #[test] + fn a_system_arabic_face_renders_both_samples() { + let mut engine = photocraft_text::TextEngine::new(); + engine.fonts.load_system_fonts(); + let both = engine.fonts.families().into_iter().find(|f| sample_texts(&engine.fonts.charmap(f, 400, false)).1.is_some()); + let Some(family) = both else { + eprintln!("skipped: no installed family covers both Latin and Arabic"); + return; + }; + let images = images(&mut engine, &family, 1).expect(&family); + let arabic = images.arabic.expect("Arabic sample"); + assert_eq!(arabic.size, [ARABIC_WIDTH as usize, HEIGHT as usize]); + assert!(arabic.pixels.iter().any(|p| p.a() > 0), "{family}"); + } } diff --git a/crates/ui-egui/src/gallery_ui.rs b/crates/ui-egui/src/gallery_ui.rs index 642957b895..80afe652c1 100644 --- a/crates/ui-egui/src/gallery_ui.rs +++ b/crates/ui-egui/src/gallery_ui.rs @@ -3,10 +3,13 @@ //! selected effect's filter and settings, and the effect-layer stack (new, delete, reorder, //! show/hide). The stack is applied bottom to top. //! -//! The preview runs the same algorithm as the command on the visible part of the layer -//! (downsampled when zoomed out). OK runs `filter.filterGallery {effects:[...]}`, one history -//! step (a smart filter on smart objects). Everything is drivable over the control channel with -//! `filter.filterGallery {"ui": {...}}` while the dialog is open. +//! The preview shows what OK applies at the preview's zoom, like Photoshop's: the stack runs at +//! full resolution on the visible part of the layer (plus the filters' reach), and is then +//! averaged down to the zoom (#2502). On the desktop it renders off the UI thread with a progress +//! bar, so a 24 MP layer at Fit in View never freezes the window. OK runs +//! `filter.filterGallery {effects:[...]}`, one history step (a smart filter on smart objects). +//! Everything is drivable over the control channel with `filter.filterGallery {"ui": {...}}` +//! while the dialog is open. use std::hash::{Hash, Hasher}; @@ -14,7 +17,7 @@ use egui::{Align2, Color32, FontId, Rect as ERect, Sense, Stroke, TextureHandle, use photocraft_algo::{FilterParams, GALLERY_CATEGORIES, GalleryFilter}; use photocraft_doc::LayerId; use photocraft_geom::Rect; -use photocraft_raster::Surface; +use photocraft_raster::{Interrupt, Surface}; use serde_json::{Map, Value, json}; use crate::PhotocraftApp; @@ -73,7 +76,15 @@ pub struct GalleryDialog { pub zoom: f32, center: [f32; 2], tex: Option, - shown: Option<(u64, ERect)>, + /// The key of the preview in `tex` and the document rect it covers. + shown: Option<(u64, Rect)>, + /// The preview rendering off the UI thread (desktop), and the latest wanted key with when it + /// was first wanted (ms), to cancel a stale render once the settings settle. + #[cfg(not(target_arch = "wasm32"))] + job: Option, + latest: Option<(u64, f64)>, + /// A key whose render failed: not retried until the settings or view change. + failed: Option, thumb_src: Surface, thumbs: Vec>, pub preview_ms: f64, @@ -87,6 +98,7 @@ impl GalleryDialog { "selected": self.selected, "zoom": if self.zoom > 0.0 { json!(self.zoom) } else { json!("fit") }, "previewMs": self.preview_ms, + "rendering": self.rendering().is_some(), }) } @@ -95,6 +107,15 @@ impl GalleryDialog { json!({"effects": self.effects.iter().map(EffectLayer::to_json).collect::>(), "foreground": self.foreground, "background": self.background}) } + /// The progress (`0.0..=1.0`) of a preview still rendering. + fn rendering(&self) -> Option { + #[cfg(not(target_arch = "wasm32"))] + if let Some(j) = &self.job { + return Some(j.ctl.fraction()); + } + None + } + fn cur(&mut self) -> &mut EffectLayer { let i = self.selected.min(self.effects.len() - 1); &mut self.effects[i] @@ -160,6 +181,74 @@ fn render(src: &Surface, params: &Value) -> Surface { photocraft_algo::apply_in(src, &FilterParams::FilterGallery { effects }, b, b, None, b) } +/// The preview of `vis` at 1/`k` (#2502): the stack runs at full resolution exactly as OK runs it +/// (same bounds, same canvas-edge handling, textures and noise seeded by document position), so +/// the pixels equal the same crop of the applied result; then `k`×`k` blocks are averaged. +/// `None` when cancelled. +fn preview_pixels(src: &Surface, canvas: Rect, params: &Value, vis: Rect, k: u32, ctl: &Interrupt) -> Option { + let effects = photocraft_engine::gallery_cmds::effects_from_json(params).unwrap_or_default(); + let fp = FilterParams::FilterGallery { effects }; + let content = src.content_bounds(); + let area = photocraft_algo::output_area(&fp, content, canvas, None).intersect(&vis); + let full = if area.is_empty() { src.clone() } else { photocraft_algo::apply_in_with(src, &fp, area, canvas, None, canvas.union(&content), ctl)? }; + Some(reduce(&full, vis, k)) +} + +/// `r` of `surf` scaled down by `k`, each pixel the average of a `k`×`k` block (colour weighted by +/// alpha), placed at the origin. +fn reduce(surf: &Surface, r: Rect, k: u32) -> Surface { + let k = k.clamp(1, 1 << 16) as usize; + let (bw, bh) = (r.width() as usize, r.height() as usize); + let (w, h) = (bw.div_ceil(k), bh.div_ceil(k)); + let mut out = Surface::new(surf.format()); + if w == 0 || h == 0 { + return out; + } + let n = surf.channels().max(1); + let alpha = surf.format().alpha; + let colours = if alpha { n - 1 } else { n }; + // One output row from a band of `k` source rows. + let row = |j: usize, out: &mut [f32]| { + let y0 = r.y0.saturating_add((j * k) as i32); + let band = surf.read_region(Rect::new(r.x0, y0, r.x1, y0.saturating_add(k as i32).min(r.y1))); + let mut acc = vec![0.0f64; w * n]; + let mut count = vec![0u32; w]; + for (i, px) in band.chunks_exact(n).enumerate() { + let x = (i % bw) / k; + let (Some(sum), Some(c)) = (acc.get_mut(x * n..x * n + n), count.get_mut(x)) else { continue }; + let a = if alpha { px[n - 1] as f64 } else { 1.0 }; + for (s, v) in sum.iter_mut().zip(&px[..colours]) { + *s += *v as f64 * a; + } + if alpha { + sum[n - 1] += a; + } + *c += 1; + } + for ((sum, c), o) in acc.chunks_exact(n).zip(&count).zip(out.chunks_exact_mut(n)) { + let a = if alpha { sum[n - 1] } else { *c as f64 }; + for (o, v) in o.iter_mut().zip(&sum[..colours]) { + *o = if a > 0.0 { (v / a) as f32 } else { 0.0 }; + } + if alpha { + o[n - 1] = (a / (*c).max(1) as f64) as f32; + } + } + }; + let mut data = vec![0.0f32; w * h * n]; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + data.par_chunks_mut(w * n).enumerate().for_each(|(j, out)| row(j, out)); + } + #[cfg(target_arch = "wasm32")] + for (j, out) in data.chunks_mut(w * n).enumerate() { + row(j, out); + } + out.write_region(Rect::new(0, 0, w as i32, h as i32), &data); + out +} + fn image(surf: &Surface, w: usize, h: usize) -> egui::ColorImage { let mut px = vec![[0u8; 4]; w * h]; for y in 0..h { @@ -217,6 +306,10 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { center: [(canvas.x0 + canvas.x1) as f32 / 2.0, (canvas.y0 + canvas.y1) as f32 / 2.0], tex: None, shown: None, + #[cfg(not(target_arch = "wasm32"))] + job: None, + latest: None, + failed: None, thumb_src, thumbs: vec![None; GalleryFilter::ALL.len()], preview_ms: 0.0, @@ -284,9 +377,27 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { Ok(d.describe()) } -/// Recomputes the preview texture when the stack or the view changed. -fn update_preview(d: &mut GalleryDialog, ctx: &egui::Context, area: ERect) { - let (cw, ch) = (d.canvas.width() as f32, d.canvas.height() as f32); +/// A preview rendering on a worker thread: what it shows, its result and its cancellation. +#[cfg(not(target_arch = "wasm32"))] +struct PreviewJob { + key: u64, + vis: Rect, + rx: std::sync::mpsc::Receiver, String>>, + ctl: photocraft_engine::jobs::JobCtx, +} + +#[cfg(not(target_arch = "wasm32"))] +impl Drop for PreviewJob { + /// A closed dialog or a superseded render stops at its next tile. + fn drop(&mut self) { + self.ctl.cancel(); + } +} + +/// The preview's zoom (screen px per document px), the visible document rect and the factor the +/// texture is reduced by. Fit in View also recentres the view. +fn view(d: &mut GalleryDialog, area: ERect) -> (f32, Rect, u32) { + let (cw, ch) = (d.canvas.width().max(1) as f32, d.canvas.height().max(1) as f32); let zoom = if d.zoom > 0.0 { d.zoom } else { ((area.width() - 24.0) / cw).min((area.height() - 24.0) / ch).clamp(0.005, 1.0) }; if d.zoom <= 0.0 { d.center = [d.canvas.x0 as f32 + cw / 2.0, d.canvas.y0 as f32 + ch / 2.0]; @@ -295,34 +406,112 @@ fn update_preview(d: &mut GalleryDialog, ctx: &egui::Context, area: ERect) { let vis = Rect::new((d.center[0] - hw).floor() as i32, (d.center[1] - hh).floor() as i32, (d.center[0] + hw).ceil() as i32, (d.center[1] + hh).ceil() as i32) .intersect(&d.canvas); - let k = (1.0 / zoom).ceil().max(1.0) as u32; + // At least one texture pixel per screen pixel; the GPU's linear filter does the rest. + let k = (1.0 / zoom + 1e-3).floor().clamp(1.0, 4096.0) as u32; + (zoom, vis, k) +} + +/// Keeps the preview texture current with the stack and the view; returns where it is drawn. +/// With `background` the render runs off the UI thread and the last preview stays up (moving +/// with the view) until it is done; otherwise it renders inline (web, tests, inline sessions). +fn update_preview(d: &mut GalleryDialog, ctx: &egui::Context, area: ERect, background: bool) -> Option { + let (zoom, vis, k) = view(d, area); let params = d.params(); let mut h = std::collections::hash_map::DefaultHasher::new(); params.to_string().hash(&mut h); (vis.x0, vis.y0, vis.x1, vis.y1, k).hash(&mut h); let key = h.finish(); - let screen = ERect::from_min_max( - area.center() + vec2((vis.x0 as f32 - d.center[0]) * zoom, (vis.y0 as f32 - d.center[1]) * zoom), - area.center() + vec2((vis.x1 as f32 - d.center[0]) * zoom, (vis.y1 as f32 - d.center[1]) * zoom), - ); - if d.shown.is_some_and(|(k0, _)| k0 == key) { - d.shown = Some((key, screen)); - return; + let now = crate::gpu_canvas::now_ms(); + if d.latest.is_none_or(|(k0, _)| k0 != key) { + d.latest = Some((key, now)); } - if vis.is_empty() { - return; + #[cfg(not(target_arch = "wasm32"))] + poll_job(d, ctx); + let wanted = d.shown.is_none_or(|(k0, _)| k0 != key) && !vis.is_empty() && d.failed != Some(key); + #[cfg(not(target_arch = "wasm32"))] + if wanted && background { + start_job(d, ctx, key, vis, k, ¶ms, now); + } else if d.job.as_ref().is_some_and(|j| j.key != key) { + // Back to what is already shown: the render for other settings is no longer needed. + d.job = None; } - let t0 = crate::gpu_canvas::now_ms(); - let small = sample(&d.src, vis, k); - let out = render(&small, ¶ms); - let (w, hgt) = (vis.width().div_ceil(k) as usize, vis.height().div_ceil(k) as usize); - let img = image(&out, w, hgt); + if wanted && !background { + let t0 = crate::gpu_canvas::now_ms(); + if let Some(out) = preview_pixels(&d.src, d.canvas, ¶ms, vis, k, &Interrupt::NONE) { + let img = image(&out, reduced(vis.width(), k), reduced(vis.height(), k)); + show_image(d, ctx, img, key, vis, crate::gpu_canvas::now_ms() - t0); + } + } + let (_, r) = d.shown?; + let at = |x: i32, y: i32| area.center() + vec2((x as f32 - d.center[0]) * zoom, (y as f32 - d.center[1]) * zoom); + Some(ERect::from_min_max(at(r.x0, r.y0), at(r.x1, r.y1))) +} + +/// A length of `n` document pixels reduced by `k` (partial blocks count). +fn reduced(n: u32, k: u32) -> usize { + (n as usize).div_ceil(k.max(1) as usize) +} + +fn show_image(d: &mut GalleryDialog, ctx: &egui::Context, img: egui::ColorImage, key: u64, vis: Rect, ms: f64) { match &mut d.tex { Some(t) => t.set(img, egui::TextureOptions::LINEAR), None => d.tex = Some(ctx.load_texture("gallery-preview", img, egui::TextureOptions::LINEAR)), } - d.preview_ms = crate::gpu_canvas::now_ms() - t0; - d.shown = Some((key, screen)); + d.preview_ms = ms; + d.shown = Some((key, vis)); +} + +/// Takes a finished render: shown when it completed, dropped when cancelled, remembered when it +/// failed (so a failing stack isn't retried every frame). +#[cfg(not(target_arch = "wasm32"))] +fn poll_job(d: &mut GalleryDialog, ctx: &egui::Context) { + let Some(job) = &d.job else { return }; + let result = match job.rx.try_recv() { + Ok(r) => r, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => Err("the Filter Gallery preview stopped".into()), + }; + let Some(job) = d.job.take() else { return }; + match result { + Ok(Some((img, ms))) => show_image(d, ctx, img, job.key, job.vis, ms), + Ok(None) => {} + Err(_) => d.failed = Some(job.key), + } +} + +/// One render at a time: a running render for older settings finishes (so a slider drag keeps +/// updating) unless the settings have been unchanged for a moment, then it is cancelled. +#[cfg(not(target_arch = "wasm32"))] +fn start_job(d: &mut GalleryDialog, ctx: &egui::Context, key: u64, vis: Rect, k: u32, params: &Value, now: f64) { + if let Some(job) = &d.job { + let settled = d.latest.is_some_and(|(k0, t)| k0 == key && now - t >= crate::filter_preview_worker::SETTLE_MS); + if job.key != key && settled { + job.ctl.cancel(); + } + ctx.request_repaint_after(std::time::Duration::from_millis(30)); + return; + } + let (tx, rx) = std::sync::mpsc::sync_channel(1); + let ctl = photocraft_engine::jobs::JobCtx::new(); + let (work, src, canvas, params, repaint) = (ctl.clone(), d.src.clone(), d.canvas, params.clone(), ctx.clone()); + let spawned = std::thread::Builder::new().name("gallery-preview".into()).spawn(move || { + let run = || { + let t0 = crate::gpu_canvas::now_ms(); + let out = work.stage(0.0, 1.0, "", |ctl| preview_pixels(&src, canvas, ¶ms, vis, k, ctl))?; + let img = image(&out, reduced(vis.width(), k), reduced(vis.height(), k)); + Some((img, crate::gpu_canvas::now_ms() - t0)) + }; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(run)).map_err(|_| "the Filter Gallery preview failed".to_string()); + let _ = tx.send(result); + repaint.request_repaint(); + }); + match spawned { + Ok(_) => { + d.job = Some(PreviewJob { key, vis, rx, ctl }); + ctx.request_repaint_after(std::time::Duration::from_millis(30)); + } + Err(_) => d.failed = Some(key), + } } /// Renders up to `budget` missing thumbnails (spread over frames so opening stays instant). @@ -350,6 +539,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let screen = ctx.content_rect(); let mut action: Option<&str> = None; + let background = app.background_jobs; egui::Area::new(egui::Id::new("gallery-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { let Some(d) = app.distort.gallery.as_mut() else { return }; let (full, _) = ui.allocate_exact_size(screen.size(), Sense::hover()); @@ -369,9 +559,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // ---- Preview ---- painter.rect_filled(area, 0.0, t.canvas); - update_preview(d, ctx, area); + let shown = update_preview(d, ctx, area, background); let clip = painter.with_clip_rect(area); - if let (Some(tex), Some((_, r))) = (&d.tex, d.shown) { + if let (Some(tex), Some(r)) = (&d.tex, shown) { widgets::checker(&clip, r.intersect(area), 8.0); clip.image(tex.id(), r, ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); clip.rect_stroke(r, 0.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); @@ -397,7 +587,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if widgets::dropdown(&mut zb, "gallery-zoom", &mut z, &opts, 110.0) { d.zoom = z; } - zb.label(egui::RichText::new(format!("{} {:.0} ms", tl!("Preview"), d.preview_ms)).color(t.text_faint).size(11.0)); + if let Some(p) = d.rendering() { + // Like Photoshop's: a progress bar while the preview renders (the last one stays up). + let (br, _) = zb.allocate_exact_size(vec2(90.0, 6.0), Sense::hover()); + crate::jobs_ui::bar(&zb, br, (p > 0.0).then_some(p), &t); + } else { + zb.label(egui::RichText::new(format!("{} {:.0} ms", tl!("Preview"), d.preview_ms)).color(t.text_faint).size(11.0)); + } // ---- Category folders with thumbnails ---- painter.rect_filled(mid, 0.0, t.dock); @@ -652,7 +848,7 @@ mod tests { let mut app = app(); open(&mut app).unwrap(); let d = app.distort.gallery.as_mut().unwrap(); - update_preview(d, &ctx, ERect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 300.0))); + update_preview(d, &ctx, ERect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 300.0)), false); assert!(d.tex.is_some() && d.shown.is_some()); d.open = [true; 6]; update_thumbs(d, &ctx, 100); @@ -669,4 +865,185 @@ mod tests { let worst = a.read_region(d.canvas).iter().zip(&b).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max); assert!(worst < 2e-3, "preview differs from the result by {worst}"); } + + /// A 240×160 layer with gradients, edges and fine detail, so every gallery filter has + /// something to draw. + fn photo_app() -> PhotocraftApp { + let (w, h) = (240, 160); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": w, "height": h, "depth": 16})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, active| { + let s = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + for y in 0..h { + for x in 0..w { + let (u, v) = (x as f32 / w as f32, y as f32 / h as f32); + let disc = (((x - 150) * (x - 150) + (y - 70) * (y - 70)) < 45 * 45) as i32 as f32; + let stripes = ((x + y) / 11 % 2) as f32; + s.write_pixel(x, y, &[u * 0.8 + disc * 0.2, 0.25 + 0.5 * stripes * (1.0 - disc), v * (1.0 - disc) + disc * 0.9, 1.0]); + } + } + Ok(()) + }) + .unwrap(); + app.sync_views(); + app + } + + /// What OK applies: `filter.filterGallery` with the dialog's stack on a copy of the document. + fn applied(app: &PhotocraftApp, d: &GalleryDialog) -> Surface { + let mut s2 = photocraft_engine::Session::new(); + s2.add_document(app.session.active().unwrap().doc.as_ref().clone(), None); + s2.select_layer(d.layer).unwrap(); + s2.execute("filter.filterGallery", d.params()).unwrap(); + s2.active().unwrap().doc.layer(d.layer).unwrap().surface().unwrap().clone() + } + + /// Mean of `k`×`k` blocks (opaque layers), written independently of [`reduce`]. + fn box_mean(surf: &Surface, r: Rect, k: i32) -> Vec { + let mut out = Vec::new(); + let mut px = [0.0f32; 4]; + for by in (r.y0..r.y1).step_by(k as usize) { + for bx in (r.x0..r.x1).step_by(k as usize) { + let mut sum = [0.0f32; 4]; + let mut n = 0.0; + for y in by..(by + k).min(r.y1) { + for x in bx..(bx + k).min(r.x1) { + surf.read_pixel(x, y, &mut px); + sum.iter_mut().zip(px).for_each(|(s, v)| *s += v); + n += 1.0; + } + } + out.extend(sum.map(|s| s / n)); + } + } + out + } + + /// Mean absolute RGB difference. + fn mean_diff(a: &[f32], b: &[f32]) -> f32 { + assert_eq!(a.len(), b.len()); + let (sum, n) = a.chunks(4).zip(b.chunks(4)).fold((0.0, 0.0), |(s, n), (p, q)| (s + (0..3).map(|c| (p[c] - q[c]).abs()).sum::(), n + 3.0)); + sum / n + } + + #[test] + fn zoomed_out_preview_matches_the_applied_result_scaled_down() { + // #2502: below 100 % the preview filtered every k-th pixel with the same params, so + // strokes, grain and textures showed several times too large. + let ctx = egui::Context::default(); + let mut app = photo_app(); + open(&mut app).unwrap(); + let area = ERect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 300.0)); + for f in ["graphicPen", "charcoal", "reticulation", "stainedGlass", "spatter", "chalkCharcoal"] { + control(&mut app, &json!({"filter": f, "zoom": 0.25})).unwrap(); + let reference = { + let d = app.distort.gallery.as_ref().unwrap(); + box_mean(&applied(&app, d), d.canvas, 4) + }; + let d = app.distort.gallery.as_mut().unwrap(); + let (_, vis, k) = view(d, area); + assert_eq!((vis, k), (d.canvas, 4), "{f}: at 25 % the whole layer shows, averaged 4×4"); + assert!(update_preview(d, &ctx, area, false).is_some()); + let shown = preview_pixels(&d.src, d.canvas, &d.params(), vis, k, &Interrupt::NONE).unwrap(); + let (w, h) = (reduced(vis.width(), k) as i32, reduced(vis.height(), k) as i32); + let diff = mean_diff(&shown.read_region(Rect::new(0, 0, w, h)), &reference); + // The old preview (every 4th pixel, filtered at that size) for comparison. + let old = mean_diff(&render(&sample(&d.src, vis, k), &d.params()).read_region(Rect::new(0, 0, w, h)), &reference); + assert!(diff < 1e-3, "{f}: preview differs from the applied result by {diff}"); + assert!(old > 20.0 * diff.max(1e-3), "{f}: the old preview differed by only {old}"); + } + } + + #[test] + fn a_region_renders_the_same_pixels_as_the_full_result() { + // Zoomed in, only the visible part renders: noise, cells and textures are seeded by + // document position, and the filters read their reach around the region. + let mut app = photo_app(); + open(&mut app).unwrap(); + let region = Rect::new(37, 21, 151, 117); + for f in GalleryFilter::ALL { + control(&mut app, &json!({"filter": f.key()})).unwrap(); + let d = app.distort.gallery.as_ref().unwrap(); + let full = applied(&app, d).read_region(region); + let part = preview_pixels(&d.src, d.canvas, &d.params(), region, 1, &Interrupt::NONE).unwrap(); + let part = part.read_region(Rect::new(0, 0, region.width() as i32, region.height() as i32)); + let worst = full.iter().zip(&part).map(|(a, b)| (a - b).abs()).fold(0.0, f32::max); + assert!(worst < 1e-3, "{}: region differs from the full result by {worst}", f.key()); + } + } + + #[test] + fn reduce_averages_blocks_weighted_by_alpha() { + let mut s = Surface::new(photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::F32, true)); + s.write_pixel(0, 0, &[1.0, 0.0, 0.0, 1.0]); + s.write_pixel(1, 0, &[0.0, 1.0, 0.0, 0.0]); // transparent: its colour doesn't count + s.write_pixel(2, 0, &[0.2, 0.4, 0.6, 1.0]); // a partial block at the edge + let r = reduce(&s, Rect::new(0, 0, 3, 2), 2).read_region(Rect::new(0, 0, 2, 1)); + assert_eq!(r, [1.0, 0.0, 0.0, 0.25, 0.2, 0.4, 0.6, 0.5]); + assert_eq!(reduce(&s, Rect::EMPTY, 2).content_bounds(), Rect::EMPTY); + } + + #[test] + fn background_preview_renders_off_the_ui_thread() { + let ctx = egui::Context::default(); + let mut app = photo_app(); + open(&mut app).unwrap(); + control(&mut app, &json!({"filter": "graphicPen"})).unwrap(); + let d = app.distort.gallery.as_mut().unwrap(); + let area = ERect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 300.0)); + assert!(update_preview(d, &ctx, area, true).is_none(), "nothing to show until the first render is done"); + assert!(d.describe()["rendering"].as_bool().unwrap()); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while d.rendering().is_some() { + assert!(std::time::Instant::now() < deadline, "the preview never finished"); + std::thread::sleep(std::time::Duration::from_millis(5)); + update_preview(d, &ctx, area, true); + } + assert!(d.tex.is_some() && update_preview(d, &ctx, area, true).is_some()); + // A new setting starts a new render; the last preview stays up meanwhile. + let before = d.cur().params.get("strokeLength").cloned().unwrap(); + d.cur().params.insert("strokeLength".into(), json!(5)); + assert!(update_preview(d, &ctx, area, true).is_some()); + assert!(d.rendering().is_some()); + // Back to the settings on screen: that render is dropped (and cancelled). + d.cur().params.insert("strokeLength".into(), before); + assert!(update_preview(d, &ctx, area, true).is_some()); + assert!(d.rendering().is_none()); + d.cur().params.insert("strokeLength".into(), json!(5)); + update_preview(d, &ctx, area, true); + // Closing the dialog cancels it. + app.distort.gallery = None; + } + + /// Preview cost on 24 MP: `cargo test -p photocraft-ui-egui --lib bench_gallery_preview -- --ignored --nocapture`. + #[test] + #[ignore] + fn bench_gallery_preview() { + let (w, h) = (6000, 4000); + let canvas = Rect::new(0, 0, w, h); + let mut s = Surface::new(photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8, true)); + s.fill_rect(canvas, &[0.5, 0.4, 0.3, 1.0]); + for y in (0..h).step_by(9) { + s.fill_rect(Rect::new(0, y, w, y + 3), &[0.9, 0.8, 0.2, 1.0]); + } + let ms = |t: std::time::Instant| t.elapsed().as_secs_f64() * 1000.0; + let t = std::time::Instant::now(); + std::hint::black_box(reduce(&s, canvas, 8)); + println!("{:>14}: {:>7.0} ms", "reduce 1/8", ms(t)); + for f in ["graphicPen", "charcoal", "reticulation", "stainedGlass", "spatter", "roughPastels"] { + let p = json!({"effects": [{"filter": f}]}); + let t = std::time::Instant::now(); + std::hint::black_box(render(&sample(&s, canvas, 8), &p)); + let old = ms(t); + let t = std::time::Instant::now(); + std::hint::black_box(preview_pixels(&s, canvas, &p, canvas, 8, &Interrupt::NONE)); + let fit = ms(t); + let t = std::time::Instant::now(); + std::hint::black_box(preview_pixels(&s, canvas, &p, Rect::new(2400, 1600, 3500, 2400), 1, &Interrupt::NONE)); + let screen = ms(t); + println!("{f:>14}: old fit {old:>5.0} ms, new fit {fit:>6.0} ms, new 100 % (1100x800) {screen:>5.0} ms"); + } + } } diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index a67d1a8572..bf5222c45c 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -47,9 +47,10 @@ const VIEW_UNIFORM_SIZE: u64 = 128; const VIEW_FLOATS: usize = 32; const TILE_UNIFORM_SIZE: u64 = 16; -/// Whether this view can use the GPU canvas. Flip is still a CPU blit; rotation is a shader uniform. -pub fn gpu_view_ok(flip: bool, _rotation: f32) -> bool { - !flip +/// Whether this view can use the GPU canvas. Flip and non-square pixel aspect correction are +/// still CPU blits (the shader's filtering assumes square pixels); rotation is a shader uniform. +pub fn gpu_view_ok(flip: bool, _rotation: f32, aspect: f32) -> bool { + !flip && (aspect - 1.0).abs() < 1e-6 } /// Parameters for drawing one document view. Positions are in egui points. @@ -2032,9 +2033,10 @@ mod tests { assert_eq!(VIEW_FLOATS, 32); assert_eq!(VIEW_UNIFORM_SIZE, 128); assert_eq!(VIEW_FLOATS * 4, VIEW_UNIFORM_SIZE as usize); - assert!(gpu_view_ok(false, 0.0)); - assert!(gpu_view_ok(false, 45.0_f32.to_radians())); - assert!(!gpu_view_ok(true, 45.0_f32.to_radians())); + assert!(gpu_view_ok(false, 0.0, 1.0)); + assert!(gpu_view_ok(false, 45.0_f32.to_radians(), 1.0)); + assert!(!gpu_view_ok(true, 45.0_f32.to_radians(), 1.0)); + assert!(!gpu_view_ok(false, 0.0, 2.0), "pixel aspect correction draws on the CPU"); // Identity packing: rotation 0 matches the unrotated origin + scale map. let origin = [10.0, 20.0]; let (scale, center) = (2.0, [40.0, 8.0]); diff --git a/crates/ui-egui/src/hold_keys.rs b/crates/ui-egui/src/hold_keys.rs index c8f95d7ae8..64cda72397 100644 --- a/crates/ui-egui/src/hold_keys.rs +++ b/crates/ui-egui/src/hold_keys.rs @@ -2,16 +2,20 @@ //! //! - Space: the Hand tool while held. //! - ⌘Space (Ctrl+Space off the Mac): Zoom In while held; a drag is a scrubby zoom. -//! - ⌘⌥Space: Zoom Out while held. +//! - ⌥Space (Alt+Space off the Mac): Zoom Out while held. ⌥ added to a held Zoom In (⌘⌥Space) +//! zooms out too, as ⌥ does with the Zoom tool. +//! - The keys can be pressed in any order. Photoshop users usually hold Space first: on the Mac +//! that keeps ⌘Space from opening Spotlight, and many Linux desktops take ⌥Space for the window +//! menu when ⌥ comes first. //! - Space while a marquee, lasso or shape is being dragged repositions it; releasing Space //! goes back to sizing it (the Crop tool does the same for its frame, `crop_ui`). //! - ⌘ (Ctrl off the Mac): the Move tool while held, with the painting, retouching, eraser, //! gradient, eyedropper and other non-selection tools ([`cmd_moves`]); so ⌘-drag with the //! Brush moves the layer, ⌘⌥-drag duplicates it first (`move_mods`), and a ⌘-click picks the -//! layer under the pointer, as with the Move tool itself. The selection tools do their own ⌘ -//! handling in `canvas` (⌘ inside the selection cuts the selected pixels, outside it moves the -//! layer, #896), and the Hand, Zoom, Crop, Slice, Path Selection, shape, Pen and Type tools -//! keep ⌘ for themselves. +//! layer under the pointer, as with the Move tool itself; ⌘-arrows nudge ([`cmd_nudges`]). The +//! selection tools do their own ⌘ handling in `canvas` (⌘ inside the selection cuts the +//! selected pixels, outside it moves the layer, #896), and the Hand, Zoom, Crop, Slice, Path +//! Selection, shape, Pen and Type tools keep ⌘ for themselves. //! //! The current tool is never changed, so releasing the key gives the previous tool back. A //! temporary tool that started a drag lasts until the button is released, as in Photoshop. @@ -59,11 +63,11 @@ impl Temporary { } /// Tools on which holding ⌘ (Ctrl off the Mac) is the Move tool. Photoshop excepts the Hand, -/// Rotate View, Zoom, Slice, Path Selection, shape, Pen and Type tools (⌘ means something else to -/// each) and the Crop tool's frame; a Free Transform in progress keeps ⌘ for its distort handles. The -/// selection tools are left out too: for them ⌘ is handled per press in `canvas::tool_event` -/// (`selection_drag_kind`, `command_moves_layer`), which needs the selection tool to stay in -/// effect. +/// Rotate View, Zoom, Slice, Path and Direct Selection, shape, Pen and Type tools (⌘ means +/// something else to each) and the Crop tool's frame; a Free Transform in progress keeps ⌘ for its +/// distort handles. The selection tools are left out too: for them ⌘ is handled per press in +/// `canvas::tool_event` (`selection_drag_kind`, `command_moves_layer`), which needs the selection +/// tool to stay in effect. pub fn cmd_moves(t: Tool) -> bool { !t.is_type() && !matches!( @@ -76,6 +80,7 @@ pub fn cmd_moves(t: Tool) -> bool { | Tool::Slice | Tool::SliceSelect | Tool::PathSelection + | Tool::DirectSelection | Tool::Pen | Tool::RectMarquee | Tool::EllipseMarquee @@ -86,6 +91,20 @@ pub fn cmd_moves(t: Tool) -> bool { && !crate::vector_ui::is_shape_tool(t) } +/// Do ⌘-arrows (Ctrl off the Mac) nudge as the Move tool's arrows do (#2474)? Wherever ⌘ is the +/// Move tool ([`cmd_moves`]), with the Move tool itself, and with the selection tools: in +/// Photoshop their ⌘ is the Move tool too, so ⌘-arrows move the selected pixels (or the layer) +/// while the plain arrows move the outline. Not while a polygon or Magnetic Lasso border is in +/// progress, during Free Transform or while editing text. +pub fn cmd_nudges(app: &PhotocraftApp) -> bool { + let t = app.ui.tool; + let selecting = crate::tool_feedback::is_selection_tool(t); + app.ui.transform.is_none() + && app.ui.text_edit.is_none() + && (cmd_moves(t) || t == Tool::Move || selecting) + && !(selecting && (!app.ui.polygon.is_empty() || app.ui.magnetic.active())) +} + /// Is ⌘ alone (no other temporary key) making the Move tool now? fn cmd_move_held(app: &PhotocraftApp, i: &InputState) -> bool { i.modifiers.command && cmd_moves(app.ui.tool) && app.ui.transform.is_none() && app.ui.text_edit.is_none() @@ -114,8 +133,8 @@ fn held(i: &InputState, sc: &KeyboardShortcut) -> bool { && (!want.mac_cmd || m.mac_cmd) } -/// The temporary tool whose key is held now, the most specific binding first (⌘⌥Space before -/// ⌘Space before Space). Never while a text field has the keyboard. +/// The temporary tool whose key is held now, the most specific binding first (⌘Space before +/// Space). Never while a text field has the keyboard. pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option { if ctx.text_edit_focused() { return None; @@ -124,21 +143,26 @@ pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option let m = sc.modifiers; m.alt as u8 + m.shift as u8 + (m.command || m.mac_cmd) as u8 + (m.ctrl && !m.command) as u8 }; - let mut best: Option<(u8, Temporary)> = None; + let mut best: Option<(u8, Temporary, KeyboardShortcut)> = None; let mut cmd_move = false; + let mut alt = false; ctx.input(|i| { for t in Temporary::BOUND { if let Some(sc) = binding(app, t) && held(i, &sc) - && best.is_none_or(|(n, _)| count(&sc) > n) + && best.is_none_or(|(n, ..)| count(&sc) > n) { - best = Some((count(&sc), t)); + best = Some((count(&sc), t, sc)); } } cmd_move = cmd_move_held(app, i); + alt = i.modifiers.alt; }); + // ⌥ turns a held Zoom In around, as it does the Zoom tool: ⌘⌥Space zooms out (and wins the + // tie with ⌥Space), unless ⌥ is part of the Zoom In key itself. + let held = best.map(|(_, t, sc)| if t == Temporary::ZoomIn && alt && !sc.modifiers.alt { Temporary::ZoomOut } else { t }); // A bound key (⌘Space is Zoom In) wins over ⌘ alone. - best.map(|(_, t)| t).or(cmd_move.then_some(Temporary::Move)) + held.or(cmd_move.then_some(Temporary::Move)) } /// Is the reposition key (the Hand key, any modifiers) down? diff --git a/crates/ui-egui/src/hold_keys/tests.rs b/crates/ui-egui/src/hold_keys/tests.rs index 0cfed5e8a6..2e144b154c 100644 --- a/crates/ui-egui/src/hold_keys/tests.rs +++ b/crates/ui-egui/src/hold_keys/tests.rs @@ -1,5 +1,5 @@ //! #249 through the real canvas and key handling: Space repositions a marquee being drawn, Space -//! and ⌘Space / ⌘⌥Space are temporary Hand / Zoom tools that give the tool back, D / X and the +//! and ⌘Space / ⌥Space are temporary Hand / Zoom tools that give the tool back, D / X and the //! fill keys are commands whose Keyboard Shortcuts overrides replace the defaults. use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2}; @@ -46,6 +46,7 @@ fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation, + aspect: app.ui.view.display_aspect(), }; xf.to_screen(x, y) } @@ -217,6 +218,79 @@ fn temporary_zoom_drag_is_a_scrubby_zoom() { assert!(!h.state().session.active().unwrap().history.can_undo(), "nothing was painted"); } +/// Changes only the held modifiers, as pressing or releasing ⌥ / ⌘ does with Space already down. +fn hold_mods(h: &mut Harness<'static, PhotocraftApp>, m: Modifiers) { + h.event(Event::ModifiersChanged(platform(m))); + h.run_steps(1); +} + +#[test] +fn space_then_alt_zooms_out_as_in_photoshop() { + // Photoshop users hold Space first, then add ⌥ and click. + let mut h = harness(Tool::Brush); + h.state_mut().run("tools.setColors", json!({"foreground": "#ff0000", "background": "#00ff00"})).unwrap(); + let colors = fg_bg(&h); + let z0 = h.state().ui.views[0].zoom; + key(&mut h, Key::Space, true, Modifiers::NONE); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::Hand)); + hold_mods(&mut h, Modifiers::ALT); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomOut)); + click(&mut h, 200.0, 150.0, Modifiers::ALT); + let z1 = h.state().ui.views[0].zoom; + assert!(z1 < z0, "{z0} -> {z1}"); + // Letting go of ⌥ with Space still down is the Hand again. + hold_mods(&mut h, Modifiers::NONE); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::Hand)); + key(&mut h, Key::Space, false, Modifiers::NONE); + h.run_steps(2); + assert_eq!(held_tool(h.state(), &h.ctx), None); + assert_eq!(h.state().ui.tool, Tool::Brush, "the brush is back"); + assert_eq!(fg_bg(&h), colors, "the ⌥-click didn't reach the brush's eyedropper"); + assert!(!h.state().session.active().unwrap().history.can_undo(), "nothing was painted"); +} + +#[test] +fn space_then_cmd_zooms_in_and_adding_alt_zooms_out() { + let mut h = harness(Tool::RectMarquee); + let z0 = h.state().ui.views[0].zoom; + key(&mut h, Key::Space, true, Modifiers::NONE); + hold_mods(&mut h, Modifiers::COMMAND); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomIn)); + click(&mut h, 200.0, 150.0, Modifiers::COMMAND); + let z1 = h.state().ui.views[0].zoom; + assert!(z1 > z0, "{z0} -> {z1}"); + hold_mods(&mut h, Modifiers::COMMAND | Modifiers::ALT); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomOut)); + click(&mut h, 200.0, 150.0, Modifiers::COMMAND | Modifiers::ALT); + assert!(h.state().ui.views[0].zoom < z1); + hold_mods(&mut h, Modifiers::NONE); + key(&mut h, Key::Space, false, Modifiers::NONE); + h.run_steps(2); + assert_eq!(h.state().ui.tool, Tool::RectMarquee); + assert!(h.state().session.active().unwrap().doc.selection.is_none(), "no click reached the marquee"); +} + +#[test] +fn alt_turns_a_held_zoom_in_around_unless_the_key_includes_alt() { + let mut h = harness(Tool::Brush); + // With Zoom Out unbound, ⌘⌥Space still zooms out: ⌥ turns the held Zoom In around, as it + // does the Zoom tool. + h.state_mut().run("edit.keyboardShortcuts", json!({"set": {"tools.temporary.zoomOut": ""}})).unwrap(); + key(&mut h, Key::Space, true, Modifiers::COMMAND | Modifiers::ALT); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomOut)); + key(&mut h, Key::Space, false, Modifiers::COMMAND | Modifiers::ALT); + hold_mods(&mut h, Modifiers::NONE); + // An ⌥ that is part of the Zoom In key doesn't. + h.state_mut().run("edit.keyboardShortcuts", json!({"set": {"tools.temporary.zoomIn": "Alt+Z"}})).unwrap(); + let z0 = h.state().ui.views[0].zoom; + key(&mut h, Key::Z, true, Modifiers::ALT); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomIn)); + click(&mut h, 200.0, 150.0, Modifiers::ALT); + key(&mut h, Key::Z, false, Modifiers::ALT); + hold_mods(&mut h, Modifiers::NONE); + assert!(h.state().ui.views[0].zoom > z0); +} + #[test] fn rebound_temporary_zoom_uses_the_new_key_only() { let mut h = harness(Tool::RectMarquee); @@ -315,7 +389,7 @@ fn keyboard_shortcuts_dialog_lists_the_keys_under_tools() { assert_eq!(find("edit.fillBackgroundPreserve"), Some((tools(), Some("Cmd+Shift+Backspace".into())))); let temp = vec!["Tools".to_string(), "Temporary".to_string()]; assert_eq!(find("tools.temporary.hand"), Some((temp.clone(), Some("Space".into())))); - assert_eq!(find("tools.temporary.zoomOut"), Some((temp, Some("Cmd+Alt+Space".into())))); + assert_eq!(find("tools.temporary.zoomOut"), Some((temp, Some("Alt+Space".into())))); assert_eq!(crate::prefs_ui::default_shortcut("tools.temporary.zoomIn").as_deref(), Some("Cmd+Space")); // One contiguous Tools section (the dialog prints a header where the top level changes). let first = items.iter().position(|i| i.2.first().map(String::as_str) == Some("Tools")).unwrap(); @@ -529,6 +603,7 @@ fn cmd_is_not_the_move_tool_for_selection_hand_zoom_pen_shape_or_type_tools() { Tool::VerticalType, Tool::Crop, Tool::PathSelection, + Tool::DirectSelection, Tool::Move, Tool::RectMarquee, Tool::EllipseMarquee, diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d8fb21025d..a506161e61 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -40,8 +40,15 @@ New from Clipboard Nový ze schránky Open… Otevřít… Open As… Otevřít jako… + Open EXR Parts… Otevřít části EXR… + Add beauty precomp from the AOVs Přidat předběžnou kompozici krásy z AOV + Both Obě + Left Vlevo + Right Vpravo Open Recent Otevřít nedávné Clear Recent Files Vymazat nedávné soubory + Remove from Recent Odebrat z nedávných + Remove {name} from Recent Odebrat {name} z nedávných Close Zavřít Minimize Minimalizovat Maximize Maximalizovat @@ -89,6 +96,8 @@ Import Import Browse… Procházet… Reveal in Finder Zobrazit ve Finderu + Show in Explorer Zobrazit v Průzkumníkovi + Show in Folder Zobrazit ve složce File Info… Informace o souboru… Print… Tisk… Print One Copy Vytisknout jednu kopii @@ -264,6 +273,8 @@ Layer via Cut Vrstva vyjmutím Layer From Background… Vrstva z pozadí… Layer from Background… Vrstva z pozadí… + Background from Layer Pozadí z vrstvy + Background From Layer Pozadí z vrstvy New Shape Layer Nová vrstva tvaru New Type Layer Nová textová vrstva New Fill Layer Nová vrstva výplně @@ -591,6 +602,10 @@ 100% 100 % 200% 200 % 75% 75 % + 80% 80 % + 85% 85 % + 90% 90 % + 95% 95 % 125% 125 % 150% 150 % 175% 175 % @@ -683,6 +698,10 @@ Classic Theme Klasický motiv Solarized dark Solarized tmavý Solarized Dark Theme Tmavý motiv Solarized + Breeze dark Breeze tmavý + Breeze Dark Theme Tmavý motiv Breeze + Breeze light Breeze světlý + Breeze Light Theme Světlý motiv Breeze Next Theme Další motiv Actions Akce Brush Settings Nastavení štětce @@ -823,7 +842,6 @@ Blending Options Možnosti prolnutí Blue Modrá Blue, Red, Yellow Modrá, červená, žlutá - Both Obojí Both Axes Obě osy Bottom Dole Brightness Jas @@ -1142,7 +1160,6 @@ Leading Proklad Leading (set to the font size × auto-leading when Auto) Proklad (při volbě Automaticky velikost písma × automatický proklad) Leave as is (don't color manage) Ponechat beze změny (bez správy barev) - Left Vlevo Left align text Zarovnat text vlevo Length: Délka: Level Úroveň @@ -1314,6 +1331,7 @@ Record ● a sequence of edits, then play ▶ it on any document. Zaznamenejte ● posloupnost úprav a pak ji přehrajte ▶ na libovolném dokumentu. Red Červená Redefine style by current selection Předefinovat styl podle aktuálního výběru + Refine Upřesnit Reflected Gradient Zrcadlený přechod Registration Marks Soutiskové značky Regular Obyčejné @@ -1352,7 +1370,6 @@ Restore All Obnovit vše Restore Defaults Obnovit výchozí Restrictive (Web) Omezená (web) - Right Vpravo Right align text Zarovnat text vpravo Rigid Tuhý Rotate: Otočení: @@ -1538,6 +1555,8 @@ Blur Tool Nástroj rozostření Bold Tučné Brush Štětec + Pencil Tužka + Block Blok Brush Pose Poloha štětce Brush Tip Shape Tvar hrotu štětce Brush Tool Nástroj štětec @@ -1586,6 +1605,8 @@ Efficiency: 100% Efektivita: 100 % Ellipse Tool Nástroj elipsa Elliptical Marquee Tool Nástroj eliptický výběr + Single Row Marquee Tool Nástroj výběr jednoho řádku + Single Column Marquee Tool Nástroj výběr jednoho sloupce Emboss Reliéf Eraser Guma Eraser Tool Nástroj guma @@ -1643,6 +1664,7 @@ Mobile Mobilní zařízení Move Tool Nástroj přesun Could not use the move tool because the layer is locked. Nástroj Přesun nelze použít, protože vrstva je zamčená. + Could not complete the Free Transform command because the layer is locked. Příkaz Volná transformace nelze dokončit, protože vrstva je zamčená. Multiply Násobit My Brushes Moje štětce Neutrals Neutrální @@ -1753,6 +1775,7 @@ Invalid {mode} profile Neplatný profil {mode} Unnamed {mode} profile Bezejmenný profil {mode} {w} px x {h} px ({ppi} ppi) {w} px × {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} × {h} {unit} ({ppi} ppi) Resolution: {dpi} pixels/inch Rozlišení: {dpi} pixelů/palec Doc: {w} × {h} px Dok.: {w} × {h} px Frame {current} / {total} Snímek {current} / {total} @@ -1905,6 +1928,7 @@ Show only crosshair while painting Při malování zobrazit jen nitkový kříž Show slice numbers Zobrazit čísla řezů Show tooltips Zobrazit popisky nástrojů + Show highlight on rollover Zobrazit zvýraznění při přejetí Show bounding box when dragging layer Při přetahování vrstvy zobrazit ohraničovací rámeček System title bar Systémové záhlaví okna Show transformation values Zobrazit hodnoty transformace @@ -2012,6 +2036,7 @@ Create Warped Text Vytvořit deformovaný text Rasterize Smart Object Rastrovat chytrý objekt Rasterize Fill Content Rastrovat obsah výplně + Rasterize Layer Style Rastrovat styl vrstvy Unlock: picking a preset replaces these settings Odemknout: výběr přednastavení tato nastavení nahradí Lock: keep these settings when picking another preset Zamknout: zachovat tato nastavení při výběru jiného přednastavení Diameter: Průměr: @@ -2224,6 +2249,7 @@ cameraRaw Color Barvy # Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Výběr barvy (Barva textu) + Color Picker ({name}) Výběr barvy ({name}) Color Picker (Fill Color) Výběr barvy (Barva výplně) Fill color Barva výplně Color Picker (Stroke Color) Výběr barvy (Barva tahu) @@ -2324,6 +2350,7 @@ fontWeight Italic Kurzíva Color to Alpha… Barva na alfu… Transparency Threshold Práh průhlednosti Opacity Threshold Práh krytí + PhotoCraft — Screen Color Picker PhotoCraft — Výběr barvy obrazovky Rasterize svg groups deeper than Rasterizovat skupiny SVG hlubší než tato úroveň 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 vypne tento práh; skupiny SVG vnořené hlouběji než 100 úrovní se rastrují vždy. @@ -2418,3 +2445,11 @@ fontWeight Italic Kurzíva Panel menu Nabídka panelu Resize panel window Změnit velikost okna panelu Resize panels Změnit velikost panelů + Use Classic Mode Použít klasický režim + Auto Center Preview Automaticky vystředit náhled + Next Document Další dokument + Previous Document Předchozí dokument + W x H x Resolution Š x V x Rozlišení + Front Image Přední obraz + Search settings… Hledat nastavení… + No matching settings Žádná odpovídající nastavení diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fe3163101d..dba14c6934 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -36,8 +36,15 @@ New from Clipboard Neu aus Zwischenablage Open… Öffnen… Open As… Öffnen als… + Open EXR Parts… EXR-Teile öffnen… + Add beauty precomp from the AOVs Schönheits-Vorkomposition aus den AOVs hinzufügen + Both Beide + Left Links + Right Rechts Open Recent Zuletzt geöffnete Dateien Clear Recent Files Zuletzt geöffnete Dateien leeren + Remove from Recent Aus zuletzt geöffneten Dateien entfernen + Remove {name} from Recent {name} aus zuletzt geöffneten Dateien entfernen Close Schließen Minimize Minimieren Maximize Maximieren @@ -85,6 +92,8 @@ Import Importieren Browse… Durchsuchen… Reveal in Finder Im Finder anzeigen + Show in Explorer Im Explorer anzeigen + Show in Folder Im Ordner anzeigen File Info… Dateiinformationen… Print… Drucken… Print One Copy Eine Kopie drucken @@ -260,6 +269,8 @@ Layer via Cut Ebene durch Ausschneiden Layer From Background… Ebene aus Hintergrund… Layer from Background… Ebene aus Hintergrund… + Background from Layer Hintergrund aus Ebene + Background From Layer Hintergrund aus Ebene New Shape Layer Neue Formebene New Type Layer Neue Textebene New Fill Layer Neue Füllebene @@ -673,6 +684,10 @@ Classic Theme Klassisches Design Solarized dark Solarized dunkel Solarized Dark Theme Solarized-Design (Dunkel) + Breeze dark Breeze dunkel + Breeze Dark Theme Breeze-Design (Dunkel) + Breeze light Breeze hell + Breeze Light Theme Breeze-Design (Hell) Next Theme Nächstes Design Actions Aktionen Brush Settings Pinseleinstellungen @@ -804,7 +819,6 @@ Blending Options Fusionsoptionen Blue Blau Blue, Red, Yellow Blau, Rot, Gelb - Both Beide Both Axes Beide Achsen Bottom Unten Brightness Helligkeit @@ -1126,7 +1140,6 @@ Leading Zeilenabstand Leading (set to the font size × auto-leading when Auto) Zeilenabstand (bei „Automatisch“ auf Schriftgröße × automatischer Zeilenabstand gesetzt) Leave as is (don't color manage) Unverändert lassen (keine Farbverwaltung) - Left Links Left align text Text links ausrichten Length: Länge: Level Stufe @@ -1298,6 +1311,7 @@ Record ● a sequence of edits, then play ▶ it on any document. Zeichne ● eine Folge von Bearbeitungen auf und gib sie ▶ auf jedem Dokument wieder. Red Rot Redefine style by current selection Stil anhand der aktuellen Auswahl neu definieren + Refine Verfeinern Reflected Gradient Reflektierter Verlauf Registration Marks Passmarken Regular Regulär @@ -1336,7 +1350,6 @@ Restore All Alle wiederherstellen Restore Defaults Standardwerte wiederherstellen Restrictive (Web) Restriktiv (Web) - Right Rechts Right align text Text rechts ausrichten Rigid Starr Rotate: Drehen: @@ -1518,6 +1531,7 @@ Blur Tool Weichzeichner-Werkzeug Bold Fett Brush Pinsel + Block Block Brush Pose Pinselhaltung Brush Tip Shape Pinselspitzenform Brush Tool Pinsel-Werkzeug @@ -1566,6 +1580,8 @@ Efficiency: 100% Effizienz: 100 % Ellipse Tool Ellipsen-Werkzeug Elliptical Marquee Tool Ellipsenauswahl-Werkzeug + Single Row Marquee Tool Auswahlwerkzeug (einzelne Zeile) + Single Column Marquee Tool Auswahlwerkzeug (einzelne Spalte) Emboss Relief Eraser Radiergummi Eraser Tool Radiergummi-Werkzeug @@ -1623,6 +1639,7 @@ Mobile Mobil Move Tool Verschieben-Werkzeug Could not use the move tool because the layer is locked. Das Verschieben-Werkzeug konnte nicht verwendet werden, weil die Ebene gesperrt ist. + Could not complete the Free Transform command because the layer is locked. Der Befehl „Frei transformieren“ konnte nicht ausgeführt werden, weil die Ebene gesperrt ist. Multiply Multiplizieren My Brushes Meine Pinsel Neutrals Neutrale @@ -1731,6 +1748,7 @@ Invalid {mode} profile Ungültiges {mode}-Profil Unnamed {mode} profile Unbenanntes {mode}-Profil {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) Resolution: {dpi} pixels/inch Auflösung: {dpi} Pixel/Zoll Doc: {w} × {h} px Dok: {w} × {h} px Frame {current} / {total} Frame {current} / {total} @@ -1881,6 +1899,7 @@ Show only crosshair while painting Beim Malen nur Fadenkreuz zeigen Show slice numbers Slice-Nummern anzeigen Show tooltips Tooltips anzeigen + Show highlight on rollover Hervorhebung bei Rollover anzeigen Show bounding box when dragging layer Begrenzungsrahmen beim Ziehen einer Ebene anzeigen System title bar System-Titelleiste Show transformation values Transformationswerte anzeigen @@ -1984,6 +2003,7 @@ Create Warped Text Verkrümmten Text erstellen Rasterize Smart Object Smart-Objekt rasterisieren Rasterize Fill Content Füllinhalt rasterisieren + Rasterize Layer Style Ebenenstil rasterisieren Unlock: picking a preset replaces these settings Entsperren: das Wählen einer Vorgabe ersetzt diese Einstellungen Lock: keep these settings when picking another preset Sperren: diese Einstellungen beim Wählen einer anderen Vorgabe behalten Diameter: Durchmesser: @@ -2184,6 +2204,10 @@ Show Vectorscope Vektorskop anzeigen Tone Histogram Tonwerthistogramm 75% 75 % + 80% 80 % + 85% 85 % + 90% 90 % + 95% 95 % 125% 125 % 150% 150 % 175% 175 % @@ -2263,6 +2287,7 @@ # Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Farbwähler (Textfarbe) + Color Picker ({name}) Farbwähler ({name}) Color Picker (Fill Color) Farbwähler (Füllfarbe) Fill color Füllfarbe Color Picker (Stroke Color) Farbwähler (Konturfarbe) @@ -2363,6 +2388,7 @@ fontWeight Italic Kursiv Color to Alpha… Farbe zu Alpha… Transparency Threshold Transparenz-Schwellenwert Opacity Threshold Deckkraft-Schwellenwert + PhotoCraft — Screen Color Picker PhotoCraft — Bildschirmfarbenauswahl Rasterize svg groups deeper than SVG-Gruppen ab dieser Tiefe rastern 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 schaltet diese Schwelle aus; SVG-Gruppen, die tiefer als 100 Ebenen verschachtelt sind, werden immer gerastert. @@ -2457,3 +2483,11 @@ fontWeight Italic Kursiv Panel menu Bedienfeldmenü Resize panel window Bedienfeldfenstergröße ändern Resize panels Bedienfeldgröße ändern + Use Classic Mode Classic-Modus verwenden + Auto Center Preview Automatische zentrierte Vorschau + Next Document Nächstes Dokument + Previous Document Vorheriges Dokument + W x H x Resolution B x H x Auflösung + Front Image Vorderes Bild + Search settings… Einstellungen durchsuchen… + No matching settings Keine passenden Einstellungen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b3d7856135..bc85440b19 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -18,8 +18,15 @@ New from Clipboard Νέο από το πρόχειρο Open… Άνοιγμα… Open As… Άνοιγμα ως… + Open EXR Parts… Άνοιγμα τμημάτων EXR… + Add beauty precomp from the AOVs Προσθήκη προ-σύνθεσης ομορφιάς από τα AOV + Both Και τα δύο + Left Αριστερά + Right Δεξιά Open Recent Πρόσφατα αρχεία Clear Recent Files Εκκαθάριση πρόσφατων αρχείων + Remove from Recent Αφαίρεση από τα πρόσφατα + Remove {name} from Recent Αφαίρεση του {name} από τα πρόσφατα Close Κλείσιμο Minimize Ελαχιστοποίηση Maximize Μεγιστοποίηση @@ -67,6 +74,8 @@ Import Εισαγωγή Browse… Περιήγηση… Reveal in Finder Εμφάνιση στο Finder + Show in Explorer Εμφάνιση στην Εξερεύνηση αρχείων + Show in Folder Εμφάνιση στον φάκελο File Info… Πληροφορίες αρχείου… Print… Εκτύπωση… Print One Copy Εκτύπωση ενός αντιγράφου @@ -436,8 +445,6 @@ Alignment Στοίχιση Align Στοίχιση Distribute Κατανομή - Left Αριστερά - Right Δεξιά Center Κέντρο Top Πάνω Bottom Κάτω @@ -1431,6 +1438,7 @@ Show only crosshair while painting Εμφάνιση μόνο σταυρονήματος κατά τη ζωγραφική Show slice numbers Εμφάνιση αριθμών τομών Show tooltips Εμφάνιση επεξηγήσεων + Show highlight on rollover Εμφάνιση επισήμανσης κατά την κατάδειξη Show bounding box when dragging layer Εμφάνιση πλαισίου οριοθέτησης κατά τη μετακίνηση επιπέδου Show transformation values Εμφάνιση τιμών μετασχηματισμού Slice color Χρώμα τομής @@ -1479,6 +1487,7 @@ Create Warped Text Δημιουργία παραμορφωμένου κειμένου Rasterize Smart Object Ραστεροποίηση έξυπνου αντικειμένου Rasterize Fill Content Ραστεροποίηση περιεχομένου γεμίσματος + Rasterize Layer Style Ραστεροποίηση στυλ επιπέδου Unlock: picking a preset replaces these settings Ξεκλείδωμα: η επιλογή προεπιλογής αντικαθιστά αυτές τις ρυθμίσεις Lock: keep these settings when picking another preset Κλείδωμα: διατήρηση ρυθμίσεων κατά την αλλαγή προεπιλογής Diameter: Διάμετρος: @@ -1720,6 +1729,7 @@ Layer via Cut Επίπεδο μέσω αποκοπής Layer From Background… Επίπεδο από φόντο… Layer from Background… Επίπεδο από φόντο… + Background From Layer Φόντο από επίπεδο New Shape Layer Νέο επίπεδο σχήματος New Type Layer Νέο επίπεδο κειμένου New Fill Layer Νέο επίπεδο γεμίσματος @@ -2015,7 +2025,6 @@ Bold Italic Έντονα πλάγια Border Περίγραμμα Border… Περίγραμμα… - Both Και τα δύο Both Axes Και οι δύο άξονες Box Blur Θόλωση πλαισίου Box Blur… Θόλωση πλαισίου… @@ -2064,6 +2073,10 @@ Classic Theme Κλασικό θέμα Solarized dark Solarized σκούρο Solarized Dark Theme Σκούρο θέμα Solarized + Breeze dark Breeze σκούρο + Breeze Dark Theme Σκούρο θέμα Breeze + Breeze light Breeze φωτεινό + Breeze Light Theme Φωτεινό θέμα Breeze Clear Canvas Guides Απαλοιφή οδηγών καμβά Clear Override Απαλοιφή παράκαμψης Clear Samples Απαλοιφή δειγμάτων @@ -2785,6 +2798,7 @@ Reduce Noise Μείωση θορύβου Reduce Noise… Μείωση θορύβου… Reflected Ανακλώμενο + Refine Βελτίωση Reflected Gradient Ανακλώμενη διαβάθμιση Registration Marks Σημάδια συμπτώσεως Regular Κανονικό @@ -3210,6 +3224,8 @@ cameraRaw Yellow Κίτρινο Dodge Tool Φωτισμός (Dodge Tool) Ellipse Tool Έλλειψη (Ellipse Tool) Elliptical Marquee Tool Ελλειπτική επιλογή (Elliptical Marquee Tool) + Single Row Marquee Tool Επιλογή μίας γραμμής (Single Row Marquee Tool) + Single Column Marquee Tool Επιλογή μίας στήλης (Single Column Marquee Tool) Eraser Tool Γόμα (Eraser Tool) Eyedropper Tool Σταγονόμετρο (Eyedropper Tool) Gradient Tool Διαβάθμιση (Gradient Tool) @@ -3223,6 +3239,7 @@ cameraRaw Yellow Κίτρινο Magic Wand Tool Μαγικό ραβδί (Magic Wand Tool) Move Tool Μετακίνηση (Move Tool) Could not use the move tool because the layer is locked. Δεν ήταν δυνατή η χρήση του εργαλείου Μετακίνηση, επειδή το επίπεδο είναι κλειδωμένο. + Could not complete the Free Transform command because the layer is locked. Δεν ήταν δυνατή η ολοκλήρωση της εντολής Ελεύθερος μετασχηματισμός, επειδή το επίπεδο είναι κλειδωμένο. Note Tool Σημείωση (Note Tool) Object Selection Tool Επιλογή αντικειμένου (Object Selection Tool) Paint Bucket Tool Κουβάς γεμίσματος (Paint Bucket Tool) @@ -3334,6 +3351,7 @@ cameraRaw Yellow Κίτρινο open source ανοιχτού κώδικα {size} (was {old}) {size} (ήταν {old}) {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) …and {n} more …και {n} ακόμη 125% 125% 150% 150% @@ -3342,12 +3360,18 @@ cameraRaw Yellow Κίτρινο 300% 300% 50% Gray 50% γκρι 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% Adobe Adobe Adobe rgb Adobe RGB Android 1080p Android 1080p Apple Watch 45mm Apple Watch 45mm Black Matte Μαύρο ματ Brush Πινέλο + Pencil Μολύβι + Block Μπλοκ Color… Χρώμα… Cone (Inverted) Κώνος (αντεστραμμένος) Could not open Δεν ήταν δυνατό το άνοιγμα @@ -3434,6 +3458,7 @@ fontWeight Thin Πολύ λεπτά Color to Alpha… Χρώμα σε άλφα… Transparency Threshold Κατώφλι διαφάνειας Opacity Threshold Κατώφλι αδιαφάνειας + PhotoCraft — Screen Color Picker PhotoCraft — Επιλογή χρώματος οθόνης Rasterize svg groups deeper than Ραστεροποίηση ομάδων SVG πέρα από αυτό το βάθος 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. Το 0 απενεργοποιεί αυτό το όριο· ομάδες SVG με ένθεση βαθύτερη από 100 επίπεδα ραστεροποιούνται πάντα. @@ -3453,6 +3478,7 @@ fontWeight Thin Πολύ λεπτά # Shared text style Color Picker (#2015) Color Picker (Text Color) Επιλογέας χρώματος (χρώμα κειμένου) + Color Picker ({name}) Επιλογέας χρώματος ({name}) Auto Hide Notifications Αυτόματη απόκρυψη ειδοποιήσεων Notification Duration (seconds) Διάρκεια ειδοποίησης (δευτερόλεπτα) Appearance Mode Λειτουργία εμφάνισης @@ -3526,3 +3552,11 @@ fontWeight Thin Πολύ λεπτά Panel menu Μενού πάνελ Resize panel window Αλλαγή μεγέθους παραθύρου πάνελ Resize panels Αλλαγή μεγέθους πάνελ + Use Classic Mode Χρήση κλασικής λειτουργίας + Auto Center Preview Αυτόματο κεντράρισμα προεπισκόπησης + Next Document Επόμενο έγγραφο + Previous Document Προηγούμενο έγγραφο + W x H x Resolution Π x Υ x Ανάλυση + Front Image Μπροστινή εικόνα + Search settings… Αναζήτηση ρυθμίσεων… + No matching settings Δεν βρέθηκαν ρυθμίσεις diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1dac1a7eb2..a9281a1202 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -32,8 +32,15 @@ New from Clipboard Nuevo desde el portapapeles Open… Abrir… Open As… Abrir como… + Open EXR Parts… Abrir partes EXR… + Add beauty precomp from the AOVs Añadir la precomposición de belleza a partir de los AOV + Both Ambas + Left Izquierda + Right Derecha Open Recent Archivos recientes Clear Recent Files Vaciar archivos recientes + Remove from Recent Quitar de recientes + Remove {name} from Recent Quitar {name} de recientes Close Cerrar Minimize Minimizar Maximize Maximizar @@ -81,6 +88,8 @@ Import Importar Browse… Examinar… Reveal in Finder Mostrar en Finder + Show in Explorer Mostrar en el Explorador + Show in Folder Mostrar en la carpeta File Info… Información del archivo… Print… Imprimir… Print One Copy Imprimir un ejemplar @@ -256,6 +265,8 @@ Layer via Cut Capa vía corte Layer From Background… Capa a partir del fondo… Layer from Background… Capa a partir del fondo… + Background from Layer Fondo a partir de capa + Background From Layer Fondo a partir de capa New Shape Layer Nueva capa de forma New Type Layer Nueva capa de texto New Fill Layer Nueva capa de relleno @@ -583,6 +594,10 @@ 100% 100% 200% 200% 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% 125% 125% 150% 150% 175% 175% @@ -675,6 +690,10 @@ Classic Theme Tema Clásico Solarized dark Solarized oscuro Solarized Dark Theme Tema Solarized oscuro + Breeze dark Breeze oscuro + Breeze Dark Theme Tema Breeze oscuro + Breeze light Breeze claro + Breeze Light Theme Tema Breeze claro Next Theme Tema siguiente Actions Acciones Brush Settings Ajustes del pincel @@ -808,7 +827,6 @@ Blending Options Opciones de fusión Blue Azul Blue, Red, Yellow Azul, rojo, amarillo - Both Ambos Both Axes Ambos ejes Bottom Inferior Brightness Brillo @@ -1127,7 +1145,6 @@ Leading Interlínea Leading (set to the font size × auto-leading when Auto) Interlínea (al estar en Automático se iguala al tamaño de fuente × interlínea automática) Leave as is (don't color manage) Dejar como está (no gestionar el color) - Left Izquierda Left align text Alinear texto a la izquierda Length: Longitud: Level Nivel @@ -1299,6 +1316,7 @@ Record ● a sequence of edits, then play ▶ it on any document. Graba ● una secuencia de ediciones y reprodúcela ▶ en cualquier documento. Red Rojo Redefine style by current selection Redefinir el estilo con la selección actual + Refine Refinar Reflected Gradient Degradado reflejado Registration Marks Marcas de registro Regular Normal @@ -1337,7 +1355,6 @@ Restore All Restaurar todo Restore Defaults Restaurar valores predeterminados Restrictive (Web) Restrictivo (web) - Right Derecha Right align text Alinear texto a la derecha Rigid Rígido Rotate: Rotación: @@ -1521,6 +1538,7 @@ Blur Tool Herramienta Desenfocar Bold Negrita Brush Pincel + Block Bloque Brush Pose Postura del pincel Brush Tip Shape Forma de la punta del pincel Brush Tool Herramienta Pincel @@ -1570,6 +1588,8 @@ Efficiency: 100% Eficiencia: 100% Ellipse Tool Herramienta Elipse Elliptical Marquee Tool Herramienta Marco elíptico + Single Row Marquee Tool Herramienta Marco de fila única + Single Column Marquee Tool Herramienta Marco de columna única Emboss Relieve Eraser Borrador Eraser Tool Herramienta Borrador @@ -1627,6 +1647,7 @@ Mobile Móvil Move Tool Herramienta Mover Could not use the move tool because the layer is locked. No se pudo usar la herramienta Mover porque la capa está bloqueada. + Could not complete the Free Transform command because the layer is locked. No se pudo completar el comando Transformación libre porque la capa está bloqueada. Multiply Multiplicar My Brushes Mis pinceles Neutrals Neutros @@ -1737,6 +1758,7 @@ Invalid {mode} profile Perfil {mode} no válido Unnamed {mode} profile Perfil {mode} sin nombre {w} px x {h} px ({ppi} ppi) {w} px × {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} × {h} {unit} ({ppi} ppi) Resolution: {dpi} pixels/inch Resolución: {dpi} píxeles/pulgada Doc: {w} × {h} px Doc: {w} × {h} px Frame {current} / {total} Fotograma {current} / {total} @@ -1889,6 +1911,7 @@ Show only crosshair while painting Mostrar solo la retícula al pintar Show slice numbers Mostrar los números de sección Show tooltips Mostrar las descripciones emergentes + Show highlight on rollover Mostrar resaltado al pasar el cursor Show bounding box when dragging layer Mostrar el cuadro delimitador al arrastrar la capa System title bar Barra de título del sistema Show transformation values Mostrar los valores de transformación @@ -1996,6 +2019,7 @@ Create Warped Text Crear texto deformado Rasterize Smart Object Rasterizar objeto inteligente Rasterize Fill Content Rasterizar contenido de relleno + Rasterize Layer Style Rasterizar estilo de capa Unlock: picking a preset replaces these settings Desbloquear: al elegir un preajuste se reemplazan estos ajustes Lock: keep these settings when picking another preset Bloquear: conservar estos ajustes al elegir otro preajuste Diameter: Diámetro: @@ -2293,6 +2317,7 @@ cameraRaw Amount Cantidad # Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Selector de color (Color del texto) + Color Picker ({name}) Selector de color ({name}) Color Picker (Fill Color) Selector de color (Color de relleno) Fill color Color de relleno Color Picker (Stroke Color) Selector de color (Color del trazo) @@ -2392,6 +2417,7 @@ fontWeight Italic Cursiva Color to Alpha… Color a alfa… Transparency Threshold Umbral de transparencia Opacity Threshold Umbral de opacidad + PhotoCraft — Screen Color Picker PhotoCraft — Selector de color de pantalla Rasterize svg groups deeper than Rasterizar grupos SVG con mayor profundidad 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 desactiva este umbral; los grupos SVG anidados a más de 100 niveles siempre se rasterizan. @@ -2486,3 +2512,23 @@ fontWeight Italic Cursiva Panel menu Menú del panel Resize panel window Cambiar tamaño de la ventana del panel Resize panels Cambiar tamaño de los paneles + +# Add Noise, Reduce Noise and Smart Sharpen dialog controls (Filter > Noise, Filter > Sharpen). + Distribution Distribución + Uniform Uniforme + Gaussian Gaussiana + Monochromatic Monocromático + Seed Semilla + Strength Intensidad + Reduce Color Noise Reducir ruido de color + Remove Jpeg Artifact Quitar artefactos JPEG + Sharpen Details Enfocar detalles + Reduce Noise Reducir ruido + Use Classic Mode Usar modo clásico + Auto Center Preview Centrar previsualización automáticamente + Next Document Documento siguiente + Previous Document Documento anterior + W x H x Resolution An. x Al. x Resolución + Front Image Imagen frontal + Search settings… Buscar ajustes… + No matching settings No hay ajustes que coincidan diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 49aa622bc0..2d340befdc 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -38,8 +38,15 @@ New… Nouveau… Open… Ouvrir… Open As… Ouvrir en tant que… + Open EXR Parts… Ouvrir les parties EXR… + Add beauty precomp from the AOVs Ajouter la précomposition de la beauté depuis les AOV + Both Les deux + Left Gauche + Right Droite Open Recent Ouvrir un fichier récent Clear Recent Files Effacer la liste des fichiers récents + Remove from Recent Retirer des fichiers récents + Remove {name} from Recent Retirer {name} des fichiers récents Close Fermer Minimize Réduire Maximize Agrandir @@ -87,6 +94,8 @@ Import Importer Browse… Parcourir… Reveal in Finder Afficher dans le Finder + Show in Explorer Afficher dans l'Explorateur + Show in Folder Afficher dans le dossier File Info… Informations sur le fichier… Print… Imprimer… Print One Copy Imprimer un exemplaire @@ -255,6 +264,7 @@ Layer via Cut Calque par coupe Layer From Background… Calque d'après l'arrière-plan… Layer from Background… Calque d'après l'arrière-plan… + Background From Layer Arrière-plan d'après le calque New Shape Layer Nouveau calque de forme New Type Layer Nouveau calque de texte New Fill Layer Nouveau calque de remplissage @@ -575,6 +585,10 @@ 100% 100 % 200% 200 % 75% 75 % + 80% 80 % + 85% 85 % + 90% 90 % + 95% 95 % 125% 125 % 150% 150 % 175% 175 % @@ -667,6 +681,10 @@ Classic Theme Thème Classique Solarized dark Solarized sombre Solarized Dark Theme Thème Solarized sombre + Breeze dark Breeze sombre + Breeze Dark Theme Thème Breeze sombre + Breeze light Breeze clair + Breeze Light Theme Thème Breeze clair Next Theme Thème suivant Actions Actions Brush Settings Paramètres du pinceau @@ -798,7 +816,6 @@ Blending Options Options de fusion Blue Bleu Blue, Red, Yellow Bleu, rouge, jaune - Both Les deux Both Axes Deux axes Bottom Bas Brightness Luminosité @@ -1111,7 +1128,6 @@ Leading Interlignage Leading (set to the font size × auto-leading when Auto) Interlignage (en Auto : taille de police × interlignage automatique) Leave as is (don't color manage) Laisser tel quel (sans gestion des couleurs) - Left Gauche Left align text Aligner le texte à gauche Length: Longueur : Level Niveau @@ -1283,6 +1299,7 @@ Record ● a sequence of edits, then play ▶ it on any document. Enregistrez ● une suite de modifications, puis rejouez-la ▶ sur n'importe quel document. Red Rouge Redefine style by current selection Redéfinir le style d'après la sélection actuelle + Refine Affiner Reflected Gradient Dégradé réfléchi Registration Marks Repères de montage Regular Normal @@ -1321,7 +1338,6 @@ Restore All Tout restaurer Restore Defaults Rétablir les valeurs par défaut Restrictive (Web) Restrictive (Web) - Right Droite Right align text Aligner le texte à droite Rigid Rigide Rotate: Rotation : @@ -1501,6 +1517,7 @@ Blur Tool Outil Flou Bold Gras Brush Pinceau + Block Carré Brush Pose Position du pinceau Brush Tip Shape Forme de la pointe Brush Tool Outil Pinceau @@ -1549,6 +1566,8 @@ Efficiency: 100% Efficacité : 100 % Ellipse Tool Outil Ellipse Elliptical Marquee Tool Outil Sélection elliptique + Single Row Marquee Tool Outil Sélection de ligne unique + Single Column Marquee Tool Outil Sélection de colonne unique Emboss Relief Eraser Gomme Eraser Tool Outil Gomme @@ -1606,6 +1625,7 @@ Mobile Mobile Move Tool Outil Déplacement Could not use the move tool because the layer is locked. Impossible d'utiliser l'outil Déplacement, car le calque est verrouillé. + Could not complete the Free Transform command because the layer is locked. Impossible d'exécuter la commande Transformation libre, car le calque est verrouillé. Multiply Produit My Brushes Mes pinceaux Neutrals Neutres @@ -1712,6 +1732,7 @@ Scratch: {size} Travail : {size} Untagged {mode} {mode} sans profil {w} px x {h} px ({ppi} ppi) {w} px × {h} px ({ppi} ppp) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} × {h} {unit} ({ppi} ppp) Resolution: {dpi} pixels/inch Résolution : {dpi} pixels/pouce Doc: {w} × {h} px Doc : {w} × {h} px Frame {current} / {total} Image {current} / {total} @@ -1862,6 +1883,7 @@ Show only crosshair while painting N'afficher que le réticule pendant la peinture Show slice numbers Afficher les numéros de tranche Show tooltips Afficher les info-bulles + Show highlight on rollover Afficher la mise en surbrillance au survol Show transformation values Afficher les valeurs de transformation Slice color Couleur des tranches Smart guide color Couleur des repères intelligents @@ -1963,6 +1985,7 @@ Create Warped Text Créer un texte déformé Rasterize Smart Object Rastériser l'objet intelligent Rasterize Fill Content Rastériser le contenu du remplissage + Rasterize Layer Style Rastériser le style de calque Unlock: picking a preset replaces these settings Déverrouiller : choisir un préréglage remplace ces paramètres Lock: keep these settings when picking another preset Verrouiller : conserver ces paramètres en choisissant un autre préréglage Diameter: Diamètre : @@ -2649,6 +2672,7 @@ cameraRaw Blending Fusion # Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Sélecteur de couleurs (Couleur du texte) + Color Picker ({name}) Sélecteur de couleurs ({name}) Color Picker (Fill Color) Sélecteur de couleurs (Couleur de remplissage) Fill color Couleur de remplissage Color Picker (Stroke Color) Sélecteur de couleurs (Couleur du contour) @@ -2744,6 +2768,7 @@ fontWeight Italic Italique Color to Alpha… Couleur vers alpha… Transparency Threshold Seuil de transparence Opacity Threshold Seuil d'opacité + PhotoCraft — Screen Color Picker PhotoCraft — Sélecteur de couleur d'écran Rasterize svg groups deeper than Pixelliser les groupes SVG au-delà de cette profondeur 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 désactive ce seuil ; les groupes SVG imbriqués sur plus de 100 niveaux sont toujours pixellisés. @@ -2837,3 +2862,11 @@ fontWeight Italic Italique Panel menu Menu du panneau Resize panel window Redimensionner la fenêtre du panneau Resize panels Redimensionner les panneaux + Use Classic Mode Utiliser le mode classique + Auto Center Preview Centrer automatiquement l'aperçu + Next Document Document suivant + Previous Document Document précédent + W x H x Resolution L x H x Résolution + Front Image Image de premier plan + Search settings… Rechercher des réglages… + No matching settings Aucun réglage correspondant diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8033d8279e..25fd766dbe 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -31,8 +31,15 @@ New… Baru… Open… Buka… Open As… Buka Sebagai… + Open EXR Parts… Buka bagian EXR… + Add beauty precomp from the AOVs Tambahkan prakomposisi beauty dari AOV + Both Keduanya + Left Kiri + Right Kanan Open Recent Terakhir Dibuka Clear Recent Files Hapus Daftar Berkas Terakhir + Remove from Recent Hapus dari Daftar Terakhir + Remove {name} from Recent Hapus {name} dari Daftar Terakhir Close Tutup Minimize Minimalkan Maximize Maksimalkan @@ -80,6 +87,8 @@ Import Impor Browse… Telusuri… Reveal in Finder Tampilkan di Finder + Show in Explorer Tampilkan di Explorer + Show in Folder Tampilkan di Folder File Info… Info Berkas… Print… Cetak… Print One Copy Cetak Satu Salinan @@ -248,6 +257,7 @@ Layer via Cut Layer via Potong Layer From Background… Layer dari Background… Layer from Background… Layer dari Background… + Background From Layer Background dari Layer New Shape Layer Layer Bentuk Baru New Type Layer Layer Teks Baru New Fill Layer Layer Isian Baru @@ -568,6 +578,10 @@ 100% 100% 200% 200% 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% 125% 125% 150% 150% 175% 175% @@ -660,6 +674,10 @@ Classic Theme Tema Klasik Solarized dark Solarized gelap Solarized Dark Theme Tema Solarized Gelap + Breeze dark Breeze gelap + Breeze Dark Theme Tema Breeze Gelap + Breeze light Breeze terang + Breeze Light Theme Tema Breeze Terang Next Theme Tema Berikutnya Actions Action Brush Settings Pengaturan Kuas @@ -792,7 +810,6 @@ Blending Options Opsi Blending Blue Biru Blue, Red, Yellow Biru, Merah, Kuning - Both Keduanya Both Axes Kedua Sumbu Bottom Bawah Brightness Kecerahan @@ -1105,7 +1122,6 @@ Leading Leading Leading (set to the font size × auto-leading when Auto) Leading (diatur ke ukuran font × auto-leading saat Auto) Leave as is (don't color manage) Biarkan apa adanya (tanpa manajemen warna) - Left Kiri Left align text Teks rata kiri Length: Panjang: Level Level @@ -1277,6 +1293,7 @@ Record ● a sequence of edits, then play ▶ it on any document. Rekam ● urutan pengeditan, lalu putar ▶ di dokumen apa saja. Red Merah Redefine style by current selection Definisikan ulang gaya dengan seleksi saat ini + Refine Perhalus Reflected Gradient Gradien Pantulan Registration Marks Tanda Registrasi Regular Reguler @@ -1315,7 +1332,6 @@ Restore All Pulihkan Semua Restore Defaults Pulihkan Default Restrictive (Web) Restriktif (Web) - Right Kanan Right align text Teks rata kanan Rigid Kaku Rotate: Putar: @@ -1495,6 +1511,7 @@ Blur Tool Alat Blur Bold Tebal Brush Kuas + Block Blok Brush Pose Pose Kuas Brush Tip Shape Bentuk Ujung Kuas Brush Tool Alat Kuas @@ -1543,6 +1560,8 @@ Efficiency: 100% Efisiensi: 100% Ellipse Tool Alat Ellipse Elliptical Marquee Tool Alat Elliptical Marquee + Single Row Marquee Tool Alat Single Row Marquee + Single Column Marquee Tool Alat Single Column Marquee Emboss Emboss Eraser Penghapus Eraser Tool Alat Penghapus @@ -1600,6 +1619,7 @@ Mobile Seluler Move Tool Alat Pindah Could not use the move tool because the layer is locked. Tidak dapat menggunakan alat Pindahkan karena layer terkunci. + Could not complete the Free Transform command because the layer is locked. Tidak dapat menyelesaikan perintah Transformasi Bebas karena layer terkunci. Multiply Multiply My Brushes Kuas Saya Neutrals Netral @@ -1706,6 +1726,7 @@ Scratch: {size} Scratch: {size} Untagged {mode} {mode} Tanpa Tag {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) Resolution: {dpi} pixels/inch Resolusi: {dpi} piksel/inci Doc: {w} × {h} px Dok: {w} × {h} px Frame {current} / {total} Frame {current} / {total} @@ -1856,6 +1877,7 @@ Show only crosshair while painting Tampilkan crosshair saja saat melukis Show slice numbers Tampilkan nomor slice Show tooltips Tampilkan tooltip + Show highlight on rollover Tampilkan sorotan saat kursor melintas Show transformation values Tampilkan nilai transformasi Slice color Warna slice Smart guide color Warna smart guide @@ -1957,6 +1979,7 @@ Create Warped Text Buat Teks Warp Rasterize Smart Object Rasterisasi Smart Object Rasterize Fill Content Rasterisasi Isi Konten + Rasterize Layer Style Rasterisasi Gaya Layer Unlock: picking a preset replaces these settings Buka kunci: memilih preset akan mengganti pengaturan ini Lock: keep these settings when picking another preset Kunci: pertahankan pengaturan ini saat memilih preset lain Diameter: Diameter: @@ -2636,6 +2659,7 @@ cameraRaw Blending Pembauran # Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Pemilih Warna (Warna Teks) + Color Picker ({name}) Pemilih Warna ({name}) Color Picker (Fill Color) Pemilih Warna (Warna Isian) Fill color Warna isian Color Picker (Stroke Color) Pemilih Warna (Warna Stroke) @@ -2731,6 +2755,7 @@ fontWeight Italic Miring Color to Alpha… Warna ke Alpha… Transparency Threshold Ambang transparansi Opacity Threshold Ambang opasitas + PhotoCraft — Screen Color Picker PhotoCraft — Pemilih Warna Layar Rasterize svg groups deeper than Rasterisasi grup SVG yang lebih dalam dari ini 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 menonaktifkan ambang ini; grup SVG yang bersarang lebih dari 100 tingkat selalu dirasterisasi. @@ -2824,3 +2849,11 @@ fontWeight Italic Miring Panel menu Menu panel Resize panel window Ubah ukuran jendela panel Resize panels Ubah ukuran panel + Use Classic Mode Gunakan Mode Klasik + Auto Center Preview Pusatkan Pratinjau Otomatis + Next Document Dokumen Berikutnya + Previous Document Dokumen Sebelumnya + W x H x Resolution L x T x Resolusi + Front Image Gambar Depan + Search settings… Cari pengaturan… + No matching settings Tidak ada pengaturan yang cocok diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 94cfd708b2..fb1bc73b24 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -32,8 +32,15 @@ New from Clipboard Nuovo dagli appunti Open… Apri… Open As… Apri come… + Open EXR Parts… Apri parti EXR… + Add beauty precomp from the AOVs Aggiungi la precomposizione della bellezza dagli AOV + Both Entrambe + Left Sinistra + Right Destra Open Recent Apri recenti Clear Recent Files Cancella file recenti + Remove from Recent Rimuovi dai recenti + Remove {name} from Recent Rimuovi {name} dai recenti Close Chiudi Minimize Riduci a icona Maximize Ingrandisci @@ -81,6 +88,8 @@ Import Importa Browse… Sfoglia… Reveal in Finder Mostra nel Finder + Show in Explorer Mostra in Esplora file + Show in Folder Mostra nella cartella File Info… Info file… Print… Stampa… Print One Copy Stampa una copia @@ -256,6 +265,8 @@ Layer via Cut Livello tramite taglia Layer From Background… Livello dallo sfondo… Layer from Background… Livello dallo sfondo… + Background from Layer Sfondo da livello + Background From Layer Sfondo da livello New Shape Layer Nuovo livello forma New Type Layer Nuovo livello testo New Fill Layer Nuovo livello riempimento @@ -581,6 +592,10 @@ 100% 100% 200% 200% 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% 125% 125% 150% 150% 175% 175% @@ -673,6 +688,10 @@ Classic Theme Tema classico Solarized dark Solarized scuro Solarized Dark Theme Tema Solarized scuro + Breeze dark Breeze scuro + Breeze Dark Theme Tema Breeze scuro + Breeze light Breeze chiaro + Breeze Light Theme Tema Breeze chiaro Next Theme Tema successivo Actions Azioni Brush Settings Impostazioni pennello @@ -806,7 +825,6 @@ Blending Options Opzioni di fusione Blue Blu Blue, Red, Yellow Blu, rosso, giallo - Both Entrambi Both Axes Entrambi gli assi Bottom In basso Brightness Luminosità @@ -1126,7 +1144,6 @@ Leading Interlinea Leading (set to the font size × auto-leading when Auto) Interlinea (impostata a dimensione font × interlinea auto quando Auto) Leave as is (don't color manage) Lascia com'è (non gestire colore) - Left Sinistra Left align text Allinea testo a sinistra Length: Lunghezza: Level Livello @@ -1298,6 +1315,7 @@ Record ● a sequence of edits, then play ▶ it on any document. Registra ● una sequenza di modifiche, poi riproduci ▶ su qualsiasi documento. Red Rosso Redefine style by current selection Ridefinisci stile con selezione corrente + Refine Rifinisci Reflected Gradient Sfumatura riflessa Registration Marks Crocini di registro Regular Normale @@ -1336,7 +1354,6 @@ Restore All Ripristina tutto Restore Defaults Ripristina predefiniti Restrictive (Web) Restrittivo (web) - Right Destra Right align text Allinea testo a destra Rigid Rigido Rotate: Rotazione: @@ -1520,6 +1537,7 @@ Blur Tool Strumento Sfoca Bold Grassetto Brush Pennello + Block Blocco Brush Pose Posa pennello Brush Tip Shape Forma punta pennello Brush Tool Strumento Pennello @@ -1568,6 +1586,8 @@ Efficiency: 100% Efficienza: 100% Ellipse Tool Strumento Ellisse Elliptical Marquee Tool Strumento Selezione ellittica + Single Row Marquee Tool Strumento Selezione singola riga + Single Column Marquee Tool Strumento Selezione singola colonna Emboss Rilievo Eraser Gomma Eraser Tool Strumento Gomma @@ -1625,6 +1645,7 @@ Mobile Mobile Move Tool Strumento Sposta Could not use the move tool because the layer is locked. Impossibile usare lo strumento Sposta perché il livello è bloccato. + Could not complete the Free Transform command because the layer is locked. Impossibile completare il comando Trasformazione libera perché il livello è bloccato. Multiply Moltiplica My Brushes I miei pennelli Neutrals Neutri @@ -1735,6 +1756,7 @@ Invalid {mode} profile Profilo {mode} non valido Unnamed {mode} profile Profilo {mode} senza nome {w} px x {h} px ({ppi} ppi) {w} px × {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} × {h} {unit} ({ppi} ppi) Resolution: {dpi} pixels/inch Risoluzione: {dpi} pixel/pollice Doc: {w} × {h} px Doc: {w} × {h} px Frame {current} / {total} Fotogramma {current} / {total} @@ -1887,6 +1909,7 @@ Show only crosshair while painting Mostra solo mirino durante pittura Show slice numbers Mostra numeri sezioni Show tooltips Mostra tooltip + Show highlight on rollover Mostra evidenziazione al passaggio del mouse Show bounding box when dragging layer Mostra riquadro selezione al trascinamento livello System title bar Barra del titolo di sistema Show transformation values Mostra valori trasformazione @@ -1994,6 +2017,7 @@ Create Warped Text Crea testo deformato Rasterize Smart Object Rasterizza oggetto avanzato Rasterize Fill Content Rasterizza contenuto riempimento + Rasterize Layer Style Rasterizza stile livello Unlock: picking a preset replaces these settings Sblocca: scegliendo un predefinito sostituisce queste impostazioni Lock: keep these settings when picking another preset Blocca: mantiene queste impostazioni scegliendo altro predefinito Diameter: Diametro: @@ -2297,6 +2321,7 @@ cameraRaw Amount Quantità # Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Selettore colore (Colore del testo) + Color Picker ({name}) Selettore colore ({name}) Color Picker (Fill Color) Selettore colore (Colore di riempimento) Fill color Colore di riempimento Color Picker (Stroke Color) Selettore colore (Colore della traccia) @@ -2394,6 +2419,7 @@ fontWeight Italic Corsivo Color to Alpha… Colore ad alfa… Transparency Threshold Soglia di trasparenza Opacity Threshold Soglia di opacità + PhotoCraft — Screen Color Picker PhotoCraft — Selettore colore schermo Rasterize svg groups deeper than Rasterizza i gruppi SVG oltre questa profondità 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 disattiva questa soglia; i gruppi SVG annidati oltre 100 livelli vengono sempre rasterizzati. @@ -2488,3 +2514,11 @@ fontWeight Italic Corsivo Panel menu Menu del pannello Resize panel window Ridimensiona finestra del pannello Resize panels Ridimensiona pannelli + Use Classic Mode Usa modalità classica + Auto Center Preview Centra anteprima automaticamente + Next Document Documento successivo + Previous Document Documento precedente + W x H x Resolution L x A x Risoluzione + Front Image Immagine in primo piano + Search settings… Cerca impostazioni… + No matching settings Nessuna impostazione corrispondente diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7f76f25496..3614d38be7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -36,8 +36,15 @@ New from Clipboard クリップボードから新規作成 Open… 開く… Open As… 形式を指定して開く… + Open EXR Parts… EXRパートを開く… + Add beauty precomp from the AOVs AOVからビューティーをプリコンポーズして追加 + Both 両方 + Left 左 + Right 右 Open Recent 最近使ったファイルを開く Clear Recent Files 最近使ったファイルを消去 + Remove from Recent 最近使ったファイルから削除 + Remove {name} from Recent {name} を最近使ったファイルから削除 Close 閉じる Minimize 最小化 Maximize 最大化 @@ -85,6 +92,8 @@ Import 読み込み Browse… 参照… Reveal in Finder Finder で表示 + Show in Explorer エクスプローラーで表示 + Show in Folder フォルダーで表示 File Info… ファイル情報… Print… プリント… Print One Copy 1 部プリント @@ -260,6 +269,7 @@ Layer via Cut 切り取ったレイヤー Layer From Background… 背景からレイヤーへ… Layer from Background… 背景からレイヤーへ… + Background From Layer レイヤーから背景 New Shape Layer 新規シェイプレイヤー New Type Layer 新規文字レイヤー New Fill Layer 新規塗りつぶしレイヤー @@ -587,6 +597,10 @@ 100% 100% 200% 200% 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% 125% 125% 150% 150% 175% 175% @@ -679,6 +693,10 @@ Classic Theme クラシックテーマ Solarized dark Solarized ダーク Solarized Dark Theme Solarized ダークテーマ + Breeze dark Breeze ダーク + Breeze Dark Theme Breeze ダークテーマ + Breeze light Breeze ライト + Breeze Light Theme Breeze ライトテーマ Next Theme 次のテーマ Actions アクション Brush Settings ブラシ設定 @@ -813,7 +831,6 @@ Blending Options 描画オプション Blue 青 Blue, Red, Yellow 青、赤、黄 - Both 両方 Both Axes 両方の軸 Bottom 下 Brightness 明るさ @@ -1132,7 +1149,6 @@ Leading 行送り Leading (set to the font size × auto-leading when Auto) 行送り(自動のときはフォントサイズ × 自動行送り) Leave as is (don't color manage) そのまま(カラーマネジメントしない) - Left 左 Left align text 左揃え Length: 長さ: Level レベル @@ -1304,6 +1320,7 @@ Record ● a sequence of edits, then play ▶ it on any document. 一連の編集を記録 ● し、任意のドキュメントで再生 ▶ できます。 Red 赤 Redefine style by current selection 現在の選択でスタイルを再定義 + Refine 調整 Reflected Gradient 反射形グラデーション Registration Marks 見当合わせマーク Regular レギュラー @@ -1341,7 +1358,6 @@ Restore All すべて復元 Restore Defaults 既定に戻す Restrictive (Web) 制限(Web) - Right 右 Right align text 右揃え Rigid 剛体 Rotate: 回転: @@ -1525,6 +1541,7 @@ Blur Tool ぼかしツール Bold ボールド Brush ブラシ + Block ブロック Brush Pose ブラシの姿勢 Brush Tip Shape ブラシ先端のシェイプ Brush Tool ブラシツール @@ -1574,6 +1591,8 @@ Efficiency: 100% 効率: 100% Ellipse Tool 楕円形ツール Elliptical Marquee Tool 楕円形選択ツール + Single Row Marquee Tool 一行選択ツール + Single Column Marquee Tool 一列選択ツール Emboss エンボス Eraser 消しゴム Eraser Tool 消しゴムツール @@ -1631,6 +1650,7 @@ Mobile モバイル Move Tool 移動ツール Could not use the move tool because the layer is locked. レイヤーがロックされているため、移動ツールを使用できません。 + Could not complete the Free Transform command because the layer is locked. レイヤーがロックされているため、自由変形コマンドを完了できません。 Multiply 乗算 My Brushes マイブラシ Neutrals 中間色系 @@ -1741,6 +1761,7 @@ Invalid {mode} profile 無効な {mode} プロファイル Unnamed {mode} profile 名前のない {mode} プロファイル {w} px x {h} px ({ppi} ppi) {w} px × {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} × {h} {unit} ({ppi} ppi) Resolution: {dpi} pixels/inch 解像度: {dpi} ピクセル/インチ Doc: {w} × {h} px ドキュメント: {w} × {h} px Frame {current} / {total} フレーム {current} / {total} @@ -1893,6 +1914,7 @@ Show only crosshair while painting 描画中は十字線のみ表示 Show slice numbers スライス番号を表示 Show tooltips ツールチップを表示 + Show highlight on rollover ロールオーバー時にハイライトを表示 Show bounding box when dragging layer レイヤーのドラッグ中にバウンディングボックスを表示 System title bar システムのタイトルバー Show transformation values 変形値を表示 @@ -2000,6 +2022,7 @@ Create Warped Text ワープテキストを作成 Rasterize Smart Object スマートオブジェクトをラスタライズ Rasterize Fill Content 塗りの内容をラスタライズ + Rasterize Layer Style レイヤースタイルをラスタライズ Unlock: picking a preset replaces these settings ロック解除: プリセットを選ぶとこの設定は置き換わります Lock: keep these settings when picking another preset ロック: 別のプリセットを選んでもこの設定を保持します Diameter: 直径: @@ -2202,6 +2225,7 @@ cameraRaw Color カラー # Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) カラーピッカー(文字色) + Color Picker ({name}) カラーピッカー({name}) Color Picker (Fill Color) カラーピッカー(塗りつぶしカラー) Fill color 塗りつぶしカラー Color Picker (Stroke Color) カラーピッカー(線のカラー) @@ -3347,6 +3371,7 @@ fontWeight Italic 斜体 Color to Alpha… カラーを透明に… Transparency Threshold 透明のしきい値 Opacity Threshold 不透明度のしきい値 + PhotoCraft — Screen Color Picker PhotoCraft — スクリーンカラーピッカー Rasterize svg groups deeper than この深さを超える SVG グループをラスタライズ 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 でこのしきい値は無効になります。100 階層を超えて入れ子になった SVG グループは常にラスタライズされます。 @@ -3438,3 +3463,11 @@ fontWeight Italic 斜体 Panel menu パネルメニュー Resize panel window パネルウィンドウのサイズを変更 Resize panels パネルのサイズを変更 + Use Classic Mode クラシックモードを使用 + Auto Center Preview プレビューを自動的に中央に配置 + Next Document 次のドキュメント + Previous Document 前のドキュメント + W x H x Resolution 幅 x 高さ x 解像度 + Front Image 前面の画像 + Search settings… 設定を検索… + No matching settings 一致する設定はありません diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 40fdb4ccd8..0f11c22f5b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -24,8 +24,15 @@ New from Clipboard 클립보드에서 새로 만들기 Open… 열기… Open As… 형식을 지정하여 열기… + Open EXR Parts… EXR 파트 열기… + Add beauty precomp from the AOVs AOV에서 뷰티 프리컴포지션 추가 + Both 양쪽 + Left 왼쪽 + Right 오른쪽 Open Recent 최근 파일 열기 Clear Recent Files 최근 파일 목록 지우기 + Remove from Recent 최근 항목에서 제거 + Remove {name} from Recent 최근 항목에서 {name} 제거 Close 닫기 Minimize 최소화 Maximize 최대화 @@ -73,6 +80,8 @@ Import 가져오기 Browse… 찾아보기… Reveal in Finder Finder에서 보기 + Show in Explorer 파일 탐색기에서 보기 + Show in Folder 폴더에서 보기 File Info… 파일 정보… Print… 인쇄… Print One Copy 한 부 인쇄 @@ -243,6 +252,8 @@ Layer via Cut 잘라내어 레이어 만들기 Layer From Background… 배경에서 레이어 만들기… Layer from Background… 배경에서 레이어 만들기… + Background from Layer 레이어에서 배경 만들기 + Background From Layer 레이어에서 배경 만들기 New Shape Layer 새 모양 레이어 New Type Layer 새 문자 레이어 New Fill Layer 새 칠 레이어 @@ -655,6 +666,10 @@ Classic Theme 클래식 테마 Solarized dark Solarized 어두움 Solarized Dark Theme Solarized 어두운 테마 + Breeze dark Breeze 어두움 + Breeze Dark Theme Breeze 어두운 테마 + Breeze light Breeze 밝게 + Breeze Light Theme Breeze 밝은 테마 Next Theme 다음 테마 Actions 액션 Brush Settings 브러시 설정 @@ -787,7 +802,6 @@ Blending Options 혼합 옵션 Blue 파랑 Blue, Red, Yellow 파랑, 빨강, 노랑 - Both 둘 다 Both Axes 양쪽 축 Bottom 아래쪽 Brightness 밝기 @@ -1103,7 +1117,6 @@ Leading 행간 Leading (set to the font size × auto-leading when Auto) 행간(자동일 때 글꼴 크기 × 자동 행간으로 설정) Leave as is (don't color manage) 그대로 두기(색상 관리 안 함) - Left 왼쪽 Left align text 텍스트 왼쪽 정렬 Length: 길이: Level 레벨 @@ -1275,6 +1288,7 @@ Record ● a sequence of edits, then play ▶ it on any document. 연속된 편집을 ● 기록한 다음 원하는 문서에서 ▶ 재생하세요. Red 빨강 Redefine style by current selection 현재 선택으로 스타일 재정의 + Refine 다듬기 Reflected Gradient 반사 그레이디언트 Registration Marks 맞춤 표시 Regular 보통 @@ -1312,7 +1326,6 @@ Restore All 모두 복원 Restore Defaults 기본값 복원 Restrictive (Web) 제한됨(웹) - Right 오른쪽 Right align text 텍스트 오른쪽 정렬 Rigid 고정 Rotate: 회전: @@ -1496,6 +1509,8 @@ Blur Tool 흐림 효과 도구 Bold 굵게 Brush 브러시 + Pencil 연필 + Block 블록 Brush Pose 브러시 자세 Brush Tip Shape 브러시 끝 모양 Brush Tool 브러시 도구 @@ -1544,6 +1559,8 @@ Efficiency: 100% 효율: 100% Ellipse Tool 타원 도구 Elliptical Marquee Tool 원형 선택 윤곽 도구 + Single Row Marquee Tool 단일 행 선택 윤곽 도구 + Single Column Marquee Tool 단일 열 선택 윤곽 도구 Emboss 엠보스 Eraser 지우개 Eraser Tool 지우개 도구 @@ -1601,6 +1618,7 @@ Mobile 모바일 Move Tool 이동 도구 Could not use the move tool because the layer is locked. 레이어가 잠겨 있어 이동 도구를 사용할 수 없습니다. + Could not complete the Free Transform command because the layer is locked. 레이어가 잠겨 있어 자유 변형 명령을 완료할 수 없습니다. Multiply 곱하기 My Brushes 내 브러시 Neutrals 중립색 @@ -1707,6 +1725,7 @@ Scratch: {size} 스크래치: {size} Untagged {mode} 프로파일 없는 {mode} {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) Resolution: {dpi} pixels/inch 해상도: {dpi}픽셀/인치 Doc: {w} × {h} px 문서: {w} × {h} px Frame {current} / {total} 프레임 {current} / {total} @@ -1857,6 +1876,7 @@ Show only crosshair while painting 칠하는 동안 십자선만 표시 Show slice numbers 분할 영역 번호 표시 Show tooltips 도구 설명 표시 + Show highlight on rollover 롤오버 시 강조 표시 Show transformation values 변형 값 표시 Slice color 분할 영역 색상 Smart guide color 고급 안내선 색상 @@ -1958,6 +1978,7 @@ Create Warped Text 뒤틀린 텍스트 만들기 Rasterize Smart Object 스마트 오브젝트 래스터화 Rasterize Fill Content 칠 내용 래스터화 + Rasterize Layer Style 레이어 스타일 래스터화 Unlock: picking a preset replaces these settings 잠금 해제: 사전 설정 선택 시 이 설정 대체 Lock: keep these settings when picking another preset 잠금: 다른 사전 설정 선택 시 이 설정 유지 Diameter: 직경: @@ -2109,6 +2130,10 @@ cameraRaw Color 색상 # UI scale steps (#611) 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% 125% 125% 150% 150% 175% 175% @@ -2811,6 +2836,7 @@ cameraRaw Color 색상 # Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) 색상 선택기 (텍스트 색상) + Color Picker ({name}) 색상 선택기 ({name}) Color Picker (Fill Color) 색상 선택기 (채우기 색상) Fill color 채우기 색상 Color Picker (Stroke Color) 색상 선택기 (획 색상) @@ -2904,6 +2930,7 @@ fontWeight Italic 기울임 Color to Alpha… 색상을 알파로… Transparency Threshold 투명도 한계값 Opacity Threshold 불투명도 한계값 + PhotoCraft — Screen Color Picker PhotoCraft — 화면 색상 선택기 Rasterize svg groups deeper than 이 깊이보다 깊은 SVG 그룹 래스터화 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0이면 이 임계값을 끕니다. 100단계보다 깊이 중첩된 SVG 그룹은 항상 래스터화됩니다. @@ -2996,3 +3023,11 @@ fontWeight Italic 기울임 Panel menu 패널 메뉴 Resize panel window 패널 창 크기 조절 Resize panels 패널 크기 조절 + Use Classic Mode 클래식 모드 사용 + Auto Center Preview 미리 보기 자동 가운데 맞춤 + Next Document 다음 문서 + Previous Document 이전 문서 + W x H x Resolution 폭 x 높이 x 해상도 + Front Image 전면 이미지 + Search settings… 설정 검색… + No matching settings 일치하는 설정 없음 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 2b89f59e73..6766688de7 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -785,6 +785,22 @@ mod tests { } } + /// `menus::reveal_label` picks one name per platform, so every name needs a translation + /// whichever OS runs the tests (UI-217-19). + #[test] + fn reveal_labels_are_translated() { + for lang in Lang::all().filter(|l| l.complete_menus()) { + let cat = lang.0.catalog(); + for label in ["Reveal in Finder", "Show in Explorer", "Show in Folder"] { + let tr = cat.plain(label); + assert!(tr.is_some(), "{} missing {label}", lang.code()); + if label != "Reveal in Finder" { + assert!(!tr.unwrap_or_default().contains("Finder"), "{}: {label} names the Finder", lang.code()); + } + } + } + } + #[test] fn layer_color_names_are_translated() { for lang in Lang::all().filter(|l| l.complete_menus()) { diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index a7a99f396f..f9e2ccdc0f 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -28,8 +28,15 @@ New from Clipboard Nieuw vanaf klembord Open… Openen… Open As… Openen als… + Open EXR Parts… EXR-delen openen… + Add beauty precomp from the AOVs Schoonheidsvoorcompositie toevoegen vanuit de AOV's + Both Beide + Left Links + Right Rechts Open Recent Recent geopend Clear Recent Files Recente bestanden wissen + Remove from Recent Verwijderen uit recente bestanden + Remove {name} from Recent {name} verwijderen uit recente bestanden Close Sluiten Minimize Minimaliseren Maximize Maximaliseren @@ -77,6 +84,8 @@ Import Importeren Browse… Bladeren… Reveal in Finder Weergeven in Finder + Show in Explorer Weergeven in Verkenner + Show in Folder Weergeven in map File Info… Bestandsinformatie… Print… Afdrukken… Print One Copy Eén exemplaar afdrukken @@ -252,6 +261,7 @@ Layer via Cut Laag via knippen Layer From Background… Laag uit achtergrond… Layer from Background… Laag uit achtergrond… + Background From Layer Achtergrond uit laag New Shape Layer Nieuwe vormlaag New Type Layer Nieuwe tekstlaag New Fill Layer Nieuwe opvullaag @@ -663,6 +673,10 @@ Classic Theme Klassiek thema Solarized dark Solarized donker Solarized Dark Theme Solarized-thema donker + Breeze dark Breeze donker + Breeze Dark Theme Breeze-thema donker + Breeze light Breeze licht + Breeze Light Theme Breeze-thema licht Adwaita Light Theme Adwaita-thema licht Adwaita Dark Theme Adwaita-thema donker Adwaita Adwaita @@ -798,7 +812,6 @@ Blending Options Overvloeiopties Blue Blauw Blue, Red, Yellow Blauw, rood, geel - Both Beide Both Axes Beide assen Bottom Onder Brightness Helderheid @@ -1120,7 +1133,6 @@ Leading Interlinie Leading (set to the font size × auto-leading when Auto) Interlinie (bij Automatisch: lettergrootte × automatische interlinie) Leave as is (don't color manage) Ongewijzigd laten (geen kleurbeheer) - Left Links Left align text Tekst links uitlijnen Length: Lengte: Level Niveau @@ -1292,6 +1304,7 @@ Record ● a sequence of edits, then play ▶ it on any document. Neem ● een reeks bewerkingen op en speel deze ▶ af op elk document. Red Rood Redefine style by current selection Stijl opnieuw definiëren op basis van huidige selectie + Refine Verfijnen Reflected Gradient Gespiegeld verloop Registration Marks Pastekens Regular Normaal @@ -1329,7 +1342,6 @@ Restore All Alles herstellen Restore Defaults Standaardwaarden herstellen Restrictive (Web) Beperkt (web) - Right Rechts Right align text Tekst rechts uitlijnen Rigid Star Rotate: Roteren: @@ -1509,6 +1521,7 @@ Blur Tool Vervagingsgereedschap Bold Vet Brush Penseel + Block Blok Brush Pose Penseelhouding Brush Tip Shape Vorm penseelpunt Brush Tool Penseelgereedschap @@ -1557,6 +1570,8 @@ Efficiency: 100% Efficiëntie: 100% Ellipse Tool Ellipsgereedschap Elliptical Marquee Tool Ovaal selectiekader + Single Row Marquee Tool Selectiekader enkele rij + Single Column Marquee Tool Selectiekader enkele kolom Emboss Reliëf Eraser Gum Eraser Tool Gumgereedschap @@ -1614,6 +1629,7 @@ Mobile Mobiel Move Tool Verplaatsgereedschap Could not use the move tool because the layer is locked. Kan het verplaatsgereedschap niet gebruiken omdat de laag is vergrendeld. + Could not complete the Free Transform command because the layer is locked. Kan de opdracht Vrije transformatie niet voltooien omdat de laag is vergrendeld. Multiply Vermenigvuldigen My Brushes Mijn penselen Neutrals Neutrale tinten @@ -1722,6 +1738,7 @@ Invalid {mode} profile Ongeldig {mode}-profiel Unnamed {mode} profile Naamloos {mode}-profiel {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) Resolution: {dpi} pixels/inch Resolutie: {dpi} pixels/inch Doc: {w} × {h} px Doc: {w} × {h} px Frame {current} / {total} Frame {current} / {total} @@ -1872,6 +1889,7 @@ Show only crosshair while painting Alleen dradenkruis tonen tijdens schilderen Show slice numbers Segmentnummers tonen Show tooltips Knopinfo tonen + Show highlight on rollover Markering bij aanwijzen tonen Show bounding box when dragging layer Selectiekader tonen bij slepen van laag System title bar Titelbalk van systeem Show transformation values Transformatiewaarden tonen @@ -1973,6 +1991,7 @@ Create Warped Text Verbogen tekst maken Rasterize Smart Object Slim object rasteren Rasterize Fill Content Vulinhoud rasteren + Rasterize Layer Style Laagstijl rasteren Unlock: picking a preset replaces these settings Ontgrendelen: een voorinstelling kiezen vervangt deze instellingen Lock: keep these settings when picking another preset Vergrendelen: deze instellingen behouden bij het kiezen van een andere voorinstelling Diameter: Diameter: @@ -2170,6 +2189,10 @@ Show Vectorscope Vectorscoop tonen Tone Histogram Toonhistogram 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% 125% 125% 150% 150% 175% 175% @@ -2240,6 +2263,7 @@ Knockout Uitsparing Apply At Canvas Bounds Toepassen op canvasgrenzen Color Picker (Text Color) Kleurkiezer (tekstkleur) + Color Picker ({name}) Kleurkiezer ({name}) Color Picker (Fill Color) Kleurkiezer (vulkleur) Fill color Vulkleur Color Picker (Stroke Color) Kleurkiezer (lijnkleur) @@ -3597,3 +3621,12 @@ cameraRaw Whites Witten Panel menu Paneelmenu Resize panel window Paneelvenster vergroten of verkleinen Resize panels Panelen vergroten of verkleinen + Use Classic Mode Klassieke modus gebruiken + Auto Center Preview Voorvertoning automatisch centreren + Next Document Volgend document + Previous Document Vorig document + W x H x Resolution B x H x Resolutie + Front Image Voorste afbeelding + Search settings… Instellingen zoeken… + No matching settings Geen overeenkomende instellingen + PhotoCraft — Screen Color Picker PhotoCraft — Schermkleurkiezer diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a97c8ac91c..08fa9c9fdb 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -35,8 +35,15 @@ New from Clipboard Nowy ze schowka Open… Otwórz… Open As… Otwórz jako… + Open EXR Parts… Otwórz części EXR… + Add beauty precomp from the AOVs Dodaj wstępną kompozycję piękna z AOV-ów + Both Obie + Left Lewa + Right Prawa Open Recent Otwórz ostatnie Clear Recent Files Wyczyść listę ostatnich plików + Remove from Recent Usuń z ostatnich plików + Remove {name} from Recent Usuń {name} z ostatnich plików Close Zamknij Minimize Minimalizuj Maximize Maksymalizuj @@ -84,6 +91,8 @@ Import Importuj Browse… Przeglądaj… Reveal in Finder Pokaż w Finderze + Show in Explorer Pokaż w Eksploratorze + Show in Folder Pokaż w folderze File Info… Informacje o pliku… Print… Drukuj… Print One Copy Drukuj jedną kopię @@ -258,6 +267,7 @@ Layer via Cut Warstwa przez wycięcie Layer From Background… Warstwa z tła… Layer from Background… Warstwa z tła… + Background From Layer Tło z warstwy New Shape Layer Nowa warstwa kształtu New Type Layer Nowa warstwa tekstowa New Fill Layer Nowa warstwa wypełnienia @@ -585,6 +595,10 @@ 100% 100% 200% 200% 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% 125% 125% 150% 150% 175% 175% @@ -677,6 +691,10 @@ Classic Theme Motyw klasyczny Solarized dark Solarized ciemny Solarized Dark Theme Ciemny motyw Solarized + Breeze dark Breeze ciemny + Breeze Dark Theme Ciemny motyw Breeze + Breeze light Breeze jasny + Breeze Light Theme Jasny motyw Breeze Next Theme Następny motyw Actions Akcje Brush Settings Ustawienia pędzla @@ -810,7 +828,6 @@ Blending Options Opcje mieszania Blue Niebieski Blue, Red, Yellow Niebieski, czerwony, żółty - Both Oba Both Axes Obie osie Bottom Dół Brightness Jasność @@ -1127,7 +1144,6 @@ Leading Interlinia Leading (set to the font size × auto-leading when Auto) Interlinia (przy ustawieniu Automatycznie: rozmiar czcionki × automatyczna interlinia) Leave as is (don't color manage) Pozostaw bez zmian (bez zarządzania kolorami) - Left Lewo Left align text Wyrównaj tekst do lewej Length: Długość: Level Poziom @@ -1299,6 +1315,7 @@ Record ● a sequence of edits, then play ▶ it on any document. Nagraj ● sekwencję edycji, a następnie odtwórz ▶ ją na dowolnym dokumencie. Red Czerwony Redefine style by current selection Zdefiniuj styl ponownie na podstawie bieżącego zaznaczenia + Refine Dopracuj Reflected Gradient Gradient odbity Registration Marks Znaczniki pasowania Regular Zwykła @@ -1336,7 +1353,6 @@ Restore All Przywróć wszystko Restore Defaults Przywróć domyślne Restrictive (Web) Ograniczona (Internet) - Right Prawo Right align text Wyrównaj tekst do prawej Rigid Sztywne Rotate: Obrót: @@ -1518,6 +1534,7 @@ Blur Tool Narzędzie Rozmycie Bold Pogrubiona Brush Pędzel + Block Blok Brush Pose Pozycja pędzla Brush Tip Shape Kształt końcówki pędzla Brush Tool Narzędzie Pędzel @@ -1567,6 +1584,8 @@ Efficiency: 100% Wydajność: 100% Ellipse Tool Narzędzie Elipsa Elliptical Marquee Tool Narzędzie Zaznaczenie eliptyczne + Single Row Marquee Tool Narzędzie Zaznaczenie pojedynczego wiersza + Single Column Marquee Tool Narzędzie Zaznaczenie pojedynczej kolumny Emboss Płaskorzeźba Eraser Gumka Eraser Tool Narzędzie Gumka @@ -1624,6 +1643,7 @@ Mobile Urządzenia mobilne Move Tool Narzędzie Przesunięcie Could not use the move tool because the layer is locked. Nie można użyć narzędzia Przesunięcie, ponieważ warstwa jest zablokowana. + Could not complete the Free Transform command because the layer is locked. Nie można ukończyć polecenia Dowolne przekształcanie, ponieważ warstwa jest zablokowana. Multiply Mnożenie My Brushes Moje pędzle Neutrals Neutralne @@ -1734,6 +1754,7 @@ Invalid {mode} profile Nieprawidłowy profil {mode} Unnamed {mode} profile Profil {mode} bez nazwy {w} px x {h} px ({ppi} ppi) {w} px × {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} × {h} {unit} ({ppi} ppi) Resolution: {dpi} pixels/inch Rozdzielczość: {dpi} pikseli/cal Doc: {w} × {h} px Dok.: {w} × {h} px Frame {current} / {total} Klatka {current} / {total} @@ -1885,6 +1906,7 @@ Show only crosshair while painting Podczas malowania pokaż tylko celownik Show slice numbers Pokaż numery plasterków Show tooltips Pokaż podpowiedzi + Show highlight on rollover Pokaż podświetlenie po najechaniu Show bounding box when dragging layer Pokaż ramkę ograniczającą podczas przeciągania warstwy Show transformation values Pokaż wartości przekształcenia Slice color Kolor plasterków @@ -1990,6 +2012,7 @@ Create Warped Text Utwórz wypaczony tekst Rasterize Smart Object Rasteryzuj obiekt inteligentny Rasterize Fill Content Rasteryzuj zawartość wypełnienia + Rasterize Layer Style Rasteryzuj styl warstwy Unlock: picking a preset replaces these settings Odblokuj: wybór ustawienia wstępnego zastąpi te ustawienia Lock: keep these settings when picking another preset Zablokuj: zachowaj te ustawienia po wybraniu innego ustawienia wstępnego Diameter: Średnica: @@ -3241,6 +3264,7 @@ cameraRaw Roughness Chropowatość # Text Color Picker and screen sampling Color Picker (Text Color) Próbnik kolorów (Kolor tekstu) + Color Picker ({name}) Próbnik kolorów ({name}) Color Picker (Fill Color) Próbnik kolorów (Kolor wypełnienia) Fill color Kolor wypełnienia Color Picker (Stroke Color) Próbnik kolorów (Kolor obrysu) @@ -3347,6 +3371,7 @@ cameraRaw Roughness Chropowatość Color to Alpha… Kolor na alfę… Transparency Threshold Próg przezroczystości Opacity Threshold Próg krycia + PhotoCraft — Screen Color Picker PhotoCraft — Próbnik kolorów ekranu Rasterize svg groups deeper than Rasteryzuj grupy SVG głębsze niż ten poziom 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 wyłącza ten próg; grupy SVG zagnieżdżone głębiej niż 100 poziomów są zawsze rasteryzowane. @@ -3438,3 +3463,11 @@ cameraRaw Roughness Chropowatość Panel menu Menu panelu Resize panel window Zmień rozmiar okna panelu Resize panels Zmień rozmiar paneli + Use Classic Mode Użyj trybu klasycznego + Auto Center Preview Automatycznie wyśrodkuj podgląd + Next Document Następny dokument + Previous Document Poprzedni dokument + W x H x Resolution Szer. x Wys. x Rozdzielczość + Front Image Obraz na wierzchu + Search settings… Szukaj ustawień… + No matching settings Brak pasujących ustawień diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 809b4d2fad..ac6a0d71ce 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -35,8 +35,15 @@ New… Novo… Open… Abrir… Open As… Abrir como… + Open EXR Parts… Abrir partes EXR… + Add beauty precomp from the AOVs Adicionar a pré-composição da beleza a partir dos AOVs + Both Ambas + Left Esquerda + Right Direita Open Recent Abrir recentes Clear Recent Files Limpar arquivos recentes + Remove from Recent Remover dos recentes + Remove {name} from Recent Remover {name} dos recentes Close Fechar Minimize Minimizar Maximize Maximizar @@ -84,6 +91,8 @@ Import Importar Browse… Procurar… Reveal in Finder Mostrar no Finder + Show in Explorer Mostrar no Explorador + Show in Folder Mostrar na pasta File Info… Informações do arquivo… Print… Imprimir… Print One Copy Imprimir uma cópia @@ -252,6 +261,7 @@ Layer via Cut Camada via recorte Layer From Background… Camada a partir do plano de fundo… Layer from Background… Camada a partir do plano de fundo… + Background From Layer Plano de fundo a partir da camada New Shape Layer Nova camada de forma New Type Layer Nova camada de texto New Fill Layer Nova camada de preenchimento @@ -572,6 +582,10 @@ 100% 100% 200% 200% 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% 125% 125% 150% 150% 175% 175% @@ -664,6 +678,10 @@ Classic Theme Tema clássico Solarized dark Solarized escuro Solarized Dark Theme Tema Solarized Escuro + Breeze dark Breeze escuro + Breeze Dark Theme Tema Breeze Escuro + Breeze light Breeze claro + Breeze Light Theme Tema Breeze Claro Next Theme Próximo tema Actions Ações Brush Settings Configurações do pincel @@ -795,7 +813,6 @@ Blending Options Opções de mesclagem Blue Azul Blue, Red, Yellow Azul, vermelho, amarelo - Both Ambos Both Axes Ambos os eixos Bottom Inferior Brightness Brilho @@ -1110,7 +1127,6 @@ Leading Entrelinha Leading (set to the font size × auto-leading when Auto) Entrelinha (no modo Automático, é o tamanho da fonte × entrelinha automática) Leave as is (don't color manage) Deixar como está (não gerenciar cores) - Left Esquerda Left align text Alinhar texto à esquerda Length: Comprimento: Level Nível @@ -1282,6 +1298,7 @@ Record ● a sequence of edits, then play ▶ it on any document. Grave ● uma sequência de edições e depois reproduza-a ▶ em qualquer documento. Red Vermelho Redefine style by current selection Redefinir o estilo pela seleção atual + Refine Refinar Reflected Gradient Degradê refletido Registration Marks Marcas de registro Regular Regular @@ -1320,7 +1337,6 @@ Restore All Restaurar tudo Restore Defaults Restaurar padrões Restrictive (Web) Restritiva (Web) - Right Direita Right align text Alinhar texto à direita Rigid Rígido Rotate: Girar: @@ -1500,6 +1516,7 @@ Blur Tool Ferramenta Desfoque Bold Negrito Brush Pincel + Block Bloco Brush Pose Pose do pincel Brush Tip Shape Forma da ponta do pincel Brush Tool Ferramenta Pincel @@ -1548,6 +1565,8 @@ Efficiency: 100% Eficiência: 100% Ellipse Tool Ferramenta Elipse Elliptical Marquee Tool Ferramenta Letreiro elíptico + Single Row Marquee Tool Ferramenta Letreiro de linha única + Single Column Marquee Tool Ferramenta Letreiro de coluna única Emboss Entalhe Eraser Borracha Eraser Tool Ferramenta Borracha @@ -1605,6 +1624,7 @@ Mobile Dispositivos móveis Move Tool Ferramenta Mover Could not use the move tool because the layer is locked. Não foi possível usar a ferramenta Mover porque a camada está bloqueada. + Could not complete the Free Transform command because the layer is locked. Não foi possível concluir o comando Transformação livre porque a camada está bloqueada. Multiply Multiplicação My Brushes Meus pincéis Neutrals Neutros @@ -1711,6 +1731,7 @@ Scratch: {size} Trabalho: {size} Untagged {mode} {mode} sem marcação {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) Resolution: {dpi} pixels/inch Resolução: {dpi} pixels/polegada Doc: {w} × {h} px Doc: {w} × {h} px Frame {current} / {total} Quadro {current} / {total} @@ -1861,6 +1882,7 @@ Show only crosshair while painting Mostrar apenas a mira ao pintar Show slice numbers Mostrar números das fatias Show tooltips Mostrar dicas de ferramentas + Show highlight on rollover Mostrar realce ao passar o mouse Show transformation values Mostrar valores de transformação Slice color Cor das fatias Smart guide color Cor das guias inteligentes @@ -1962,6 +1984,7 @@ Create Warped Text Criar texto deformado Rasterize Smart Object Rasterizar objeto inteligente Rasterize Fill Content Rasterizar conteúdo do preenchimento + Rasterize Layer Style Rasterizar estilo de camada Unlock: picking a preset replaces these settings Desbloquear: escolher uma predefinição substitui estas configurações Lock: keep these settings when picking another preset Bloquear: manter estas configurações ao escolher outra predefinição Diameter: Diâmetro: @@ -2657,6 +2680,7 @@ cameraRaw Whites Brancos # Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Seletor de cores (Cor do texto) + Color Picker ({name}) Seletor de cores ({name}) Color Picker (Fill Color) Seletor de cores (Cor de preenchimento) Fill color Cor de preenchimento Color Picker (Stroke Color) Seletor de cores (Cor do traçado) @@ -2752,6 +2776,7 @@ fontWeight Italic Itálico Color to Alpha… Cor para alfa… Transparency Threshold Limiar de transparência Opacity Threshold Limiar de opacidade + PhotoCraft — Screen Color Picker PhotoCraft — Seletor de cor da tela Rasterize svg groups deeper than Rasterizar grupos SVG além desta profundidade 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 desativa este limite; grupos SVG aninhados em mais de 100 níveis são sempre rasterizados. @@ -2845,3 +2870,11 @@ fontWeight Italic Itálico Panel menu Menu do painel Resize panel window Redimensionar janela do painel Resize panels Redimensionar painéis + Use Classic Mode Usar modo clássico + Auto Center Preview Centralizar visualização automaticamente + Next Document Próximo documento + Previous Document Documento anterior + W x H x Resolution L x A x Resolução + Front Image Imagem frontal + Search settings… Pesquisar configurações… + No matching settings Nenhuma configuração encontrada diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 5380a144c6..fde0abc57f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -42,8 +42,15 @@ Open… Открыть… Open Открыть Open As… Открыть как… + Open EXR Parts… Открыть части EXR… + Add beauty precomp from the AOVs Добавить предкомпозит красоты из AOV + Both Оба + Left Левый + Right Правый Open Recent Открыть недавние Clear Recent Files Очистить список недавних файлов + Remove from Recent Удалить из недавних + Remove {name} from Recent Удалить {name} из недавних Close Закрыть Minimize Свернуть Maximize Развернуть @@ -145,6 +152,8 @@ Rename Layer Переименовать слой Layer From Background… Слой из фона… Layer from Background… Слой из фона… + Background from Layer Фон из слоя + Background From Layer Фон из слоя Group Layers Сгруппировать слои Group… Группировать… Ungroup Layers Разгруппировать слои @@ -386,6 +395,10 @@ 100% 100% 200% 200% 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% 125% 125% 150% 150% 175% 175% @@ -481,6 +494,7 @@ Wand Волшебная палочка Eyedropper Пипетка Brush Кисть + Block Квадрат Pencil Карандаш Eraser Ластик Clone Stamp Штамп @@ -635,6 +649,7 @@ Resolution: Разрешение: Resolution: {dpi} pixels/inch Разрешение: {dpi} пикс./дюйм {w} px x {h} px ({ppi} ppi) {w} px × {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} × {h} {unit} ({ppi} ppi) Pixels: Пикселы: Pixel Length: Длина в пикселах: Logical Length: Логическая длина: @@ -1078,8 +1093,6 @@ Single Word Justification Выключка одного слова Alignment Выравнивание Indent First Отступ первой строки - Left По левому краю - Right По правому краю Center По центру Justify По ширине @@ -1382,6 +1395,10 @@ Classic Theme Классическая тема Solarized dark Solarized тёмная Solarized Dark Theme Тёмная тема Solarized + Breeze dark Breeze тёмная + Breeze Dark Theme Тёмная тема Breeze + Breeze light Breeze светлая + Breeze Light Theme Светлая тема Breeze Studio Theme Тема Studio Studio Light Theme Светлая тема Studio Adwaita Light Theme Светлая тема Adwaita @@ -1508,8 +1525,11 @@ {name} Properties Свойства: {name} Move Tool Перемещение Could not use the move tool because the layer is locked. Не удалось использовать инструмент «Перемещение», потому что слой заблокирован. + Could not complete the Free Transform command because the layer is locked. Не удалось выполнить команду «Свободное трансформирование», потому что слой заблокирован. Rectangular Marquee Tool Прямоугольная область Elliptical Marquee Tool Овальная область + Single Row Marquee Tool Область (горизонтальная строка) + Single Column Marquee Tool Область (вертикальная строка) Brush Tool Кисть Pencil Tool Карандаш Eraser Tool Ластик @@ -1613,6 +1633,8 @@ Open a document to select a colour range. Откройте документ, чтобы выбрать цветовой диапазон. Saved Сохранено Reveal in Finder Показать в Finder + Show in Explorer Показать в Проводнике + Show in Folder Показать в папке Before (Y) До (Y) After (X) После (X) Original Оригинал @@ -1928,7 +1950,6 @@ Bilinear Билинейная Black, White Чёрный, белый Blue, Red, Yellow Синий, красный, жёлтый - Both Оба Bottom Снизу Brightness Jitter Колебание яркости Brush Reconstruct Options Параметры восстановления кисти @@ -2076,6 +2097,7 @@ Quality Качество Recent Недавние Reconstruct Восстановление + Refine Уточнение Registration Marks Кресты приводки Regular Обычный Remove stop Удалить ограничитель @@ -2306,6 +2328,7 @@ Show only crosshair while painting Только перекрестье при рисовании Show slice numbers Показывать номера фрагментов Show tooltips Показывать подсказки + Show highlight on rollover Показывать подсветку при наведении Show bounding box when dragging layer Показывать рамку при перетаскивании слоя System title bar Системная строка заголовка Show transformation values Показывать значения трансформации @@ -2498,6 +2521,7 @@ cameraRaw Roughness Неровность # Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Палитра цветов (Цвет текста) + Color Picker ({name}) Палитра цветов ({name}) Color Picker (Fill Color) Палитра цветов (Цвет заливки) Fill color Цвет заливки Color Picker (Stroke Color) Палитра цветов (Цвет обводки) @@ -2597,6 +2621,7 @@ fontWeight Italic Курсив Color to Alpha… Цвет в альфа-канал… Transparency Threshold Порог прозрачности Opacity Threshold Порог непрозрачности + PhotoCraft — Screen Color Picker PhotoCraft — Пипетка цвета экрана Rasterize svg groups deeper than Растеризовать группы SVG глубже этого уровня 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 отключает этот порог; группы SVG с вложенностью более 100 уровней всегда растрируются. @@ -2702,3 +2727,13 @@ fontWeight Italic Курсив Panel menu Меню панели Resize panel window Изменить размер окна панели Resize panels Изменить размер панелей + Use Classic Mode Использовать классический режим + Auto Center Preview Автоматически центрировать просмотр + Next Document Следующий документ + Previous Document Предыдущий документ + W x H x Resolution Ш x В x Разрешение + Front Image Изображение переднего плана + Swaps height and width Меняет местами высоту и ширину + Rasterize Layer Style Растрировать стиль слоя + Search settings… Поиск настроек… + No matching settings Нет подходящих настроек diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 374d56531b..9a76d6836b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -33,8 +33,15 @@ Open… Відкрити… Open Відкрити Open As… Відкрити як… + Open EXR Parts… Відкрити частини EXR… + Add beauty precomp from the AOVs Додати попередню композицію краси з AOV + Both Обидва + Left Лівий + Right Правий Open Recent Нещодавні файли Clear Recent Files Очистити список нещодавніх файлів + Remove from Recent Вилучити з нещодавніх + Remove {name} from Recent Вилучити {name} з нещодавніх Close Закрити Minimize Згорнути Maximize Розгорнути @@ -136,6 +143,7 @@ Rename Layer Перейменувати шар Layer From Background… Шар із фону… Layer from Background… Шар із фону… + Background From Layer Фон із шару Group Layers Згрупувати шари Group… Група… Ungroup Layers Розгрупувати шари @@ -366,6 +374,10 @@ 100% 100% 200% 200% 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% 125% 125% 150% 150% 175% 175% @@ -461,6 +473,7 @@ Wand Чарівна паличка Eyedropper Піпетка Brush Пензель + Block Блок Pencil Олівець Eraser Гумка Clone Stamp Клонувальний штамп @@ -615,6 +628,7 @@ Resolution: Роздільність: Resolution: {dpi} pixels/inch Роздільність: {dpi} пікселів/дюйм {w} px x {h} px ({ppi} ppi) {w} пкс x {h} пкс ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) Pixels: Пікселі: Pixel Length: Довжина в пікселях: Logical Length: Логічна довжина: @@ -1058,8 +1072,6 @@ Single Word Justification Вирівнювання одного слова по ширині Alignment Вирівнювання Indent First Відступ першого рядка - Left Ліворуч - Right Праворуч Center По центру Justify По ширині @@ -1363,6 +1375,10 @@ Classic Theme Класична тема Solarized dark Solarized темна Solarized Dark Theme Темна тема Solarized + Breeze dark Breeze темна + Breeze Dark Theme Темна тема Breeze + Breeze light Breeze світла + Breeze Light Theme Світла тема Breeze Adwaita Light Theme Світла тема Adwaita Adwaita Dark Theme Темна тема Adwaita Adwaita Adwaita @@ -1491,8 +1507,11 @@ {name} Properties Властивості: {name} Move Tool Інструмент «Переміщення» Could not use the move tool because the layer is locked. Не вдалося використати інструмент «Переміщення», бо шар заблоковано. + Could not complete the Free Transform command because the layer is locked. Не вдалося виконати команду «Вільна трансформація», бо шар заблоковано. Rectangular Marquee Tool Інструмент «Прямокутне виділення» Elliptical Marquee Tool Інструмент «Еліптичне виділення» + Single Row Marquee Tool Інструмент «Виділення одного рядка» + Single Column Marquee Tool Інструмент «Виділення одного стовпця» Brush Tool Інструмент «Пензель» Pencil Tool Інструмент «Олівець» Eraser Tool Інструмент «Гумка» @@ -1596,6 +1615,8 @@ Open a document to select a colour range. Відкрийте документ, щоб виділити колірний діапазон. Saved Збережено Reveal in Finder Показати у Finder + Show in Explorer Показати у Провіднику + Show in Folder Показати в теці Before (Y) До (Y) After (X) Після (X) Original Оригінал @@ -1910,7 +1931,6 @@ Bilinear Білінійна Black, White Чорний, білий Blue, Red, Yellow Синій, червоний, жовтий - Both Обидва Bottom Низ Brightness Jitter Коливання яскравості Brush Reconstruct Options Параметри пензля реконструкції @@ -2285,6 +2305,7 @@ Show only crosshair while painting Показувати лише перехрестя під час малювання Show slice numbers Показувати номери фрагментів Show tooltips Показувати підказки + Show highlight on rollover Показувати підсвічування під час наведення Show bounding box when dragging layer Показувати рамку під час перетягування шару System title bar Системний рядок заголовка Show transformation values Показувати значення трансформації @@ -2474,6 +2495,7 @@ cameraRaw Roughness Шорсткість # Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Вибір кольору (колір тексту) + Color Picker ({name}) Вибір кольору ({name}) Color Picker (Fill Color) Вибір кольору (колір заливки) Fill color Колір заливки Color Picker (Stroke Color) Вибір кольору (колір обведення) @@ -2636,6 +2658,7 @@ fontWeight Italic Курсивний Prevent auto-nesting in and out of Artboards and Frames Запобігати автоматичному вкладенню в монтажні області та кадри або виходу з них Quick Actions Швидкі дії Radial Gradient Радіальний градієнт + Refine Уточнення Reflected Gradient Відбитий градієнт Reset to defaults Скинути до типових значень Search commands Пошук команд @@ -2757,6 +2780,7 @@ fontWeight Italic Курсивний Create Warped Text Створити деформований текст Rasterize Smart Object Растеризувати смартоб'єкт Rasterize Fill Content Растеризувати вміст заливки + Rasterize Layer Style Растеризувати стиль шару Toggle the Brush Settings panel (F5) Перемкнути панель налаштувань пензля (F5) Create new brush preset from the current settings Створити новий набір пензля з поточних параметрів Brush Settings… (F5): every option of this brush Налаштування пензля… (F5): усі параметри цього пензля @@ -3525,3 +3549,12 @@ cameraRaw Masking Маскування Panel menu Меню панелі Resize panel window Змінити розмір вікна панелі Resize panels Змінити розмір панелей + Use Classic Mode Використовувати класичний режим + Auto Center Preview Автоматично центрувати перегляд + Next Document Наступний документ + Previous Document Попередній документ + W x H x Resolution Ш x В x Роздільна здатність + Front Image Зображення переднього плану + Search settings… Пошук налаштувань… + No matching settings Немає відповідних налаштувань + PhotoCraft — Screen Color Picker PhotoCraft — Піпетка кольору екрана diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3c57e090ad..06a73fa0d3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -32,8 +32,15 @@ New from Clipboard 从剪贴板新建 Open… 打开… Open As… 打开为… + Open EXR Parts… 打开 EXR 分段… + Add beauty precomp from the AOVs 从 AOV 预合成美丽通道 + Both 两者 + Left 左 + Right 右 Open Recent 打开最近文件 Clear Recent Files 清除最近文件 + Remove from Recent 从最近文件中移除 + Remove {name} from Recent 从最近文件中移除 {name} Close 关闭 Minimize 最小化 Maximize 最大化 @@ -81,6 +88,8 @@ Import 导入 Browse… 浏览… Reveal in Finder 在 Finder 中显示 + Show in Explorer 在文件资源管理器中显示 + Show in Folder 在文件夹中显示 File Info… 文件信息… Print… 打印… Print One Copy 打印一份 @@ -259,6 +268,7 @@ Layer via Cut 通过剪切创建图层 Layer From Background… 从背景创建图层… Layer from Background… 从背景创建图层… + Background From Layer 从图层创建背景 Background from Layer 从图层创建背景 New Shape Layer 新建形状图层 New Type Layer 新建文字图层 @@ -587,6 +597,10 @@ 100% 100% 200% 200% 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% 125% 125% 150% 150% 175% 175% @@ -679,6 +693,10 @@ Classic Theme 经典主题 Solarized dark Solarized 深色 Solarized Dark Theme Solarized 深色主题 + Breeze dark Breeze 深色 + Breeze Dark Theme Breeze 深色主题 + Breeze light Breeze 浅色 + Breeze Light Theme Breeze 浅色主题 Next Theme 下一代主题 Actions 动作 Brush Settings 画笔设置 @@ -812,7 +830,6 @@ Blending Options 混合选项 Blue 蓝色 Blue, Red, Yellow 蓝色、红色、黄色 - Both 两者 Both Axes 双轴 Bottom 底部 Brightness 亮度 @@ -1134,7 +1151,6 @@ Leading 行距 Leading (set to the font size × auto-leading when Auto) 行距(自动时为字号 × 自动行距系数) Leave as is (don't color manage) 保持原样(不进行色彩管理) - Left 左 Left align text 文字左对齐 Length: 长度: Level 级别 @@ -1308,6 +1324,7 @@ Record ● a sequence of edits, then play ▶ it on any document. 录制 ● 一系列编辑,然后在任意文档上播放 ▶。 Red 红色 Redefine style by current selection 用当前选区重新定义样式 + Refine 调整 Reflected Gradient 对称渐变 Registration Marks 套准标记 Regular 常规 @@ -1346,7 +1363,6 @@ Restore All 全部还原 Restore Defaults 还原默认值 Restrictive (Web) 限定(Web) - Right 右 Right align text 文字右对齐 Rigid 刚性 Rotate: 旋转: @@ -1533,6 +1549,8 @@ Blur Tool 模糊工具 Bold 粗体 Brush 画笔 + Pencil 铅笔 + Block 块 Brush Pose 画笔姿态 Brush Tip Shape 画笔笔尖形状 Brush Tool 画笔工具 @@ -1582,6 +1600,8 @@ Efficiency: 100% 效率:100% Ellipse Tool 椭圆工具 Elliptical Marquee Tool 椭圆选框工具 + Single Row Marquee Tool 单行选框工具 + Single Column Marquee Tool 单列选框工具 Emboss 浮雕 Eraser 橡皮擦 Eraser Tool 橡皮擦工具 @@ -1640,6 +1660,7 @@ Mobile 移动设备 Move Tool 移动工具 Could not use the move tool because the layer is locked. 无法使用移动工具,因为图层已锁定。 + Could not complete the Free Transform command because the layer is locked. 无法完成“自由变换”命令,因为图层已锁定。 Multiply 正片叠底 My Brushes 我的画笔 Neutrals 中性色 @@ -1749,6 +1770,7 @@ Invalid {mode} profile 无效的 {mode} 配置文件 Unnamed {mode} profile 未命名的 {mode} 配置文件 {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) Resolution: {dpi} pixels/inch 分辨率:{dpi} 像素/英寸 Doc: {w} × {h} px 文档:{w} × {h} px Frame {current} / {total} 帧 {current} / {total} @@ -1898,6 +1920,7 @@ Show only crosshair while painting 绘画时仅显示十字线 Show slice numbers 显示切片编号 Show tooltips 显示工具提示 + Show highlight on rollover 鼠标悬停时显示高亮 Show bounding box when dragging layer 拖动图层时显示边界框 System title bar 系统标题栏 Show transformation values 显示变换值 @@ -2055,6 +2078,7 @@ cameraRaw Blending 混合 Create Warped Text 创建变形文字 Rasterize Smart Object 栅格化智能对象 Rasterize Fill Content 栅格化填充内容 + Rasterize Layer Style 栅格化图层样式 Unlock: picking a preset replaces these settings 解锁:选择预设时替换这些设置 Lock: keep these settings when picking another preset 锁定:选择其他预设时保留这些设置 Diameter: 直径: @@ -2232,6 +2256,7 @@ cameraRaw Blending 混合 # Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) 拾色器(文字颜色) + Color Picker ({name}) 拾色器({name}) Color Picker (Fill Color) 拾色器(填充颜色) Fill color 填充颜色 Color Picker (Stroke Color) 拾色器(描边颜色) @@ -2332,6 +2357,7 @@ fontWeight Italic 斜体 Color to Alpha… 颜色转为 Alpha… Transparency Threshold 透明度阈值 Opacity Threshold 不透明度阈值 + PhotoCraft — Screen Color Picker PhotoCraft — 屏幕取色器 Rasterize svg groups deeper than 栅格化超过此深度的 SVG 组 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 设为 0 可关闭此阈值;嵌套超过 100 层的 SVG 组始终会被栅格化。 @@ -2426,3 +2452,11 @@ fontWeight Italic 斜体 Panel menu 面板菜单 Resize panel window 调整面板窗口大小 Resize panels 调整面板大小 + Use Classic Mode 使用经典模式 + Auto Center Preview 自动居中预览 + Next Document 下一个文档 + Previous Document 上一个文档 + W x H x Resolution 宽 x 高 x 分辨率 + Front Image 前面的图像 + Search settings… 搜索设置… + No matching settings 没有匹配的设置 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index acaae595d6..4256b8ffc7 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -40,8 +40,15 @@ New from Clipboard 從剪貼簿新增 Open… 開啟… Open As… 開啟為… + Open EXR Parts… 開啟 EXR 分段… + Add beauty precomp from the AOVs 從 AOV 預合成美麗通道 + Both 兩者 + Left 左 + Right 右 Open Recent 最近開啟的檔案 Clear Recent Files 清除最近檔案 + Remove from Recent 從最近檔案中移除 + Remove {name} from Recent 從最近檔案中移除 {name} Close 關閉 Minimize 最小化 Maximize 最大化 @@ -89,6 +96,8 @@ Import 匯入 Browse… 瀏覽… Reveal in Finder 在 Finder 中顯示 + Show in Explorer 在檔案總管中顯示 + Show in Folder 在資料夾中顯示 File Info… 檔案資訊… Print… 列印… Print One Copy 列印一份拷貝 @@ -264,6 +273,8 @@ Layer via Cut 剪下的圖層 Layer From Background… 從背景建立圖層… Layer from Background… 從背景建立圖層… + Background from Layer 從圖層建立背景 + Background From Layer 從圖層建立背景 New Shape Layer 新增形狀圖層 New Type Layer 新增文字圖層 New Fill Layer 新增填滿圖層 @@ -591,6 +602,10 @@ 100% 100% 200% 200% 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% 125% 125% 150% 150% 175% 175% @@ -683,6 +698,10 @@ Classic Theme 經典主題 Solarized dark Solarized 深色 Solarized Dark Theme Solarized 深色主題 + Breeze dark Breeze 深色 + Breeze Dark Theme Breeze 深色主題 + Breeze light Breeze 淺色 + Breeze Light Theme Breeze 淺色主題 Next Theme 下一個主題 Actions 動作 Brush Settings 筆刷設定 @@ -814,7 +833,6 @@ Blending Options 混合選項 Blue 藍色 Blue, Red, Yellow 藍色、紅色、黃色 - Both 兩者 Both Axes 兩軸 Bottom 下 Brightness 亮度 @@ -1133,7 +1151,6 @@ Leading 行距 Leading (set to the font size × auto-leading when Auto) 行距 (自動時設為字體大小 × 自動行距) Leave as is (don't color manage) 保持原樣 (不進行色彩管理) - Left 左 Left align text 文字靠左對齊 Length: 長度: Level 層級 @@ -1305,6 +1322,7 @@ Record ● a sequence of edits, then play ▶ it on any document. 錄製 ● 一連串編輯,然後在任何文件上播放 ▶。 Red 紅色 Redefine style by current selection 依目前選取項目重新定義樣式 + Refine 調整 Reflected Gradient 反射性漸層 Registration Marks 對齊標記 Regular 標準 @@ -1343,7 +1361,6 @@ Restore All 全部還原 Restore Defaults 還原預設值 Restrictive (Web) 限制 (網頁) - Right 右 Right align text 文字靠右對齊 Rigid 剛性 Rotate: 旋轉: @@ -1527,6 +1544,8 @@ Blur Tool 模糊工具 Bold 粗體 Brush 筆刷 + Pencil 鉛筆 + Block 區塊 Brush Pose 筆刷姿勢 Brush Tip Shape 筆尖形狀 Brush Tool 筆刷工具 @@ -1576,6 +1595,8 @@ Efficiency: 100% 效率:100% Ellipse Tool 橢圓工具 Elliptical Marquee Tool 橢圓選取畫面工具 + Single Row Marquee Tool 單列選取畫面工具 + Single Column Marquee Tool 單欄選取畫面工具 Emboss 浮雕 Eraser 橡皮擦 Eraser Tool 橡皮擦工具 @@ -1633,6 +1654,7 @@ Mobile 行動裝置 Move Tool 移動工具 Could not use the move tool because the layer is locked. 無法使用移動工具,因為圖層已鎖定。 + Could not complete the Free Transform command because the layer is locked. 無法完成「任意變形」指令,因為圖層已鎖定。 Multiply 色彩增值 My Brushes 我的筆刷 Neutrals 中間色 @@ -1741,6 +1763,7 @@ Invalid {mode} profile 無效的 {mode} 描述檔 Unnamed {mode} profile 未命名的 {mode} 描述檔 {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) Resolution: {dpi} pixels/inch 解析度:{dpi} 像素/英吋 Doc: {w} × {h} px 文件:{w} × {h} px Frame {current} / {total} 影格 {current} / {total} @@ -1891,6 +1914,7 @@ Show only crosshair while painting 繪圖時僅顯示十字線 Show slice numbers 顯示切片編號 Show tooltips 顯示工具提示 + Show highlight on rollover 滑鼠懸停時顯示醒目提示 Show bounding box when dragging layer 拖曳圖層時顯示邊界框 System title bar 系統標題列 Show transformation values 顯示變形數值 @@ -1994,6 +2018,7 @@ Create Warped Text 建立彎曲文字 Rasterize Smart Object 點陣化智慧型物件 Rasterize Fill Content 點陣化填滿內容 + Rasterize Layer Style 點陣化圖層樣式 Unlock: picking a preset replaces these settings 解除鎖定:選擇預設集會取代這些設定 Lock: keep these settings when picking another preset 鎖定:選擇其他預設集時保留這些設定 Diameter: 直徑: @@ -2201,6 +2226,7 @@ cameraRaw Blending 混合 # Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) 檢色器(文字顏色) + Color Picker ({name}) 檢色器({name}) Color Picker (Fill Color) 檢色器(填滿顏色) Fill color 填滿顏色 Color Picker (Stroke Color) 檢色器(筆畫顏色) @@ -2301,6 +2327,7 @@ fontWeight Italic 斜體 Color to Alpha… 顏色轉為 Alpha… Transparency Threshold 透明度臨界值 Opacity Threshold 不透明度臨界值 + PhotoCraft — Screen Color Picker PhotoCraft — 螢幕取色器 Rasterize svg groups deeper than 點陣化超過此深度的 SVG 群組 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 設為 0 可關閉此臨界值;巢狀超過 100 層的 SVG 群組一律會點陣化。 @@ -2395,3 +2422,11 @@ fontWeight Italic 斜體 Panel menu 面板選單 Resize panel window 調整面板視窗大小 Resize panels 調整面板大小 + Use Classic Mode 使用傳統模式 + Auto Center Preview 自動置中預視 + Next Document 下一個文件 + Previous Document 上一個文件 + W x H x Resolution 寬 x 高 x 解析度 + Front Image 前面的影像 + Search settings… 搜尋設定… + No matching settings 沒有符合的設定 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index d845ce38eb..f452e1cbe3 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -64,6 +64,9 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("lock", include_bytes!("../../../assets/icons/lock.svg")), ("lock-open", include_bytes!("../../../assets/icons/lock-open.svg")), ("lollipop", include_bytes!("../../../assets/icons/lollipop.svg")), + // PhotoCraft originals: the Single Row / Single Column Marquee glyphs. + ("marquee-column", include_bytes!("../../../assets/icons/marquee-column.svg")), + ("marquee-row", include_bytes!("../../../assets/icons/marquee-row.svg")), ("maximize-2", include_bytes!("../../../assets/icons/maximize-2.svg")), ("message-square", include_bytes!("../../../assets/icons/message-square.svg")), ("minus", include_bytes!("../../../assets/icons/minus.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 6edc87aa3c..33b290eda3 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -87,6 +87,8 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::Move => "move", Tool::RectMarquee => "square-dashed", Tool::EllipseMarquee => "circle-dashed", + Tool::SingleRowMarquee => "marquee-row", + Tool::SingleColumnMarquee => "marquee-column", Tool::Brush => "brush", Tool::Pencil => "pencil", Tool::MixerBrush => "palette", diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index df51d69d51..35794f1287 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -651,3 +651,45 @@ fn esc_ends_a_pen_path_and_keeps_it() { assert_eq!(path.subpaths[0].knots.len(), 3); assert_eq!(h.state().ui.selected_path.as_deref(), Some("work")); } + +/// Esc with no active operation deselects (#2674), like ⌘D. +#[test] +fn escape_deselects_when_nothing_else_needs_cancelling() { + let mut h = harness(); + h.state_mut().run("select.all", json!({})).unwrap(); + h.run_steps(1); + assert!(h.state().session.active().unwrap().doc.selection.is_some()); + h.key_press(Key::Escape); + h.run_steps(2); + assert!(h.state().session.active().unwrap().doc.selection.is_none(), "Esc deselects"); + assert!(!h.state().ui.status_error, "no error: {}", h.state().ui.status); + // With nothing selected, Esc stays untouched (no history step, no error). + let steps = h.state().session.active().unwrap().history.entries().len(); + h.key_press(Key::Escape); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps, "no Deselect step"); +} + +/// The dialog owns Esc: it cancels the dialog and the selection stays. +#[test] +fn escape_with_a_dialog_open_cancels_the_dialog_not_the_selection() { + let mut h = harness(); + h.state_mut().run("select.all", json!({})).unwrap(); + open_dialog(&mut h); + h.key_press(Key::Escape); + h.run_steps(2); + assert!(h.state().ui.dialogs.is_empty(), "Esc cancelled the dialog"); + assert!(h.state().session.active().unwrap().doc.selection.is_some(), "the selection stays"); +} + +/// The command palette owns Esc: it closes the palette and the selection stays. +#[test] +fn escape_with_the_palette_open_does_not_deselect() { + let mut h = harness(); + h.state_mut().run("select.all", json!({})).unwrap(); + h.state_mut().ui.palette_open = true; + h.run_steps(1); + h.key_press(Key::Escape); + h.run_steps(2); + assert!(h.state().session.active().unwrap().doc.selection.is_some(), "Esc belongs to the palette"); +} diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 3ba7f79b99..c81abf5aed 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -545,33 +545,75 @@ fn elapsed(ms: f64) -> String { if s < 60.0 { format!("{s:.1} s") } else { format!("{}:{:02}", (s / 60.0).floor() as u64, (s % 60.0).floor() as u64) } } +/// Where the opening card drew its parts last frame (tests check that everything fits, #2423). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OpenCardRects { + pub card: Rect, + pub title: Rect, + pub cancel: Rect, +} + +fn open_card_rects_id() -> egui::Id { + egui::Id::new("open-card-rects") +} + +/// The opening card's parts as drawn last frame, if it was shown. +pub fn open_card_rects(ctx: &egui::Context) -> Option { + ctx.data(|d| d.get_temp::(open_card_rects_id())) +} + +/// The card's width and padding, and the height it has when the title and message fit one line. +const OPEN_CARD_WIDTH: f32 = 340.0; +const OPEN_CARD_PAD: f32 = 18.0; +const OPEN_CARD_MIN_HEIGHT: f32 = 132.0; + /// The canvas area of an opening tab: the file name, a bar and Cancel, centred on the -/// workspace backdrop. +/// workspace backdrop. The name and the message stay on one line (truncated with "…", the full +/// text on hover) and the card grows to fit its rows, so nothing lands outside it (#2423). pub fn open_card(app: &mut PhotocraftApp, ui: &mut egui::Ui, job: JobId) { let Some(tab) = app.jobs.opens.iter().find(|o| o.job == job).cloned() else { return }; let Some(j) = app.session.job(job) else { return }; let t = Tokens::get(ui.ctx()); let area = ui.available_rect_before_wrap(); ui.painter().rect_filled(area, 0.0, t.canvas); - let card = Rect::from_center_size(area.center(), vec2(340.0, 132.0)); - ui.painter().rect_filled(card, t.radius_lg, t.card); - ui.painter().rect_stroke(card, t.radius_lg, Stroke::new(1.0, t.card_border), egui::StrokeKind::Inside); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(card.shrink(18.0))); - child.label(RichText::new(crate::i18n::fmt(tl!("Opening {name}"), &[("name", &tab.name)])).font(crate::theme::semibold(14.0))); + // Centre the card at the height its content needed last frame; the background is painted + // once the rows are laid out, so a taller content (a long translation) still gets a card. + let height_id = egui::Id::new(("open-card-height", job)); + let height = ui.ctx().data(|d| d.get_temp::(height_id)).unwrap_or(OPEN_CARD_MIN_HEIGHT); + let card = Rect::from_center_size(area.center(), vec2(OPEN_CARD_WIDTH, height)); + let fill = ui.painter().add(egui::Shape::Noop); + let border = ui.painter().add(egui::Shape::Noop); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(card.shrink(OPEN_CARD_PAD))); + let title = child + .add(egui::Label::new(RichText::new(crate::i18n::fmt(tl!("Opening {name}"), &[("name", &tab.name)])).font(crate::theme::semibold(14.0))).truncate()) + .rect; child.add_space(4.0); let msg = if j.message.is_empty() { tl!("Reading…").to_string() } else { format!("{}…", j.message) }; - child.label(RichText::new(msg).color(t.text_dim).size(12.0)); + child.add(egui::Label::new(RichText::new(msg).color(t.text_dim).size(12.0)).truncate()); child.add_space(10.0); let (br, _) = child.allocate_exact_size(vec2(child.available_width(), 8.0), Sense::hover()); bar(&child, br, shown_fraction(&j), &t); child.add_space(10.0); let mut cancel_it = false; + let mut cancel_rect = Rect::NOTHING; child.horizontal(|ui| { ui.label(RichText::new(percent(&j)).color(t.text_faint).size(11.5)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - cancel_it = crate::widgets::secondary_button(ui, tl!("Cancel"), 76.0).clicked(); + let r = crate::widgets::secondary_button(ui, tl!("Cancel"), 76.0); + cancel_it = r.clicked(); + cancel_rect = r.rect; }); }); + let content = child.min_rect(); + let needed = (content.height() + 2.0 * OPEN_CARD_PAD).max(OPEN_CARD_MIN_HEIGHT); + let card = Rect::from_min_size(card.min, vec2(OPEN_CARD_WIDTH, height.max(needed))); + ui.painter().set(fill, egui::Shape::rect_filled(card, t.radius_lg, t.card)); + ui.painter().set(border, egui::Shape::rect_stroke(card, t.radius_lg, Stroke::new(1.0, t.card_border), egui::StrokeKind::Inside)); + if (needed - height).abs() > 0.5 { + ui.ctx().data_mut(|d| d.insert_temp(height_id, needed)); + ui.ctx().request_repaint(); + } + ui.ctx().data_mut(|d| d.insert_temp(open_card_rects_id(), OpenCardRects { card, title, cancel: cancel_rect })); if cancel_it { cancel(app, job); } diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index aa6b391b7d..0679c0d3d4 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -283,6 +283,32 @@ fn files_open_in_the_background_with_a_tab_and_cancel_closes_it() { assert!(h.state().jobs.focus.is_none()); } +/// #2423: a long file name used to wrap the card's title and push the percentage and Cancel +/// below the card. The name stays on one line and every row is inside the card. +#[test] +fn the_opening_card_keeps_a_long_name_and_cancel_inside() { + let mut h = app_harness(); + let gate = Arc::new(AtomicBool::new(false)); + let job = slow_job(h.state_mut(), &gate); + let long = "a7rv-test_2024_02_17_shot_30_3705_with_an_even_longer_suffix_that_never_ends.psb"; + for name in ["short.psd", long] { + h.state_mut().jobs.opens.clear(); + h.state_mut().jobs.opens.push(crate::jobs_ui::OpenTab { job, name: name.to_string(), path: None, slot: None }); + h.state_mut().jobs.focus = Some(job); + h.run_steps(4); + let r = crate::jobs_ui::open_card_rects(&h.ctx).expect("the opening card was drawn"); + assert!(r.title.height() < 22.0, "{name}: the title is one line, {:?}", r.title); + assert!(r.card.contains_rect(r.title), "{name}: title {:?} inside card {:?}", r.title, r.card); + assert!(r.card.contains_rect(r.cancel), "{name}: Cancel {:?} inside card {:?}", r.cancel, r.card); + assert!(r.title.right() <= r.card.right() - 10.0, "{name}: the title keeps the card's padding"); + assert!(r.cancel.width() > 0.0 && r.cancel.height() > 0.0, "{name}: Cancel was drawn"); + } + gate.store(true, Ordering::Relaxed); + h.state_mut().jobs.focus = None; + h.state_mut().jobs.opens.clear(); + h.run_steps(4); +} + #[test] fn templates_open_untitled_in_the_background() { let mut h = app_harness(); diff --git a/crates/ui-egui/src/lasso_ui.rs b/crates/ui-egui/src/lasso_ui.rs index 7b5a3a6874..b03eea3b45 100644 --- a/crates/ui-egui/src/lasso_ui.rs +++ b/crates/ui-egui/src/lasso_ui.rs @@ -53,6 +53,13 @@ fn moving_selection(app: &PhotocraftApp) -> bool { app.drag.as_ref().is_some_and(|d| d.tool == Tool::Lasso && d.sel_move.is_some()) } +/// A drag the canvas handles rather than this module, which only feeds it the events: moving the +/// selection, a guide that ⌘ took (`canvas::command_guide_at`, #2690), or the layer that ⌘ moves +/// (`canvas::command_moves_layer`, refused on a locked layer: `move_blocked`, #2725). +fn handed_over(app: &PhotocraftApp) -> bool { + moving_selection(app) || app.guide_drag.is_some_and(|d| d.index.is_some()) || app.drag.as_ref().is_some_and(|d| d.tool == Tool::Move) || app.move_blocked +} + pub fn cancel_stale(app: &mut PhotocraftApp) { if app .drag @@ -100,17 +107,18 @@ pub fn commit(app: &mut PhotocraftApp) { pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { cancel_stale(app); - if app.ui.tool != Tool::Lasso || moving_selection(app) { + if app.ui.tool != Tool::Lasso || handed_over(app) { return false; } let p = match ev { ToolEvent::Down { x, y, .. } | ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => [x, y], }; - // A press inside the selection (no outline in progress) drags the selection instead. + // A press inside the selection (no outline in progress) drags the selection instead; a ⌘ + // press outside it moves the layer, as the Move tool (#2725). if matches!(ev, ToolEvent::Down { .. }) && !active(app) && p.iter().all(|n| n.is_finite()) - && crate::canvas::selection_drag_kind(app, Tool::Lasso, p, mods).is_some() + && (crate::canvas::selection_drag_kind(app, Tool::Lasso, p, mods).is_some() || crate::canvas::command_moves_layer(app, Tool::Lasso, p, mods)) { return false; } @@ -192,13 +200,13 @@ pub fn canvas_input(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform let p = xf.to_doc(pos); if pressed && response.contains_pointer() && response.rect.contains(pos) { Some(ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }) - } else if !pressed && (active(app) || moving_selection(app)) { + } else if !pressed && (active(app) || handed_over(app)) { Some(ToolEvent::Up { x: p[0], y: p[1] }) } else { None } } - Event::PointerMoved(pos) if active(app) || moving_selection(app) => { + Event::PointerMoved(pos) if active(app) || handed_over(app) => { let p = xf.to_doc(pos); Some(ToolEvent::Move { x: p[0], y: p[1], pressure: 1.0 }) } diff --git a/crates/ui-egui/src/lasso_ui/tests.rs b/crates/ui-egui/src/lasso_ui/tests.rs index e76355b56f..6484ed03c7 100644 --- a/crates/ui-egui/src/lasso_ui/tests.rs +++ b/crates/ui-egui/src/lasso_ui/tests.rs @@ -153,7 +153,14 @@ fn harness() -> egui_kittest::Harness<'static, PhotocraftApp> { fn mouse(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, kind: &str, x: f32, y: f32, mods: Modifiers) { let app = h.state(); let v = &app.ui.views[0]; - let xf = ViewXform { rect: crate::rulers::content_rect(app, app.last_canvas_rect), zoom: v.zoom, center: v.center, flip: false, rotation: v.rotation }; + let xf = ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: false, + rotation: v.rotation, + aspect: 1.0, + }; let pos = xf.to_screen(x, y); h.event(Event::ModifiersChanged(mods)); h.event(Event::PointerMoved(pos)); @@ -265,7 +272,14 @@ fn real_canvas_backspace_and_right_click_retract_polygonal_points() { let state = h.state(); let view = &state.ui.views[0]; - let xf = ViewXform { rect: crate::rulers::content_rect(state, state.last_canvas_rect), zoom: view.zoom, center: view.center, flip: false, rotation: 0.0 }; + let xf = ViewXform { + rect: crate::rulers::content_rect(state, state.last_canvas_rect), + zoom: view.zoom, + center: view.center, + flip: false, + rotation: 0.0, + aspect: 1.0, + }; let pos = xf.to_screen(155.0, 100.0); h.event(Event::PointerMoved(pos)); h.event(Event::PointerButton { pos, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::ALT }); @@ -291,3 +305,30 @@ fn clicking_the_last_point_again_closes_the_outline() { assert!(!active(&app)); assert!(app.session.active().unwrap().doc.selection.as_ref().unwrap().sample_channel(150, 100, 0) > 0.9); } + +/// ⌘ (Ctrl) with the Lasso is the Move tool for the drag: through the real canvas, a ⌘-drag outside +/// the selection (here: without one) moves the layer and draws no outline (`canvas::command_moves_layer`). +#[test] +fn real_canvas_cmd_drag_outside_the_selection_moves_the_layer() { + let mut h = harness(); + h.state_mut().ui.extras.snap = false; + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + let layer = h.state().session.active().unwrap().active_layer.unwrap(); + h.state_mut() + .session + .edit("paint", |doc, _| { + doc.layer_mut(layer).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(100, 100, 140, 140), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + h.run_steps(1); + mouse(&mut h, "down", 120.0, 120.0, Modifiers::COMMAND); + mouse(&mut h, "move", 130.0, 120.0, Modifiers::COMMAND); + mouse(&mut h, "move", 150.0, 120.0, Modifiers::COMMAND); + mouse(&mut h, "up", 150.0, 120.0, Modifiers::COMMAND); + let app = h.state(); + let alpha = |x, y| app.session.active().unwrap().doc.layer(layer).unwrap().surface().unwrap().rgba(x, y)[3]; + assert!(app.drag.is_none() && !active(app), "no outline in progress"); + assert!(app.session.active().unwrap().doc.selection.is_none(), "no selection was made"); + assert_eq!((alpha(105, 120), alpha(165, 120)), (0.0, 1.0), "the layer moved 30 px to the right"); +} diff --git a/crates/ui-egui/src/layer_menu_ui.rs b/crates/ui-egui/src/layer_menu_ui.rs index 3ed3f9f09b..dbd359012b 100644 --- a/crates/ui-egui/src/layer_menu_ui.rs +++ b/crates/ui-egui/src/layer_menu_ui.rs @@ -85,6 +85,10 @@ pub fn entries(l: &Layer, multi: bool, has_selection: bool) -> Vec { _ => v.push(Some(("Rasterize Layer", "layer.rasterize.layer"))), } } + // Photoshop offers it under Rasterize Layer for a layer with a style (it bakes the effects). + if multi || !l.effects.items.is_empty() { + v.push(Some(("Rasterize Layer Style", "layer.rasterize.layerStyle"))); + } v.push(None); // A selection has no single mask to toggle, apply or delete, so its menu offers Add Layer Mask only. if let Some(mask) = l.mask.as_ref().filter(|_| !multi) { diff --git a/crates/ui-egui/src/layer_pct_slider_tests.rs b/crates/ui-egui/src/layer_pct_slider_tests.rs index eae2a53cfc..4a717e210f 100644 --- a/crates/ui-egui/src/layer_pct_slider_tests.rs +++ b/crates/ui-egui/src/layer_pct_slider_tests.rs @@ -135,3 +135,31 @@ fn the_background_layer_has_no_pop_up_slider() { click(&mut h, arrow.center()); assert!(by_role(&h, "Opacity", Role::Slider).is_none(), "Photoshop greys Opacity for the Background"); } + +#[test] +fn dragging_opacity_and_fill_labels_scrubs_percentages_in_one_history_step() { + for (name, value) in [("Opacity", (|l: &photocraft_doc::Layer| l.opacity) as fn(&photocraft_doc::Layer) -> f32), ("Fill", |l| l.fill_opacity)] { + let mut h = harness(); + // The label, not the adjacent spin button or popup arrow, is the drag target. + // The popup arrow is named after its field; the text label sits on the same row (egui + // exposes a label's text as its value, not its name). + let arrow = by_role(&h, name, Role::Button).unwrap_or_else(|| panic!("missing {name} popup arrow")); + let with_colon = format!("{name}:"); + let rect = h + .query_all_by_role(Role::Label) + .filter(|node| { + let text = node.accesskit_node().value(); + (text.as_deref() == Some(name) || text.as_deref() == Some(with_colon.as_str())) && (node.rect().center().y - arrow.center().y).abs() < 10.0 + }) + .map(|node| node.rect()) + .next() + .unwrap_or_else(|| panic!("missing {name} text label")); + let before = steps(&h); + drag(&mut h, rect.center(), rect.center() - vec2(40.0, 0.0)); + assert!((value(&layer(&h)) - 0.8).abs() < 0.03, "{name}: label scrub changed the value"); + assert_eq!(steps(&h), before + 1, "{name}: a label scrub is one history step"); + h.state_mut().session.undo(); + h.run_steps(3); + assert!((value(&layer(&h)) - 1.0).abs() < 1e-6, "{name}: undo restores the original value"); + } +} diff --git a/crates/ui-egui/src/layer_props_ui.rs b/crates/ui-egui/src/layer_props_ui.rs index 104201c2e4..76d35bb0f2 100644 --- a/crates/ui-egui/src/layer_props_ui.rs +++ b/crates/ui-egui/src/layer_props_ui.rs @@ -34,19 +34,48 @@ pub fn transform_params(layer: u64, b: [i32; 4], w: Option, h: Option, } /// A number field that reports a value once committed (drag released, Enter, focus lost). -fn field(ui: &mut egui::Ui, id: &str, label: &str, current: f32, width: f32) -> Option { +fn field(ui: &mut egui::Ui, id: &str, label: &str, current: f32, width: f32, u: LenUnit, extent: f64) -> Option { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 22.0), Sense::hover()); ui.painter().text(pos2(r.right() - 2.0, r.center().y), egui::Align2::RIGHT_CENTER, tl!(label), egui::FontId::proportional(12.0), t.text_dim); let key = ui.make_persistent_id(("layer-props-field", id)); - let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or(current); - let resp = widgets::value_field(ui, &mut v, -300_000.0..=300_000.0, "px", width); + let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| u.shown(f64::from(current), extent)); + let resp = widgets::value_field(ui, &mut v, -300_000.0..=300_000.0, u.unit.suffix(), width); if resp.dragged() || resp.has_focus() { ui.data_mut(|d| d.insert_temp(key, v)); return None; } ui.data_mut(|d| d.remove::(key)); - (resp.drag_stopped() || resp.lost_focus() || resp.changed()).then_some(v).filter(|v| (*v - current).abs() >= 0.5) + if !(resp.drag_stopped() || resp.lost_focus() || resp.changed()) { + return None; + } + let px = u.px(v, extent) as f32; + ((px - current).abs() >= 0.5).then_some(px) +} + +/// Lengths in the Units & Rulers unit, as Photoshop's Properties panel shows them (cm when the +/// rulers are in centimeters): document px ↔ that unit at the document's resolution. +#[derive(Clone, Copy, Debug)] +pub(crate) struct LenUnit { + pub unit: photocraft_engine::prefs::Unit, + pub dpi: f64, + pub ppi: f64, +} + +impl LenUnit { + pub(crate) fn of(app: &PhotocraftApp) -> Self { + let ur = &app.session.prefs().units_and_rulers; + let dpi = app.session.active().map_or(72.0, |s| f64::from(s.doc.resolution_dpi)); + LenUnit { unit: ur.rulers, dpi, ppi: ur.point_size.per_inch() } + } + /// `px` shown in the unit (`extent` is 100% for percent). + pub(crate) fn shown(&self, px: f64, extent: f64) -> f32 { + self.unit.from_px(px, self.dpi, extent, self.ppi) as f32 + } + /// A value typed in the unit, in document px. + pub(crate) fn px(&self, v: f32, extent: f64) -> f64 { + self.unit.to_px(f64::from(v), self.dpi, extent, self.ppi) + } } /// Align glyph: a reference line and two bars (Photoshop's align icons). @@ -99,6 +128,8 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { let mut run: Vec<(String, Value)> = Vec::new(); if let Some(s) = layer.surface() { let b = app.cached_bounds(layer.id.0, s); + let u = LenUnit::of(app); + let (dw, dh) = app.session.active().map_or((1.0, 1.0), |s| (f64::from(s.doc.size.width), f64::from(s.doc.size.height))); if section(ui, "transform", tl!("Transform")) { let link_key = egui::Id::new("layer-props-link"); let linked = ui.data(|d| d.get_temp::(link_key)).unwrap_or(true); @@ -107,24 +138,24 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { let w = ((ui.available_width() - 2.0 * (LABEL_W + LABEL_GAP) - LINK_W - 2.0 * LABEL_GAP) / 2.0).max(36.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = LABEL_GAP; - if let Some(v) = field(ui, "w", "W", b.width() as f32, w) { + if let Some(v) = field(ui, "w", "W", b.width() as f32, w, u, dw) { run.extend(transform_params(layer.id.0, bb, Some(v), None, linked).map(|p| ("edit.transform".to_string(), p))); } if crate::icons::button(ui, if linked { "link" } else { "unlink" }, LINK_W, linked, tl!("Link width and height")).clicked() { ui.data_mut(|d| d.insert_temp(link_key, !linked)); } - if let Some(v) = field(ui, "h", "H", b.height() as f32, w) { + if let Some(v) = field(ui, "h", "H", b.height() as f32, w, u, dh) { run.extend(transform_params(layer.id.0, bb, None, Some(v), linked).map(|p| ("edit.transform".to_string(), p))); } }); ui.add_space(ROW_GAP); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = LABEL_GAP; - if let Some(v) = field(ui, "x", "X", b.x0 as f32, w) { + if let Some(v) = field(ui, "x", "X", b.x0 as f32, w, u, dw) { run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": (v - b.x0 as f32).round() as i32, "dy": 0}))); } ui.add_space(LINK_W + LABEL_GAP); - if let Some(v) = field(ui, "y", "Y", b.y0 as f32, w) { + if let Some(v) = field(ui, "y", "Y", b.y0 as f32, w, u, dh) { run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": 0, "dy": (v - b.y0 as f32).round() as i32}))); } }); @@ -155,6 +186,17 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { } } }); + // The engine already supports equal-gap distribution in both axes. Make those + // existing commands discoverable here as well as through Layer > Distribute (#2349). + // Three or more selected layers are required; the engine remains authoritative. + ui.horizontal_wrapped(|ui| { + for (kind, text) in [("horizontally", tl!("Horizontally")), ("vertically", tl!("Vertically"))] { + let id = format!("layer.distribute.{kind}"); + if ui.add_enabled(app.session.is_enabled(&id), egui::Button::new(text)).clicked() { + run.push((id, json!({}))); + } + } + }); ui.add_space(ROW_GAP); } for (id, p) in run.drain(..) { @@ -183,6 +225,20 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { mod tests { use super::*; + #[test] + fn transform_fields_show_and_take_the_ruler_unit() { + use photocraft_engine::prefs::Unit; + // 72 dpi: 1644 px = 58 cm; typing 60 cm sets 1700.8 px + let u = LenUnit { unit: Unit::Centimeters, dpi: 72.0, ppi: 72.0 }; + assert!((u.shown(1644.0, 1.0) - 58.0).abs() < 0.01); + assert!((u.px(60.0, 1.0) - 1700.787).abs() < 0.01); + let px = LenUnit { unit: Unit::Pixels, dpi: 150.0, ppi: 72.0 }; + assert_eq!(px.shown(170.0, 1.0), 170.0); + assert_eq!(px.px(170.0, 1.0), 170.0); + let pc = LenUnit { unit: Unit::Percent, dpi: 72.0, ppi: 72.0 }; + assert_eq!(pc.shown(822.0, 1644.0), 50.0); + } + #[test] fn width_edit_scales_from_the_top_left() { let p = transform_params(7, [10, 20, 110, 70], Some(200.0), None, true).unwrap(); diff --git a/crates/ui-egui/src/layer_row_ui/tests.rs b/crates/ui-egui/src/layer_row_ui/tests.rs index 796084b42f..f2ac633ba2 100644 --- a/crates/ui-egui/src/layer_row_ui/tests.rs +++ b/crates/ui-egui/src/layer_row_ui/tests.rs @@ -359,6 +359,79 @@ fn alt_dragging_a_row_drops_a_copy_and_a_plain_drag_moves() { assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Reorder Layers")); } +/// #1721: a press-move-release from `from` to `to`, with `release` held only when the row is +/// dropped. +fn drag_from_to(h: &mut Harness<'static, PhotocraftApp>, from: Pos2, to: Pos2, release: Modifiers) { + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for k in 1..=8 { + h.event(egui::Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))); + h.run_steps(1); + } + h.event(egui::Event::ModifiersChanged(release)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: release }); + h.run_steps(1); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); +} + +/// #1721: with a single layer there is no other row to drop on, so with ⌥ held the dragged row +/// accepts the drop onto itself and the copy lands above it. Without ⌥ the drop on itself does +/// nothing. +#[test] +fn alt_dragging_the_only_layer_drops_a_copy_on_itself() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let mut h = harness(s, 1.0, "promedium", 290.0); + let names = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().doc.layers.iter().map(|l| l.name.clone()).collect::>(); + let steps = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().history.past_len(); + assert_eq!(names(&h), ["Background"]); + let row = recorded(&h.ctx)[0].row; + let from = pos2(row.center().x, row.bottom() - 4.0); + let before = steps(&h); + drag_from_to(&mut h, from, pos2(row.center().x, row.top() + row.height() * 0.25), Modifiers::ALT); + assert_eq!(names(&h), ["Background", "Background copy"]); + assert_eq!(steps(&h), before + 1, "one undo step"); + assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Duplicate Layer")); + // A plain drop back on the same layer leaves everything as it is. + let row = recorded(&h.ctx)[0].row; + let before = steps(&h); + drag_from_to(&mut h, from, pos2(row.center().x, row.top() + row.height() * 0.75), Modifiers::NONE); + assert_eq!(names(&h), ["Background", "Background copy"], "a plain drop on itself does nothing"); + assert_eq!(steps(&h), before); +} + +/// #1721: the empty space below the bottom-most row is a drop target too (Photoshop): ⌥ copies +/// the layer to the very bottom of the stack, a plain drag moves it there. +#[test] +fn dropping_below_the_bottom_row_lands_at_the_bottom_of_the_stack() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + // Nothing can go below a Background (#2626), so the bottom layer is a normal one here. + s.execute("layer.new.layerFromBackground", json!({})).unwrap(); + for i in 0..2 { + s.execute("layer.new.layer", json!({"name": format!("L{i}")})).unwrap(); + } + let mut h = harness(s, 1.0, "promedium", 290.0); + let names = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().doc.layers.iter().map(|l| l.name.clone()).collect::>(); + // Rows top to bottom: L1, L0, Layer 0. The empty space starts under Layer 0. + let top = recorded(&h.ctx)[0].row.center(); + let bottom = recorded(&h.ctx)[2].row; + drag_from_to(&mut h, top, pos2(bottom.center().x, bottom.bottom() + 10.0), Modifiers::ALT); + assert_eq!(names(&h), ["L1 copy", "Layer 0", "L0", "L1"]); + assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Duplicate Layer")); + // Rows: L1, L0, Layer 0, L1 copy. A plain drag of L0 to the empty space moves it to the + // very bottom. + let grabbed = recorded(&h.ctx)[1].row.center(); + let bottom = recorded(&h.ctx)[3].row; + drag_from_to(&mut h, grabbed, pos2(bottom.center().x, bottom.bottom() + 10.0), Modifiers::NONE); + assert_eq!(names(&h), ["L0", "L1 copy", "Layer 0", "L1"]); + assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Reorder Layers")); +} + /// Photoshop's Layers footer is a bar along the panel's bottom edge: its buttons sit in the middle /// of the bar's height, from the right. They used to hang under the line with twice the gap below. #[test] diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index 2aff1608fd..f582197bde 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -680,6 +680,12 @@ fn save_new_style(app: &mut PhotocraftApp, f: &Map) { } } +/// Disabled effects keep their muted labels even when their parameter page stays +/// selected; selecting a page is distinct from enabling its effect (#2288). +fn fx_label_color(enabled: bool, t: &Tokens) -> Color32 { + if enabled { t.text } else { t.text_dim } +} + /// Dialog body (left list, right parameters). /// The enable checkbox of an effect row. fn fx_checkbox_rect(row: egui::Rect) -> egui::Rect { @@ -774,7 +780,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map Option<&photocraft_doc::Layer> { + match doc.layers.as_slice() { + [layer] + if matches!(layer.content, photocraft_doc::LayerContent::Raster(_)) + && layer.visible + && layer.opacity >= 1.0 + && layer.fill_opacity >= 1.0 + && layer.mask.is_none() + && layer.vector_mask.is_none() + && layer.effects.items.is_empty() + && layer.effects.psd_raw.is_none() + && matches!(layer.blend, photocraft_color::BlendMode::Normal | photocraft_color::BlendMode::PassThrough) + && photocraft_compose::channel_weights(layer, doc.mode).is_none() + && !photocraft_compose::blend_if_active(layer, doc.mode) => + { + Some(layer) + } + _ => None, + } +} + /// Encode a document: (file bytes, warnings about anything approximated or dropped). pub type ExportFn = Box Result<(Vec, Vec), String>>; pub type WriteFn = Box Result<(), String>>; @@ -275,6 +304,9 @@ pub type CursorPosFn = Box Option>; /// Whether Caps Lock is toggled on, read from the OS. `None` where the platform cannot /// report it (native Wayland): the cursor then follows the cursor preference (#1758). pub type CapsLockFn = Box bool>; +/// Keeps an undecorated window's OS frame from offsetting its content and pointer (#2246). +/// Called once per frame; `None` when the window is decorated or the platform needs no fixing. +pub type WindowFrameFn = Box; /// Platform services injected by the app binary (file dialogs, codecs), keeping this crate free of /// I/O dependencies. @@ -340,6 +372,10 @@ pub struct Services { /// Caps Lock toggled on (desktop; `None` on Wayland and the web). Read once per frame, so /// the canvas can show the precise crosshair for painting tools, whatever the preference. pub caps_lock: Option, + /// Keeps the undecorated window borderless, so the content and the pointer stay aligned + /// (Windows custom title bar, #2246). Called once per frame; `None` when the window is + /// decorated or the platform needs no fixing. + pub window_frame: Option, /// The persistent brush preset store, loading in the background (desktop; see /// `photocraft_engine::preset_store`). Attached to the session once it arrives; without /// one, brush presets are session-only unless the shell attached a store before startup. @@ -386,8 +422,12 @@ pub struct PhotocraftApp { live_stroke: Option, /// Footprint trail of a retouching drag (see `stroke_trail`). trail: Option, + /// The open menus' items, reused between frames (`menus::ItemCache`). + pub(crate) menu_cache: Option, /// Move tool drag shown live (`move_ui`). pub(crate) move_preview: Option, + /// Move tool › Show Highlight on Rollover: the last hover answer (`move_hover`). + pub(crate) move_hover: Option, /// A blend mode hovered in the Layers panel, shown live (`blend_preview`). pub(crate) blend_preview: Option, /// Patch Tool drag: the healed document at the pointer (`patch_preview`). @@ -535,6 +575,8 @@ pub struct PhotocraftApp { /// Type tool layout cache: ((doc, revision, layer), layout). pub(crate) type_layout: Option<((u64, u64, u64), std::sync::Arc)>, pub(crate) type_transform_preview: Option, + /// A font hovered in the Type tool's font picker, shown live (`type_font_preview`). + pub(crate) type_font_preview: Option, /// Channel thumbnails for one document snapshot; view-only revisions reuse their pixels. channel_thumbs: Option<(DocId, std::sync::Weak, bool, Vec)>, /// Channels panel overlays / channel views drawn over the canvas, per document id. @@ -556,6 +598,9 @@ pub struct PhotocraftApp { pub(crate) file_dialog: Option, /// A Save As to a layered TIFF parked behind the TIFF Options prompt (see `tiff_options_ui`). pub(crate) tiff_options: Option, + /// Where each document was last saved or exported to through a dialog (#1826): its next Save + /// As or export dialog starts there rather than beside the document. Session-only. + pub(crate) save_dirs: HashMap, /// Set once the user has agreed to quit, so the resulting close request goes through. pub(crate) allow_close: bool, /// Pen pressure/tilt from the platform (see `stylus`). @@ -585,7 +630,9 @@ impl PhotocraftApp { tool_override: None, live_stroke: None, trail: None, + menu_cache: None, move_preview: None, + move_hover: None, blend_preview: None, patch_preview: None, shape_stroke_preview: None, @@ -637,6 +684,7 @@ impl PhotocraftApp { channel_views: HashMap::new(), type_layout: None, type_transform_preview: None, + type_font_preview: None, guide_drag: None, crop: Default::default(), hover_doc: None, @@ -666,6 +714,7 @@ impl PhotocraftApp { discard: None, file_dialog: None, tiff_options: None, + save_dirs: HashMap::new(), allow_close: false, stylus: Default::default(), background_jobs: false, @@ -805,7 +854,8 @@ impl PhotocraftApp { } else { jobs_ui::run(self, id, params) }; - if r.is_ok() && ADDS_LAYER_MASK.contains(&id) { + let creates_adjustment_or_fill = id.starts_with("layer.newAdjustmentLayer.") || id.starts_with("layer.newFillLayer."); + if r.is_ok() && (ADDS_LAYER_MASK.contains(&id) || creates_adjustment_or_fill) { // Adding a layer mask targets it, as in Photoshop (#2166). self.ui.mask_target = true; self.ui.vector_mask_target = false; @@ -917,6 +967,13 @@ impl PhotocraftApp { self.session.prefs.edit(|p| p.file_handling.recent_files.clear()); } + /// Take one file off the recent list (the Home screen's × or Remove from Recent, #2691). The + /// file stays on disk, and opening it again lists it again. + pub fn remove_recent(&mut self, path: &str) { + self.ui.recent_files.retain(|p| p != path); + self.session.prefs.edit(|p| p.file_handling.recent_files.retain(|r| r != path)); + } + /// How many recent files to remember ("Recent File List Contains", 0–100). pub fn recent_cap(&self) -> usize { self.session.prefs().file_handling.recent_file_count.min(100) as usize @@ -1033,18 +1090,11 @@ impl PhotocraftApp { // Other files keep their format only when it can retain their layers. A plain raster // (including an unlocked transparent PNG) can keep its flat format; editable contents, // masks and layer appearance settings need a layered format even with just one layer. - let plain_raster = matches!(st.doc.layers.as_slice(), [layer] - if matches!(layer.content, photocraft_doc::LayerContent::Raster(_)) - && layer.visible && layer.opacity >= 1.0 && layer.fill_opacity >= 1.0 - && layer.mask.is_none() && layer.vector_mask.is_none() - && layer.effects.items.is_empty() && layer.effects.psd_raw.is_none() - && matches!(layer.blend, photocraft_color::BlendMode::Normal | photocraft_color::BlendMode::PassThrough) - && photocraft_compose::channel_weights(layer, st.doc.mode).is_none() - && !photocraft_compose::blend_if_active(layer, st.doc.mode)); + let flat = plain_raster(&st.doc).is_some(); let ext = st.path.as_deref().and_then(|p| std::path::Path::new(p).extension()).map(|e| e.to_string_lossy().to_ascii_lowercase()); let writable = ext.is_some_and(|e| { matches!(e.as_str(), photocraft_format::EXTENSION | "psd" | "psb" | "tif" | "tiff" | "ora") - || (plain_raster && photocraft_codecs::from_extension(&e).is_some_and(|f| f.caps().write)) + || (flat && photocraft_codecs::from_extension(&e).is_some_and(|f| f.caps().write)) }); let suggested = match &st.path { Some(p) if writable => p.clone(), @@ -1061,12 +1111,69 @@ impl PhotocraftApp { /// [`Self::save_as`] once the path is known. fn save_to(&mut self, path: String) -> Result { // A layered TIFF asks about its layers first (Preferences › File Handling); the save - // continues from the prompt. + // continues from the prompt, which records the step once answered. if tiff_options_ui::wants_prompt(self, &path) { tiff_options_ui::park(self, path.clone())?; return Ok(serde_json::json!({"path": path, "warnings": []})); } - match self.write_document(path.clone(), &ExportSettings::default(), false)? { + self.save_as_step(path, None) + } + + /// File › Save back to the document's own layered file, recorded as a `file.save` step. + pub(crate) fn save_in_place(&mut self, path: String) -> Result { + if self.tiff_options.is_some() { + return Err("Answer TIFF Options before starting another save".into()); + } + let r = self.write_save(path, None)?; + self.session.journal.push(("file.save".into(), Value::Object(Default::default()))); + Ok(r) + } + + /// Writes the Save As and journals it as a `file.saveAs` step with its path (and TIFF + /// answer), so an action being recorded keeps it (#2032). + fn save_as_step(&mut self, path: String, tiff_layers: Option) -> Result { + let r = self.write_save(path.clone(), tiff_layers)?; + let mut step = serde_json::json!({"path": path}); + if let Some(layers) = tiff_layers { + step["tiffLayers"] = layers.into(); + } + self.session.journal.push(("file.saveAs".into(), step)); + Ok(r) + } + + /// Replays a recorded `file.save` / `file.saveAs` step: no dialog or prompt, and the + /// recorded TIFF answer (if any) stands in for the TIFF Options prompt. + pub(crate) fn replay_save(&mut self, id: &str, params: &Value) -> Result { + if self.tiff_options.is_some() { + return Err("Answer TIFF Options before starting another save".into()); + } + if id == "file.save" { + let path = self + .session + .active() + .ok_or("no document")? + .path + .clone() + .filter(|p| photocraft_engine::file_cmds::saves_in_place(p)) + .ok_or("Save writes back only to the document's own PSD, PSB or .pcraft file")?; + return self.save_in_place(path); + } + let path = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("Save As needs a `path`")?; + self.save_as_step(path.to_string(), params.get("tiffLayers").and_then(Value::as_bool)) + } + + /// Writes the active document to a known path. `tiff_layers` is the TIFF Options answer: + /// `Some(false)` discards the layers and saves a copy. + fn write_save(&mut self, path: String, tiff_layers: Option) -> Result { + // A flat file that can't hold the document (its layers, or the layered file it lives in) + // is written as a copy, as in Photoshop: the document keeps its file, so Save still + // writes the layered original and the edits stay unsaved (#2550). + let st = self.session.active().ok_or("no document")?; + let layered = + |p: &str| photocraft_engine::file_cmds::saves_in_place(p) || matches!(photocraft_engine::file_cmds::extension(p).as_deref(), Some("tif" | "tiff")); + let copy = tiff_layers == Some(false) || (!layered(&path) && (plain_raster(&st.doc).is_none() || st.path.as_deref().is_some_and(layered))); + let settings = ExportSettings { tiff_layers: tiff_layers.unwrap_or(ExportSettings::default().tiff_layers), ..Default::default() }; + match self.write_document(path.clone(), &settings, copy)? { Some((path, warnings)) => Ok(serde_json::json!({"path": path, "warnings": warnings})), None => Ok(serde_json::json!({"path": path, "warnings": [], "pending": true, "job": self.jobs.last_started.map(|j| j.0)})), } @@ -1138,7 +1245,11 @@ impl PhotocraftApp { let (bytes, warnings) = export(&state.doc, &target, &ExportSettings::default())?; let write = self.services.automation_write.as_mut().ok_or("automation write authority is not configured")?; write(&target, &bytes)?; - if let Some(state) = self.session.active_mut() { + // Only a layered save becomes the document's file; a flat one is a copy, as in the + // headless server (#2579). + if photocraft_engine::file_cmds::extension(&target).is_some_and(|e| photocraft_engine::file_cmds::layered_extension(&e)) + && let Some(state) = self.session.active_mut() + { state.saved_to(target.clone()); } self.ui.status = format!("Saved {target}"); @@ -1153,7 +1264,7 @@ impl PhotocraftApp { while let Ok(req) = rx.try_recv() { let reply = req.reply.clone(); if req.deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) { - let _ = reply.send(serde_json::json!({"ok": false, "error": "timeout"})); + let _ = reply.send(serde_json::json!({"ok": false, "error": control::timeout_error()})); continue; } match control::handle(self, ctx, &req) { @@ -1235,6 +1346,7 @@ impl eframe::App for PhotocraftApp { } self.last_frame_time = now; self.sync_views(); + crate::type_font_preview::follow_hover(self, ctx); self.check_gpu(ctx); #[cfg(all(debug_assertions, not(target_arch = "wasm32")))] if self.live_tokens.poll(ctx, self.ui.theme) { @@ -1296,6 +1408,11 @@ impl eframe::App for PhotocraftApp { } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + // An undecorated window gets its caption back when the toolkit changes its flags + // (maximize, restore, …): take it off again before the frame is laid out (#2246). + if let Some(window_frame) = self.services.window_frame.as_mut() { + window_frame(); + } // Native menu key equivalents become the key presses they were (see `native_menu`). if let Some(menu) = self.services.native_menu.as_mut() { menu.raw_input(raw_input); @@ -1459,7 +1576,7 @@ impl PhotocraftApp { /// Viewing a layer mask (#196) targets it; a vector-mask target needs a vector mask on the /// active layer (a shape layer's path is its content, not a mask). Targeting a mask or the /// pixels brings back that target's foreground/background pair, as in Photoshop (#2166). - fn sync_mask_targets(&mut self) { + pub(crate) fn sync_mask_targets(&mut self) { if let Some(st) = self.session.active() { if photocraft_engine::mask_view_cmds::current(st).is_some() { self.ui.mask_target = true; @@ -1870,28 +1987,43 @@ impl PhotocraftApp { let r = photocraft_geom::Rect::new(0, 0, w as i32, h as i32); let mut surface = photocraft_raster::Surface::from_interleaved(photocraft_color::PixelFormat::RGBA8, r, &bytes); surface.prune(); - self.session.clipboard = Some(photocraft_engine::edit_cmds::Clip { surface, bounds: r }); + self.session.clipboard = Some(photocraft_engine::edit_cmds::Clip { surface, bounds: r, layers: None }); self.os_clip_sig = Some(sig); self.clip_external = true; true } } +#[cfg(test)] +mod color_swatch_tests; + #[cfg(test)] mod input_tests; #[cfg(test)] mod pencil_tests; +#[cfg(test)] +mod pixel_aspect_tests; + #[cfg(test)] mod transform_undo_tests; +#[cfg(test)] +mod transform_type_tests; + #[cfg(test)] mod save_identity_tests; #[cfg(test)] mod move_auto_select_tests; +#[cfg(test)] +mod new_group_button_tests; + +#[cfg(test)] +mod move_outline_tests; + #[cfg(test)] mod mask_thumb_refresh_tests; @@ -1904,6 +2036,9 @@ mod hidden_layer_tests; #[cfg(test)] mod blend_dropdown_keys_tests; +#[cfg(test)] +mod warp_text_dialog_tests; + #[cfg(test)] mod blend_dropdown_wheel_tests; @@ -1927,6 +2062,9 @@ mod stroke_timing_tests; #[cfg(test)] mod polygon_lasso_tests; +#[cfg(test)] +mod window_frame_tests; + #[cfg(test)] mod clipboard_tests { use super::*; diff --git a/crates/ui-egui/src/magnetic_lasso_ui/tests.rs b/crates/ui-egui/src/magnetic_lasso_ui/tests.rs index 9aec5110b2..11d5137621 100644 --- a/crates/ui-egui/src/magnetic_lasso_ui/tests.rs +++ b/crates/ui-egui/src/magnetic_lasso_ui/tests.rs @@ -316,6 +316,7 @@ fn screen(h: &Harness<'static, PhotocraftApp>, x: f64, y: f64) -> Pos2 { center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation, + aspect: app.ui.view.display_aspect(), }; xf.to_screen(x as f32, y as f32) } diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index bfc94bcd13..f03c6ec222 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -2,7 +2,7 @@ //! ⌥ draws from the centre (both together work too), a "W × H px" readout follows the cursor, and //! modifiers held before the drag still pick add/subtract (#188) instead of constraining. -use egui::{Modifiers, PointerButton, Pos2, vec2}; +use egui::{Modifiers, PointerButton, Pos2, pos2, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use photocraft_geom::Rect; @@ -40,17 +40,21 @@ fn harness(tool: Tool) -> Harness<'static, PhotocraftApp> { h } -fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { +fn view_xform(h: &Harness<'static, PhotocraftApp>) -> ViewXform { let app = h.state(); let v = &app.ui.views[0]; - let xf = ViewXform { + ViewXform { rect: crate::rulers::content_rect(app, app.last_canvas_rect), zoom: v.zoom, center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation, - }; - xf.to_screen(x, y) + aspect: app.ui.view.display_aspect(), + } +} + +fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { + view_xform(h).to_screen(x, y) } fn mods(h: &mut Harness<'static, PhotocraftApp>, m: Modifiers) { @@ -97,6 +101,37 @@ fn readout(h: &Harness<'static, PhotocraftApp>, w: i32, ht: i32) -> bool { labels && values && h.query_all_by_label_contains(" px").count() == 2 } +#[test] +fn marquee_intent_is_captured_before_the_drag_threshold() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + for at_press in [Modifiers::NONE, Modifiers::SHIFT] { + let mut h = harness(tool); + h.state_mut().ui.extras.snap = false; + h.state_mut().run("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + mods(&mut h, at_press); + move_to(&mut h, 100.0, 100.0); + let start = screen(&h, 100.0, 100.0); + button(&mut h, start, true, at_press); + assert!(h.state().drag.is_none(), "still below the drag threshold"); + // Change Shift while the pointer is still at the press, before egui starts a drag. + let during = if at_press.shift { Modifiers::NONE } else { Modifiers::SHIFT }; + mods(&mut h, during); + move_to(&mut h, 180.0, 130.0); + assert!(readout(&h, 80, if at_press.shift { 30 } else { 80 }), "{tool:?}, Shift at press: {}", at_press.shift); + release_at(&mut h, 180.0, 130.0, during); + let st = h.state().session.active().unwrap(); + let sel = st.doc.selection.as_ref().unwrap(); + if at_press.shift { + assert!(sel.sample_channel(20, 20, 0) > 0.99, "Shift at the press keeps Add mode"); + assert_eq!(selection(&h), Rect::new(10, 10, 180, 130)); + } else { + assert!(sel.sample_channel(20, 20, 0) < 0.01, "Shift after the press constrains a replacement"); + assert_eq!(selection(&h), Rect::new(100, 100, 180, 180)); + } + } + } +} + #[test] fn shift_during_the_drag_draws_a_square_and_the_readout_follows() { let mut h = harness(Tool::RectMarquee); @@ -146,13 +181,123 @@ fn the_live_outline_snaps_to_pixels_and_matches_the_committed_selection() { press_at(&mut h, 10.3, 10.3, Modifiers::NONE); move_to(&mut h, 20.6, 15.4); let app = h.state(); - let live = app.drag.as_ref().and_then(|d| crate::canvas::marquee_preview_px(&app.ui.tool_options, d)).unwrap(); + let live = app.drag.as_ref().and_then(|d| crate::canvas::marquee_preview_px(&app.ui.tool_options, d, app.point_zoom())).unwrap(); assert_eq!(live, [10.0, 10.0, 21.0, 16.0], "{tool:?}: whole pixels while dragging"); release_at(&mut h, 20.6, 15.4, Modifiers::NONE); assert_eq!(selection(&h), Rect::new(10, 10, 21, 16), "{tool:?}: the commit is what was shown"); } } +/// #2512: the hinting must follow the same transform as the release. With a rotated view the +/// marquee projects onto a rotated rectangle, and an axis-aligned screen box drawn through its +/// two diagonal corners is a different shape somewhere else entirely. +#[test] +fn the_live_outline_matches_the_committed_selection_in_a_rotated_view() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + let mut h = harness(tool); + let v = &mut h.state_mut().ui.views[0]; + v.rotation = 30.0; + h.run_steps(2); + press_at(&mut h, 50.0, 50.0, Modifiers::NONE); + move_to(&mut h, 150.0, 90.0); + let xf = view_xform(&h); + let (live, pts) = { + let app = h.state(); + let d = app.drag.as_ref().expect("the drag is on"); + let live = crate::canvas::marquee_preview_px(&app.ui.tool_options, d, app.point_zoom()).expect("a marquee is dragged"); + // Exactly what the canvas paints for this drag. + (live, crate::canvas::marquee_drag_ants(d, &xf, Some(live))) + }; + release_at(&mut h, 150.0, 90.0, Modifiers::NONE); + let sel = selection(&h); + // The pointer round-trips through f32 screen points, so a corner can land a hair over a + // pixel edge; what matters is that the rotation moves neither the selection nor the + // hinting, and that the release commits what was shown. + assert!( + (sel.x0 as f64 - live[0]).abs() <= 1.0 + && (sel.y0 as f64 - live[1]).abs() <= 1.0 + && (sel.x1 as f64 - live[2]).abs() <= 1.0 + && (sel.y1 as f64 - live[3]).abs() <= 1.0, + "{tool:?}: the release commits what the hinting showed: {sel:?} vs {live:?}" + ); + if tool == Tool::RectMarquee { + // Each corner is a corner of the dragged rectangle, mapped: an axis-aligned box + // through the two diagonal corners puts all four elsewhere. + let snap = |p: Pos2| pos2(p.x.round() + 0.5, p.y.round() + 0.5); + let want = [[live[0], live[1]], [live[2], live[1]], [live[2], live[3]], [live[0], live[3]]].map(|[x, y]| snap(xf.to_screen(x as f32, y as f32))); + for (i, (p, w)) in pts.iter().zip(want).enumerate() { + assert!((p.x - w.x).abs() < 0.01 && (p.y - w.y).abs() < 0.01, "{tool:?}: corner {i} at {p:?}, the dragged rectangle's corner is at {w:?}"); + } + } else { + // Every point maps back onto the ellipse the drag drew. + let (cx, cy, rx, ry) = ((live[0] + live[2]) * 0.5, (live[1] + live[3]) * 0.5, (live[2] - live[0]) * 0.5, (live[3] - live[1]) * 0.5); + for p in &pts { + let [x, y] = xf.to_doc(*p); + let e = ((x - cx) / rx).powi(2) + ((y - cy) / ry).powi(2); + assert!((e - 1.0).abs() < 0.08, "{tool:?}: {p:?} maps back to ({x},{y}), off the dragged ellipse (e = {e})"); + } + } + } +} + +/// 800 %, snapping off, the view centred on (30, 30): the pointer lands between pixel edges. +fn zoomed(tool: Tool) -> Harness<'static, PhotocraftApp> { + let mut h = harness(tool); + h.state_mut().ui.extras.snap = false; + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 8.0; + v.center = [30.0, 30.0]; + h.run_steps(2); + h +} + +/// Press at `from` and move to `to` in six ordinary pointer moves, the button still held. +fn drag_to(h: &mut Harness<'static, PhotocraftApp>, from: [f32; 2], to: [f32; 2]) { + move_to(h, from[0], from[1]); + button(h, screen(h, from[0], from[1]), true, Modifiers::NONE); + for i in 1..=6 { + let t = i as f32 / 6.0; + move_to(h, from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t); + } +} + +/// #2391: a one-pixel column or row shown while dragging (ants and readout) is the selection the +/// release keeps, also when the drag starts outside a selection it replaces (Photoshop). A click +/// without a drag still deselects. +#[test] +fn a_one_pixel_column_or_row_is_kept_on_release() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + for (name, end, want) in [("column", [10.6, 50.4], Rect::new(10, 10, 11, 51)), ("row", [50.6, 10.8], Rect::new(10, 10, 51, 11))] { + for existing in [false, true] { + let mut h = zoomed(tool); + if existing { + h.state_mut().run("select.rect", json!({"x": 100, "y": 100, "width": 10, "height": 10})).unwrap(); + } + drag_to(&mut h, [10.2, 10.4], end); + let app = h.state(); + let live = app.drag.as_ref().and_then(|d| crate::canvas::marquee_preview_px(&app.ui.tool_options, d, app.point_zoom())); + let shown = [want.x0, want.y0, want.x1, want.y1].map(f64::from); + assert_eq!(live, Some(shown), "{tool:?} {name} (existing {existing}): the outline while dragging"); + assert!(readout(&h, want.width() as i32, want.height() as i32), "{tool:?} {name}: W/H readout"); + let p = screen(&h, end[0], end[1]); + button(&mut h, p, false, Modifiers::NONE); + h.run_steps(2); + assert_eq!(selection(&h), want, "{tool:?} {name} (existing {existing}): the release keeps what was shown"); + assert!(h.query_by_label("W:").is_none(), "the readout goes away with the drag"); + } + } + // A plain click outside the selection deselects. + let mut h = zoomed(tool); + h.state_mut().run("select.rect", json!({"x": 100, "y": 100, "width": 10, "height": 10})).unwrap(); + move_to(&mut h, 10.2, 10.4); + let p = screen(&h, 10.2, 10.4); + button(&mut h, p, true, Modifiers::NONE); + button(&mut h, p, false, Modifiers::NONE); + h.run_steps(2); + assert!(h.state().session.active().unwrap().doc.selection.is_none(), "{tool:?}: a click deselects"); + } +} + #[test] fn modifiers_held_before_the_drag_pick_the_mode_not_the_shape() { // #188: ⇧ held at the press adds (no square); released and pressed again it constrains. @@ -533,3 +678,72 @@ fn selection_cursor_follows_the_modifiers_and_the_floating_piece() { tool_event(&mut app, ToolEvent::Move { x: 30.0, y: 20.0, pressure: 1.0 }, none); assert_eq!(selection_cursor(&app, Tool::RectMarquee, [30.0, 20.0], none), Some(SelCursor::Outline)); } + +/// A click with `tool` at document point `(x, y)`, as the canvas sends it. +fn click_with(app: &mut PhotocraftApp, tool: Tool, x: f64, y: f64, m: Modifiers) { + use crate::canvas::{ToolEvent, tool_event}; + app.ui.tool = tool; + tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x, y }, m); +} + +/// Single Row / Single Column Marquee (#2837): a click selects one pixel row (column) across the +/// whole canvas; ⇧ adds, ⌥ subtracts, as with the other marquees. +#[test] +fn single_row_and_column_marquee_select_one_pixel_lines() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.ui.extras.snap = false; + let bounds = |app: &PhotocraftApp| app.session.active().unwrap().doc.selection.as_ref().map_or(Rect::EMPTY, |s| s.content_bounds()); + let at = |app: &PhotocraftApp, x: i32, y: i32| app.session.active().unwrap().doc.selection.as_ref().map_or(0.0, |s| s.sample_channel(x, y, 0)); + + click_with(&mut app, Tool::SingleRowMarquee, 120.3, 37.6, Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(0, 37, 200, 38)); + click_with(&mut app, Tool::SingleRowMarquee, 15.0, 60.2, Modifiers::SHIFT); + assert_eq!(bounds(&app), Rect::new(0, 37, 200, 61)); + assert!(at(&app, 5, 37) > 0.99 && at(&app, 199, 60) > 0.99 && at(&app, 100, 50) < 0.01, "two rows, nothing between"); + + click_with(&mut app, Tool::SingleColumnMarquee, 42.9, 80.0, Modifiers::NONE); + assert_eq!(bounds(&app), Rect::new(42, 0, 43, 100)); + click_with(&mut app, Tool::SingleColumnMarquee, 150.5, 3.0, Modifiers::SHIFT); + assert_eq!(bounds(&app), Rect::new(42, 0, 151, 100)); + click_with(&mut app, Tool::SingleColumnMarquee, 42.0, 10.0, Modifiers::ALT); + assert_eq!(bounds(&app), Rect::new(150, 0, 151, 100), "⌥ subtracts the column"); +} + +/// The two single-line marquees sit in the Marquee flyout but M (⇧M) cycles only the +/// Rectangular and Elliptical Marquee, as in Photoshop; automation picks them by name. +#[test] +fn m_cycles_only_the_rectangular_and_elliptical_marquee() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + app.ui.tool = Tool::SingleRowMarquee; + let mut seen = Vec::new(); + for _ in 0..4 { + let raw = egui::RawInput { + events: vec![egui::Event::Key { key: egui::Key::M, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(&mut app, &ctx); + ctx.end_pass().textures_delta.clear(); + seen.push(app.ui.tool); + } + assert_eq!(seen, [Tool::RectMarquee, Tool::EllipseMarquee, Tool::RectMarquee, Tool::EllipseMarquee]); + assert_eq!(Tool::from_name("SingleRowMarquee"), Some(Tool::SingleRowMarquee)); + assert_eq!(Tool::from_name("Single Column Marquee Tool"), Some(Tool::SingleColumnMarquee)); +} + +#[test] +fn size_readout_follows_the_ruler_unit() { + use photocraft_engine::prefs::{Unit, UnitsAndRulers}; + let mut ur = UnitsAndRulers::default(); + // 1644 × 2659 px at 72 dpi: 58 cm wide + let r = [0.0, 0.0, 1644.0, 2659.0]; + ur.rulers = Unit::Pixels; + assert_eq!(crate::canvas::readout_in(&ur, 72.0, [1644.0, 2659.0], r), ["1644 px".to_string(), "2659 px".to_string()]); + ur.rulers = Unit::Centimeters; + assert_eq!(crate::canvas::readout_in(&ur, 72.0, [1644.0, 2659.0], r), ["58 cm".to_string(), "93.8 cm".to_string()]); + ur.rulers = Unit::Millimeters; + assert_eq!(crate::canvas::readout_in(&ur, 150.0, [1644.0, 2659.0], [0.0, 0.0, 3425.0, 100.0])[0], "579.97 mm"); +} diff --git a/crates/ui-egui/src/mask_props_ui.rs b/crates/ui-egui/src/mask_props_ui.rs index efbf344649..bfbeeb5098 100644 --- a/crates/ui-egui/src/mask_props_ui.rs +++ b/crates/ui-egui/src/mask_props_ui.rs @@ -3,8 +3,13 @@ use photocraft_doc::Layer; use serde_json::json; +use crate::props_layout::COL_GAP; +use crate::theme::{self, Tokens}; use crate::{PhotocraftApp, widgets}; +/// Image › Adjustments › Invert, which inverts a targeted layer mask in place (#780). +const INVERT: &str = "image.adjustments.invert"; + pub fn targeted(app: &PhotocraftApp, layer: &Layer) -> bool { app.ui.mask_target && !app.ui.vector_mask_target && layer.mask.is_some() } @@ -50,5 +55,39 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { } } } + refine_row(app, ui); }); } + +/// Photoshop's Refine row below Density and Feather: Select and Mask… and Color Range… open their +/// dialogs (the mask is Select and Mask's input when nothing is selected), and Invert flips the +/// mask. Invert names its target itself (`"target":"mask"`) rather than leaning on the shell's +/// target routing, so it edits the mask and never the layer's pixels (#1137). +fn refine_row(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let refine: [(&str, &str); 3] = [(tl!("Select and Mask…"), "select.selectAndMask"), (tl!("Color Range…"), "select.colorRange"), (tl!("Invert"), INVERT)]; + ui.add_space(theme::ROW_GAP / 2.0); + ui.label(egui::RichText::new(tl!("Refine")).color(Tokens::get(ui.ctx()).text_dim)); + ui.add_space(theme::ROW_GAP / 2.0); + let avail = ui.available_width(); + // Two buttons to a row in a wide panel, as Quick Actions do; a narrow one stacks them. + let cols = if avail >= 360.0 { 2 } else { 1 }; + for row in refine.chunks(cols) { + // A short last row (or a lone button) still spans the panel. + let w = ((avail - (row.len() - 1) as f32 * COL_GAP) / row.len() as f32).floor(); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = COL_GAP; + for &(label, id) in row { + // Greyed when its command can't run, by the same rule as the menu item. + let enabled = crate::menus::is_enabled(app, id); + if ui.add_enabled_ui(enabled, |ui| widgets::secondary_button(ui, label, w).clicked()).inner { + let params = if id == INVERT { json!({"target": "mask"}) } else { json!({}) }; + if let Err(error) = crate::menus::invoke(app, ui.ctx(), id, params) { + app.ui.status = error; + app.ui.status_error = true; + } + } + } + }); + ui.add_space(theme::ROW_GAP); + } +} diff --git a/crates/ui-egui/src/mask_props_ui_tests.rs b/crates/ui-egui/src/mask_props_ui_tests.rs index 1c3b927671..d65ac275ab 100644 --- a/crates/ui-egui/src/mask_props_ui_tests.rs +++ b/crates/ui-egui/src/mask_props_ui_tests.rs @@ -116,3 +116,67 @@ fn uncommitted_mask_number_stays_with_its_layer() { let doc = &h.state().session.active().unwrap().doc; assert_ne!(doc.layer(first).unwrap().mask.as_ref().unwrap().density, 0.5, "uncommitted expression is not applied on selection change"); } + +/// Document-wide row of mask coverage (or of the layer's own red channel) across the middle. +fn row(h: &Harness<'_, PhotocraftApp>, of_mask: bool) -> Vec { + let s = h.state().session.active().unwrap(); + let layer = s.doc.layer(s.active_layer.unwrap()).unwrap(); + let surface = if of_mask { &layer.mask.as_ref().unwrap().surface } else { layer.surface().unwrap() }; + (0..64).map(|x| surface.sample_channel(x, 32, 0)).collect() +} + +#[test] +fn refine_row_offers_select_and_mask_color_range_and_invert() { + for theme in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio] { + for adjustment in [false, true] { + let mut h = harness(adjustment, theme); + for label in ["Refine", "Select and Mask…", "Color Range…", "Invert"] { + assert!(h.query_all_by_label(label).next().is_some(), "{theme:?}, adjustment={adjustment}: no {label}"); + } + h.state_mut().ui.mask_target = false; + h.run_steps(4); + assert!(h.query_all_by_label("Select and Mask…").next().is_none(), "{theme:?}: the Refine row belongs to the mask target"); + assert!(h.query_all_by_label("Refine").next().is_none()); + } + } +} + +#[test] +fn refine_invert_inverts_the_mask_and_leaves_the_layer_alone() { + let mut h = harness(false, crate::theme::ThemeKind::Pro); + let gradient = |target: &str| json!({"from": [0, 0], "to": [63, 0], "colors": ["#000000", "#ffffff"], "target": target}); + // Pixels and mask both vary, so inverting the wrong surface shows up either way (#1137). + h.state_mut().session.execute("paint.gradient", gradient("pixels")).unwrap(); + h.state_mut().session.execute("paint.gradient", gradient("mask")).unwrap(); + h.run_steps(3); + let (mask_before, pixels_before) = (row(&h, true), row(&h, false)); + assert!(mask_before[8] < mask_before[56], "the mask must vary across the document"); + let steps = h.state().session.active().unwrap().history.past_len(); + h.get_by_label("Invert").click(); + h.run_steps(3); + for (x, (before, after)) in mask_before.iter().zip(row(&h, true)).enumerate() { + assert!((1.0 - before - after).abs() < 0.01, "mask at x={x}: {before} should invert to {}, got {after}", 1.0 - before); + } + assert_eq!(row(&h, false), pixels_before, "the layer's pixels must not be touched"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "one undoable step"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(row(&h, true), mask_before, "Undo restores the mask"); +} + +#[test] +fn refine_row_opens_the_select_and_mask_and_color_range_dialogs() { + for (label, open) in [("Select and Mask…", "select.refineEdge"), ("Color Range…", crate::color_range_ui::COMMAND)] { + let mut h = harness(false, crate::theme::ThemeKind::Pro); + assert!(h.state().ui.dialogs.is_empty()); + h.get_by_label(label).click(); + h.run_steps(3); + let dialogs = &h.state().ui.dialogs; + assert_eq!(dialogs.len(), 1, "{label} opens one dialog"); + let fields = &dialogs[0].fields; + let opened = fields.get("__command").and_then(Value::as_str).unwrap_or(crate::color_range_ui::COMMAND); + assert_eq!(opened, open, "{label} opened {opened}"); + if open == crate::color_range_ui::COMMAND { + assert!(crate::color_range_ui::owns(fields), "the Color Range dialog, not the generic one"); + } + } +} diff --git a/crates/ui-egui/src/mask_thumbs_ui/tests.rs b/crates/ui-egui/src/mask_thumbs_ui/tests.rs index f92f5546aa..3fd0904eb9 100644 --- a/crates/ui-egui/src/mask_thumbs_ui/tests.rs +++ b/crates/ui-egui/src/mask_thumbs_ui/tests.rs @@ -551,3 +551,60 @@ fn command_i_inverts_the_targeted_mask() { let mask = &st.doc.layer(st.active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface; assert_eq!(mask.sample_channel(10, 10, 0), 0.0, "reveal all → hide all"); } + +/// #2420: Selecting a layer mask in the Layers panel toggles the color swatches +/// to the mask's colors (white/black by default), and deselecting toggles back. +#[test] +fn selecting_mask_thumbnail_toggles_swatches_to_mask_colors_and_back() { + const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; + const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; + const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; + const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0]; + + let (mut s, masked, _) = dotted(); + s.execute("tools.setColors", json!({"foreground": "#ff0000", "background": "#0000ff"})).unwrap(); + let mut h = harness(s, 0, 1.0, 290.0); + + // Initial state: layer pixels targeted, red foreground, blue background. + assert!(!h.state().ui.mask_target); + assert_eq!(h.state().session.tools.foreground, RED); + assert_eq!(h.state().session.tools.background, BLUE); + + // 1. Click mask thumbnail in Layers panel -> targets mask, swatches toggle to mask colors. + let r_mask = mask_rect(&h, masked, MaskKind::Pixel); + click_with(&mut h, r_mask.center(), Modifiers::NONE); + assert!(h.state().ui.mask_target, "mask thumbnail clicked: mask is targeted"); + assert_eq!(h.state().session.tools.foreground, WHITE); + assert_eq!(h.state().session.tools.background, BLACK); + + // 2. Click layer thumbnail -> deselects mask, swatches toggle back to pixel colors. + let r_layer = layer_thumb_center(&h, masked); + click_with(&mut h, r_layer, Modifiers::NONE); + assert!(!h.state().ui.mask_target, "layer thumbnail clicked: pixels are targeted"); + assert_eq!(h.state().session.tools.foreground, RED); + assert_eq!(h.state().session.tools.background, BLUE); + + // 3. Click mask thumbnail again -> swatches toggle back to mask colors. + click_with(&mut h, r_mask.center(), Modifiers::NONE); + assert!(h.state().ui.mask_target); + assert_eq!(h.state().session.tools.foreground, WHITE); + assert_eq!(h.state().session.tools.background, BLACK); + + // 4. Changing mask colors preserves them when switching away and back. + h.state_mut().run("tools.setColors", json!({"foreground": "#808080"})).unwrap(); + let grey = h.state().session.tools.foreground; + assert_eq!(grey, [128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]); + + click_with(&mut h, r_layer, Modifiers::NONE); + assert_eq!(h.state().session.tools.foreground, RED, "pixel colors restored"); + + click_with(&mut h, r_mask.center(), Modifiers::NONE); + assert_eq!(h.state().session.tools.foreground, grey, "mask colors restored"); + + // 5. Creating a new adjustment layer targets its mask and toggles to mask colors. + click_with(&mut h, r_layer, Modifiers::NONE); + assert_eq!(h.state().session.tools.foreground, RED); + h.state_mut().run("layer.newAdjustmentLayer.curves", json!({})).unwrap(); + assert!(h.state().ui.mask_target, "new adjustment layer targets its mask"); + assert_eq!(h.state().session.tools.foreground, grey); +} diff --git a/crates/ui-egui/src/menu_catalog.rs b/crates/ui-egui/src/menu_catalog.rs index 37032dbb5f..355a08261e 100644 --- a/crates/ui-egui/src/menu_catalog.rs +++ b/crates/ui-egui/src/menu_catalog.rs @@ -7,6 +7,7 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["File"], "New…", Some("Cmd+N"), "file.new"), (&["File"], "Open…", Some("Cmd+O"), "file.open"), (&["File"], "Open As…", Some("Cmd+Alt+Shift+O"), "file.openAs"), + (&["File"], "Open EXR Parts…", None, "file.openExrParts"), (&["File"], "---", None, "---"), (&["File"], "Close", Some("Cmd+W"), "file.close"), (&["File"], "Close All", Some("Cmd+Alt+W"), "file.closeAll"), @@ -244,6 +245,7 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Image", "Analysis"], "Place Scale Marker…", None, "image.analysis.placeScaleMarker"), (&["Layer", "New"], "Layer…", Some("Cmd+Shift+N"), "layer.new.layer"), (&["Layer", "New"], "Layer from Background…", None, "layer.new.layerFromBackground"), + (&["Layer", "New"], "Background from Layer", None, "layer.new.backgroundFromLayer"), (&["Layer", "New"], "Group…", None, "layer.new.group"), (&["Layer", "New"], "Group from Layers…", None, "layer.new.groupFromLayers"), (&["Layer", "New"], "---", None, "---"), @@ -392,6 +394,7 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Layer", "Rasterize"], "Vector Mask", None, "layer.rasterize.vectorMask"), (&["Layer", "Rasterize"], "Smart Object", None, "layer.rasterize.smartObject"), (&["Layer", "Rasterize"], "Video", None, "layer.rasterize.video"), + (&["Layer", "Rasterize"], "Layer Style", None, "layer.rasterize.layerStyle"), (&["Layer", "Rasterize"], "---", None, "---"), (&["Layer", "Rasterize"], "Layer", None, "layer.rasterize.layer"), (&["Layer", "Rasterize"], "All Layers", None, "layer.rasterize.allLayers"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 49907e80d6..a717a12190 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -70,6 +70,9 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("window.togglePanels", "Show/Hide All Panels", &[], Some("Tab")), ("window.toggle.dock", "Show/Hide Panels", &[], Some("Shift+Tab")), ("window.toggle.options", "Options", &["Window"], None), + // Photoshop's Ctrl+Tab and Ctrl+Shift+Tab, on macOS too (⌘Tab belongs to the system, #2340). + ("window.nextDocument", "Next Document", &[], Some("Ctrl+Tab")), + ("window.previousDocument", "Previous Document", &[], Some("Ctrl+Shift+Tab")), ("window.theme.toggle", "Next Appearance Mode", &["Window"], None), ("window.theme.pro", "Pro Theme", &["Window", "Theme"], None), ("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None), @@ -79,6 +82,8 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("window.theme.solarizedDark", "Solarized Dark Theme", &["Window", "Theme"], None), ("window.theme.adwaita", "Adwaita Light Theme", &["Window", "Theme"], None), ("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None), + ("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None), + ("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None), ("window.theme.system", "Sync with system", &["Window", "Theme"], None), ("edit.search", "Search…", &["Edit"], Some("Cmd+F")), ("help.discord", "Join the ArtCraft Discord…", &["Help"], None), @@ -276,12 +281,13 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } "file.save" => { // Writes back only to a layered file; a flat one goes through Save As. - let path = params - .get("path") - .and_then(Value::as_str) - .map(str::to_string) - .or_else(|| app.session.active().and_then(|d| d.path.clone()).filter(|p| photocraft_engine::file_cmds::saves_in_place(p))); - app.save_as(path) + match params.get("path").and_then(Value::as_str) { + Some(path) => app.save_as(Some(path.to_string())), + None => match app.session.active().and_then(|d| d.path.clone()).filter(|p| photocraft_engine::file_cmds::saves_in_place(p)) { + Some(path) => app.save_in_place(path), + None => app.save_as(None), + }, + } } "file.exit" => { ctx.send_viewport_cmd(egui::ViewportCommand::Close); @@ -291,6 +297,11 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: app.clear_recent(); Ok(Value::Null) } + "file.removeRecent" => { + let path = params.get("path").and_then(Value::as_str).ok_or("file.removeRecent needs a \"path\"")?; + app.remove_recent(path); + Ok(Value::Null) + } id if id.starts_with("file.openRecent.") => { #[cfg(not(target_arch = "wasm32"))] { @@ -330,7 +341,9 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: | "window.theme.classic" | "window.theme.solarizedDark" | "window.theme.adwaita" - | "window.theme.adwaitaDark" => { + | "window.theme.adwaitaDark" + | "window.theme.breezeLight" + | "window.theme.breezeDark" => { let k = crate::theme::ThemeKind::from_name(&id["window.theme.".len()..]).unwrap_or_default(); app.set_theme(ctx, k); Ok(Value::Null) @@ -426,6 +439,10 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: "select.transformSelection" if params.as_object().is_none_or(|o| o.is_empty()) => { crate::transform_tool::begin_selection(app, ctx).map(|_| json!({"transform": app.ui.transform})) } + "edit.cut" if app.ui.text_edit.is_some() => { + crate::type_tool::cut_selection(app, ctx); + Ok(Value::Null) + } "edit.paste" if params.as_object().is_none_or(|o| o.is_empty()) => { // Photoshop: paste in place when the copied area is visible, else centred in the view; // images from other apps are always centred. @@ -436,7 +453,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: && app.session.active_index().zip(app.session.clipboard.as_ref()).is_some_and(|(i, clip)| { let v = &app.ui.views[i]; let pz = app.point_zoom(); - let (hw, hh) = (app.last_canvas_rect.width() / 2.0 / pz, app.last_canvas_rect.height() / 2.0 / pz); + let (hw, hh) = (app.last_canvas_rect.width() / 2.0 / pz / app.ui.view.display_aspect(), app.last_canvas_rect.height() / 2.0 / pz); let r = photocraft_geom::Rect::new((v.center[0] - hw) as i32, (v.center[1] - hh) as i32, (v.center[0] + hw) as i32, (v.center[1] + hh) as i32); !clip.bounds.intersect(&r).is_empty() @@ -517,6 +534,15 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } Ok(Value::Null) } + "window.nextDocument" | "window.previousDocument" => { + // Tabs are in document order, so the next tab is the next document, wrapping around. + let n = app.session.documents().len(); + let at = app.session.active_index().ok_or("no document open")?; + let to = if id == "window.nextDocument" { (at + 1) % n } else { (at + n - 1) % n }; + app.session.set_active(to); + app.jobs.focus = None; + Ok(json!({"document": to})) + } t if t.starts_with("window.toggle.") => { // A shown but collapsed dock group is expanded rather than hidden (#129). if let Some(g) = crate::dock::Group::from_key(&t["window.toggle.".len()..]).filter(|g| g.shown(&app.ui.panels) && app.ui.dock.is_collapsed(*g)) { @@ -594,7 +620,7 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { match id { "window.panel.activate" | "window.panel.layout" => true, "window.panel.group" | "window.panel.move" | "window.panel.float" | "window.panel.dock" | "window.panel.close" => !app.session.prefs().workspace_locked, - "file.open" | "file.exit" | "file.clearRecent" | "help.about" | "help.systemInfo" | "edit.search" => true, + "file.open" | "file.exit" | "file.clearRecent" | "file.removeRecent" | "help.about" | "help.systemInfo" | "edit.search" => true, i if i.starts_with("file.openRecent.") => true, i if crate::links::url_for(i).is_some() => true, i if i.starts_with("window.theme.") => true, @@ -603,11 +629,14 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { } i if i.starts_with("window.toggle.") => true, "window.togglePanels" => true, + // With one document open the key stays quiet, as in Photoshop, rather than reporting why. + "window.nextDocument" | "window.previousDocument" => app.session.active().is_some(), i if panel_alias(i).is_some() || workspace_name(i).is_some() => true, i if proof_preset(i).is_some() => app.session.active().is_some(), // "Custom…" is the full Proof Setup dialog. "view.proofSetup.custom" => app.session.active().is_some(), "view.rulers" | "view.show.grid" | "view.show.guides" | "view.snap" | "view.lockGuides" => true, + "edit.cut" if app.ui.text_edit.is_some() => crate::type_tool::selected_range(app).is_some(), // An image copied in another app can only be seen by reading the OS clipboard, which happens // on an explicit paste: with a clipboard service these stay enabled. Paste and New from // Clipboard need no document (with none open, Paste makes one); Paste in Place does. @@ -729,6 +758,19 @@ fn checked(app: &PhotocraftApp, id: &str) -> Option { }) } +/// "Reveal in Finder" named after the file manager the engine opens on this platform (UI-217-19): +/// Explorer on Windows, a plain folder elsewhere (xdg-open, the web). English action key; each +/// display translates it. +pub(crate) fn reveal_label() -> &'static str { + if cfg!(target_os = "macos") { + "Reveal in Finder" + } else if cfg!(windows) { + "Show in Explorer" + } else { + "Show in Folder" + } +} + /// Translate the fixed command label and substitute the currently configured export format. /// Reuse the existing translated PNG sentence, so dynamic formats work in every UI language. fn translated_menu_label(lang: crate::i18n::Lang, item: &MenuItem) -> String { @@ -860,6 +902,10 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec { }; item.label = format!("Quick Export as {format}"); } + // Smart Objects › Reveal in Finder names this platform's file manager (UI-217-19). + if let Some(item) = items.iter_mut().find(|i| i.id == "layer.smartObjects.revealInFinder") { + item.label = reveal_label().into(); + } // File › Open Recent: a dynamic submenu of recently opened files (inserted after "Open As…"). if let Some(after) = items.iter().position(|i| i.id == "file.openAs") { let rp: Vec = vec!["File".into(), "Open Recent".into()]; @@ -930,11 +976,29 @@ fn menu_tint(name: &str) -> Option { }) } +/// The open menus' items, kept between frames: building them asks every command whether it can +/// run, which on a document with thousands of layers costs far more than a frame. +pub(crate) struct ItemCache { + /// [`crate::native_menu::state_hash`] of the state the items were built from. + state: u64, + items: Vec, +} + +impl ItemCache { + /// The items, while they still describe `state`. A frame with a click or key press rebuilds + /// them, which covers state the hash doesn't list. + fn reuse(self, state: u64, input: bool) -> Option> { + (!input && self.state == state).then_some(self.items) + } +} + /// Draws the menu bar; returns the right edge of the last menu title (the bar itself fills the row). pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { // Built only while a menu is open: every item's enabled/checked state scales with the - // document (layer lookups), which cost milliseconds per frame on large layouts (#125). - let items: std::cell::OnceCell> = std::cell::OnceCell::new(); + // document (layer lookups), which cost milliseconds per frame on large layouts (#125). While + // one stays open, the items are reused until what they depend on changes. + let kept = std::cell::Cell::new(app.menu_cache.take()); + let built: std::cell::OnceCell = std::cell::OnceCell::new(); let app_ref: &PhotocraftApp = app; let mut right = ui.cursor().left(); let lang = crate::i18n::current(); @@ -979,7 +1043,14 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { .style(config.style.clone()) .info(egui::UiStackInfo::new(egui::UiKind::Menu).with_tag_value(egui::containers::menu::MenuConfig::MENU_CONFIG_TAG, config)) .show(|ui| { - let items = items.get_or_init(|| menu_items(app_ref)); + let items = &built + .get_or_init(|| { + let state = crate::native_menu::state_hash(app_ref, None); + let input = crate::native_menu::had_input(ui.ctx()); + let items = kept.take().and_then(|c| c.reuse(state, input)).unwrap_or_else(|| menu_items(app_ref)); + ItemCache { state, items } + }) + .items; let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect(); ui.set_min_width(220.0); if mine.is_empty() { @@ -1004,6 +1075,8 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { }); }); nav.store(ui.ctx()); + // No menu open this frame: nothing is kept. + app.menu_cache = built.into_inner(); // The press-drag gesture ends with the button (its release was handled by the rows above). if ui.input(|i| i.pointer.primary_released() || !i.pointer.primary_down()) { ui.ctx().data_mut(|d| d.remove::(press_gesture_id())); @@ -1179,7 +1252,6 @@ fn render_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: & const MENU_SEPARATOR: f32 = 9.0; fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option, nav: &mut crate::menu_nav::Nav) { - let t = crate::theme::Tokens::get(ui.ctx()); let lang = crate::i18n::current(); // Items never wrap: the menu widens to its longest label plus shortcut (translations can be // longer than the English). @@ -1187,15 +1259,8 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click // Rows touch, as in native menus: the dialog spacing between them made long menus a fifth // taller than they need to be (#402). ui.spacing_mut().item_spacing.y = 0.0; - if t.pro { - // Spectrum/macOS menus: blue highlight row with white text. - let v = &mut ui.style_mut().visuals; - v.widgets.hovered.weak_bg_fill = t.accent; - v.widgets.hovered.bg_fill = t.accent; - v.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE); - v.widgets.hovered.corner_radius = egui::CornerRadius::same(3); - ui.spacing_mut().button_padding = egui::vec2(10.0, 4.0); - } + // Secondary menus use the exact same Spectrum hover contrast and spacing. + crate::widgets::style_spectrum_popup_menu(ui); // Walk in Photoshop order: leaves and separators at this depth; a submenu appears at the position // of its first child. let mut shown_subs: Vec<&str> = Vec::new(); @@ -1504,6 +1569,42 @@ mod tests { assert!(harness.query_by_label_contains("Duplicate…").is_some(), "Image menu should open on hover"); } + /// An open menu reuses its items between frames (building them costs far more than a frame on + /// a document with thousands of layers), rebuilds them when the state changes under it, and + /// keeps nothing once it closes. + #[test] + fn an_open_menu_reuses_its_items_until_the_state_changes() { + use egui_kittest::{Harness, kittest::Queryable}; + + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut harness = Harness::builder().with_size(egui::vec2(1200.0, 700.0)).build_ui_state( + |ui, app| { + menu_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]); + harness.run_steps(3); + assert!(harness.state().menu_cache.is_none(), "no menu open, nothing built"); + harness.get_by_label("File").click(); + harness.run_steps(3); + // The same allocation frame after frame: the items were not rebuilt. + let built = |app: &PhotocraftApp| app.menu_cache.as_ref().map(|c| c.items.as_ptr()); + let close = |app: &PhotocraftApp| app.menu_cache.as_ref().and_then(|c| c.items.iter().find(|i| i.id == "file.close")).map(|i| i.enabled); + let first = built(harness.state()); + assert!(first.is_some()); + assert_eq!(close(harness.state()), Some(false)); + harness.run_steps(3); + assert_eq!(built(harness.state()), first); + // A document opened under the open menu (a job ending, an agent): the items follow. + harness.state_mut().run("file.new", json!({"width": 8, "height": 8})).unwrap(); + harness.run_steps(1); + assert_eq!(close(harness.state()), Some(true)); + harness.key_press(egui::Key::Escape); + harness.run_steps(3); + assert!(harness.state().menu_cache.is_none(), "closed, nothing kept"); + } + #[test] fn help_search_finds_commands_by_any_word() { let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -1762,6 +1863,17 @@ mod open_recent_tests { assert!(!menu_items(&app).iter().any(|i| i.id.starts_with("file.openRecent."))); // A bad recent index errors gracefully (no panic). assert!(invoke(&mut app, &ctx, "file.openRecent.5", json!({})).is_err()); + + // Remove one entry (the Home screen's ×, #2691): the others keep their order. + for p in ["/tmp/c.tif", "/tmp/b.psd", "/tmp/a.png"] { + app.push_recent(p); + } + invoke(&mut app, &ctx, "file.removeRecent", json!({"path": "/tmp/b.psd"})).unwrap(); + assert_eq!(app.ui.recent_files, vec!["/tmp/a.png".to_string(), "/tmp/c.tif".to_string()]); + assert_eq!(app.session.prefs().file_handling.recent_files, app.ui.recent_files, "removed in the preferences too"); + invoke(&mut app, &ctx, "file.removeRecent", json!({"path": "/tmp/not-listed.png"})).unwrap(); + assert_eq!(app.ui.recent_files.len(), 2, "a path that isn't listed changes nothing"); + assert!(invoke(&mut app, &ctx, "file.removeRecent", json!({})).is_err(), "needs a path"); } #[cfg(not(target_arch = "wasm32"))] @@ -1811,3 +1923,45 @@ mod quick_export_label_tests { } } } + +#[cfg(test)] +mod reveal_label_tests { + use super::*; + + #[test] + fn reveal_label_names_the_platform_file_manager() { + let expected = if cfg!(target_os = "macos") { + "Reveal in Finder" + } else if cfg!(windows) { + "Show in Explorer" + } else { + "Show in Folder" + }; + assert_eq!(reveal_label(), expected); + } + + #[test] + fn smart_objects_reveal_item_names_the_platform_file_manager() { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let id = "layer.smartObjects.revealInFinder"; + let items = menu_items(&app); + let reveal: Vec<_> = items.iter().filter(|i| i.id == id).collect(); + assert_eq!(reveal.len(), 1); + assert_eq!(reveal[0].path, ["Layer", "Smart Objects"]); + assert_eq!(reveal[0].label, reveal_label()); + if !cfg!(target_os = "macos") { + assert!(!reveal[0].label.contains("Finder"), "{}", reveal[0].label); + } + let shortcuts = crate::prefs_ui::shortcut_items(&app); + assert!(shortcuts.iter().any(|(i, label, _, _)| i == id && label == reveal_label()), "the shortcut list shows the menu label"); + let de = crate::i18n::Lang::from_code("de").unwrap(); + let expected = match reveal_label() { + "Reveal in Finder" => "Im Finder anzeigen", + "Show in Explorer" => "Im Explorer anzeigen", + _ => "Im Ordner anzeigen", + }; + assert_eq!(translated_menu_label(de, reveal[0]), expected); + // The menu catalogue (and docs/parity-checklist.md) keeps Photoshop's macOS name. + assert!(crate::menu_catalog::CATALOG.iter().any(|(_, label, _, i)| *i == id && *label == "Reveal in Finder")); + } +} diff --git a/crates/ui-egui/src/move_hover.rs b/crates/ui-egui/src/move_hover.rs new file mode 100644 index 0000000000..01e6a40f3a --- /dev/null +++ b/crates/ui-egui/src/move_hover.rs @@ -0,0 +1,128 @@ +//! Move tool › Show Highlight on Rollover (Preferences › Tools, on by default): with Auto-Select +//! on, or ⌘ held while it is off, hovering the canvas outlines the layer (or group, per the +//! Auto-Select target) that a click there would pick. The hit test is the click's own +//! (`pick_cmds::auto_select_target`), so a type layer is outlined anywhere inside its text, as it +//! is picked there (#2591). The answer is cached per document revision and pointer pixel: a +//! pointer resting on the canvas costs nothing per frame. + +use egui::{Pos2, Stroke}; +use photocraft_doc::{DocId, Document, Layer, LayerContent, LayerId}; +use photocraft_geom::Rect; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::state::Tool; + +/// The last answer: what a click at `at` on that revision of the document would outline. +pub(crate) struct Hover { + doc: DocId, + revision: u64, + at: [i32; 2], + group: bool, + bounds: Option, +} + +/// The document-space bounds to outline with the pointer at `at` (document px), or `None` when +/// nothing is highlighted: the preference is off, another tool is current, a click wouldn't +/// Auto-Select (it's off without ⌘, or the selected pixels move instead), a button is down, or a +/// drag or transform is under way. +pub(crate) fn target(app: &mut PhotocraftApp, at: Option<[f64; 2]>, command: bool, pressed: bool) -> Option { + if !app.session.prefs().tools.show_highlight_on_rollover + || app.ui.tool != Tool::Move + || pressed + || app.drag.is_some() + || app.guide_drag.is_some() + || app.ui.transform.is_some() + || app.ui.tool_options.move_auto_select == command + || crate::move_ui::moves_selected_pixels_with(app, Tool::Move) + { + return None; + } + let at = at.filter(|p| p[0].is_finite() && p[1].is_finite())?; + let at = [at[0].floor() as i32, at[1].floor() as i32]; + let group = app.ui.tool_options.move_target == "group"; + let st = app.session.active()?; + let (doc, revision) = (st.doc.id, st.revision); + if let Some(h) = app.move_hover.as_ref().filter(|h| h.doc == doc && h.revision == revision && h.at == at && h.group == group) { + return h.bounds; + } + let bounds = photocraft_engine::pick_cmds::auto_select_target(&st.doc, at[0], at[1], group).and_then(|id| outline(&st.doc, id)); + app.move_hover = Some(Hover { doc, revision, at, group, bounds }); + bounds +} + +/// The outline of a picked layer: its visible content (a group's, its visible layers' together). +/// Not the Background: it fills the canvas, and the Move tool doesn't move it. +fn outline(doc: &Document, id: LayerId) -> Option { + let rows = doc.walk(); + let (prefix, _, top) = rows.iter().find(|(_, _, l)| l.id == id)?; + if crate::doc_props_ui::is_background(doc, top) { + return None; + } + let b = rows + .iter() + .filter(|(p, _, _)| p.starts_with(prefix) && (prefix.len()..=p.len()).all(|n| p.get(..n).and_then(|q| doc.layer_at(q)).is_some_and(|l| l.visible))) + .fold(Rect::EMPTY, |acc, (_, _, l)| acc.union(&bounds(doc, l))); + (!b.is_empty()).then_some(b) +} + +fn bounds(doc: &Document, l: &Layer) -> Rect { + if let Some(a) = l.artboard() { + return a.rect; + } + let content = |s| photocraft_compose::bounds::content_bounds(s); + match &l.content { + LayerContent::Group(_) | LayerContent::Adjustment(_) => Rect::EMPTY, + // A fill layer covers the canvas; its mask limits it. + LayerContent::Fill(_) => l.mask.as_ref().filter(|m| m.enabled).map_or(doc.bounds(), |m| content(&m.surface)), + _ => l.surface().map_or(Rect::EMPTY, content), + } +} + +/// Draws the highlight on the active document's canvas, in the theme's accent colour. +pub(crate) fn draw(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, idx: usize, hover: Option) { + if app.session.active_index() != Some(idx) { + return; + } + let (mods, pressed) = painter.ctx().input(|i| (i.modifiers, i.pointer.any_down())); + let command = crate::workspace_ui::sticky_mods(app, mods).command; + let at = hover.map(|p| xf.to_doc(p)); + let Some(b) = target(app, at, command, pressed) else { return }; + let accent = crate::theme::Tokens::get(painter.ctx()).accent; + painter.rect_stroke(xf.doc_rect(b), 0.0, Stroke::new(1.5, accent), egui::StrokeKind::Outside); +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn rollover_outlines_the_layer_a_click_would_pick() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(8, 8, 24, 24), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.ui.tool = Tool::Move; + let a = Rect::new(8, 8, 24, 24); + // The empty top layer is clicked through to the painted one; the Background isn't outlined. + assert_eq!(target(&mut app, Some([10.5, 12.0]), false, false), Some(a)); + assert_eq!(target(&mut app, Some([40.0, 40.0]), false, false), None); + // Nothing while a button is down or a drag runs. + assert_eq!(target(&mut app, Some([10.5, 12.0]), false, true), None); + // Auto-Select off: only with ⌘ held. + app.ui.tool_options.move_auto_select = false; + assert_eq!(target(&mut app, Some([10.5, 12.0]), false, false), None); + assert_eq!(target(&mut app, Some([10.5, 12.0]), true, false), Some(a)); + // Preferences › Tools › Show Highlight on Rollover off. + app.session.edit_prefs(|p| p.tools.show_highlight_on_rollover = false); + assert_eq!(target(&mut app, Some([10.5, 12.0]), true, false), None); + } +} diff --git a/crates/ui-egui/src/move_lock.rs b/crates/ui-egui/src/move_lock.rs index 2cef0f2230..d7fedbf620 100644 --- a/crates/ui-egui/src/move_lock.rs +++ b/crates/ui-egui/src/move_lock.rs @@ -29,9 +29,9 @@ pub fn blocked(app: &PhotocraftApp, tool: Tool, p: [f64; 2], mods: egui::Modifie }) }; if tool == Tool::Move { - // `moves_selected_pixels` is false for a layer whose pixels can't float: then the Move tool + // `moves_selected_pixels_with` is false for a layer whose pixels can't float: then the Move tool // moves the layer, which its position lock refuses too. - if crate::move_ui::moves_selected_pixels(app) { + if crate::move_ui::moves_selected_pixels_with(app, tool) { return pixels_locked(); } return !floating && st.doc.selection.is_some() && pixels_locked() || layers_locked(); diff --git a/crates/ui-egui/src/move_mods.rs b/crates/ui-egui/src/move_mods.rs index b9cee5a021..193a70bb50 100644 --- a/crates/ui-egui/src/move_mods.rs +++ b/crates/ui-egui/src/move_mods.rs @@ -168,17 +168,22 @@ pub fn abandon(app: &mut PhotocraftApp) { } /// Arrow keys with the Move tool (or while Free Transform is active): nudge 1 px, ⇧ 10 px; -/// ⌥ duplicates the layers first. Returns true when a key was used. +/// ⌥ duplicates the layers first. ⌘-arrows (Ctrl off the Mac) do the same from most other tools, +/// where ⌘ is the temporary Move tool (`hold_keys::cmd_nudges`, #2474). Returns true when a key +/// was used. pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { - if app.ui.tool != Tool::Move && app.ui.transform.is_none() { + use egui::{Key, Modifiers}; + let cmd = ctx.input(|i| i.modifiers.command) && crate::hold_keys::cmd_nudges(app); + if app.ui.tool != Tool::Move && app.ui.transform.is_none() && !cmd { return selection_arrow_keys(app, ctx); } - use egui::{Key, Modifiers}; + // `consume_key` never ignores an extra ⌘, so it is part of every pattern while it moves. + let base = if cmd { Modifiers::COMMAND } else { Modifiers::NONE }; let keys = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)]; for (key, ux, uy) in keys { // Most specific first: `consume_key` ignores extra ⇧ / ⌥ (`matches_logically`). for mods in [Modifiers::SHIFT | Modifiers::ALT, Modifiers::SHIFT, Modifiers::ALT, Modifiers::NONE] { - if ctx.input_mut(|i| i.consume_key(mods, key)) { + if ctx.input_mut(|i| i.consume_key(base | mods, key)) { let k = if mods.shift { 10.0 } else { 1.0 }; nudge(app, ux * k, uy * k, mods.alt); return true; @@ -189,7 +194,7 @@ pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } /// Moves the selected layers (or the Free Transform box, or the selected pixels) by `(dx, dy)` -/// pixels. +/// pixels, as the Move tool (also when ⌘ makes it the temporary Move tool). pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) { if let Some(t) = app.ui.transform.as_mut() { if t.warp.is_none() { @@ -201,7 +206,7 @@ pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) { if app.drag.is_some() { return; } - if crate::move_ui::moves_selected_pixels(app) { + if crate::move_ui::moves_selected_pixels_with(app, Tool::Move) { crate::move_ui::float_selected(app, duplicate_first, dx, dy); return; } @@ -416,6 +421,77 @@ mod tests { assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(9, 18, 25, 34), "the original stays"); } + /// ⌘ as a Mac sends it, and Ctrl as Windows and Linux send it (`command` is Ctrl there). + const MAC_CMD: egui::Modifiers = egui::Modifiers { alt: false, ctrl: false, shift: false, mac_cmd: true, command: true }; + const CTRL: egui::Modifiers = egui::Modifiers { alt: false, ctrl: true, shift: false, mac_cmd: false, command: true }; + + /// #2474: ⌘ is the temporary Move tool, so ⌘-arrows nudge the layer from the Brush (⌘⇧ 10 px, + /// ⌘⌥ duplicates first); the plain arrows still do nothing there. + #[test] + fn cmd_arrows_nudge_the_layer_from_the_brush() { + let mut app = app_with_layer(); + app.ui.tool = Tool::Brush; + let id = app.session.active().unwrap().active_layer.unwrap(); + let n0 = app.session.active().unwrap().doc.layers.len(); + assert!(!press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE), "a plain arrow is not the Brush's"); + assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(8, 8, 24, 24)); + for cmd in [CTRL, MAC_CMD] { + assert!(press(&mut app, egui::Key::ArrowRight, cmd)); + } + assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(10, 8, 26, 24), "1 px per press"); + assert!(press(&mut app, egui::Key::ArrowDown, CTRL | egui::Modifiers::SHIFT)); + assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(10, 18, 26, 34), "⌘⇧: 10 px"); + assert!(press(&mut app, egui::Key::ArrowLeft, CTRL | egui::Modifiers::ALT)); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layers.len(), n0 + 1, "⌘⌥ duplicated first"); + assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(10, 18, 26, 34), "the original stays"); + assert_eq!(bounds(&app, st.active_layer.unwrap()), photocraft_geom::Rect::new(9, 18, 25, 34), "the copy moved"); + assert_eq!(app.ui.tool, Tool::Brush); + // The Move tool itself takes ⌘-arrows as plain ones. + app.ui.tool = Tool::Move; + assert!(press(&mut app, egui::Key::ArrowUp, CTRL)); + let copy = app.session.active().unwrap().active_layer.unwrap(); + assert_eq!(bounds(&app, copy), photocraft_geom::Rect::new(9, 17, 25, 33)); + } + + /// #2474: tools where ⌘ means something else in Photoshop (Pen and path tools, Hand, Zoom, + /// Crop, Type) keep ⌘-arrows away from the layer, as does an active Free Transform. + #[test] + fn cmd_arrows_leave_the_layer_alone_where_cmd_is_not_the_move_tool() { + let mut app = app_with_layer(); + let id = app.session.active().unwrap().active_layer.unwrap(); + for tool in [Tool::Pen, Tool::PathSelection, Tool::DirectSelection, Tool::Rectangle, Tool::Hand, Tool::Zoom, Tool::Crop, Tool::Type] { + app.ui.tool = tool; + assert!(!press(&mut app, egui::Key::ArrowRight, CTRL), "{tool:?}"); + assert!(!press(&mut app, egui::Key::ArrowRight, MAC_CMD | egui::Modifiers::SHIFT), "{tool:?}"); + } + assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(8, 8, 24, 24)); + app.ui.tool = Tool::Brush; + crate::transform_tool::begin(&mut app, &egui::Context::default()).unwrap(); + let q0 = app.ui.transform.as_ref().unwrap().quad; + assert!(!press(&mut app, egui::Key::ArrowRight, CTRL)); + assert_eq!(app.ui.transform.as_ref().unwrap().quad, q0); + } + + /// #2474: with a selection tool ⌘-arrows move the selected pixels (the temporary Move tool), the + /// plain arrows the outline. + #[test] + fn cmd_arrows_move_the_selected_pixels_with_a_selection_tool() { + let mut app = app_with_layer(); + app.ui.tool = Tool::RectMarquee; + app.session.execute("select.rect", json!({"x": 8, "y": 8, "width": 8, "height": 8})).unwrap(); + assert!(press(&mut app, egui::Key::ArrowRight, CTRL)); + let offset = |app: &PhotocraftApp| photocraft_engine::float_cmds::floating(app.session.active().unwrap()).map(|f| f.offset); + assert_eq!(offset(&app), Some((1, 0)), "the selected pixels float, 1 px to the right"); + assert!(press(&mut app, egui::Key::ArrowDown, CTRL | egui::Modifiers::SHIFT)); + assert_eq!(offset(&app), Some((1, 10)), "⌘⇧: 10 px"); + assert_eq!(app.ui.tool, Tool::RectMarquee); + // Not while a polygon is being drawn. + app.ui.tool = Tool::PolygonLasso; + app.ui.polygon = vec![[1.0, 1.0]]; + assert!(!press(&mut app, egui::Key::ArrowRight, CTRL)); + } + /// #1428: with a selection tool the arrows nudge the selection outline (⇧ 10 px), one step /// each; the layer stays put. Without a selection they are left alone. #[test] diff --git a/crates/ui-egui/src/move_outline_tests.rs b/crates/ui-egui/src/move_outline_tests.rs new file mode 100644 index 0000000000..7029ab9faf --- /dev/null +++ b/crates/ui-egui/src/move_outline_tests.rs @@ -0,0 +1,151 @@ +//! #2413: View › Show › Layer Edges while the Move tool drags. As in Photoshop, the outline belongs +//! to the layer being moved (the one Auto-Select picks on the press, not the one selected before) +//! and moves in lockstep with its pixels, however fast the pointer goes. + +use egui::{Event, PointerButton, Pos2}; +use egui_kittest::Harness; +use photocraft_doc::LayerId; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{LAYER_EDGES, ViewXform}; +use crate::state::{Tool, View}; + +/// A 64×64 document with two painted layers: A covers (8..24)², B covers (40..56)². B is active, +/// the Move tool (Auto-Select on) is current and View › Show › Layer Edges is on. +fn app() -> (PhotocraftApp, LayerId, LayerId) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.sync_views(); + let mut ids = Vec::new(); + for at in [8, 40] { + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(at, at, at + 16, at + 16), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + ids.push(app.session.active().unwrap().active_layer.unwrap()); + } + app.ui.tool = Tool::Move; + assert!(app.ui.tool_options.move_auto_select); + // Snapping would shift the expected offsets. + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + app.ui.extras.rulers = false; + app.ui.view.extras = true; + app.ui.view.show.layer_edges = true; + // 4 screen px per document px, the document centred. + app.ui.views = vec![View { zoom: 4.0, center: [32.0, 32.0], fit_pending: false, fill_pending: false, doc_size: [64, 64], rotation: 0.0 }]; + (app, ids[0], ids[1]) +} + +fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(400.0, 340.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + app.last_canvas_rect = ui.max_rect(); + let v = app.ui.views[0].clone(); + crate::canvas::canvas_view(app, ui, 0, ui.max_rect(), v, true); + }, + app, + ); + h.run_steps(2); + h +} + +fn xf(app: &PhotocraftApp) -> ViewXform { + let v = &app.ui.views[0]; + ViewXform { rect: app.last_canvas_rect, zoom: v.zoom / app.ppp, center: v.center, flip: false, rotation: 0.0, aspect: 1.0 } +} + +/// The screen point of document point `p`. +fn at(h: &Harness<'_, PhotocraftApp>, p: [f32; 2]) -> Pos2 { + xf(h.state()).to_screen(p[0], p[1]) +} + +/// The Layer Edges outlines the last frame drew. +fn edges(h: &Harness<'_, PhotocraftApp>) -> Vec { + h.output() + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Rect(r) if r.stroke.color == LAYER_EDGES && r.fill == egui::Color32::TRANSPARENT => Some(r.rect), + _ => None, + }) + .collect() +} + +/// The outline Layer Edges draws around document rect `r`. +fn outline(h: &Harness<'_, PhotocraftApp>, r: Rect) -> Vec { + vec![xf(h.state()).doc_rect(r)] +} + +fn button(h: &mut Harness<'_, PhotocraftApp>, pos: Pos2, pressed: bool) { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); +} + +fn active(h: &Harness<'_, PhotocraftApp>) -> Option { + h.state().session.active().unwrap().active_layer +} + +const A: Rect = Rect::new(8, 8, 24, 24); +const B: Rect = Rect::new(40, 40, 56, 56); + +#[test] +fn the_outline_belongs_to_the_layer_auto_select_picked() { + let (app, a, b) = app(); + let mut h = harness(app); + assert_eq!(active(&h), Some(b)); + assert_eq!(edges(&h), outline(&h, B), "B is selected: its edges show"); + // Press on A and drag it 5 px to the right (20 screen px, past egui's click distance). + let p0 = at(&h, [16.0, 16.0]); + h.event(Event::PointerMoved(p0)); + button(&mut h, p0, true); + h.step(); + h.event(Event::PointerMoved(p0 + egui::vec2(20.0, 0.0))); + h.step(); + // The frame the drag starts in picks A: it outlines A, never B, which isn't moving. + assert_eq!(active(&h), Some(a), "Auto-Select picked A"); + assert_eq!(edges(&h), outline(&h, A), "the frame that picks A outlines A, not the previously selected B"); + h.step(); + assert_eq!(edges(&h), outline(&h, A.translate(5, 0)), "A's outline follows it"); + button(&mut h, p0 + egui::vec2(20.0, 0.0), false); + h.step(); + h.step(); + assert_eq!(edges(&h), outline(&h, A.translate(5, 0)), "after the release the outline stays on A where it landed"); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.layer(a).unwrap().surface().unwrap().content_bounds(), A.translate(5, 0)); +} + +#[test] +fn the_outline_moves_in_lockstep_with_a_fast_drag() { + let (app, _, b) = app(); + let mut h = harness(app); + let p0 = at(&h, [48.0, 48.0]); + h.event(Event::PointerMoved(p0)); + button(&mut h, p0, true); + h.step(); + h.event(Event::PointerMoved(p0 + egui::vec2(-20.0, 0.0))); + h.step(); + h.step(); + assert_eq!(active(&h), Some(b)); + assert_eq!(edges(&h), outline(&h, B.translate(-5, 0))); + // Big jumps, one per frame. A frame draws the layer where the previous frame's events left it + // (the image is painted before this frame's events are read), and the outline goes with it. + let mut shown = (-5, 0); + for (dx, dy) in [(-30, -10), (-36, -30), (-12, -36), (-40, 0)] { + h.event(Event::PointerMoved(p0 + egui::vec2(dx as f32 * 4.0, dy as f32 * 4.0))); + h.step(); + assert_eq!(edges(&h), outline(&h, B.translate(shown.0, shown.1)), "drawn with the layer at {shown:?} while the pointer moved to {dx},{dy}"); + // The next frame shows both at the pointer. + h.step(); + assert_eq!(edges(&h), outline(&h, B.translate(dx, dy)), "the outline is where the layer is shown, at {dx},{dy}"); + shown = (dx, dy); + } + button(&mut h, p0 + egui::vec2(-160.0, 0.0), false); + h.step(); + h.step(); + assert_eq!(edges(&h), outline(&h, B.translate(-40, 0)), "dropped where it was shown"); +} diff --git a/crates/ui-egui/src/move_ui.rs b/crates/ui-egui/src/move_ui.rs index 883ebc2712..c99c230384 100644 --- a/crates/ui-egui/src/move_ui.rs +++ b/crates/ui-egui/src/move_ui.rs @@ -17,15 +17,11 @@ use crate::PhotocraftApp; use crate::state::Tool; /// Does the Move tool drag (and do the arrow keys nudge) the selected pixels of the active layer -/// rather than the whole layer? With a selection, as in Photoshop; a layer whose pixels can't -/// float (type, shape, Smart Object, group, position-locked), a hidden layer or several selected -/// layers still move whole, as without one. -pub(crate) fn moves_selected_pixels(app: &PhotocraftApp) -> bool { - // The Move tool, or ⌘ held with a painting tool (`hold_keys::cmd_moves`). - moves_selected_pixels_with(app, app.active_tool()) -} - -/// [`moves_selected_pixels`] with `tool` in effect (the canvas cursor asks before the press). +/// rather than the whole layer, with `tool` in effect? With a selection, as in Photoshop; a layer +/// whose pixels can't float (type, shape, Smart Object, group, position-locked), a hidden layer or +/// several selected layers still move whole, as without one. The canvas cursor asks before the +/// press, and a pointer event passes `canvas::event_tool`, which also sees a ⌘ sent with the event +/// (#2768). pub(crate) fn moves_selected_pixels_with(app: &PhotocraftApp, tool: Tool) -> bool { let Some(st) = app.session.active() else { return false }; tool == Tool::Move @@ -60,6 +56,8 @@ pub(crate) struct MovePreview { revision: u64, /// Layers that move ([`photocraft_engine::layer_multi_cmds::move_targets`]). ids: Vec, + /// `ids` and every layer inside them: the layers shown at the drag's offset. + moving: Vec, /// What moving them can change at offset (0, 0), not clipped to the canvas: a layer larger /// than the canvas brings its pixels from beyond the edge into view (`None` = anything). bounds: Option, @@ -127,7 +125,8 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc Option<(Arc Vec { + let walk = doc.walk(); + let roots: Vec<_> = walk.iter().filter(|(_, _, l)| ids.contains(&l.id)).map(|(p, _, _)| p.as_slice()).collect(); + walk.iter().filter(|(p, _, _)| roots.iter().any(|r| p.starts_with(r))).map(|(_, _, l)| l.id).collect() +} + +/// Where the canvas shows layer `id` of document `doc` at `revision` relative to where it is: the +/// offset of the latest Move drag frame drawn ([`display_doc`]) when the layer moves with the drag, +/// so its Layer Edges outline moves in lockstep with its pixels (#2413). `None` when it shows +/// where it is. +pub(crate) fn shown_offset(app: &PhotocraftApp, doc: DocId, revision: u64, id: LayerId) -> Option<(i32, i32)> { + let p = app.move_preview.as_ref().filter(|p| p.doc == doc && p.revision == revision && !p.floating && p.shown.is_some())?; + if !p.moving.contains(&id) { + return None; + } + p.offsets.last().copied() +} + /// A floating selection's offset plus a drag of it in progress (`canvas::selection_drag_delta`). pub(crate) fn floating_offset(app: &PhotocraftApp) -> Option<(i32, i32)> { let f = photocraft_engine::float_cmds::floating(app.session.active()?)?; @@ -172,7 +190,17 @@ fn floating_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc, u if !fresh { let bounds = st.doc.selection.as_ref().map(|s| s.content_bounds()); let canvas = st.doc.bounds(); - app.move_preview = Some(MovePreview { doc: doc_id, revision, ids: vec![f.layer], bounds, canvas, offsets: Vec::new(), shown: None, floating: true }); + app.move_preview = Some(MovePreview { + doc: doc_id, + revision, + ids: vec![f.layer], + moving: Vec::new(), + bounds, + canvas, + offsets: Vec::new(), + shown: None, + floating: true, + }); } let st = app.session.documents().get(idx)?; let shown = app.move_preview.as_ref()?.offsets.last() != Some(&offset); @@ -376,6 +404,23 @@ mod tests { } } + /// ⌘-drag with a painting tool drags the selected pixels as the Move tool does, also when ⌘ + /// comes with the event (control channel, MCP) rather than as the held-key override. + #[test] + fn command_drag_with_a_painting_tool_moves_only_the_selected_pixels() { + let (mut app, id) = selected_pixels(8); + app.ui.tool = Tool::Brush; + app.ui.tool_options.move_auto_select = false; + let cmd = egui::Modifiers { mac_cmd: true, command: true, ..Default::default() }; + drag(&mut app, [50.0, 40.0], [60.0, 45.0], cmd); + assert_eq!(offset(&app), Some((10, 5)), "the selected pixels float"); + drag(&mut app, [50.0, 40.0], [60.0, 45.0], cmd); + assert_eq!(offset(&app), Some((20, 10)), "a second drag moves the same floating piece"); + app.run("select.drop", json!({})).unwrap(); + assert!(alpha(&app, id, 10, 10) == 0.0 && alpha(&app, id, 40, 30) == 1.0, "the red square moved"); + assert_eq!(alpha(&app, id, 48, 16), 1.0, "the unselected blue one stayed"); + } + /// A click moves nothing and leaves nothing floating (Undo isn't spent putting it back). #[test] fn a_click_with_a_selection_moves_nothing() { diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 847217e98b..91131627d8 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -683,8 +683,7 @@ pub fn run(app: &mut PhotocraftApp, ctx: &egui::Context) { /// every command's enabled state, so it doesn't run on frames that only animate or scroll. pub fn sync(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(menu) = app.services.native_menu.as_ref() else { return }; - let input = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. } | egui::Event::PointerButton { pressed: false, .. }))); - let state = state_hash(app, input.then_some(app.frame)); + let state = state_hash(app, had_input(ctx).then_some(app.frame)); if menu.state == Some(state) && !menu.dirty { return; } @@ -706,16 +705,23 @@ pub fn sync(app: &mut PhotocraftApp, ctx: &egui::Context) { menu.dirty = false; } +/// A click or key press this frame: it may have changed state [`state_hash`] doesn't list. +pub(crate) fn had_input(ctx: &egui::Context) -> bool { + ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. } | egui::Event::PointerButton { pressed: false, .. }))) +} + /// What the menus' rows depend on, cheaply: commands run, the documents and their revisions, /// selection, recent files, panels and view state, the language. `input` forces a change on /// frames with a click or key press, which covers state the hash doesn't list. -fn state_hash(app: &PhotocraftApp, input: Option) -> u64 { +pub(crate) fn state_hash(app: &PhotocraftApp, input: Option) -> u64 { let mut h = DefaultHasher::new(); input.hash(&mut h); let s = &app.session; s.journal.len().hash(&mut h); s.active_index().hash(&mut h); s.clipboard.is_some().hash(&mut h); + // A running job greys the commands that would change its document. + s.has_jobs().hash(&mut h); if let Some(d) = s.active() { (d.doc.id.0, d.revision, d.active_layer.map(|l| l.0), d.selected_layers.len(), d.isolated_layers.len()).hash(&mut h); } @@ -1057,6 +1063,72 @@ mod tests { assert!(synced.borrow().last().unwrap().find("image.imageRotation.90cw").unwrap().enabled); } + /// Edit > Cut uses the type editor's selection, like the clipboard Cut event (#1355). + #[test] + fn cut_selected_type_from_native_menu_matches_keyboard_cut() { + for native_click in [true, false] { + let synced = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let mut app = app(true); + let ctx = egui::Context::default(); + let id = photocraft_doc::LayerId(app.run("type.create", json!({"text": "Hello", "size": 12, "x": 4, "y": 24})).unwrap()["layer"].as_u64().unwrap()); + crate::menus::invoke(&mut app, &ctx, "type.editText", json!({})).unwrap(); + let edit = app.ui.text_edit.as_ref().unwrap(); + assert_eq!((edit.anchor, edit.caret), (0, 5)); + let events = if native_click { vec![Event::Click("edit.cut".into())] } else { Vec::new() }; + app.services.native_menu = Some(NativeMenu::new(Box::new(Fake { synced: synced.clone(), events }))); + frame(&ctx, egui::RawInput::default(), |ui| sync(&mut app, ui.ctx())); + assert!(synced.borrow().last().unwrap().find("edit.cut").unwrap().enabled, "Cut must be enabled for selected characters"); + + let mut raw = egui::RawInput::default(); + app.services.native_menu.as_mut().unwrap().raw_input(&mut raw); + if !native_click { + raw.events.push(egui::Event::Cut); + } + let mut out = ctx.run_ui(raw, |ui| { + run(&mut app, ui.ctx()); + crate::shortcuts::handle(&mut app, ui.ctx()); + sync(&mut app, ui.ctx()); + }); + out.textures_delta.clear(); + assert_eq!(out.platform_output.commands, [egui::OutputCommand::CopyText("Hello".into())]); + let photocraft_doc::LayerContent::Text(text) = &app.session.active().unwrap().doc.layer(id).unwrap().content else { + panic!("type layer changed kind") + }; + assert!(text.text.is_empty()); + let edit = app.ui.text_edit.as_ref().unwrap(); + assert_eq!((edit.anchor, edit.caret), (0, 0)); + assert!(!synced.borrow().last().unwrap().find("edit.cut").unwrap().enabled, "Cut must be disabled at an empty caret"); + assert!(app.session.clipboard.is_none(), "text Cut must not copy rendered pixels"); + } + } + + #[test] + fn native_cut_without_type_edit_keeps_pixel_clipboard_behavior() { + let synced = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let mut app = app(false); + app.run("file.new", json!({"width": 4, "height": 4, "background": "transparent"})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + assert_eq!(app.run("document.pixel", json!({"x": 1, "y": 1})).unwrap(), json!([1.0, 0.0, 0.0, 1.0])); + let ctx = egui::Context::default(); + app.services.native_menu = Some(NativeMenu::new(Box::new(Fake { synced: synced.clone(), events: vec![Event::Click("edit.cut".into())] }))); + frame(&ctx, egui::RawInput::default(), |ui| sync(&mut app, ui.ctx())); + assert_eq!(synced.borrow().last().unwrap().find("edit.cut").unwrap().enabled, app.session.is_enabled("edit.cut")); + assert!(app.session.is_enabled("edit.cut")); + let mut raw = egui::RawInput::default(); + app.services.native_menu.as_mut().unwrap().raw_input(&mut raw); + frame(&ctx, raw, |ui| { + run(&mut app, ui.ctx()); + sync(&mut app, ui.ctx()); + }); + assert_eq!(app.run("document.pixel", json!({"x": 1, "y": 1})).unwrap(), json!([0.0, 0.0, 0.0, 0.0])); + let clip = app.session.clipboard.as_ref().unwrap(); + assert_eq!((clip.bounds.width(), clip.bounds.height()), (4, 4)); + let mut px = vec![[0u8; 4]; 16]; + clip.surface.read_rgba8_into(clip.bounds, &mut px); + assert_eq!(px, vec![[255u8, 0, 0, 255]; 16]); + assert!(app.ui.text_edit.is_none()); + } + /// With the Mac menu bar the title bar draws no menu titles: each title shows once fewer. #[test] fn the_title_bar_hides_its_menus_with_the_mac_menu_bar() { diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index fb9631324e..43db284c32 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -243,20 +243,23 @@ pub fn apply_saved_preset(f: &mut Map, preset: &photocraft_engine f.remove(&typed_key("height")); } -fn saved_presets(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { +fn saved_presets(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) -> bool { let presets = app.session.presets.documents.clone(); if presets.is_empty() { ui.label(tl!("No saved presets yet.")); - return; + return false; } + let mut create = false; egui::ScrollArea::vertical().id_salt("saved-document-presets").max_height(320.0).show(ui, |ui| { for preset in &presets { ui.push_id(&preset.name, |ui| { ui.horizontal(|ui| { let selected = get_s(f, "__savedPreset", "") == preset.name; let label = egui::Button::new(&preset.name).selected(selected).truncate(); - if ui.add_sized(vec2(422.0, 32.0), label).on_hover_text(&preset.name).clicked() { + let response = ui.add_sized(vec2(422.0, 32.0), label).on_hover_text(&preset.name); + if response.clicked() { apply_saved_preset(f, preset); + create |= response.double_clicked() || response.triple_clicked(); } if widgets::secondary_button(ui, tl!("Delete"), 76.0).clicked() { match app.run("document.presets.delete", json!({"name":preset.name})) { @@ -278,6 +281,7 @@ fn saved_presets(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map< }); } }); + create } fn save_preset(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { @@ -326,9 +330,12 @@ fn save_preset(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { +/// Returns whether a preset double-click requested creation, after applying its settings. +/// A recent category click can make egui count the preset double-click as a triple-click. +pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) -> bool { let t = Tokens::get(ui.ctx()); let cat = get_s(f, "__category", "Recent"); + let mut create = false; // Category tabs. ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 18.0; @@ -356,7 +363,7 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = UNITS.iter().map(|u| (u.0.to_string(), u.1)).collect(); @@ -435,7 +454,7 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map Harness<'static, PhotocraftApp> { let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + let mut h = Harness::builder() + .with_size(egui::vec2(1400.0, 900.0)) + .with_step_dt(1.0 / 60.0) + .build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); h.state_mut().ui.open_dialog(DialogKind::NewDocument, UiState::new_document_fields()); h.run_steps(3); @@ -832,6 +855,114 @@ mod tests { (d.size.width, d.size.height, d.resolution_dpi) } + /// Preset cards paint their titles directly, without an accessibility label. + fn preset_title_at(h: &Harness<'static, PhotocraftApp>, title: &str) -> egui::Pos2 { + fn find(shape: &egui::Shape, title: &str) -> Option { + match shape { + egui::Shape::Text(text) if text.galley.job.text == title => Some(text.visual_bounding_rect().center()), + egui::Shape::Vec(shapes) => shapes.iter().find_map(|shape| find(shape, title)), + _ => None, + } + } + h.output().shapes.iter().find_map(|shape| find(&shape.shape, title)).expect("visible preset title") + } + + fn second_click_creates_once(h: &mut Harness<'static, PhotocraftApp>, at: egui::Pos2) { + assert!(h.state().session.documents().is_empty(), "the first click only selects the preset"); + assert_eq!(h.state().ui.dialogs.len(), 1); + // Two real pointer press/release pairs within egui's double-click interval. + click_at(h, at); + h.run_steps(3); + assert_eq!(h.state().session.documents().len(), 1, "the second click creates exactly one document"); + assert!(h.state().ui.dialogs.is_empty(), "double-click closes the New Document dialog"); + assert!(h.state().ui.views[0].fit_pending, "the normal Create path fits the new document"); + } + + fn assert_pixel(h: &mut Harness<'static, PhotocraftApp>, expected: [f64; 4]) { + let pixel = h.state_mut().session.execute("document.pixel", serde_json::json!({"x":0,"y":0})).unwrap(); + for (actual, expected) in pixel.as_array().unwrap().iter().zip(expected) { + assert!((actual.as_f64().unwrap() - expected).abs() < 0.001, "background pixel: {pixel}"); + } + } + + #[test] + fn double_click_builtin_preset_creates_its_size_and_current_details() { + let mut h = harness(); + let mut f = fields(&h); + f.extend(serde_json::json!({"name":"Portrait proof","mode":"cmyk","depth":16,"background":"transparent"}).as_object().unwrap().clone()); + set_fields(&mut h, f); + h.get_by_label("Photo").click(); + h.run_steps(2); + let at = preset_title_at(&h, "Portrait, 4 x 6"); + click_at(&mut h, at); + assert_eq!(fields(&h)["__preset"], "Portrait, 4 x 6"); + assert_eq!( + super::super::command_params(&fields(&h)), + serde_json::json!({"name":"Portrait proof","width":1200,"height":1800,"resolution":300.0, + "mode":"cmyk","depth":16,"background":"transparent"}) + ); + second_click_creates_once(&mut h, at); + assert_eq!(created(&h), (1200, 1800, 300.0)); + let doc = &h.state().session.active().unwrap().doc; + assert_eq!(doc.name, "Portrait proof"); + assert_eq!((doc.mode, doc.depth), (photocraft_doc::ColorMode::Cmyk, photocraft_doc::SampleType::U16)); + assert_pixel(&mut h, [0.0; 4]); + } + + #[test] + fn double_click_clipboard_preset_creates_its_size_and_current_details() { + let mut h = harness(); + let mut f = fields(&h); + super::super::set_clipboard(&mut f, 37, 23); + apply_preset(&mut f, &("Other size", 100, 200, 300.0)); + f.extend(serde_json::json!({"mode":"gray","depth":8,"background":"white"}).as_object().unwrap().clone()); + set_fields(&mut h, f); + let at = preset_title_at(&h, super::super::CLIPBOARD); + click_at(&mut h, at); + assert_eq!(fields(&h)["__preset"], super::super::CLIPBOARD); + assert_eq!( + super::super::command_params(&fields(&h)), + serde_json::json!({"name":"Untitled-1","width":37,"height":23,"resolution":72.0, + "mode":"gray","depth":8,"background":"white"}) + ); + second_click_creates_once(&mut h, at); + assert_eq!(created(&h), (37, 23, 72.0)); + let doc = &h.state().session.active().unwrap().doc; + assert_eq!((doc.mode, doc.depth), (photocraft_doc::ColorMode::Grayscale, photocraft_doc::SampleType::U8)); + assert_pixel(&mut h, [1.0; 4]); + } + + #[test] + fn double_click_saved_preset_restores_all_settings_and_captured_background() { + let mut h = harness(); + h.state_mut() + .run( + "document.presets.save", + serde_json::json!({"name":"Saved proof","settings":{ + "width":48,"height":32,"resolution":254.0,"mode":"rgb","depth":32, + "background":"backgroundColor","backgroundColor":[0.125,0.375,0.625],"unit":"mm","resolutionUnit":"cm" + }}), + ) + .unwrap(); + h.state_mut().session.tools.background = [1.0, 0.0, 1.0, 1.0]; + let mut f = fields(&h); + f.insert("name".into(), serde_json::json!("Current document name")); + set_fields(&mut h, f); + h.get_by_label("Saved").click(); + h.run_steps(2); + let at = h.get_by_label("Saved proof").rect().center(); + click_at(&mut h, at); + assert_eq!(fields(&h)["__savedPreset"], "Saved proof"); + assert_eq!(fields(&h)["__unit"], "mm"); + assert_eq!(fields(&h)["__resUnit"], "cm"); + second_click_creates_once(&mut h, at); + assert_eq!(created(&h), (48, 32, 254.0)); + let doc = &h.state().session.active().unwrap().doc; + assert_eq!(doc.name, "Current document name"); + assert_eq!((doc.mode, doc.depth), (photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::F32)); + assert_pixel(&mut h, [0.125, 0.375, 0.625, 1.0]); + } + #[test] fn arithmetic_dimensions_create_the_evaluated_size() { let mut h = harness(); diff --git a/crates/ui-egui/src/new_group_button_tests.rs b/crates/ui-egui/src/new_group_button_tests.rs new file mode 100644 index 0000000000..e236ad74ec --- /dev/null +++ b/crates/ui-egui/src/new_group_button_tests.rs @@ -0,0 +1,55 @@ +//! #2561: the Layers panel New Group button. Like Photoshop a plain click adds an empty group +//! and Shift-click groups the selected layers. + +use egui::Modifiers; +use egui_kittest::kittest::Queryable; +use photocraft_doc::{Layer, LayerId}; +use serde_json::json; + +use crate::PhotocraftApp; + +/// Background, then `A` and `B` on top of it with both selected (`B` active). +fn two_selected() -> (egui_kittest::Harness<'static, PhotocraftApp>, LayerId, LayerId) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let a = LayerId(s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap()); + let b = LayerId(s.execute("layer.new.layer", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + h.run_steps(4); + (h, a, b) +} + +fn groups(h: &egui_kittest::Harness<'static, PhotocraftApp>) -> Vec { + h.state().session.active().unwrap().doc.walk().into_iter().map(|(_, _, l)| l).filter(|l| l.is_group()).cloned().collect() +} + +#[test] +fn shift_click_groups_the_selected_layers_in_order() { + let (mut h, a, b) = two_selected(); + h.get_by_label("Create a new group").click_modifiers(Modifiers::SHIFT); + h.run_steps(4); + let groups = groups(&h); + assert_eq!(groups.len(), 1); + let children: Vec = groups[0].children().unwrap().iter().map(|l| l.id).collect(); + assert_eq!(children, vec![a, b]); + let doc = &h.state().session.active().unwrap().doc; + assert_eq!(doc.layers.len(), 2, "background and the new group stay at the top level"); +} + +#[test] +fn plain_click_still_adds_an_empty_group() { + let (mut h, a, b) = two_selected(); + h.get_by_label("Create a new group").click(); + h.run_steps(4); + let groups = groups(&h); + assert_eq!(groups.len(), 1); + assert!(groups[0].children().unwrap().is_empty()); + let doc = &h.state().session.active().unwrap().doc; + let top: Vec = doc.layers.iter().map(|l| l.id).collect(); + assert_eq!(top[1..3], [a, b], "the selected layers stay where they were"); +} diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index 9fefa2a1ea..382a28458b 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -11,6 +11,10 @@ use photocraft_engine::prefs::RightClickPaint; use crate::PhotocraftApp; use crate::state::Tool; +fn picker_open_frame_id() -> egui::Id { + egui::Id::new("canvas-brush-picker-open-frame") +} + /// The brush is a tool option, kept per tool like Photoshop's options bar: switching from the /// Brush to the Eraser saves the session brush for the old tool and loads the new tool's, so each /// keeps its own size, hardness, mode, opacity, dynamics and smoothing. A tool seen for the @@ -104,6 +108,8 @@ pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool) && has_brush_picker(tool) && let Some(p) = response.interact_pointer_pos() { + let frame = response.ctx.cumulative_frame_nr(); + response.ctx.data_mut(|d| d.insert_temp(picker_open_frame_id(), frame)); app.ui.brush_picker = Some([p.x, p.y]); } let erase_click = erase && right_click; @@ -186,7 +192,11 @@ pub fn show_picker(app: &mut PhotocraftApp, ctx: &egui::Context) { // just being open: a press elsewhere closes the picker in the same click that closes the menu. let press = ctx.input(|i| i.pointer.any_pressed().then(|| i.pointer.interact_pos()).flatten()); let on_menu = press.is_some_and(|p| ctx.layer_id_at(p).is_some_and(|l| l.order == egui::Order::Foreground)); - let outside = press.is_some_and(|p| !area.response.rect.contains(p) && !crate::brush_picker::on_chip(ctx, p)) && !on_menu; + // A quick trackpad tap can press and release in one frame. The area rounds its position + // to physical pixels, so that opening press can lie just outside its rounded rectangle. + // Ignore it throughout the opening frame, including any additional layout passes. + let opening = ctx.data(|d| d.get_temp::(picker_open_frame_id())) == Some(ctx.cumulative_frame_nr()); + let outside = !opening && press.is_some_and(|p| !area.response.rect.contains(p) && !crate::brush_picker::on_chip(ctx, p)) && !on_menu; if outside || key_close || area.inner { crate::brush_picker::close(&mut app.ui); } @@ -304,6 +314,46 @@ mod tests { assert!(alpha_at(&h, end + vec2(60.0, 40.0)) > 0.9, "the segment between the strokes is painted"); } + #[test] + fn secondary_taps_reopen_the_picker_after_closing() { + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.ui.tool = Tool::Brush; + app + }); + h.run_steps(5); + let canvas = crate::rulers::content_rect(h.state(), h.state().last_canvas_rect); + let points = [canvas.center(), canvas.left_top() + vec2(40.0, 40.0), canvas.right_bottom() - vec2(40.0, 40.0)]; + for at in points { + for attempt in 0..3 { + let before = strokes(&h).len(); + let undo = h.state().session.active().unwrap().history.past_len(); + h.event(egui::Event::PointerMoved(at)); + h.run_steps(2); + for pressed in [true, false] { + // Harness::event processes each event in a separate frame. RawInput keeps + // the press and release together, as a quick two-finger tap can arrive. + h.input_mut().events.push(egui::Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(3); + assert!(h.state().ui.brush_picker.is_some(), "reopen {attempt} at {at:?}"); + assert_eq!(strokes(&h).len(), before, "opening never paints"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), undo); + let shown = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-brush-picker"))).unwrap(); + let outside = points.into_iter().find(|p| !shown.contains(*p)).unwrap(); + h.event(egui::Event::PointerMoved(outside)); + for pressed in [true, false] { + h.input_mut().events.push(egui::Event::PointerButton { pos: outside, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(3); + assert!(h.state().ui.brush_picker.is_none()); + } + } + } + #[test] fn right_click_opens_the_brush_picker_and_never_paints() { let mut h = harness(None); diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index fa149827b7..abf22ebf3c 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -36,6 +36,13 @@ pub fn fuzzy_score(query: &str, text: &str) -> Option { Some(score - (t.len() as i32 / 8)) } +/// Bonus that puts a command or tool whose name is exactly the query ahead of every fuzzy match +/// ("settings" finds Settings before Color Settings). +fn exact_bonus(query: &str, names: &[&str]) -> i32 { + let q = query.trim(); + if !q.is_empty() && names.iter().any(|n| n.trim_end_matches('…').trim().to_lowercase() == q.to_lowercase()) { 1000 } else { 0 } +} + /// How many recently run commands the palette remembers. const RECENT_CAP: usize = 8; @@ -87,6 +94,29 @@ fn recent_hits(recents: &[String], menus: &[crate::menus::MenuItem], lang: crate .collect() } +/// The menu commands matching `q`, as palette rows. Separators are not commands: they would +/// match on their menu path and list as blank "---" rows (#2479). +fn menu_hits(menus: Vec, q: &str, lang: crate::i18n::Lang) -> Vec<(i32, String, String, Option, bool)> { + menus + .into_iter() + .filter(|m| m.label != "---") + .filter_map(|m| { + let path_en = m.path.join(" › "); + let path = m.path.iter().map(|p| crate::i18n::tr(lang, p)).collect::>().join(" › "); + let label = crate::i18n::tr_id(lang, &m.id, &m.label); + // Match what is shown and the English name (commands are documented in English). + let s = fuzzy_score(q, &format!("{label} {path} {} {path_en}", m.label))? + exact_bonus(q, &[label, &m.label]); + Some(( + s, + m.id, + label.trim_end_matches('…').to_string(), + Some(path + &m.shortcut.map(|s| format!(" {}", crate::shortcuts::pretty(&s))).unwrap_or_default()), + m.enabled, + )) + }) + .collect() +} + pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if !app.ui.palette_open { return; @@ -144,22 +174,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // fresh session) it falls back to the full fuzzy list, as before. let mut hits: Vec<(i32, String, String, Option, bool)> = if empty { recent_hits(&recents, &menus, lang) } else { Vec::new() }; if !empty || hits.is_empty() { - hits.extend(menus.into_iter().filter_map(|m| { - let path_en = m.path.join(" › "); - let path = m.path.iter().map(|p| crate::i18n::tr(lang, p)).collect::>().join(" › "); - let label = crate::i18n::tr_id(lang, &m.id, &m.label); - // Match what is shown and the English name (commands are documented in English). - let s = fuzzy_score(&q, &format!("{label} {path} {} {path_en}", m.label))?; - Some(( - s, - m.id, - label.trim_end_matches('…').to_string(), - Some(path + &m.shortcut.map(|s| format!(" {}", crate::shortcuts::pretty(&s))).unwrap_or_default()), - m.enabled, - )) - })); + hits.extend(menu_hits(menus, &q, lang)); for tool in crate::state::Tool::ALL { if let Some(s) = fuzzy_score(&q, &format!("{} {}", tl!(tool.label()), tool.label())) { + let s = s + exact_bonus(&q, &[tl!(tool.label()).as_ref(), tool.label()]); hits.push((s + 2, format!("tool:{tool:?}"), tl!(tool.label()).into(), Some(format!("Tool {}", tool.key())), true)); } } @@ -240,7 +258,7 @@ mod tests { use egui_kittest::Harness; use serde_json::json; - use super::{fuzzy_score, push_recent}; + use super::{exact_bonus, fuzzy_score, push_recent}; use crate::PhotocraftApp; #[test] @@ -315,6 +333,20 @@ mod tests { assert_eq!(super::recent_ids(&ctx).len(), 1, "re-running it moves it to the front, no duplicate"); } + /// #2479: menu separators matched on their menu path ("f" → File, Filter) and filled the + /// palette with blank "---" rows. + #[test] + fn separators_are_not_palette_hits() { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let menus = crate::menus::menu_items(&app); + assert!(menus.iter().any(|m| m.label == "---"), "the menus have separators to filter"); + for q in ["f", "file", "filter", "", "-"] { + let hits = super::menu_hits(menus.clone(), q, crate::i18n::Lang::EN); + assert!(!hits.is_empty(), "{q:?} still matches commands"); + assert!(hits.iter().all(|h| h.1 != "---" && h.2 != "---"), "{q:?} lists a separator"); + } + } + #[test] fn fuzzy_ranks_prefix_and_contiguous_higher() { assert!(fuzzy_score("hue", "Hue/Saturation…").unwrap() > fuzzy_score("hue", "Channel Mixer Hue").unwrap_or(-99)); @@ -323,6 +355,17 @@ mod tests { assert_eq!(fuzzy_score("", "anything"), Some(0)); } + /// Typing a command's exact name ranks it first, even when a longer name scores the same. + #[test] + fn exact_name_ranks_first() { + let settings = fuzzy_score("settings", "Settings Edit › Preferences Settings Edit › Preferences").unwrap() + exact_bonus("settings", &["Settings"]); + let color = fuzzy_score("settings", "Color Settings… Edit Color Settings… Edit").unwrap() + exact_bonus("settings", &["Color Settings…"]); + assert!(settings > color); + assert_eq!(exact_bonus("Color settings", &["Color Settings…"]), 1000); + assert_eq!(exact_bonus(" ", &[""]), 0); + assert_eq!(exact_bonus("set", &["Settings"]), 0); + } + /// #536: Edit › Search → "Keyboard Shortcuts" must open the dialog, with and without a document. #[test] fn palette_opens_the_keyboard_shortcuts_dialog() { diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 3c7ffe61dd..8c219bdd6c 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -371,14 +371,18 @@ pub(crate) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { previous.is_some_and(|generation| generation != app.ui.docking_generation) }); if changed_workspace || stale_origin { + ui.ctx().data_mut(|data| data.insert_temp(area.with("legacy-suppressed"), true)); craft_ui::docking::cancel_drag::(ui.ctx(), area); ui.ctx().data_mut(|data| data.remove::(area.with("legacy-origin"))); } let cancelled = ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape) || (!input.pointer.primary_down() && !input.pointer.any_released())); - if cancelled && cancel_legacy_drag(app, ui.ctx()) && app.ui.docking.is_none() { - return false; + if cancelled && cancel_legacy_drag(app, ui.ctx()) { + ui.ctx().data_mut(|data| data.insert_temp(area.with("legacy-suppressed"), true)); + if app.ui.docking.is_none() { + return false; + } } let Some(layout) = app.ui.docking.as_ref() else { return false }; if !valid(layout) { @@ -487,11 +491,20 @@ pub(crate) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { } pub(crate) fn legacy_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) { - if app.session.prefs().workspace_locked { + let suppressed = egui::Id::new("photocraft-panel-docking").with("legacy-suppressed"); + if ui.input(|input| input.pointer.button_pressed(egui::PointerButton::Primary)) { + ui.ctx().data_mut(|data| data.remove::(suppressed)); + } + if ui.ctx().data(|data| data.get_temp::(suppressed)).unwrap_or(false) || app.session.prefs().workspace_locked { return; } let Some(panel) = normalize(panel) else { return }; - if response.drag_started() + // Native tab reordering and vertical whole-group moves stay in the dock. + // Crossing the dock column horizontally detaches through the shared layout. + let strip = egui::Rect::from_min_max(egui::pos2(ui.max_rect().left(), response.rect.top()), egui::pos2(ui.max_rect().right(), response.rect.bottom())); + if response.dragged() + && ui.input(|input| input.focused && input.pointer.primary_down() && !input.key_pressed(egui::Key::Escape)) + && ui.ctx().pointer_interact_pos().is_some_and(|position| position.x < strip.left() - 24.0 || position.x > strip.right() + 24.0) && let Some(id) = normalize(panel) { let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 2d2436ccbe..6980a8a93f 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -398,6 +398,14 @@ fn first_legacy_drag_workspace_switch_preserves_custom_and_legacy_destinations() } h.run_steps(1); assert_eq!(layout_state(h.state()), destination, "{customized_destination}/{cancellation}"); + if cancellation == "focus-loss" { + let at = source - vec2(90.0, 0.0); + h.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert_eq!(layout_state(h.state()), destination, "secondary press must not resume the stale primary drag"); + h.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + } assert_eq!(serde_json::to_value(h.state().ui.dock_tabs).unwrap(), tabs); assert!(h.ctx.data_mut(|data| data.get_temp::(origin)).is_none(), "stale origin: {customized_destination}/{cancellation}"); h.event(Event::WindowFocused(true)); @@ -626,3 +634,28 @@ fn mixed_theme_and_legacy_selection_projects_the_selected_panel_in_the_new_theme assert!(panel_active(app.ui.docking.as_ref().unwrap(), "color")); assert_eq!(app.ui.dock_tabs.color, 0); } + +#[test] +fn native_tab_reorder_stays_native_until_drag_crosses_the_strip() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + let target = h.query_all_by_label("Channels").find(|node| node.rect().height() <= 45.0).unwrap().rect().right_center() - vec2(1.0, 0.0); + h.event(Event::PointerMoved(source)); + h.run_steps(1); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + let near_edge = egui::pos2(target.x, source.y - 21.0); + h.event(Event::PointerMoved(near_edge)); + h.run_steps(1); + assert!(h.state().ui.docking.is_none(), "native near-edge reorder tolerance must not promote to custom docking"); + h.event(Event::PointerMoved(target)); + h.run_steps(1); + assert!(h.state().ui.docking.is_none(), "native horizontal reorder must not promote to custom docking"); + h.event(Event::PointerButton { pos: target, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(h.state().ui.docking.is_none()); + let tabs = h.state().ui.dock.visible_tabs(Group::Layers, false); + let layers = tabs.iter().position(|(_, label)| *label == "Layers").unwrap(); + let channels = tabs.iter().position(|(_, label)| *label == "Channels").unwrap(); + assert!(channels < layers, "upstream native reorder remains effective"); +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 68dfcd77e2..444036c57f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -17,7 +17,7 @@ use crate::{PhotocraftApp, icons, widgets}; const TOOL_SECTIONS: &[&[&[Tool]]] = &[ &[&[Tool::Move]], &[ - &[Tool::RectMarquee, Tool::EllipseMarquee], + &[Tool::RectMarquee, Tool::EllipseMarquee, Tool::SingleRowMarquee, Tool::SingleColumnMarquee], &[Tool::Lasso, Tool::PolygonLasso, Tool::MagneticLasso], &[Tool::ObjectSelection, Tool::QuickSelection, Tool::MagicWand], &[Tool::Crop, Tool::Slice, Tool::SliceSelect], @@ -160,6 +160,10 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // With no document open there is nothing to fit. let _ = app.run("view.fitOnScreen", json!({})); } + if resp.double_clicked() && tool == Tool::Zoom { + app.ui.tool = tool; + let _ = app.run("view.actualPixels", json!({})); + } // Right-click or long-press opens the flyout (Photoshop). let held_for = resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); @@ -868,24 +872,38 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let b = &mut brush; match app.ui.tool { Tool::Brush | Tool::Eraser if t.pro => { - brush_preset_chip(ui, b, &mut app.ui); - crate::brush_picker::settings_toggle(app, ui); + // A Block-mode Eraser has a fixed size, opacity and flow (Photoshop, #2770). + let block = crate::eraser_ui::block_mode(app, app.ui.tool); + ui.add_enabled_ui(!block, |ui| { + brush_preset_chip(ui, b, &mut app.ui); + crate::brush_picker::settings_toggle(app, ui); + }); widgets::vline(ui, 22.0); - opt_label(ui, tl!("Mode")); - let mut mode = b.mode; - let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); - if widgets::dropdown(ui, "brush-mode", &mut mode, &opts, 96.0) { - b.mode = mode; - } - percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 62.0); - if icons::button(ui, "circle-dot", 24.0, b.pressure_opacity, tl!("Always use pressure for opacity")).clicked() { - b.pressure_opacity = !b.pressure_opacity; - } - percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 62.0); - let airbrush = icons::button(ui, "sparkles", 24.0, b.build_up, tl!("Enable airbrush-style build-up effects")); - if crate::brush_picker::named(airbrush, tl!("Enable airbrush-style build-up effects")).clicked() { - b.build_up = !b.build_up; + // The Eraser has no blend mode: its Mode is Brush, Pencil or Block (#2662). + if app.ui.tool == Tool::Eraser { + crate::eraser_ui::mode_dropdown(ui, &mut app.ui.tool_options.eraser_mode); + } else { + opt_label(ui, tl!("Mode")); + let mut mode = b.mode; + let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); + if widgets::dropdown(ui, "brush-mode", &mut mode, &opts, 96.0) { + b.mode = mode; + } } + ui.add_enabled_ui(!block, |ui| { + percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 62.0); + if icons::button(ui, "circle-dot", 24.0, b.pressure_opacity, tl!("Always use pressure for opacity")).clicked() { + b.pressure_opacity = !b.pressure_opacity; + } + }); + // A Pencil- or Block-mode Eraser is always full flow, without build-up (Photoshop). + ui.add_enabled_ui(!block && !crate::eraser_ui::pencil_mode(app, app.ui.tool), |ui| { + percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 62.0); + let airbrush = icons::button(ui, "sparkles", 24.0, b.build_up, tl!("Enable airbrush-style build-up effects")); + if crate::brush_picker::named(airbrush, tl!("Enable airbrush-style build-up effects")).clicked() { + b.build_up = !b.build_up; + } + }); opt_label(ui, tl!("Smoothing")); smoothing_field(ui, b, 58.0); smoothing_options(ui, b); @@ -893,7 +911,6 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if icons::button(ui, "circle-dot", 24.0, b.pressure_size, tl!("Always use pressure for size")).clicked() { b.pressure_size = !b.pressure_size; } - crate::symmetry_ui::menu(app, ui); } // Pencil: Photoshop's options (no hardness or flow: the pencil is always hard). Tool::Pencil => { @@ -921,12 +938,21 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { brush_preset_chip(ui, b, &mut app.ui); crate::brush_picker::settings_toggle(app, ui); widgets::vline(ui, 22.0); - opt_label(ui, tl!("Size")); - widgets::value_field(ui, &mut b.size, 1.0..=2500.0, "px", 76.0); + // A Block-mode Eraser has a fixed size, opacity and flow (Photoshop, #2770). + let block = crate::eraser_ui::block_mode(app, app.ui.tool); + ui.add_enabled_ui(!block, |ui| { + opt_label(ui, tl!("Size")); + widgets::value_field(ui, &mut b.size, 1.0..=2500.0, "px", 76.0); + }); widgets::vline(ui, 22.0); - percent_field(ui, tl!("Hardness"), &mut b.hardness, 0.0..=100.0, 66.0); - percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 66.0); - percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 66.0); + if app.ui.tool == Tool::Eraser { + crate::eraser_ui::mode_dropdown(ui, &mut app.ui.tool_options.eraser_mode); + } + // A Pencil-mode Eraser is always hard and at full flow (Photoshop). + let fixed = block || crate::eraser_ui::pencil_mode(app, app.ui.tool); + ui.add_enabled_ui(!fixed, |ui| percent_field(ui, tl!("Hardness"), &mut b.hardness, 0.0..=100.0, 66.0)); + ui.add_enabled_ui(!block, |ui| percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 66.0)); + ui.add_enabled_ui(!fixed, |ui| percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 66.0)); opt_label(ui, tl!("Smoothing")); smoothing_field(ui, b, 66.0); widgets::vline(ui, 22.0); @@ -948,7 +974,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::vline(ui, 22.0); widgets::checkbox(ui, &mut b.mixer.sample_all_layers, tl!("Sample All Layers")); } - Tool::RectMarquee | Tool::EllipseMarquee if t.pro => { + Tool::RectMarquee | Tool::EllipseMarquee | Tool::SingleRowMarquee | Tool::SingleColumnMarquee if t.pro => { ui.spacing_mut().item_spacing.x = 2.0; for (i, (icon, tip)) in [ ("square", tl!("New selection").to_string()), @@ -974,16 +1000,18 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Photoshop greys Anti-alias for the Rectangular Marquee (its edges are always hard). ui.add_enabled_ui(app.ui.tool == Tool::EllipseMarquee, |ui| widgets::checkbox(ui, &mut o.anti_alias, tl!("Anti-alias"))); widgets::vline(ui, 22.0); - opt_label(ui, tl!("Style")); + // A single row or column has no style or size: Photoshop greys them. + let sized = matches!(app.ui.tool, Tool::RectMarquee | Tool::EllipseMarquee); + ui.add_enabled_ui(sized, |ui| opt_label(ui, tl!("Style"))); let styles = [ ("normal".to_string(), tl!("Normal")), ("fixedRatio".to_string(), tl!("Fixed Ratio")), ("fixedSize".to_string(), tl!("Fixed Size")), ]; - if widgets::dropdown(ui, "marquee-style", &mut o.marquee_style, &styles, 96.0) { + if ui.add_enabled_ui(sized, |ui| widgets::dropdown(ui, "marquee-style", &mut o.marquee_style, &styles, 96.0)).inner { (o.marquee_width, o.marquee_height) = if o.marquee_style == "fixedSize" { (64.0, 64.0) } else { (1.0, 1.0) }; } - let fixed = o.marquee_style != "normal"; + let fixed = sized && o.marquee_style != "normal"; let (unit, range) = if o.marquee_style == "fixedSize" { ("px", 1.0..=300_000.0) } else { ("", 0.001..=999.0) }; ui.add_enabled_ui(fixed, |ui| { opt_label(ui, tl!("Width")); @@ -1116,23 +1144,34 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::gradient_ui::options_changed(app, &before); } Tool::Crop if t.pro => { - let o = &mut app.ui.tool_options; - let ratios: Vec<(String, &str)> = crate::chrome_ui::CROP_RATIOS.iter().map(|(k, l)| (k.to_string(), *l)).collect(); - widgets::dropdown(ui, "crop-ratio", &mut o.crop_ratio, &ratios, 120.0); - // Custom ratio fields (Photoshop shows the preset's numbers here). - let (mut rw, mut rh) = crate::chrome_ui::crop_ratio(&o.crop_ratio, 0.0, 0.0).map_or((0.0, 0.0), |(w, h)| (w as f32, h as f32)); - let cw = widgets::value_field(ui, &mut rw, 0.0..=99_999.0, "", 54.0).changed(); - let swap = icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked(); - let ch = widgets::value_field(ui, &mut rh, 0.0..=99_999.0, "", 54.0).changed(); - if swap { - std::mem::swap(&mut rw, &mut rh); + let presets = crate::crop_size::dropdown_options(); + if widgets::dropdown(ui, "crop-ratio", &mut app.ui.tool_options.crop_ratio, &presets, 140.0) { + // A size preset or Front Image fills W x H x Resolution (#2443). + crate::crop_size::chosen(app); } - if (cw || ch || swap) && rw > 0.0 && rh > 0.0 { - o.crop_ratio = format!("{}:{}", widgets::fmt_num(rw as f64), widgets::fmt_num(rh as f64)); + let o = &mut app.ui.tool_options; + if o.crop_ratio == crate::crop_size::WHR { + crate::crop_size::fields(o, ui); + } else { + // Custom ratio fields (Photoshop shows the preset's numbers here). + let (mut rw, mut rh) = crate::chrome_ui::crop_ratio(&o.crop_ratio, 0.0, 0.0).map_or((0.0, 0.0), |(w, h)| (w as f32, h as f32)); + let cw = widgets::value_field(ui, &mut rw, 0.0..=99_999.0, "", 54.0).changed(); + let swap = icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked(); + let ch = widgets::value_field(ui, &mut rh, 0.0..=99_999.0, "", 54.0).changed(); + if swap { + std::mem::swap(&mut rw, &mut rh); + } + if (cw || ch || swap) && rw > 0.0 && rh > 0.0 { + o.crop_ratio = format!("{}:{}", widgets::fmt_num(rw as f64), widgets::fmt_num(rh as f64)); + } } widgets::vline(ui, 22.0); if widgets::secondary_button(ui, tl!("Clear"), 0.0).clicked() { - o.crop_ratio.clear(); + if o.crop_ratio == crate::crop_size::WHR { + crate::crop_size::clear(o); + } else { + o.crop_ratio.clear(); + } } crate::crop_straighten::options_button(&mut app.crop.straighten, ui); crate::crop_overlay::options_button(o, ui); @@ -1211,6 +1250,15 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::shortcuts::pretty("Shift+Alt") ), ), + Tool::SingleRowMarquee | Tool::SingleColumnMarquee => hint( + ui, + &format!( + "Click to select a one-pixel line · {} add · {} subtract · {} intersect", + crate::shortcuts::pretty("Shift"), + crate::shortcuts::pretty("Alt"), + crate::shortcuts::pretty("Shift+Alt") + ), + ), Tool::Move => { // The Studio themes keep Photoshop's Auto-Select toggle too (#1275). widgets::checkbox(ui, &mut app.ui.tool_options.move_auto_select, tl!("Auto-Select:")); @@ -1306,6 +1354,11 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Retouching and smart-selection tools draw their bar in `retouch_ui::options_bar`. _ => {} } + // Painting symmetry is independent of visual theme. Keep the options-bar + // action available for Brush, Pencil and Eraser in every palette (#2659). + if matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser) { + crate::symmetry_ui::menu(app, ui); + } crate::brush_panel::commit_gesture(app, ui.ctx(), &brush_before, &brush); }); // Font ink can extend beyond its advance width at fractional positions. @@ -1333,6 +1386,36 @@ fn label(ui: &mut egui::Ui, s: &str) { ui.label(RichText::new(s).color(t.text_dim)); } +/// Photoshop's scrubby Opacity/Fill labels: drag horizontally to change the percentage without +/// hitting the small number field. The unique drag ID coalesces every frame into one undo step. +fn scrub_pct_label(ui: &mut egui::Ui, text: &str, percent: &mut f32) -> widgets::PopupFieldResponse { + let t = Tokens::get(ui.ctx()); + let resp = + ui.add(egui::Label::new(RichText::new(text).color(t.text_dim)).sense(Sense::click_and_drag())).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + let key = resp.id.with("percent-label-drag"); + if resp.drag_started() { + let stamp = ui.ctx().cumulative_pass_nr(); + ui.data_mut(|d| d.insert_temp(key, (*percent, stamp))); + } + let mut out = widgets::PopupFieldResponse::default(); + if resp.dragged() + && let Some((start, stamp)) = ui.data(|d| d.get_temp::<(f32, u64)>(key)) + && let (Some(origin), Some(pos)) = (ui.input(|i| i.pointer.press_origin()), resp.interact_pointer_pos()) + { + // Match DragValue's 0.5 percentage-point per screen point; do not jump on press. + let next = (start + (pos.x - origin.x) * 0.5).clamp(0.0, 100.0); + if next.is_finite() && next != *percent { + *percent = next; + out.changed = true; + out.drag = Some(stamp); + } + } + if resp.drag_stopped() { + ui.data_mut(|d| d.remove::<(f32, u64)>(key)); + } + out +} + /// Width of `s` as [`label`] draws it (body text). fn body_text_width(ui: &egui::Ui, s: &str) -> f32 { let font = egui::TextStyle::Body.resolve(ui.style()); @@ -1610,15 +1693,18 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let Some(tex) = crate::canvas::navigator_texture(app, &ctx, idx) else { return }; let size = app.session.documents()[idx].doc.size; let avail = ui.available_width(); - let box_h = 150.0; + // The preview fills the group's height above the zoom controls (their height as measured + // last frame), so it grows when the group is made taller, as in Photoshop (#2726). + let controls_h = ui.data(|d| d.get_temp::(navigator_controls_id())).unwrap_or(48.0); + let left = ui.available_height() - controls_h; + let box_h = if left.is_finite() { left.max(40.0) } else { 150.0 }; let (frame, resp) = ui.allocate_exact_size(vec2(avail, box_h), Sense::click_and_drag()); ui.painter().rect_filled(frame, t.radius_sm, t.canvas); - let aspect = size.width as f32 / size.height.max(1) as f32; - let (w, h) = if aspect > avail / box_h { (avail - 16.0, (avail - 16.0) / aspect) } else { ((box_h - 16.0) * aspect, box_h - 16.0) }; - let rect = Rect::from_center_size(frame.center(), vec2(w, h)); + let rect = navigator_fit(frame, size.width, size.height); + ui.data_mut(|d| d.insert_temp(navigator_preview_id(), rect)); widgets::checker(ui.painter(), rect, 6.0); ui.painter().image(tex, rect, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); - let s = w / size.width as f32; + let s = rect.width() / size.width.max(1) as f32; if (resp.clicked() || resp.dragged()) && let Some(pp) = resp.interact_pointer_pos() { @@ -1626,36 +1712,131 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.ui.views[idx].center = [d.x.clamp(0.0, size.width as f32), d.y.clamp(0.0, size.height as f32)]; app.ui.views[idx].fit_pending = false; } + // The Navigator has its own zoom target: an unmodified wheel over the preview + // should zoom even when the document canvas is configured to scroll on wheel. + // Use the canvas wheel classifier to honour one ×1.1 step per notch without + // applying egui's smoothed wheel tail a second time. + if resp.hovered() + && let Some(crate::wheel_nav::Wheel::Zoom(factor)) = crate::wheel_nav::read(&ctx, true) + { + let view = &mut app.ui.views[idx]; + let next = crate::zoom_levels::clamp(view.zoom * factor, view.doc_size); + if next != view.zoom { + view.zoom = next; + view.fit_pending = false; + } + } // Visible-area rectangle. let v = app.ui.views[idx].clone(); let canvas = app.last_canvas_rect; let point_zoom = (v.zoom / app.canvas_ppp()).max(1e-6); - let vw = canvas.width() / point_zoom * s; + // Pixel aspect correction shows fewer document columns across the same canvas width. + let vw = canvas.width() / point_zoom / app.ui.view.display_aspect() * s; let vh = canvas.height() / point_zoom * s; let c = pos2(rect.min.x + v.center[0] * s, rect.min.y + v.center[1] * s); let vr = Rect::from_center_size(c, vec2(vw, vh)).intersect(frame.shrink(1.0)); ui.painter().rect_stroke(vr, 2.0, Stroke::new(1.5, Color32::from_rgb(255, 84, 84)), StrokeKind::Middle); - ui.add_space(4.0); - ui.horizontal(|ui| { - label(ui, tl!("Zoom")); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - for (lbl, z) in [("200%", 2.0), ("100%", 1.0)] { - if widgets::pill_tab(ui, lbl, (v.zoom - z).abs() < 1e-3).clicked() { - app.ui.views[idx].zoom = z; - app.ui.views[idx].fit_pending = false; + let controls = ui.vertical(|ui| { + ui.add_space(4.0); + ui.horizontal(|ui| { + label(ui, tl!("Zoom")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + for (lbl, z) in [("200%", 2.0), ("100%", 1.0)] { + if widgets::pill_tab(ui, lbl, (v.zoom - z).abs() < 1e-3).clicked() { + app.ui.views[idx].zoom = z; + app.ui.views[idx].fit_pending = false; + } } - } - if widgets::pill_tab(ui, tl!("Fit"), false).clicked() { - app.ui.views[idx].fit_pending = true; - } + if widgets::pill_tab(ui, tl!("Fit"), false).clicked() { + app.ui.views[idx].fit_pending = true; + } + }); }); + // The whole zoom range, and always the current zoom: a narrower slider would pull it back. + let (lo, hi) = (crate::zoom_levels::min(v.doc_size).min(v.zoom).log2(), crate::zoom_levels::MAX.max(v.zoom).log2()); + let mut lz = v.zoom.log2(); + if lz.is_finite() && widgets::slider(ui, &mut lz, lo..=hi, None).changed() { + app.ui.views[idx].zoom = crate::zoom_levels::clamp(2f32.powf(lz), v.doc_size); + app.ui.views[idx].fit_pending = false; + } }); - // The whole zoom range, and always the current zoom: a narrower slider would pull it back. - let (lo, hi) = (crate::zoom_levels::min(v.doc_size).min(v.zoom).log2(), crate::zoom_levels::MAX.max(v.zoom).log2()); - let mut lz = v.zoom.log2(); - if lz.is_finite() && widgets::slider(ui, &mut lz, lo..=hi, None).changed() { - app.ui.views[idx].zoom = crate::zoom_levels::clamp(2f32.powf(lz), v.doc_size); - app.ui.views[idx].fit_pending = false; + // From the preview's bottom, so the spacing above the controls counts too. + let controls = Rect::from_min_max(pos2(frame.left(), frame.bottom()), pos2(frame.right(), controls.response.rect.bottom())); + ui.data_mut(|d| { + d.insert_temp(navigator_controls_id(), controls.height()); + d.insert_temp(navigator_controls_rect_id(), controls); + }); +} + +/// The document's `w` × `h` fitted inside the Navigator's `frame` with an 8 pt inset, centred and +/// keeping its aspect. +fn navigator_fit(frame: Rect, w: u32, h: u32) -> Rect { + let inner = (frame.size() - vec2(16.0, 16.0)).max(vec2(1.0, 1.0)); + let (w, h) = (w.max(1) as f32, h.max(1) as f32); + let s = (inner.x / w).min(inner.y / h); + Rect::from_center_size(frame.center(), vec2(w * s, h * s)) +} + +fn navigator_preview_id() -> egui::Id { + egui::Id::new("navigator-preview-rect") +} + +fn navigator_controls_id() -> egui::Id { + egui::Id::new("navigator-controls-height") +} + +fn navigator_controls_rect_id() -> egui::Id { + egui::Id::new("navigator-controls-rect") +} + +/// Where the Navigator drew its document preview last frame. +#[cfg(test)] +pub(crate) fn last_navigator_preview(ctx: &egui::Context) -> Option { + ctx.data(|d| d.get_temp(navigator_preview_id())) +} + +/// The Navigator's zoom controls (below the preview) last frame. +#[cfg(test)] +pub(crate) fn last_navigator_controls(ctx: &egui::Context) -> Option { + ctx.data(|d| d.get_temp(navigator_controls_rect_id())) +} + +#[cfg(test)] +mod navigator_wheel_tests { + use super::*; + use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase}; + + fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, pointer: Pos2, wheel: f32) { + let mut events = vec![Event::PointerMoved(pointer)]; + if wheel != 0.0 { + events.push(Event::MouseWheel { unit: MouseWheelUnit::Line, delta: vec2(0.0, wheel), phase: TouchPhase::Move, modifiers: Modifiers::NONE }); + } + let mut out = ctx + .run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(430.0, 460.0))), events, ..Default::default() }, |ui| navigator(app, ui)); + out.textures_delta.clear(); + } + + #[test] + fn navigator_wheel_changes_zoom_only_when_over_preview() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + crate::wheel_nav::configure(&ctx); + frame(&mut app, &ctx, pos2(50.0, 55.0), 0.0); + let center = app.ui.views[0].center; + frame(&mut app, &ctx, pos2(50.0, 55.0), 1.0); + assert!((app.ui.views[0].zoom - 1.1).abs() < 1e-4, "one wheel notch zooms once"); + assert_eq!(app.ui.views[0].center, center, "Navigator wheel must not pan"); + frame(&mut app, &ctx, pos2(50.0, 55.0), -1.0); + assert!((app.ui.views[0].zoom - 1.0).abs() < 1e-4); + // Below the preview (it now fills the group, #2726): over the zoom controls. + let below = last_navigator_controls(&ctx).map_or(pos2(50.0, 455.0), |r| pos2(r.left() + 4.0, r.bottom() - 2.0)); + frame(&mut app, &ctx, below, 1.0); + assert!((app.ui.views[0].zoom - 1.0).abs() < 1e-4, "wheel outside preview leaves zoom unchanged"); } } @@ -1677,12 +1858,14 @@ fn color_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut s = hsva0.s * 100.0; let mut v = hsva0.v * 100.0; let hue = widgets::hue_stops(); - let mut changed = widgets::slider_row(ui, tl!("Hue"), &mut h, 0.0..=360.0, "°", Some(&hue)).changed(); + // Preferences ▸ Interface ▸ Dynamic Color Sliders: off, the tracks are the plain ones. + let dynamic = app.session.prefs().interface.dynamic_color_sliders; + let mut changed = widgets::slider_row(ui, tl!("Hue"), &mut h, 0.0..=360.0, "°", dynamic.then_some(&hue)).changed(); let sat_stops = [egui::ecolor::Hsva::new(hsva0.h, 0.0, hsva0.v.max(0.2), 1.0), egui::ecolor::Hsva::new(hsva0.h, 1.0, hsva0.v.max(0.2), 1.0)].map(Color32::from); - changed |= widgets::slider_row(ui, tl!("Saturation"), &mut s, 0.0..=100.0, "%", Some(&sat_stops)).changed(); + changed |= widgets::slider_row(ui, tl!("Saturation"), &mut s, 0.0..=100.0, "%", dynamic.then_some(&sat_stops)).changed(); let val_stops = [Color32::BLACK, Color32::from(egui::ecolor::Hsva::new(hsva0.h, hsva0.s, 1.0, 1.0))]; - changed |= widgets::slider_row(ui, tl!("Brightness"), &mut v, 0.0..=100.0, "%", Some(&val_stops)).changed(); + changed |= widgets::slider_row(ui, tl!("Brightness"), &mut v, 0.0..=100.0, "%", dynamic.then_some(&val_stops)).changed(); let hsva = egui::ecolor::Hsva::new(h / 360.0, s / 100.0, v / 100.0, 1.0); // Only an edit counts: the h/s/v round trip isn't exact, so comparing values would rewrite // the foreground (and recolour selected type) every frame. @@ -1752,6 +1935,18 @@ fn layer_drag_edge_scroll(pointer: Option, viewport: Rect, dragging: bool, direction.signum() * velocity * dt.clamp(0.0, 0.05) } +/// Fit blend mode + Opacity into the available Layers-panel width (#2309). +/// Preserve the normal translated label when there is room; on a narrow dock, +/// keep the field interactive and give the dropdown the remaining width. +fn opacity_row_layout(available: f32, label_width: f32, gap: f32) -> (f32, bool) { + let full_right = label_width + LAYER_PCT_W + 2.0 * gap + 16.0; + // The label shows while the blend dropdown keeps a usable width (at the default dock width + // with an English label it does; a long translated label on a narrow dock gives way). + let show_label = available >= full_right + 80.0; + let reserved = LAYER_PCT_W + gap + if show_label { label_width + gap + 16.0 } else { 0.0 }; + ((available - reserved).max(40.0), show_label) +} + fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // A new active layer opens its parent groups and is scrolled into view (#152). let reveal = crate::layer_reveal::track(app, ui.ctx()); @@ -1801,12 +1996,13 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.add_enabled_ui(!bg, |ui| { let mut m = l.blend; - // Leave room for the Opacity label and field: a translated label ("Непрозрачность:") - // can be much wider than the English one, and must not slide under the dropdown. + // Reserve space for the numeric field first. On a narrow dock, hide the + // redundant visible Opacity label rather than force the dropdown beyond + // the panel's right edge (#2309). The numeric field keeps its name. let opacity_label = if t.pro { tl!("Opacity:") } else { tl!("Opacity") }; - let right = (body_text_width(ui, opacity_label) + LAYER_PCT_W + 2.0 * ui.spacing().item_spacing.x + 16.0).max(150.0); - let w = ui.available_width() - right; - let (chosen, hovered) = widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &blend_options(l.is_group(), l.blend), w.max(100.0)); + let (blend_width, show_opacity_label) = + opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x); + let (chosen, hovered) = widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &blend_options(l.is_group(), l.blend), blend_width); // One step per choice: a click, an arrow key or each wheel notch (#1747). for m in &chosen { actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "blend": m.label()}))); @@ -1815,11 +2011,12 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::blend_preview::hover(app, l.id, hovered.filter(|_| chosen.is_empty())); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let mut o = l.opacity * 100.0; - let r = widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W); - if r.changed { - actions.push(pct_action(l, "opacity", o, r.drag)); + let field = widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W); + // On a narrow dock the (scrubby) label yields its room to the controls (#2309). + let scrub = if show_opacity_label { scrub_pct_label(ui, opacity_label, &mut o) } else { widgets::PopupFieldResponse::default() }; + if field.changed || scrub.changed { + actions.push(pct_action(l, "opacity", o, scrub.drag.or(field.drag))); } - label(ui, opacity_label); }); }); }); @@ -1864,11 +2061,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.add_enabled_ui(!bg, |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let mut f = l.fill_opacity * 100.0; - let r = widgets::popup_value_field(ui, fill_label, &mut f, 0.0..=100.0, "%", LAYER_PCT_W); - if r.changed { - actions.push(pct_action(l, "fill", f, r.drag)); + let field = widgets::popup_value_field(ui, fill_label, &mut f, 0.0..=100.0, "%", LAYER_PCT_W); + let scrub = scrub_pct_label(ui, fill_label, &mut f); + if field.changed || scrub.changed { + actions.push(pct_action(l, "fill", f, scrub.drag.or(field.drag))); } - label(ui, fill_label); }) }); }); @@ -1904,6 +2101,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let filter = app.ui.layer_filter.clone(); let fx_collapsed = app.session.active().map(|d| d.fx_collapsed.clone()).unwrap_or_default(); crate::layer_row_ui::begin(ui.ctx()); + // The drawn rows, top to bottom (#1721): the space between them and below the last + // one is a drop zone too. + let mut drawn: Vec<(LayerId, Rect)> = Vec::new(); for &(depth, l) in &rows { // Select › Isolate Layers. if !photocraft_engine::select_extra_cmds::isolation_shows(&doc, &isolated, l.id) { @@ -1924,7 +2124,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let row = RowSel { selected: selection.contains(&l.id), primary: active == Some(l.id), multi: selection.len() > 1 }; let top = ui.cursor().top(); - layer_row(app, &ctx, ui, &doc, l, depth, row, &mut actions); + drawn.push((l.id, layer_row(app, &ctx, ui, &doc, l, depth, row, &mut actions))); if reveal == Some(l.id) { crate::layer_reveal::scroll_to_row(ui, top); } @@ -1933,6 +2133,17 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } crate::smart_ui::filter_rows(app, ui, l, depth, &mut actions); } + // #1721: the list takes drops everywhere, not only on a row (Photoshop): the space + // between two rows lands above the lower one, the empty space below the bottom-most + // row below it. Both copy with ⌥ held, move without. + for w in drawn.windows(2) { + let gap = Rect::from_min_max(pos2(w[0].1.left(), w[0].1.bottom()), pos2(w[0].1.right(), w[1].1.top())); + layer_drop_zone(app, &ctx, ui, gap, (w[1].0, w[1].1), "above", &mut actions); + } + if let Some(&(last, last_rect)) = drawn.last() { + let below = Rect::from_min_max(pos2(last_rect.left(), last_rect.bottom()), pos2(last_rect.right(), ui.clip_rect().bottom())); + layer_drop_zone(app, &ctx, ui, below, (last, last_rect), "below", &mut actions); + } // ⌥-click the line between two layers: clip / release the upper one (#967). crate::clip_line_ui::show(ui, &doc, &mut actions); // A rename whose row is gone (deleted, filtered out, inside a closed group) ends, @@ -1983,7 +2194,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { actions.extend(footer_drop(ui, &new_layer, footer_drag, "layer.duplicate")); let group = icons::button(ui, "folder", 26.0, false, tl!("Create a new group")); if group.clicked() { - actions.push(("layer.new.group".into(), json!({}))); + // Like Photoshop a plain click adds an empty group; Shift-click groups the selected + // layers, the same as Layer › Group Layers (#2561). + let shift = ui.input(|i| i.modifiers.shift); + let cmd = if shift && active_layer.is_some() { "layer.groupLayers" } else { "layer.new.group" }; + actions.push((cmd.into(), json!({}))); } actions.extend(footer_drop(ui, &group, footer_drag, "layer.groupLayers")); let adj = footer_menu_button(ui, "adjustment-layer", 26.0, tl!("Create new fill or adjustment layer")); @@ -2042,11 +2257,13 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if id == "ui.maskTarget" { app.ui.mask_target = p.as_bool().unwrap_or(false); app.ui.vector_mask_target = false; + app.sync_mask_targets(); continue; } if id == "ui.vectorMaskTarget" { app.ui.vector_mask_target = p.as_bool().unwrap_or(false); app.ui.mask_target = false; + app.sync_mask_targets(); continue; } if p.is_null() { @@ -2192,7 +2409,7 @@ fn layer_row( depth: usize, row: RowSel, actions: &mut Vec<(String, Value)>, -) { +) -> Rect { let selected = row.selected; let t = Tokens::get(ctx); // Photoshop's default (medium) thumbnails: 32 pt rows. @@ -2209,7 +2426,7 @@ fn layer_row( // otherwise lay out names, icons and thumbnails every frame. Group rows stay whole: their // disclosure triangle is a widget (accessibility, scroll-to). if !ui.is_rect_visible(rect) && !l.is_group() && !resp.context_menu_opened() && crate::layer_row_ui::renaming(ctx) != Some(l.id.0) { - return; + return rect; } let painter = ui.painter_at(rect.expand(1.0)); if t.pro { @@ -2404,6 +2621,7 @@ fn layer_row( } // Right-click context menu. resp.context_menu(|ui| { + crate::widgets::style_spectrum_popup_menu(ui); // Right-clicking inside a multi-selection keeps it and acts on every selected layer. let on_set = row.multi && selected; if crate::layer_menu_ui::show(app, ui, l, on_set, actions) @@ -2412,6 +2630,7 @@ fn layer_row( actions.push(done); } }); + rect } /// Screen rect and UVs for a layer thumbnail: the document-shaped part of the square cell and of @@ -2834,6 +3053,18 @@ fn field_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, chips: Rect) { } } +/// Where the dock tells the Color field how tall its group body is. +pub(crate) fn color_field_fill_id() -> egui::Id { + egui::Id::new("color-field-fill-height") +} + +/// Height of the Color field: the group body's height (when docked) less the hex/RGB readout, +/// which folds onto a second line in a narrow dock. 120 pt when nothing sets a height. +fn color_field_height(avail: Option, width: f32) -> f32 { + let reserve = 32.0 + if width < 280.0 { 26.0 } else { 0.0 }; + avail.filter(|a| a.is_finite()).map_or(120.0, |a| (a - reserve).clamp(120.0, 2000.0)) +} + /// Photoshop Color panel: saturation/brightness field + hue strip, drawn as shaded meshes. fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); @@ -2847,7 +3078,9 @@ fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let w = ui.available_width(); let strip_w = 14.0; - let h = 120.0; + // Photoshop's Color panel field fills the panel: take the height the dock offers, less the + // hex/RGB readout (which folds onto a second line in a narrow dock). Without a dock, 120 pt. + let h = color_field_height(ui.data(|d| d.get_temp::(color_field_fill_id())), w); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 8.0; let (chips, _) = ui.allocate_exact_size(vec2(38.0, h), Sense::hover()); @@ -3083,6 +3316,15 @@ fn layer_drop_payload(dragged: u64, target: LayerId, position: &str, selected: & } } +/// Would this drop move the Background off the bottom, or put a layer below it? Photoshop +/// refuses both (no drop indicator); ⌥-dragging the Background still copies it above. +fn background_drop_refused(doc: &photocraft_doc::Document, payload: &Value, target: LayerId, position: &str, copy: bool) -> bool { + let Some(bg) = doc.layers.first().filter(|b| crate::doc_props_ui::is_background(doc, b)).map(|b| b.id) else { return false }; + let moves_bg = + !copy && (payload["layer"].as_u64() == Some(bg.0) || payload["layers"].as_array().is_some_and(|a| a.iter().any(|v| v.as_u64() == Some(bg.0)))); + moves_bg || (target == bg && position == "below") +} + /// Drag a layer row to reorder: drop above, below, or inside an existing group. With ⌥ held on /// release the layers stay put and copies land there instead (Photoshop). /// Multi-layer moves are atomic (one undo step), using the engine's stable document order. @@ -3112,6 +3354,31 @@ fn layer_drag_and_drop( if copy { ctx.set_cursor_icon(egui::CursorIcon::Copy); } + // #1721: with ⌥ held the row also takes the drop onto itself — the only drop target a + // single-layer document has. The copy lands above or below it (never into a group's own + // centre). A multi-selection is left alone: the engine copies it beside each of its own + // layers, not onto one of them. + let selected = app.session.active().map(|st| st.selected_layers()).unwrap_or_default(); + let in_multi = selected.len() > 1 && selected.contains(&l.id); + if copy + && !in_multi + && let Some(p) = pointer.filter(|p| rect.contains(*p)) + { + let position = if (p.y - rect.top()) / rect.height() < 0.5 { "above" } else { "below" }; + let painter = ui.painter(); + if position == "above" { + painter.line_segment([rect.left_top(), rect.right_top()], Stroke::new(2.0, t.accent)); + } else { + painter.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(2.0, t.accent)); + } + if released { + let mut payload = layer_drop_payload(dragged, l.id, position, &selected); + if let Some(o) = payload.as_object_mut() { + o.insert("copy".into(), json!(true)); + } + actions.push(("layer.moveTo".into(), payload)); + } + } return; } let Some(p) = pointer else { return }; @@ -3126,6 +3393,11 @@ fn layer_drag_and_drop( } else { "below" }; + let Some(st) = app.session.active() else { return }; + let mut payload = layer_drop_payload(dragged, l.id, position, &st.selected_layers()); + if background_drop_refused(&st.doc, &payload, l.id, position, copy) { + return; + } let painter = ui.painter(); match position { "into" => { @@ -3139,8 +3411,6 @@ fn layer_drag_and_drop( } } if released { - let selected = app.session.active().map(|st| st.selected_layers()).unwrap_or_default(); - let mut payload = layer_drop_payload(dragged, l.id, position, &selected); if copy && let Some(o) = payload.as_object_mut() { o.insert("copy".into(), json!(true)); } @@ -3148,6 +3418,42 @@ fn layer_drag_and_drop( } } +/// #1721: the drop zones no row owns — the space between two rows and the empty space below the +/// bottom-most one. The drop lands on `target` at `position` (`above` or `below` its row): a copy +/// with ⌥ held on release (Photoshop), a move without. A multi-selection dropped on the row it +/// was grabbed from is left alone: the engine can only copy it beside each of its own layers. +fn layer_drop_zone( + app: &PhotocraftApp, + ctx: &egui::Context, + ui: &egui::Ui, + zone: Rect, + target: (LayerId, Rect), + position: &'static str, + actions: &mut Vec<(String, Value)>, +) { + let Some(dragged) = ctx.data(|d| d.get_temp::(egui::Id::new("layer-drag"))) else { return }; + let selected = app.session.active().map(|st| st.selected_layers()).unwrap_or_default(); + if target.0 == LayerId(dragged) && selected.len() > 1 && selected.contains(&target.0) { + return; + } + let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + if !zone.contains(p) { + return; + } + let t = Tokens::get(ctx); + let line = if position == "above" { [target.1.left_top(), target.1.right_top()] } else { [target.1.left_bottom(), target.1.right_bottom()] }; + ui.painter().line_segment(line, Stroke::new(2.0, t.accent)); + if ctx.input(|i| i.pointer.any_released()) { + let mut payload = layer_drop_payload(dragged, target.0, position, &selected); + if ctx.input(|i| i.modifiers.alt) + && let Some(o) = payload.as_object_mut() + { + o.insert("copy".into(), json!(true)); + } + actions.push(("layer.moveTo".into(), payload)); + } +} + /// An fx row being dragged: the layer it belongs to and the effect index (`None` = the "Effects" /// row, all of them). type FxDrag = (u64, Option); @@ -3281,6 +3587,27 @@ fn selection_mode_buttons(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.x = 8.0; } +#[cfg(test)] +mod symmetry_theme_tests { + use super::*; + use egui_kittest::{Harness, kittest::Queryable}; + + #[test] + fn brush_pencil_and_eraser_expose_symmetry_menu_in_every_theme() { + for kind in crate::theme::ThemeKind::ALL { + for tool in [Tool::Brush, Tool::Pencil, Tool::Eraser] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 80, "height": 80})).unwrap(); + app.ui.tool = tool; + let mut h = Harness::builder().with_size(vec2(1600.0, 300.0)).build_ui_state(|ui, app| options_bar(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(4); + assert!(h.query_by_label("Set painting symmetry options").is_some(), "missing symmetry menu in {:?} for {:?}", kind, tool); + } + } + } +} + #[cfg(test)] mod color_tests { use super::*; @@ -3466,6 +3793,17 @@ mod color_tests { assert_eq!(srgb_bytes(h.state().session.tools.foreground), [0, 255, 0]); } + /// The Color field fills the dock group, never shrinks below 120 pt, and ignores a bad height. + #[test] + fn color_field_height_fills_the_group() { + assert_eq!(color_field_height(None, 300.0), 120.0); + assert_eq!(color_field_height(Some(500.0), 300.0), 468.0); + assert_eq!(color_field_height(Some(500.0), 200.0), 442.0); + assert_eq!(color_field_height(Some(60.0), 300.0), 120.0); + assert_eq!(color_field_height(Some(f32::NAN), 300.0), 120.0); + assert_eq!(color_field_height(Some(1.0e9), 300.0), 2000.0); + } + fn field_harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> { // 60 fps steps, so two clicks a frame apart are a double-click. let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 200.0)).with_step_dt(1.0 / 60.0).build_ui_state( @@ -3956,16 +4294,20 @@ mod toolbar_tests { } } - /// The Hand tool's toolbar button: tool buttons have no label, so find it by slot order. - fn hand_button(h: &egui_kittest::Harness<'_, PhotocraftApp>) -> egui::Pos2 { + /// A tool's toolbar button: tool buttons have no label, so find it by slot order. + fn tool_button(h: &egui_kittest::Harness<'_, PhotocraftApp>, tool: Tool) -> egui::Pos2 { let size = egui::Vec2::splat(if Tokens::get(&h.ctx).pro { 30.0 } else { 36.0 }); let buttons: Vec = h.ctx.viewport(|v| { v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size && w.sense.senses_click()).map(|w| w.rect).collect() }); - let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Hand)).unwrap(); + let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&tool)).unwrap(); buttons[index].center() } + fn hand_button(h: &egui_kittest::Harness<'_, PhotocraftApp>) -> egui::Pos2 { + tool_button(h, Tool::Hand) + } + fn toolbar_harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> { // 60 fps steps, so two clicks a few frames apart are a double-click. let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 1400.0)).with_step_dt(1.0 / 60.0).build_ui_state( @@ -4014,6 +4356,41 @@ mod toolbar_tests { assert_eq!(h.state().ui.tool, Tool::Hand); assert!(h.state().session.active().is_none()); } + + /// Double-clicking the Zoom tool zooms to 100% (Photoshop); a single click only picks the tool. + #[test] + fn double_clicking_the_zoom_tool_zooms_to_actual_pixels() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.ui.tool = Tool::Move; + app.ui.views[0].fit_pending = true; + app.ui.views[0].zoom = 0.25; + let mut h = toolbar_harness(app); + let p = tool_button(&h, Tool::Zoom); + click(&mut h, p); + assert_eq!(h.state().ui.tool, Tool::Zoom); + assert_eq!(h.state().ui.views[0].zoom, 0.25, "a single click doesn't zoom"); + // Past the double-click window, so the next two clicks are a fresh double-click. + h.run_steps(40); + click(&mut h, p); + click(&mut h, p); + assert_eq!(h.state().ui.tool, Tool::Zoom); + assert_eq!(h.state().ui.views[0].zoom, 1.0, "a double-click zooms to 100%"); + assert!(!h.state().ui.views[0].fit_pending, "100% replaces a pending fit"); + } + + /// With no document open the double-click still picks the tool and doesn't panic. + #[test] + fn double_clicking_the_zoom_tool_without_a_document_is_harmless() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.tool = Tool::Move; + let mut h = toolbar_harness(app); + let p = tool_button(&h, Tool::Zoom); + click(&mut h, p); + click(&mut h, p); + assert_eq!(h.state().ui.tool, Tool::Zoom); + assert!(h.state().session.active().is_none()); + } } #[cfg(test)] @@ -4153,6 +4530,31 @@ mod group_drag_selection_tests { assert_eq!(layer_drop_payload(b.0, group, "below", &[a, b])["position"], "below"); } + #[test] + fn the_background_row_neither_moves_nor_takes_a_layer_below_it() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let bg = s.active().unwrap().doc.layers[0].id; + let a = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()); + let doc = &s.active().unwrap().doc; + let refused = |dragged: LayerId, target: LayerId, pos: &str, sel: &[LayerId], copy: bool| { + background_drop_refused(doc, &layer_drop_payload(dragged.0, target, pos, sel), target, pos, copy) + }; + assert!(refused(bg, a, "above", &[bg], false)); + assert!(refused(a, bg, "below", &[a], false)); + assert!(refused(a, bg, "below", &[a], true)); + assert!(refused(a, a, "above", &[bg, a], false), "the Background is among the dragged layers"); + assert!(!refused(a, bg, "above", &[a], false)); + assert!(!refused(bg, a, "above", &[bg], true), "⌥-dragging copies the Background above"); + // Without a Background every drop is fine. + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.new.layerFromBackground", json!({})).unwrap(); + let l0 = s.active().unwrap().doc.layers[0].id; + let doc = &s.active().unwrap().doc; + assert!(!background_drop_refused(doc, &layer_drop_payload(l0.0, a, "above", &[l0]), a, "above", false)); + } + #[test] fn dragging_unselected_or_singular_row_remains_a_single_layer_move() { let a = LayerId(10); @@ -4173,6 +4575,29 @@ mod group_drag_selection_tests { } } +#[cfg(test)] +mod opacity_row_layout_tests { + use super::*; + + #[test] + fn opacity_label_yields_to_controls_in_narrow_layers_panel() { + let gap = 2.0; + let label = 62.0; + let (wide, visible) = opacity_row_layout(360.0, label, gap); + assert!(visible); + assert!(wide >= 100.0); + // The default dock width keeps the English label (the Layers dock at its 250 px minimum). + assert!(opacity_row_layout(255.0, 46.0, 8.0).1); + let (medium, visible) = opacity_row_layout(180.0, label, gap); + assert!(!visible, "the long label must not overlap the blend field"); + assert!(medium + LAYER_PCT_W + gap <= 180.0); + let (narrow, visible) = opacity_row_layout(135.0, label, gap); + assert!(!visible); + assert!(narrow >= 40.0); + assert!(narrow + LAYER_PCT_W + gap <= 135.0); + } +} + #[cfg(test)] #[path = "mask_props_ui_tests.rs"] mod mask_properties_tests; diff --git a/crates/ui-egui/src/parity.rs b/crates/ui-egui/src/parity.rs index 36e59c4d48..dedd0e810a 100644 --- a/crates/ui-egui/src/parity.rs +++ b/crates/ui-egui/src/parity.rs @@ -1,13 +1,13 @@ //! Photoshop menu parity: how much of [`crate::menu_catalog::CATALOG`] is live. //! -//! `cargo xtask parity` prints this report and writes `docs/parity.md`. A test keeps the live +//! `cargo xtask parity` prints this report and writes `docs/parity-checklist.md`. A test keeps the live //! count from regressing below [`FLOOR`]. use crate::menu_catalog::CATALOG; use crate::menus::is_live; /// Minimum number of live catalog items. Raise it when parity grows; never lower it. -pub const FLOOR: usize = 627; +pub const FLOOR: usize = 630; /// One top-level menu's coverage. #[derive(Clone, Debug, serde::Serialize)] @@ -31,7 +31,7 @@ impl Parity { if self.total == 0 { 0.0 } else { 100.0 * self.live as f64 / self.total as f64 } } - /// The report as Markdown (the body of `docs/parity.md`). + /// The report as Markdown (the body of `docs/parity-checklist.md`). pub fn to_markdown(&self) -> String { let mut s = String::new(); s.push_str("# Photoshop menu parity\n\n"); diff --git a/crates/ui-egui/src/pencil_tests.rs b/crates/ui-egui/src/pencil_tests.rs index 53cce12173..19255962d9 100644 --- a/crates/ui-egui/src/pencil_tests.rs +++ b/crates/ui-egui/src/pencil_tests.rs @@ -6,7 +6,7 @@ use egui_kittest::Harness; use serde_json::json; use crate::PhotocraftApp; -use crate::canvas::{ToolEvent, ViewXform, pencil_cursor_rect, tool_event}; +use crate::canvas::{ToolEvent, ViewXform, pencil_cursor_rect, pencil_tip_is_round, tool_event}; use crate::state::Tool; fn app() -> PhotocraftApp { @@ -114,7 +114,7 @@ fn ctrl_alt_drag_resizes_the_pencil_without_painting() { #[test] fn square_cursor_sits_on_the_pixel_grid() { let rect = Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(800.0, 600.0)); - let xf = ViewXform { rect, zoom: 8.0, center: [50.0, 37.5], flip: false, rotation: 0.0 }; + let xf = ViewXform { rect, zoom: 8.0, center: [50.0, 37.5], flip: false, rotation: 0.0, aspect: 1.0 }; // 1 px at 800 %: the 8-point square of the pixel under the pointer. let r = pencil_cursor_rect(&xf, [10.3, 5.7], 1.0, 1.0); assert_eq!(r, Rect::from_two_pos(xf.to_screen(10.0, 5.0), xf.to_screen(11.0, 6.0))); @@ -126,7 +126,14 @@ fn square_cursor_sits_on_the_pixel_grid() { assert_eq!(r, Rect::from_two_pos(xf.to_screen(9.0, 4.0), xf.to_screen(12.0, 7.0))); // At 2× and at an odd zoom the edges land on physical pixels. for (zoom, ppp) in [(3.3, 2.0), (0.5, 2.0), (1.0, 1.0), (13.7, 1.5)] { - let xf = ViewXform { rect: Rect::from_min_size(Pos2::new(0.3, 0.7), vec2(800.0, 600.0)), zoom, center: [50.2, 37.9], flip: true, rotation: 0.0 }; + let xf = ViewXform { + rect: Rect::from_min_size(Pos2::new(0.3, 0.7), vec2(800.0, 600.0)), + zoom, + center: [50.2, 37.9], + flip: true, + rotation: 0.0, + aspect: 1.0, + }; let r = pencil_cursor_rect(&xf, [20.4, 30.6], 5.0, ppp); for v in [r.min.x, r.min.y, r.max.x, r.max.y] { assert!((v * ppp - (v * ppp).round()).abs() < 1e-3, "zoom {zoom} ppp {ppp}: {v}"); @@ -137,6 +144,76 @@ fn square_cursor_sits_on_the_pixel_grid() { let _ = pencil_cursor_rect(&xf, [f64::NAN, f64::INFINITY], f32::NAN, f32::NAN); } +#[test] +fn the_cursor_is_round_exactly_when_the_pencil_dab_is() { + // #2662: one dab per size; the cursor is the square only while the dab fills its corners. + let mut app = app(); + for n in 1..=12u32 { + let (x, y) = (8.0 + f64::from((n - 1) % 6) * 18.0 + 0.3, 15.0 + f64::from((n - 1) / 6) * 30.0 + 0.6); + app.run("paint.pencil", json!({"points": [[x, y]], "size": n})).unwrap(); + let [x0, y0, ..] = photocraft_engine::paint::grid_square(x, y, n as f32); + let corner = rgba(&app, x0 as i32, y0 as i32)[3] == 1.0; + assert_eq!(pencil_tip_is_round(n as f32), !corner, "{n} px"); + } + assert!(pencil_tip_is_round(9.0) && !pencil_tip_is_round(3.0)); + assert!(!pencil_tip_is_round(f32::NAN)); +} + +#[test] +fn the_eraser_in_pencil_mode_erases_aliased_pixels() { + // #2662: an opaque layer, then a soft Eraser in Pencil mode erases with hard edges. + let mut app = app(); + app.run("paint.pencil", json!({"points": [[60.0, 40.0]], "size": 400})).unwrap(); + app.ui.tool = Tool::Eraser; + crate::paint_mouse::sync_tool_brush(&mut app); + app.run("tools.setBrush", json!({"brush": {"size": 15, "hardness": 0.0, "smoothing": {"amount": 0.0}}})).unwrap(); + app.ui.tool_options.eraser_mode = "pencil".into(); + drag(&mut app, &[(10.0, 40.0), (60.3, 25.7), (110.0, 40.0)], Modifiers::NONE); + let (id, p) = last(&app); + assert_eq!((id.as_str(), &p["erase"]), ("paint.pencil", &json!(true))); + let s = layer(&app).surface().unwrap().clone(); + let mut erased = 0; + for y in 0..80 { + for x in 0..120 { + let a = s.rgba(x, y)[3]; + assert!(a == 0.0 || a == 1.0, "partial alpha {a} at ({x},{y})"); + erased += usize::from(a == 0.0); + } + } + assert!(erased > 500, "{erased}"); +} + +#[test] +fn the_eraser_in_block_mode_erases_a_16_screen_px_square() { + // #2770: a soft, small, half-opacity Eraser in Block mode still clears a hard square of + // 16 screen pixels: 16 document pixels at 100 %, 8 at 200 %. + for (zoom, n) in [(1.0, 16usize), (2.0, 8)] { + let mut app = app(); + app.run("paint.pencil", json!({"points": [[60.0, 40.0]], "size": 400})).unwrap(); + app.ui.tool = Tool::Eraser; + crate::paint_mouse::sync_tool_brush(&mut app); + app.run("tools.setBrush", json!({"brush": {"size": 5, "hardness": 0.0, "opacity": 0.5, "flow": 0.3, "smoothing": {"amount": 0.0}}})).unwrap(); + app.ui.tool_options.eraser_mode = "block".into(); + app.ui.views[0].zoom = zoom; + drag(&mut app, &[(60.3, 40.6)], Modifiers::NONE); + let (id, p) = last(&app); + assert_eq!((id.as_str(), &p["block"]), ("paint.pencil", &json!(true))); + let s = layer(&app).surface().unwrap().clone(); + let (mut erased, mut x0, mut y0, mut x1, mut y1) = (0, i32::MAX, i32::MAX, 0, 0); + for y in 0..80 { + for x in 0..120 { + let a = s.rgba(x, y)[3]; + assert!(a == 0.0 || a == 1.0, "partial alpha {a} at ({x},{y}), zoom {zoom}"); + if a == 0.0 { + erased += 1; + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + } + } + } + assert_eq!((erased, x1 - x0 + 1, y1 - y0 + 1), (n * n, n as i32, n as i32), "zoom {zoom}"); + } +} + #[test] fn b_cycles_brush_pencil_and_mixer_brush() { let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(|cc| { diff --git a/crates/ui-egui/src/pixel_aspect_tests.rs b/crates/ui-egui/src/pixel_aspect_tests.rs new file mode 100644 index 0000000000..947ebf361d --- /dev/null +++ b/crates/ui-egui/src/pixel_aspect_tests.rs @@ -0,0 +1,144 @@ +//! View › Pixel Aspect Ratio (#2594): with correction on, the canvas shows each document pixel +//! that many times wider, and the pointer still lands on the pixel drawn under it. The document's +//! pixels never change. + +use egui::{Modifiers, PointerButton, Pos2, Rect, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::state::Tool; + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap(); + app.run("tools.setColors", json!({"foreground": "#000000"})).unwrap(); + app.ui.tool = Tool::Pencil; + crate::paint_mouse::sync_tool_brush(&mut app); + app.run("tools.setBrush", json!({"brush": {"size": 1, "smoothing": {"amount": 0.0}}})).unwrap(); + app.sync_views(); + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + h +} + +fn menu(h: &mut Harness<'static, PhotocraftApp>, id: &str, params: serde_json::Value) { + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, id, params).unwrap(); + h.run_steps(3); +} + +/// The image's screen rectangle, as the canvas draws it. +fn image_rect(h: &Harness<'static, PhotocraftApp>) -> Rect { + let app = h.state(); + let doc = &app.session.active().unwrap().doc; + ViewXform::active(app).unwrap().doc_rect(doc.bounds()) +} + +fn click(h: &mut Harness<'static, PhotocraftApp>, pos: Pos2) { + h.event(egui::Event::PointerMoved(pos)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(2); + } +} + +fn alpha(h: &Harness<'static, PhotocraftApp>, x: i32, y: i32) -> f32 { + let st = h.state().session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y)[3] +} + +#[test] +fn the_mapping_stretches_document_x_and_inverts_exactly() { + let rect = Rect::from_min_size(Pos2::new(10.0, 20.0), vec2(800.0, 600.0)); + let square = ViewXform { rect, zoom: 1.5, center: [100.0, 50.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let wide = ViewXform { aspect: 2.0, ..square }; + let r = wide.doc_rect(photocraft_geom::Rect::new(0, 0, 200, 100)); + assert!((r.width() - 600.0).abs() < 1e-3 && (r.height() - 150.0).abs() < 1e-3, "{r:?}"); + assert_eq!(r.center(), square.doc_rect(photocraft_geom::Rect::new(0, 0, 200, 100)).center()); + assert!((wide.zoom_x() - 3.0).abs() < 1e-6); + for flip in [false, true] { + for rotation in [0.0, 30.0, -90.0] { + for aspect in [0.91, 1.0, 2.0] { + let xf = ViewXform { flip, rotation, aspect, ..square }; + let p = xf.to_screen(37.0, 81.0); + let d = xf.to_doc(p); + assert!((d[0] - 37.0).abs() < 1e-3 && (d[1] - 81.0).abs() < 1e-3, "{flip} {rotation} {aspect}: {d:?}"); + // Pan and zoom-about deltas agree with the point mapping. + let q = xf.to_screen(40.0, 85.0); + let u = xf.unmap_vec(q - p) / xf.zoom; + assert!((u.x - 3.0).abs() < 1e-3 && (u.y - 4.0).abs() < 1e-3, "{flip} {rotation} {aspect}: {u:?}"); + } + } + } + // A hostile ratio draws square instead of collapsing the image. + for aspect in [0.0, -1.0, f32::NAN, f32::INFINITY] { + let xf = ViewXform { aspect, ..square }; + assert_eq!(xf.to_screen(5.0, 5.0), square.to_screen(5.0, 5.0)); + } +} + +#[test] +fn anamorphic_ratio_draws_the_canvas_twice_as_wide_and_correction_off_restores_it() { + let mut h = harness(); + let square = image_rect(&h); + assert!((square.width() / square.height() - 2.0).abs() < 0.02, "{square:?}"); + menu(&mut h, "view.pixelAspectRatio.anamorphic2To1", json!({})); + assert!(h.state().ui.view.pixel_aspect_correction, "correction is on by default, as in Photoshop"); + let wide = image_rect(&h); + assert!((wide.width() / wide.height() - 4.0).abs() < 0.02, "{wide:?}"); + assert!((wide.height() - square.height()).abs() < 0.5, "only the width changes: {square:?} {wide:?}"); + // The document itself keeps its pixels. + let doc = &h.state().session.active().unwrap().doc; + assert_eq!([doc.size.width, doc.size.height], [200, 100]); + // View › Pixel Aspect Ratio Correction off: square pixels again. + menu(&mut h, "view.pixelAspectRatioCorrection", json!({})); + assert!(!h.state().ui.view.pixel_aspect_correction); + let off = image_rect(&h); + assert!((off.width() / off.height() - 2.0).abs() < 0.02, "{off:?}"); +} + +#[test] +fn a_click_paints_the_pixel_drawn_under_the_pointer() { + let mut h = harness(); + menu(&mut h, "view.pixelAspectRatio.anamorphic2To1", json!({})); + let xf = ViewXform::active(h.state()).unwrap(); + let at = xf.to_screen(120.5, 30.5); + click(&mut h, at); + assert_eq!(alpha(&h, 120, 30), 1.0, "painted under the pointer"); + // A square mapping of the same screen point is a different pixel, left blank. + let square = ViewXform { aspect: 1.0, ..xf }.to_doc(at); + let (sx, sy) = (square[0].floor() as i32, square[1].floor() as i32); + assert_ne!((sx, sy), (120, 30)); + assert_eq!(alpha(&h, sx, sy), 0.0); +} + +#[test] +fn fit_and_fill_on_screen_use_the_stretched_width() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1000, "height": 500})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + // Fit keeps 20 points of margin per side: 1000 points across for 1000 px wide, 2000 shown. + let area = vec2(1040.0, 800.0); + let mut view = crate::state::View::default(); + crate::canvas::fit_view(&mut view, &doc, area, 1.0, 1.0); + assert_eq!(view.zoom, 1.0); + crate::canvas::fit_view(&mut view, &doc, area, 1.0, 2.0); + assert_eq!(view.zoom, 0.5); + assert_eq!(view.center, [500.0, 250.0]); + // Fill: the stretched image is wider than tall, so the height fills. + crate::canvas::fill_view(&mut view, &doc, vec2(1000.0, 1000.0), 1.0, 2.0); + assert_eq!(view.zoom, 2.0); +} diff --git a/crates/ui-egui/src/plugin_ui.rs b/crates/ui-egui/src/plugin_ui.rs index 7b15312c82..2b3a7f88c4 100644 --- a/crates/ui-egui/src/plugin_ui.rs +++ b/crates/ui-egui/src/plugin_ui.rs @@ -13,7 +13,11 @@ const PREFIX: &str = "plugin.filter."; pub fn insert_menu_items(app: &PhotocraftApp, items: &mut Vec) { let plugins = photocraft_plugins::registry::list(); let Some(at) = items.iter().position(|i| i.id == "plugin.install") else { return }; - let path: Vec = vec![tl!("Filter").into(), tl!("Plug-ins").into()]; + // `MenuItem::path` is the untranslated English path from the command's engine spec: the menu + // renderer groups rows by it and translates only at display time. Translating it here left the + // plug-in rows under a path no menu node matched, so under any non-English language they were + // never drawn (#2540). Borrow the path of "Install Plug-in…", the item we sit above. + let Some(path) = items.get(at).map(|i| i.path.clone()) else { return }; let enabled = app.session.is_enabled("plugin.run"); let mut extra: Vec = plugins .iter() @@ -93,4 +97,23 @@ mod tests { let r = crate::menus::invoke(&mut app, &ctx, "plugin.install", json!({})).unwrap(); assert!(r.get("dialog").is_some()); } + + /// Plug-in rows carry the untranslated English menu path like every other item, so they still + /// land under Filter › Plug-ins when the UI language translates those menu names (#2540). + #[test] + fn plugin_menu_path_stays_untranslated_in_other_languages() { + let ja = crate::i18n::Lang::from_code("ja").expect("registered language"); + // The names on the path must really differ from the English, or the test proves nothing. + assert_ne!(crate::i18n::tr(ja, "Filter"), "Filter"); + assert_ne!(crate::i18n::tr(ja, "Plug-ins"), "Plug-ins"); + let _lang = crate::i18n::language_scope(ja); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../plugins/tests/fixtures/invert.wasm"); + app.run("plugin.install", json!({"path": path})).unwrap(); + let items = crate::menus::menu_items(&app); + let me = items.iter().find(|i| i.id == "plugin.filter.org.photocraft.example.invert").expect("plug-in listed"); + assert_eq!(me.path, ["Filter", "Plug-ins"], "plug-in row sits where the menu renderer looks for it"); + let install = items.iter().find(|i| i.id == "plugin.install").expect("Install Plug-in… listed"); + assert_eq!(me.path, install.path, "same submenu as Install Plug-in…"); + } } diff --git a/crates/ui-egui/src/polygon_lasso_tests.rs b/crates/ui-egui/src/polygon_lasso_tests.rs index 2dbfd0605f..ed46a7e50b 100644 --- a/crates/ui-egui/src/polygon_lasso_tests.rs +++ b/crates/ui-egui/src/polygon_lasso_tests.rs @@ -49,6 +49,7 @@ fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation, + aspect: app.ui.view.display_aspect(), }; xf.to_screen(x, y) } @@ -344,3 +345,37 @@ fn alt_drags_draw_freehand_and_releasing_alt_keeps_the_polygon_open() { crate::canvas::commit_polygon(&mut app); assert!(app.session.active().unwrap().doc.selection.as_ref().unwrap().sample_channel(110, 100, 0) > 0.5); } + +#[test] +fn shift_constrains_polygonal_lasso_segments_but_unmodified_clicks_remain_free() { + let mut app = polygon_app(); + ev(&mut app, "down", 100.0, 100.0, Modifiers::NONE); + ev(&mut app, "up", 100.0, 100.0, Modifiers::NONE); + assert_eq!(app.ui.polygon, vec![[100.0, 100.0]]); + + // Near a diagonal: Shift constrains to exactly 45° from the previous fixed point. + ev(&mut app, "down", 140.0, 120.0, Modifiers::SHIFT); + ev(&mut app, "up", 140.0, 120.0, Modifiers::SHIFT); + let diagonal = app.ui.polygon[1]; + assert!((diagonal[0] - diagonal[1]).abs() < 1e-8, "45-degree direction: {diagonal:?}"); + assert!((diagonal[0] - 100.0 - 40.0_f64.hypot(20.0) / 2.0_f64.sqrt()).abs() < 1e-8); + // Releasing Shift restores free placement rather than constraining subsequent points. + ev(&mut app, "down", 155.0, 175.0, Modifiers::NONE); + ev(&mut app, "up", 155.0, 175.0, Modifiers::NONE); + assert_eq!(app.ui.polygon[2], [155.0, 175.0]); +} + +#[test] +fn shift_polygonal_lasso_snap_has_all_eight_directions_and_handles_zero_length() { + let origin = [200.0, 150.0]; + let directions = [[40.0, 1.0], [40.0, 25.0], [1.0, 40.0], [-40.0, 25.0], [-40.0, -1.0], [-40.0, -25.0], [-1.0, -40.0], [40.0, -25.0]]; + for delta in directions { + let end = crate::canvas::constrained_polygon_point(origin, [origin[0] + delta[0], origin[1] + delta[1]]); + let dx = end[0] - origin[0]; + let dy = end[1] - origin[1]; + let angle = dy.atan2(dx) / std::f64::consts::FRAC_PI_4; + assert!((angle - angle.round()).abs() < 1e-9, "{delta:?} → {end:?}"); + assert!((dx.hypot(dy) - delta[0].hypot(delta[1])).abs() < 1e-8); + } + assert_eq!(crate::canvas::constrained_polygon_point(origin, origin), origin); +} diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index ee0bb22f83..460fba699f 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -90,6 +90,7 @@ fn selected_theme(interface: &prefs::Interface, system: Option) -> LightTheme::StudioLight => ThemeKind::StudioLight, LightTheme::Classic => ThemeKind::Classic, LightTheme::Adwaita => ThemeKind::Adwaita, + LightTheme::BreezeLight => ThemeKind::BreezeLight, } } else { match interface.dark_theme { @@ -98,6 +99,7 @@ fn selected_theme(interface: &prefs::Interface, system: Option) -> DarkTheme::Studio => ThemeKind::Studio, DarkTheme::SolarizedDark => ThemeKind::SolarizedDark, DarkTheme::AdwaitaDark => ThemeKind::AdwaitaDark, + DarkTheme::BreezeDark => ThemeKind::BreezeDark, } } } @@ -933,7 +935,7 @@ fn choice_label(v: &str) -> String { match v { "cm" => "Centimeters".into(), "mm" => "Millimeters".into(), - "75" | "100" | "125" | "150" | "175" | "200" | "250" | "300" => format!("{v}%"), + "75" | "80" | "85" | "90" | "95" | "100" | "125" | "150" | "175" | "200" | "250" | "300" => format!("{v}%"), "8" => "8 Bits/Channel".into(), "16" => "16 Bits/Channel".into(), "postScript" => "PostScript (72 points/inch)".into(), @@ -970,6 +972,29 @@ fn color_of(s: &str) -> Color32 { prefs::parse_hex(s).map_or(Color32::GRAY, |c| Color32::from_rgb(c[0], c[1], c[2])) } +/// Search the same visible, editable preference labels used in the dialog. +fn preference_matches(section: &str, key: &str, query: &str) -> bool { + let label = humanize(key); + format!("{section}.{key}").to_lowercase().contains(query) || label.to_lowercase().contains(query) || tl!(&label).to_lowercase().contains(query) +} + +fn preference_sections(values: &Value, search: &str) -> Vec<(&'static str, &'static str)> { + let query = search.trim().to_lowercase(); + SECTIONS + .iter() + .copied() + .filter(|(id, title)| { + query.is_empty() + || title.to_lowercase().contains(&query) + || tl!(title).to_lowercase().contains(&query) + || values + .get(*id) + .and_then(Value::as_object) + .is_some_and(|fields| fields.keys().any(|key| !prefs::is_hidden(&format!("{id}.{key}")) && preference_matches(id, key, &query))) + }) + .collect() +} + /// Preferences: section list on the left, the section's settings on the right. fn prefs_body(ui: &mut egui::Ui, f: &mut Map, system: Option) { let t = Tokens::get(ui.ctx()); @@ -977,11 +1002,23 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map, system: Option, system: Option = @@ -1024,7 +1065,7 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map, system: Option, system: Option, system: Option, system: Opti "Light Theme", light_active, &mut light, - &[("studioLight", ThemeKind::StudioLight), ("classic", ThemeKind::Classic), ("adwaita", ThemeKind::Adwaita)], + &[ + ("studioLight", ThemeKind::StudioLight), + ("classic", ThemeKind::Classic), + ("adwaita", ThemeKind::Adwaita), + ("breezeLight", ThemeKind::BreezeLight), + ], width, ); }); @@ -1224,6 +1276,7 @@ fn appearance_rows(ui: &mut egui::Ui, obj: &mut Map, system: Opti ("studio", ThemeKind::Studio), ("solarizedDark", ThemeKind::SolarizedDark), ("adwaitaDark", ThemeKind::AdwaitaDark), + ("breezeDark", ThemeKind::BreezeDark), ], width, ); @@ -1234,14 +1287,33 @@ fn appearance_rows(ui: &mut egui::Ui, obj: &mut Map, system: Opti ui.add_space(12.0); } +/// Where a clicked colour swatch leaves its preference path and label for the dialog body, which +/// holds the fields the Color Picker request goes in. +const PICK_ID: &str = "prefs-pick-color"; + +/// The colour swatch clicked this frame, if any: (preference path, label). +fn ctx_take_pick(ui: &egui::Ui) -> Option<(String, String)> { + ui.ctx().data_mut(|d| d.remove_temp::<(String, String)>(egui::Id::new(PICK_ID))) +} + /// Generic editor for a section's fields: checkboxes, dropdowns for choices, colour swatches, /// number fields with the preference's range, text fields. -fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map, order: &[String], lang: crate::i18n::Lang, system: Option) { +fn section_fields( + ui: &mut egui::Ui, + section: &str, + obj: &mut Map, + order: &[String], + lang: crate::i18n::Lang, + system: Option, + search: &str, +) { let t = Tokens::get(ui.ctx()); - if section == "interface" { + let query = search.to_lowercase(); + if section == "interface" && (query.is_empty() || ["appearanceMode", "darkTheme", "lightTheme"].iter().any(|key| preference_matches(section, key, &query))) + { appearance_rows(ui, obj, system); } - if section == "performance" { + if section == "performance" && (query.is_empty() || preference_matches(section, "renderingMode", &query)) { rendering_mode_row(ui, obj); } let mut keys: Vec = order.iter().filter(|k| obj.contains_key(*k)).cloned().collect(); @@ -1249,6 +1321,9 @@ fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map egui::Grid::new(("prefs-grid", section)).num_columns(2).spacing([14.0, 7.0]).show(ui, |ui| { for k in keys { let path = format!("{section}.{k}"); + if !query.is_empty() && !preference_matches(section, &k, &query) { + continue; + } // Settings nothing reads yet stay out of the dialog (issue #204); their stored values // pass through untouched. if (section == "interface" && matches!(k.as_str(), "theme" | "appearanceMode" | "darkTheme" | "lightTheme")) @@ -1297,7 +1372,10 @@ fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map let c = prefs::parse_hex(s).unwrap_or([128, 128, 128]); let mut rgb = c; ui.horizontal(|ui| { - crate::widgets::color_edit_button_srgb(ui, &mut rgb); + // PhotoCraft's Color Picker, as everywhere else (#2144). + if crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(rgb[0], rgb[1], rgb[2]), &label).clicked() { + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(PICK_ID), (path.clone(), label.clone()))); + } hex_field(ui, &path, &mut rgb); }); obj.insert(k, json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); @@ -1811,6 +1889,18 @@ mod tests { assert_eq!(super::rendering_mode_value(automatic.as_object().unwrap()), "auto"); } + #[test] + fn preferences_search_matches_editable_fields_across_sections() { + let values = prefs::Preferences::default().to_json(); + let contains = |needle: &str, section: &str| preference_sections(&values, needle).iter().any(|(id, _)| *id == section); + assert!(contains("UI Scale", "interface")); + assert!(contains("placeholder", "type")); + assert!(contains("performance", "performance")); + assert!(contains("interface.uiScale", "interface")); + assert!(preference_sections(&values, "this setting does not exist").is_empty()); + assert_eq!(preference_sections(&values, "").len(), SECTIONS.len()); + } + #[test] fn preferences_dialog_width_follows_the_window() { let prefs = serde_json::json!({"__prefsui": "prefs"}); @@ -1839,6 +1929,23 @@ mod tests { assert_eq!(selected_theme(&interface, Some(egui::Theme::Dark)), ThemeKind::Classic); } + #[test] + fn breeze_themes_are_chosen_by_preference_and_fix_the_matching_mode() { + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + for (id, kind, mode) in [("breezeLight", ThemeKind::BreezeLight, AppearanceMode::Light), ("breezeDark", ThemeKind::BreezeDark, AppearanceMode::Dark)] { + app.run("prefs.set", json!({"path": "interface.theme", "value": id})).unwrap(); + tick(&mut app, &ctx); + let interface = app.session.prefs().interface.clone(); + assert_eq!(interface.appearance_mode, mode, "{id}"); + assert_eq!(selected_theme(&interface, None), kind, "{id}"); + assert_eq!(app.ui.theme, kind, "{id}"); + } + // The two slots are independent: choosing the dark one left the light choice alone. + assert_eq!(app.session.prefs().interface.light_theme, LightTheme::BreezeLight); + assert_eq!(app.session.prefs().interface.dark_theme, DarkTheme::BreezeDark); + } + #[test] fn auto_uses_native_system_theme_when_egui_does_not_report_one() { let (mut app, _) = app_with_store(); @@ -2260,7 +2367,7 @@ mod tests { step(vec2(2560.0, 1440.0), 1.75, 1.75); step(vec2(3840.0, 2160.0), 1.0, 2.0); } - for (pref, expected) in [("200", 2.0), ("125", 1.25), ("150", 1.5), ("100", 1.0), ("auto", 1.5)] { + for (pref, expected) in [("200", 2.0), ("125", 1.25), ("150", 1.5), ("100", 1.0), ("95", 0.95), ("90", 0.9), ("85", 0.85), ("80", 0.8), ("auto", 1.5)] { app.run("prefs.set", json!({"values": {"interface.uiScale": pref}})).unwrap(); let mut input = egui::RawInput::default(); input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(1.5); @@ -2383,7 +2490,7 @@ mod tests { assert!(has_visible_fields(&values, "type")); assert!(!prefs::is_hidden("type.fillNewTypeLayersWithPlaceholder")); assert!(!prefs::is_hidden("type.useEscToCommit")); - assert!(prefs::is_hidden("type.smartQuotes")); + assert!(!prefs::is_hidden("type.smartQuotes")); // Rotate View with Trackpad is live; the other Enhanced Controls rows stay hidden. assert!(has_visible_fields(&values, "enhancedControls")); assert!(!prefs::is_hidden("enhancedControls.rotateViewWithTrackpad")); @@ -2581,8 +2688,10 @@ mod tests { fn next_launch_notice_does_not_mark_unrelated_interface_preferences() { use egui_kittest::{Harness, kittest::Queryable}; let obj = json!({"showTooltips": true}).as_object().unwrap().clone(); - let mut h = - Harness::new_ui_state(|ui, obj: &mut Map| section_fields(ui, "interface", obj, &[], crate::i18n::Lang::from_pref("en"), None), obj); + let mut h = Harness::new_ui_state( + |ui, obj: &mut Map| section_fields(ui, "interface", obj, &[], crate::i18n::Lang::from_pref("en"), None, ""), + obj, + ); h.run_steps(4); assert!(h.query_by_label("Applies at next launch.").is_none()); h.get_by_label("Show tooltips").click(); diff --git a/crates/ui-egui/src/props_layout_tests.rs b/crates/ui-egui/src/props_layout_tests.rs index fd41fce350..0b99569fe7 100644 --- a/crates/ui-egui/src/props_layout_tests.rs +++ b/crates/ui-egui/src/props_layout_tests.rs @@ -263,3 +263,49 @@ fn layers_opacity_and_fill_share_one_right_column() { assert!((ol.right() - fl.right()).abs() < 0.5, "{theme}: Opacity label ends at {}, Fill label at {}", ol.right(), fl.right()); } } + +#[test] +fn properties_distribute_buttons_move_selected_layers_and_undo_in_both_axes() { + use photocraft_doc::LayerId; + for horizontal in [true, false] { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 300, "height": 300, "background": "transparent"})).unwrap(); + let mut ids = Vec::new(); + for (i, at) in [10, 40, 160].into_iter().enumerate() { + let id = s.execute("layer.new.layer", json!({"name": format!("Block {i}")})).unwrap()["layer"].as_u64().unwrap(); + let (x, y) = if horizontal { (at, 10) } else { (10, at) }; + s.execute("select.rect", json!({"x": x, "y": y, "width": 10, "height": 10})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + ids.push(id); + } + for (i, id) in ids.iter().copied().enumerate() { + s.execute("layer.select", json!({"layer": id, "mode": if i == 0 { "replace" } else { "add" }})).unwrap(); + } + let mut h = harness(s, 1.0, 320.0); + let button = if horizontal { "Horizontally" } else { "Vertically" }; + // An enabled Properties button invokes the existing command (not a UI-only gesture). + let before = { + let st = h.state().session.active().unwrap(); + let b = st.doc.layer(LayerId(ids[1])).unwrap().surface().unwrap().content_bounds(); + if horizontal { b.x0 } else { b.y0 } + }; + let history = h.state().session.active().unwrap().history.entries().len(); + h.get_by_label(button).click(); + h.run_steps(4); + let after = { + let st = h.state().session.active().unwrap(); + let b = st.doc.layer(LayerId(ids[1])).unwrap().surface().unwrap().content_bounds(); + if horizontal { b.x0 } else { b.y0 } + }; + assert_ne!(after, before, "{button} moved the middle layer"); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), history + 1); + h.state_mut().session.undo(); + let restored = { + let st = h.state().session.active().unwrap(); + let b = st.doc.layer(LayerId(ids[1])).unwrap().surface().unwrap().content_bounds(); + if horizontal { b.x0 } else { b.y0 } + }; + assert_eq!(restored, before, "{button}: undo restores its location"); + } +} diff --git a/crates/ui-egui/src/rasterize_prompt_tests.rs b/crates/ui-egui/src/rasterize_prompt_tests.rs index 108397cc38..8eb2dfa58b 100644 --- a/crates/ui-egui/src/rasterize_prompt_tests.rs +++ b/crates/ui-egui/src/rasterize_prompt_tests.rs @@ -20,6 +20,10 @@ fn app_with(kind: Kind) -> PhotocraftApp { Kind::Fill => app.run("layer.newFillLayer.solidColor", json!({"color": "#00ff00"})).map(|_| ()), } .unwrap(); + // A new fill layer targets its mask (Photoshop); painting its content means targeting the + // layer itself, as a click on its thumbnail does. + app.ui.mask_target = false; + app.sync_mask_targets(); app.run("tools.setColors", json!({"foreground": "#0000ff"})).unwrap(); app } diff --git a/crates/ui-egui/src/rotate_view.rs b/crates/ui-egui/src/rotate_view.rs index 5ef742cb62..eee0b29e56 100644 --- a/crates/ui-egui/src/rotate_view.rs +++ b/crates/ui-egui/src/rotate_view.rs @@ -316,7 +316,7 @@ mod tests { for center in [[0.0, 0.0], [320.0, 240.0], [10.0, -4.0]] { for flip in [false, true] { let rect = Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0)); - let a = ViewXform { rect, zoom, center, flip, rotation: 0.0 }; + let a = ViewXform { rect, zoom, center, flip, rotation: 0.0, aspect: 1.0 }; let sx = if flip { -1.0 } else { 1.0 }; let want = rect.center() + vec2((10.0 - center[0]) * zoom * sx, (20.0 - center[1]) * zoom); let got = a.to_screen(10.0, 20.0); @@ -331,21 +331,21 @@ mod tests { let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 300.0)); for rot in [15.0, 90.0, -45.0, 180.0] { for flip in [false, true] { - let xf = ViewXform { rect, zoom: 2.0, center: [80.0, 40.0], flip, rotation: rot }; + let xf = ViewXform { rect, zoom: 2.0, center: [80.0, 40.0], flip, rotation: rot, aspect: 1.0 }; let s = xf.to_screen(10.0, 20.0); let d = xf.to_doc(s); assert!((d[0] - 10.0).abs() < 1e-3 && (d[1] - 20.0).abs() < 1e-3, "rot={rot} flip={flip} {d:?}"); } } // 90°: a point on +X in document space lands on +Y on screen (Y-down = clockwise). - let xf = ViewXform { rect, zoom: 1.0, center: [0.0, 0.0], flip: false, rotation: 90.0 }; + let xf = ViewXform { rect, zoom: 1.0, center: [0.0, 0.0], flip: false, rotation: 90.0, aspect: 1.0 }; let s = xf.to_screen(1.0, 0.0); let c = rect.center(); assert!((s.x - c.x).abs() < 1e-3 && (s.y - (c.y + 1.0)).abs() < 1e-3, "{s:?} vs {c:?}"); // Flip after rotate: 0° +X goes left. - let xf = ViewXform { rect, zoom: 1.0, center: [0.0, 0.0], flip: true, rotation: 0.0 }; + let xf = ViewXform { rect, zoom: 1.0, center: [0.0, 0.0], flip: true, rotation: 0.0, aspect: 1.0 }; assert!(xf.to_screen(1.0, 0.0).x < rect.center().x); - let xf = ViewXform { rect, zoom: 1.0, center: [0.0, 0.0], flip: true, rotation: 90.0 }; + let xf = ViewXform { rect, zoom: 1.0, center: [0.0, 0.0], flip: true, rotation: 90.0, aspect: 1.0 }; let s = xf.to_screen(1.0, 0.0); assert!((s.x - c.x).abs() < 1e-3 && (s.y - (c.y + 1.0)).abs() < 1e-3, "flip then rotate {s:?}"); } @@ -509,7 +509,8 @@ mod tests { let b = st.doc.selection.as_ref().and_then(|s| photocraft_compose::bounds::content_bounds(s).into()).expect("selection"); // Axis-aligned in the file: the outline is drawn rotated via ViewXform. assert!(b.width() >= 40 && b.height() >= 10, "{b:?}"); - let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), zoom: 1.0, center: [100.0, 50.0], flip: false, rotation: 30.0 }; + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), zoom: 1.0, center: [100.0, 50.0], flip: false, rotation: 30.0, aspect: 1.0 }; let a = xf.to_screen(10.0, 20.0); let c = xf.to_screen(50.0, 20.0); let v = c - a; @@ -521,8 +522,8 @@ mod tests { #[test] fn gpu_path_stays_on_with_rotation() { // Flip is the only CPU-blit fallback; rotation is a shader uniform. - assert!(crate::gpu_canvas::gpu_view_ok(false, 45.0)); - assert!(!crate::gpu_canvas::gpu_view_ok(true, 45.0)); - assert!(crate::gpu_canvas::gpu_view_ok(false, 0.0)); + assert!(crate::gpu_canvas::gpu_view_ok(false, 45.0, 1.0)); + assert!(!crate::gpu_canvas::gpu_view_ok(true, 45.0, 1.0)); + assert!(crate::gpu_canvas::gpu_view_ok(false, 0.0, 1.0)); } } diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index 0bd6132d9e..ff7444078d 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -126,7 +126,7 @@ pub fn draw_grid(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, d } let st = Stroke::new(1.0, base.gamma_multiply(alpha)); let [step_x, step_y] = steps; - if step_x * (xf.zoom as f64) >= 6.0 { + if step_x * (xf.zoom_x() as f64) >= 6.0 { let mut x = step_x; while x < w { styled_line(painter, xf.to_screen(x as f32, 0.0), xf.to_screen(x as f32, h as f32), st, g.grid_style); @@ -246,7 +246,9 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & let label = |v: f64, step: f64| -> String { if step >= 1.0 || whole { format!("{}", v.round() as i64) } else { crate::widgets::fmt_num2(v) } }; for (vertical, extent) in [(false, size[0]), (true, size[1])] { let px_per_unit = unit.to_px(1.0, dpi, extent, ppi).max(1e-9); - let step = nice_step(60.0 / (xf.zoom as f64 * px_per_unit).max(1e-6), whole); + // The top ruler runs along document x, which pixel aspect correction stretches. + let zoom = if vertical { xf.zoom } else { xf.zoom_x() }; + let step = nice_step(60.0 / (zoom as f64 * px_per_unit).max(1e-6), whole); let (u0, u1) = if vertical { ruler_range(xf, left.left_top(), left.left_bottom(), 1, px_per_unit) } else { @@ -429,6 +431,40 @@ mod tests { assert_eq!(guide_at(&app, 0.0, 0.0), None); } + /// ⌘ is the Move tool with any tool, so a ⌘-drag over a guide moves the guide, never the layer + /// (#2690); with Lock Guides a ⌘-drag with a marquee moves the layer as before. + #[test] + fn command_drag_over_a_guide_moves_the_guide_with_any_tool() { + use crate::canvas::{ToolEvent, tool_event}; + use crate::state::Tool; + for (tool, locked) in [(Tool::Brush, false), (Tool::RectMarquee, false), (Tool::Lasso, false), (Tool::MagicWand, false), (Tool::RectMarquee, true)] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 80, "height": 60, "background": "transparent"})).unwrap(); + app.sync_views(); + app.ui.tool = tool; + app.ui.extras.lock_guides = locked; + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.session + .edit("paint", |doc, _| { + doc.layer_mut(layer).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(10, 10, 30, 30), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.run("view.newGuide", json!({"orientation": "vertical", "position": 50})).unwrap(); + let cmd = egui::Modifiers::COMMAND; + tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 40.0, pressure: 1.0 }, cmd); + tool_event(&mut app, ToolEvent::Move { x: 65.0, y: 40.0, pressure: 1.0 }, cmd); + tool_event(&mut app, ToolEvent::Up { x: 65.0, y: 40.0 }, cmd); + let doc = &app.session.active().unwrap().doc; + let moved = doc.layer(layer).unwrap().surface().unwrap().rgba(12, 12)[3] == 0.0; + if locked { + assert_eq!((doc.guides.vertical.clone(), moved), (vec![50.0], true), "{tool:?}, guides locked: the layer moves"); + } else { + assert_eq!((doc.guides.vertical.clone(), moved), (vec![65.0], false), "{tool:?}: the guide moves, the layer stays"); + } + } + } + #[test] fn ruler_range_is_normalised_when_flipped() { // #1814: with View > Flip Horizontal, `to_doc` negates x, so the raw @@ -437,7 +473,7 @@ mod tests { let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); let top = Rect::from_min_size(rect.min, vec2(rect.width(), RULER)); for flip in [false, true] { - let xf = ViewXform { rect, zoom: 1.0, center: [400.0, 300.0], flip, rotation: 0.0 }; + let xf = ViewXform { rect, zoom: 1.0, center: [400.0, 300.0], flip, rotation: 0.0, aspect: 1.0 }; let (u0, u1) = ruler_range(&xf, top.left_top(), top.right_top(), 0, 1.0); assert!(u0 <= u1, "flip={flip}: u0 ({u0}) must be <= u1 ({u1})"); assert!(u1 - u0 > 0.0, "flip={flip}: range is empty"); diff --git a/crates/ui-egui/src/save_identity_tests.rs b/crates/ui-egui/src/save_identity_tests.rs index f5513a1854..9cfc86d500 100644 --- a/crates/ui-egui/src/save_identity_tests.rs +++ b/crates/ui-egui/src/save_identity_tests.rs @@ -116,3 +116,69 @@ fn web_download_and_automation_saves_use_the_same_identity_completion() { app.save_automation(Some("保存/agent save.psd".into())).unwrap(); assert_eq!(app.session.active().unwrap().doc.name, "agent save.psd"); } + +/// Save As to a flat format writes a copy when the flat file can't hold the document: its layers, +/// or the layered file it already lives in. The document keeps its file, so Save still writes the +/// layered original and the edits stay unsaved (#2550). +#[test] +fn save_as_flat_keeps_the_layered_original() { + let ctx = egui::Context::default(); + // A layered document, and a one-layer one (the issue's case: the dialog offered a JPEG). + for layered in [true, false] { + let mut app = app(); + if !layered { + app.run("file.new", json!({"width": 4, "height": 4, "name": "Untitled-2"})).unwrap(); + } + let written = Rc::new(RefCell::new(Vec::new())); + let w = written.clone(); + app.services.write = Some(Box::new(move |path, _| { + w.borrow_mut().push(path.to_string()); + Ok(()) + })); + menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": "art.psd"})).unwrap(); + app.run("edit.fill", json!({"color": "#000000"})).unwrap(); + let before = identity(&app); + for flat in ["art.jpg", "art.png"] { + menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": flat})).unwrap(); + assert_eq!(identity(&app), before, "{flat}, layered: {layered}"); + } + menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + assert_eq!(*written.borrow(), ["art.psd", "art.jpg", "art.png", "art.psd"]); + let st = app.session.active().unwrap(); + assert_eq!(st.saved_revision, st.revision); + } + // An untitled layered document saved as a JPEG is still untitled and unsaved. + let mut app = app(); + let before = identity(&app); + app.save_as(Some("flat.jpg".into())).unwrap(); + assert_eq!(identity(&app), before); +} + +/// A flat document that isn't in a layered file becomes the flat file it is saved as, as before. +#[test] +fn save_as_flat_retargets_a_flat_document() { + let mut app = app(); + app.run("file.new", json!({"width": 4, "height": 4, "name": "Untitled-2"})).unwrap(); + app.save_as(Some("shot.png".into())).unwrap(); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some("shot.png")); + app.save_as(Some("shot.jpg".into())).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.name.as_str(), st.path.as_deref()), ("shot.jpg", Some("shot.jpg"))); + assert_eq!(st.saved_revision, st.revision); +} + +/// `app.save` (control channel, MCP bridge) to a flat format is a copy, as in the headless server +/// (#1547); only a layered format becomes the document's file (#2579). +#[test] +fn automation_flat_save_is_a_copy() { + let mut app = app(); + let before = identity(&app); + for flat in ["flat.png", "flat.jpg", "flat.tif"] { + app.save_automation(Some(flat.into())).unwrap(); + assert_eq!(identity(&app), before, "{flat}"); + } + app.save_automation(Some("layers.psd".into())).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.name.as_str(), st.path.as_deref()), ("layers.psd", Some("layers.psd"))); + assert_eq!(st.saved_revision, st.revision); +} diff --git a/crates/ui-egui/src/screen_picker.rs b/crates/ui-egui/src/screen_picker.rs index 2915d53ff6..c9da4cdca1 100644 --- a/crates/ui-egui/src/screen_picker.rs +++ b/crates/ui-egui/src/screen_picker.rs @@ -261,7 +261,7 @@ pub fn show(ctx: &Context) { let shared = r.clone(); let vid = egui::ViewportId::from_hash_of(("screen-color", generation, n)); let mut builder = egui::ViewportBuilder::default() - .with_title("PhotoCraft — Screen Color Picker") + .with_title(tl!("PhotoCraft — Screen Color Picker")) .with_decorations(false) .with_resizable(false) .with_position(image.position / ctx.zoom_factor()) diff --git a/crates/ui-egui/src/scrollbars.rs b/crates/ui-egui/src/scrollbars.rs index 05f1edf58c..2ab7cdeab6 100644 --- a/crates/ui-egui/src/scrollbars.rs +++ b/crates/ui-egui/src/scrollbars.rs @@ -124,9 +124,10 @@ pub fn bar_rects(rect: Rect, horizontal: bool, vertical: bool) -> (Option, /// Show the scrollbars of the canvas `rect` and apply their drags and page clicks to `view`. /// `flip` is View › Flip Horizontal (the bar then runs the other way through the image); -/// `overscroll` is Preferences › Tools › Overscroll. +/// `aspect` is the displayed pixel aspect ratio (a document pixel is that many points wider per +/// point of height); `overscroll` is Preferences › Tools › Overscroll. /// Returns true when the pointer is over a bar (the canvas then leaves the pointer alone). -pub fn show(ui: &Ui, rect: Rect, view: &mut View, flip: bool, overscroll: bool, key: Id) -> bool { +pub fn show(ui: &Ui, rect: Rect, view: &mut View, flip: bool, aspect: f32, overscroll: bool, key: Id) -> bool { let [w, h] = view.doc_size; // `Span` works in egui points; `View::zoom` is device pixels per document pixel. let ppp = ui.ctx().pixels_per_point(); @@ -134,7 +135,8 @@ pub fn show(ui: &Ui, rect: Rect, view: &mut View, flip: bool, overscroll: bool, // Horizontal axis in "screen order": with the view flipped, run the image backwards. let hx = |c: f32| if flip { w as f32 - c } else { c }; let with_extent = |s: Span| (s, s.extent(overscroll)); - let ex = Span::of(w as f32, hx(view.center[0]), rect.width(), zoom).map(with_extent); + let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 }; + let ex = Span::of(w as f32, hx(view.center[0]), rect.width() / aspect, zoom).map(with_extent); let ey = Span::of(h as f32, view.center[1], rect.height(), zoom).map(with_extent); if ex.is_none() && ey.is_none() { return false; diff --git a/crates/ui-egui/src/shape_dialog.rs b/crates/ui-egui/src/shape_dialog.rs index 0056f255f1..f643c210cd 100644 --- a/crates/ui-egui/src/shape_dialog.rs +++ b/crates/ui-egui/src/shape_dialog.rs @@ -173,7 +173,8 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) let px = num(f, key).unwrap_or(100.0); let to_unit = |px: f64| unit.from_px(px, dpi, extent, ppi) as f32; let mut v = to_unit(px); - if crate::widgets::value_field(ui, &mut v, to_unit(1.0)..=to_unit(MAX_PX), unit.suffix(), 110.0).changed() { + let places = unit.decimals() as u32; + if crate::widgets::value_field_prec(ui, &mut v, to_unit(1.0)..=to_unit(MAX_PX), unit.suffix(), 110.0, places).changed() { let px = unit.to_px(f64::from(v), dpi, extent, ppi).clamp(1.0, MAX_PX); f.insert(key.into(), json!((px * 1000.0).round() / 1000.0)); } diff --git a/crates/ui-egui/src/shortcut_dispatch.rs b/crates/ui-egui/src/shortcut_dispatch.rs index 5a2b17fdca..7439d56ce8 100644 --- a/crates/ui-egui/src/shortcut_dispatch.rs +++ b/crates/ui-egui/src/shortcut_dispatch.rs @@ -140,9 +140,13 @@ impl Focus { /// May `sc` fire with this focus? A focused widget keeps its navigation keys (arrows, ↩, /// Space, Esc…, plus ⌫ / Delete when it claimed them); a text field keeps everything but ⌘ shortcuts (minus its own editing - /// ones: select all, clipboard, undo) and the function keys. + /// ones: select all, clipboard, undo) and the function keys. Ctrl+Tab and Ctrl+Shift+Tab switch + /// documents whatever has focus, as in Photoshop: no widget or text field edits with them (#2340). pub fn allows(self, sc: &KeyboardShortcut) -> bool { let k = sc.logical_key; + if k == Key::Tab && sc.modifiers.ctrl { + return true; + } let function = matches!( k, Key::F1 @@ -243,8 +247,10 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc /// Clear's key (Delete / Backspace) with nothing selected deletes the selected layers, of any /// kind, as in Photoshop (#1077): Edit › Clear only clears pixel layers, so on an adjustment, fill, -/// type or shape layer the key did nothing. A selection, a targeted layer mask, a single channel -/// or Quick Mask keeps Clear. +/// type or shape layer the key did nothing. A selection, a single channel or Quick Mask keeps Clear. +/// With the active layer's mask targeted the key edits the mask, never the layer's pixels: a +/// selection is filled with the background colour in the mask (Photoshop's Delete on a mask), and +/// with no selection the mask itself is deleted, as Layer › Layer Mask › Delete. fn delete_key_command(app: &PhotocraftApp, id: String) -> String { if id != "edit.clear" { return id; @@ -252,7 +258,11 @@ fn delete_key_command(app: &PhotocraftApp, id: String) -> String { let Some(st) = app.session.active() else { return id }; let composite = st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::Composite && st.doc.quick_mask.is_none(); let mask = app.ui.mask_target && st.active_layer.and_then(|l| st.doc.layer(l)).is_some_and(|l| l.mask.is_some()); - if st.doc.selection.is_some() || !composite || mask { + if mask && composite { + let cmd = if st.doc.selection.is_some() { "edit.fillBackground" } else { "layer.layerMask.delete" }; + return cmd.into(); + } + if st.doc.selection.is_some() || !composite { return id; } "layer.delete".into() diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index 3b109bad64..84b87bb76d 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -458,6 +458,43 @@ fn delete_without_a_selection_deletes_the_selected_layer() { } } +/// With the layer mask targeted, Delete / Backspace edit the mask and never the layer's pixels: +/// a selection fills the mask with the background colour there, and with no selection the mask +/// itself is deleted. +#[test] +fn delete_with_the_mask_targeted_edits_the_mask_not_the_layer() { + for key in ["Delete", "Backspace"] { + let mut h = harness(); + put_focus(&mut h, Place::Canvas); + let s = &mut h.state_mut().session; + let layer = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + let sel = s.active().unwrap().doc.selection.as_ref().expect("the harness keeps a selection").content_bounds(); + let (x, y) = ((sel.x0 + sel.x1) / 2, (sel.y0 + sel.y1) / 2); + h.state_mut().ui.mask_target = true; + h.run_steps(2); + let layer_pixels = |h: &Harness<'_, PhotocraftApp>| { + h.state().session.active().unwrap().doc.layer(layer).and_then(|l| l.surface().cloned()).map(|s| s.read_region(sel)) + }; + let mask_at = |h: &Harness<'_, PhotocraftApp>| { + h.state().session.active().unwrap().doc.layer(layer).and_then(|l| l.mask.as_ref()).map(|m| m.surface.sample_channel(x, y, 0)) + }; + let before = layer_pixels(&h); + let bg = h.state().session.tools.background; + press(&mut h, key); + assert_eq!(logged(&h), ["edit.fillBackground"], "{key}"); + assert_eq!(mask_at(&h), Some(bg[0]), "{key}: the mask takes the background colour in the selection"); + assert_eq!(layer_pixels(&h), before, "{key}: the layer's pixels are untouched"); + h.state_mut().session.execute("select.deselect", json!({})).unwrap(); + h.run_steps(2); + press(&mut h, key); + assert_eq!(logged(&h), ["layer.layerMask.delete"], "{key}"); + assert_eq!(mask_at(&h), None, "{key}: no selection deletes the mask"); + assert!(h.state().session.active().unwrap().doc.layer(layer).is_some(), "{key}: the layer stays"); + assert_eq!(layer_pixels(&h), before, "{key}: the layer's pixels are untouched"); + } +} + /// Every selected layer goes, pixel layers too (Photoshop deletes, it doesn't clear the layer). #[test] fn delete_without_a_selection_deletes_every_selected_layer() { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f2962ac9b2..6584dfa042 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -175,6 +175,10 @@ fn clipboard_press(e: &egui::Event, mods: Modifiers, is_windows: bool) -> Option /// held, so ⇧⌘C is Copy Merged), and a ⌘V release without a ⌘V press (a paste event, or the Mac /// menu's key equivalent) becomes a ⌘V press. /// +/// The web build gets both: eframe forwards the ⌘V key-down, then the browser's `paste` event +/// (the key-down's default action) arrives as `Event::Paste` in the same frame. A clipboard event +/// right after its own ⌘ key press is that press, not a second one (#2638). +/// /// Ctrl+Insert copies and Shift+Insert pastes: egui-winit maps them on Windows (a Shift+Insert /// release without a paste event pastes the image, like ⌘V), and we map them here on Linux. pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInput) { @@ -196,12 +200,36 @@ pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInpu }); } if typing { + // A text field copies and pastes on egui's Copy / Cut / Paste events, which egui-winit + // makes from the window's key and so never sends as key presses. A ⌘C / ⌘X / ⌘V press + // here is the Mac menu's key equivalent (AppKit takes the key while Edit › Copy, Cut or + // Paste is enabled), which a text field ignores: pasting text worked only while + // PhotoCraft's own clipboard was empty. Give those presses back as the events; Paste asks + // the window for the clipboard text, which arrives as a Paste event next frame. + raw.events.retain_mut(|e| { + let egui::Event::Key { key, pressed: true, modifiers: m, .. } = *e else { return true }; + if !m.command || m.alt || m.shift { + return true; + } + match key { + Key::C => *e = egui::Event::Copy, + Key::X => *e = egui::Event::Cut, + Key::V => { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste); + return false; + } + _ => {} + } + true + }); return; } let seen = egui::Id::new("pc-paste-key-seen"); // The modifiers held at each event: last frame's, updated as the events report changes. let mut mods = ctx.input(|i| i.modifiers); let mut out = Vec::with_capacity(raw.events.len()); + // The last ⌘C/⌘X/⌘V press this frame whose clipboard event hasn't arrived (yet). + let mut unpaired: Option = None; for e in raw.events.drain(..) { if let egui::Event::ModifiersChanged(m) | egui::Event::Key { modifiers: m, .. } = &e { mods = *m; @@ -210,14 +238,20 @@ pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInpu if key == Key::V { ctx.data_mut(|d| d.insert_temp(seen, true)); } - out.push(press(key, held)); + if unpaired.take_if(|k| *k == key).is_none() { + out.push(press(key, held)); + } continue; } match &e { - // A ⌘V press egui-winit didn't swallow (the Mac menu's key equivalent, Shift+Insert on - // Linux): its release must not paste a second time (#1638). - egui::Event::Key { key: Key::V, pressed: true, modifiers, .. } if modifiers.command => { - ctx.data_mut(|d| d.insert_temp(seen, true)); + // A ⌘C/⌘X/⌘V press egui-winit didn't swallow (the web, the Mac menu's key equivalent, + // Shift+Insert on Linux): its clipboard event and, for ⌘V, its release must not run + // the command a second time (#2638, #1638). + egui::Event::Key { key: key @ (Key::C | Key::X | Key::V), pressed: true, modifiers, .. } if modifiers.command => { + unpaired = Some(*key); + if *key == Key::V { + ctx.data_mut(|d| d.insert_temp(seen, true)); + } } // A paste key's release ends that press, with or without ⌘ still held (else a stale // mark would swallow the next image paste); without a press it is the paste itself. @@ -242,6 +276,9 @@ pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInpu } pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { + // However the tool was picked (toolbar, flyout, shortcut, automation), its group's key brings + // it back later (#2608). + app.ui.remember_group_tool(); // Camera Raw is modal like Photoshop's filter dialog: no application shortcut (Save, Undo, // tools) runs beneath it, and it handles its own keys (Y, U, O, S). Unlike the other dialogs // below it covers the canvas, so canvas zoom keys are blocked too. @@ -378,6 +415,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { if crate::crop_shield::keys(app, ctx) { return; } + // ... and I fills W x H x Resolution from the document (Front Image, #2443). + if crate::crop_size::keys(app, ctx) { + return; + } // Single-key tools (no modifiers). D and X (`tools.defaultColors` / `tools.swapColors`) and the // brush keys [ ] ⇧[ ⇧] (`tools.decreaseBrushSize`…) are commands, dispatched above with any // Keyboard Shortcuts override. @@ -410,9 +451,35 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } } + // Esc with no active operation deselects (#2674), like ⌘D. The key is only taken when a + // selection exists, and never from a state that handles Esc later in the frame: menus, + // dialogs and inline type returned above, transform, pen, lasso and crop consumed it, and + // these own theirs: full screen (Esc returns), the palette, the canvas and layer menus, the + // brush picker, Rotate View, Adaptive Wide Angle, the Filter Gallery, the TIFF prompt, any + // open egui popup and the toolbar tool flyout. A canvas drag in progress (a marquee being + // drawn) keeps the selection too. + let esc_taken = app.ui.view.hides_chrome() + || app.drag.is_some() + || app.ui.palette_open + || app.ui.canvas_tool_menu.is_some() + || app.ui.layer_menu.is_some() + || app.ui.brush_picker.is_some() + || app.ui.tool == Tool::RotateView + || app.wide_angle.is_some() + || app.tiff_options.is_some() + || app.distort.gallery.is_some() + || ctx.any_popup_open() + || ctx.data(|d| d.get_temp::<(egui::Id, egui::Rect)>(egui::Id::new("tool-flyout")).is_some()); + if !esc_taken && app.session.active().is_some_and(|st| st.doc.selection.is_some()) && pressed(Key::Escape) { + if let Err(e) = app.run("select.deselect", json!({})) { + app.ui.status = e; + app.ui.status_error = true; + } + return; + } // Tool keys; pressing the key of the current group cycles within it. With Preferences › // Tools › Use Shift Key for Tool Switch, only ⇧+key cycles and the plain key keeps the - // group's current tool. + // group's current tool. From another group, the key picks the group's last-used tool. let shift_switch = app.session.prefs().tools.use_shift_key_for_tool_switch; for t in Tool::ALL { let Some(k) = Key::from_name(&t.key().to_string()) else { continue }; @@ -422,7 +489,7 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { app.ui.tool = match group.iter().position(|x| *x == app.ui.tool) { Some(i) if shift_switch && !cycle_shift => group[i], Some(i) => group[(i + 1) % group.len()], - None => group[0], + None => app.ui.group_tool(t.key()).unwrap_or(group[0]), }; return; } @@ -435,6 +502,40 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { mod tests { use super::*; + #[test] + fn tool_key_brings_back_the_groups_last_used_tool() { + use crate::state::Tool; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + let press = |app: &mut PhotocraftApp, key, modifiers| { + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + ctx.begin_pass(raw); + handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); + }; + // Polygonal Lasso picked from the toolbar flyout, then the Brush (#2608). + app.ui.tool = Tool::PolygonLasso; + press(&mut app, Key::B, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::Brush); + press(&mut app, Key::L, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::PolygonLasso); + // Within the group, L still cycles; leaving and coming back keeps the new pick. + press(&mut app, Key::L, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::MagneticLasso); + press(&mut app, Key::M, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::RectMarquee); + press(&mut app, Key::L, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::MagneticLasso); + // Use Shift Key for Tool Switch: the plain key also returns to the last-used tool. + app.session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = true); + press(&mut app, Key::B, Modifiers::NONE); + press(&mut app, Key::L, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::MagneticLasso); + } + #[test] fn pen_command_z_and_backspace_retract_points_before_document_undo() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); @@ -600,6 +701,47 @@ mod tests { assert!(matches!(r.events.as_slice(), [_, egui::Event::Paste(_)])); } + /// In a text field the Mac menu's ⌘C / ⌘X key equivalents become the field's Copy / Cut, and + /// its ⌘V asks the window for the clipboard text, which the field pastes. Other keys, and ⇧ / ⌥ + /// chords, pass through. + #[test] + fn menu_key_equivalents_reach_a_text_field_as_clipboard_events() { + let ctx = egui::Context::default(); + let key = |key, pressed, modifiers| egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers }; + let cmd = Modifiers::COMMAND; + let events = vec![ + key(Key::C, true, cmd), + key(Key::X, true, cmd), + key(Key::V, true, cmd), + key(Key::V, false, cmd), + key(Key::A, true, cmd), + key(Key::C, true, cmd | Modifiers::SHIFT), + ]; + let mut r = egui::RawInput { events: [vec![egui::Event::ModifiersChanged(cmd)], events].concat(), ..Default::default() }; + // In a frame, as the app calls it, so the request for the clipboard text is in its output. + ctx.begin_pass(egui::RawInput::default()); + clipboard_keys(&ctx, true, &mut r); + let mut out = ctx.end_pass(); + out.textures_delta.clear(); + assert!( + matches!( + &r.events[1..], + [ + egui::Event::Copy, + egui::Event::Cut, + egui::Event::Key { key: Key::V, pressed: false, .. }, + egui::Event::Key { key: Key::A, .. }, + egui::Event::Key { key: Key::C, .. } + ] + ), + "{:?}", + r.events + ); + // The ⌘V press became a request for the clipboard's text. + let asked = out.viewport_output.values().flat_map(|v| &v.commands).any(|c| matches!(c, egui::ViewportCommand::RequestPaste)); + assert!(asked, "the window is asked for the clipboard text"); + } + /// #1638: every ⌘V is one press, however its key-down and key-up arrive. #[test] fn each_paste_key_is_one_press() { @@ -619,6 +761,21 @@ mod tests { assert_eq!(presses(vec![egui::Event::ModifiersChanged(Modifiers::COMMAND), key(Key::V, false, Modifiers::COMMAND)]), 1, "image paste"); } + /// #2638: on the web one ⌘V brings its key press and the browser's paste event; that is one paste. + #[test] + fn web_paste_key_and_paste_event_are_one_press() { + let ctx = egui::Context::default(); + let key = |key, pressed| egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers: Modifiers::COMMAND }; + let mut r = + egui::RawInput { events: vec![key(Key::V, true), egui::Event::Paste("x".into()), key(Key::X, true), egui::Event::Cut], ..Default::default() }; + clipboard_keys(&ctx, false, &mut r); + let presses = |k| r.events.iter().filter(|e| matches!(e, egui::Event::Key { key, pressed: true, .. } if *key == k)).count(); + assert_eq!((presses(Key::V), presses(Key::X)), (1, 1)); + let mut r = egui::RawInput { events: vec![key(Key::V, false)], ..Default::default() }; + clipboard_keys(&ctx, false, &mut r); + assert!(!r.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. })), "its release doesn't paste again"); + } + /// #530: egui-winit sends Cut for Shift+Delete on Windows; on the canvas it opens Fill. #[test] fn windows_shift_delete_fills_instead_of_cutting() { diff --git a/crates/ui-egui/src/sizing.rs b/crates/ui-egui/src/sizing.rs index ae3e940073..f194cad33e 100644 --- a/crates/ui-egui/src/sizing.rs +++ b/crates/ui-egui/src/sizing.rs @@ -28,7 +28,7 @@ fn pref_unit(u: photocraft_engine::prefs::Unit) -> &'static str { } /// Image Size resampling for Preferences › General › Image Interpolation. -fn pref_resample(i: photocraft_engine::prefs::Interpolation) -> &'static str { +pub(crate) fn pref_resample(i: photocraft_engine::prefs::Interpolation) -> &'static str { use photocraft_engine::prefs::Interpolation; match i { Interpolation::Nearest => "nearest", @@ -142,7 +142,8 @@ fn dim_field(ui: &mut egui::Ui, f: &mut Map, key: &str, orig_key: let px = num(f, key); let mut v = to_unit(px, &unit, orig, res) as f32; let range = if unit == "px" { -300000.0..=300000.0 } else { -30000.0..=30000.0 }; - let changed = crate::widgets::value_field(ui, &mut v, range, "", 90.0).changed(); + // Each unit keeps its own precision: whole pixels, 3 places for inches, 2 for cm/mm/picas (#2434). + let changed = crate::widgets::value_field_prec(ui, &mut v, range, "", 90.0, crate::widgets::unit_decimals(&unit)).changed(); if changed { let px = from_unit(v as f64, &unit, orig, res); f.insert(key.into(), json!(if unit == "px" { px.round() } else { px })); @@ -165,24 +166,47 @@ fn image_size(ui: &mut egui::Ui, f: &mut Map) { let bpp = num(f, "__bytesPerPixel").max(1.0); let resample = f.get("resample").and_then(Value::as_str).unwrap_or("bicubic").to_string(); let resampling = resample != "none"; + if !resampling { + // Without resampling the pixel count is fixed (`image.imageSize` keeps it): only the + // resolution changes, so the readouts and fields always show the current pixels. + f.insert("width".into(), json!(ow)); + f.insert("height".into(), json!(oh)); + } let (w, h) = (num(f, "width").round(), num(f, "height").round()); info_row(ui, tl!("Image Size:"), crate::i18n::fmt(tl!("{size} (was {old})"), &[("size", &human_bytes(w * h * bpp)), ("old", &human_bytes(ow * oh * bpp))])); info_row(ui, tl!("Dimensions:"), format!("{} px × {} px", w as i64, h as i64)); ui.add_space(6.0); - let mut constrain = f.get("__constrain").and_then(Value::as_bool).unwrap_or(true); + // Photoshop keeps width and height linked while Resample is off. + let mut constrain = !resampling || f.get("__constrain").and_then(Value::as_bool).unwrap_or(true); + // With Resample off, a size in pixels or percent can't change (Photoshop disables those + // fields); a physical size (inches, cm…) changes the resolution instead. + let unit = f.get("__unit").and_then(Value::as_str).unwrap_or("px").to_string(); + let editable = resampling || !matches!(unit.as_str(), "px" | "percent"); let w_row = ui.horizontal(|ui| { label(ui, tl!("Width:"), 90.0); - let c = dim_field(ui, f, "width", "__origW", "is-w"); + let c = ui.add_enabled_ui(editable, |ui| dim_field(ui, f, "width", "__origW", "is-w")).inner; unit_dropdown(ui, f, "is-unit-w"); c }); let h_row = ui.horizontal(|ui| { label(ui, tl!("Height:"), 90.0); - let c = dim_field(ui, f, "height", "__origH", "is-h"); + let c = ui.add_enabled_ui(editable, |ui| dim_field(ui, f, "height", "__origH", "is-h")).inner; unit_dropdown(ui, f, "is-unit-h"); c }); let (w_changed, h_changed) = (w_row.inner, h_row.inner); + if !resampling && (w_changed || h_changed) { + // The edited side's new pixel count at the current resolution gives its new physical size; + // keep the pixels and fit the resolution to that size instead. + let (key, orig) = if w_changed { ("width", ow) } else { ("height", oh) }; + let res = f.get("resolution").and_then(Value::as_f64).unwrap_or(num(f, "__origRes")); + let px = num(f, key); + if px > 0.0 && px.is_finite() { + f.insert("resolution".into(), json!((res * orig / px).clamp(1.0, 30000.0))); + } + f.insert("width".into(), json!(ow)); + f.insert("height".into(), json!(oh)); + } // `dim_field` allows negatives for Canvas Size's relative mode, but an image is at least // one pixel: clamp here so the preview shows the size `image.imageSize` will apply. for (key, changed) in [("width", w_changed), ("height", h_changed)] { @@ -206,7 +230,7 @@ fn image_size(ui: &mut egui::Ui, f: &mut Map) { let chain = Rect::from_center_size(pos2(bracket.left() + 8.0, bracket.center().y), vec2(14.0, 14.0)); ui.painter().rect_filled(chain, 2.0, if link.hovered() { t.hover } else { Color32::TRANSPARENT }); crate::icons::paint(ui, chain, if constrain { "link" } else { "unlink" }, 11.0, col); - if link.on_hover_text(tl!("Constrain proportions")).clicked() { + if link.on_hover_text(tl!("Constrain proportions")).clicked() && resampling { constrain = !constrain; f.insert("__constrain".into(), json!(constrain)); } @@ -419,13 +443,8 @@ mod tests { PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); let id = open(h.state_mut(), "image.imageSize").unwrap(); h.run_steps(3); - let width = h.query_all_by_role(Role::SpinButton).next().map(|n| n.rect()).expect("the Width field"); - h.hover_at(width.center()); - h.run_steps(1); - h.drag_at(width.center()); - h.run_steps(1); - h.drop_at(width.center()); - h.run_steps(2); + // The dialog opens on Width, selected (#2329): the typed 0 replaces the 4. + assert!(h.query_all_by_role(Role::SpinButton).next().expect("the Width field").is_focused()); h.event(egui::Event::Text("0".into())); h.run_steps(1); h.key_press(egui::Key::Tab); @@ -435,4 +454,98 @@ mod tests { crate::dialogs::confirm(h.state_mut(), id).unwrap(); assert_eq!(h.state().session.active().unwrap().doc.size, photocraft_doc::Size::new(1, 1)); } + + /// An Image Size dialog for a 100 x 50 px, 72 ppi document with Resample off, shown in `unit`. + fn resample_off_dialog(unit: &str) -> (egui_kittest::Harness<'static, PhotocraftApp>, u64) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 100, "height": 50, "resolution": 72})).unwrap(); + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(1400.0, 900.0)) + .build_ui_state(|ui, app: &mut PhotocraftApp| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(h.state_mut(), "image.imageSize").unwrap(); + let d = h.state_mut().ui.dialog_mut(id).unwrap(); + d.fields.insert("resample".into(), json!("none")); + d.fields.insert("__unit".into(), json!(unit)); + h.run_steps(3); + (h, id) + } + + /// Types `text` into the Width field and leaves it with Tab. + fn type_width(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, text: &str) { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + let width = h.query_all_by_role(Role::SpinButton).next().map(|n| n.rect()).expect("the Width field"); + h.hover_at(width.center()); + h.run_steps(1); + h.drag_at(width.center()); + h.run_steps(1); + h.drop_at(width.center()); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + h.key_press(egui::Key::Tab); + h.run_steps(3); + } + + fn fields(h: &egui_kittest::Harness<'static, PhotocraftApp>) -> Map { + h.state().ui.dialogs.first().map(|d| d.fields.clone()).expect("the dialog is open") + } + + /// #2393: with Resample off, a pixel Width can't be edited and the readouts keep showing the + /// pixels OK keeps (they showed "200 px × 50 px" and "39K (was 20K)"). + #[test] + fn resample_off_keeps_pixel_dimensions_in_the_dialog() { + use egui_kittest::kittest::Queryable; + let (mut h, id) = resample_off_dialog("px"); + type_width(&mut h, "200"); + let f = fields(&h); + assert_eq!((num(&f, "width"), num(&f, "height"), num(&f, "resolution")), (100.0, 50.0, 72.0), "{f:?}"); + assert!(h.query_by_label("100 px × 50 px").is_some(), "the Dimensions line shows the kept pixels"); + assert!(h.query_by_label_contains("20K (was 20K)").is_some(), "the Image Size estimate is unchanged"); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let doc = &h.state().session.active().unwrap().doc; + assert_eq!((doc.size, doc.resolution_dpi), (photocraft_doc::Size::new(100, 50), 72.0)); + } + + /// With Resample off, a physical Width changes the resolution and keeps the pixels (Photoshop): + /// 100 px at 72 ppi is 1.39 in; 2 in wide is 50 ppi. + #[test] + fn resample_off_physical_width_changes_the_resolution() { + let (mut h, id) = resample_off_dialog("in"); + type_width(&mut h, "2"); + let f = fields(&h); + assert_eq!((num(&f, "width"), num(&f, "height")), (100.0, 50.0), "{f:?}"); + assert!((num(&f, "resolution") - 50.0).abs() < 1e-3, "{f:?}"); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let doc = &h.state().session.active().unwrap().doc; + assert_eq!(doc.size, photocraft_doc::Size::new(100, 50)); + assert!((doc.resolution_dpi - 50.0).abs() < 1e-3, "{}", doc.resolution_dpi); + } + + /// #2329: Image Size and Canvas Size open on Width with its value selected, as in Photoshop: + /// typing replaces it and Enter applies it. + #[test] + fn sizing_dialogs_open_on_width_selected() { + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + for command in ["image.imageSize", "image.canvasSize"] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + open(h.state_mut(), command).unwrap(); + h.run_steps(3); + assert!(h.query_all_by_role(Role::SpinButton).next().expect("the Width field").is_focused(), "{command}: Width has focus"); + h.event(egui::Event::Text("50".into())); + h.run_steps(1); + let f = h.state().ui.dialogs.first().map(|d| d.fields.clone()).expect("the dialog is open"); + assert_eq!(num(&f, "width"), 50.0, "{command}: typing replaced the selected 200"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert!(h.state().ui.dialogs.is_empty(), "{command}: Enter is OK"); + assert_eq!(h.state().session.active().unwrap().doc.size.width, 50, "{command}"); + } + } } diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index 65e5a214e6..99dc614ed9 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -26,6 +26,9 @@ pub enum Gesture { Move { rect: [f64; 4] }, /// Free Transform box dragged from inside: its bounds at the start of the drag. TransformMove { rect: [f64; 4] }, + /// A selection outline (or its floating piece) dragged with a selection tool: its bounds at + /// the start of the drag. + SelectionMove { rect: [f64; 4] }, /// A guide being moved with the Move tool. Guide, } @@ -76,23 +79,31 @@ fn build(app: &PhotocraftApp, exclude: &[LayerId], smart: bool) -> SnapTargets { if smart { t.filtered(SnapKind::is_smart) } else { t } } -/// What the Move tool drags: the selection's bounds where its pixels float (`move_ui`), else the -/// union of the selected layers' bounds. -fn moving_rect(app: &PhotocraftApp) -> Option<[f64; 4]> { +/// What a move with `tool` in effect drags: the selection's bounds where its pixels float +/// (`move_ui`), else the union of the selected layers' bounds. +fn moving_rect(app: &PhotocraftApp, tool: Tool) -> Option<[f64; 4]> { let st = app.session.active()?; - if crate::move_ui::moves_selected_pixels(app) { - let (dx, dy) = photocraft_engine::float_cmds::floating(st).map_or((0, 0), |f| f.offset); - let r = st.doc.selection.as_ref()?.content_bounds().translate(dx, dy); - return (!r.is_empty()).then(|| [r.x0, r.y0, r.x1, r.y1].map(f64::from)); + if crate::move_ui::moves_selected_pixels_with(app, tool) { + return selection_rect(app); } union(st.selected_layers().into_iter().filter_map(|id| layer_rect(&st.doc, id))) } +/// The selection's bounds where it is shown: moved by its floating piece's offset. +fn selection_rect(app: &PhotocraftApp) -> Option<[f64; 4]> { + let st = app.session.active()?; + let (dx, dy) = photocraft_engine::float_cmds::floating(st).map_or((0, 0), |f| f.offset); + let r = st.doc.selection.as_ref()?.content_bounds().translate(dx, dy); + (!r.is_empty()).then(|| [r.x0, r.y0, r.x1, r.y1].map(f64::from)) +} + fn is_point_tool(t: Tool) -> bool { matches!( t, Tool::RectMarquee | Tool::EllipseMarquee + | Tool::SingleRowMarquee + | Tool::SingleColumnMarquee | Tool::Crop | Tool::ObjectSelection | Tool::Pen @@ -140,14 +151,24 @@ fn override_held(mods: egui::Modifiers) -> bool { mods.ctrl && !mods.mac_cmd } -/// Start snapping for a drag beginning at `p` (called on pointer down). -fn begin(app: &mut PhotocraftApp, p: [f64; 2]) { +/// The Move tool's press at `p` picked other layers (Auto-Select): the drag moves those, so its +/// box, what it snaps and what it leaves out of the targets are theirs, not the ones selected +/// before (a group or an empty layer selected before had no box at all). +pub(crate) fn retarget_move(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { + begin(app, p, mods); +} + +/// Start snapping for a drag beginning at `p` with `mods` held (called on pointer down). +fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { app.prefs_rt.snap = None; app.prefs_rt.snap_lines.clear(); if app.session.active().is_none() { return; } - let tool = app.ui.tool; + // The tool the press acts as: ⌘ makes a painting tool the Move tool, and a selection tool + // outside the selection moves the layer as the Move tool does (#2681). + let tool = crate::canvas::event_tool(app, mods); + let moves_layer = crate::canvas::command_moves_layer(app, tool, p, mods); let tol = tolerance(app); let gesture = if let Some(t) = &app.ui.transform { if t.mode == crate::state::TransformMode::Distort { @@ -163,21 +184,31 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2]) { } } else if tool == Tool::Move && crate::rulers::guide_at(app, p[0], p[1]).is_some() { Some((Gesture::Guide, Vec::new())) - } else if tool == Tool::Move { + } else if tool == Tool::Move || moves_layer { let exclude = app.session.active().map(|s| s.selected_layers()).unwrap_or_default(); - moving_rect(app).map(|rect| (Gesture::Move { rect }, exclude)) + moving_rect(app, tool).map(|rect| (Gesture::Move { rect }, exclude)) } else if tool == Tool::Crop + && (crate::crop_mode::classic(app) || app.crop.default_frame) && let Some(rect) = app.ui.crop_rect.filter(|r| crate::crop_ui::angle(app) == 0.0 && crate::crop_ui::hit(*r, p, tol) == crate::crop_ui::Hit::Inside) { - // Moving the crop frame snaps its edges, like the Move tool's layer bounds. - Some((Gesture::Move { rect }, Vec::new())) + // Inside the untouched default frame draws a new crop (in both modes): snap its corner, not + // the old canvas-sized frame's bounds. An edited Classic Mode frame still moves and snaps + // its edges. + let gesture = if app.crop.default_frame { Gesture::Point } else { Gesture::Move { rect } }; + Some((gesture, Vec::new())) } else if tool == Tool::Crop && (crate::crop_ui::turns_at(app, p) || app.ui.crop_rect.is_some_and(|r| crate::crop_ui::hit_turned(r, crate::crop_ui::angle(app), p, tol) == crate::crop_ui::Hit::Inside)) { - // Turning the frame, or moving a turned one (its edges don't line up with anything): no - // snapping. + // Turning the frame, moving a turned one (its edges don't line up with anything), or in + // the default mode moving or turning the image under the box: no snapping. None + } else if let Some(cut) = crate::canvas::selection_drag_kind(app, tool, p, mods) { + // A marquee or lasso inside the selection moves the outline (⌘: cuts its pixels into a + // floating piece): its bounds snap, as the Move tool's do, not the pointer. A cut piece + // doesn't snap to its own layer. + let exclude = if cut { app.session.active().map(|s| s.selected_layers()).unwrap_or_default() } else { Vec::new() }; + selection_rect(app).map(|rect| (Gesture::SelectionMove { rect }, exclude)) } else if is_point_tool(tool) { Some((Gesture::Point, Vec::new())) } else { @@ -189,10 +220,22 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2]) { // A guide never snaps to itself (or other guides). targets = targets.filtered(|k| k != SnapKind::Guide); } + if matches!(gesture, Gesture::SelectionMove { .. }) { + // A moving selection never snaps back to where it started. + targets = targets.filtered(|k| k != SnapKind::Selection); + } let smart = if matches!(gesture, Gesture::Move { .. }) && smart_on(app) { build(app, &exclude, true) } else { SnapTargets::default() }; app.prefs_rt.snap = Some(ActiveSnap { gesture, start: p, targets, smart, disabled: false }); } +/// Snap a guide drag that ⌘ with a selection tool starts at `p` (`canvas::command_guide_at`, +/// #2690) as the Move tool's guide drag: never to guides, so not back onto itself. +pub fn begin_guide(app: &mut PhotocraftApp, p: [f64; 2]) { + app.prefs_rt.snap_lines.clear(); + let targets = if snap_on(app) { build(app, &[], false).filtered(|k| k != SnapKind::Guide) } else { SnapTargets::default() }; + app.prefs_rt.snap = Some(ActiveSnap { gesture: Gesture::Guide, start: p, targets, smart: SnapTargets::default(), disabled: false }); +} + /// Round to whole pixels when Preferences › Tools asks vector tools and transforms to snap to /// the pixel grid. fn pixel_round(app: &PhotocraftApp, p: [f64; 2]) -> [f64; 2] { @@ -215,7 +258,7 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2]) -> [f64; 2] { } let (out, lines) = match &snap.gesture { Gesture::Point | Gesture::Guide => snap.targets.snap_point(p, tol), - Gesture::Move { rect } | Gesture::TransformMove { rect } => { + Gesture::Move { rect } | Gesture::TransformMove { rect } | Gesture::SelectionMove { rect } => { let d = [p[0] - snap.start[0], p[1] - snap.start[1]]; let moved = [rect[0] + d[0], rect[1] + d[1], rect[2] + d[0], rect[3] + d[1]]; let set = if snap.targets.is_empty() { &snap.smart } else { &snap.targets }; @@ -231,7 +274,7 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2]) -> [f64; 2] { pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> ToolEvent { match ev { ToolEvent::Down { x, y, pressure } => { - begin(app, [x, y]); + begin(app, [x, y], mods); // Moves snap their delta, which is zero at the start: keep the press point. let p = match app.prefs_rt.snap.as_ref().map(|s| &s.gesture) { Some(Gesture::Point) => { @@ -350,6 +393,40 @@ mod tests { st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds() } + #[test] + fn drawing_a_new_crop_snaps_its_corner_not_the_default_frame() { + // In both crop modes: the default mode draws a new box inside the untouched frame too. + for (classic, snap, end) in [(true, false, [300.0, 220.0]), (true, true, [294.0, 217.0]), (false, false, [300.0, 220.0]), (false, true, [294.0, 217.0])] + { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 640, "height": 480, "background": "white"})).unwrap(); + app.run("shape.create", json!({"kind": "rect", "rect": [100, 80, 200, 140], "fill": "#d59b40"})).unwrap(); + app.sync_views(); + app.ppp = 2.0; + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + app.ui.tool = Tool::Crop; + app.ui.tool_options.crop_shield.classic_mode = classic; + app.ui.extras.snap = snap; + app.ui.view.show.smart_guides = true; + crate::crop_ui::ensure_frame(&mut app); + let view = app.ui.views[0].clone(); + let history = app.session.active().unwrap().history.past_len(); + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 80.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: end[0], y: end[1], pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: end[0], y: end[1] }, m); + // Smart Guides must not snap the old canvas-sized frame as if it were moving. + // With View Snap on, the new corner still snaps to the nearby shape's edges. + assert_eq!(app.ui.crop_rect, Some([100.0, 80.0, 300.0, 220.0]), "Classic Mode: {classic}, View Snap: {snap}"); + // The default mode's Auto Center Preview re-centres the drawn box; Classic Mode keeps the view. + if classic { + assert_eq!(app.ui.views[0], view); + } + assert_eq!(app.session.active().unwrap().history.past_len(), history); + } + } + #[test] fn move_tool_snaps_to_other_layer_edges_with_smart_guides() { let mut app = app_with_box(); @@ -364,6 +441,51 @@ mod tests { assert!(app.prefs_rt.snap.is_none()); } + /// ⌘ turns a painting tool, or a marquee outside the selection, into the Move tool for the + /// drag: it gets the Move tool's box and smart-guide snapping, not the selected tool's. + #[test] + fn command_drag_temporary_move_snaps_like_the_move_tool() { + for tool in [Tool::Brush, Tool::RectMarquee] { + let mut app = app_with_box(); + app.ui.tool = tool; + let cmd = egui::Modifiers { mac_cmd: true, command: true, ..Default::default() }; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, cmd); + let gesture = app.prefs_rt.snap.as_ref().map(|s| s.gesture.clone()); + assert_eq!(gesture, Some(Gesture::Move { rect: [40.0, 40.0, 90.0, 80.0] }), "{tool:?}: the mover's box"); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 323.0, y: 100.0 }, cmd); + assert_eq!(mover_bounds(&app).x0, 300, "{tool:?}: snapped to the target's edge"); + } + } + + /// The drag's box and snapping belong to the layer Auto-Select picks on the press, not to the + /// one selected before: another layer (its box would move instead), or an empty layer (no box, + /// no snapping at all). + #[test] + fn move_tool_box_and_snapping_follow_the_auto_selected_layer() { + for before in ["target", "empty"] { + let mut app = app_with_box(); + let mover = app.session.active().unwrap().active_layer.unwrap(); + if before == "empty" { + app.run("layer.new.layer", json!({"name": "empty"})).unwrap(); + } else { + let st = app.session.active().unwrap(); + let target = st.doc.walk().into_iter().find(|(_, _, l)| l.name == "target").unwrap().2.id; + app.run("layer.select", json!({"layer": target.0})).unwrap(); + } + app.ui.tool = Tool::Move; + app.ui.tool_options.move_auto_select = true; + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, m); + assert_eq!(app.session.active().unwrap().active_layer, Some(mover), "{before}: Auto-Select picked the mover"); + let gesture = app.prefs_rt.snap.as_ref().map(|s| s.gesture.clone()); + assert_eq!(gesture, Some(Gesture::Move { rect: [40.0, 40.0, 90.0, 80.0] }), "{before}: the box is the mover's"); + // The mover's left edge lands 3 px right of the target's and snaps to it. + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 323.0, y: 100.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 323.0, y: 100.0 }, m); + assert_eq!(mover_bounds(&app).x0, 300, "{before}: snapped to the target's edge"); + } + } + #[test] fn ctrl_or_snap_off_disables_snapping() { let mut app = app_with_box(); @@ -411,6 +533,32 @@ mod tests { assert_eq!(sel.x1, 116); } + /// Dragging inside a selection with a marquee or lasso moves the outline (⌘ cuts the pixels + /// into a floating piece): its bounds snap, not the pointer. + #[test] + fn selection_drag_snaps_the_selection_bounds() { + for (tool, cut) in [(Tool::RectMarquee, false), (Tool::Lasso, false), (Tool::RectMarquee, true)] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + app.run("view.newGuide", json!({"orientation": "vertical", "position": 220})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 100, "width": 50, "height": 40})).unwrap(); + app.ui.tool = tool; + let m = if cut { egui::Modifiers { mac_cmd: true, command: true, ..Default::default() } } else { egui::Modifiers::NONE }; + // The outline's right edge lands 2 px short of the guide (x = 218); the pointer ends + // 7 px from the document centre (x = 200), which it must not snap to. + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 125.0, y: 117.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 193.0, y: 117.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 193.0, y: 117.0 }, m); + let st = app.session.active().unwrap(); + let off = photocraft_engine::float_cmds::floating(st).map_or((0, 0), |f| f.offset); + let sel = st.doc.selection.as_ref().unwrap().content_bounds().translate(off.0, off.1); + assert_eq!((sel.x0, sel.y0, sel.x1, sel.y1), (170, 100, 220, 140), "{tool:?} cut: {cut}"); + } + } + #[test] fn distort_mode_never_snaps() { let mut app = app_with_box(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f1b5aea3c6..deb82b7885 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -65,6 +65,11 @@ pub enum Tool { Move, RectMarquee, EllipseMarquee, + /// Selects one pixel row across the whole canvas at the clicked y (Photoshop's flyout only: + /// no key, not in the ⇧M cycle). + SingleRowMarquee, + /// Selects one pixel column down the whole canvas at the clicked x. + SingleColumnMarquee, Lasso, PolygonLasso, MagneticLasso, @@ -118,10 +123,12 @@ pub enum Tool { } impl Tool { - pub const ALL: [Tool; 53] = [ + pub const ALL: [Tool; 55] = [ Tool::Move, Tool::RectMarquee, Tool::EllipseMarquee, + Tool::SingleRowMarquee, + Tool::SingleColumnMarquee, Tool::Lasso, Tool::PolygonLasso, Tool::MagneticLasso, @@ -179,6 +186,8 @@ impl Tool { Tool::Move => "Move Tool", Tool::RectMarquee => "Rectangular Marquee Tool", Tool::EllipseMarquee => "Elliptical Marquee Tool", + Tool::SingleRowMarquee => "Single Row Marquee Tool", + Tool::SingleColumnMarquee => "Single Column Marquee Tool", Tool::Brush => "Brush Tool", Tool::Pencil => "Pencil Tool", Tool::MixerBrush => "Mixer Brush Tool", @@ -278,6 +287,8 @@ impl Tool { Tool::CloneStamp | Tool::PatternStamp => 'S', Tool::HistoryBrush => 'Y', Tool::Blur | Tool::Sharpen | Tool::Smudge => '\0', + // In the Marquee flyout, but M cycles only the Rectangular and Elliptical Marquee. + Tool::SingleRowMarquee | Tool::SingleColumnMarquee => '\0', Tool::Dodge | Tool::Burn | Tool::Sponge => 'O', Tool::QuickSelection | Tool::ObjectSelection => 'W', Tool::Pen => 'P', @@ -532,6 +543,17 @@ pub struct ToolOptions { pub crop_ratio: String, #[serde(default = "yes")] pub crop_delete: bool, + /// Crop W x H x Resolution (`crop_ratio` = "whr", #2443): width and height as typed lengths + /// with their unit ("4 in", "1024 px"; empty = unset), the resolution ("" = the document's) + /// and its unit ("px/in" or "px/cm"). See `crop_size`. + #[serde(default)] + pub crop_width: String, + #[serde(default)] + pub crop_height: String, + #[serde(default)] + pub crop_resolution: String, + #[serde(default = "default_crop_resolution_unit")] + pub crop_resolution_unit: String, /// Crop overlay (#1919): the guide in the crop box, when it shows and its orientation /// (Photoshop's defaults: Rule of Thirds, Auto Show Overlay). See `crop_overlay`. #[serde(default)] @@ -558,6 +580,10 @@ pub struct ToolOptions { /// Pencil › Auto Erase: a stroke that starts on the foreground colour paints the background colour. #[serde(default)] pub pencil_auto_erase: bool, + /// Eraser › Mode: `brush` (soft, anti-aliased) or `pencil` (hard, aliased pixels, through + /// `paint.pencil`), as in Photoshop's options bar (#2662). + #[serde(default = "default_eraser_mode")] + pub eraser_mode: String, /// Magnetic Lasso: detection width (px, 1..256), edge contrast (%, 1..100), how often it /// fastens points by itself (0..100), and whether pen pressure narrows the width. pub magnetic_width: f32, @@ -587,10 +613,18 @@ fn yes() -> bool { true } +fn default_crop_resolution_unit() -> String { + crate::crop_size::PX_PER_IN.into() +} + fn default_move_target() -> String { "layer".into() } +fn default_eraser_mode() -> String { + "brush".into() +} + fn default_marquee_style() -> String { "normal".into() } @@ -674,6 +708,10 @@ impl Default for ToolOptions { move_show_transform: false, crop_ratio: String::new(), crop_delete: true, + crop_width: String::new(), + crop_height: String::new(), + crop_resolution: String::new(), + crop_resolution_unit: default_crop_resolution_unit(), crop_overlay: Default::default(), crop_overlay_show: Default::default(), crop_overlay_orientation: 0, @@ -685,6 +723,7 @@ impl Default for ToolOptions { bg_protect_fg: false, zoom_scrubby: true, pencil_auto_erase: false, + eraser_mode: default_eraser_mode(), magnetic_width: 10.0, magnetic_contrast: 10.0, magnetic_frequency: 57.0, @@ -844,6 +883,10 @@ pub struct ColorPanelState { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct UiState { pub tool: Tool, + /// The last tool used in each shortcut group (the tools sharing a key, [`Tool::key`]), at most + /// one per group: the group's key brings it back, as in Photoshop (#2608). + #[serde(default)] + pub group_tools: Vec, /// Recently opened file paths, most-recent first (File › Open Recent). Capped; de-duplicated. #[serde(default)] pub recent_files: Vec, @@ -1034,6 +1077,7 @@ impl Default for UiState { fn default() -> Self { Self { tool: Tool::Brush, + group_tools: Vec::new(), recent_files: Vec::new(), text_edit: None, type_transform: None, @@ -1107,6 +1151,20 @@ impl Default for UiState { } impl UiState { + /// Records the current tool as the last one used in its shortcut group. + pub fn remember_group_tool(&mut self) { + let tool = self.tool; + if !self.group_tools.contains(&tool) { + self.group_tools.retain(|t| t.key() != tool.key()); + self.group_tools.push(tool); + } + } + + /// The last tool used in the shortcut group of `key`, if any. + pub fn group_tool(&self, key: char) -> Option { + self.group_tools.iter().copied().find(|t| t.key() == key) + } + pub fn alloc_id(&mut self) -> u64 { let id = self.next_id; self.next_id += 1; @@ -1152,7 +1210,7 @@ mod tests { assert!(!Tool::RedEye.is_brushlike()); assert_eq!(Tool::from_name("patternStamp"), Some(Tool::PatternStamp)); assert_eq!(Tool::from_name("Pattern Stamp Tool"), Some(Tool::PatternStamp)); - assert_eq!(Tool::ALL.len(), 53); + assert_eq!(Tool::ALL.len(), 55); assert_eq!(Tool::from_name("Remove Tool"), Some(Tool::Remove)); assert_eq!(Tool::Remove.key(), 'J'); assert!(Tool::Remove.is_brushlike()); diff --git a/crates/ui-egui/src/stroke_constraint.rs b/crates/ui-egui/src/stroke_constraint.rs index 731b79d570..d16db5d0dc 100644 --- a/crates/ui-egui/src/stroke_constraint.rs +++ b/crates/ui-egui/src/stroke_constraint.rs @@ -355,7 +355,7 @@ mod tests { let xf = { let st = h.state(); let v = st.ui.views[st.session.active_index().unwrap()].clone(); - crate::canvas::ViewXform { rect: st.last_canvas_rect, zoom: v.zoom, center: v.center, flip: false, rotation: v.rotation } + crate::canvas::ViewXform { rect: st.last_canvas_rect, zoom: v.zoom, center: v.center, flip: false, rotation: v.rotation, aspect: 1.0 } }; let from = xf.to_screen(end[0] as f32, end[1] as f32); h.event(Event::PointerMoved(target)); diff --git a/crates/ui-egui/src/stroke_timing_tests.rs b/crates/ui-egui/src/stroke_timing_tests.rs index 94dfe9f1c7..578c0c531a 100644 --- a/crates/ui-egui/src/stroke_timing_tests.rs +++ b/crates/ui-egui/src/stroke_timing_tests.rs @@ -43,6 +43,7 @@ fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation, + aspect: app.ui.view.display_aspect(), }; xf.to_screen(x, y) } @@ -181,3 +182,120 @@ fn the_pressure_curve_shapes_the_committed_stroke() { let pts = committed_points(&h); assert!(pts.iter().all(|p| (p[2] - 0.6).abs() < 1e-3), "pen 0.3 through the curve is 0.6: {pts:?}"); } + +/// A Brush whose size follows pen pressure, painting on a new empty layer. +fn pressure_sized_brush(h: &mut Harness<'static, PhotocraftApp>) { + let app = h.state_mut(); + app.run("layer.new.layer", json!({})).unwrap(); + let brush = json!({"size": 40, "hardness": 1.0, "smoothing": {"amount": 0.0}, "shapeDynamics": {"enabled": true, "size": {"control": "penPressure"}}}); + app.run("tools.setBrush", json!({ "brush": brush })).unwrap(); + h.run_steps(1); +} + +/// Width in pixels of the paint on the active layer along row `y`. +fn painted_width(h: &Harness<'static, PhotocraftApp>, y: i32) -> usize { + let st = h.state().session.active().expect("a document"); + let s = st.doc.layer(st.active_layer.expect("an active layer")).and_then(|l| l.surface()).expect("a raster layer"); + (0..200).filter(|x| s.rgba(*x, y)[3] > 0.5).count() +} + +/// Press and release at (`x`, 50) in one frame, the pen reporting `samples` (`None`: cleared, as +/// a lifting pen or a mouse leaves it) before the UI runs: a tap shorter than a frame. +fn tap(h: &mut Harness<'static, PhotocraftApp>, x: f32, samples: &[Option]) { + use crate::stylus::PenSample; + move_to(h, x, 50.0); + for p in samples { + h.state().stylus.feed.set(p.map(|pressure| PenSample { pressure, ..Default::default() })); + } + let q = screen(h, x, 50.0); + for pressed in [true, false] { + h.input_mut().events.push(egui::Event::PointerButton { pos: q, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(3); +} + +#[test] +fn a_tap_shorter_than_a_frame_paints_at_the_pens_pressure() { + // #1798: the pen lifted (its sample cleared) before the UI ran, so the tap read as a mouse + // and painted a full-size dab whatever the pressure. + let mut h = harness(); + pressure_sized_brush(&mut h); + tap(&mut h, 50.0, &[Some(0.2), None]); + assert!((committed_points(&h)[0][2] - 0.2).abs() < 1e-3, "{:?}", committed_points(&h)); + tap(&mut h, 150.0, &[Some(0.9), None]); + assert!((committed_points(&h)[0][2] - 0.9).abs() < 1e-3, "{:?}", committed_points(&h)); + let st = h.state().session.active().unwrap(); + let s = st.doc.layer(st.active_layer.unwrap()).and_then(|l| l.surface()).unwrap(); + let width = |x0: i32, x1: i32| (x0..x1).filter(|x| s.rgba(*x, 50)[3] > 0.5).count() as f64; + let (light, firm) = (width(0, 100), width(100, 200)); + assert!((light / 40.0 - 0.2).abs() < 0.1, "a light tap is a small dab: {light} px"); + assert!((firm / 40.0 - 0.9).abs() < 0.1, "a firm tap is a large dab: {firm} px"); +} + +#[test] +fn a_tap_never_paints_at_the_lifts_zero_pressure() { + // X11 and a hovering macOS pen report pressure 0 around the contact. + let mut h = harness(); + pressure_sized_brush(&mut h); + tap(&mut h, 50.0, &[Some(0.0), Some(0.6), Some(0.0)]); + assert!((committed_points(&h)[0][2] - 0.6).abs() < 1e-3, "{:?}", committed_points(&h)); +} + +#[test] +fn a_tap_over_two_frames_paints_at_the_pressure_it_reached() { + use crate::stylus::PenSample; + let mut h = harness(); + pressure_sized_brush(&mut h); + let pen = |p: f32| Some(PenSample { pressure: p, ..Default::default() }); + move_to(&mut h, 50.0, 50.0); + h.state().stylus.feed.set(pen(0.1)); + button(&mut h, 50.0, 50.0, true); + h.state().stylus.feed.set(pen(0.5)); + h.state().stylus.feed.set(pen(0.0)); + button(&mut h, 50.0, 50.0, false); + h.run_steps(2); + let pts = committed_points(&h); + assert_eq!(pts.len(), 1, "{pts:?}"); + assert!((pts[0][2] - 0.5).abs() < 1e-3, "the tap's peak, not its first touch: {pts:?}"); +} + +#[test] +fn a_mouse_tap_paints_a_full_dab() { + let mut h = harness(); + pressure_sized_brush(&mut h); + tap(&mut h, 50.0, &[]); + assert_eq!(committed_points(&h)[0][2], 1.0); + let w = painted_width(&h, 50) as f64; + assert!((w - 40.0).abs() <= 3.0, "full size: {w} px"); +} + +#[test] +fn a_stroke_ends_at_the_last_pressure_the_pen_drew_with() { + use crate::stylus::PenSample; + // The lift either clears the pen (macOS proximity, a non-tablet button-up) or reports 0 + // (X11) before the UI runs the frame with the stroke's last moves. + for lift in [None, Some(0.0)] { + let mut h = harness(); + pressure_sized_brush(&mut h); + let pen = |p: f32| Some(PenSample { pressure: p, ..Default::default() }); + h.state().stylus.feed.set(pen(0.4)); + move_to(&mut h, 20.0, 50.0); + button(&mut h, 20.0, 50.0, true); + for x in [30.0, 40.0] { + h.state().stylus.feed.set(pen(0.4)); + move_to(&mut h, x, 50.0); + } + // One frame: the last moves, then the lift. + for x in [50.0, 60.0] { + h.state().stylus.feed.set(pen(0.4)); + let q = screen(&h, x, 50.0); + h.input_mut().events.push(egui::Event::PointerMoved(q)); + } + h.state().stylus.feed.set(lift.map(|p| PenSample { pressure: p, ..Default::default() })); + let q = screen(&h, 60.0, 50.0); + h.input_mut().events.push(egui::Event::PointerButton { pos: q, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let pts = committed_points(&h); + assert!(pts.iter().all(|p| (p[2] - 0.4).abs() < 1e-3), "lift {lift:?}: no full or zero pressure dab at the end: {pts:?}"); + } +} diff --git a/crates/ui-egui/src/stroke_trail.rs b/crates/ui-egui/src/stroke_trail.rs index 4d1fe86994..6a5465ca7f 100644 --- a/crates/ui-egui/src/stroke_trail.rs +++ b/crates/ui-egui/src/stroke_trail.rs @@ -2,11 +2,14 @@ //! Selection show while dragging: the union of the brush's round footprint along the drag. //! //! It is a coverage mask in document space, stamped segment by segment as the pointer moves and -//! drawn as one texture over the document. It used to be an egui polyline as wide as the brush: +//! drawn as one texture over the document. The Spot Healing Brush's trail is instead the coverage +//! of the brush's own dabs ([`Trail::feed_dabs`]): hardness, pressure and shape dynamics show as +//! they heal (#2364). It used to be an egui polyline as wide as the brush: //! egui tessellates a polyline whose width is much larger than its steps (any zoomed-in drag) //! into hard pie wedges fanning out across the canvas (#189), and translucent overlaps doubled up. use egui::{Color32, ColorImage, Rect, TextureHandle, TextureOptions, pos2}; +use photocraft_paint::{BrushSettings, StrokePoint, StrokeRenderer}; /// Longest side of the mask, in mask pixels (big documents are stamped at a reduced scale). const MAX_SIDE: u32 = 1024; @@ -26,13 +29,22 @@ pub struct Trail { /// Mask pixels changed since the last upload (x0, y0, x1, y1), if any. dirty: Option<[usize; 4]>, tex: Option, + /// The stroke's dabs, when the trail shows their coverage ([`Self::feed_dabs`]). + dabs: Option, +} + +/// The brush stroke behind a [`Trail::feed_dabs`] trail. +struct Dabs { + renderer: StrokeRenderer, + /// Document pixels the last "finish now" tail (a lone first dab, smoothing catch-up) covered. + tail: photocraft_geom::Rect, } impl Trail { pub fn new(doc: [u32; 2]) -> Self { let k = doc[0].max(doc[1]).div_ceil(MAX_SIDE).max(1); let (w, h) = (doc[0].div_ceil(k).max(1) as usize, doc[1].div_ceil(k).max(1) as usize); - Self { doc, k: k as f32, w, h, cov: vec![0; w * h], fed: 0, dirty: None, tex: None } + Self { doc, k: k as f32, w, h, cov: vec![0; w * h], fed: 0, dirty: None, tex: None, dabs: None } } /// Coverage at a mask pixel (tests). @@ -62,6 +74,63 @@ impl Trail { self.fed = points.len(); } + /// Show the coverage the brush's own dabs leave along the drag, as the stroke commit renders + /// it (`photocraft_paint::retouch::stroke_coverage`): soft edges for a soft brush, the size and + /// shape each dab takes from pen pressure and dynamics. `brush` is asked for once, on the first + /// call. What finishing the stroke now would add (the press's lone first dab) shows too. + pub fn feed_dabs(&mut self, points: &[[f64; 3]], brush: impl FnOnce() -> BrushSettings) { + if self.dabs.is_some() && self.fed >= points.len() { + return; + } + let mut dabs = self.dabs.take().unwrap_or_else(|| Dabs { renderer: StrokeRenderer::new(&brush(), None, 1.0), tail: photocraft_geom::Rect::EMPTY }); + let new: Vec = points + .get(self.fed..) + .unwrap_or(&[]) + .iter() + .filter(|p| p.iter().all(|v| v.is_finite()) && p[0].abs() < 1e7 && p[1].abs() < 1e7) + .map(|p| StrokePoint::new(p[0], p[1], p[2] as f32)) + .collect(); + self.fed = points.len(); + dabs.renderer.push(&new); + let mut changed = dabs.renderer.take_dirty_rect().union(&dabs.tail); + let tail = dabs.renderer.tail_preview().map(|mut t| (t.take_dirty_rect(), t)); + dabs.tail = tail.as_ref().map_or(photocraft_geom::Rect::EMPTY, |(r, _)| *r); + changed = changed.union(&dabs.tail); + let tail = tail.map(|(_, t)| t); + let renderer = &dabs.renderer; + self.restamp(changed, |x, y| renderer.coverage_at(x, y).max(tail.as_ref().map_or(0.0, |t| t.coverage_at(x, y)))); + self.dabs = Some(dabs); + } + + /// Recompute the mask pixels over the document rectangle `r` from `cov` (document pixel → + /// coverage 0..=1), sampled at each mask pixel's centre. + fn restamp(&mut self, r: photocraft_geom::Rect, cov: impl Fn(i32, i32) -> f32) { + if r.is_empty() { + return; + } + let k = f64::from(self.k); + let x0 = (f64::from(r.x0) / k).floor().max(0.0) as usize; + let y0 = (f64::from(r.y0) / k).floor().max(0.0) as usize; + let x1 = (f64::from(r.x1) / k).ceil().clamp(0.0, self.w as f64) as usize; + let y1 = (f64::from(r.y1) / k).ceil().clamp(0.0, self.h as f64) as usize; + if x1 <= x0 || y1 <= y0 { + return; + } + for y in y0..y1 { + let dy = ((y as f64 + 0.5) * k).floor() as i32; + for x in x0..x1 { + let dx = ((x as f64 + 0.5) * k).floor() as i32; + let c = cov(dx, dy); + let c = if c.is_finite() { (c.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }; + if let Some(v) = self.cov.get_mut(y * self.w + x) { + *v = c; + } + } + } + let d = self.dirty.get_or_insert([x0, y0, x1, y1]); + *d = [d[0].min(x0), d[1].min(y0), d[2].max(x1), d[3].max(y1)]; + } + fn capsule(&mut self, a: [f64; 2], b: [f64; 2], r: f64) { if !(a.iter().chain(&b).all(|v| v.is_finite()) && r.is_finite()) { return; diff --git a/crates/ui-egui/src/stylus.rs b/crates/ui-egui/src/stylus.rs index f301414708..6bee705147 100644 --- a/crates/ui-egui/src/stylus.rs +++ b/crates/ui-egui/src/stylus.rs @@ -128,6 +128,8 @@ pub struct Stylus { prev: Option, /// The sample [`Self::select`] interpolated for the pointer move being processed. current: Option, + /// The highest pen pressure (before the curve) since the drag began, for a tap's one dab. + peak: f32, /// The egui frame [`Self::update_for_frame`] last ran in. updated_frame: Option, /// Tilt X, tilt Y, rotation of each point of the current drag (parallel to its points). @@ -154,6 +156,7 @@ impl Default for Stylus { frame: Vec::new(), prev: None, current: None, + peak: 0.0, updated_frame: None, stroke: Vec::new(), times: Vec::new(), @@ -195,6 +198,25 @@ impl Stylus { } } } + // A pen reports pressure 0 while it hovers and as it lifts (X11, macOS): that is no + // contact, so a frame that also touched keeps only its contact samples. A stroke's last + // moves then keep the pressure the pen left with, and a tap its own (#1798). + if self.frame.iter().any(|s| s.pressure > 0.0) { + self.frame.retain(|s| s.pressure > 0.0); + } + self.peak = self.frame.iter().fold(self.peak, |m, s| m.max(s.pressure)); + } + + /// The pen's current sample. When the pen touched during this frame but has lifted since + /// (the feed is cleared or back at pressure 0 by the time the UI runs, as for a tap shorter + /// than a frame), the frame's last contact sample: never full pressure as for a mouse, and + /// never the lift's 0. + fn latest(&self) -> Option { + let feed = self.feed.get(); + match self.frame.last() { + Some(s) if s.pressure > 0.0 && feed.is_none_or(|f| f.pressure <= 0.0) => Some(*s), + _ => feed, + } } /// The current pen sample, `None` for a mouse (and for any pen while Use Tablet Pressure is @@ -203,7 +225,7 @@ impl Stylus { if !self.use_pressure { return None; } - let s = self.current.or_else(|| self.feed.get()).or(self.touch.map(|pressure| PenSample { pressure, ..Default::default() }))?; + let s = self.current.or_else(|| self.latest()).or(self.touch.map(|pressure| PenSample { pressure, ..Default::default() }))?; // The pen's pressure through Preferences › Tools › Pressure Curve, before any brush sees it. Some(match &self.pressure_curve { Some(c) => PenSample { pressure: c.eval(s.pressure), ..s }, @@ -293,11 +315,24 @@ impl Stylus { self.sample().map_or(1.0, |s| s.pressure) } + /// Pressure for a tap, a drag released where it was pressed: the highest the pen pressed + /// since the drag began (through the pressure curve), as the first sample of a contact is + /// often far lighter than the tap. 1 for a mouse. + pub(crate) fn tap_pressure(&self) -> f32 { + let Some(s) = self.sample() else { return 1.0 }; + let peak = match &self.pressure_curve { + Some(c) => c.eval(self.peak), + None => self.peak, + }; + if self.peak > 0.0 { peak.max(s.pressure) } else { s.pressure } + } + /// Start recording a drag's tilt/rotation. pub(crate) fn begin_stroke(&mut self) { // A new stroke's first moves interpolate from this frame's samples, never from a sample // left over from an earlier stroke. self.prev = None; + self.peak = self.frame.iter().fold(0.0, |m, s| m.max(s.pressure)); self.stroke.clear(); self.times.clear(); self.record_point(); diff --git a/crates/ui-egui/src/symmetry_ui/tests.rs b/crates/ui-egui/src/symmetry_ui/tests.rs index 066ca3f884..3510e4c83b 100644 --- a/crates/ui-egui/src/symmetry_ui/tests.rs +++ b/crates/ui-egui/src/symmetry_ui/tests.rs @@ -169,7 +169,8 @@ fn overlays_follow_document_view_transforms_in_every_theme() { app.run("paint.setSymmetry", json!({"mode":"dual"})).unwrap(); let ctx = Context::default(); crate::theme::apply(&ctx, theme); - let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(500.0, 400.0)), zoom: 1.7, center: [50.0, 40.0], rotation: 37.0, flip: true }; + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(500.0, 400.0)), zoom: 1.7, center: [50.0, 40.0], rotation: 37.0, flip: true, aspect: 1.0 }; let mut out = ctx.run_ui(RawInput { screen_rect: Some(xf.rect), ..Default::default() }, |ui| { let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("symmetry"))); draw(&app, &painter, &xf); diff --git a/crates/ui-egui/src/tab_strip.rs b/crates/ui-egui/src/tab_strip.rs index b91480583a..576bb0cb77 100644 --- a/crates/ui-egui/src/tab_strip.rs +++ b/crates/ui-egui/src/tab_strip.rs @@ -2,6 +2,7 @@ //! the panel menu button they shrink, eliding their labels with '…', and once they are at their //! minimum width the ones that still don't fit move into a » chevron menu at the end of the //! strip. The selected tab always stays on the strip, and no tab ever runs under the menu button. +//! On a reorderable strip a tab dragged along the strip moves to where it is dropped (#2272). use egui::{CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; @@ -40,6 +41,13 @@ pub struct StripOut { pub responses: Vec<(usize, Response)>, /// The » overflow button, when some tabs didn't fit. pub chevron: Option, + /// A tab was dragged along the strip and dropped: `(tab, before)`, draw `tab` just before + /// tab `before` (`None`: last). + pub reorder: Option<(usize, Option)>, + /// A tab is being dragged away from the strip (the dock moves the whole group then). + pub dragging_out: bool, + /// A tab drag ended away from the strip. + pub dropped_out: bool, } /// Width of the » overflow button. @@ -83,6 +91,7 @@ fn tabs_in( font: egui::FontId, pad: f32, min_w: f32, + reorderable: bool, active: impl Fn(usize) -> bool, mut paint_tab: impl FnMut(&Ui, Rect, usize, std::sync::Arc, &Response, bool), ) -> StripOut { @@ -100,18 +109,33 @@ fn tabs_in( tabs: Vec::with_capacity(f.shown.len()), responses: Vec::with_capacity(f.shown.len()), chevron: None, + reorder: None, + dragging_out: false, + dropped_out: false, }; + // Without reordering, drags on a tab fall through to the strip behind it. + let sense = if reorderable { Sense::click_and_drag() } else { Sense::click() }; + let mut drag: Option<(usize, bool)> = None; for &(i, w) in &f.shown { let Some(name) = tabs.get(i) else { continue }; let r = Rect::from_min_size(pos2(x, area.top()), vec2(w, area.height())); // The padding gives way (down to a third) before the label is cut. let galley = elided(ui, name, font.clone(), t.text, (w - pad / 3.0).max(1.0)); let cut = galley.size().x + pad + 0.5 < natural.get(i).copied().unwrap_or(0.0) && galley.size().x + pad / 3.0 >= w - 0.5; - let resp = craft_ui::tabs::Tab::new(id.with(("tab", i)), name, i == *selected) - .sense(Sense::click_and_drag()) - .focus_stroke(Stroke::new(1.0, t.accent)) - .show_at(ui, r, |ui, resp| paint_tab(ui, r, i, galley, resp, active(i))); + let resp = craft_ui::tabs::Tab::new(id.with(("tab", i)), name, i == *selected).sense(sense).focus_stroke(Stroke::new(1.0, t.accent)).show_at( + ui, + r, + |ui, resp| paint_tab(ui, r, i, galley, resp, active(i)), + ); let resp = if cut { resp.on_hover_text(*name) } else { resp }; + if resp.drag_started() { + *selected = i; + } + if resp.dragged() { + drag = Some((i, false)); + } else if resp.drag_stopped() { + drag = Some((i, true)); + } out.double_clicked |= resp.double_clicked(); if resp.clicked() { out.clicked = true; @@ -143,12 +167,43 @@ fn tabs_in( } out.chevron = Some(r); } + if let Some((tab, released)) = drag + && let Some(p) = ui.ctx().pointer_interact_pos().or_else(|| ui.ctx().pointer_latest_pos()) + { + match drop_slot(&out.tabs, area, p) { + Some(slot) => { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + let before = match out.tabs.get(slot) { + Some((b, _)) => Some(*b), + // After the last tab on the strip: before the first overflowing one, if any. + None => out.tabs.last().map(|(l, _)| l + 1).filter(|b| *b < tabs.len()), + }; + if released { + out.reorder = Some((tab, before)); + } else if let Some((_, r)) = out.tabs.get(slot.min(out.tabs.len().saturating_sub(1))) { + crate::widgets::drop_line(ui, *r, slot >= out.tabs.len(), true, &t); + } + } + None if released => out.dropped_out = true, + None => out.dragging_out = true, + } + } out } +/// Where a tab dragged to `p` lands: before the `n`th tab on the strip (`tabs.len()`: after the +/// last), or `None` once the pointer has left the strip. +pub fn drop_slot(tabs: &[(usize, Rect)], area: Rect, p: egui::Pos2) -> Option { + if !area.expand2(vec2(24.0, area.height())).contains(p) { + return None; + } + Some(tabs.iter().filter(|(_, r)| r.center().x < p.x).count()) +} + /// Photoshop-grammar strip (Pro themes): flat tabs on the dark strip; `menu_left` is where the /// panel menu button starts. -pub fn pro_tabs(ui: &mut Ui, id: egui::Id, strip: Rect, menu_left: f32, tabs: &[&str], selected: &mut usize, collapsed: bool) -> StripOut { +#[allow(clippy::too_many_arguments)] +pub fn pro_tabs(ui: &mut Ui, id: egui::Id, strip: Rect, menu_left: f32, tabs: &[&str], selected: &mut usize, collapsed: bool, reorderable: bool) -> StripOut { let t = Tokens::get(ui.ctx()); let area = Rect::from_min_max(strip.min, pos2(menu_left.max(strip.left()), strip.bottom())); let sel = *selected; @@ -161,6 +216,7 @@ pub fn pro_tabs(ui: &mut Ui, id: egui::Id, strip: Rect, menu_left: f32, tabs: &[ egui::FontId::proportional(11.5), 22.0, 40.0, + reorderable, |i| i == sel && !collapsed, |ui, r, i, galley, resp, active| { if active { @@ -181,7 +237,7 @@ pub fn pro_tabs(ui: &mut Ui, id: egui::Id, strip: Rect, menu_left: f32, tabs: &[ } /// Studio strip: pill tabs in `area` (the row minus the menu button). -pub fn pill_tabs(ui: &mut Ui, id: egui::Id, area: Rect, tabs: &[&str], selected: &mut usize) -> StripOut { +pub fn pill_tabs(ui: &mut Ui, id: egui::Id, area: Rect, tabs: &[&str], selected: &mut usize, reorderable: bool) -> StripOut { let t = Tokens::get(ui.ctx()); let sel = *selected; tabs_in( @@ -193,6 +249,7 @@ pub fn pill_tabs(ui: &mut Ui, id: egui::Id, area: Rect, tabs: &[&str], selected: theme::medium(12.5), 20.0, 44.0, + reorderable, |i| i == sel, |ui, r, _, galley, resp, active| { // 2 pt between pills, as the old horizontal layout had. @@ -270,7 +327,8 @@ mod tests { let area = Rect::from_min_size(pos2(20.0, 20.0), vec2(width, 26.0)); let mut selected = 3; let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { - let out = pro_tabs(ui, ui.id().with("tiny-tabs"), area, area.right(), &["Color", "Swatches", "Gradients", "Patterns"], &mut selected, false); + let out = + pro_tabs(ui, ui.id().with("tiny-tabs"), area, area.right(), &["Color", "Swatches", "Gradients", "Patterns"], &mut selected, false, false); assert!(out.tabs.iter().any(|(i, _)| *i == 3)); for (_, rect) in &out.tabs { assert!(rect.left() >= area.left() && rect.right() <= area.right() + 0.001, "{width}: {rect:?}"); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 83074d8c86..17bd3b439d 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -9,6 +9,9 @@ //! with the rest of a GNOME desktop. //! - **Solarized Dark**: Ethan Schoonover's Solarized palette (base03/base02 surfaces, base0 text, //! Solarized blue accent) with the Studio layout. +//! - **Breeze** (Light and Dark): the Studio layout adapted to the look of KDE Plasma's Breeze +//! (blue-grey surfaces, Breeze blue accent, 5 px corners), so the app sits with the rest of a +//! Plasma desktop. //! //! Widgets read [`Tokens::get`] instead of hard-coding colours, so every theme applies everywhere. @@ -34,10 +37,14 @@ pub enum ThemeKind { /// Solarized Dark (Ethan Schoonover's palette): the base03 background, base02 panels and /// base0 body text, with the Solarized blue accent. SolarizedDark, + /// An adaptation of KDE Plasma's Breeze Light look. + BreezeLight, + /// An adaptation of KDE Plasma's Breeze Dark look. + BreezeDark, } impl ThemeKind { - pub const ALL: [ThemeKind; 8] = [ + pub const ALL: [ThemeKind; 10] = [ ThemeKind::Pro, ThemeKind::ProMedium, ThemeKind::Studio, @@ -46,6 +53,8 @@ impl ThemeKind { ThemeKind::SolarizedDark, ThemeKind::Adwaita, ThemeKind::AdwaitaDark, + ThemeKind::BreezeLight, + ThemeKind::BreezeDark, ]; pub fn label(self) -> &'static str { match self { @@ -57,6 +66,8 @@ impl ThemeKind { ThemeKind::Adwaita => "Adwaita (Light)", ThemeKind::AdwaitaDark => "Adwaita (Dark)", ThemeKind::SolarizedDark => "Solarized Dark", + ThemeKind::BreezeLight => "Breeze Light", + ThemeKind::BreezeDark => "Breeze Dark", } } /// The canonical name: `ui.set {theme}` accepts it and the Window › Theme commands are @@ -71,6 +82,8 @@ impl ThemeKind { ThemeKind::Adwaita => "adwaita", ThemeKind::AdwaitaDark => "adwaitaDark", ThemeKind::SolarizedDark => "solarizedDark", + ThemeKind::BreezeLight => "breezeLight", + ThemeKind::BreezeDark => "breezeDark", } } pub fn next(self) -> Self { @@ -87,6 +100,8 @@ impl ThemeKind { "adwaita" | "adwaitalight" | "gnome" | "gnomelight" => Some(ThemeKind::Adwaita), "adwaitadark" | "gnomedark" => Some(ThemeKind::AdwaitaDark), "solarizeddark" | "solarized" => Some(ThemeKind::SolarizedDark), + "breezelight" | "breeze" | "kde" | "kdelight" | "plasma" | "plasmalight" => Some(ThemeKind::BreezeLight), + "breezedark" | "kdedark" | "plasmadark" => Some(ThemeKind::BreezeDark), _ => None, } } @@ -422,6 +437,102 @@ impl Tokens { caption_close_text: Color32::WHITE, scrim: Color32::from_black_alpha(110), }, + // The light counterpart, adapted the same way: our own values, chosen a few levels + // from Breeze Light's surfaces so the app blends into a Plasma desktop without being a + // copy. Breeze's own inactive text and link blue are only about 2.8:1 on such + // surfaces, so the secondary and faint text, the accent and danger text and the status + // hues are darker to hold 4.5:1 (checked by the tests), and the primary button is a + // deeper blue that takes white text. + ThemeKind::BreezeLight => Tokens { + kind, + chrome: Color32::from_rgb(219, 222, 226), + canvas: Color32::from_rgb(219, 222, 226), + canvas_dot: Color32::from_rgb(219, 222, 226), + dock: Color32::from_rgb(224, 227, 231), + card: Color32::from_rgb(236, 238, 241), + card_border: Color32::from_rgb(203, 208, 213), + field: Color32::from_rgb(249, 250, 252), + field_border: Color32::from_rgb(184, 191, 198), + hover: Color32::from_rgb(226, 230, 235), + pressed: Color32::from_rgb(208, 214, 221), + text: Color32::from_rgb(33, 37, 42), + text_dim: Color32::from_rgb(54, 63, 72), + text_faint: Color32::from_rgb(81, 90, 100), + icon: Color32::from_rgb(52, 58, 64), + accent: Color32::from_rgb(58, 172, 232), + accent_soft: Color32::from_rgba_unmultiplied(58, 172, 232, 48), + accent_border: Color32::from_rgba_unmultiplied(58, 172, 232, 170), + accent_text: Color32::from_rgb(30, 94, 136), + separator: Color32::from_rgb(214, 218, 222), + shadow: Color32::from_black_alpha(45), + primary_bg: Color32::from_rgb(35, 122, 180), + primary_text: Color32::WHITE, + danger: Color32::from_rgb(170, 50, 64), + warning: Color32::from_rgb(153, 72, 0), + radius_sm: 5.0, + radius: 5.0, + radius_lg: 6.0, + bevel: false, + pro: false, + round_chips: true, + tab_strip: Color32::TRANSPARENT, + row_selected: Color32::TRANSPARENT, + histogram_bg: Color32::from_rgb(33, 37, 42), + histogram_level: 240, + caption_close: Color32::from_rgb(213, 51, 60), + caption_close_text: Color32::WHITE, + scrim: Color32::from_black_alpha(110), + }, + // An adaptation of KDE Plasma's Breeze Dark look on the Studio layout, not a copy: our + // own values, chosen a few levels from Breeze Dark's surfaces (the darkest canvas, then + // the dock, the cards, and the chrome and controls) so the app blends into a Plasma + // desktop. Near-white text, the blue accent and the orange warning hue sit close to + // Breeze's, with its 5 px frame radius. + // Readability wins over fidelity: every text colour holds 4.5:1 on every surface and on + // the hover, pressed and selected states (checked by the tests), so the secondary and + // faint text, the accent and danger text and the status hues are lighter than Breeze's, + // hover and pressed are small steps, and the primary button is a deeper blue that takes + // white text. Outlines and those steps are our own tints. + ThemeKind::BreezeDark => Tokens { + kind, + chrome: Color32::from_rgb(38, 42, 48), + canvas: Color32::from_rgb(17, 20, 24), + canvas_dot: Color32::from_rgb(17, 20, 24), + dock: Color32::from_rgb(26, 29, 34), + card: Color32::from_rgb(29, 33, 38), + card_border: Color32::from_rgb(52, 56, 61), + field: Color32::from_rgb(38, 42, 48), + field_border: Color32::from_rgb(68, 73, 79), + hover: Color32::from_rgb(50, 54, 59), + pressed: Color32::from_rgb(58, 63, 69), + text: Color32::from_rgb(250, 251, 253), + text_dim: Color32::from_rgb(200, 206, 213), + text_faint: Color32::from_rgb(164, 170, 177), + icon: Color32::from_rgb(236, 238, 240), + accent: Color32::from_rgb(58, 172, 232), + accent_soft: Color32::from_rgba_unmultiplied(58, 172, 232, 40), + accent_border: Color32::from_rgba_unmultiplied(58, 172, 232, 160), + accent_text: Color32::from_rgb(80, 184, 238), + separator: Color32::from_rgb(46, 50, 54), + shadow: Color32::from_black_alpha(150), + primary_bg: Color32::from_rgb(30, 118, 176), + primary_text: Color32::WHITE, + danger: Color32::from_rgb(227, 111, 123), + warning: Color32::from_rgb(244, 120, 6), + radius_sm: 5.0, + radius: 5.0, + radius_lg: 6.0, + bevel: false, + pro: false, + round_chips: true, + tab_strip: Color32::TRANSPARENT, + row_selected: Color32::TRANSPARENT, + histogram_bg: Color32::from_rgb(17, 20, 24), + histogram_level: 225, + caption_close: Color32::from_rgb(213, 51, 60), + caption_close_text: Color32::WHITE, + scrim: Color32::from_black_alpha(110), + }, // Ethan Schoonover's Solarized Dark palette: base03 canvas, base02 surfaces, base0 // body text and the Solarized blue accent. https://ethanschoonover.com/solarized/ // (MIT; see LICENSE-solarized.txt) @@ -523,7 +634,10 @@ impl Tokens { } pub fn dark(&self) -> bool { - matches!(self.kind, ThemeKind::Studio | ThemeKind::Pro | ThemeKind::ProMedium | ThemeKind::SolarizedDark | ThemeKind::AdwaitaDark) + matches!( + self.kind, + ThemeKind::Studio | ThemeKind::Pro | ThemeKind::ProMedium | ThemeKind::SolarizedDark | ThemeKind::AdwaitaDark | ThemeKind::BreezeDark + ) } } @@ -934,6 +1048,68 @@ mod tests { } assert_eq!(ThemeKind::from_name("gnome dark"), Some(ThemeKind::AdwaitaDark)); } + + #[test] + fn breeze_themes_parse_and_meet_text_contrast() { + for (kind, aliases) in [ + (ThemeKind::BreezeLight, ["breeze", "Breeze Light", "breeze_light", "kde", "plasma"]), + (ThemeKind::BreezeDark, ["breeze dark", "Breeze Dark", "breeze_dark", "kde dark", "plasma-dark"]), + ] { + assert_eq!(ThemeKind::from_name(kind.id()), Some(kind)); + assert_eq!(ThemeKind::from_name(kind.label()), Some(kind)); + for alias in aliases { + assert_eq!(ThemeKind::from_name(alias), Some(kind), "{alias}"); + } + assert!(ThemeKind::ALL.contains(&kind)); + let t = Tokens::for_kind(kind); + assert_eq!(t.kind, kind); + assert_eq!(t.dark(), kind == ThemeKind::BreezeDark); + assert!(!t.pro && !t.bevel, "{kind:?} uses the Studio layout"); + // The design intent, not exact values (the palette is an adaptation and may be tuned): + // the canvas is the most recessed surface, then the dock and the cards, and controls + // sit above the cards they are on; the accent is a blue. + let lum = |c: Color32| u32::from(c.r()) + u32::from(c.g()) + u32::from(c.b()); + assert!(lum(t.canvas) < lum(t.dock) && lum(t.dock) < lum(t.card) && lum(t.card) < lum(t.field), "{kind:?}: surface order"); + assert!(t.accent.b() > t.accent.g() && t.accent.g() > t.accent.r(), "blue accent: {:?}", t.accent); + // Plasma's surfaces are cool blue-greys: dark ones stay below 64 per channel and light + // ones above 205, all with a little more blue than red (our own values, close to it). + for c in [t.chrome, t.canvas, t.dock, t.card, t.field] { + assert!(if t.dark() { c.r().max(c.g()).max(c.b()) < 64 } else { c.r().min(c.g()).min(c.b()) > 205 }, "{kind:?}: {c:?}"); + assert!(c.b() > c.r(), "{kind:?}: blue-grey {c:?}"); + } + // All text must read: AA (4.5:1) for every text colour on every surface it can sit on, + // including a hovered or pressed control and a selected (accent-tinted) row. + let tint = |under: Color32| { + let k = f32::from(t.accent_soft.a()) / 255.0; + let ch = |over: u8, under: u8| (f32::from(over) * k + f32::from(under) * (1.0 - k)).round() as u8; + Color32::from_rgb(ch(t.accent.r(), under.r()), ch(t.accent.g(), under.g()), ch(t.accent.b(), under.b())) + }; + let resting = [t.chrome, t.canvas, t.dock, t.card, t.field]; + let active = [t.hover, t.pressed, tint(t.chrome), tint(t.dock), tint(t.card), tint(t.field)]; + for surface in resting.into_iter().chain(active) { + for (name, colour) in [("text", t.text), ("dim text", t.text_dim), ("faint text", t.text_faint), ("accent text", t.accent_text)] { + assert!(contrast(colour, surface) >= 4.5, "{kind:?}: {name} on {surface:?}"); + } + } + // The text must also keep a hierarchy, not collapse into one grey. + assert!(contrast(t.text, t.card) > contrast(t.text_dim, t.card) && contrast(t.text_dim, t.card) > contrast(t.text_faint, t.card), "{kind:?}"); + // Danger and warning colours carry text on the resting surfaces; on hovered, pressed and + // selected ones they are status marks, held to the 3:1 of graphical objects. + for surface in resting { + assert!(contrast(t.danger, surface) >= 4.5, "{kind:?}: danger on {surface:?}"); + assert!(contrast(t.warning, surface) >= 4.5, "{kind:?}: warning on {surface:?}"); + } + for surface in active { + assert!(contrast(t.danger, surface) >= 3.0, "{kind:?}: danger on {surface:?}"); + assert!(contrast(t.warning, surface) >= 3.0, "{kind:?}: warning on {surface:?}"); + } + // A primary button darkens while hovered and pressed (button_impl), so check all three. + for k in [1.0, 0.93, 0.85] { + assert!(contrast(t.primary_text, t.primary_bg.gamma_multiply(k)) >= 4.5, "{kind:?}: primary button label at {k}"); + } + assert!(contrast(t.caption_close_text, t.caption_close) >= 4.5, "{kind:?}: close button glyph"); + } + } } /// Development feature: live design-token overrides. diff --git a/crates/ui-egui/src/tiff_options_ui.rs b/crates/ui-egui/src/tiff_options_ui.rs index 163c717143..260bac152d 100644 --- a/crates/ui-egui/src/tiff_options_ui.rs +++ b/crates/ui-egui/src/tiff_options_ui.rs @@ -71,7 +71,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { app.session.edit_prefs(|prefs| prefs.file_handling.ask_before_saving_layered_tiff = false); } let settings = ExportSettings { tiff_layers: !p.discard_layers, ..Default::default() }; - match app.with_document(p.doc, |app| app.write_document(p.path, &settings, p.discard_layers)) { + let step = serde_json::json!({"path": p.path, "tiffLayers": !p.discard_layers}); + let written = app.with_document(p.doc, |app| app.write_document(p.path, &settings, p.discard_layers)); + if written.is_ok() { + // An action being recorded keeps the Save As with this answer (#2032). + app.session.journal.push(("file.saveAs".into(), step)); + } + match written { Err(e) => { app.ui.status = crate::i18n::fmt(tl!("Save failed: {error}"), &[("error", &e)]); app.ui.status_error = true; diff --git a/crates/ui-egui/src/tool_feedback.rs b/crates/ui-egui/src/tool_feedback.rs index 64ae769562..56f7444b45 100644 --- a/crates/ui-egui/src/tool_feedback.rs +++ b/crates/ui-egui/src/tool_feedback.rs @@ -37,6 +37,8 @@ pub fn is_selection_tool(tool: Tool) -> bool { tool, Tool::RectMarquee | Tool::EllipseMarquee + | Tool::SingleRowMarquee + | Tool::SingleColumnMarquee | Tool::Lasso | Tool::PolygonLasso | Tool::MagneticLasso diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index e7a63b9809..ed3ce527bc 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -47,6 +47,14 @@ impl SplitTool { } } +/// What a Warp-mode press grabbed. `pts0` is the mesh's control grid at the press. +enum WarpDrag { + /// An anchor or handle: it follows the pointer (an anchor takes its handles along). + Point { index: usize, start: [f64; 2], pts0: Vec<[f64; 2]> }, + /// Anywhere else on the grid: the surface under the pointer follows it. + Surface { s: f64, t: f64, start: [f64; 2], pts0: Vec<[f64; 2]> }, +} + /// Preview state that isn't serialisable: the document without the transformed pixels, and /// full-resolution textures of those pixels (transform_tex.rs). pub struct TransformPreview { @@ -54,9 +62,11 @@ pub struct TransformPreview { pub doc: Arc, pub texture: crate::transform_tex::PreviewTextures, pub opacity: f32, + /// The layers stacked over the moving one, drawn over its pixels ([`Above`]). + above: Option, gesture: Option, - /// Warp-mode drag: (control point, pointer start, mesh points at the start). - warp_drag: Option<(usize, [f64; 2], Vec<[f64; 2]>)>, + /// Warp-mode drag in progress. + warp_drag: Option, /// Armed split tool. None while the buttons are off and control points drag as usual. split_tool: Option, /// Document point the split guide is following (a drag, or the hover while placing). @@ -83,6 +93,10 @@ struct Follow { hidden: bool, } +/// Photoshop's alert when Free Transform (⌘T, Edit › Transform, the Move tool's transform controls) +/// starts on a layer locked in place, by its own lock or a group's. +pub const LOCKED: &str = "Could not complete the Free Transform command because the layer is locked."; + /// Grab radius of the box's handles, in screen points. Generous, so a corner is easy to catch; /// just beyond it, outside the box, a drag rotates. pub const HANDLE_PX: f64 = 12.0; @@ -158,6 +172,11 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> } let id = st.active_layer.ok_or("no active layer")?; let layer = doc.layer(id).ok_or("no layer")?; + if photocraft_engine::transform_cmds::locked_for_transform(&doc, id) { + // Photoshop refuses before the box opens rather than when it is committed (#2585). + app.ui.open_dialog(crate::state::DialogKind::Error, json!({"message": tl!(LOCKED)}).as_object().cloned().unwrap_or_default()); + return Err(LOCKED.into()); + } if matches!(layer.content, LayerContent::Adjustment(_)) && layer.mask.is_none() { return Err("Adjustment layers have nothing to transform".into()); } @@ -171,9 +190,10 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> // Preview document: the moving pixels removed (layer hidden, or the selection lifted out). let mut pd = (*doc).clone(); let mut lifted = None; - if let Some(sel) = doc.selection.clone().filter(|_| !layer.is_group() && layer.surface().is_some()) { + // Type, shapes and smart objects move whole, selection or not (#2630): only pixels are lifted. + if let Some(sel) = doc.selection.clone().filter(|_| !photocraft_engine::transform_cmds::moves_whole(layer) && layer.surface().is_some()) { if let Some(surf) = pd.layer_mut(id).and_then(|l| l.surface_mut()) { - let (l, rest) = photocraft_engine::transform_cmds::split_selected(surf, &sel); + let (l, rest) = photocraft_engine::transform_cmds::split_selected(surf, &sel).map_err(|e| e.to_string())?; *surf = rest; lifted = Some(l); } @@ -182,6 +202,9 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> } // egui reports the renderer's limit in the app; offscreen harnesses only through the device. let max_side = ctx.input(|i| i.max_texture_side).max(app.gpu.as_ref().map_or(1, |g| g.max_texture_side())); + // The layers covering the moving pixels draw over them instead of under them. + let above = Above::new(ctx, format!("transform-{session}-above"), &doc, id, max_side); + above.hide_in(&mut pd.layers); let (image, uv) = preview_image(&doc, id, lifted.as_ref(), b, max_side); let texture = crate::transform_tex::PreviewTextures::new(ctx, format!("transform-{session}"), image, uv); app.transform_preview = Some(TransformPreview { @@ -189,6 +212,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> doc: Arc::new(pd), texture, opacity: layer.opacity * layer.fill_opacity, + above: Some(above), follow: Some(Follow { revision, hidden: lifted.is_none() }), gesture: None, warp_drag: None, @@ -308,7 +332,7 @@ fn begin_lone( &whole } }; - let (lifted, rest) = tc::split_gray_selected(&surf, sel).ok_or("this channel can't be transformed")?; + let (lifted, rest) = tc::split_gray_selected(&surf, sel).map_err(|e| e.to_string())?.ok_or("this channel can't be transformed")?; crate::type_tool::commit(app); let layer = app.session.active().and_then(|st| st.active_layer).map_or(0, |l| l.0); let mut pd = (*doc).clone(); @@ -326,6 +350,7 @@ fn begin_lone( doc: Arc::new(pd), texture, opacity: 0.6, + above: None, follow: None, gesture: None, warp_drag: None, @@ -382,6 +407,7 @@ fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json:: steps: Steps::default(), tool: app.ui.tool, path: Some(path), + above: None, follow: None, }); app.ui.transform = Some(TransformSession { @@ -452,6 +478,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<( doc: Arc::new(pd), texture, opacity: 1.0, + above: None, follow: None, gesture: None, warp_drag: None, @@ -592,6 +619,158 @@ fn preview_mask(l: &photocraft_doc::Layer, b: photocraft_geom::Rect) -> Option, + /// Their composite and the canvas area it covers; `None` when nothing visible covers the layer. + shown: Option<(crate::transform_tex::PreviewTextures, photocraft_geom::Rect)>, +} + +impl Above { + fn new(ctx: &egui::Context, name: String, doc: &Document, id: LayerId, max_side: usize) -> Self { + let mut a = Self { ctx: ctx.clone(), name, max_side, ids: Vec::new(), shown: None }; + a.rebuild(doc, id); + a + } + + /// Composite what covers layer `id` of `doc` (the live document) again. + fn rebuild(&mut self, doc: &Document, id: LayerId) { + self.ids.clear(); + covering(&doc.layers, id, &mut self.ids); + let mut up = doc.clone(); + show_only(&mut up.layers, &self.ids); + let canvas = up.bounds(); + let rect = up + .layers + .iter() + .filter(|l| l.visible) + .fold(photocraft_geom::Rect::EMPTY, |r, l| r.union(&photocraft_compose::composite_bounds(l, canvas).unwrap_or(canvas))) + .intersect(&canvas); + self.shown = (!rect.is_empty()).then(|| { + let (image, uv) = composite_image(&up, rect, self.max_side); + (crate::transform_tex::PreviewTextures::new(&self.ctx, self.name.clone(), image, uv), rect) + }); + } + + /// Leave the covering layers out of `layers` (the document under the box). + fn hide_in(&self, layers: &mut [photocraft_doc::Layer]) { + for l in layers.iter_mut() { + if self.ids.contains(&l.id) { + l.visible = false; + } else if let LayerContent::Group(g) = &mut l.content { + self.hide_in(&mut g.children); + } + } + } + + /// Draw the composite over the moving pixels. + fn draw(&self, painter: &egui::Painter, xf: &ViewXform) { + let Some((texture, r)) = &self.shown else { return }; + let (tex, uv) = texture.pick(painter.ctx(), xf.zoom * painter.ctx().pixels_per_point(), 1.0, false); + let mut mesh = egui::Mesh::with_texture(tex); + let corners = [(r.x0, r.y0, 0.0, 0.0), (r.x1, r.y0, uv[0], 0.0), (r.x1, r.y1, uv[0], uv[1]), (r.x0, r.y1, 0.0, uv[1])]; + for (x, y, u, v) in corners { + mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u, v), color: Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(mesh); + } +} + +/// `rect` of `doc`'s composite as premultiplied texels: full resolution when it fits in +/// `max_side`, composited in bands (no full-size float copy), else area-averaged down to fit. +fn composite_image(doc: &Document, rect: photocraft_geom::Rect, max_side: usize) -> (egui::ColorImage, [f32; 2]) { + let (w, h) = (rect.width() as usize, rect.height() as usize); + let to_color = |p: &[u8; 4]| Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]); + if w.max(h) <= max_side.max(1) { + let mut px = Vec::with_capacity(w * h); + let _ = photocraft_compose::render_bands(doc, rect, 0, |band| { + px.extend(band.to_rgba8().pixels.as_chunks::<4>().0.iter().map(to_color)); + Ok::<(), ()>(()) + }); + px.resize(w * h, Color32::TRANSPARENT); + return (egui::ColorImage::new([w, h], px), [1.0, 1.0]); + } + let k = w.max(h).div_ceil(max_side.max(1)); + let (tw, th) = (w.div_ceil(k).max(1), h.div_ceil(k).max(1)); + let buf = photocraft_compose::render_reduced_rect(doc, rect, tw as u32, th as u32); + let px: Vec = buf.to_rgba8().pixels.as_chunks::<4>().0.iter().map(to_color).collect(); + let [bw, bh] = [buf.rect.width() as usize, buf.rect.height() as usize]; + if px.len() != bw * bh || bw == 0 || bh == 0 { + return (egui::ColorImage::new([1, 1], vec![Color32::TRANSPARENT]), [1.0, 1.0]); + } + (egui::ColorImage::new([bw, bh], px), [1.0, 1.0]) +} + +/// A layer that composites over anything the same as over transparency, so it can be drawn over +/// the moving pixels on its own: a Normal layer that isn't an adjustment, or a Normal or Pass +/// Through group of such layers (hidden layers count, they draw nothing). Adjustment layers and +/// the other blend modes depend on what is under them. +fn plain(l: &photocraft_doc::Layer) -> bool { + use photocraft_color::BlendMode; + !l.visible + || match &l.content { + LayerContent::Adjustment(_) => false, + LayerContent::Group(g) => matches!(l.blend, BlendMode::Normal | BlendMode::PassThrough) && g.children.iter().all(plain), + _ => l.blend == BlendMode::Normal, + } +} + +/// The layers over `id` in `layers` (bottom to top, groups holding `id` searched too) that cover +/// it in the preview: from the top down, while they are [`plain`]. The first layer that isn't +/// stops the search, so it and everything under it stay in the document under the box as before +/// (an adjustment there keeps applying to the layers under it). A clipping group goes as a whole, +/// and the layers clipped to `id` (or to the group holding it) stay with it. +fn covering(layers: &[photocraft_doc::Layer], id: LayerId, out: &mut Vec) { + let mut end = layers.len(); + while end > 0 { + // The clipping group ending at `end`: its base and the layers clipped to it. + let mut start = end - 1; + while start > 0 && layers.get(start).is_some_and(|l| l.clipped) { + start -= 1; + } + let unit = layers.get(start..end).unwrap_or_default(); + if unit.iter().any(|l| contains(l, id)) { + // A group holding `id` with nothing clipped to it: go on inside it. + if let [g] = unit + && g.id != id + && let LayerContent::Group(group) = &g.content + && matches!(g.blend, photocraft_color::BlendMode::Normal | photocraft_color::BlendMode::PassThrough) + { + covering(&group.children, id, out); + } + return; + } + if !unit.iter().all(plain) { + return; + } + out.extend(unit.iter().map(|l| l.id)); + end = start; + } +} + +/// Hide everything in `layers` but the `keep` layers and the groups holding them. +fn show_only(layers: &mut [photocraft_doc::Layer], keep: &[LayerId]) { + for l in layers.iter_mut() { + if keep.contains(&l.id) { + continue; + } + match &mut l.content { + LayerContent::Group(g) if g.children.iter().any(|c| keep.iter().any(|k| contains(c, *k))) => show_only(&mut g.children, keep), + _ => l.visible = false, + } + } +} + fn contains(l: &photocraft_doc::Layer, id: LayerId) -> bool { l.id == id || matches!(&l.content, LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id))) } @@ -713,6 +892,10 @@ pub fn follow_edits(app: &mut PhotocraftApp) { l.content = moving.content.clone(); l.visible = l.visible && !follow.hidden; } + if let Some(a) = pv.above.as_mut() { + a.rebuild(&st.doc, id); + a.hide_in(&mut pd.layers); + } pv.opacity = if live.visible { live.opacity * live.fill_opacity } else { 0.0 }; follow.revision = st.revision; pv.doc = Arc::new(pd); @@ -1162,8 +1345,15 @@ fn single_patch(mesh: &BezierMesh) -> bool { mesh.us.len() == 2 && mesh.vs.len() == 2 } -/// Option-click picks the split: crosswise in the open, and the perpendicular split when the -/// pointer is close to an existing grid line. +/// The surface parameters under `p` when it is inside the warped mesh, else None. +fn surface_at(mesh: &BezierMesh, p: [f64; 2]) -> Option<(f64, f64)> { + let (s, t) = mesh.param_at(p); + let q = mesh.eval(s, t); + ((q[0] - p[0]).hypot(q[1] - p[1]) < 1.0).then_some((s, t)) +} + +/// Ctrl-click (or Option-click) picks the split: crosswise in the open, and the perpendicular +/// split when the pointer is close to an existing grid line. fn quick_split_kind(mesh: &BezierMesh, bounds: [f64; 4], p: [f64; 2]) -> SplitTool { let (s, t) = mesh.param_at(p); let w = (bounds[2] - bounds[0]).abs().max(1.0); @@ -1195,7 +1385,7 @@ fn split_gesture(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) let armed = app.transform_preview.as_ref().and_then(|p| p.split_tool); let placing = app.transform_preview.as_ref().is_some_and(|p| p.split_placing); match ev { - ToolEvent::Down { x, y, .. } if armed.is_some() || mods.alt => { + ToolEvent::Down { x, y, .. } if armed.is_some() || mods.alt || mods.command => { if let Some(pv) = app.transform_preview.as_mut() { pv.split_placing = true; pv.split_pointer = Some([x, y]); @@ -1233,9 +1423,10 @@ fn split_gesture(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } } -/// Drags a warp control point. Anchors (patch corners) carry their handles along. Preset warps -/// turn into a custom mesh on the first drag. An armed split tool (or Option) places a split -/// where the pointer is released instead of moving a point. +/// Drags the warp grid. A press on an anchor or handle moves that point (an anchor carries its +/// handles along); a press anywhere else inside the grid pulls the surface under the pointer. +/// Preset warps turn into a custom mesh when the press grabs something. An armed split tool, or +/// Ctrl / Option held, places a split where the pointer is released instead. fn warp_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tol: f64, mods: egui::Modifiers) { if split_gesture(app, ev, mods) { return; @@ -1246,29 +1437,20 @@ fn warp_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tol: f64, mods: egui::Mo ToolEvent::Down { x, y, .. } => { // A preset warp becomes a custom mesh only when the press grabs one of its points. let custom = if w.mesh.is_none() || w.style != WarpStyle::Custom { Warp::custom(w.to_mesh(1, 1), w.bounds) } else { w.clone() }; - pv.warp_drag = warp_hit(&custom, [x, y], tol).map(|i| (i, [x, y], custom.mesh.as_ref().map(|m| m.points.clone()).unwrap_or_default())); + let pts0 = custom.mesh.as_ref().map(|m| m.points.clone()).unwrap_or_default(); + pv.warp_drag = warp_hit(&custom, [x, y], tol).map(|index| WarpDrag::Point { index, start: [x, y], pts0: pts0.clone() }).or_else(|| { + let (s, t) = surface_at(custom.mesh.as_ref()?, [x, y])?; + Some(WarpDrag::Surface { s, t, start: [x, y], pts0 }) + }); if pv.warp_drag.is_some() { *w = custom; } } ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => { - if let (Some((i, start, pts0)), Some(m)) = (&pv.warp_drag, w.mesh.as_mut()) { - let (dx, dy) = (x - start[0], y - start[1]); - let nx = m.nx(); - let (ci, cj) = (i % nx, i / nx); - let ny = m.ny(); - let mut moved = vec![*i]; - if ci % 3 == 0 && cj % 3 == 0 { - for (di, dj) in [(-1i64, 0i64), (1, 0), (0, -1), (0, 1)] { - let (a, b) = (ci as i64 + di, cj as i64 + dj); - if a >= 0 && b >= 0 && (a as usize) < nx && (b as usize) < ny { - moved.push(b as usize * nx + a as usize); - } - } - } - for k in moved { - m.points[k] = [pts0[k][0] + dx, pts0[k][1] + dy]; - } + match (&pv.warp_drag, w.mesh.as_mut()) { + (Some(WarpDrag::Point { index, start, pts0 }), Some(m)) => m.drag_point(pts0, *index, [x - start[0], y - start[1]]), + (Some(WarpDrag::Surface { s, t, start, pts0 }), Some(m)) => m.pull(pts0, *s, *t, [x - start[0], y - start[1]]), + _ => {} } if matches!(ev, ToolEvent::Up { .. }) { pv.warp_drag = None; @@ -1323,19 +1505,22 @@ fn rotation_degrees(quad: [[f64; 2]; 4]) -> f64 { if dx.is_finite() && dy.is_finite() && dx.hypot(dy) > 1e-9 { dy.atan2(dx).to_degrees() } else { 0.0 } } -/// Cursor for hovering a document point while transforming. `alt` is Option, which arms a quick -/// split while a warp is active. -pub fn cursor(app: &PhotocraftApp, p: [f64; 2], alt: bool) -> Option { +/// Cursor for hovering a document point while transforming. `quick_split` is Ctrl or Option, +/// which arms a quick split while a warp is active. +pub fn cursor(app: &PhotocraftApp, p: [f64; 2], quick_split: bool) -> Option { let t = app.ui.transform.as_ref()?; let tol = handle_tolerance(app); if let Some(w) = &t.warp { if let Some(tool) = app.transform_preview.as_ref().and_then(|pv| pv.split_tool) { return Some(split_cursor(tool)); } - if alt { + if quick_split { return Some(split_cursor(quick_split_kind(&w.to_mesh(1, 1), w.bounds, p))); } - return Some(if w.style != WarpStyle::Custom || warp_hit(w, p, tol).is_some() { CursorIcon::Crosshair } else { CursorIcon::Default }); + if w.style != WarpStyle::Custom || warp_hit(w, p, tol).is_some() { + return Some(CursorIcon::Crosshair); + } + return Some(if w.mesh.as_ref().and_then(|m| surface_at(m, p)).is_some() { CursorIcon::Grab } else { CursorIcon::Default }); } let h = hit(t, p, tol); if !distort_allows(t.mode, h) { @@ -1365,7 +1550,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if let Some(w) = &t.warp { let over_canvas = painter.ctx().input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)).map(|p| xf.to_doc(p)); let at = pv.split_pointer.or(over_canvas); - let alt = painter.ctx().input(|i| i.modifiers.alt); + let alt = painter.ctx().input(|i| i.modifiers.alt || i.modifiers.command); let guide = at.and_then(|p| { let tool = pv.split_tool.or_else(|| (alt || pv.split_quick).then(|| quick_split_kind(&w.to_mesh(1, 1), w.bounds, p))); tool.map(|tool| (tool, p)) @@ -1395,6 +1580,9 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } painter.add(clip_to_canvas(mesh, canvas_size(pv), |p| xf.to_screen(p.x, p.y))); } + if let Some(a) = &pv.above { + a.draw(painter, xf); + } let accent = crate::theme::Tokens::get(painter.ctx()).accent; if let (Some(path), Some(h)) = (&pv.path, Homography::rect_to_quad(t.rect, t.quad)) { crate::vector_ui::draw_outline( @@ -1512,32 +1700,82 @@ fn draw_rotate_feedback(painter: &egui::Painter, at: Pos2, angle: Option, b /// boundaries are solid. A single patch (Grid › Default) also draws its rule-of-thirds guides; /// 3×3, 4×4 and 5×5 are just those even cells, with an anchor at every intersection. `guide` is /// the split line following the pointer. +/// How near the pointer (screen px) a handle's anchor or the handle itself must be for the +/// handle to show. Everything else stays out of the way: only anchors are always drawn. +const HANDLE_REVEAL_PX: f32 = 56.0; + +/// The anchor a handle belongs to, as `(i, j)` in the control grid (an anchor is its own). +fn handle_anchor(i: usize, j: usize) -> (usize, usize) { + if i.is_multiple_of(3) { + let aj = match j % 3 { + 0 => j, + 1 => j - 1, + _ => j + 1, + }; + (i, aj) + } else { + (if i % 3 == 1 { i - 1 } else { i + 1 }, j) + } +} +/// Anchors always, small and quiet. A handle and its arm show only while the pointer is close to +/// them or they are being dragged, so a dense grid does not bury the picture in dots. +fn draw_warp_points(painter: &egui::Painter, m: &BezierMesh, scr: impl Fn([f64; 2]) -> Pos2, pv: &TransformPreview, line: Stroke) { + let (nx, ny) = (m.nx(), m.ny()); + let hover = painter.ctx().input(|i| i.pointer.hover_pos()); + let dragged = match &pv.warp_drag { + Some(WarpDrag::Point { index, .. }) => Some(handle_anchor(index % nx, index / nx)), + _ => None, + }; + let near = |p: Pos2| hover.is_some_and(|h| h.distance(p) <= HANDLE_REVEAL_PX); + let faint = Stroke::new(0.75, line.color.gamma_multiply(0.8)); + for j in 0..ny { + for i in 0..nx { + if !on_section_edge(i, j) { + continue; + } + let p = scr(m.point(i, j)); + if i.is_multiple_of(3) && j.is_multiple_of(3) { + let r = egui::Rect::from_center_size(p, vec2(5.0, 5.0)); + painter.rect_filled(r, 0.0, Color32::WHITE); + painter.rect_stroke(r, 0.0, faint, egui::StrokeKind::Inside); + continue; + } + let (ai, aj) = handle_anchor(i, j); + let a = scr(m.point(ai, aj)); + if !(near(p) || near(a) || dragged == Some((ai, aj))) { + continue; + } + painter.line_segment([a, p], faint); + painter.circle_filled(p, 2.75, Color32::WHITE); + painter.circle_stroke(p, 2.75, faint); + } + } +} + fn draw_warp(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: &Warp, pv: &TransformPreview, guide: Option<(SplitTool, [f64; 2])>) { let scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); let r = t.rect; - let n = 32; + let m = w.to_mesh(1, 1); + let (us, vs) = crate::warp_preview::samples(&m, xf.zoom as f64); let (tex, uv) = pv.texture.pick(painter.ctx(), xf.zoom * painter.ctx().pixels_per_point(), 1.0, t.interpolation == "nearest"); let mut mesh = egui::Mesh::with_texture(tex); let tint = Color32::from_white_alpha((pv.opacity.clamp(0.0, 1.0) * 255.0) as u8); - for j in 0..=n { - for i in 0..=n { - let (u, v) = (i as f64 / n as f64, j as f64 / n as f64); + for v in &vs { + for u in &us { let (x, y) = w.map(r[0] + u * (r[2] - r[0]), r[1] + v * (r[3] - r[1])); - mesh.vertices.push(egui::epaint::Vertex { pos: pos2(x as f32, y as f32), uv: pos2(u as f32 * uv[0], v as f32 * uv[1]), color: tint }); - } - } - for j in 0..n { - for i in 0..n { - let a = (j * (n + 1) + i) as u32; - mesh.add_triangle(a, a + 1, a + n as u32 + 2); - mesh.add_triangle(a, a + n as u32 + 2, a + n as u32 + 1); + mesh.vertices.push(egui::epaint::Vertex { pos: pos2(x as f32, y as f32), uv: pos2(*u as f32 * uv[0], *v as f32 * uv[1]), color: tint }); } } + let pos: Vec<[f32; 2]> = mesh.vertices.iter().map(|v| [v.pos.x, v.pos.y]).collect(); + let rest: Vec<[f32; 2]> = vs.iter().flat_map(|v| us.iter().map(move |u| [(r[0] + u * (r[2] - r[0])) as f32, (r[1] + v * (r[3] - r[1])) as f32])).collect(); + mesh.indices = crate::warp_preview::triangle_order(&pos, &rest, us.len()); painter.add(clip_to_canvas(mesh, canvas_size(pv), |p| xf.to_screen(p.x, p.y))); + if let Some(a) = &pv.above { + a.draw(painter, xf); + } let accent = crate::theme::Tokens::get(painter.ctx()).accent; let line = Stroke::new(1.0, accent); let thin = Stroke::new(0.75, accent.gamma_multiply(0.7)); - let m = w.to_mesh(1, 1); let curve = |fixed_u: Option, fixed_v: Option| -> Vec { (0..=40) .map(|k| { @@ -1573,27 +1811,7 @@ fn draw_warp(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: & painter.add(egui::Shape::line(curve(None, Some(win[1])), line)); } if w.style == WarpStyle::Custom { - let (nx, ny) = (m.nx(), m.ny()); - for j in 0..ny { - for i in 0..nx { - if !on_section_edge(i, j) { - continue; - } - let p = scr(m.point(i, j)); - let anchor = i.is_multiple_of(3) && j.is_multiple_of(3); - if anchor { - let r = egui::Rect::from_center_size(p, vec2(7.0, 7.0)); - painter.rect_filled(r, 0.0, Color32::WHITE); - painter.rect_stroke(r, 0.0, line, egui::StrokeKind::Inside); - } else { - // Arm to the nearest anchor along the row/column. - let (ai, aj) = if i.is_multiple_of(3) { (i, if j % 3 == 1 { j - 1 } else { j + 1 }) } else { (if i % 3 == 1 { i - 1 } else { i + 1 }, j) }; - painter.line_segment([scr(m.point(ai, aj)), p], line); - painter.circle_filled(p, 3.5, Color32::WHITE); - painter.circle_stroke(p, 3.5, line); - } - } - } + draw_warp_points(painter, &m, scr, pv, line); } if let Some((tool, p)) = guide { let (s, t) = m.param_at(p); @@ -1635,6 +1853,8 @@ fn readout(t: &TransformSession) -> (f64, f64, f64, f64) { /// Width of the mode, cancel and commit cluster kept on the right of the options bar. const ACTIONS_W: f32 = 140.0; +/// Width of the action buttons when they follow the Warp fields (one more than the box bar: Reset). +const WARP_ACTIONS_W: f32 = 176.0; /// Reach of the reference point X/Y fields: the largest document side (`image.canvasSize`'s limit). const POSITION_LIMIT_PX: f32 = 300_000.0; @@ -1652,26 +1872,29 @@ fn scale_limit_pct(side: f64) -> f32 { ((f64::from(POSITION_LIMIT_PX) / side * 100.0) as f32).clamp(100.0, SCALE_LIMIT_PCT) } -/// Options bar while transforming: reference point X/Y, W/H %, angle, interpolation, and, pinned -/// to the right, the warp switch, cancel and commit. +/// Options bar while transforming: the fields (reference point X/Y, W/H %, angle, interpolation, +/// or the warp controls), then the mode switch, cancel and commit right after them. A bar too +/// narrow for both pins the buttons to the right edge and clips the fields, so Commit and Cancel +/// are always reachable. pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let Some(t) = app.ui.transform.clone() else { return }; let bar = ui.available_rect_before_wrap(); - let split = (bar.right() - ACTIONS_W).max(bar.left()); + let warping = t.warp.is_some(); + let buttons_w = if warping { WARP_ACTIONS_W } else { ACTIONS_W }; + let split = (bar.right() - buttons_w).max(bar.left()); let fields = egui::Rect::from_min_max(bar.min, egui::pos2(split, bar.bottom())); - let actions = egui::Rect::from_min_max(egui::pos2(split, bar.top()), bar.max); - { - let mut row = - ui.new_child(egui::UiBuilder::new().id_salt("transform-fields").max_rect(fields).layout(egui::Layout::left_to_right(egui::Align::Center))); - row.set_clip_rect(fields); - if let Some(w) = t.warp.clone() { - warp_fields(app, &mut row, &w); - } else { - transform_fields(app, &mut row, &t); - } + let mut row = ui.new_child(egui::UiBuilder::new().id_salt("transform-fields").max_rect(fields).layout(egui::Layout::left_to_right(egui::Align::Center))); + row.set_clip_rect(fields); + if let Some(w) = t.warp.clone() { + warp_fields(app, &mut row, &w); + } else { + transform_fields(app, &mut row, &t); } - let mut row = ui.new_child(egui::UiBuilder::new().id_salt("transform-actions").max_rect(actions).layout(egui::Layout::right_to_left(egui::Align::Center))); - transform_actions(app, &mut row, t.warp.is_some()); + crate::widgets::vline(&mut row, 22.0); + let start = row.min_rect().right().min(split); + let actions = egui::Rect::from_min_max(egui::pos2(start, bar.top()), egui::pos2(start + buttons_w, bar.bottom())); + let mut btns = ui.new_child(egui::UiBuilder::new().id_salt("transform-actions").max_rect(actions).layout(egui::Layout::right_to_left(egui::Align::Center))); + transform_actions(app, &mut btns, warping); } fn transform_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSession) { @@ -1742,6 +1965,47 @@ fn warp_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, w: &Warp) { let lbl = |ui: &mut egui::Ui, s: &str| { ui.label(egui::RichText::new(s).color(tk.text_dim).size(12.0)); }; + // Split, Grid, then the warp style with its bend fields. + lbl(ui, tl!("Split:")); + let armed = app.transform_preview.as_ref().and_then(|p| p.split_tool); + for (tool, tip) in [ + (SplitTool::Cross, tl!("Split Warp Crosswise")), + (SplitTool::Vertical, tl!("Split Warp Vertically")), + (SplitTool::Horizontal, tl!("Split Warp Horizontally")), + ] { + let on = armed == Some(tool); + if split_icon(ui, tool.icon(), tip, on).clicked() + && let Some(pv) = app.transform_preview.as_mut() + { + pv.split_tool = if on { None } else { Some(tool) }; + pv.split_placing = false; + pv.split_pointer = None; + pv.split_quick = false; + } + } + crate::widgets::vline(ui, 22.0); + lbl(ui, tl!("Grid:")); + let mesh = w.mesh.clone().unwrap_or_else(|| BezierMesh::identity(w.bounds, 1, 1)); + let mut grid = warp_grid_id(&mesh).to_string(); + let opts = [ + ("custom".to_string(), tl!("Custom")), + ("default".to_string(), tl!("Default")), + ("3".to_string(), tl!("3 x 3")), + ("4".to_string(), tl!("4 x 4")), + ("5".to_string(), tl!("5 x 5")), + ]; + if crate::widgets::dropdown(ui, "warp-grid", &mut grid, &opts, 92.0) + && let Some(n) = match grid.as_str() { + "default" => Some(1), + "3" => Some(3), + "4" => Some(4), + "5" => Some(5), + _ => None, + } + { + let _ = edit_session_warp(app, "edit.transform.warpGrid", &json!({ "size": n })); + } + crate::widgets::vline(ui, 22.0); lbl(ui, tl!("Warp:")); let mut style = w.style; let opts: Vec<(WarpStyle, &str)> = WarpStyle::all().map(|s| (s, s.label())).collect(); @@ -1755,12 +2019,15 @@ fn warp_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, w: &Warp) { s => Warp { style: s, bend: if w.style.is_preset() { w.bend } else { 50.0 }, ..Warp::none(b) }, }); } - if w.style.is_preset() { - let mut vertical = w.vertical; - if crate::widgets::checkbox(ui, &mut vertical, tl!("Vertical")).changed() + // Orientation and the bend fields are always there, and only act on a preset style. + let preset = w.style.is_preset(); + ui.add_enabled_ui(preset, |ui| { + let orient = crate::icons::button(ui, "arrow-left-right", 26.0, w.vertical, tl!("Vertical")); + orient.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, preset, "Change warp orientation")); + if orient.clicked() && let Some(Some(cur)) = app.ui.transform.as_mut().map(|t| t.warp.as_mut()) { - cur.vertical = vertical; + cur.vertical = !cur.vertical; } let fields: [(&str, f64); 3] = [(tl!("Bend:"), w.bend), ("H:", w.h_distort), ("V:", w.v_distort)]; for (k, (name, v)) in fields.iter().enumerate() { @@ -1776,47 +2043,23 @@ fn warp_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, w: &Warp) { } } } - } else { - crate::widgets::vline(ui, 22.0); - lbl(ui, tl!("Split:")); - let armed = app.transform_preview.as_ref().and_then(|p| p.split_tool); - for (tool, tip) in [ - (SplitTool::Cross, tl!("Split Warp Crosswise")), - (SplitTool::Vertical, tl!("Split Warp Vertically")), - (SplitTool::Horizontal, tl!("Split Warp Horizontally")), - ] { - let on = armed == Some(tool); - if split_icon(ui, tool.icon(), tip, on).clicked() - && let Some(pv) = app.transform_preview.as_mut() - { - pv.split_tool = if on { None } else { Some(tool) }; - pv.split_placing = false; - pv.split_pointer = None; - pv.split_quick = false; - } - } - crate::widgets::vline(ui, 22.0); - lbl(ui, tl!("Grid:")); - let mesh = w.mesh.clone().unwrap_or_else(|| BezierMesh::identity(w.bounds, 1, 1)); - let mut grid = warp_grid_id(&mesh).to_string(); - let opts = [ - ("custom".to_string(), tl!("Custom")), - ("default".to_string(), tl!("Default")), - ("3".to_string(), tl!("3 x 3")), - ("4".to_string(), tl!("4 x 4")), - ("5".to_string(), tl!("5 x 5")), - ]; - if crate::widgets::dropdown(ui, "warp-grid", &mut grid, &opts, 92.0) - && let Some(n) = match grid.as_str() { - "default" => Some(1), - "3" => Some(3), - "4" => Some(4), - "5" => Some(5), - _ => None, - } - { - let _ = edit_session_warp(app, "edit.transform.warpGrid", &json!({ "size": n })); - } + }); +} + +/// Back to the untouched box: the default one-patch grid over the box, no bend. +pub fn reset_warp(app: &mut PhotocraftApp) { + let Some(t) = app.ui.transform.as_mut() else { return }; + if t.warp.is_none() { + return; + } + let b = t.rect; + t.warp = Some(Warp::custom(BezierMesh::identity(b, 1, 1), b)); + if let Some(pv) = app.transform_preview.as_mut() { + pv.warp_drag = None; + pv.split_tool = None; + pv.split_pointer = None; + pv.split_placing = false; + pv.split_quick = false; } } @@ -1891,6 +2134,13 @@ fn transform_actions(app: &mut PhotocraftApp, ui: &mut egui::Ui, warping: bool) if cancel_btn.clicked() { cancel(app); } + if warping { + let reset = crate::icons::button(ui, "undo-2", 26.0, false, tl!("Reset")); + reset.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Reset warp")); + if reset.clicked() { + reset_warp(app); + } + } crate::widgets::vline(ui, 22.0); let mode = crate::icons::button(ui, "grid-3x3", 26.0, warping, tl!("Switch between free transform and warp modes")); mode.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Switch between free transform and warp modes")); @@ -2004,6 +2254,58 @@ mod tests { assert!(h.state().ui.transform.is_none()); } + #[test] + fn free_transform_bar_buttons_follow_the_fields_on_a_wide_bar() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.transform = Some(session()); + let width = 2400.0; + let mut h = Harness::builder().with_size(vec2(width, 48.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + ui.horizontal_centered(|ui| options_bar(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(6); + let commit = h.get_by_label("Commit transform").rect(); + assert!(commit.right() < width * 0.7, "commit sits next to the fields, not at the far right: {commit:?}"); + } + + #[test] + fn warp_bar_buttons_follow_the_fields_on_a_wide_bar() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.transform = Some(session()); + let width = 2000.0; + let mut h = Harness::builder().with_size(vec2(width, 48.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + ui.horizontal_centered(|ui| options_bar(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(6); + h.get_by_label("Switch between free transform and warp modes").click(); + h.run_steps(4); + assert!(h.state().ui.transform.as_ref().unwrap().warp.is_some()); + let commit = h.get_by_label("Commit transform").rect(); + assert!(commit.right() < width / 2.0, "commit sits next to the fields, not at the far right: {commit:?}"); + let reset = h.get_by_label("Reset warp").rect(); + assert!(reset.right() < commit.left() + 1.0, "reset is left of commit"); + h.get_by_label("Reset warp").click(); + h.run_steps(2); + assert!(h.state().ui.transform.as_ref().unwrap().warp.as_ref().unwrap().is_identity()); + } + /// Types `text` + Enter into the `n`th numeric field of the transform bar (X, Y, W, H, angle…). fn type_into_bar_field(n: usize, text: &str) -> TransformSession { use egui_kittest::Harness; @@ -2758,6 +3060,36 @@ mod tests { assert_eq!(app.ui.transform.as_ref().unwrap().mode, TransformMode::Distort); } + #[test] + fn right_click_menu_warp_row_enters_warp_mode() { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let ctx = egui::Context::default(); + begin(&mut app, &ctx).unwrap(); + assert!(crate::canvas_tool_menu::open_transform(&mut app, [10.0, 10.0])); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + assert!(crate::canvas_tool_menu::rows(&menu).iter().flatten().any(|e| e.1 == "edit.transform.warp")); + assert!(crate::canvas_tool_menu::available(&app, &menu, "edit.transform.warp")); + // Only the current mode (Free Transform) is checked: not Scale, Rotate or Warp. + let checked: Vec<&str> = + crate::canvas_tool_menu::rows(&menu).iter().flatten().map(|e| e.1).filter(|id| crate::canvas_tool_menu::mode_checked(&app, &menu, id)).collect(); + assert_eq!(checked, ["edit.freeTransform"]); + crate::canvas_tool_menu::choose(&mut app, &ctx, "edit.transform.warp"); + assert!(app.ui.transform.as_ref().unwrap().warp.is_some()); + // Already warping: the row is no longer offered. + assert!(!crate::canvas_tool_menu::available(&app, &menu, "edit.transform.warp")); + } + + #[test] + fn right_click_menu_warp_row_is_off_for_a_lone_path() { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let ctx = egui::Context::default(); + begin(&mut app, &ctx).unwrap(); + app.ui.transform.as_mut().unwrap().path = Some(json!({})); + assert!(crate::canvas_tool_menu::open_transform(&mut app, [10.0, 10.0])); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + assert!(!crate::canvas_tool_menu::available(&app, &menu, "edit.transform.warp")); + } + fn app_with_square(size: u32, fill: photocraft_geom::Rect) -> PhotocraftApp { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.session.execute("file.new", json!({"width": size, "height": size})).unwrap(); @@ -2772,6 +3104,92 @@ mod tests { app } + /// #2439: a layer under others keeps its place in the stack while the box is open. The + /// canvas under the box shows only the layers below it; the ones over it are composited on + /// their own and drawn over the moving pixels. + #[test] + fn free_transform_keeps_the_layer_under_the_layers_over_it() { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let red = app.session.active().unwrap().active_layer.unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(16, 16, 40, 40), &[0.0, 0.0, 1.0, 1.0]); + Ok(()) + }) + .unwrap(); + let blue = app.session.active().unwrap().active_layer.unwrap(); + let bg = app.session.active().unwrap().doc.layers[0].id; + app.session.execute("layer.select", json!({"layer": red.0})).unwrap(); + let ctx = gpu_ctx(); + begin(&mut app, &ctx).unwrap(); + let pv = app.transform_preview.as_ref().unwrap(); + assert!(pv.doc.layer(bg).unwrap().visible, "the layers below show under the box"); + assert!(!pv.doc.layer(blue).unwrap().visible, "the layer over it is drawn over the box instead"); + let (texture, rect) = pv.above.as_ref().and_then(|a| a.shown.as_ref()).expect("something covers the layer"); + assert_eq!(*rect, photocraft_geom::Rect::new(16, 16, 40, 40)); + assert_eq!(texture.size(), [24, 24]); + cancel(&mut app); + + // The top layer: nothing to draw over it. + app.session.execute("layer.select", json!({"layer": blue.0})).unwrap(); + begin(&mut app, &ctx).unwrap(); + let pv = app.transform_preview.as_ref().unwrap(); + assert!(pv.above.as_ref().is_some_and(|a| a.shown.is_none())); + assert!(pv.doc.layer(red).unwrap().visible && pv.doc.layer(bg).unwrap().visible); + cancel(&mut app); + } + + #[test] + fn plain_layers_from_the_top_cover_the_moving_one() { + use photocraft_color::BlendMode; + use photocraft_doc::Layer; + let px = |name: &str| Layer::raster(name, photocraft_color::PixelFormat::RGBA8); + let covers = |layers: &[Layer], id: LayerId| { + let mut out = Vec::new(); + covering(layers, id, &mut out); + out + }; + // In a group: the group's layers over it, then the ones over the group; never the layers + // clipped to it. + let (below, target, mut clip, inner_top, top) = (px("below"), px("target"), px("clip"), px("inner top"), px("top")); + clip.clipped = true; + let (t, c, inner, tp) = (target.id, clip.id, inner_top.id, top.id); + let mut layers = vec![below, Layer::group("group", vec![target, clip, inner_top]), top]; + assert_eq!(covers(&layers, t), [tp, inner]); + assert_eq!(covers(&layers, c), [tp, inner], "a clipped layer: covered by what is over its clipping group"); + + // Under the box, the covering layers leave the document; over it, they alone show. + let above = Above { ctx: egui::Context::default(), name: String::new(), max_side: 64, ids: vec![tp, inner], shown: None }; + let mut under = layers.clone(); + above.hide_in(&mut under); + let vis = |ls: &[Layer]| [ls[0].visible, ls[1].visible, ls[2].visible]; + assert_eq!(vis(&under), [true, true, false]); + let mut over = layers.clone(); + show_only(&mut over, &above.ids); + assert_eq!(vis(&over), [false, true, true]); + let inside: Vec = match &over[1].content { + LayerContent::Group(g) => g.children.iter().map(|l| l.visible).collect(), + _ => Vec::new(), + }; + assert_eq!(inside, [false, false, true]); + + // A layer that blends with what is under it stops the search: it and everything under it + // stay in the document under the box. + layers[2].blend = BlendMode::Multiply; + assert!(covers(&layers, t).is_empty()); + layers[2].blend = BlendMode::Normal; + let mut multiply = px("multiply"); + multiply.blend = BlendMode::Multiply; + layers.insert(2, multiply); + assert_eq!(covers(&layers, t), [tp]); + // A group that isn't Normal or Pass Through keeps what is inside it. + layers[1].blend = BlendMode::Screen; + layers.remove(2); + assert_eq!(covers(&layers, t), [tp]); + assert_eq!(covers(&layers, LayerId::fresh()), [tp], "not in the stack: down to the first layer that isn't plain"); + } + /// A context that reports a real GPU's texture limit (the default reports 2048). fn gpu_ctx() -> egui::Context { let ctx = egui::Context::default(); @@ -2821,7 +3239,14 @@ mod tests { assert!(q[0][0] < -10.0 && q[2][1] > 74.0, "the box reaches past the canvas: {q:?}"); let tol = 1e-3; for (rotation, flip) in [(0.0, false), (30.0, false), (-75.0, true)] { - let xf = ViewXform { rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)), zoom: 2.0, center: [32.0, 32.0], flip, rotation }; + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)), + zoom: 2.0, + center: [32.0, 32.0], + flip, + rotation, + aspect: 1.0, + }; let meshes = preview_meshes(&app, &xf); let verts: Vec<[f64; 2]> = meshes.iter().flat_map(|m| m.vertices.iter().map(|v| xf.to_doc(v.pos))).collect(); assert!(!verts.is_empty(), "the preview still draws (rotation {rotation})"); @@ -2836,8 +3261,14 @@ mod tests { // Inside the canvas the mesh is drawn unchanged (24×24 grid). cancel(&mut app); begin(&mut app, &gpu_ctx()).unwrap(); - let xf = - ViewXform { rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0 }; + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(200.0, 200.0)), + zoom: 1.0, + center: [32.0, 32.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; let meshes = preview_meshes(&app, &xf); assert_eq!(meshes.iter().map(|m| m.vertices.len()).sum::(), 25 * 25); } @@ -2912,6 +3343,7 @@ mod tests { center: [3000.0, 2000.0], flip: false, rotation: 0.0, + aspect: 1.0, }; draw_overlay(&app, &painter, &xf); }); @@ -2984,6 +3416,84 @@ mod tests { assert!(app.ui.transform.as_ref().unwrap().warp.is_none()); } + fn warping_square() -> PhotocraftApp { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 32, 32)); + begin_warp(&mut app, &egui::Context::default()).unwrap(); + app + } + + fn warp_mesh(app: &PhotocraftApp) -> BezierMesh { + app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap().mesh.clone().unwrap() + } + + #[test] + fn warp_drag_from_inside_the_grid_pulls_the_surface_under_the_pointer() { + let mut app = warping_square(); + let before = warp_mesh(&app); + // (20, 20) is mid-cell, far from every anchor and handle. + warp_pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE); + assert!(matches!(app.transform_preview.as_ref().unwrap().warp_drag, Some(WarpDrag::Surface { .. }))); + warp_pointer(&mut app, ToolEvent::Move { x: 30.0, y: 14.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE); + warp_pointer(&mut app, ToolEvent::Up { x: 30.0, y: 14.0 }, 2.0, egui::Modifiers::NONE); + let after = warp_mesh(&app); + let (s, t) = surface_at(&before, [20.0, 20.0]).unwrap(); + let moved = after.eval(s, t); + assert!((moved[0] - 30.0).abs() < 1e-6 && (moved[1] - 14.0).abs() < 1e-6, "{moved:?}"); + assert!(app.transform_preview.as_ref().unwrap().warp_drag.is_none()); + // The picture keeps filling its frame: a pull in the middle leaves the box corners put. + assert!([0, 3, 12, 15].iter().all(|k| after.points[*k] == before.points[*k])); + assert!(after.points[5] != before.points[5], "the inside of the grid moved"); + } + + #[test] + fn a_handle_belongs_to_the_anchor_it_hangs_off() { + assert_eq!(handle_anchor(0, 1), (0, 0)); + assert_eq!(handle_anchor(0, 2), (0, 3)); + assert_eq!(handle_anchor(1, 0), (0, 0)); + assert_eq!(handle_anchor(5, 3), (6, 3)); + assert_eq!(handle_anchor(3, 3), (3, 3)); + } + + #[test] + fn reset_warp_returns_to_the_untouched_grid_and_keeps_the_session() { + let mut app = warping_square(); + warp_pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE); + warp_pointer(&mut app, ToolEvent::Up { x: 30.0, y: 14.0 }, 2.0, egui::Modifiers::NONE); + assert!(!app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap().is_identity()); + reset_warp(&mut app); + let w = app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap(); + assert!(w.is_identity(), "back to the untouched box"); + assert_eq!(w.mesh.as_ref().map(|m| (m.us.len(), m.vs.len())), Some((2, 2))); + assert!(app.transform_preview.as_ref().unwrap().warp_drag.is_none()); + // Not warping: nothing to reset. + leave_warp(&mut app); + reset_warp(&mut app); + assert!(app.ui.transform.as_ref().unwrap().warp.is_none()); + } + + #[test] + fn warp_press_outside_the_grid_grabs_nothing() { + let mut app = warping_square(); + let before = warp_mesh(&app); + warp_pointer(&mut app, ToolEvent::Down { x: 55.0, y: 55.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE); + assert!(app.transform_preview.as_ref().unwrap().warp_drag.is_none()); + warp_pointer(&mut app, ToolEvent::Up { x: 60.0, y: 60.0 }, 2.0, egui::Modifiers::NONE); + assert_eq!(warp_mesh(&app), before); + } + + #[test] + fn ctrl_click_cuts_the_grid_both_ways() { + let mut app = warping_square(); + let ctrl = egui::Modifiers::COMMAND; + warp_pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, 2.0, ctrl); + warp_pointer(&mut app, ToolEvent::Up { x: 20.0, y: 20.0 }, 2.0, ctrl); + let m = warp_mesh(&app); + // A cut each way at the pointer (the default grid's rule-of-thirds guides become real + // lines too, as with the Cross split tool). + assert!(m.us.iter().any(|u| (u - 0.5).abs() < 1e-6), "vertical cut: {:?}", m.us); + assert!(m.vs.iter().any(|v| (v - 0.5).abs() < 1e-6), "horizontal cut: {:?}", m.vs); + } + #[test] fn replacing_the_warp_mesh_cancels_a_pending_point_drag() { let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 32, 32)); @@ -2994,7 +3504,7 @@ mod tests { } pointer(&mut app, ToolEvent::Down { x: 32.0, y: 32.0, pressure: 1.0 }, egui::Modifiers::NONE); - assert!(app.transform_preview.as_ref().unwrap().warp_drag.is_some(), "the anchor drag is armed"); + assert!(matches!(app.transform_preview.as_ref().unwrap().warp_drag, Some(WarpDrag::Point { .. })), "the anchor drag is armed"); split(&mut app, "edit.transform.removeWarpSplit", None).unwrap(); assert!(app.transform_preview.as_ref().unwrap().warp_drag.is_none(), "the old mesh index is discarded"); @@ -3006,7 +3516,7 @@ mod tests { /// A press on a preset warp that misses its points leaves the preset alone (no invisible undo /// step); grabbing a point turns it into a custom mesh. #[test] - fn a_press_on_a_preset_warp_converts_it_only_when_it_grabs_a_point() { + fn a_press_on_a_preset_warp_converts_it_only_when_it_grabs_the_grid() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); app.sync_views(); @@ -3021,9 +3531,15 @@ mod tests { let t = app.ui.transform.as_mut().unwrap(); let preset = Warp::preset(WarpStyle::Arc, 50.0, t.rect); t.warp = Some(preset.clone()); - warp_pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE); - warp_pointer(&mut app, ToolEvent::Up { x: 20.0, y: 20.0 }, 2.0, egui::Modifiers::NONE); + // Off the grid altogether: a miss. + warp_pointer(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE); + warp_pointer(&mut app, ToolEvent::Up { x: 60.0, y: 60.0 }, 2.0, egui::Modifiers::NONE); assert_eq!(app.ui.transform.as_ref().unwrap().warp.as_ref(), Some(&preset), "a miss changes nothing"); + // Inside the grid, away from every point: the surface is grabbed, so the preset converts. + warp_pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE); + assert_eq!(app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap().style, WarpStyle::Custom); + warp_pointer(&mut app, ToolEvent::Up { x: 20.0, y: 20.0 }, 2.0, egui::Modifiers::NONE); + app.ui.transform.as_mut().unwrap().warp = Some(preset.clone()); let corner = preset.to_mesh(1, 1).points[15]; warp_pointer(&mut app, ToolEvent::Down { x: corner[0], y: corner[1], pressure: 1.0 }, 2.0, egui::Modifiers::NONE); assert_eq!(app.ui.transform.as_ref().unwrap().warp.as_ref().unwrap().style, WarpStyle::Custom); @@ -3070,7 +3586,7 @@ mod tests { let st = app.session.active().unwrap(); let sel = st.doc.selection.clone().unwrap(); let id = st.active_layer.unwrap(); - let (lifted, _) = photocraft_engine::transform_cmds::split_selected(st.doc.layer(id).unwrap().surface().unwrap(), &sel); + let (lifted, _) = photocraft_engine::transform_cmds::split_selected(st.doc.layer(id).unwrap().surface().unwrap(), &sel).unwrap(); let b = photocraft_geom::Rect::new(8, 8, 20, 24); let (img, _) = preview_image(&st.doc, id, Some(&lifted), b, 4096); assert_eq!(texel_alpha(&img, 2, 4), 0, "masked"); diff --git a/crates/ui-egui/src/transform_type_tests.rs b/crates/ui-egui/src/transform_type_tests.rs new file mode 100644 index 0000000000..edc4c19ab3 --- /dev/null +++ b/crates/ui-egui/src/transform_type_tests.rs @@ -0,0 +1,87 @@ +//! Free Transform on type layers (#2630): the Edit menu offers it, a selection doesn't narrow it +//! (type can't be partly transformed, so the whole layer moves), and Distort / Perspective are +//! greyed as in Photoshop until the type is rasterized. + +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +/// A 200 × 120 document with a type layer "Hello" active and the Rectangular Marquee chosen. +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); + app.sync_views(); + app.run("type.create", json!({"x": 20, "y": 60, "text": "Hello", "size": 36})).unwrap(); + app.ui.tool = Tool::RectMarquee; + app +} + +fn type_bounds(app: &PhotocraftApp) -> photocraft_geom::Rect { + let st = app.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds() +} + +fn select_left_half(app: &mut PhotocraftApp) { + let b = type_bounds(app); + app.run("select.rect", json!({"x": b.x0, "y": b.y0, "width": b.width() / 2, "height": b.height()})).unwrap(); +} + +#[test] +fn type_layers_offer_free_transform_but_not_distort_or_perspective() { + let mut app = app(); + for selection in [false, true] { + if selection { + select_left_half(&mut app); + } + for id in ["edit.freeTransform", "edit.transform.scale", "edit.transform.rotate", "edit.transform.skew"] { + assert!(crate::menus::is_enabled(&app, id), "{id} (selection: {selection})"); + } + for id in ["edit.transform.distort", "edit.transform.perspective"] { + assert!(!crate::menus::is_enabled(&app, id), "{id} is greyed for editable type (selection: {selection})"); + } + } + // The same rows in the marquee's canvas context menu. + let menu = crate::canvas_tool_menu::CanvasToolMenu { + pos: [10.0, 10.0], + tool: Tool::RectMarquee, + has_selection: true, + has_path: false, + path_name: None, + transform: false, + pasteboard: false, + }; + assert!(crate::canvas_tool_menu::available(&app, &menu, "edit.freeTransform")); + assert!(!crate::canvas_tool_menu::available(&app, &menu, "edit.transform.distort")); + assert!(!crate::canvas_tool_menu::available(&app, &menu, "edit.transform.perspective")); + // Rasterized, the type is pixels and can be distorted. + app.run("layer.rasterize.type", json!({})).unwrap(); + assert!(crate::menus::is_enabled(&app, "edit.transform.distort") && crate::menus::is_enabled(&app, "edit.transform.perspective")); +} + +#[test] +fn free_transform_with_a_selection_frames_and_moves_the_whole_type_layer() { + let mut app = app(); + let whole = type_bounds(&app); + select_left_half(&mut app); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "edit.freeTransform", json!({})).unwrap(); + let t = app.ui.transform.as_ref().unwrap(); + assert_eq!(t.rect, [whole.x0 as f64, whole.y0 as f64, whole.x1 as f64, whole.y1 as f64], "the box frames the whole text"); + // The preview moves the whole layer (it is hidden under the box), not half of its pixels. + let pv = app.transform_preview.as_ref().unwrap(); + let id = app.session.active().unwrap().active_layer.unwrap(); + let under = pv.doc.layer(id).unwrap(); + assert!(!under.visible, "the type layer is hidden while its preview moves"); + assert_eq!(under.surface().unwrap().content_bounds(), whole, "nothing lifted out of the type"); + // Move the box 10 px right: the text moves 10 px, unscaled. + if let Some(t) = app.ui.transform.as_mut() { + t.quad = t.quad.map(|q| [q[0] + 10.0, q[1]]); + } + crate::transform_tool::commit(&mut app); + assert_eq!(app.ui.status, ""); + let moved = type_bounds(&app); + assert!(moved.x0.abs_diff(whole.x0 + 10) <= 1 && moved.width().abs_diff(whole.width()) <= 1, "{whole:?} -> {moved:?}"); + let st = app.session.active().unwrap(); + assert!(matches!(st.doc.layer(id).unwrap().content, photocraft_doc::LayerContent::Text(_)), "still editable type"); +} diff --git a/crates/ui-egui/src/transform_undo_tests.rs b/crates/ui-egui/src/transform_undo_tests.rs index 7e290f8904..3607702319 100644 --- a/crates/ui-egui/src/transform_undo_tests.rs +++ b/crates/ui-egui/src/transform_undo_tests.rs @@ -13,6 +13,11 @@ use crate::state::Tool; /// A 400 × 300 document with a 100 × 60 red rectangle on its own layer at (100, 100), the Move /// tool active, at 100 %. fn harness() -> Harness<'static, PhotocraftApp> { + harness_tool(Tool::Move) +} + +/// The same document with a chosen tool selected (e.g. Hand, #2302). +fn harness_tool(tool: Tool) -> Harness<'static, PhotocraftApp> { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); app.run("layer.new.layer", json!({})).unwrap(); @@ -21,7 +26,7 @@ fn harness() -> Harness<'static, PhotocraftApp> { app.run("select.deselect", json!({})).unwrap(); app.sync_views(); app.ui.extras.rulers = false; - app.ui.tool = Tool::Move; + app.ui.tool = tool; let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( |ui, app: &mut PhotocraftApp| { let ctx = ui.ctx().clone(); @@ -52,6 +57,7 @@ fn screen(h: &Harness<'static, PhotocraftApp>, p: [f64; 2]) -> Pos2 { center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation, + aspect: app.ui.view.display_aspect(), }; xf.to_screen(p[0] as f32, p[1] as f32) } @@ -205,6 +211,61 @@ fn a_press_just_off_a_corner_scales_rather_than_rotates() { } } +/// With the Hand tool selected, a Free Transform drag resizes the box rather than panning the view +/// away: the open box owns the pointer (#2302). +#[test] +fn the_hand_tool_does_not_steal_a_free_transform_drag() { + let mut h = harness_tool(Tool::Hand); + begin(&mut h); + let q0 = quad(&h); + let center0 = h.state().ui.views[0].center; + drag(&mut h, q0[2], [300.0, 220.0]); + let q1 = quad(&h); + assert!(!close(q1, q0), "the drag scaled the box, not the view: {q1:?}"); + assert_eq!(h.state().ui.views[0].center, center0, "the Hand did not pan"); + key(&mut h, Modifiers::NONE, Key::Enter); + assert!(h.state().ui.transform.is_none(), "the transform committed"); + assert!(width(&h) > 150, "the committed resize applied: {}", width(&h)); +} + +/// With the (freehand) Lasso selected, a Free Transform of a lasso selection follows the drag: the +/// Lasso reads its own pointer events, and dropped the moves and release of a drag it hadn't +/// started, so the box never changed (#2153). +#[test] +fn the_lasso_tool_does_not_swallow_a_free_transform_drag() { + let mut h = harness_tool(Tool::Lasso); + let outline = json!([[90, 90], [210, 90], [210, 170], [90, 170]]); + h.state_mut().run("select.lasso", json!({"points": outline})).unwrap(); + begin(&mut h); + let q0 = quad(&h); + drag(&mut h, q0[2], [300.0, 220.0]); + let q1 = quad(&h); + assert!(!close(q1, q0), "the drag scaled the box: {q1:?}"); + key(&mut h, Modifiers::NONE, Key::Enter); + assert!(h.state().ui.transform.is_none(), "the transform committed"); + assert!(width(&h) > 150, "the committed resize applied: {}", width(&h)); +} + +/// Holding Space (the temporary Hand, #249) still pans the view while a Free Transform box is open, +/// rather than resizing it (#2302 review): only the selected Hand yields to the box. +#[test] +fn holding_space_still_pans_while_transforming() { + let mut h = harness(); + begin(&mut h); + let q0 = quad(&h); + let center0 = h.state().ui.views[0].center; + let space = |h: &mut Harness<'static, PhotocraftApp>, down: bool| { + h.event(egui::Event::Key { key: Key::Space, physical_key: None, pressed: down, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + }; + space(&mut h, true); + drag(&mut h, q0[2], [300.0, 220.0]); + space(&mut h, false); + h.run_steps(2); + assert!(close(quad(&h), q0), "the space-drag did not touch the box: {:?}", quad(&h)); + assert_ne!(h.state().ui.views[0].center, center0, "the view panned instead"); +} + /// A transform started by dragging a Move-tool control: that first drag is undoable too. #[test] fn the_first_drag_from_the_move_tools_controls_undoes() { diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index 6e67eeb53c..137a4a5090 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -19,7 +19,6 @@ fn harness() -> Harness<'static, State> { let state = State { current: "Family 00".into(), families, popup: egui::Id::NULL }; let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state( |ui, s: &mut State| { - // `ComboBox::from_id_salt` wraps its salt in an `IdSalt`; the popup id is the button's + "popup". s.popup = ui.make_persistent_id(egui::IdSalt::new("type-font")).with("popup"); let fams = s.families.clone(); font_picker_in(ui, &mut s.current, 170.0, &fams); @@ -37,10 +36,35 @@ fn popup_rect(h: &Harness<'static, State>) -> Option { } fn open(h: &mut Harness<'static, State>) { - h.get_by_role(Role::ComboBox).click(); + h.query_all_by_role(Role::ComboBox).next().unwrap().click(); h.run(); } +#[test] +fn hovering_a_font_row_sets_preview_and_leaving_clears_it() { + let mut h = harness(); + open(&mut h); + let row = h.get_by_label("Family 02").rect().center(); + let popup = popup_rect(&h).expect("font menu open"); + assert!(popup.contains(row), "row {row:?} is outside popup {popup:?}"); + h.hover_at(row); + h.run_steps(2); + let hover_id = egui::Id::new("type-font-hover"); + assert_eq!(h.ctx.input(|i| i.pointer.interact_pos()), Some(row)); + assert!(popup_rect(&h).is_some(), "popup closes while moving pointer to the row"); + assert_eq!( + h.ctx.data(|d| d.get_temp::(hover_id)).as_deref(), + Some("Family 02"), + "row {row:?}, popup {popup:?}, top layer {:?}", + h.ctx.layer_id_at(row) + ); + + let search = h.get_by_role(Role::TextInput).rect().center(); + h.hover_at(search); + h.run(); + assert!(h.ctx.data(|d| d.get_temp::(hover_id)).is_none()); +} + #[test] fn clearing_the_query_restores_the_menu_height() { let mut h = harness(); @@ -193,4 +217,162 @@ fn font_cycle_in_options_bar_edits_the_layer_and_can_be_undone() { h.run(); h.state_mut().run("edit.undo", json!({})).unwrap(); assert_eq!(font(h.state()), "Inter"); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!(font(h.state()), "JetBrains Mono"); +} + +#[test] +fn hovering_a_font_does_not_pollute_history_and_clears_on_close() { + use crate::{PhotocraftApp, Services}; + use serde_json::json; + + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("type.create", json!({"text": "Sample", "font": "Inter", "size": 18})).unwrap(); + + let layer_id = app.session.active().unwrap().active_layer.unwrap(); + let history_before = app.session.active().unwrap().history.entries().len(); + + // Simulate hovering a font directly via the preview module + let ctx = egui::Context::default(); + ctx.data_mut(|d| d.insert_temp(egui::Id::new("type-font-hover"), "JetBrains Mono".to_string())); + crate::type_font_preview::follow_hover(&mut app, &ctx); + + // Verify a preview document is generated + let (_, preview_key) = crate::type_font_preview::display_doc(&mut app, 0).expect("preview document"); + + // Verify the original document's history is NOT polluted + let history_after = app.session.active().unwrap().history.entries().len(); + assert_eq!(history_before, history_after, "Hovering a font does not create an undo step"); + let doc = app.session.active().unwrap().doc.id; + let revision = app.session.active().unwrap().revision; + assert!(crate::type_font_preview::damage(&app, doc, revision, 0, preview_key).is_some(), "preview bounds survive dismissal for canvas invalidation"); + + // Verify the actual document layer still has the original font + let original_font = match &app.session.active().unwrap().doc.layer(layer_id).unwrap().content { + photocraft_doc::LayerContent::Text(t) => t.font_family.clone(), + _ => panic!("type layer"), + }; + assert_eq!(original_font, "Inter", "Hovering does not commit the font to the document"); + + // Simulate the font menu closing (hover cleared) + crate::type_font_preview::follow_hover(&mut app, &ctx); + let no_preview = crate::type_font_preview::display_doc(&mut app, 0); + assert!(no_preview.is_none(), "No preview document is returned after clearing"); + + crate::type_font_preview::set(&mut app, "Unavailable Font".into()); + assert!(crate::type_font_preview::display_doc(&mut app, 0).is_none(), "unavailable fonts do not render a misleading fallback preview"); +} + +#[test] +fn options_bar_hover_previews_on_canvas_without_committing() { + use crate::{PhotocraftApp, Services}; + use photocraft_doc::LayerContent; + use serde_json::json; + + struct PreviewApp { + app: PhotocraftApp, + } + + impl eframe::App for PreviewApp { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + crate::type_font_preview::follow_hover(&mut self.app, ctx); + } + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + ui.horizontal(|ui| crate::type_tool::options_bar(&mut self.app, ui)); + } + } + + let mut h = Harness::builder().with_size(vec2(900.0, 700.0)).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::ALL[0]); + PreviewApp { app: PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()) } + }); + h.state_mut().app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + h.state_mut().app.run("type.create", json!({"text": "Sample", "font": "Inter", "size": 18})).unwrap(); + let layer = h.state().app.session.active().unwrap().active_layer.unwrap(); + h.state_mut().app.ui.tool = crate::state::Tool::Type; + h.run_steps(3); + h.query_all_by_role(Role::ComboBox).next().unwrap().click(); + h.run(); + h.get_by_role(Role::TextInput).type_text("JetBrains Mono"); + h.run(); + h.get_by_label("JetBrains Mono").hover(); + h.run(); + + let (preview, _) = crate::canvas::display_doc(&mut h.state_mut().app, 0); + let LayerContent::Text(preview_text) = &preview.layer(layer).unwrap().content else { + panic!("type layer"); + }; + assert_eq!(preview_text.char_runs()[0].style.font_family, "JetBrains Mono"); + let LayerContent::Text(original_text) = &h.state().app.session.active().unwrap().doc.layer(layer).unwrap().content else { + panic!("type layer"); + }; + assert_eq!(original_text.char_runs()[0].style.font_family, "Inter"); + assert_eq!(h.state().app.session.active().unwrap().history.entries().len(), 2, "hover adds no history entry"); + + h.get_by_role(Role::TextInput).hover(); + h.run(); + let (restored, key) = crate::canvas::display_doc(&mut h.state_mut().app, 0); + let LayerContent::Text(restored_text) = &restored.layer(layer).unwrap().content else { + panic!("type layer"); + }; + assert_eq!(restored_text.char_runs()[0].style.font_family, "Inter"); + assert_eq!(key, 0, "leaving the font row restores committed canvas content"); + + h.get_by_label("JetBrains Mono").hover(); + h.run(); + h.get_by_label("JetBrains Mono").click(); + h.run(); + let (committed, key) = crate::canvas::display_doc(&mut h.state_mut().app, 0); + let LayerContent::Text(committed_text) = &committed.layer(layer).unwrap().content else { + panic!("type layer"); + }; + assert_eq!(committed_text.char_runs()[0].style.font_family, "JetBrains Mono"); + assert_eq!(key, 0, "selection replaces the preview with committed content"); + assert_eq!(h.state().app.session.active().unwrap().history.entries().len(), 3, "selection adds one history step"); + let committed_font = |app: &PhotocraftApp| { + let LayerContent::Text(text) = &app.session.active().unwrap().doc.layer(layer).unwrap().content else { + panic!("type layer"); + }; + text.char_runs()[0].style.font_family.clone() + }; + h.state_mut().app.run("edit.undo", json!({})).unwrap(); + assert_eq!(committed_font(&h.state().app), "Inter"); + h.state_mut().app.run("edit.redo", json!({})).unwrap(); + assert_eq!(committed_font(&h.state().app), "JetBrains Mono"); +} + +#[test] +fn hovering_a_font_previews_only_the_selected_characters() { + use crate::{PhotocraftApp, Services}; + use serde_json::json; + + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("type.create", json!({"text": "abcdef", "font": "Inter", "size": 18})).unwrap(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.run("type.setStyle", json!({"layer": layer.0, "range": [0, 2], "font": "JetBrains Mono"})).unwrap(); + app.ui.text_edit = Some(crate::state::TextEdit { + layer: layer.0, + caret: 5, + anchor: 3, + session: "font-preview-test".into(), + created: false, + dragging: false, + resize: None, + preedit: None, + }); + + crate::type_font_preview::set(&mut app, "Inter".into()); + let (preview, _) = crate::type_font_preview::display_doc(&mut app, 0).expect("preview document"); + let fonts = |doc: &photocraft_doc::Document| { + let photocraft_doc::LayerContent::Text(text) = &doc.layer(layer).unwrap().content else { + panic!("type layer"); + }; + text.char_runs().into_iter().flat_map(|run| std::iter::repeat_n(run.style.font_family, run.len)).collect::>() + }; + + assert_eq!(fonts(&preview), ["JetBrains Mono", "JetBrains Mono", "Inter", "Inter", "Inter", "Inter"]); + assert_eq!(fonts(&app.session.active().unwrap().doc), ["JetBrains Mono", "JetBrains Mono", "Inter", "Inter", "Inter", "Inter"]); } diff --git a/crates/ui-egui/src/type_font_preview.rs b/crates/ui-egui/src/type_font_preview.rs new file mode 100644 index 0000000000..4c652ed7ce --- /dev/null +++ b/crates/ui-egui/src/type_font_preview.rs @@ -0,0 +1,137 @@ +//! Live canvas preview for hovered fonts in the Type tool's font picker (#2315). + +use std::collections::VecDeque; +use std::sync::Arc; + +use photocraft_doc::{DocId, Document, LayerContent, LayerId}; +use photocraft_geom::Rect; + +use crate::PhotocraftApp; + +const TAG: u64 = 1 << 60; + +#[derive(Clone)] +pub struct TypeFontPreview { + document: DocId, + revision: u64, + layer: LayerId, + range: Option<[usize; 2]>, + family: Option, + shown: Option>, + key: u64, + original_area: Rect, + areas: VecDeque<(u64, Rect)>, +} + +pub(crate) fn follow_hover(app: &mut PhotocraftApp, ctx: &egui::Context) { + let hover_id = egui::Id::new("type-font-hover"); + let family = ctx.data_mut(|d| { + let family = d.get_temp::(hover_id); + d.remove::(hover_id); + family + }); + if let Some(family) = family { + set(app, family); + } else if app.type_font_preview.is_some() { + clear(app); + } +} + +pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc, u64)> { + let p = app.type_font_preview.as_ref()?.clone(); + let family = p.family.as_deref()?; + let st = app.session.documents().get(idx)?; + if st.doc.id != p.document || st.revision != p.revision { + return None; + } + if !photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).fonts.has_family(family) { + return None; + } + if let Some(shown) = &p.shown { + return Some((shown.clone(), p.key)); + } + + // Clone the original document to avoid mutating the session's document + let original_doc = st.doc.clone(); + let mut doc = (*st.doc).clone(); + let layer = p.layer; + let family = family.to_string(); + let range = p.range; + + { + let l = doc.layer_mut(layer)?; + if let LayerContent::Text(t) = &mut l.content { + let (start, end) = range.map_or((0, t.text.len()), |[a, b]| (photocraft_text::byte_index(&t.text, a), photocraft_text::byte_index(&t.text, b))); + photocraft_engine::type_cmds::style_range(t, start, end, &|style| { + style.font_family.clone_from(&family); + style.postscript_name = None; + }); + photocraft_engine::type_cmds::refresh(&original_doc, t); + } else { + return None; + } + } + + let area = doc.layer(layer)?.surface().map_or(Rect::EMPTY, |surface| surface.tile_bounds()); + let shown = Arc::new(photocraft_engine::mode_cmds::display_document(&doc).unwrap_or(doc)); + let key = p.key; + if let Some(p) = app.type_font_preview.as_mut() { + if p.areas.len() >= 128 { + p.areas.pop_front(); + } + p.areas.push_back((key, area)); + p.shown = Some(shown.clone()); + } + Some((shown, key)) +} + +pub(crate) fn set(app: &mut PhotocraftApp, family: String) { + let Some((layer, range)) = crate::type_tool::target(app) else { + clear(app); + return; + }; + let layer = LayerId(layer); + let Some(st) = app.session.active() else { + clear(app); + return; + }; + if !matches!(st.doc.layer(layer).map(|l| &l.content), Some(LayerContent::Text(_))) { + clear(app); + return; + } + photocraft_text::served::request(&family); + if app.type_font_preview.as_ref().is_some_and(|p| { + p.family.as_deref() == Some(family.as_str()) && p.document == st.doc.id && p.revision == st.revision && p.layer == layer && p.range == range + }) { + return; + } + let doc_id = st.doc.id; + let revision = st.revision; + let original_area = st.doc.layer(layer).and_then(|l| l.surface()).map_or(Rect::EMPTY, |s| s.tile_bounds()); + let previous = app.type_font_preview.take().filter(|p| p.document == doc_id && p.revision == revision && p.layer == layer && p.range == range); + let (areas, original_area) = previous.map_or((VecDeque::new(), original_area), |p| (p.areas, p.original_area)); + let key = preview_key(revision, layer, range, &family); + app.type_font_preview = Some(TypeFontPreview { document: doc_id, revision, layer, range, family: Some(family), shown: None, key, original_area, areas }); +} + +pub(crate) fn clear(app: &mut PhotocraftApp) { + if let Some(p) = app.type_font_preview.as_mut() { + p.family = None; + p.shown = None; + } +} + +pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, before: u64, after: u64) -> Option { + if before == after { + return None; + } + let p = app.type_font_preview.as_ref().filter(|p| p.document == doc && p.revision == revision)?; + let area = |key| if key == 0 { Some(p.original_area) } else { p.areas.iter().find(|(k, _)| *k == key).map(|(_, r)| *r) }; + Some(area(before)?.union(&area(after)?)) +} + +fn preview_key(revision: u64, layer: LayerId, range: Option<[usize; 2]>, family: &str) -> u64 { + let range_key = range.map_or(0, |[start, end]| (start as u64).rotate_left(17) ^ end as u64); + let h = family.bytes().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ layer.0 ^ range_key, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b))); + (h & (TAG - 1)) | TAG +} diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 0e03175605..55e1dad241 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -295,6 +295,28 @@ fn current_text(app: &PhotocraftApp, id: LayerId) -> Option { Some(text_layer(&app.session.active()?.doc, id)?.text.clone()) } +/// The selected characters, clamped after undo/redo may have shortened the text. +pub(crate) fn selected_range(app: &PhotocraftApp) -> Option<[usize; 2]> { + let ed = app.ui.text_edit.as_ref()?; + let n = text_layer(&app.session.active()?.doc, LayerId(ed.layer))?.text.chars().count(); + let (a, b) = (ed.caret.min(ed.anchor).min(n), ed.caret.max(ed.anchor).min(n)); + (a < b).then_some([a, b]) +} + +/// Shared by the clipboard Cut event and Edit > Cut during a type edit. +pub(crate) fn cut_selection(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some([a, b]) = selected_range(app) else { return }; + let Some(ed) = app.ui.text_edit.as_ref() else { return }; + let Some(st) = app.session.active() else { return }; + let Some(text) = text_layer(&st.doc, LayerId(ed.layer)) else { return }; + ctx.copy_text(text.text.chars().skip(a).take(b - a).collect()); + if let Some(ed) = app.ui.text_edit.as_mut() { + ed.anchor = a; + ed.caret = b; + } + insert(app, ""); +} + /// Replace the selection with `s`. fn insert(app: &mut PhotocraftApp, s: &str) { let Some(ed) = app.ui.text_edit.clone() else { return }; @@ -312,6 +334,53 @@ fn insert(app: &mut PhotocraftApp, s: &str) { } } +/// Typed text (not a paste): with Preferences ▸ Type ▸ Use Smart Quotes, straight quotes become +/// typographic (Photoshop does not rewrite pasted text). +fn insert_typed(app: &mut PhotocraftApp, s: &str) { + if !app.session.prefs().type_.smart_quotes { + return insert(app, s); + } + let prev = app.ui.text_edit.clone().and_then(|ed| { + let at = ed.caret.min(ed.anchor); + let text = current_text(app, LayerId(ed.layer))?; + (at > 0).then(|| text.chars().nth(at - 1)).flatten() + }); + insert(app, &smart_quotes(s, prev)); +} + +/// Straight quotes become “ ” and ‘ ’: opening after whitespace, a bracket or a dash (or at the +/// start of the text), closing next to a letter or another quote. `prev` is the character before +/// the caret. +fn smart_quotes(s: &str, mut prev: Option) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + let c = match c { + '"' => { + if opens_quote(prev) { + '\u{201c}' + } else { + '\u{201d}' + } + } + '\'' => { + if opens_quote(prev) { + '\u{2018}' + } else { + '\u{2019}' + } + } + _ => c, + }; + out.push(c); + prev = Some(c); + } + out +} + +fn opens_quote(prev: Option) -> bool { + prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '-' | '\u{2013}' | '\u{2014}')) +} + /// Alt+←/→: kern the pair before the caret by `by` (1/1000 em). No pair (caret at a text or line /// edge): nothing happens, like Photoshop. Not coalesced: one history step per press. fn kern_pair(app: &mut PhotocraftApp, id: LayerId, caret: usize, by: f32) { @@ -454,20 +523,19 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { }; handled[k] = true; match ev { - egui::Event::Text(s) | egui::Event::Paste(s) => insert(app, s), + egui::Event::Text(s) => insert_typed(app, s), + egui::Event::Paste(s) => insert(app, s), // A bare line break from the IME is the Enter key, which the key arm handles. egui::Event::Ime(egui::ImeEvent::Preedit { text: s, .. } | egui::ImeEvent::Commit(s)) if s == "\n" || s == "\r" => handled[k] = false, egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => ime_update(app, text, false), egui::Event::Ime(egui::ImeEvent::Commit(s)) => ime_update(app, s, true), egui::Event::Ime(_) => {} - egui::Event::Copy | egui::Event::Cut => { + egui::Event::Copy => { if a < b { ctx.copy_text(text.chars().skip(a).take(b - a).collect()); - if matches!(ev, egui::Event::Cut) { - insert(app, ""); - } } } + egui::Event::Cut => cut_selection(app, ctx), egui::Event::Key { key, pressed: true, modifiers: m, .. } => { use egui::Key; let key = &flow_key(*key, vertical_flow); @@ -527,6 +595,8 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } Key::Enter if m.command => commit(app), Key::Enter => insert(app, "\n"), + // Photoshop types a tab character; layout advances it to the next tab stop. + Key::Tab if !m.command && !m.alt && !m.shift => insert(app, "\t"), Key::Escape if crate::type_transform::active(app) => crate::type_transform::cancel_drag(app), // Preferences ▸ Type: with "Use Escape to Commit" off, Escape cancels the // session (undoes it, removing a just-created layer) like the Cancel button. @@ -800,7 +870,8 @@ pub(crate) fn family_picker_in(ui: &mut egui::Ui, salt: &str, current: &mut Stri // Clicks inside the menu (the search field, the scroll bar) must not close it (#1369); // picking a font closes it explicitly below. .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside); - combo.show_ui(ui, |ui| { + + let response = combo.show_ui(ui, |ui| { let (pass_id, focus_id, height_id) = (search_id.with("pass"), search_id.with("focus"), search_id.with("height")); let pass = ui.ctx().cumulative_pass_nr(); let last_pass: Option = ui.data(|d| d.get_temp(pass_id)); @@ -841,14 +912,19 @@ pub(crate) fn family_picker_in(ui: &mut egui::Ui, salt: &str, current: &mut Stri photocraft_text::served::request(f); } } - if enter && filtered.iter().any(|f| *f == current) { + let accept = enter && filtered.iter().any(|f| *f == current); + if accept { ui.data_mut(|d| d.remove::(search_id)); ui.close(); } + + let mut hovered: Option = None; for f in filtered { let response = ui.add_sized( [330.0, 28.0], - egui::Button::selectable(f == current, f).truncate().right_text(egui::Atom::custom(ui.id().with(("font-sample", f)), egui::vec2(100.0, 24.0))), + egui::Button::selectable(f == current, f) + .truncate() + .right_text(egui::Atom::custom(ui.id().with(("font-sample", f)), egui::vec2(crate::font_preview::slot_width(ui.ctx(), f), 24.0))), ); if (down || up) && f == current { response.scroll_to_me(Some(egui::Align::Center)); @@ -856,12 +932,16 @@ pub(crate) fn family_picker_in(ui: &mut egui::Ui, salt: &str, current: &mut Stri if ui.is_rect_visible(response.rect) { crate::font_preview::paint(ui, f, response.rect); } + if response.hovered() { + hovered = Some(f.clone()); + } if response.clicked() { *current = f.clone(); // A served family not fetched yet: start now, before any text needs it. photocraft_text::served::request(f); changed = true; ui.data_mut(|d| d.remove::(search_id)); + hovered = None; ui.close(); } } @@ -874,12 +954,24 @@ pub(crate) fn family_picker_in(ui: &mut egui::Ui, salt: &str, current: &mut Stri } else if let Some(h) = ui.data(|d| d.get_temp::(height_id)) { ui.set_min_height(h.min(FONT_MENU_HEIGHT)); } + if accept { None } else { hovered } }); + let hovered_font = response.inner.unwrap_or_default(); + + if salt == "type-font" { + let hover_id = egui::Id::new("type-font-hover"); + if let Some(f) = hovered_font { + ui.ctx().data_mut(|d| d.insert_temp(hover_id, f)); + } else { + ui.ctx().data_mut(|d| d.remove::(hover_id)); + } + } + changed } /// Representative for the displayed properties: the edit layer, else a selected type layer. -fn target(app: &PhotocraftApp) -> Option<(u64, Option<[usize; 2]>)> { +pub(crate) fn target(app: &PhotocraftApp) -> Option<(u64, Option<[usize; 2]>)> { if let Some(ed) = &app.ui.text_edit { let (a, b) = (ed.caret.min(ed.anchor), ed.caret.max(ed.anchor)); return Some((ed.layer, (a < b).then_some([a, b]))); @@ -892,6 +984,12 @@ fn target(app: &PhotocraftApp) -> Option<(u64, Option<[usize; 2]>)> { Some((id.0, None)) } +/// The type layer whose properties the Type controls and dialogs show (see [`target`]). +pub(crate) fn target_text(app: &PhotocraftApp) -> Option<&TextLayer> { + let (id, _) = target(app)?; + text_layer(&app.session.active()?.doc, LayerId(id)) +} + /// Snapshot the mutation scope separately from the representative used to display properties. /// An inline edit remains on its layer/range; layer selection formats every selected type layer. pub(crate) fn formatting_params(app: &PhotocraftApp) -> Option { @@ -1602,6 +1700,29 @@ mod tests { assert!(app.ui.text_edit.is_none()); } + /// Pure smart-quote mapping: opening after whitespace, a bracket or a dash, closing next to a + /// letter or another quote. + #[test] + fn smart_quotes_are_typographic() { + assert_eq!(smart_quotes("\"Hello\"", None), "\u{201c}Hello\u{201d}"); + assert_eq!(smart_quotes("don't", None), "don\u{2019}t"); + assert_eq!(smart_quotes("(\"x\")", None), "(\u{201c}x\u{201d})"); + assert_eq!(smart_quotes("--'x'", None), "--\u{2018}x\u{2019}", "a dash stays a dash, the quote opens after it"); + assert_eq!(smart_quotes("\"\"", None), "\u{201c}\u{201d}"); + } + + /// Preferences ▸ Type ▸ Use Smart Quotes switches typed quotes (typed text only, not paste). + #[test] + fn smart_quotes_preference_controls_typed_quotes() { + for (smart, expected) in [(true, "hi \u{201c}you\u{201d}"), (false, "hi \"you\"")] { + let mut app = app(); + app.run("prefs.set", json!({"path": "type.smartQuotes", "value": smart})).unwrap(); + pointer_up(&mut app, [50.0, 100.0], [50.0, 100.0]); + insert_typed(&mut app, "hi \"you\""); + assert_eq!(layer_text(&app), expected, "smartQuotes={smart}"); + } + } + #[test] fn a_name_from_text_after_blank_lines_keeps_following_the_text() { let mut app = app(); diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index c046ce53ee..741aaabc07 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -54,6 +54,7 @@ fn xf(app: &PhotocraftApp) -> ViewXform { center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation, + aspect: app.ui.view.display_aspect(), } } @@ -485,6 +486,24 @@ fn command_t_while_typing_toggles_the_character_panel() { assert_ne!(crate::view_cmds::checked(h.state(), "window.panel.character"), before, "the Character panel toggled"); } +/// #1881: Tab while typing inserts a tab character (laid out to the next tab stop), as in +/// Photoshop, instead of moving keyboard focus or hiding the panels. +#[test] +fn tab_while_typing_inserts_a_tab() { + let mut app = new_app(); + let id = LayerId(app.run("type.create", json!({"text": "HOHO", "size": 60, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap()); + let mut h = harness(1.0, app); + let p = glyph(&mut h, id, 1, 0.2); + click(&mut h, p); + assert_eq!(selection(&h), (1, 1)); + let panels = h.state().ui.panels.dock; + key(&mut h, egui::Key::Tab); + assert_eq!(text(h.state(), id).text, "H\tOHO"); + assert_eq!(selection(&h), (2, 2)); + assert!(h.state().ui.text_edit.is_some(), "still editing"); + assert_eq!(h.state().ui.panels.dock, panels, "the panels stay as they were"); +} + #[test] fn text_color_dialog_edits_only_the_selected_range_and_cancel_is_inert() { let mut app = new_app(); @@ -1410,6 +1429,7 @@ mod multi_layer_formatting { for label in ["Layer", "Layers", "Range", "Coalesce"] { assert!(h.query_by_label(label).is_none(), "target metadata must not become a form field: {label}"); } + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("style".into(), json!("arc")); h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("bend".into(), json!(25.0)); selected(h.state_mut(), &[ids[2]]); h.get_by_label("OK").click(); diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 756bfac182..f1cd830db1 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1168,7 +1168,7 @@ mod tests { let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { let rect = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0)); let painter = ui.ctx().layer_painter(egui::LayerId::background()).with_clip_rect(rect); - let xf = ViewXform { rect, zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0 }; + let xf = ViewXform { rect, zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; draw_overlay(app, &painter, &xf, &doc); }); out.textures_delta.clear(); diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 53838dbbdc..e5c4d39fad 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -111,7 +111,8 @@ impl Default for ViewOptions { snap_to: SnapTo::default(), flip_horizontal: false, pixel_aspect: "square".into(), - pixel_aspect_correction: false, + // Photoshop shows non-square pixels corrected until the user turns it off. + pixel_aspect_correction: true, pattern_preview: false, pixel_art_preview: false, arrange: "tabs".into(), @@ -134,6 +135,13 @@ impl ViewOptions { pub fn hides_tabs(&self) -> bool { self.screen_mode != "standard" } + /// Horizontal display scale of a document pixel: the pixel aspect ratio while View › Pixel + /// Aspect Ratio Correction is on, else 1 (square). The document's pixels never change. + pub fn display_aspect(&self) -> f32 { + let r = pixel_aspect_ratio(&self.pixel_aspect); + // `ui.set` can store any string; only the dialog's 0.1–10 range is drawn. + if self.pixel_aspect_correction && r.is_finite() { r.clamp(0.1, 10.0) } else { 1.0 } + } /// Photoshop's full screen modes show no scroll bars. pub fn shows_scrollbars(&self) -> bool { self.screen_mode == "standard" @@ -424,6 +432,9 @@ fn wraps(id: &str) -> bool { | "file.saveACopy" | "file.placeEmbedded" | "file.placeLinked" + | "layer.smartObjects.replaceContents" + | "layer.smartObjects.exportContents" + | "layer.smartObjects.convertToLinked" | "file.closeAll" | "file.closeOthers" | "file.fileInfo" @@ -727,6 +738,59 @@ pub fn cells(layout: &str, rect: egui::Rect, n: usize) -> Option // ---------- dialogs and pickers in front of engine commands ---------- /// A generic form dialog that runs `command` with its fields on OK (rendered by [`form_body`]). +/// File › Open EXR Parts…: pick an EXR, then choose the parts and channel groups that become +/// the layers of one document (a Maya/Arnold render writes one part per AOV). The dialog is +/// a plain command form, so the control channel can drive the same selection headlessly. +fn open_exr_parts_dialog(app: &mut PhotocraftApp) -> Option> { + Some(app.pick_file_bytes_filtered(&["exr"], |app, name, _bytes| { + let info = app.run("file.exrParts", json!({"path": name})).map_err(|e| e.to_string())?; + let entries = info.as_array().cloned().unwrap_or_default(); + let mut fields = Map::new(); + fields.insert("path".into(), json!(name)); + fields.insert("view".into(), json!("both")); + fields.insert("precomp".into(), json!(false)); + let mut labels = Map::new(); + // The path field shows the picked file's name (data, verbatim); the rest translate. + if let Some(file) = name.rsplit(['/', '\\']).next() { + labels.insert("path".into(), json!(file)); + } + labels.insert("view".into(), json!(crate::i18n::t("View"))); + labels.insert("both".into(), json!(crate::i18n::t("Both"))); + labels.insert("left".into(), json!(crate::i18n::t("Left"))); + labels.insert("right".into(), json!(crate::i18n::t("Right"))); + labels.insert("precomp".into(), json!(crate::i18n::t("Add beauty precomp from the AOVs"))); + let mut any = false; + for e in &entries { + let kind = e["kind"].as_str().unwrap_or_default().to_string(); + let ename = e["name"].as_str().unwrap_or_default().to_string(); + let channels = e["channels"].as_u64().unwrap_or(0); + let key = format!("{kind}/{ename}"); + match kind.as_str() { + "part" => { + labels.insert(key.clone(), json!(format!("{ename} ({}×{}, {channels} ch)", e["width"], e["height"]))); + let on = !any && channels >= 3; + any |= on; + fields.insert(key, json!(on)); + } + "group" => { + labels.insert(key.clone(), json!(format!("{ename} ({channels} ch)"))); + fields.insert(key, json!(false)); + } + _ => {} + } + } + // Cryptomatte layers are information here (the picker comes separately); the array is + // not rendered but rides along for the control channel. + let crypto: Vec = entries.iter().filter(|e| e["kind"] == "cryptomatte").cloned().collect(); + if !crypto.is_empty() { + fields.insert("cryptomatte".into(), Value::Array(crypto)); + } + fields.insert("__labels".into(), Value::Object(labels)); + let choices = json!({"view": ["both", "left", "right"]}); + Ok(json!({"dialog": form(app, "file.openExrParts", "Open EXR Parts…", Value::Object(fields), choices)})) + })) +} + fn form(app: &mut PhotocraftApp, command: &str, label: &str, fields: Value, choices: Value) -> u64 { let mut f = Map::new(); f.insert("__command".into(), json!(command)); @@ -777,9 +841,11 @@ mod label_tests { /// Body of a `__form` dialog: text fields, number fields, checkboxes and `__choices` dropdowns. pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { let choices = f.get("__choices").cloned().unwrap_or(Value::Null); - let warp_text = f.get("__command").and_then(Value::as_str) == Some("type.warpText"); - let keys: Vec = - f.keys().filter(|k| !(k.starts_with("__") || warp_text && matches!(k.as_str(), "layer" | "layers" | "range" | "coalesce"))).cloned().collect(); + // Data-driven labels (a per-part checkbox shows the part's name, not a catalogue entry) + // come from `__labels` verbatim; everything else goes through the catalogue. + let labels = f.get("__labels").cloned().unwrap_or(Value::Null); + let label = |k: &str| labels.get(k).and_then(Value::as_str).map_or_else(|| label_of(k), str::to_string); + let keys: Vec = f.keys().filter(|k| !k.starts_with("__")).cloned().collect(); egui::Grid::new("form-dialog").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { for k in keys { let v = f.get(&k).cloned().unwrap_or(Value::Null); @@ -788,11 +854,11 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { Value::Bool(b) => { ui.label(""); let mut b = *b; - ui.checkbox(&mut b, label_of(&k)); + ui.checkbox(&mut b, label(&k)); json!(b) } Value::String(s) => { - ui.label(label_of(&k)); + ui.label(label(&k)); let mut s = s.clone(); match choices.get(&k).and_then(Value::as_array) { Some(opts) => { @@ -807,7 +873,7 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { json!(s) } Value::Number(n) => { - ui.label(label_of(&k)); + ui.label(label(&k)); if let Some(mut i) = n.as_i64() { ui.add(egui::DragValue::new(&mut i).custom_parser(crate::widgets::parse_num)); json!(i) @@ -864,6 +930,7 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option Some(app.open_dialog_file()), + "file.openExrParts" => open_exr_parts_dialog(app), "file.saveACopy" => Some(save_a_copy(app)), "file.placeEmbedded" | "file.placeLinked" => { let doc = match app.active_doc_id() { @@ -877,6 +944,29 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option { + let st = app.session.active()?; + let (doc, layer) = (st.doc.id, st.active_layer?); + Some(app.pick_file_bytes(move |app, name, bytes| { + app.with_document(doc, |app| { + let r = photocraft_engine::smart_cmds::replace_contents_bytes(&mut app.session, layer, &name, bytes).map_err(|e| e.to_string()); + app.sync_views(); + r + }) + })) + } + "layer.smartObjects.exportContents" => Some(export_contents(app)), + "layer.smartObjects.convertToLinked" => { + let st = app.session.active()?; + let (doc, layer) = (st.doc.id, st.active_layer?); + let (name, _) = match photocraft_engine::smart_cmds::contents_of(&st.doc, layer) { + Ok(c) => c, + Err(e) => return Some(Err(e.to_string())), + }; + Some(app.pick_save(&name, move |app, path| { + app.with_document(doc, |app| app.run("layer.smartObjects.convertToLinked", json!({"layer": layer.0, "path": path}))) + })) + } "file.fileInfo" => { let info = app.session.execute("file.fileInfo", json!({})).ok()?; let kw = info["keywords"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect::>().join("; ")).unwrap_or_default(); @@ -899,16 +989,7 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option { - let styles: Vec<&str> = std::iter::once("none").chain(photocraft_text::warp::STYLES.iter().map(|(_, s)| *s)).collect(); - let mut fields = json!({"style": "arc", "orientation": "horizontal", "bend": 50.0, "horizontalDistortion": 0.0, "verticalDistortion": 0.0}); - if let Some(Value::Object(target)) = crate::type_tool::formatting_params(app) { - for (key, value) in target { - fields[key] = value; - } - } - dialog(app, fields, json!({"style": styles, "orientation": ["horizontal", "vertical"]})) - } + "type.warpText" => Some(Ok(json!({"dialog": crate::dialogs::open_command_dialog(app, id, label)}))), "file.export.layersToFiles" => { let (_, _, name) = doc?; dialog( @@ -1075,6 +1156,21 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result { }) } +/// Layer › Smart Objects › Export Contents: saves the contents file under its own name by default. +fn export_contents(app: &mut PhotocraftApp) -> Result { + let st = app.session.active().ok_or("no document")?; + let layer = st.active_layer.ok_or("no layer selected")?; + // The contents as they were when asked, like Save a Copy. + let (name, bytes) = photocraft_engine::smart_cmds::contents_of(&st.doc, layer).map_err(|e| e.to_string())?; + app.pick_save(&name.clone(), move |app, path| { + let write = app.services.write.as_mut().ok_or("no writer configured")?; + write(&path, &bytes)?; + app.ui.status = format!("Exported contents to {path}"); + app.ui.status_error = false; + Ok(json!({"path": path, "fileName": name, "bytes": bytes.len()})) + }) +} + #[cfg(test)] #[path = "view_cmds/tests.rs"] mod tests; diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 6e849d4190..bd91465706 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -201,6 +201,7 @@ fn engine_commands_open_their_dialogs() { assert_eq!(app.session.active().unwrap().doc.guides.vertical, vec![0.0, 22.5, 32.5, 55.0]); app.run("type.create", json!({"x": 5, "y": 40, "text": "Warp", "size": 20})).unwrap(); let d = menu(&mut app, &ctx, "type.warpText", json!({})).unwrap()["dialog"].as_u64().unwrap(); + app.ui.dialog_mut(d).unwrap().fields.insert("style".into(), json!("arc")); crate::dialogs::confirm(&mut app, d).unwrap(); assert!(app.run("type.info", json!({})).unwrap()["warp"]["style"] == "warpArc"); // Batch needs a recorded action. diff --git a/crates/ui-egui/src/warp_preview.rs b/crates/ui-egui/src/warp_preview.rs new file mode 100644 index 0000000000..ec14375793 --- /dev/null +++ b/crates/ui-egui/src/warp_preview.rs @@ -0,0 +1,152 @@ +//! Where the Warp preview samples its mesh: finer where the warp pulls hard. +//! +//! A fixed grid of samples across the whole picture draws a narrow, heavily pulled grid cell with +//! only a few big triangles. Their shear shows as a comb of long wedges along the fold. Here each +//! grid cell gets as many samples as its control points are spread wide, so every triangle stays +//! a few screen pixels across wherever the surface is stretched. + +use photocraft_geom::warp::BezierMesh; + +/// Target screen pixels per preview segment. +const SEGMENT_PX: f64 = 6.0; +/// Fewest and most segments one grid cell gets along an axis. +const MIN_PER_CELL: usize = 4; +const MAX_PER_CELL: usize = 96; +/// Most samples along either axis for the whole mesh (the triangle count grows with their product). +const MAX_PER_AXIS: usize = 240; + +/// Normalised sample positions `(us, vs)` for `mesh` shown at `zoom` screen px per document px. +/// Both include every grid line (0, the knots and 1) in increasing order. +pub fn samples(mesh: &BezierMesh, zoom: f64) -> (Vec, Vec) { + let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 }; + let (cols, rows) = (mesh.us.len() - 1, mesh.vs.len() - 1); + let nx = mesh.nx(); + // Screen size of each cell's control points: bounds how far the cell's surface can stretch. + let mut cu = vec![MIN_PER_CELL; cols]; + let mut cv = vec![MIN_PER_CELL; rows]; + for (b, cv) in cv.iter_mut().enumerate() { + for (a, cu) in cu.iter_mut().enumerate() { + let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]); + for j in 0..4 { + for i in 0..4 { + if let Some(p) = mesh.points.get((3 * b + j) * nx + 3 * a + i) { + for k in 0..2 { + lo[k] = lo[k].min(p[k]); + hi[k] = hi[k].max(p[k]); + } + } + } + } + let ext = ((hi[0] - lo[0]).max(hi[1] - lo[1]) * zoom).abs(); + let n = if ext.is_finite() { (ext / SEGMENT_PX).ceil() as usize } else { MIN_PER_CELL }; + let n = n.clamp(MIN_PER_CELL, MAX_PER_CELL); + *cu = (*cu).max(n); + *cv = (*cv).max(n); + } + } + (axis(&mesh.us, &mut cu), axis(&mesh.vs, &mut cv)) +} + +/// Triangle indices for a `row`-wide vertex grid (`pos` and `rest` row by row; `rest` is where each +/// vertex sits with no warp), two triangles per cell. Cells are drawn from the most displaced to the +/// least, so the part of the picture the warp left alone is on top. +/// +/// Drawing in plain row order lets the layers of a hard pull (the sheets of an S-shaped fold) cover +/// each other row by row, which shows as a comb along the fold. A fold's layers differ in how far +/// the warp moved them, so ordering by displacement keeps each layer whole, with the tip of the +/// pull underneath and the picture as it was on top. +pub fn triangle_order(pos: &[[f32; 2]], rest: &[[f32; 2]], row: usize) -> Vec { + if row < 2 || pos.len() != rest.len() { + return Vec::new(); + } + let rows = pos.len() / row; + let moved = |k: usize| match (pos.get(k), rest.get(k)) { + (Some(p), Some(r)) => (p[0] - r[0]).hypot(p[1] - r[1]), + _ => 0.0, + }; + let mut cells: Vec<(f32, usize)> = Vec::with_capacity(rows * row); + for j in 0..rows.saturating_sub(1) { + for i in 0..row - 1 { + let a = j * row + i; + cells.push(((moved(a) + moved(a + 1) + moved(a + row) + moved(a + row + 1)) / 4.0, a)); + } + } + cells.sort_by(|x, y| y.0.total_cmp(&x.0)); + let mut out = Vec::with_capacity(cells.len() * 6); + for (_, a) in cells { + out.extend([a, a + 1, a + row + 1, a, a + row + 1, a + row].map(|k| k as u32)); + } + out +} + +/// Samples along one axis: `counts[k]` segments in knot interval `k`, scaled down together when +/// the total would pass [`MAX_PER_AXIS`]. +fn axis(knots: &[f64], counts: &mut [usize]) -> Vec { + let total: usize = counts.iter().sum(); + if total > MAX_PER_AXIS { + for c in counts.iter_mut() { + *c = (*c * MAX_PER_AXIS / total).max(2); + } + } + let mut out = Vec::with_capacity(counts.iter().sum::() + 1); + for (k, n) in counts.iter().enumerate() { + let (a, b) = (knots[k], knots[k + 1]); + out.extend((0..*n).map(|i| a + (b - a) * i as f64 / *n as f64)); + } + out.push(1.0); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_narrow_pulled_cell_gets_as_many_samples_as_its_stretch_needs() { + // A 900 x 1100 mesh with a narrow column (0.45..0.5) pulled 300 px to the left. + let mut base = BezierMesh::identity([0.0, 0.0, 900.0, 1100.0], 1, 1); + assert!(base.split_u(0.45) && base.split_u(0.5)); + let mut m = base.clone(); + m.pull(&base.points, 0.47, 0.5, [-300.0, 0.0]); + let (us, vs) = samples(&m, 1.0); + let in_narrow = us.iter().filter(|u| **u >= 0.45 && **u < 0.5).count(); + assert!(in_narrow >= 30, "{in_narrow} samples in the narrow cell"); + // Grid lines are samples, and the lists are sorted and span the box. + for k in [0.0, 0.45, 0.5, 1.0] { + assert!(us.iter().any(|u| (u - k).abs() < 1e-12), "{k}"); + } + assert!(us.windows(2).all(|w| w[1] > w[0]) && vs.windows(2).all(|w| w[1] > w[0])); + assert!(us.len() <= MAX_PER_AXIS + 8 && vs.len() <= MAX_PER_AXIS + 8, "{} x {}", us.len(), vs.len()); + } + + #[test] + fn the_most_displaced_cells_are_drawn_first() { + // A 4 x 3 vertex grid; the middle column was pulled 30 px right, so it moved most. + let rest: Vec<[f32; 2]> = (0..3).flat_map(|j| (0..4).map(move |i| [i as f32 * 10.0, j as f32 * 10.0])).collect(); + let pos: Vec<[f32; 2]> = rest.iter().enumerate().map(|(k, p)| if k % 4 == 1 || k % 4 == 2 { [p[0] + 30.0, p[1]] } else { *p }).collect(); + let idx = triangle_order(&pos, &rest, 4); + assert_eq!(idx.len(), 2 * 2 * 3 * 3); + // Cells come in whole pairs of triangles, and displacement never rises along the list. + let moved = |k: u32| (pos[k as usize][0] - rest[k as usize][0]).abs(); + let per_cell: Vec = idx.chunks(6).map(|c| c.iter().map(|k| moved(*k)).sum::()).collect(); + assert!(per_cell.windows(2).all(|w| w[0] >= w[1]), "{per_cell:?}"); + assert!(per_cell[0] > per_cell[per_cell.len() - 1]); + assert!(triangle_order(&pos, &rest[..5], 4).is_empty()); + } + + #[test] + fn an_unwarped_mesh_stays_cheap() { + let m = BezierMesh::identity([0.0, 0.0, 100.0, 100.0], 1, 1); + let (us, vs) = samples(&m, 1.0); + assert!(us.len() <= 20 && vs.len() <= 20, "{} x {}", us.len(), vs.len()); + } + + #[test] + fn a_bad_zoom_still_gives_samples() { + let m = BezierMesh::identity([0.0, 0.0, 100.0, 100.0], 2, 2); + for z in [0.0, -1.0, f64::NAN, f64::INFINITY] { + let (us, vs) = samples(&m, z); + assert!(us.len() >= 3 && vs.len() >= 3); + } + } +} diff --git a/crates/ui-egui/src/warp_text_dialog_tests.rs b/crates/ui-egui/src/warp_text_dialog_tests.rs new file mode 100644 index 0000000000..2431c840c9 --- /dev/null +++ b/crates/ui-egui/src/warp_text_dialog_tests.rs @@ -0,0 +1,208 @@ +//! #218: Type › Warp Text… opens with the type layer's current warp (it always started at Arc) +//! and previews the warp live on the canvas. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use photocraft_doc::text::TextWarp; +use photocraft_doc::{Document, LayerContent}; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; + +/// A document with one type layer, active. +fn app_with_type() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + app.run("type.create", json!({"x": 20, "y": 120, "text": "Warp", "size": 60})).unwrap(); + app +} + +fn flag() -> Value { + json!({"style": "flag", "bend": -30.0, "horizontalDistortion": 20.0, "verticalDistortion": -10.0, "orientation": "vertical"}) +} + +fn warp(app: &PhotocraftApp) -> Option { + let st = app.session.active().unwrap(); + warp_of(&st.doc, st.active_layer.unwrap()) +} + +fn warp_of(doc: &Document, id: photocraft_doc::LayerId) -> Option { + match &doc.layer(id).unwrap().content { + LayerContent::Text(t) => t.warp.clone(), + _ => panic!("the layer is a type layer"), + } +} + +fn steps(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.entries().len() +} + +/// The open dialogs drawn over `app`, as the window shows them. +fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state( + |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::dialogs::show(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h +} + +/// Set dialog values as the sliders and dropdowns would, and compute the canvas preview as a +/// frame of the canvas does. +fn edit_and_preview(h: &mut Harness<'_, PhotocraftApp>, id: u64, values: Value) { + let f = &mut h.state_mut().ui.dialog_mut(id).unwrap().fields; + for (k, v) in values.as_object().unwrap() { + f.insert(k.clone(), v.clone()); + } + h.run_steps(2); + crate::canvas::ensure_filter_preview(h.state_mut(), 0, &egui::Context::default()); +} + +/// Open Warp Text from the Type menu and return the dialog with its fields. +fn open(app: &mut PhotocraftApp) -> (u64, Map) { + let r = crate::menus::invoke(app, &egui::Context::default(), "type.warpText", json!({})).unwrap(); + let id = r["dialog"].as_u64().unwrap(); + (id, app.ui.dialog_mut(id).unwrap().fields.clone()) +} + +fn shown(fields: &Map) -> (&str, f64, f64, f64, &str) { + let n = |k: &str| fields[k].as_f64().unwrap(); + (fields["style"].as_str().unwrap(), n("bend"), n("horizontalDistortion"), n("verticalDistortion"), fields["orientation"].as_str().unwrap()) +} + +#[test] +fn warp_text_opens_with_the_type_layers_current_warp() { + let mut app = app_with_type(); + app.run("type.warpText", flag()).unwrap(); + let (_, fields) = open(&mut app); + assert_eq!(shown(&fields), ("flag", -30.0, 20.0, -10.0, "vertical")); +} + +#[test] +fn confirming_the_warp_text_dialog_unchanged_keeps_the_layers_warp() { + let mut app = app_with_type(); + app.run("type.warpText", flag()).unwrap(); + let before = warp(&app); + assert_eq!(before.as_ref().map(|w| w.style.as_str()), Some("warpFlag")); + let (id, _) = open(&mut app); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(warp(&app), before); +} + +#[test] +fn warp_text_opens_at_none_for_a_layer_without_warp() { + let mut app = app_with_type(); + let (id, fields) = open(&mut app); + assert_eq!(shown(&fields), ("none", 50.0, 0.0, 0.0, "horizontal")); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(warp(&app), None); +} + +#[test] +fn remembered_warp_values_never_override_the_layers_warp() { + let mut app = app_with_type(); + let (id, _) = open(&mut app); + let f = &mut app.ui.dialog_mut(id).unwrap().fields; + f.insert("style".into(), json!("twist")); + f.insert("bend".into(), json!(80.0)); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(warp(&app).map(|w| (w.style, w.value)), Some(("warpTwist".to_string(), 80.0))); + app.run("type.create", json!({"x": 20, "y": 60, "text": "Other", "size": 30})).unwrap(); + app.run("type.warpText", flag()).unwrap(); + let (_, fields) = open(&mut app); + assert_eq!(shown(&fields), ("flag", -30.0, 20.0, -10.0, "vertical")); +} + +/// A PSD can carry a style without a short id: the dialog shows None with the layer's values. +#[test] +fn an_unknown_warp_style_opens_at_none_with_the_layers_values() { + let mut app = app_with_type(); + let st = app.session.active_mut().unwrap(); + let id = st.active_layer.unwrap(); + let mut doc = (*st.doc).clone(); + if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { + t.warp = Some(TextWarp { style: "warpCustom".into(), value: 12.0, horizontal_distortion: -5.0, vertical_distortion: 7.0, horizontal: false }); + } + st.doc = std::sync::Arc::new(doc); + let (_, fields) = open(&mut app); + assert_eq!(shown(&fields), ("none", 12.0, -5.0, 7.0, "vertical")); +} + +#[test] +fn warp_text_dialog_previews_the_warp_without_editing_the_document() { + let mut app = app_with_type(); + let (id, fields) = open(&mut app); + assert_eq!(fields.get("__preview"), Some(&json!(true))); + let (revision, original) = { + let st = app.session.active().unwrap(); + (st.revision, st.doc.clone()) + }; + let layer = app.session.active().unwrap().active_layer.unwrap(); + let mut h = harness(app); + assert!(h.query_by_label("Preview").is_some(), "the dialog offers Preview"); + edit_and_preview(&mut h, id, json!({"style": "arc", "bend": 40.0})); + let app = h.state(); + let shown = app.filter_preview.as_ref().and_then(|p| p.result.clone()).expect("a preview"); + assert_eq!(warp_of(&shown, layer).map(|w| (w.style, w.value)), Some(("warpArc".to_string(), 40.0))); + assert_ne!(photocraft_compose::flatten(&shown).px, photocraft_compose::flatten(&original).px, "the preview shows the warp"); + let st = app.session.active().unwrap(); + assert_eq!(st.revision, revision); + assert_eq!(warp(app), None, "the document is not edited"); +} + +#[test] +fn cancelling_warp_text_leaves_the_document_and_history_untouched() { + let mut app = app_with_type(); + app.run("type.warpText", flag()).unwrap(); + let (before, history) = (warp(&app), steps(&app)); + let revision = app.session.active().unwrap().revision; + let (id, _) = open(&mut app); + let mut h = harness(app); + edit_and_preview(&mut h, id, json!({"style": "wave", "bend": 70.0})); + assert!(h.state().filter_preview.as_ref().is_some_and(|p| p.result.is_some()), "the preview is on screen"); + h.get_by_label("Cancel").click(); + h.run_steps(2); + let app = h.state(); + assert!(app.ui.dialogs.is_empty()); + assert!(app.filter_preview.is_none(), "the preview is gone"); + assert_eq!(app.session.active().unwrap().revision, revision); + assert_eq!((warp(app), steps(app)), (before, history)); +} + +#[test] +fn confirming_warp_text_is_one_undoable_step() { + let mut app = app_with_type(); + app.run("type.warpText", flag()).unwrap(); + let (before, history) = (warp(&app), steps(&app)); + let (id, _) = open(&mut app); + let mut h = harness(app); + edit_and_preview(&mut h, id, json!({"style": "fish", "bend": 25.0, "orientation": "horizontal"})); + h.get_by_label("OK").click(); + h.run_steps(2); + let app = h.state_mut(); + assert!(app.ui.dialogs.is_empty()); + assert_eq!(warp(app).map(|w| (w.style, w.value, w.horizontal)), Some(("warpFish".to_string(), 25.0, true))); + assert_eq!(steps(app), history + 1); + assert_eq!(app.session.active().unwrap().history.entries().last().map(|e| e.to_string()), Some("Warp Text".to_string())); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(warp(app), before); +} + +/// #2689: as in Photoshop, the dialog lists Style, then the orientation, then Bend and the two +/// distortions. +#[test] +fn warp_text_dialog_lists_style_orientation_then_bend_and_distortions() { + let mut app = app_with_type(); + open(&mut app); + let h = harness(app); + let top = |name: &str| h.query_all_by_label(name).map(|n| n.rect().top()).fold(f32::INFINITY, f32::min); + let order = ["Style", "Orientation", "Bend", "Horizontal Distortion", "Vertical Distortion"].map(top); + assert!(order.iter().all(|y| y.is_finite()), "every field is shown: {order:?}"); + assert!(order.windows(2).all(|w| w[0] < w[1]), "fields top to bottom: {order:?}"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index fba3f1ca28..ae440dbcb6 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -8,6 +8,23 @@ use crate::theme::{self, Tokens}; mod color_count; pub use color_count::color_count_row; +/// Match the Spectrum application-menu padding and blue/white hover state in +/// secondary menus (status bar, dock hamburger, layer context menus; #2187). +/// The setting is local to this popup's Ui and leaves other themes unchanged. +pub fn style_spectrum_popup_menu(ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + if !t.pro { + return; + } + ui.spacing_mut().button_padding = vec2(10.0, 4.0); + let v = &mut ui.style_mut().visuals; + v.widgets.hovered.weak_bg_fill = t.accent; + v.widgets.hovered.bg_fill = t.accent; + v.widgets.hovered.fg_stroke = Stroke::new(1.0, Color32::WHITE); + v.widgets.hovered.bg_stroke = Stroke::NONE; + v.widgets.hovered.corner_radius = CornerRadius::same(3); +} + /// An accent insertion line on one edge of `r` while a drag hovers it (vertical: on its left or, /// `after`, right edge; else on its top or bottom). pub fn drop_line(ui: &Ui, r: Rect, after: bool, vertical: bool, t: &Tokens) { @@ -38,7 +55,7 @@ pub fn surface(ui: &Ui, rect: Rect, fill: Color32, raised: bool) { /// A dock card: rounded container with a header of pill tabs and optional trailing actions. /// Returns the index of the selected tab. pub fn card(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, body: impl FnOnce(&mut Ui, usize)) { - let _ = card_ex(ui, id, tabs, selected, false, body); + let _ = card_ex(ui, id, tabs, selected, false, false, body); } /// What happened on a card's tab strip this frame (see [`card_ex`]). @@ -59,13 +76,47 @@ pub struct CardResponse { pub responses: Vec<(usize, Response)>, /// The » overflow button, when some tabs didn't fit. pub chevron: Option, + /// A tab dragged along the strip was dropped: `(tab, before)` (#2272). + pub tab_reorder: Option<(usize, Option)>, + /// A tab is being dragged away from the strip. + pub tab_dragging_out: bool, + /// A tab drag ended away from the strip. + pub tab_dropped_out: bool, +} + +impl CardResponse { + fn new(strip: Response, menu: Response, tabs: crate::tab_strip::StripOut) -> Self { + CardResponse { + strip, + menu, + tab_clicked: tabs.clicked, + tab_double_clicked: tabs.double_clicked, + tab_context: tabs.context, + tabs: tabs.tabs, + responses: tabs.responses, + chevron: tabs.chevron, + tab_reorder: tabs.reorder, + tab_dragging_out: tabs.dragging_out, + tab_dropped_out: tabs.dropped_out, + } + } } /// [`card`] that can be collapsed to its tab strip and reports strip and menu interactions. -pub fn card_ex(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, collapsed: bool, body: impl FnOnce(&mut Ui, usize)) -> CardResponse { +/// With `reorderable`, tabs can be dragged along the strip (reported, not applied). +#[allow(clippy::too_many_arguments)] +pub fn card_ex( + ui: &mut Ui, + id: &str, + tabs: &[&str], + selected: &mut usize, + collapsed: bool, + reorderable: bool, + body: impl FnOnce(&mut Ui, usize), +) -> CardResponse { let t = Tokens::get(ui.ctx()); if t.pro { - return pro_panel(ui, id, tabs, selected, collapsed, body); + return pro_panel(ui, id, tabs, selected, collapsed, reorderable, body); } let m = body_margin(false); let frame = egui::Frame::NONE @@ -85,21 +136,12 @@ pub fn card_ex(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, colla let menu_rect = Rect::from_min_max(pos2(row.right() - 22.0, row.top() + 1.0), pos2(row.right(), row.bottom() - 1.0)); let menu = crate::icons::button(&mut ui.new_child(egui::UiBuilder::new().max_rect(menu_rect)), "ellipsis", 22.0, false, &tip); let area = Rect::from_min_max(row.min, pos2((menu_rect.left() - 4.0).max(row.left()), row.bottom())); - let tabs_out = crate::tab_strip::pill_tabs(ui, ui.id().with((id, "tabs")), area, tabs, selected); + let tabs_out = crate::tab_strip::pill_tabs(ui, ui.id().with((id, "tabs")), area, tabs, selected, reorderable); if !collapsed { ui.add_space(6.0); body(ui, *selected); } - CardResponse { - strip, - menu, - tab_clicked: tabs_out.clicked, - tab_double_clicked: tabs_out.double_clicked, - tab_context: tabs_out.context, - tabs: tabs_out.tabs, - responses: tabs_out.responses, - chevron: tabs_out.chevron, - } + CardResponse::new(strip, menu, tabs_out) }) .inner; ui.add_space(6.0); @@ -137,7 +179,15 @@ pub fn panel_footer(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { } /// Photoshop-grammar panel group: dark tab strip with flat tabs, flat body, hamburger menu. -fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, collapsed: bool, body: impl FnOnce(&mut Ui, usize)) -> CardResponse { +fn pro_panel( + ui: &mut Ui, + id: &str, + tabs: &[&str], + selected: &mut usize, + collapsed: bool, + reorderable: bool, + body: impl FnOnce(&mut Ui, usize), +) -> CardResponse { let t = Tokens::get(ui.ctx()); let width = ui.available_width(); // Tab strip. Its background senses drags (move the group) and double-clicks (collapse); @@ -148,7 +198,7 @@ fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, collaps ui.painter().rect_filled(strip, rounding, t.tab_strip); // Panel menu (hamburger); the tabs stay left of it, eliding or overflowing (#151). let menu = Rect::from_center_size(pos2(strip.right() - 14.0, strip.center().y), vec2(20.0, 18.0)); - let tabs_out = crate::tab_strip::pro_tabs(ui, ui.id().with((id, "tabs")), strip, menu.left(), tabs, selected, collapsed); + let tabs_out = crate::tab_strip::pro_tabs(ui, ui.id().with((id, "tabs")), strip, menu.left(), tabs, selected, collapsed, reorderable); let mresp = ui.interact(menu, ui.id().with((id, "menu")), Sense::click()); let c = if mresp.hovered() { t.text } else { t.text_faint }; for k in 0..3 { @@ -163,16 +213,7 @@ fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, collaps }); } ui.add_space(2.0); - CardResponse { - strip: strip_resp, - menu: mresp, - tab_clicked: tabs_out.clicked, - tab_double_clicked: tabs_out.double_clicked, - tab_context: tabs_out.context, - tabs: tabs_out.tabs, - responses: tabs_out.responses, - chevron: tabs_out.chevron, - } + CardResponse::new(strip_resp, mresp, tabs_out) } pub fn pill_tab(ui: &mut Ui, label: &str, selected: bool) -> Response { @@ -196,12 +237,43 @@ pub fn pill_tab(ui: &mut Ui, label: &str, selected: bool) -> Response { /// Monospace numeric field with a dimmed unit suffix, Photoshop style. Drag to scrub. pub fn value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, suffix: &str, width: f32) -> Response { - value_field_in(ui, value, range, suffix, width, 0.0).0 + value_field_in(ui, value, range, suffix, width, 0.0, None).0 +} + +/// Decimal places for a size dialog's unit key (`px`, `in`, `cm`, `mm`, `pt`, `pica`, `percent`). +/// The places themselves live on [`photocraft_engine::prefs::Unit`], so the dialogs, the Info +/// panel and the rulers agree (#2434). +pub fn unit_decimals(key: &str) -> u32 { + use photocraft_engine::prefs::Unit; + let unit = match key { + "in" => Unit::Inches, + "cm" => Unit::Centimeters, + "mm" => Unit::Millimeters, + "pt" => Unit::Points, + "pica" => Unit::Picas, + "percent" => Unit::Percent, + _ => Unit::Pixels, + }; + unit.decimals() as u32 +} + +/// A [`value_field`] showing `decimals` decimal places instead of the one (or two, for a small +/// range) it picks itself: the ruler units need their own precision (#2434). +pub fn value_field_prec(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, suffix: &str, width: f32, decimals: u32) -> Response { + value_field_in(ui, value, range, suffix, width, 0.0, Some(decimals)).0 } /// A [`value_field`] that leaves `trailing` points free inside its box, right of the suffix, and /// returns the box with the number's response. -fn value_field_in(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, suffix: &str, width: f32, trailing: f32) -> (Response, Rect) { +fn value_field_in( + ui: &mut Ui, + value: &mut f32, + range: std::ops::RangeInclusive, + suffix: &str, + width: f32, + trailing: f32, + decimals: Option, +) -> (Response, Rect) { let t = Tokens::get(ui.ctx()); let (rect, slot) = ui.allocate_exact_size(vec2(width, 24.0), Sense::hover()); surface(ui, rect, t.field, false); @@ -209,7 +281,8 @@ fn value_field_in(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive< ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside); } let suffix_w = if suffix.is_empty() { 0.0 } else { 16.0 }; - let field = Rect::from_min_max(rect.min + vec2(4.0, 2.0), rect.max - vec2(4.0 + suffix_w + trailing, 2.0)); + // 1 pt higher than centred: digits have no descenders, so centred text looks low. + let field = Rect::from_min_max(rect.min + vec2(4.0, 1.0), rect.max - vec2(4.0 + suffix_w + trailing, 3.0)); // Small ranges (gamma 0.01–9.99, 0–1 centres) need two decimals and a finer drag, like Photoshop. let fine = range.end() - range.start() <= 10.0; let (lo, hi) = (*range.start(), *range.end()); @@ -227,7 +300,7 @@ fn value_field_in(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive< ui.style_mut().override_font_id = Some(theme::mono(12.0)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let layout = egui::Layout::centered_and_justified(ui.layout().main_dir()); - ui.allocate_ui_with_layout(field.size(), layout, |ui| number_edit(ui, value, range, fine)).inner + ui.allocate_ui_with_layout(field.size(), layout, |ui| number_edit(ui, value, range, fine, decimals)).inner }) .inner } @@ -244,6 +317,8 @@ fn value_field_in(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive< // `f32::clamp` panics on a reversed or NaN range, which `DragValue::range` accepts. *value = if lo <= hi { v.clamp(lo, hi) } else { v }; ui.memory_mut(|m| m.request_focus(resp.id)); + // The text stays selected, as in Photoshop: typing after a step replaces the value. + crate::field_tab::select_all(ui.ctx(), resp.id, &if fine { fmt_num2(f64::from(*value)) } else { fmt_num(f64::from(*value)) }); resp.mark_changed(); } (resp, rect) @@ -274,7 +349,7 @@ pub struct PopupFieldResponse { pub fn popup_value_field(ui: &mut Ui, name: &str, value: &mut f32, range: std::ops::RangeInclusive, suffix: &str, width: f32) -> PopupFieldResponse { let t = Tokens::get(ui.ctx()); let (lo, hi) = (*range.start(), *range.end()); - let (field, rect) = value_field_in(ui, value, range.clone(), suffix, width, POPUP_ARROW_W); + let (field, rect) = value_field_in(ui, value, range.clone(), suffix, width, POPUP_ARROW_W, None); let arrow = Rect::from_min_max(pos2(rect.right() - POPUP_ARROW_W - 2.0, rect.top() + 2.0), rect.max - vec2(2.0, 2.0)); let resp = ui.interact(arrow, field.id.with("popup-slider"), Sense::click_and_drag()); let name = name.trim_end_matches([':', ':']).to_string(); @@ -365,12 +440,14 @@ pub fn focus_first_field(ctx: &egui::Context, on: bool) { /// The number in a [`value_field`]. A typed number applies as it's typed; arithmetic waits for /// Enter, Tab or click-away, because per keystroke `5/2` would land first and a caller that /// rounds it would cut `5/2*2` short. `changed()` means a new value, not just a keystroke. -fn number_edit(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, fine: bool) -> Response { +fn number_edit(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, fine: bool, decimals: Option) -> Response { let (id, ctx) = (ui.next_auto_id(), ui.ctx().clone()); // Focused before the DragValue is drawn, so it gains focus this frame and selects its text. if ui.data_mut(|d| d.remove_temp::(first_field_id())).unwrap_or(false) { ui.memory_mut(|m| m.request_focus(id)); } + // Tab in a dialog goes from one of these to the next (field_tab.rs). + crate::field_tab::register(&ctx, id); let held = id.with("arithmetic"); let math = ui.memory(|m| m.has_focus(id)) && ui.data(|d| d.get_temp(held)).unwrap_or(false); ui.data_mut(|d| d.insert_temp(held, math)); @@ -379,7 +456,11 @@ fn number_edit(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive fmt_num_prec(v, d), + None if fine => fmt_num2(v), + None => fmt_num(v), + }) .update_while_editing(!math) // Focus is read when parsing, not above: Tab hands it on inside `ui.add`. .custom_parser(move |s| { @@ -876,6 +957,7 @@ fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, o let reveal_id = wheel_id.with("reveal"); let mut reveal = wheel && ui.data_mut(|d| d.remove_temp::(reveal_id)).unwrap_or(false); let response = egui::ComboBox::from_id_salt(id).selected_text(label).width(width).height(420.0).icon(chevron_icon).show_ui(ui, |ui| { + fit_widest_option(ui, options.iter().map(|(_, l)| tl!(l))); // Over the open list the wheel steps the value too, instead of scrolling the list. over_list = wheel && ui.rect_contains_pointer(ui.clip_rect()); if over_list { @@ -884,6 +966,9 @@ fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, o reveal |= wheeled; chosen.extend(stepped); } + // The row nearest the pointer, by its distance to each row. + let pointer = ui.ctx().pointer_hover_pos().filter(|_| ui.rect_contains_pointer(ui.clip_rect())); + let mut nearest: Option<(f32, &T)> = None; for (v, l) in options { let item = ui.selectable_label(v == current, tl!(l)); if reveal && v == current { @@ -892,11 +977,22 @@ fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, o if item.hovered() { hovered = Some(v.clone()); } + if let Some(p) = pointer { + let d = (item.rect.top() - p.y).max(p.y - item.rect.bottom()).max(0.0); + if nearest.is_none_or(|(n, _)| d < n) { + nearest = Some((d, v)); + } + } if item.clicked() { *current = v.clone(); chosen.push(v.clone()); } } + // Between two rows (item spacing) the pointer hovers neither; it is still over the list, + // so keep previewing the nearest row instead of flashing the current value back (#2553). + if hovered.is_none() { + hovered = nearest.map(|(_, v)| v.clone()); + } }); if combo_box_arrow_keys(ui, &response.response, current, options) { chosen.push(current.clone()); @@ -915,6 +1011,16 @@ fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, o (chosen, hovered) } +/// Makes an open dropdown list as wide as its widest option is when hovered, from the start. egui +/// takes a plain row's frame stroke off its padding but draws a hovered row with the stroke, so a +/// row grows by two stroke widths under the pointer, and the list never shrinks back: scrolling +/// the widest option under the pointer widened the list (#2607). +fn fit_widest_option<'a>(ui: &mut Ui, labels: impl Iterator) { + let extend = Some(egui::TextWrapMode::Extend); + let widest = labels.map(|l| egui::WidgetText::from(l).into_galley(ui, extend, f32::INFINITY, egui::FontSelection::Default).size().x).fold(0.0, f32::max); + ui.set_min_width(widest + 2.0 * ui.spacing().button_padding.x); +} + /// Whether the pointer still rests where the wheel last stepped dropdown `id` (`wheeled`: it did /// this frame). The option under it then isn't the one to preview: the value moved on without it. fn wheel_rests(ui: &Ui, id: egui::Id, wheeled: bool) -> bool { @@ -1035,6 +1141,7 @@ pub fn dropdown_with_tooltips(ui: &mut Ui, id: &str, curre let label = options.iter().find(|(v, _, _)| v == current).map(|(_, l, _)| tl!(l)).unwrap_or("—"); let mut changed = false; let response = egui::ComboBox::from_id_salt(id).selected_text(label).width(width).height(420.0).icon(chevron_icon).show_ui(ui, |ui| { + fit_widest_option(ui, options.iter().map(|(_, l, _)| tl!(l))); for (v, l, tip) in options { if ui.selectable_label(v == current, tl!(l)).on_hover_text(tl!(tip)).clicked() { *current = v.clone(); @@ -1104,14 +1211,19 @@ pub fn chevron_icon(ui: &Ui, rect: Rect, visuals: &egui::style::WidgetVisuals, _ /// Photoshop-style numbers: "100", "12.5" (never "100.0"). pub fn fmt_num(v: f64) -> String { - let r = (v * 10.0).round() / 10.0; - if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r:.1}") } + fmt_num_prec(v, 1) +} + +/// Rounds `v` to `decimals` places (half away from zero) and drops trailing zeros, so a value is +/// never shown with more precision than it has: `0.7995` at three places is `0.8`, `2.0` is `2` +/// (#2434). [`photocraft_engine::prefs::Unit::decimals`] gives each ruler unit's places. +pub fn fmt_num_prec(v: f64, decimals: u32) -> String { + photocraft_engine::prefs::fmt_decimals(v, decimals as usize) } /// Two-decimal variant of [`fmt_num`] for small ranges: `1.05`, `0.78`, `2`. pub fn fmt_num2(v: f64) -> String { - let r = (v * 100.0).round() / 100.0; - format!("{r:.2}").trim_end_matches('0').trim_end_matches('.').to_string() + fmt_num_prec(v, 2) } /// Parses a typed number or bounded arithmetic, including parentheses and powers. @@ -1135,6 +1247,29 @@ fn clean(text: &str) -> String { #[cfg(test)] mod tests { use egui::{Key, Modifiers}; + + #[test] + fn spectrum_popup_style_matches_main_menu_and_preserves_studio() { + for kind in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::ProMedium, crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::StudioLight] { + let ctx = egui::Context::default(); + crate::theme::apply(&ctx, kind); + let tokens = crate::theme::Tokens::for_kind(kind); + let mut out = ctx.run_ui(Default::default(), |ui| { + let original_padding = ui.spacing().button_padding; + let original_hover = ui.visuals().widgets.hovered.fg_stroke; + super::style_spectrum_popup_menu(ui); + if tokens.pro { + assert_eq!(ui.spacing().button_padding, egui::vec2(10.0, 4.0)); + assert_eq!(ui.visuals().widgets.hovered.bg_fill, tokens.accent); + assert_eq!(ui.visuals().widgets.hovered.fg_stroke.color, egui::Color32::WHITE); + } else { + assert_eq!(ui.spacing().button_padding, original_padding); + assert_eq!(ui.visuals().widgets.hovered.fg_stroke, original_hover); + } + }); + out.textures_delta.clear(); + } + } use egui_kittest::{Harness, kittest::Queryable}; /// Sets up a test with one value field. Its state is its value and how many times it changed. @@ -1164,6 +1299,21 @@ mod tests { h } + /// Digits have no descenders, so the number sits 1 pt above the box's centre to look centred. + #[test] + fn value_field_digits_sit_one_point_above_centre() { + let mut h = Harness::new_ui_state( + |ui, s: &mut (f32, egui::Rect)| { + s.1 = super::value_field_in(ui, &mut s.0, 0.0..=100.0, "px", 80.0, 0.0, None).1; + }, + (50.0, egui::Rect::NOTHING), + ); + h.run(); + let number = h.get_by_role(egui::accesskit::Role::SpinButton).rect(); + let field = h.state().1; + assert!((field.center().y - number.center().y - 1.0).abs() < 0.01, "field {field:?}, number {number:?}"); + } + #[test] fn arrow_keys_step_a_focused_field_by_one_and_shift_by_ten() { let mut h = focused(100.0, 0.0..=1000.0); @@ -1310,7 +1460,7 @@ mod tests { if round { *v = v.round(); } - if ui.button("OK").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + if ui.button(tl!("OK")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { *ok = Some(*v); } }, @@ -1364,6 +1514,16 @@ mod tests { #[test] fn numbers_drop_trailing_zero() { + // #2434: the unit keys map to Photoshop's places, and the shared formatter trims zeros. + assert_eq!(super::unit_decimals("px"), 0); + assert_eq!(super::unit_decimals("pt"), 1); + assert_eq!(super::unit_decimals("mm"), 2); + assert_eq!(super::unit_decimals("cm"), 2); + assert_eq!(super::unit_decimals("pica"), 2); + assert_eq!(super::unit_decimals("in"), 3); + assert_eq!(super::unit_decimals("nonsense"), 0); + assert_eq!(super::fmt_num_prec(0.7995, 3), "0.8"); + assert_eq!(super::fmt_num_prec(2.5, 0), "3"); assert_eq!(super::fmt_num(100.0), "100"); assert_eq!(super::fmt_num(12.46), "12.5"); assert_eq!(super::fmt_num(-3.0), "-3"); @@ -1391,6 +1551,38 @@ mod tests { assert_eq!(*h.state(), 0); } + /// Scrolling rows under the pointer doesn't widen the list: it is as wide as the widest + /// option hovered from the start (#2607). + #[test] + fn dropdown_list_keeps_its_width_while_scrolling() { + use photocraft_color::BlendMode; + let mut h = Harness::builder().with_size(egui::vec2(400.0, 700.0)).build_ui_state( + |ui, mode: &mut BlendMode| { + let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); + super::dropdown(ui, "brush-mode", mode, &opts, 96.0); + }, + BlendMode::Normal, + ); + // The app's theme strokes plain rows, which is what made hovered rows wider. + crate::theme::apply(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.run(); + h.get_by_role(egui::accesskit::Role::ComboBox).click(); + h.run(); + let list = |h: &Harness<'_, BlendMode>| h.ctx.memory(|m| m.layer_ids().filter(|l| l.order == egui::Order::Foreground).find_map(|l| m.area_rect(l.id))); + let Some(open) = list(&h) else { panic!("the list is open") }; + for _ in 0..30 { + h.hover_at(open.center()); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }); + h.run(); + assert_eq!(list(&h).map(|r| r.width()), Some(open.width())); + } + } + const MODES: [(usize, &str); 4] = [(0, "Normal"), (1, "Dissolve"), (2, "Darken"), (3, "Multiply")]; /// A wheel dropdown (or a plain one) of [`MODES`], whose state is the chosen index. diff --git a/crates/ui-egui/src/window_frame_tests.rs b/crates/ui-egui/src/window_frame_tests.rs new file mode 100644 index 0000000000..e0b3408f8a --- /dev/null +++ b/crates/ui-egui/src/window_frame_tests.rs @@ -0,0 +1,40 @@ +//! The window-frame service keeps the custom title bar's undecorated window borderless (#2246). +//! +//! winit 0.30 gives an undecorated window a caption and border (`WS_CAPTION | WS_BORDER`), which +//! on some Windows systems offset the drawn content and the pointer. The app shell strips them +//! and re-strips them after every toolkit flag change (maximize, restore, …), driven from +//! `raw_input_hook`. These tests pin the shell side: the hook must run the service once per frame, +//! before the frame is laid out. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use crate::PhotocraftApp; +use crate::Services; + +#[test] +fn the_window_frame_service_runs_every_raw_input_hook() { + let calls = Arc::new(AtomicUsize::new(0)); + let n = calls.clone(); + let services = Services { + window_frame: Some(Box::new(move || { + n.fetch_add(1, Ordering::SeqCst); + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + let ctx = egui::Context::default(); + for _ in 0..3 { + eframe::App::raw_input_hook(&mut app, &ctx, &mut egui::RawInput::default()); + } + assert_eq!(calls.load(Ordering::SeqCst), 3); +} + +#[test] +fn no_window_frame_service_is_fine() { + // Decorated windows (the system title bar preference) and non-Windows platforms leave the + // service unset; the hook must simply do nothing. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()); + let ctx = egui::Context::default(); + eframe::App::raw_input_hook(&mut app, &ctx, &mut egui::RawInput::default()); +} diff --git a/crates/ui-egui/src/workspace_ui.rs b/crates/ui-egui/src/workspace_ui.rs index ce9b5a6acf..1c93272631 100644 --- a/crates/ui-egui/src/workspace_ui.rs +++ b/crates/ui-egui/src/workspace_ui.rs @@ -490,6 +490,83 @@ mod tests { (PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()), egui::Context::default()) } + #[test] + fn saved_workspaces_preserve_host_chrome_on_select_and_reset() { + for (menu_bar, rail) in [(false, false), (false, true), (true, false), (true, true)] { + for legacy_flags in [false, true] { + let (mut app, ctx) = app(); + app.ui.panels.history = true; + app.ui.panels.navigator = true; + app.ui.timeline.open = true; + crate::menus::invoke(&mut app, &ctx, "window.workspace.newWorkspace", json!({"name": "Host layout"})).unwrap(); + let saved = app.session.prefs().workspaces["Host layout"].clone(); + assert!(saved["panels"].get("menu_bar").is_none() && saved["panels"].get("rail").is_none()); + if legacy_flags { + app.session.prefs.edit(|p| { + let panels = &mut p.workspaces.get_mut("Host layout").unwrap()["panels"]; + panels["menu_bar"] = json!(!menu_bar); + panels["rail"] = json!(!rail); + }); + } + app.ui.panels.menu_bar = menu_bar; + app.ui.panels.rail = rail; + crate::menus::invoke(&mut app, &ctx, "window.workspace.painting", json!({})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "window.workspace.select", json!({"name": "Host layout"})).unwrap(); + assert_eq!((app.ui.panels.menu_bar, app.ui.panels.rail), (menu_bar, rail), "select: legacy_flags={legacy_flags}"); + let restored = json!({"dock": app.ui.dock, "dockTabs": app.ui.dock_tabs, "timelineOpen": app.ui.timeline.open}); + for key in ["dock", "dockTabs", "timelineOpen"] { + assert_eq!(restored[key], saved[key], "select restores {key}"); + } + assert!(app.ui.panels.history && app.ui.panels.navigator); + app.ui.panels.menu_bar = !menu_bar; + app.ui.panels.rail = !rail; + app.ui.panels.history = false; + app.ui.panels.navigator = false; + app.ui.timeline.open = false; + crate::menus::invoke(&mut app, &ctx, "window.workspace.resetWorkspace", json!({})).unwrap(); + assert_eq!((app.ui.panels.menu_bar, app.ui.panels.rail), (!menu_bar, !rail), "reset: legacy_flags={legacy_flags}"); + let restored = json!({"dock": app.ui.dock, "dockTabs": app.ui.dock_tabs, "timelineOpen": app.ui.timeline.open}); + for key in ["dock", "dockTabs", "timelineOpen"] { + assert_eq!(restored[key], saved[key], "reset restores {key}"); + } + assert!(app.ui.panels.history && app.ui.panels.navigator); + } + } + } + + #[test] + fn saved_workspace_keeps_host_menu_and_rail_hidden_in_the_rendered_ui() { + let mut failures = Vec::new(); + for theme in crate::theme::ThemeKind::ALL { + for width in [800.0, 1200.0] { + let builder = egui_kittest::Harness::builder().with_size(egui::vec2(width, 800.0)).with_max_steps(64); + let mut h = builder.build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, theme); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("prefs.set", json!({"path": "interface.theme", "value": theme.id()})).unwrap(); + app.ui.theme = theme; + app.ui.panels.menu_bar = false; + app.ui.panels.rail = false; + app + }); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.newWorkspace", json!({"name": "Embedded"})).unwrap(); + crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.select", json!({"name": "Embedded"})).unwrap(); + h.run_steps(4); + assert_eq!(h.state().ui.theme, theme, "the fixture renders the requested theme"); + for name in ["title_bar", "rail"] { + if egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new(name)).is_some() { + failures.push(format!("{theme:?}, width={width}: {name} is drawn")); + } + } + if h.state().ui.panels.menu_bar || h.state().ui.panels.rail { + failures.push(format!("{theme:?}, width={width}: host flags were overwritten")); + } + } + } + assert!(failures.is_empty(), "saved workspaces expose host chrome: {failures:?}"); + } + #[test] fn new_select_delete_workspaces() { let (mut app, ctx) = app(); diff --git a/crates/ui-egui/src/zoom_tool.rs b/crates/ui-egui/src/zoom_tool.rs index 30034348c1..b525dd1eab 100644 --- a/crates/ui-egui/src/zoom_tool.rs +++ b/crates/ui-egui/src/zoom_tool.rs @@ -44,9 +44,8 @@ pub fn scrub_zoom(zoom0: f32, dx: f32, size: [u32; 2]) -> f32 { pub fn anchor_view(view: &mut View, xf: &ViewXform, screen: Pos2, doc: [f64; 2], zoom: f32, ppp: f32) { view.zoom = zoom; let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; - let d = (screen - xf.rect.center()) / (zoom / ppp); - let dx = if xf.flip { -d.x } else { d.x }; - view.center = [doc[0] as f32 - dx, doc[1] as f32 - d.y]; + let d = xf.unmap_vec((screen - xf.rect.center()) / (zoom / ppp)); + view.center = [doc[0] as f32 - d.x, doc[1] as f32 - d.y]; } /// Route the canvas buttons for the Zoom tool. Returns true when it consumed the drag (the @@ -122,14 +121,14 @@ mod tests { assert_eq!(scrub_zoom(2.0, f32::NAN, DOC), 2.0); } - fn harness(scrubby: bool) -> Harness<'static, PhotocraftApp> { + fn harness(scrubby: bool, ppp: f32) -> Harness<'static, PhotocraftApp> { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); app.sync_views(); app.ui.extras.rulers = false; app.ui.tool = crate::state::Tool::Zoom; app.ui.tool_options.zoom_scrubby = scrubby; - let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_pixels_per_point(ppp).build_ui_state( |ui, app: &mut PhotocraftApp| { // Fonts set up after the first frame only apply from the next one. if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { @@ -145,9 +144,7 @@ mod tests { } fn view_xf(h: &Harness<'static, PhotocraftApp>) -> ViewXform { - let app = h.state(); - let v = &app.ui.views[0]; - ViewXform { rect: app.last_canvas_rect, zoom: v.zoom, center: v.center, flip: false, rotation: v.rotation } + ViewXform::active(h.state()).unwrap() } fn press(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, pressed: bool) { @@ -157,7 +154,7 @@ mod tests { #[test] fn scrubby_zoom_drags_continuously_about_the_press_point() { - let mut h = harness(true); + let mut h = harness(true, 1.0); let r = h.state().last_canvas_rect; let p0 = r.center() + vec2(-150.0, 60.0); let doc0 = view_xf(&h).to_doc(p0); @@ -196,9 +193,58 @@ mod tests { assert_eq!(h.state().current_zoom(), crate::zoom_levels::step(zc, 1, h.state().ui.views[0].doc_size)); } + #[test] + fn zoom_drags_keep_the_press_point_in_rotated_and_flipped_views() { + for (scrubby, out, rotation, flip, ppp) in [ + (true, false, 30.0, false, 1.0), + (true, false, -45.0, true, 1.0), + (true, false, 90.0, false, 2.0), + (true, false, 30.0, true, 2.0), + (false, false, 30.0, false, 1.0), + (false, true, -45.0, true, 2.0), + ] { + let mut h = harness(scrubby, ppp); + let view = &mut h.state_mut().ui.views[0]; + view.zoom = 1.0; + view.center = [200.0, 150.0]; + view.rotation = rotation; + view.fit_pending = false; + view.fill_pending = false; + h.state_mut().ui.view.flip_horizontal = flip; + h.run_steps(2); + let anchor = view_xf(&h).to_screen(140.0, 110.0); + let modifiers = egui::Modifiers { alt: out, ..Default::default() }; + h.event(Event::ModifiersChanged(modifiers)); + h.event(Event::PointerMoved(anchor)); + h.step(); + h.event(Event::PointerButton { pos: anchor, button: PointerButton::Primary, pressed: true, modifiers }); + h.step(); + let steps: &[f32] = if scrubby { &[15.0, 50.0, 100.0, -40.0] } else { &[20.0] }; + let mut end = anchor; + for &dx in steps { + end = anchor + vec2(dx, if scrubby { 0.0 } else { 1.0 }); + h.event(Event::PointerMoved(end)); + h.step(); + let actual = view_xf(&h).to_screen(140.0, 110.0); + assert!(actual.distance(anchor) < 0.01, "rotation={rotation}, flip={flip}, ppp={ppp}: {anchor:?} -> {actual:?}"); + let expected = if scrubby { scrub_zoom(1.0, dx, [400, 300]) } else { 1.0 }; + assert!((h.state().current_zoom() - expected).abs() < 1e-5); + } + h.event(Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers }); + h.step(); + h.run_steps(2); + assert!(view_xf(&h).to_screen(140.0, 110.0).distance(anchor) < 0.01); + let expected = if scrubby { scrub_zoom(1.0, -40.0, [400, 300]) } else { crate::zoom_levels::step(1.0, if out { -1 } else { 1 }, [400, 300]) }; + assert!((h.state().current_zoom() - expected).abs() < 1e-5); + assert_eq!(h.state().ui.views[0].rotation, rotation); + assert_eq!(h.state().ui.view.flip_horizontal, flip); + assert_eq!(h.state().session.documents()[0].history.past_len(), 0); + } + } + #[test] fn zoom_rectangle_without_scrubby_zoom() { - let mut h = harness(false); + let mut h = harness(false, 1.0); let r = h.state().last_canvas_rect; let z0 = h.state().current_zoom(); let a = r.center() - vec2(60.0, 40.0); diff --git a/crates/ui-egui/tests/crop_beyond_canvas.rs b/crates/ui-egui/tests/crop_beyond_canvas.rs index e4d4e8fb35..10798230de 100644 --- a/crates/ui-egui/tests/crop_beyond_canvas.rs +++ b/crates/ui-egui/tests/crop_beyond_canvas.rs @@ -69,6 +69,7 @@ fn screen(h: &Harness, x: f32, y: f32) -> Pos2 { center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation, + aspect: app.ui.view.display_aspect(), }; xf.to_screen(x, y) } @@ -121,7 +122,9 @@ fn editing_a_crop_frame_shows_the_pixels_past_the_canvas() { app.ui.extras.rulers = false; app.ui.extras.snap = false; } - control(&mut h, "ui.set", json!({"tool": "crop", "zoom": 1.0, "center": [100, 50]})); + // Classic Mode: the edge drag below moves only that edge (Auto Center Preview in the default + // mode would keep the box centred, growing it on both sides). + control(&mut h, "ui.set", json!({"tool": "crop", "zoom": 1.0, "center": [100, 50], "cropShield": {"classic_mode": true}})); h.run_steps(3); assert_eq!(h.state().ui.crop_rect, Some([0.0, 0.0, 200.0, 100.0]), "the default frame"); let (red, blue, white, beyond) = ([-25.0, 50.0], [225.0, 50.0], [100.0, 110.0], [-80.0, 50.0]); diff --git a/crates/ui-egui/tests/crop_default_mode.rs b/crates/ui-egui/tests/crop_default_mode.rs new file mode 100644 index 0000000000..7792c68b7c --- /dev/null +++ b/crates/ui-egui/tests/crop_default_mode.rs @@ -0,0 +1,161 @@ +//! Crop tool, Photoshop's default mode (#1919), through the real canvas: egui pointer and key +//! events on the full app UI, frames in between, as a user's drag arrives. +//! +//! In the default mode the view moves and turns with the image while the box stays put on +//! screen. The canvas works on a copy of the view for the frame and writes it back at the end, so +//! a view change made by the Crop tool during the frame used to be lost: a drag inside the box +//! made the box wander instead of moving the image, and a resize with Auto Center Preview didn't +//! keep the box's centre in place. Tests driving `tool_event` directly never saw that. +//! +//! Skips when no GPU +//! adapter exists (like `crop_beyond_canvas.rs`). + +use egui::{Key, Modifiers, PointerButton, Pos2, pos2, vec2}; +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::canvas::ViewXform; +use photocraft_ui_egui::control::{ControlRequest, handle}; +use serde_json::{Value, json}; + +type Harness = egui_kittest::Harness<'static, PhotocraftApp>; + +fn harness() -> Option { + let built = std::panic::catch_unwind(|| { + egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 640.0)).with_max_steps(64).wgpu().build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app + }) + }); + match built { + Ok(h) => Some(h), + Err(_) => { + eprintln!("skipping: no GPU adapter"); + None + } + } +} + +fn control(h: &mut Harness, method: &str, params: Value) { + let ctx = h.ctx.clone(); + let (req, _rx) = ControlRequest::new(method, params); + handle(h.state_mut(), &ctx, &req); + h.run_steps(3); +} + +fn xf(h: &Harness) -> ViewXform { + ViewXform::active(h.state()).expect("a view") +} + +/// The crop box's corners on screen. +fn screen_box(h: &Harness) -> [Pos2; 4] { + let app = h.state(); + let r = app.ui.crop_rect.expect("a frame"); + let x = xf(h); + photocraft_ui_egui::crop_ui::corners(r, photocraft_ui_egui::crop_ui::angle(app)).map(|p| x.to_screen(p[0] as f32, p[1] as f32)) +} + +fn mid(q: [Pos2; 4]) -> Pos2 { + pos2((q[0].x + q[2].x) / 2.0, (q[0].y + q[2].y) / 2.0) +} + +fn press(h: &mut Harness, p: Pos2, pressed: bool) { + h.event(egui::Event::PointerMoved(p)); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); +} + +/// A drag in screen points, `steps` frames of moves; `each` runs after every move frame. +fn drag(h: &mut Harness, from: Pos2, to: Pos2, steps: usize, mut each: impl FnMut(&mut Harness, usize)) { + press(h, from, true); + for i in 1..=steps { + let t = i as f32 / steps as f32; + h.event(egui::Event::PointerMoved(from + (to - from) * t)); + h.step(); + each(h, i); + } + press(h, to, false); + h.run_steps(2); +} + +fn setup() -> Option { + let mut h = harness()?; + h.run_steps(4); + { + let app = h.state_mut(); + app.run("file.new", json!({"width": 300, "height": 200, "background": "white"})).expect("new"); + app.run("select.rect", json!({"x": 100, "y": 50, "width": 60, "height": 60})).expect("select"); + app.run("edit.fill", json!({"color": "#ff0000"})).expect("fill"); + app.run("select.deselect", json!({})).expect("deselect"); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + } + control(&mut h, "ui.set", json!({"tool": "crop", "zoom": 1.0, "center": [150, 100]})); + h.run_steps(3); + let s = &h.state().ui.tool_options.crop_shield; + assert!(!s.classic_mode && s.auto_center_preview, "Photoshop's defaults"); + Some(h) +} + +#[test] +fn dragging_inside_moves_the_image_and_the_box_stays_centred() { + let Some(mut h) = setup() else { return }; + assert!(!photocraft_ui_egui::crop_ui::pending(h.state()), "the untouched frame"); + let c = h.state().last_canvas_rect.center(); + // Inside the untouched frame a drag draws a new box; Auto Center Preview centres it. + drag(&mut h, c + vec2(-100.0, -60.0), c + vec2(60.0, 40.0), 6, |_, _| {}); + assert!(photocraft_ui_egui::crop_ui::pending(h.state()), "a real frame now"); + let box0 = screen_box(&h); + assert!(mid(box0).distance(c) < 1.0, "the drawn box is centred: {:?} vs {c:?}", mid(box0)); + let r0 = h.state().ui.crop_rect.expect("frame"); + let red0 = xf(&h).to_screen(130.0, 80.0); + // Inside the box: the image moves with the pointer, the box stays put. + let (from, to) = (mid(box0) + vec2(-20.0, -10.0), mid(box0) + vec2(40.0, 30.0)); + drag(&mut h, from, to, 8, |h, i| { + let b = screen_box(h); + assert!(mid(b).distance(mid(box0)) < 0.5, "frame {i}: the box stays put: {:?} vs {:?}", mid(b), mid(box0)); + // The image follows the pointer. + let t = i as f32 / 8.0; + let want = red0 + (to - from) * t; + let red = xf(h).to_screen(130.0, 80.0); + assert!(red.distance(want) < 0.5, "frame {i}: the image under the pointer: {red:?} vs {want:?}"); + }); + let b = screen_box(&h); + assert!(mid(b).distance(mid(box0)) < 0.5, "after the release: {:?} vs {:?}", mid(b), mid(box0)); + let r = h.state().ui.crop_rect.expect("frame"); + assert!((r[0] - (r0[0] - 60.0)).abs() < 0.5 && (r[1] - (r0[1] - 40.0)).abs() < 0.5, "the frame lies 60, 40 up-left on the image: {r:?} (was {r0:?})"); + assert_eq!((r[2] - r[0], r[3] - r[1]), (r0[2] - r0[0], r0[3] - r0[1]), "same size"); +} + +#[test] +fn a_resize_keeps_the_box_centre_after_a_space_pan() { + let Some(mut h) = setup() else { return }; + // Space held: the temporary Hand pans the view 80, 40. + let c = h.state().last_canvas_rect.center(); + h.event(egui::Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + drag(&mut h, c + vec2(0.0, 200.0), c + vec2(80.0, 240.0), 4, |_, _| {}); + h.event(egui::Event::Key { key: Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let box0 = screen_box(&h); + assert!(mid(box0).distance(c + vec2(80.0, 40.0)) < 1.0, "panned: {:?}", mid(box0)); + // The right edge, dragged 48 out over several frames (8 a frame: past egui's drag threshold). + let right = pos2(box0[1].x, (box0[1].y + box0[2].y) / 2.0); + drag(&mut h, right, right + vec2(48.0, 0.0), 6, |h, i| { + let b = screen_box(h); + assert!(mid(b).distance(mid(box0)) < 0.5, "frame {i}: the centre stays put: {:?} vs {:?}", mid(b), mid(box0)); + let want = right.x + 48.0 * i as f32 / 6.0; + assert!((b[1].x - want).abs() < 0.5, "frame {i}: the handle follows the pointer: {} vs {want}", b[1].x); + }); + // On the image the left edge stayed put: the frame grew 96 to the right. + let r = h.state().ui.crop_rect.expect("frame"); + assert!((r[0] - 0.0).abs() < 0.5 && (r[2] - 396.0).abs() < 0.5, "{r:?}"); + // On release the box stays at the panned position (Photoshop doesn't snap it back to the + // middle). + let b = screen_box(&h); + assert!(mid(b).distance(mid(box0)) < 0.5, "still where it was: {:?} vs {:?}", mid(b), mid(box0)); +} diff --git a/crates/ui-egui/tests/drag_preview_canvas.rs b/crates/ui-egui/tests/drag_preview_canvas.rs index b3c8cc3f09..052821edc4 100644 --- a/crates/ui-egui/tests/drag_preview_canvas.rs +++ b/crates/ui-egui/tests/drag_preview_canvas.rs @@ -128,7 +128,7 @@ fn coverage(img: &Image, ppp: f32, a: Pos2, b: Pos2, test: impl Fn(u8) -> bool) let len = a.distance(b).max(1.0); let dir = (b - a) / len; let normal = egui::vec2(-dir.y, dir.x); - // Skip the ends (corners, where the other edges and the readout may be). + // Skip rectangle corners, where other edges and the readout may be. let n = (len as usize).saturating_sub(16); let mut hits = 0; for i in 0..n { @@ -142,6 +142,31 @@ fn coverage(img: &Image, ppp: f32, a: Pos2, b: Pos2, test: impl Fn(u8) -> bool) hits as f32 / n.max(1) as f32 } +/// Sample eight dash periods along the actual ellipse, including its curvature. +/// A short tangent segment can land on antialiasing ramps instead of the stroke's core. +fn ellipse_coverage(images: &[&Image], ppp: f32, center: Pos2, radius: egui::Vec2, midpoint: Pos2, test: impl Fn(u8) -> bool) -> f32 { + let (center, radius, midpoint) = (center * ppp, radius * ppp, midpoint * ppp); + let horizontal = (midpoint.y - center.y).abs() > (midpoint.x - center.x).abs(); + let mut hits = 0; + for offset in -32..32 { + let p = if horizontal { + let x = midpoint.x + offset as f32; + let y = center.y + (midpoint.y - center.y).signum() * radius.y * (1.0 - ((x - center.x) / radius.x).powi(2)).max(0.0).sqrt(); + egui::pos2(x, y) + } else { + let y = midpoint.y + offset as f32; + let x = center.x + (midpoint.x - center.x).signum() * radius.x * (1.0 - ((y - center.y) / radius.y).powi(2)).max(0.0).sqrt(); + egui::pos2(x, y) + }; + let normal = if horizontal { egui::vec2(0.0, 1.0) } else { egui::vec2(1.0, 0.0) }; + hits += usize::from((-2..=2).any(|o| { + let q = p + normal * o as f32; + images.iter().any(|img| img.luma(q.x.round() as i64, q.y.round() as i64).is_some_and(&test)) + })); + } + hits as f32 / 64.0 +} + /// Ink that contrasts with `background`: dark on white, light on black. fn ink(background: &str) -> impl Fn(u8) -> bool { let white = background == "white"; @@ -165,8 +190,17 @@ fn marquee_previews_show_while_dragging_on_white_and_black_at_1x_and_2x() { setup(&mut h, bg, tool); let (a, b) = ([100.0, 80.0], [300.0, 220.0]); let before = render(&mut h); + let before_time = h.ctx.input(|input| input.time) + 0.4; + h.input_mut().time = Some(before_time); + h.step(); + let before_next = render(&mut h); drag_to(&mut h, a, b); let img = render(&mut h); + if let Some(directory) = std::env::var_os("PHOTOCRAFT_DRAG_PREVIEW_FIXTURES") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir_all(&directory).unwrap(); + h.render().expect("capture render").save(directory.join(format!("{tool}-{bg}-{ppp}x.png"))).unwrap(); + } if tool == "rectMarquee" { for (i, (p, q)) in edges(&h, a, b).into_iter().enumerate() { let was = coverage(&before, ppp, p, q, ink(bg)); @@ -175,23 +209,46 @@ fn marquee_previews_show_while_dragging_on_white_and_black_at_1x_and_2x() { assert!(was < 0.05 && now > 0.3, "{tool} on {bg} @{ppp}x, edge {i}: {was:.2} → {now:.2} contrasting"); } } else { - // The ellipse touches its box at the edge midpoints: check short arcs there. - // The arcs keep a physical length at any scale, so their sagitta stays inside - // `coverage`'s ±2 physical px search. + // Sample both halves of the animated dash cycle while the pointer is held. + // A single phase can put antialiasing ramps at a short arc's sample positions. + let next_time = h.ctx.input(|input| input.time) + 0.4; + h.input_mut().time = Some(next_time); + h.step(); + let next = render(&mut h); + if let Some(directory) = std::env::var_os("PHOTOCRAFT_DRAG_PREVIEW_FIXTURES") { + h.render().expect("capture next phase").save(std::path::PathBuf::from(directory).join(format!("{tool}-{bg}-{ppp}x-next.png"))).unwrap(); + } + // Check actual arcs around all four ellipse extrema at every display scale. + let center = screen(&h, 200.0, 150.0); + let radius = egui::vec2((screen(&h, 300.0, 150.0).x - center.x).abs(), (screen(&h, 200.0, 220.0).y - center.y).abs()); for (mx, my) in [(200.0, 80.0), (300.0, 150.0), (200.0, 220.0), (100.0, 150.0)] { let m = screen(&h, mx, my); - let along = if mx == 200.0 { egui::vec2(14.0 / ppp, 0.0) } else { egui::vec2(0.0, 14.0 / ppp) }; - let now = coverage(&img, ppp, m - along, m + along, ink(bg)); - assert!(now > 0.3, "{tool} on {bg} @{ppp}x at {m:?}: {now:.2} contrasting"); + let was = ellipse_coverage(&[&before, &before_next], ppp, center, radius, m, ink(bg)); + let now = ellipse_coverage(&[&img, &next], ppp, center, radius, m, ink(bg)); + assert!(was < 0.05 && now > 0.3, "{tool} on {bg} @{ppp}x at {m:?}: {was:.2} → {now:.2} contrasting"); } } // Each move's frame shows the outline where the pointer is now (no stale frame). + let previous = img; pointer_move(&mut h, 340.0, 250.0); let img = render(&mut h); if tool == "rectMarquee" { let [top, right, ..] = edges(&h, a, [340.0, 250.0]); assert!(coverage(&img, ppp, top.0, top.1, ink(bg)) > 0.3, "{tool} {bg} @{ppp}x: top edge follows the pointer"); assert!(coverage(&img, ppp, right.0, right.1, ink(bg)) > 0.3, "{tool} {bg} @{ppp}x: right edge follows the pointer"); + } else { + let next_time = h.ctx.input(|input| input.time) + 0.4; + h.input_mut().time = Some(next_time); + h.step(); + let next = render(&mut h); + assert_ne!(img.px, next.px, "held ellipse must animate between the sampled phases"); + let center = screen(&h, 220.0, 165.0); + let radius = egui::vec2((screen(&h, 340.0, 165.0).x - center.x).abs(), (screen(&h, 220.0, 250.0).y - center.y).abs()); + for midpoint in [screen(&h, 340.0, 165.0), screen(&h, 220.0, 250.0)] { + let old = ellipse_coverage(&[&previous], ppp, center, radius, midpoint, ink(bg)); + let now = ellipse_coverage(&[&img, &next], ppp, center, radius, midpoint, ink(bg)); + assert!(old < 0.05 && now > 0.3, "ellipse {bg} @{ppp}x follows current bounds: {old:.2} → {now:.2}"); + } } button(&mut h, 340.0, 250.0, false); h.run_steps(2); diff --git a/crates/ui-egui/tests/layout_perf.rs b/crates/ui-egui/tests/layout_perf.rs index b35eb8c59d..502390c979 100644 --- a/crates/ui-egui/tests/layout_perf.rs +++ b/crates/ui-egui/tests/layout_perf.rs @@ -46,7 +46,7 @@ fn screen(h: &H, p: [f64; 2]) -> Pos2 { let app = h.state(); let v = &app.ui.views[0]; let rect = photocraft_ui_egui::rulers::content_rect(app, app.last_canvas_rect); - ViewXform { rect, zoom: v.zoom, center: v.center, flip: false, rotation: v.rotation }.to_screen(p[0] as f32, p[1] as f32) + ViewXform { rect, zoom: v.zoom, center: v.center, flip: false, rotation: v.rotation, aspect: 1.0 }.to_screen(p[0] as f32, p[1] as f32) } /// A point where `id` (or, for a group, one of its layers) is the topmost layer with pixels. diff --git a/crates/ui-egui/tests/paint_bucket_all_layers.rs b/crates/ui-egui/tests/paint_bucket_all_layers.rs index e55277d3f6..897a0ed540 100644 --- a/crates/ui-egui/tests/paint_bucket_all_layers.rs +++ b/crates/ui-egui/tests/paint_bucket_all_layers.rs @@ -73,6 +73,7 @@ fn screen(h: &Harness, x: f32, y: f32) -> Pos2 { center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation, + aspect: app.ui.view.display_aspect(), } .to_screen(x, y) } diff --git a/crates/vector/src/raster.rs b/crates/vector/src/raster.rs index 77af7f75e4..ba2cdd21ff 100644 --- a/crates/vector/src/raster.rs +++ b/crates/vector/src/raster.rs @@ -15,6 +15,10 @@ use photocraft_doc::{FillRule, PathOp}; use photocraft_geom::Rect; +/// Coverage below this is f32 round-off in the cell prefix sum, not area: it is well under half +/// a 16-bit step (7.6e-6), so 8- and 16-bit output is unchanged by dropping it. +const RESIDUE: f32 = 1e-6; + #[derive(Clone, Copy, Debug)] struct Edge { x0: f64, @@ -216,7 +220,10 @@ impl Rasterizer { let mut sum = 0.0f32; for (o, a) in row_out.iter_mut().zip(st.acc.iter()) { sum += *a; - *o = sum.clamp(0.0, 1.0); + // Opposite boundaries don't cancel exactly in f32, leaving ~1e-7 just past a + // shape's right edge; that residue would count as content (#2537). Snap it (and + // a NaN) to 0. + *o = if sum >= RESIDUE { sum.min(1.0) } else { 0.0 }; } } } diff --git a/crates/vector/src/tests.rs b/crates/vector/src/tests.rs index 0a49cbeac9..18b2f6f18a 100644 --- a/crates/vector/src/tests.rs +++ b/crates/vector/src/tests.rs @@ -514,3 +514,59 @@ fn joined_subpaths_fill_as_one_component() { let lone = Path::new(vec![Subpath::polygon(&[(2.0, 2.0), (8.0, 2.0), (8.0, 8.0)]).with_op(PathOp::Join)]); assert_eq!(cov(&lone, r)[3 * 20 + 6], 1.0); } + +/// Content bounds of a filled shape rendered as a layer (every depth) and as a selection +/// (every depth); they must all equal `want`. +fn assert_rendered_bounds(path: &Path, want: Rect, what: &str) { + let canvas = Rect::new(0, 0, 1920, 1080); + let sh = ShapeLayer { path: path.clone(), fill: Some(Fill::Solid(Color::rgb(0.0, 0.0, 1.0))), ..Default::default() }; + for sample in SampleType::ALL { + let fmt = PixelFormat::new(photocraft_color::ColorMode::Rgb, sample, true); + assert_eq!(render_shape(&sh, fmt, canvas).content_bounds(), want, "{what}: layer {sample:?}"); + let sel = coverage_surface(&fill_rasterizer(path, DEFAULT_TOLERANCE), PixelFormat::new(photocraft_color::ColorMode::Grayscale, sample, false), canvas); + assert_eq!(sel.content_bounds(), want, "{what}: selection {sample:?}"); + } +} + +#[test] +fn curved_shape_bounds_are_exact() { + // Issue #2537: f32 round-off left ~1e-7 coverage just right of curved edges, so curved + // shapes reported bounds one pixel too wide. Rectangles never showed it. + let p = |x: f64, y: f64| photocraft_geom::Point::new(x, y); + // An asymmetric Bézier blob whose extremes lie on its anchors (axis-parallel handles). + let blob = Path::new(vec![Subpath { + closed: true, + knots: vec![ + photocraft_doc::Knot::smooth(p(60.0, 0.0), p(30.0, 0.0), p(80.0, 0.0)), + photocraft_doc::Knot::smooth(p(100.0, 45.0), p(100.0, 20.0), p(100.0, 70.0)), + photocraft_doc::Knot::smooth(p(40.0, 100.0), p(75.0, 100.0), p(15.0, 100.0)), + photocraft_doc::Knot::smooth(p(0.0, 55.0), p(0.0, 85.0), p(0.0, 25.0)), + ], + op: Default::default(), + }]); + let cases = [ + ("ellipse", shapes::ellipse(0.0, 0.0, 100.0, 100.0), Rect::new(0, 0, 100, 100)), + ("ellipse 600", shapes::ellipse(660.0, 240.0, 600.0, 600.0), Rect::new(660, 240, 1260, 840)), + ("ellipse at x.5", shapes::ellipse(10.5, 10.0, 100.0, 100.0), Rect::new(10, 10, 111, 110)), + ("rounded rect", shapes::rounded_rect(0.0, 0.0, 100.0, 100.0, [20.0; 4]), Rect::new(0, 0, 100, 100)), + // Outer vertices at x = 50 ± 50·cos 18° (2.45, 97.55), lowest at y = 50 + 50·sin 54° (90.45). + ("star", shapes::polygon(0.0, 0.0, 100.0, 100.0, 5, 0.5), Rect::new(2, 0, 98, 91)), + ("custom path", blob, Rect::new(0, 0, 100, 100)), + ("rect", shapes::rect(0.0, 0.0, 100.0, 100.0), Rect::new(0, 0, 100, 100)), + ]; + for (what, path, want) in &cases { + assert_rendered_bounds(path, *want, what); + } +} + +#[test] +fn thin_antialiased_edges_still_count_toward_bounds() { + // A quarter-pixel overhang and a 1-px bar straddling two columns are real coverage. + assert_rendered_bounds(&shapes::rect(0.0, 0.0, 100.25, 100.0), Rect::new(0, 0, 101, 100), "quarter overhang"); + assert_rendered_bounds(&shapes::rect(10.5, 0.0, 1.0, 50.0), Rect::new(10, 0, 12, 50), "1-px bar"); + assert_rendered_bounds(&shapes::rect(0.0, 0.0, 100.01, 10.0), Rect::new(0, 0, 101, 10), "1% sliver"); + // At 32-bit even a 1e-4 sliver is kept: only round-off is dropped. + let r = fill_rasterizer(&shapes::rect(0.0, 0.0, 100.0001, 10.0), DEFAULT_TOLERANCE); + let c = r.render(Rect::new(0, 0, 102, 10)); + assert!(c[100] > 5e-5 && c[101] == 0.0, "{} {}", c[100], c[101]); +} diff --git a/docs/architecture.md b/docs/architecture.md index 41f4c673d1..7db7ba0e24 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,35 @@ -# Photocraft Architecture +# PhotoCraft architecture -Status: draft v1 (2026-09-30). This plan does not assume a final UI toolkit. The candidates are discussed in [`rust-framework-options.md`](rust-framework-options.md). +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (current-state summary added; the design plan below is kept, with its deviations listed) · **Target:** Adobe Photoshop 2026 + +## Current state (2026-10-10) + +What exists on `main` today. The numbered sections below are the original design plan of +2026-09-30; where the code differs, this summary wins. + +- **Workspace:** 25 library crates under `crates/`, 3 apps (`photocraft` desktop, `photocraft-cli`, + `photocraft-web`), `xtask`, an example plug-in and 5 fuzz crates; ~393,000 lines of Rust in + ~1,020 files; ~5,460 `#[test]` functions (ui-egui 1,677, engine 1,237, algo 408, io 377, + codecs 325, psd 263). Crate map and layers: [`AGENTS.md`](../AGENTS.md) §2 and + `xtask/src/layers.rs` (enforced by `cargo xtask layers`). +- **UI toolkit:** decided: egui/eframe on wgpu (no webview, no JavaScript). The same Rust builds + to WebAssembly for the browser (trunk + wasm-bindgen). +- **Engine:** ~700 commands registered in `crates/engine` (`CommandSpec` in `commands.rs`); the + UI, CLI, JSON control channel and MCP server all dispatch by command id. 628 Photoshop menu items + map to them (`crates/ui-egui/src/menu_catalog.rs`). +- **Rendering:** CPU compositor (`compose`, the oracle) and wgpu compositor (`gpu`, ≤ 1/255 of the + CPU); colour through our own ICC engine (`cms`), display transform folded into a GPU 3D LUT. +- **Files:** own PSD/PSB crate (`psd`), flat codecs (`codecs`), camera raw (`raw`), HEIF (`heif`), + Affinity (`affinity`), document mapping (`io`), native `.pcraft` (`format`, atomic saves). + Coverage: [`file-format-parity.md`](file-format-parity.md). +- **Not built (yet) from the plan:** the `ml` crate (no ML runtime; see gap G15), plug-in ABI v2. +- **Deviations from the plan below:** `photocraft-tablet` is the one crate allowed `unsafe` + (AppKit pen events); `affinity`, `heif` and `raw` crates were added; the phased roadmap in §15 + is superseded by [`roadmap.md`](roadmap.md). + +--- + +# Design plan (2026-09-30) This plan sets out: - the Cargo workspace, @@ -529,7 +558,7 @@ Versions get pinned when we scaffold, using the numbers in `rust-framework-optio ## 15. Phased roadmap -Each phase ends with a demoable build and green CI on all targets. Phases 8–10 (colour/print pro, photographer/designer pro, frontier) and the per-feature phase assignments are in [`photoshop-parity.md`](photoshop-parity.md). +Each phase ends with a demoable build and green CI on all targets. Phases 8–10 (colour/print pro, photographer/designer pro, frontier) and the per-feature phase assignments are in [`target-app-parity.md`](target-app-parity.md). | Phase | Scope | Done when | |---|---|---| @@ -552,3 +581,10 @@ Each phase ends with a demoable build and green CI on all targets. Phases 8–10 4. **PSD crate.** Build our own, or adopt or fork `ag-psd`/`psd`. Decide after the week-1 spike. 5. **Default working depth and blending.** 8-bit + gamma-space blending matches Photoshop. 16-bit/float + linear is the modern default. The proposal is to follow Photoshop by default and make the other a per-document option. 6. **Scope of Photoshop plugin compatibility.** Decided: no native `.8BF` hosting; a sandboxed WebAssembly plug-in API instead (see [`plugins.md`](plugins.md)). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Current-state summary and deviations from the plan; status line; broken link to `photoshop-parity.md` repointed to `target-app-parity.md` | +| 2026-09-30 | major | Design plan v1 | diff --git a/docs/brush-parity.md b/docs/brush-parity.md new file mode 100644 index 0000000000..75575abd7e --- /dev/null +++ b/docs/brush-parity.md @@ -0,0 +1,113 @@ +# Brush and painting parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: brush engine, dynamics, pressure, smoothing, cursors, retouch tools and feel audited from source and open issues) · **Target:** Adobe Photoshop 2026 + +Painting is where Photoshop users notice differences first, so it gets its own checklist: the +brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI +(`crates/ui-egui/src/brush_sections.rs`), the live stroke renderer, pen input +([`hardware-parity.md`](hardware-parity.md)), and the brush-based tools. Status words as in +[`ui-parity.md`](ui-parity.md). + +**Summary (estimated):** breadth ~90% (every Brush Settings section and option exists), ready for +real work ~55%. The engine models what Photoshop models; the gaps are feel (latency, pressure +response, edge quality, smoothing), a few missing options, and live previews for retouch tools. +Remaining: 100–180 h (gap G4 in [`gaps.md`](gaps.md)). Human: feel must be checked on real +tablets against Photoshop side by side. + +## Brush Settings sections + +| Section | Status | Evidence | +|---|---|---| +| Brush Tip Shape: size, angle, roundness, hardness, spacing, flip X/Y | done | `brush.rs` | +| Shape Dynamics (size/angle/roundness jitter, pen pressure/tilt/rotation/wheel controls, minimums, flip jitter, brush projection) | done | #174 | +| Scattering (both axes, count, count jitter) | done | | +| Texture (pattern, scale, brightness, contrast, mode, depth, each tip) | done | | +| Dual Brush | done | | +| Color Dynamics (FG/BG, hue/sat/brightness jitter, purity, per tip) | done | | +| Transfer (opacity/flow/wetness/mix jitter and controls) | done | | +| Brush Pose (tilt, rotation, pressure overrides) | done | | +| Noise, Wet Edges, Build-up, Protect Texture | done | | +| Smoothing: amount, Pulled String, Stroke Catch-up, Catch-up on End, Adjust for Zoom | done | `brush.rs:428-438` | +| Section locks | done | #174 | +| Brush tip from an image file | missing | TOOL-213-12 | +| Erodible, Airbrush and Bristle tips (Photoshop's special tips) | partial | not audited in detail; "3D-based brushes don't work" (#1660) | +| Anti-aliasing off for brushes | missing | #1666 (Pencil is the aliased path) | + +## Pressure and pen + +| Behaviour | Status | Evidence | +|---|---|---| +| Pressure drives size/opacity/flow per sample (no shared pressure within a frame) | done | #2112 | +| Global pressure curve in Preferences | done | #2195 | +| Per-control pressure curves | missing | TOOL-213-11 | +| Quick taps keep their pressure | partial | #1967 fixed one case; tap paints a full blob (#1798), tap sensitivity (#1933) | +| Smooth pressure on Windows | partial | "jumpy" (#2046) | +| Tilt and rotation drive angle/roundness | partial | macOS, X11, web; not Windows; rotation not applied (#1301) | +| Eraser end of the pen switches to Eraser | partial | macOS; flipped Wacom on Windows not (#2285) | + +## Stroke quality and feel + +| Behaviour | Status | Evidence | +|---|---|---| +| No seams, hard edges stay hard, zero-scatter defaults, ABR tips never black | done | TOOL-213-13 | +| Smooth line weight along a pressure ramp | partial | stair-steps (#1776) | +| Interpolation and smoothing match Photoshop | partial | #2138, #2057 | +| Soft-edge falloff matches Photoshop's curve | partial | #2149, #1619 | +| Brush preview and cursor crisp, not pixelated | partial | #1433 | +| Cursor outline equals the painted footprint at every zoom | partial | #1761, #2364 | +| Latency: dab to screen within a frame | partial | budgets P13/P16/P33 over (scorecard); big-brush lag #2619, #1994; cursor lag #2566, #1343 | +| No hitch at mouse-up on a long stroke | not measurable | P32 needs a frame harness | +| Live stroke for Brush and Eraser | done | | +| Live stroke for every brush-based retouch tool | missing | TOOL-213-7 | +| No OS arrow cursor over the canvas with a pen | partial | TOOL-213-14 | + +## Gestures and cursors + +| Behaviour | Status | Evidence | +|---|---|---| +| ⌃⌥-drag (macOS) / Alt+right-drag (Windows) resizes and changes hardness with a readout | done | TOOL-213-10 | +| `[` `]` size, `⇧[` `⇧]` hardness, number keys opacity, ⇧+numbers flow | done | 2026-10-07 | +| ⌥-click samples a colour with painting tools; Eyedropper comparison ring | done | TOOL-213-9 | +| ⇧-click draws a straight line from the last point | done | | +| Right-click opens the Brush Preset picker | done | [`context-menu-parity.md`](context-menu-parity.md); XP-Pen right-click not (#1980), macOS issue #2360 | +| Precise / normal / full-size tip cursors, crosshair in the tip | partial | Caps Lock precise cursor not audited; round Pencil cursor requested (#2662, #1057) | +| Brush choice kept per tool (Brush, Eraser, Blur each their own) | partial | #2542 | + +## Symmetry + +| Mode | Status | +|---|---| +| Vertical, Horizontal, Dual axis, Diagonal | done | +| From a path (Make Symmetry Path) | done | +| Wavy, Circle, Spiral, Parallel Lines, Radial, Mandala | missing (#2659) | + +## Brush-based tools + +| Tool | Status | Notes | +|---|---|---| +| Brush, Pencil (aliased, Auto Erase), Eraser (Brush/Pencil/Block) | done | Pencil 1 px quality #2139 | +| Mixer Brush (Wet, Load, Mix, Flow, Sample All Layers) | partial | "incomplete" (#2247) | +| Clone Stamp (opacity, flow, aligned, sample, overlay) | partial | Clone Source W/H link and Alt target cursor (TOOL-213-8) | +| Pattern Stamp (aligned, impressionist) | done | | +| History Brush | done | Art History Brush missing | +| Spot Healing, Healing | partial | grain not matched on flat colour (#2104), "horrible in general" (#1092), stops responding after switching (#1751) | +| Blur, Sharpen, Smudge (premultiplied, Sample All Layers, spacing) | done | BUG-207-2/3, #2171 | +| Dodge, Burn, Sponge | done | | +| Background Eraser, Magic Eraser | done | | +| Color Replacement | missing as a tool | engine command exists | +| Quick Selection (brush-driven) | done | | + +## Presets + +| Behaviour | Status | Evidence | +|---|---|---| +| .abr import (v1/v2/v6) and export, persistent presets, folders | done | `psd/src/abr.rs` | +| Brushes panel: drag a folder into a folder | missing | #2014 | +| Tool presets (.tpl) | partial | Tool Presets panel exists; .tpl import not found | +| Import 500 brushes without blocking the UI | not run | P24 | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First version, from a source audit and the open issues | diff --git a/docs/context-menu-inventory.md b/docs/context-menu-inventory.md index 74ed159c77..59fb512a63 100644 --- a/docs/context-menu-inventory.md +++ b/docs/context-menu-inventory.md @@ -1,5 +1,7 @@ # Context-menu inventory and parity contract +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (status line added; the inventory is the 2026-10-06 branch-point snapshot, current status lives in context-menu-parity.md) · **Target:** Adobe Photoshop 2026 + This is the source-code inventory of PhotoCraft's right-click behavior at the branch point, captured 2026-10-06. It distinguishes a menu from another secondary-button gesture. The initial selection, Paths row, and document-tab menus added by this PR are called out separately. Photoshop comparison should be checked against an identified Photoshop version and platform before claiming exact parity; the current Layers menu explicitly targets Photoshop 2026 order. ## Canvas: every tool @@ -50,3 +52,10 @@ Canvas invariant: secondary click must not accidentally start a paint stroke, co 3. Prioritize shared canvas menus, then high-value tool-specific interactions (path, selection, type, crop/zoom), then panel and tab gaps. Reuse Layers/Channels implementations where their command contract already exists. Preserve the existing Move/layer pick, brush picker, erase preference, Alt/Option brush resize, and toolbar flyout gestures. 4. Test each descriptor's item order and enablement, dispatch with expected params, empty/invalid states, modifiers, and action result. Add UI gesture tests for right-click opening, target selection, Escape/outside dismissal, and the absence of accidental edits. Validate native and web builds. 5. Capture screenshots from the running app for representative tool groups and panel states, with test fixture documents rather than personal photos. A screenshot proves visible menu composition; command tests prove its behavior. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | trivial | Status line, revision history; marked as the branch-point snapshot | +| 2026-10-06 | major | Source inventory of right-click behaviour | diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 93a0f9a15b..38eda4b44f 100644 --- a/docs/context-menu-parity.md +++ b/docs/context-menu-parity.md @@ -1,5 +1,7 @@ # Context-menu parity contract +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (status line and revision history added; linked from ui-parity.md) · **Target:** Adobe Photoshop 2026 + Status: implementation contract, updated 2026-10-09. Scope: canvas, document tabs, and panels. “Right-click” means a secondary pointer click; macOS Control-click should also open the same menu where the platform sends a context-menu gesture. Photoshop changes a context menu with the active tool, selection, and panel item. Adobe documents that principle but does not publish a complete current menu catalog. **Documented** means an Adobe source explicitly names the gesture or action. **Inferred** means a PhotoCraft design target or an item known from a main menu whose presence in a Photoshop context menu still needs a current-product capture. Do not present inferred rows as exact Photoshop menus. [Adobe: panels and menus](https://helpx.adobe.com/photoshop/using/panels-menus.html), [Adobe: shortcuts and context menus](https://helpx.adobe.com/sa_en/photoshop/using/customizing-keyboard-shortcuts.html). ## Dispatch contract @@ -93,3 +95,11 @@ Photoshop's Generative Fill and Delete and Fill Selection rows are left out: Pho 4. Long tail: shape/path, crop, metadata and specialist tools. Keep a per-tool fixture with no selection, active selection, masked/locked layer, and active operation where applicable. Each slice needs pure model tests (including every item dispatching a known command), UI pointer tests for secondary click and modifier precedence, command/undo tests, a disabled-state test, and screenshots from PhotoCraft with the menu actually open. Compare labels, order, and state against dated Photoshop screenshots; a screenshot of a different tool or menu bar is insufficient evidence. Maintain a checklist beside each captured reference: Photoshop version/platform, target description, tool, selection/document state, modifiers, and exact items observed. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | trivial | Status line, revision history | +| 2026-10-09 | minor | Selection canvas reference and tool matrix updates | +| 2026-10-07 | minor | Pen canvas reference capture | diff --git a/docs/contributing.md b/docs/contributing.md index 45ff6a9746..ac4eb41e34 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -23,4 +23,4 @@ 4. **Respect the context:** active selection (feathered), layer vs mask target, locks, the colour model and depth. 5. **Tests** in the module: behaviour, undo/redo, disabled states, bad params, several depths. 6. **Dialog** (if it has parameters): filters and adjustments get schema-driven dialogs with live preview (`ui-egui/src/filter_dialog.rs`, `dialogs.rs`). -7. Run `cargo xtask parity` and commit the updated `docs/parity.md`. +7. Run `cargo xtask parity` and commit the updated `docs/parity-checklist.md`. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index fe59370750..0881e62e85 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -34,7 +34,7 @@ Each reply is one JSON line with the same `id`: {"id": 2, "ok": false, "error": "unknown tool `foo`"} ``` -The desktop server waits up to 60 seconds for a reply. A request still queued at that deadline is rejected before dispatch; a timeout does not cancel work that has already started. +The desktop server waits up to 60 seconds for a reply. A request still queued at that deadline is rejected before dispatch; a timeout does not cancel work that has already started. The timeout error names the likely causes: the command may still be running, or the request was queued while the window was not drawing frames — on Wayland the frame update (and with it the control drain) only runs on compositor frame callbacks, so a display that is asleep or a fully occluded window makes every request time out while the app itself is healthy. The transport is `apps/photocraft/src/control_server.rs`, and the handlers are in `crates/ui-egui/src/control.rs`. The MCP server (`photocraft-cli mcp --bridge 127.0.0.1:`, crate `photocraft-automation`) wraps this same protocol. See [MCP bridge](#mcp-bridge) below. @@ -43,7 +43,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given. `params` must be a JSON object (omit it or pass `null` for none); an array, string, number or boolean is an error naming the command, in every transport (control channel, `serve`, MCP and the CLI) - `engine.commands`: list commands with enablement - `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `brushPicker` is `null` while closed and `{pos: [x, y], size: [w, h]?, list: {collapsed, filter, renaming, showName, showStroke, showTip, scale}}` while the Brush Preset picker is open (coordinates in screen points; `size` is the picker's content size once its corner grip was dragged, `null` before that, and the drag remembers both dimensions; the three `show*` flags pick the parts of each preset's card — at least one is always on; `scale` is the footer slider, 0.15–2 with 1 the standard, multiplying the cards' width and height — the heights stop at half size and the name and tip-size text hold their 1.0 size — and dropping the tips' size numbers at 0.40 and below; the parts and the scale are remembered across restarts, while the search, collapsed groups and a rename in progress are not). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`) -- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerName?, brushPickerStroke?, brushPickerTip?, brushPickerScale?, gradientBlendMode?, gradientClassic?, shapeStroke?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` is `list` or `grid` for the Brushes panel; `brushPickerName`, `brushPickerStroke` and `brushPickerTip` are the Brush Preset picker's card parts (booleans, at least one on), and `brushPickerScale` its footer card-scale slider (0.15 to 2, 1 the standard); `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again) +- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerName?, brushPickerStroke?, brushPickerTip?, brushPickerScale?, gradientBlendMode?, gradientClassic?, shapeStroke?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` is `list` or `grid` for the Brushes panel; `brushPickerName`, `brushPickerStroke` and `brushPickerTip` are the Brush Preset picker's card parts (booleans, at least one on), and `brushPickerScale` its footer card-scale slider (0.15 to 2, 1 the standard); `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"], tabOrder: {"layers": ["Paths", "Layers", "Channels"]}}`, the right-dock groups top to bottom, their heights in points, the groups collapsed to their tab strip and each group's tab order left to right by panel name (a group left out keeps its default order, unknown names are ignored and tabs not listed follow in their default order; `dockTabs` keeps indexing tabs in their default order); `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again) - `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state. - `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once - `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar. @@ -51,7 +51,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks. - `ui.set` also accepts the Crop tool's overlay menu (#1919): `cropOverlay` (`thirds`, `grid`, `diagonal`, `triangle`, `goldenRatio`, `goldenSpiral`), `cropOverlayShow` (`auto`: only while the crop box is dragged, Photoshop's default; `always`; `never`) and `cropOverlayOrientation` (0..3; Triangle has two orientations and Golden Spiral four, the others ignore it). `ui.inspect` reports them under `toolOptions` (`crop_overlay`, `crop_overlay_show`, `crop_overlay_orientation`). In the app, O cycles the overlay and ⇧O its orientation while the Crop tool shows a box. Both gradient modes use the current preset, including its opacity stops. Select `Foreground to Transparent` through `gradient.presets.select`, or pass custom `stops` and `transparency: [[0,100],[1,0]]` (location 0..1, opacity 0..100). Use `applyToLayer: false` to edit only the tool preset. Classic gradient drags dispatch `paint.gradient` without overriding them. -- `ui.set` also accepts the Crop tool's gear menu (#1919) as `cropShield`, an object patching any of `show_cropped_area` (Show Cropped Area; off hides everything outside the box behind the canvas colour, H in the app), `enabled` (Enable Crop Shield), `color` (`matchCanvas`, the pasteboard colour, or `custom`), `custom_color` (`[r, g, b]`, 0..255), `opacity` (0..100, default 75) and `auto_adjust` (Auto Adjust Opacity: half the opacity while the box is dragged). `ui.inspect` reports it as `toolOptions.crop_shield`. +- `ui.set` also accepts the Crop tool's gear menu (#1919) as `cropShield`, an object patching any of `show_cropped_area` (Show Cropped Area; off hides everything outside the box behind the canvas colour, H in the app), `enabled` (Enable Crop Shield), `color` (`matchCanvas`, the pasteboard colour, or `custom`), `custom_color` (`[r, g, b]`, 0..255), `opacity` (0..100, default 75), `auto_adjust` (Auto Adjust Opacity: half the opacity while the box is dragged), `classic_mode` (Use Classic Mode, default off: the box turns and moves over the image; off is Photoshop's default mode, where the box stays upright and the view turns and pans the image behind it while the Crop tool shows a frame, P in the app; in both modes picking the tool turns a rotated view upright about its centre, Classic Mode keeping the camera upright with the frame drawn turned, and the view stays upright after a commit or cancel; Classic Mode greys out Show Cropped Area, which then acts as on, and Auto Center Preview) and `auto_center_preview` (default on: in default mode a box being resized keeps its centre where it is on screen, also after a pan, a drawn one moves to the middle of the canvas, and X re-centres it; a move never re-centres the box). `ui.inspect` reports it as `toolOptions.crop_shield`. The pending frame (`crop_rect`, `crop_angle`: its turn on the image) and the `image.crop` a commit runs are the same in both modes. - `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog, wait?}` / `ui.dialog.cancel {dialog}`. Like `ui.menu.invoke`, `ui.dialog.confirm` waits for a background job its command starts (a filter dialog's OK) and replies with the result; `"wait": false` replies `{job, pending: true}` at once - A Layer Style color field opens the shared picker with `ui.dialog.open {"kind":"colorPicker","parent":12,"effect":"fx2","field":"color"}`. `parent` is the Layer Style dialog id; `effect` is its effect instance id. `field` must be one of that instance's color parameters (`color`, or `from`/`to` for Gradient Overlay). The picker previews over the pending style without changing the document. Picker OK changes only the parent parameter; Cancel discards the preview. Confirm the parent afterwards to apply the style. Parent confirmation is refused while its picker is open; parent cancellation closes both. The binding is exposed in `ui.inspect` as `__layerStyleColor`; changed/closed targets or a document switch invalidate it. - `ui.dialog.open {kind:"colorPicker", textStyle:{kind:"character"|"paragraph", id:u32}}` opens the shared Color Picker for an explicitly enabled Color attribute in Style Options. It opens the matching style panel/editor after validation; inherited colors must first be enabled through the existing style command. The picker reports its bound document/revision/style under `__textStyleColor` in `ui.inspect`. Changes preview in an isolated document; OK runs the existing style `set` command once, Cancel changes nothing. Document edits/switches or closed/changed Style Options discard the picker. `textStyle` cannot be combined with `parent`, `effect`, `field` or `target`. @@ -61,7 +61,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`). - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows -- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. +- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. - Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape. - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing - `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}` @@ -113,6 +113,7 @@ store expose and persist the same setting; scripts keep using canonical command | `actions.play` | `{"action": name or index, "from": step?}`. `from` and `failed.step` are 0-based. Returns `{action, ran, failed?:{step, id, error}}` and still returns ok when a step fails, so a partial run is reported. Leaves one history step per step that ran. Supports nested action calls up to 16 levels, rejecting cycles. While recording, playing another action appends one named `actions.play` step instead of its expanded commands. On an untrusted session each step is authorized the same way as a top-level command | | `actions.move` | `{"action": name or index, "step": index?, "to": index}` moves a step within its action, or moves the whole action when `step` is omitted. `to` is the final zero-based index. Pending steps can be reordered while recording; invalid moves leave the list unchanged. | | `actions.delete` | `{"action": name or index, "step": index?}`. With a zero-based `step`: `{action, deletedStep, steps}`, also allowed while recording. Without `step`: `{deleted}`, refused while recording | +| `actions.rename` | `{"action": name or index, "name": str}` → `{action, index}`. Empty, whitespace-only or duplicate names are rejected and keep the old name. Nested `actions.play` steps that call the action by name and its function key follow the new name. In the desktop panel, double-click an action's name to rename it in place (Enter commits, Esc cancels) | The Actions panel shows replayable steps immediately while recording. Click an individual step to select it, then use the trash button to remove that step; selecting the action @@ -175,7 +176,7 @@ the same commands work in the app, the CLI and headless MCP. Paths are dotted ca | `prefs.set` | `{"path":"cursors.painting","value":"precise"}` or `{"values":{"unitsAndRulers.rulers":"cm","guidesGridAndSlices.gridColor":"#ff8800"}}`. Values are validated (choices, ranges, `#rrggbb` colours, shortcut syntax); a batch applies all or nothing. A section path takes an object and merges it key by key | | `prefs.reset` | `{"path":"performance"}` (a section or key); no path resets everything. An individual `shortcuts.` or `menus.colors.` reset removes the override and returns `null`, including when it is already absent | | `edit.preferences.
` | the section's values; in the app (no params) it opens the Preferences dialog on that section | -| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Cmd+Alt+Space); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by menu label, a key equal to the default restores it, keys also given in `set` win, tool keys are not imported, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`). In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once | +| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Alt+Space; Alt added to a held Zoom In, Cmd+Alt+Space, zooms out too); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by menu label, a key equal to the default restores it, keys also given in `set` win, tool keys are not imported, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`). In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once | | `edit.menus` / `edit.toolbar` | `{"hide":["edit.fade"],"show":[…],"color":{"edit.fill":"red"},"reset":false}` / `{"hidden":["Sponge"],"order":[…]}` | | `edit.colorSettings` | `{"workingRgb":"display-p3","workingCmyk":"coated-cmyk","workingGray":"sgray","policyRgb":"preserve\|convert\|off",…,"askOnMismatch":true,"intent":"perceptual","bpc":true}`; honoured when opening files (`file.openAs`, the app's File › Open) and by Image › Mode and "working" profile specs. `color.profileMismatch {"action":"preserve\|convert\|discard\|assignWorking"}` answers the mismatch prompt | diff --git a/docs/development.md b/docs/development.md index 85d8f87392..e75aad769e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -3,7 +3,11 @@ ## Prerequisites - Rust stable (1.95+). Add the web target with `rustup target add wasm32-unknown-unknown`. -- macOS, Windows or Linux. Linux needs `libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev`. +- macOS, Windows or Linux. On Linux, install the windowing, GL and GTK development packages: + - Debian/Ubuntu: `sudo apt install libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev` + - Fedora/RHEL: `sudo dnf install libxkbcommon-devel wayland-devel libX11-devel libXrandr-devel libXi-devel mesa-libGL-devel gtk3-devel` + - Arch: `sudo pacman -S libxkbcommon wayland libx11 libxrandr libxi mesa gtk3` + - openSUSE: `sudo zypper install libxkbcommon-devel wayland-devel libX11-devel libXrandr-devel libXi-devel Mesa-libGL-devel gtk3-devel` ### Windows source builds @@ -37,11 +41,26 @@ cargo run --release -p photocraft -- --control 7878 --control-token-file .privat cargo test --workspace # everything cargo xtask ci # fmt + clippy + tests + layers + wasm cargo xtask stats # tests and lines per crate -cargo xtask parity # Photoshop menu coverage -> docs/parity.md +cargo xtask parity # Photoshop menu coverage -> docs/parity-checklist.md ``` Image code is slow at `opt-level 0`, so the workspace profile builds dependencies at `opt-level 2`. Use `--release` for anything interactive. +The dev profile builds with `debug = "line-tables-only"`: panic backtraces keep file and line, and test binaries link about twice as fast. To inspect local variables in a debugger, build with `CARGO_PROFILE_DEV_DEBUG=true`. + +### Faster test loops + +Most of a `cargo test` run is linking test binaries and running them one after another. While iterating: + +```sh +cargo test -p --lib --tests # skip examples and doctests (ui-egui has 11 examples) +cargo test -p --test # one integration-test file +cargo nextest run --workspace # parallel across binaries (cargo install cargo-nextest --locked) +cargo test --doc --workspace # nextest does not run doctests +``` + +[cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. CI still runs `cargo test`, and a full `cargo test` stays the final check before a PR. + ## Fonts (craft-fonts) Font assets shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo: don't commit font files here (Inter and JetBrains Mono in `assets/fonts/` are the only exceptions; new fonts go to craft-fonts). The rules are in `craftrules/standards/fonts.md` in a sibling `craftrules` checkout (see below); that repository is not public, so outside contributors can ask a maintainer for the rules that apply to their change. @@ -234,7 +253,7 @@ intents, black point compensation). It ships CC0 built-in profiles, including a ## Menu parity `cargo xtask parity` compares Photoshop's menu tree (`crates/ui-egui/src/menu_catalog.rs`) with -the live command registry (`menus::is_live`) and rewrites [`docs/parity.md`](parity.md). The test +the live command registry (`menus::is_live`) and rewrites [`docs/parity-checklist.md`](parity-checklist.md). The test `parity::tests::parity_does_not_regress` fails if the live count drops below `parity::FLOOR`. `cargo xtask i18n-coverage` prints the UI translation coverage for each language in the @@ -296,7 +315,7 @@ cargo xtask perf --update-baseline # also write perf/baseline.json from this r class, commit, date and load average. - `crates/io/tests/corpus.rs`: the corpus floors (every `Source { .. }` constant). - The prefs audit: `Preferences` fields that no code reads (target 0, #204), plus counts taken - from the tree (never-crash attribute coverage, `docs/parity.md`). + from the tree (never-crash attribute coverage, `docs/parity-checklist.md`). **Perf runs.** `xtask perf` builds the benches in release (`perf_scenarios`, `interactive_bench`, `fx_bench`, `bevel_bench`, `type_bench`, `large_image_bench`, and `layout_bench` once it exists), runs each with diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md new file mode 100644 index 0000000000..4524348521 --- /dev/null +++ b/docs/file-format-parity.md @@ -0,0 +1,136 @@ +# File-format parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: every Photoshop 2026 format from its bundle against our readers and writers; Affinity notes moved here from `roadmap.md`) · **Target:** Adobe Photoshop 2026 + +Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and +tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write +flags of `Contents/PlugIns/Required/File Formats`, and format names in the main binary (Open/Save +As behaviour from Adobe's documentation where the bundle doesn't say). Ours comes from +`crates/codecs/src/format.rs` (capability table), `crates/io/src/lib.rs` (import/export +dispatch), `crates/psd`, `crates/raw`, `crates/heif`, `crates/affinity`. More detail on our side: +[raster formats](../book/src/formats/raster-formats.md), [PSD/PSB](../book/src/formats/psd-psb.md), +[OpenRaster](ora.md), [Paint.NET](pdn.md). + +**Summary.** By count, 13 of Photoshop's 31 formats are fully supported in the directions Photoshop supports them, 4 partially (48%, +measured). Weighted by use (PSD/PSB 40%, JPEG 15%, PNG 15%, TIFF 10%, camera raw 8%, WebP/AVIF/ +HEIF/JXL 6%, PDF 3%, everything else 3%), presence is ~80% and ready-for-real-work ~60%: PSD +is broad but not yet reliable against Photoshop (gap G1), and Photoshop PDF, AVIF read and JPEG +XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. + +## Photoshop's formats + +| Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | +|---|---|---|---|---| +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2), smart filters (14 mapped, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 133/256, io 146/170, psd-tools 236/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469, #2465 (#2374, text layers, closed 2026-10-10) | +| PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | +| Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | +| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | +| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | +| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225) | +| GIF | R/W | yes | yes | First frame only | +| BMP | R/W | yes | yes | 8-bit | +| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP | +| AVIF | R/W | no (#463) | optional feature, off by default | | +| JPEG XL | R/W | inside DNG only | no | | +| HEIF/HEIC | R/W(HEIF) | yes (pure-Rust heic-rs, `heif` feature, on in releases) | no | 8/16-bit | +| OpenEXR | R/W | yes | yes | f16/f32; deep EXR and Cryptomatte readers (`corpus/exr`) | +| Radiance HDR | R/W | yes | yes | f32 | +| Portable Bit Map (PBM/PGM/PPM/PNM/PFM/PAM) | R/W | yes | yes | 8/16/32f | +| Targa | R/W | yes | yes | 8-bit | +| Photoshop PDF | R/W | no (#2541) | raster only (print, artboards to PDF) | No vector/text PDF, no multi-page save, no PDF presentation (FILE-215-10) | +| Photoshop EPS, DCS 1.0/2.0, EPS TIFF/PICT preview | R/W | no | no | | +| Generic EPS / AI | R (rasterize) | no | no | | +| SVG | R (rasterize) | yes, as editable shape layers or a vector smart object | no | Better than Photoshop for editing; gradients approximated (FILE-215-9) | +| JPEG 2000 | R/W | no (#1047) | no | | +| Cineon / DPX | R (DPX), R/W (CIN) | no (#1049) | no | | +| DICOM | R/W | no | no | | +| PCX | R/W | no | no | | +| Pixar | R/W | no | no | | +| Amiga IFF | R/W | no | no | | +| Wireless Bitmap | R/W | no | no | | +| MPO / JPS (stereo) | R/W | no | no | | +| PICT | R | no | no | Legacy | +| Scitex CT | R/W | no | no | Legacy | +| Photoshop Raw (.raw) | R/W | no | no | | +| Camera raw (via Camera Raw 18.7) | R | partial | – | See below | + +## Camera raw + +Photoshop opens raws through Camera Raw 18.7 (the bundle registers cr2, crw, dcr, dng, erf, mos, +fff, 3fr, mef, mfw, iiq, nrw, rwl, srw, mrw, nef, orf, pef, raf, srf, x3f; Camera Raw itself +supports many more, including CR3). + +| Camera format | PhotoCraft | Evidence | +|---|---|---| +| DNG (incl. LJPEG and JPEG XL tiles) | yes; lossy DNG no | FILE-215-11 | +| Canon CR2 | yes | | +| Canon CR3 | detected, unsupported | `crates/raw/src/lib.rs:219` | +| Nikon NEF/NRW (lossless, lossy) | yes; "lossy after split" no | #50 | +| Sony ARW/SR2/SRF (incl. compressed) | yes | | +| Panasonic RW2, Pentax PEF | yes | | +| Olympus ORF | uncompressed only | | +| Fujifilm RAF (Bayer and X-Trans) | uncompressed only; no Fuji colour calibration | | +| Others (CRW, 3FR, IIQ, X3F, SRW, MRW, ERF…) | embedded preview only | | +| .xmp sidecars, highlight modes, raw preferences | missing | FILE-215-11 | +| Camera Raw as a PSD smart filter | partial | 2026-10-07: WB, Light/Presence, curves, HSL, grading, detail, grain, vignette mapped (FILE-215-12) | + +## Formats Photoshop doesn't have (ours only) + +| Format | Read | Write | Notes | +|---|---|---|---| +| `.pcraft` (native) | yes | yes | Incremental, autosave, crash recovery, atomic save | +| Affinity `.af`, `.afphoto`, `.afdesign`, `.afpub` | yes | no | See below | +| Paint.NET `.pdn` | yes | no | [pdn.md](pdn.md) | +| OpenRaster `.ora` | yes | yes | [ora.md](ora.md) | +| ICO, QOI | yes | yes | | +| Krita `.kra`, GIMP `.xcf`, Procreate | no | no | Not in Photoshop either | + +### Affinity import notes (moved from `roadmap.md`) + +2026-10-08: Affinity `.af`, `.afdesign`, `.afphoto` and `.afpub` documents (container versions +8–12) open **natively** ([format notes](../crates/affinity/README.md)): pages and artboards, layers +and groups, curves and geometric shapes with fills, gradients and strokes, text as type layers, +placed images as smart objects, pixel layers and masks, as an 8-bit RGB document without a source +save path. Measured against the thumbnail Affinity embeds in each file, 21 pinned public documents +(`cargo xtask corpus --affinity`) differ by 0–4.6 of 255 on average. Layer effects, adjustments, +live filters, brush strokes, special shapes, master pages and CMYK/Lab/16-bit document colour are +approximated or left out, each with a warning; damaged or unknown files fall back to the embedded +preview. Affinity writing is not implemented: no Affinity installation was available to check +written files, so `.af` export stays unsupported. + +2026-10-10: current `.af` artboard properties (`phrp`/`aprp`) are recognized alongside the +legacy flag, including converted curve boards. Synthetic two-board regressions check separate +bounds, overflow clipping, `.pcraft`/PSD board and pixel round trips, and moving one board with its +children through undo/redo. Nested boards remain masked groups; rotated/curved board outlines +still import at their bounding rectangle. + +2026-10-09: `corpus/affinity/` now has 39 pinned documents and 21 PNGs (20 rendered references and +one bitmap-fill texture): the prior 21 public documents plus 18 CC0 samples for #1606. The new +samples compare against their exported PNGs; +mean differences range from 0.44/255 (conical gradients) to 86.05/255 (RGB/32 reduced to 8-bit). +Clouds, hearts, cogs, callouts, arrows, double stars, tears, crescents, diamonds and circular segments +now import as parametric shapes; the special-shapes sample measures 5.68/255 against its PNG. The +remaining gaps in #1606 stay tracked with per-sample corpus ceilings. + +## Presets and data files + +| Format | Photoshop | PhotoCraft | Notes | +|---|---|---|---| +| Brushes `.abr` | R/W | R/W | `psd/src/abr.rs` (v6/v12) | +| Gradients `.grd` | R/W | R/W | | +| Patterns `.pat` | R/W | R/W | | +| Swatches `.aco`, `.ase` | R/W | R/W | FILE-215-14 | +| LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | +| Keyboard shortcuts `.kys` | R/W | R | `engine/src/kys.rs` | +| ICC profiles | R | R | Own CMS | +| Actions `.atn` | R/W | no | AUTO-219-2 | +| Styles `.asl` | R/W | no | #1693 | +| Contours `.shc`, custom shapes `.csh`, tool presets `.tpl` | R/W | no | FILE-215-15 | +| Curves `.acv`, Levels `.alv`, other adjustment presets | R/W | not audited | | +| C2PA Content Credentials | R/W | no | #1050 | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First version: Photoshop 2026 27.11.0's formats from its bundle, against our codecs; Affinity notes moved from `roadmap.md` | diff --git a/docs/gaps.md b/docs/gaps.md new file mode 100644 index 0000000000..e6f4a3c848 --- /dev/null +++ b/docs/gaps.md @@ -0,0 +1,215 @@ +# Where PhotoCraft falls short of Photoshop + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: every known shortfall ranked, from the 2026-10-10 re-measure) · **Target:** Adobe Photoshop 2026 + +Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact +on the way to beta. This is the work list agents pick from: take the highest-ranked entry that +nobody is working on, and when you close part of one, update its evidence and estimate here and +the matching parity doc. Numbers come from [`target-app-parity.md`](target-app-parity.md); +checklist ids (`TOOL-…`, `FILE-…`, `UI-…`) are rows in [`scorecard.md`](scorecard.md); `#n` are +GitHub issues. + +No gap blocks alpha: the [alpha gate](roadmap.md#alpha-gate) passes. "Blocks beta" marks what +stands between alpha and beta. + +Hours are Opus 5.5 agent-hours, one agent working sequentially, including tests. **Human** marks +what an agent can't finish alone. + +## Summary + +| # | Gap | Kind | Impact | Estimate | Parity doc | +|---|---|---|---|---:|---| +| G1 | PSD fidelity and Photoshop acceptance | file format | blocks beta | 150–250 h | [file-format-parity](file-format-parity.md) | +| G2 | Interactive performance | performance | blocks beta | 200–350 h | [scorecard](scorecard.md#performance) | +| G3 | User-reported bug backlog | stability, UI | blocks beta | 250–400 h | [target-app-parity](target-app-parity.md) | +| G4 | Brush feel | UI / painting | high | 100–180 h | [brush-parity](brush-parity.md) | +| G5 | Transform, handles and modifiers | UI | high | 60–110 h | [ui-parity](ui-parity.md) | +| G6 | Missing tools (15) | features | high | 60–100 h | [ui-parity](ui-parity.md#tools) | +| G7 | Workspaces shown as parameter dialogs | features | high | 80–140 h | [target-app-parity](target-app-parity.md#feature-areas) | +| G8 | Type depth | features | high | 120–200 h | [target-app-parity](target-app-parity.md#feature-areas) | +| G9 | Panels, preferences and workspace | UI | medium-high | 150–250 h | [ui-parity](ui-parity.md#panels-and-workspace) | +| G10 | Missing and partial file formats | file format | medium | 120–220 h | [file-format-parity](file-format-parity.md) | +| G11 | Actions and scripting | ecosystem | medium | 120–200 h | [target-app-parity](target-app-parity.md) | +| G12 | Pen tablets and display hardware | hardware | medium | 80–150 h | [hardware-parity](hardware-parity.md) | +| G13 | Languages: Hindi, Arabic, Vietnamese, RTL, engine messages | localization | medium | 80–140 h + human | [localization-parity](localization-parity.md) | +| G14 | Camera Raw depth | features, formats | medium | 120–200 h | [file-format-parity](file-format-parity.md#camera-raw) | +| G15 | AI and generative features | AI | medium (high for some users) | 300–600 h + human | [target-app-parity](target-app-parity.md) | +| G16 | Plug-in compatibility (.8BF, UXP) | ecosystem | medium | 250–450 h + human | [target-app-parity](target-app-parity.md) | +| G17 | Platforms and distribution | platforms | low-medium | 30–60 h + human | [scorecard](scorecard.md#distribution) | + +## Feature gaps + +### G1. PSD fidelity and Photoshop acceptance +- **Missing:** PSDs we write are not always accepted by Photoshop, and Photoshop-authored files + don't always render as Photoshop renders them. +- **Evidence:** corpus floors (`crates/io/tests/corpus.rs`, scorecard): Photoshop-authored + oracles 133/256 (52%), io corpus 146/170 (86%), psd-tools 236/309 (76%); round trips ~100% + within the corpus. Smart-filter re-render matches 5/30. User reports: a PSD with text layers + re-saved unmodified would not reopen (#2374, closed 2026-10-10), Photoshop 26.6 cannot reopen our files (#1281), "PSD not + opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465), PSB smart object + blank (#1764), perspective placed layers flattened (#579), PSB > 4 GB fails on Windows (#375). + No test opens our exports in a third-party editor (FILE-216-4). +- **Impact:** PSD is the format professionals exchange. Beta requires it to be reliable. +- **Estimate:** 150–250 h. **Human:** opening exports in Photoshop on the owner's Mac (or a + scripted check there) is the only real oracle. + +### G6. Missing tools +- **Missing (13 of 68):** Artboard, Perspective Crop, Frame, Color Sampler (#1046), Color + Replacement (engine command exists), Art History Brush, Freeform Pen and Curvature Pen (#1054), + Add Anchor Point, Delete Anchor Point, Convert Point (only as Pen + ⌥), Horizontal Type Mask, + Vertical Type Mask. +- **Evidence:** `Tool` enum (`crates/ui-egui/src/state.rs`) against the 67 `` entries of + Photoshop's `Default Keyboard Shortcuts.kys` plus Remove, Triangle and Curvature Pen. +- **Impact:** users look for them by name and shortcut; several are daily tools for designers. +- **Estimate:** 60–100 h (2–9 h each, per the calibration). + +### G7. Workspaces shown as parameter dialogs +- **Missing:** Select and Mask workspace with view modes, Refine Edge brush and outputs + (TOOL-214-1, Refine Hair #2671); Content-Aware Fill workspace with a paintable sampling area + (TOOL-214-15); Vanishing Point plane editor (TOOL-214-14); Content-Aware Scale through the + transform box (TOOL-214-7); Puppet Warp mesh following the outline (TOOL-214-6); Content-Aware + Move's Transform On Drop (TOOL-213-4). +- **Evidence:** scorecard rows above, all partial or missing. +- **Impact:** these are the tools retouchers and compositors use daily; a slider dialog is not a + substitute. +- **Estimate:** 80–140 h. + +### G8. Type depth +- **Missing:** type on a path (TYPE-218-1), paragraph direction UI (TYPE-218-2), complex-script + fallback fonts and golden tests (TYPE-218-3), variable-font axes (TYPE-218-4), OpenType panel + UI (TYPE-218-5), manual and optical kerning with PSD round trip (TYPE-218-6, #206), live Warp + Text preview (TYPE-218-7, #2689), triple/quadruple click (TYPE-218-8), "rasterize and continue" + for distort/perspective on type (TYPE-218-9), text free transform (#2630). +- **Evidence:** scorecard Type: 0 done, 3 partial, 6 missing. The Type tool froze the app for + 60 s on one macOS machine (#2518). +- **Estimate:** 120–200 h. + +### G11. Actions and scripting +- **Missing:** document-independent action recording (AUTO-219-1), a full Actions panel with + step toggles and .atn import (AUTO-219-2), replay tests (AUTO-219-4), Batch recursion and + naming (AUTO-219-5), ExtendScript/UXP compatibility. +- **Ahead of Photoshop:** CLI, MCP and the JSON control channel drive every command. +- **Estimate:** 120–200 h. **Human:** whether to support a JavaScript scripting dialect at all + (AGENTS.md forbids JS in the app). + +### G14. Camera Raw depth +- **Missing:** CR3 (detected, unsupported), compressed RAF, lossy DNG (FILE-215-11), .xmp + sidecars, masking and calibration in the Camera Raw dialog, per-camera colour for non-DNG + cameras, lens profiles. +- **Evidence:** `crates/raw/src/lib.rs:219` (CR3), scorecard FILE-215-11/12, #50. +- **Estimate:** 120–200 h. + +### G15. AI and generative features +- **Missing:** Generative Fill and Expand, Generative Upscale, Neural Filters, Sky Replacement, + Super Zoom, ML Select Subject/Sky. There is no ML runtime (AUTO-219-7). Classical substitutes + exist (GrabCut/max-flow selection, Remove tool by non-local patch completion). +- **Evidence:** Photoshop's `Required/UXP` ships the Neural Filters gallery and its `.kys` has + a Neural Filters taskspace; users ask for local models (#2313, #1996, #2682). +- **Estimate:** 300–600 h. **Human:** owner decision #41 (model licences, local vs remote). + +### G16. Plug-in compatibility +- **Missing:** hosting Photoshop .8BF filters (#2647), UXP panels, CEP. PhotoCraft has its own + sandboxed WebAssembly plug-in ABI v1 (`docs/plugins.md`), but ABI v2 (panels, progress, other + layers) is missing (AUTO-219-6). +- **Estimate:** 250–450 h. **Human:** .8BF needs native code under the isolated-`unsafe` rule. + +## UI / UX gaps + +### G3. User-reported bug backlog +- **Missing:** fixes for 579 open issues (2026-10-10), filed mostly by 0.5/0.6 users. By title: + ~150 UI/panels/menus/shortcuts, 94 file/format, 87 layers, 54 painting, 54 selection, + 46 type, 41 performance, 34 transform/move, 25 Linux/Wayland, 24 Windows, 20 macOS. +- **Impact:** each is a place where a Photoshop user hits something different from what they + expect; together they are why ready-for-real-work is ~45% while breadth is ~76%. +- **Estimate:** 250–400 h (many overlap with other gaps; count each fix once). + +### G4. Brush feel +- **Missing:** lag with big brushes (#2619, #1994) and under the cursor (#2566, #1343); jumpy + pressure on Windows (#2046) and tap sensitivity (#1933); stair-stepped line weights (#1776), + interpolation and smoothing problems (#2138), edge falloff (#2149), pixelated cursor vs + Photoshop (#1433), brush rotation not driven by pen rotation (#1301); live preview only for + Brush and Eraser (TOOL-213-7); no image file as a brush tip (TOOL-213-12); per-control pressure + curves (TOOL-213-11); 4 of Photoshop's symmetry modes. +- **Detail:** [`brush-parity.md`](brush-parity.md). +- **Estimate:** 100–180 h. **Human:** checking feel on real tablets. + +### G5. Transform, handles and modifiers +- **Missing:** right-click menu during Free Transform (TOOL-214-5); commit/cancel buttons in the + options bar (#2442); H/V skew fields and the full interpolation list; transform keeps layer + order and blend mode in its preview (#2439); Hand tool interrupting a transform (#2302); + blend mode/opacity edits during a transform (#1384); locked layers refused before the drag + (#2585); ⌘-drag temporary Move without box or snapping (#2681), Auto-Select box follows the + previous layer (#2666); ⌘-arrow layer nudge (#2474); path-point nudge; polygonal lasso Shift + angles (#2352); autoscroll at the edge while selecting (#2231); hold-a-key spring-loaded tools + (#2672); double-click Zoom tool to 100% (#2508); wheel and pinch behaviour (#2485, #2463, + #1989); toolbar press-and-hold flyouts (#2301); tool-key memory in a group (#2608). +- **Detail:** [`ui-parity.md`](ui-parity.md). +- **Estimate:** 60–110 h. + +### G9. Panels, preferences and workspace +- **Missing:** 46 of 149 preferences do nothing (scorecard prefs audit); panel groups can't + float or tear off into OS windows (UI-217-5, #1828, #1213, #2305); Color panel lacks wheel and + slider modes (UI-217-17, #2569); UI scale presets (UI-217-9, #2492); more themes (UI-217-11, + #2661); Stamp Visible (UI-217-22); layer clipboard + (UI-217-23, #2670); tool-specific context menus beyond brushes, selections and Pen + ([context-menu-parity.md](context-menu-parity.md)); Libraries, Comments, Contextual Task Bar + panels. +- **Estimate:** 150–250 h. + +### G2. Interactive performance +- **Missing:** 22 of 25 budgeted scenarios are over budget (baseline 2026-10-05, a heavily + loaded machine): layer move with styles 321 ms p50 against 25 ms, opacity change 276 ms + against 20 ms, paste 12 MP 1.2 s against 100 ms, layer move on 15000×10000 16-bit 2.7 s, + Content-Aware Scale 24 MP 954 s against 3 s, Select Subject 559 ms against 100 ms; the + 150-layer nudge crashes the GPU compositor (P12); a 50 MP PSB with feathered masks redraws in + ~10 s (#2377); menus slow with thousands of layers (#2460); old machines lag (#2687). +- **Estimate:** 200–350 h. Serial in places (compositor architecture). + +## File-format gaps + +### G10. Missing and partial file formats +- **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and write and multi-page PDF + (FILE-215-10, #2541), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), + DPX/Cineon (#1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop + Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, + .shc, .csh presets (FILE-215-15); TGA/PNG alpha channels on open (#2225); C2PA (#1050). +- **Detail:** [`file-format-parity.md`](file-format-parity.md). +- **Estimate:** 120–220 h. + +## Hardware gaps + +### G12. Pen tablets and display hardware +- **Missing:** tilt and rotation on Windows (pressure only, via WM_POINTER; no WinTab); native + Wayland tablet-v2 (#79; the pen does not click on GNOME 50, #2622, KDE #2297); eraser end on + flipped Wacom pens (#2285); HDR/EDR output (Photoshop has "Precise Color Management for HDR + Display"); monitor profile detection off macOS and live profile changes; Touch Bar (macOS + Intel only, low value); 3D mice. +- **Detail:** [`hardware-parity.md`](hardware-parity.md). +- **Estimate:** 80–150 h. **Human:** tablets and HDR displays to test on. + +## Localization gaps + +### G13. Hindi, Arabic, Vietnamese, RTL and engine messages +- **Missing:** catalogs for Hindi, Arabic and Vietnamese (3 of the 12 key languages); right-to- + left UI layout; Devanagari/Thai/Khmer fallback fonts; engine error and status messages in + English in every language; browser-locale detection and CJK fonts on the web; Japanese IME on + Windows (#590); installed Japanese fonts missing from the font list (#2330); Arabic layer names + as tofu (#1408, #1693). +- **Detail:** [`localization-parity.md`](localization-parity.md). +- **Estimate:** 80–140 h. **Human:** native-speaker review of every catalog (none is recorded). + +## Platform gaps + +### G17. Platforms and distribution +- **Missing:** Windows code signing (material not obtained), Windows ARM64 (DIST-220-2), in-app + update check (DIST-220-1, #2650, #2375), in-app bug report (DIST-220-3), Flathub (#173), + starting without a GPU adapter (#2519, #2412), Windows 7 (#2310, out of scope), Intel Macs with + OpenCore (#1859). +- **Estimate:** 30–60 h. **Human:** signing certificates. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Checked against the alpha gate (no alpha blockers); #2374 marked closed | +| 2026-10-10 | major | First version, from the full re-measure against Photoshop 2026 27.11.0 and the open issues | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md new file mode 100644 index 0000000000..60175de1e5 --- /dev/null +++ b/docs/hardware-parity.md @@ -0,0 +1,64 @@ +# Hardware parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: GPU, pen tablets, displays, gestures per platform against Photoshop 2026) · **Target:** Adobe Photoshop 2026 + +Each hardware feature Photoshop 2026 (27.11.0) uses, per platform, against PhotoCraft. Photoshop +side: its bundle (Metal and OpenCL paths, "Precise Color Management for HDR Display" and EDR +strings, Touch Bar resources, pen pressure/tilt strings) and Adobe's GPU FAQ. PhotoCraft side: +`crates/tablet`, `crates/ui-egui/src/stylus.rs`, `apps/photocraft/src/gpu_startup.rs`, +`engine/src/display_color.rs`, `apps/photocraft/src/monitor_profile.rs`. + +**Summary (estimated):** ~50% ready. GPU compositing and colour-managed display are solid; pen +input is uneven by platform (Windows lacks tilt/rotation, Wayland is broken) and there is no HDR +output. Remaining: 80–150 h (gap G12). Human: tablets (Wacom, XP-Pen, Huion) on each OS and an +HDR display to verify. + +## GPU + +| Feature | Photoshop | PhotoCraft | Status | +|---|---|---|---| +| GPU canvas, zoom/pan, rotate view | Metal (macOS), DirectX/OpenCL (Windows) | wgpu 30: Metal, Vulkan, DX12, GL; backend selectable | done | +| GPU compositing of layers, blend modes, effects | partial (Photoshop composites mostly on CPU) | wgpu compositor, ≤ 1/255 vs CPU oracle; Multichannel and > texture-limit regions fall back to CPU | done (ahead in places) | +| GPU filters (Blur Gallery, Liquify, Smart Sharpen, Camera Raw) | yes | CPU (rayon) for filters; GPU for compose and effects | partial | +| Documents larger than the GPU texture limit | yes (tiled) | CPU fallback; tiling in progress (#49) | partial | +| CPU-only fallback when no GPU | yes, reduced | `cpu` canvas preference; fails to start without an adapter on some Linux systems (#2519, #2412) | partial | +| Old / integrated GPUs | yes | black line on Intel HD (#2426), OpenCore Macs fail (#1859), Rgba32Float panic (#2020) | partial | + +## Pen tablets + +| Feature | Photoshop | macOS | Windows | Linux X11 | Linux Wayland | Web | +|---|---|---|---|---|---|---| +| Pressure | yes | yes (AppKit monitor) | yes (WM_POINTER), jumpy (#2046) | yes (XInput2) | via Xwayland only; GNOME 50 pen doesn't click (#2622), KDE (#2297) | yes | +| Tilt | yes | yes | **no** | yes | no | yes | +| Barrel rotation | yes | yes (not applied to tip angle, #1301) | no | partial | no | yes (twist) | +| Eraser end | yes | yes | no (#2285) | partial | no | partial | +| WinTab driver mode | yes | – | no | – | – | – | +| Pressure curve | Windows Ink / driver | global curve in Preferences (#2195) | same | same | same | same | + +## Displays and colour + +| Feature | Photoshop | PhotoCraft | Status | +|---|---|---|---| +| Colour-managed canvas (document → monitor profile) | yes | yes, folded into the GPU 3D LUT | done | +| Monitor profile detection | per display, live | macOS per display at launch; others sRGB or a chosen .icc | partial | +| HDR / EDR output for 32-bit and HDR images | yes ("Precise Color Management for HDR Display") | no; 8-bit sRGB-encoded canvas texture | missing | +| High-DPI, physical 100% | yes | yes (#1943) | done | +| Multiple monitors: documents in separate OS windows | yes | yes (`extra_windows`) | done | +| Panels in separate OS windows | yes | in-app floating only (UI-217-5) | missing | + +## Input devices + +| Feature | Photoshop | PhotoCraft | Status | +|---|---|---|---| +| Trackpad pinch zoom, two-finger pan | yes | yes; pinch scrolls instead in 0.6.0 (#2463) | partial | +| Trackpad rotate (Rotate View) | yes | yes (`rotate_view.rs`) | done | +| Touch Bar | yes (Intel Macs) | no | missing (low value) | +| Touch screens (Windows) | yes | basic pointer only | partial | +| 3D mouse, MIDI, Surface Dial | Surface Dial on Windows | no | missing (low value) | +| Scanners (WIA / TWAIN) | WIA menu (Windows) | menu item live; no driver integration | partial | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First version | diff --git a/docs/iris-blur-performance.md b/docs/iris-blur-performance.md new file mode 100644 index 0000000000..a9b9369449 --- /dev/null +++ b/docs/iris-blur-performance.md @@ -0,0 +1,247 @@ +# Iris Blur computation (2026-10-10) + +Base: `7722172585a01cbdb93c06f0f5ff2634fcb17999` (freshly fetched +`origin/main` and `upstream/main`). Branch: `codex/iris-blur-invariants-20261010`. + +## Upstream investigation + +Open, closed and merged issues/PRs were searched for Iris, Blur Gallery, blur, +sampling, portable and SIMD. No overlapping Iris Blur change was found. +Gallery history includes [PR #1010](https://github.com/storytold/photocraft/pull/1010), +which normalizes nonfinite pin angles and maps nonfinite distance to infinity +(part of #916). Its behaviour is retained in prepared Iris geometry and tested. +[PR #1524](https://github.com/storytold/photocraft/pull/1524), merged as +`d651be1393b709d638dd9d0e4824a420f795d3ae`, is the merged SIMD-related work: +the [review](https://github.com/storytold/photocraft/pull/1524#issuecomment-6072959394) +requested removal of the AVX2/pulp portion because it did not improve complete +filter timings. The author removed that portion before merge. Main has portable +contiguous Mosaic row reads, with exact-output tests, rather than a SIMD subsystem. + +[PR #1602](https://github.com/storytold/photocraft/pull/1602) carries fixed-channel +Gaussian/resize accumulators from #1511, allowing compiler vectorization without +unsafe code or a new dependency. Those box accumulators use f32; Gallery uses f64 +running sums for tile independence, so they are not an exact drop-in replacement. +No nightly `std::simd`, architecture-specific instructions, new dependency or +`target-cpu=native` is introduced here. Transitive codec SIMD dependencies do not +provide a Gallery kernel. + +[PR #1899](https://github.com/storytold/photocraft/pull/1899) already runs filter +previews on a cancellable worker, with a 150 ms settle delay for stale requests. +[PR #2207](https://github.com/storytold/photocraft/pull/2207) addresses preview scale. +This change leaves the worker, proxy size, engine command and UI interactions intact. + +## Algorithm and numerical contract + +Iris Blur interpolates seven Gaussian levels (zero plus six geometrically spaced +sigmas). This is not a ray/disk sampling kernel. Every level, box radius, weight, +premultiplication, unpremultiplication and pin combination remains the same. + +- Prepare each pin's centre, rotation, normalized radii, exponent, feather and + blur once per tile. Allocation failure uses on-demand preparation instead. +- A point outside a pin's normalized bounding square is beyond its superellipse. + Its falloff is saturated; skip the square root/powers there. Inside the square, + use the original expression, divisions and floating-point order. +- Copy interleaved source samples together and premultiply in place, following + the portable row-read principle in #1524. +- For each nonzero level, retain the source's top/left and trim the right/bottom + to the output plus that level's actual three-box reach. Removing leading rows + or columns would change running-sum cancellation, especially for HDR. Keeping + the start preserves exact results; the trailing clamp cannot reach output. + Tilt-Shift and Field retain their full windows. + +There is no additional image-sized cache. The premultiplied window, sigma map, +output accumulator and reused level buffer already existed. The level buffer +reserves the original full-window capacity once when needed, preventing row +appends from doubling capacity; only its populated length and blur scratch shrink. +Prepared geometry uses 36 bytes per pin per active tile. Source reads, tile sizes, +Rayon dispatch, selection mixing and cancellation services remain unchanged. + +`tests_iris_reference.rs` freezes the base kernel. Differential tests require +exact finite f32 bits (including signed zero), with matching NaN classifications. +They exercise 1–6 channels, RGB/Gray/CMYK/Lab, alpha/no alpha, U8/U16/F32, negative +and HDR samples, extreme HDR cancellation, rotated/translated/anisotropic pins, +roundness and blur thresholds, feather 0.998, image edges, cropped output, full +focus/full blur, empty/multiple/4096 pins, degenerate images, nonfinite/extreme pin +fields, selections and multiple tile sizes. Cancellation before work and after +progress preserves the source. Invalid optimized source lengths/zero channels +return an empty internal result. Public API/command behaviour is unchanged. + +## Measurement method + +Windows x86_64, AMD Ryzen 7 9700X (8 cores / 16 logical), 64 GiB RAM, stable Rust +1.99.0 / LLVM 23.1.1. Normal workspace release profile (opt-level 3, thin LTO, one +codegen unit), eight Rayon workers. No concurrent Cargo builds or other task +benchmarks were scheduled during the measurements; ordinary system activity remains. + +`bench_iris` constructs deterministic textured RGB layers with varied alpha and +HDR F32 values. Fixture construction and output destruction are excluded; source +reads, pin preparation, filtering, selection-free surface writeback and pruning +are included. Each case has one untimed warmup, then repeated complete calls. +The unchanged base executable is retained locally for repeat comparisons. Use +the same harness on the base revision to reproduce a comparison. + +```powershell +$env:CARGO_TARGET_DIR = 'target/agent-iris-blur' +$env:RAYON_NUM_THREADS = '8' +cargo run --release -p photocraft-algo --example bench_iris -- 6000 4000 3 +cargo run --release -p photocraft-algo --example bench_iris -- 6000 6000 3 +cargo run --release -p photocraft-algo --example bench_iris -- 1500 1000 5 4 +cargo run --release -p photocraft-algo --example bench_iris -- 2000 1334 5 +cargo run --release -p photocraft-algo --example bench_iris -- 128 96 5 +cargo test --release -p photocraft-algo iris_profile_baseline_stages -- --ignored --nocapture +``` + +The 2000×1334 run is a preview-sized stress workload at fixed blur parameters. +PhotoCraft actually reduces 24 MP by factor four to 1500×1000 (its factor is the +ceiling of sqrt(pixels/2,500,000)). The separate 1500×1000 run models that existing +filtered-proxy workload directly: top-level document blur 15/80/300 becomes +3.75/20/75; the nested multiple-pin JSON amounts retain their existing values. +Neither run measures proxy construction, engine history, preview-worker queueing, +compositing, GPU upload, frame presentation or native slider response. Baseline and +candidate always use identical resolution, blur values and sample counts. + +Before-change stage profiling of a 512×512 output tile, one rotated rounded pin: +geometry 4.3–5.8 ms; premultiplication 1.5/3.5/11.7 ms at blur 15/80/300; +each active Gaussian level about 3–9/18–19/87–100 ms. Whole kernels were +30.8/52.1/208.8 ms (unused levels are skipped). These diagnostic single-tile +figures isolate the computation; end-to-end results below use repeated runs. + +## Repeated release results + +Values are median milliseconds (minimum–maximum), not frame times. Run order: +base 24 MP / stress / small, candidate 24 MP / stress / small, base 24 MP again; +then base 36 MP, candidate 36 MP, base 36 MP again, base default proxy and candidate +default proxy. Each full-size base column pools six samples from two three-repeat +rounds; each candidate has three. Proxy, stress and small-image columns each have +five samples. +Both base rounds have slower medians than the candidate in every 24 MP case. +Variation is visible, particularly in the larger proxy runs; these are local +measurements, not cross-machine performance guarantees. + +Small = blur 15; medium = blur 80, rotation 27°; large = blur 300. +Multiple = two pins at blur 80/40, one with roundness 65 and one rotated 73°. +Focused = radii 5×5, blur 80; blurred = pin centre x = −5, blur 80. + +### 6000×4000 (24 MP) + +| Depth / case | Base ms (range) | Candidate ms (range) | Speedup | +|---|---:|---:|---:| +| U8 medium | 1395.7 (1191.4–1550.9) | 1092.7 (1070.0–1113.7) | 1.28× | +| U8 small | 626.8 (589.8–655.9) | 426.9 (425.8–437.3) | 1.47× | +| U8 large | 4760.6 (4369.7–5154.0) | 3148.4 (3112.9–3175.3) | 1.51× | +| U8 multiple | 1789.9 (1695.9–1885.7) | 1016.2 (1015.6–1018.6) | 1.76× | +| U8 focused | 489.8 (472.8–527.3) | 405.3 (400.6–423.1) | 1.21× | +| U8 blurred | 1161.4 (1102.5–1195.4) | 981.3 (924.7–1007.1) | 1.18× | +| U16 medium | 1522.5 (1410.5–1577.9) | 1115.9 (1108.9–1119.3) | 1.36× | +| F32 medium | 1570.8 (1453.0–1656.7) | 1131.1 (1117.1–1134.3) | 1.39× | + +### 6000×6000 (36 MP) + +| Depth / case | Base ms (range) | Candidate ms (range) | Median ratio | +|---|---:|---:|---:| +| U8 medium | 1976.4 (1842.1–2360.1) | 1923.9 (1745.4–1993.3) | 1.03× | +| U8 small | 854.1 (773.1–947.7) | 806.0 (784.6–818.5) | 1.06× | +| U8 large | 6162.4 (5767.0–6578.0) | 5423.8 (5245.8–5995.2) | 1.14× | +| U8 multiple | 2595.8 (2544.7–2731.7) | 1822.8 (1781.5–1826.7) | 1.42× | +| U8 focused | 708.7 (684.9–740.7) | 628.6 (618.0–630.7) | 1.13× | +| U8 blurred | 1705.5 (1585.5–1821.2) | 1609.4 (1561.4–1613.6) | 1.06× | +| U16 medium | 2217.2 (2034.6–2438.8) | 2065.5 (1961.2–2127.9) | 1.07× | +| F32 medium | 2346.8 (2318.4–2411.9) | 1702.1 (1647.2–1795.3) | 1.38× | + +The second base round was faster than the candidate for U8 small and medium, +despite better pooled medians: neither demonstrates a consistent improvement at +36 MP. U16 medium and fully blurred ranges also overlap; their gains are +inconclusive. Large blur improves both round medians but ranges overlap. +Multiple pins, full focus and F32 medium have separated observed ranges. + +### 1500×1000 default filtered proxy for a 24 MP document + +Single-pin blur values below are the document values divided by four; multiple +pins retain the supplied JSON blur values, matching the existing preview path. + +| Depth / case | Base ms (range) | Candidate ms (range) | Speedup | +|---|---:|---:|---:| +| U8 medium | 51.8 (47.9–54.9) | 42.6 (39.3–45.5) | 1.22× | +| U8 small | 28.7 (27.4–31.0) | 24.9 (23.5–25.9) | 1.15× | +| U8 large | 144.0 (132.5–153.0) | 110.6 (104.0–111.9) | 1.30× | +| U8 multiple | 178.6 (174.8–205.9) | 111.6 (109.3–114.7) | 1.60× | +| U8 focused | 18.0 (17.1–19.0) | 15.0 (14.8–15.9) | 1.20× | +| U8 blurred | 33.1 (31.9–36.9) | 28.2 (27.8–29.9) | 1.17× | +| U16 medium | 52.1 (48.1–55.3) | 42.8 (40.7–49.4) | 1.22× | +| F32 medium | 60.4 (56.6–63.5) | 45.0 (43.9–50.0) | 1.34× | + +Every default-proxy median improves. U16 medium ranges overlap, so that case's +gain is less certain than the other seven separated-range cases. + +### 2000×1334 preview-sized stress workload + +| Depth / case | Base ms (range) | Candidate ms (range) | Speedup | +|---|---:|---:|---:| +| U8 medium | 306.0 (280.6–465.1) | 163.8 (150.1–203.0) | 1.87× | +| U8 small | 82.5 (75.0–87.4) | 59.0 (58.2–63.4) | 1.40× | +| U8 large | 2051.0 (1724.3–2087.2) | 1324.2 (1106.4–1533.7) | 1.55× | +| U8 multiple | 303.6 (289.2–330.7) | 209.1 (200.5–231.5) | 1.45× | +| U8 focused | 61.5 (60.6–73.2) | 55.4 (51.6–78.7) | 1.11× | +| U8 blurred | 151.0 (146.5–155.1) | 143.3 (136.3–145.8) | 1.05× | +| U16 medium | 269.7 (262.7–289.8) | 216.1 (207.1–249.0) | 1.25× | +| F32 medium | 252.5 (237.9–295.5) | 228.0 (207.6–254.8) | 1.11× | + +The already fully blurred stress case has little distance/level work to eliminate; +its improvement is modest. Focused and F32 stress ranges overlap, so their small +median gains are inconclusive. These cases do not justify additional machinery. + +### 128×96 overhead + +| Depth / case | Base ms (range) | Candidate ms (range) | Speedup | +|---|---:|---:|---:| +| U8 medium | 30.8 (30.3–31.3) | 17.7 (17.7–17.8) | 1.74× | +| U8 small | 4.1 (4.0–4.1) | 3.2 (3.1–3.3) | 1.26× | +| U8 large | 280.4 (276.1–294.6) | 149.1 (145.2–151.2) | 1.88× | +| U8 multiple | 26.0 (25.7–26.6) | 13.0 (13.0–13.1) | 1.99× | +| U8 focused | 1.7 (1.6–1.7) | 1.2 (1.2–1.3) | 1.36× | +| U8 blurred | 6.9 (6.9–7.0) | 6.3 (6.3–6.4) | 1.09× | +| U16 medium | 30.4 (30.2–31.0) | 18.3 (18.3–18.3) | 1.67× | +| F32 medium | 30.1 (30.0–30.5) | 18.3 (18.2–18.7) | 1.64× | + +Windows process peak working set for one entire 24 MP suite (including fixture +creation and all depths/cases): base repeat 2,068,135,936 bytes, candidate +1,990,852,608 bytes, about 3.7% lower. This is an allocator-dependent process +high-water measurement sampled via `PeakWorkingSet64`, not per-filter live memory +or a new allocation bound. Existing full-window buffer capacities remain the same. + +Maximum observed finite pixel error in the differential tests is **zero**: exact +bits, rather than an average-error bound. Matching every finite pixel covers +focus-transition structure, sampling aliasing, opacity and tile boundaries without +permitting a localized artifact to hide in a mean error. NaN classifications match. + +## Validation and limits + +Algorithm tests: 399 passed, 15 existing/diagnostic tests ignored. The five Iris +differential/cancellation tests also pass in release; their profiling test is +opt-in. All-target +algorithm Clippy passes with warnings denied. Layering passes (29 crates). Wasm +checks pass for all required crates and HEIF; unrelated existing dead-code warnings +in CMS/engine remain. Scorecard regeneration produces identical content; no budget, +corpus floor, menu parity or checklist status changed. `test-corpus --changed` +correctly skips this algorithm-only change. Commands and UI code are unchanged, +so command panic-hunt and UI screenshot checks are not triggered. + +`cargo xtask perf --quick` completed successfully, running its configured quick +native CPU/GPU examples. Its regression comparison was skipped because the +checked-in baseline is for an Apple M4 Pro, while this machine is Windows/Ryzen +with a Radeon RX 9060 XT. Quick mode does not enforce the full-size budgets. +Results are retained locally in `target/agent-iris-blur/perf/results.json`; +the Iris base/candidate measurements above provide this change's comparison. +Final complete wasm and algorithm formatting checks also pass. + +Final diagnostic single-tile reference/candidate calls (same profile workload, +one pair each, not a statistical benchmark): blur 15, 31.6 → 25.3 ms; blur 80, +52.9 → 43.1 ms; blur 300, 224.1 → 174.7 ms. The repeated complete-filter results +remain the performance evidence used to retain the change. + +Native Iris GUI frame/slider latency, ARM/macOS/Linux performance and wasm runtime +speed were not measured. Compilation confirms wasm compatibility only. Photoshop +equivalence beyond the base implementation's semantics is not asserted. The private +shared craftrules checkout was unavailable at the supplied locations; repository +AGENTS.md and architecture/development/contributing/control/performance rules were +followed. Existing source allocation and per-tile cancellation limits are retained. diff --git a/docs/localization-parity.md b/docs/localization-parity.md new file mode 100644 index 0000000000..a07d5cafca --- /dev/null +++ b/docs/localization-parity.md @@ -0,0 +1,64 @@ +# Localization parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: per-language status measured from the catalogs, against Photoshop 2026's language packs) · **Target:** Adobe Photoshop 2026 + +Per-language status of PhotoCraft's UI. How catalogs work and how to add one: +[`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), +[Simplified Chinese](localization-zh-hans.md). + +**How measured.** `cargo xtask i18n-coverage` (`xtask/src/i18n_coverage.rs`) defines the key +universe: `tl!("…")` literals and `trn(` plurals in `ui-egui` (tests excluded), menu-catalog +labels and path segments, `menus.rs` UI commands, and engine `CommandSpec` labels and menu paths: +**1,608 English keys** on 2026-10-10. Each catalog (`crates/ui-egui/src/i18n/.tsv`) was +checked against that universe by a source script replicating the xtask (no build). Catalogs hold +more rows than the universe because they also translate dynamic `tl!(expr)` strings (~159 call +sites the tool can't enumerate). **Not covered anywhere:** engine error and status messages and +filter parameter help, which are English in every language; so no language is `full`. + +**Photoshop 2026** ships UI language packs for cs, da, de, en, es, fi, fr (and fr-CA), hu, it, +ja, ko, nb, nl, pl, pt-BR, ru, sk, sv, tr, uk, zh-Hans, zh-Hant (24 `.lproj` markers; one pack is +installed per Creative Cloud install, here `en_US`). It has no Hindi, Arabic, Indonesian or +Vietnamese UI; Arabic and Hebrew users get English "Middle East" builds with the World-Ready +composer, and its text engine handles RTL, Devanagari and Indic scripts. + +**Summary.** 7 of the 12 key languages at 100% of measured keys (status `partial` because of the +engine messages), 3 absent (Hindi, Arabic, Vietnamese), plus 8 other languages. Weighted by +speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (none recorded for any +catalog). + +## The twelve key languages + +| Language | Code | UI strings (measured keys) | Catalog rows | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | +|---|---|---|---:|---|---|---|---|---:| +| English | `en` | 1,608 / 1,608 (100%) | source | all | Latin | – | full | 0 h | +| Simplified Chinese | `zh-hans` | 1,608 / 1,608 (100%) | 2,366 | dialogs, tooltips yes; engine messages English | CJK fonts (lazy loader), IME preedit/commit, vertical type | no | partial | 8–12 h | +| Spanish | `es` | 1,608 / 1,608 (100%) | 2,413 | as above | Latin | no | partial | 6–10 h | +| Hindi | `hi` | 0 (0%) | – | – | Devanagari shaping in the text engine (parley/HarfRust); no Devanagari fallback font (TYPE-218-3) | no | none | 20–30 h | +| Arabic | `ar` | 0 (0%) | – | – | Arabic shaping and bidi in the text engine, Arabic fallback font; no RTL UI layout (DIST-220-4), paragraph direction has no UI (TYPE-218-2), layer names as tofu (#1408, #1693) | no | none | 40–60 h | +| French | `fr` | 1,608 / 1,608 (100%) | 2,766 | as above | Latin | no | partial | 6–10 h | +| Portuguese (Brazil) | `pt-br` | 1,608 / 1,608 (100%) | 2,788 | as above | Latin; every `pt` locale uses it | no | partial | 6–10 h | +| Indonesian | `id` | 1,608 / 1,608 (100%) | 2,764 | as above | Latin | no | partial | 6–10 h | +| Japanese | `ja` | 1,608 / 1,608 (100%) | 3,356 | as above | CJK fonts, IME (Windows IME broken, #590), vertical type (tategaki); installed Japanese fonts missing from the list (#2330) | no | partial | 10–16 h | +| German | `de` | 1,608 / 1,608 (100%) | 2,400 | as above | Latin | no | partial | 6–10 h | +| Korean | `ko` | 1,608 / 1,608 (100%) | 2,936 | as above; terminology from Photoshop's Korean UI ([notes](localization-ko.md)) | Hangul fonts, IME | no | partial | 6–10 h | +| Vietnamese | `vi` | 0 (0%) | – | – | Latin with stacked diacritics (shaping supported) | no | none | 12–18 h | + +## Other shipped languages + +8 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional +Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (2,337), +Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (2,411). + +## Cross-language gaps + +- Engine error, status and parameter-help messages are English in every language. +- No right-to-left UI layout (needed for Arabic, Hebrew, Persian, Urdu). +- Web build: no browser-locale detection, no CJK web fonts. +- WebAssembly plug-ins disappear from Filter › Plug-ins when the UI isn't English (#2540). +- A user couldn't find the language option (#2532): it is in Preferences › Interface. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First version, measured from the catalogs with the `i18n-coverage` rules | diff --git a/docs/parity.md b/docs/parity-checklist.md similarity index 82% rename from docs/parity.md rename to docs/parity-checklist.md index 9394b6b6fc..763c0c826e 100644 --- a/docs/parity.md +++ b/docs/parity-checklist.md @@ -2,14 +2,14 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Photoshop's menu tree) and the live command registry. Do not edit by hand. -**627 of 627 menu items live (100.0%).** +**630 of 630 menu items live (100.0%).** | Menu | Live | Total | % | |---|---:|---:|---:| -| File | 51 | 51 | 100% | +| File | 52 | 52 | 100% | | Edit | 75 | 75 | 100% | | Image | 61 | 61 | 100% | -| Layer | 161 | 161 | 100% | +| Layer | 163 | 163 | 100% | | Type | 44 | 44 | 100% | | Select | 25 | 25 | 100% | | Filter | 75 | 75 | 100% | diff --git a/docs/parity-estimate.md b/docs/parity-estimate.md deleted file mode 100644 index 2887a4a8b9..0000000000 --- a/docs/parity-estimate.md +++ /dev/null @@ -1,92 +0,0 @@ -# Feature-parity estimate and remaining effort - -How close PhotoCraft is to Adobe Photoshop 2026, and how much Opus 5.5 work remains. Generated by -assessment, not a tool; revisit when a major arc lands. Menu coverage is from `cargo xtask parity`; -fidelity is from the PSD oracle (`cargo xtask corpus` / `oracle_diff`). - -## Headline - -> **Update 2026-10-03:** menu/command breadth reached **100% (625/625)** — every Photoshop -> menu item now dispatches a live command (incl. Variables/Data Sets, Trap, the Timeline panel and -> Layer › Video Layers). Remaining parity work is depth and fidelity, not breadth: PSD oracle -> (~66%), Camera Raw/video-layer depth, and the AI/Neural tier. See the table below. - - -- **Menu / command breadth:** 625 / 625 Photoshop menu items live = **100%** (every menu item has a live command). -- **PSD rendering fidelity (oracle):** ~111 / 170 corpus files pixel-match = **~65%**. -- **Overall functional parity (weighted, mainstream workflows):** **~86%** (menu breadth complete; depth + fidelity are the remaining gap). -- **Overall including Adobe's AI / Firefly features:** **~75%** (these are mostly cloud/ML and the - hardest to reach clean-room). - -Menu coverage (96%) overstates true parity because many live commands are faithful-but-approximate -(Camera Raw without masking/calibration, Vanishing Point without its UI, heuristic Select Subject/Sky -vs Adobe's ML, Content-Aware Fill basic). The weighted figure accounts for depth and fidelity, not -just presence. - -## Where we stand, by area - -| Area | Parity | Notes | -|---|---:|---| -| Layers, masks, groups, blend modes, smart objects, layer styles | ~92% | Broad + GPU-accelerated; bevel/satin shapes and a few blend edge cases remain | -| Selections, paths, vector shapes, pen | ~90% | Select menu 100% of items; Select Subject/Sky are heuristic, not ML | -| Paint / brush engine, retouch (clone, healing) | ~85% | Solid; no Mixer Brush depth, no Content-Aware healing | -| Adjustments + adjustment layers | ~90% | All kinds present; Camera Raw depth and a few toe-curve models approximate | -| Filters (incl. Filter Gallery ×47, Liquify, blurs, render) | ~88% | Gallery looks are approximations; no Neural Filters | -| Type / text engine, character & paragraph styles, glyphs | ~90% | Vertical type laid out and rendered (#199; no mojikumi or upright-Roman option yet); OpenType alternates thin | -| Color management (ICC v2/v4, intents, soft-proof) | ~90% | Own pure-Rust CMS; no Adobe CMYK profiles (synthetic) | -| File I/O: PSD, native .pcraft, flats (png/jpg/tiff/webp/exr/…) | ~85% | PSD oracle ~65% pixel-exact; most structure round-trips | -| Transform, warp, puppet, perspective, liquify | ~88% | No Face-Aware Liquify (needs a landmark model) | -| Automation: CLI, MCP, headless server, control channel | ~95% | Ahead of Photoshop here (see below) | -| Image modes (RGB/Gray/CMYK/Lab/Indexed/Duotone/Multichannel) | ~85% | Multichannel display-only; no ink-plate model | -| Print / export / Save for Web / slices / Generator | ~85% | No Separations/bleed; droplets basic | -| Computational photo (Photomerge, HDR, Lens, Camera Raw, Wide Angle) | ~70% | Classical implementations; generic lens profiles only | -| Data-driven graphics (Variables, Data Sets) | ~0% | Not started (5 menu items) | -| Video / Timeline / animation | ~0% | Not started (13 menu items) — a whole subsystem | -| AI / Neural / Generative (Firefly, Sensei, Neural Filters) | ~5% | Mostly absent; needs licensed/trained models | -| Prepress niche (Trap, measurement scale pro) | ~30% | Trap, ink spread not modelled | - -## The remaining 25 menu items, grouped - -- **Video & Timeline (13):** Video Layers (new/insert/duplicate/delete/replace/interpret/restore/ - reload/rasterize), Rasterize › Video, Timeline panel, Render Video, Import Video Frames to Layers. - One cohesive subsystem: a frame/time model, a video decoder/encoder (clean-room Rust, no ffmpeg), - onion-skinning, and a timeline UI. -- **Data-driven graphics (5):** Variables › Define / Data Sets, Apply Data Set, Export Data Sets as - Files, Import Variable Data Sets. A template-binding model over layers + a CSV/data-set engine. -- **Niche / platform (7):** Trap (prepress ink spread), Frame from Layers, Migrate Presets, WIA - Support (Windows scanners), and two already-adjacent items. - -## Effort to parity (Opus 5.5) - -Two clocks: **agent-hours** (total model work) and **wall-clock** (with this session's observed -~6–8× parallelism across independent arcs). This session landed ~70 menu items + major subsystems -in a handful of wall-clock hours using 8 parallel agents, so wall-clock ≈ agent-hours ÷ 6. - -| Milestone | Scope | Agent-hours | Wall-clock | -|---|---|---:|---:| -| **To 90% functional** | remaining non-AI/non-video menu items (Variables, Trap, Frame, Migrate, WIA), Camera Raw depth, Vanishing Point UI, selection quality, polish | 60–90 | 10–15 h | -| **To 95% functional** | the above + PSD oracle 65% → ~90% (bevel/satin shapes, Lab blending, noise-gradient RNG, toe curves), broad UI-parity pass, perf pass | +80–130 | +15–22 h | -| **Video / Timeline subsystem** | frame model, clean-room decode/encode, timeline, animation, Render Video | 50–80 | 10–14 h | -| **Credible AI tier** | ML Select Subject/Sky, Content-Aware Fill (PatchMatch++), basic generative fill/expand — needs model integration; some features may be infeasible fully clean-room without licensed models | 120–250+ | 25–45 h | - -**Totals:** -- To **~95% functional parity** (everything except video and AI): **~140–220 agent-hours ≈ 25–40 - wall-clock hours** of Opus 5.5 with parallel agents. -- To **~98%** incl. the video subsystem: **+50–80 agent-hours ≈ +10–14 wall-clock hours**. -- To **~99%** incl. a credible AI tier: **+120–250 agent-hours ≈ +25–45 wall-clock hours**, with the - caveat that pixel-for-pixel Firefly/Sensei parity is not achievable clean-room without trained - models; we target functional equivalents. - -**Best single number:** PhotoCraft is **~82% of the way** to full Photoshop parity, and reaching a -**95%, ships-for-most-users** state is roughly **25–40 wall-clock hours** of parallel Opus 5.5 work. -Closing the last 5% (video + AI) is the long, uncertain tail. - -## Assumptions & caveats - -- "Parity" = functional + visual equivalence of what a user can do, not byte-identical output. Clean- - room means no Adobe code, profiles, shaders, or trained models; AI features target equivalents. -- Wall-clock assumes 6–8 independent arcs in parallel with their own target dirs, as this session ran. - Serial work is ~6× longer. -- Adobe removed 3D/Substance from Photoshop, so 3D is intentionally out of scope. -- Fidelity hours are the least certain: the remaining oracle failures are proprietary RNGs and - unpublished curve models fitted by observation. diff --git a/docs/pixelate-performance.json b/docs/pixelate-performance.json new file mode 100644 index 0000000000..04b9c67a61 --- /dev/null +++ b/docs/pixelate-performance.json @@ -0,0 +1,278 @@ +[ + { + "mode": "rgb", + "case": "facet", + "width": 6000, + "height": 4000, + "depth": "u8", + "times": { + "before": [ + 0.686463, + 0.684428, + 0.681198 + ], + "after": [ + 0.311512, + 0.309932, + 0.31487 + ] + }, + "before": 0.684428, + "after": 0.311512, + "sha256": "d629acb2a4afda38dc5976ac8e552e44e419abb001a4b9a3ec7b42e289c4fac3", + "speedup": 2.1971160019517706 + }, + { + "mode": "rgb", + "case": "halftone", + "width": 6000, + "height": 4000, + "depth": "u8", + "times": { + "before": [ + 1.135315, + 1.13726, + 1.142235 + ], + "after": [ + 0.597161, + 0.612252, + 0.603813 + ] + }, + "before": 1.13726, + "after": 0.603813, + "sha256": "d8c056438d7e9c8a954c8913bb7185deaa154618c0eeb9c36a65f87148744b28", + "speedup": 1.8834639201209644 + }, + { + "mode": "rgb", + "case": "facet", + "width": 7200, + "height": 5000, + "depth": "u8", + "times": { + "before": [ + 1.027543, + 1.029425, + 1.026628 + ], + "after": [ + 0.454462, + 0.450456, + 0.476923 + ] + }, + "before": 1.027543, + "after": 0.454462, + "sha256": "cbe05cee9bd9ac15e908dc48f88668b36983a8159c326beed53ee5a84f9c8e85", + "speedup": 2.2610097213848466 + }, + { + "mode": "rgb", + "case": "halftone", + "width": 7200, + "height": 5000, + "depth": "u8", + "times": { + "before": [ + 1.707774, + 1.719981, + 1.693232 + ], + "after": [ + 0.897247, + 0.905924, + 0.898006 + ] + }, + "before": 1.707774, + "after": 0.898006, + "sha256": "bb7810a5ae26c0860ce502b3524e6ed51c75359afb230f4195fdb065f90880c2", + "speedup": 1.9017400774605069 + }, + { + "mode": "rgb", + "case": "facet", + "width": 6000, + "height": 4000, + "depth": "u16", + "times": { + "before": [ + 0.765632, + 0.755045, + 0.756924 + ], + "after": [ + 0.368377, + 0.370144, + 0.375268 + ] + }, + "before": 0.756924, + "after": 0.370144, + "sha256": "8090fc2be1997d904bd8b23dbb85466b318cb25d5ed85b567c506124dd721c57", + "speedup": 2.044944670182416 + }, + { + "mode": "rgb", + "case": "halftone", + "width": 6000, + "height": 4000, + "depth": "u16", + "times": { + "before": [ + 1.203186, + 1.189726, + 1.198921 + ], + "after": [ + 0.6647, + 0.662166, + 0.665923 + ] + }, + "before": 1.198921, + "after": 0.6647, + "sha256": "c176f05bc3e98762add441c38c8cddca4fdbcf720c54473f70e2656614d4a018", + "speedup": 1.8037024221453286 + }, + { + "mode": "rgb", + "case": "facet", + "width": 6000, + "height": 4000, + "depth": "f32", + "times": { + "before": [ + 0.806876, + 0.807626, + 0.804216 + ], + "after": [ + 0.429701, + 0.435696, + 0.441177 + ] + }, + "before": 0.806876, + "after": 0.435696, + "sha256": "049ea951c124c77a3090751be168a07949a81340eee7c622b28603eefb5f2b3d", + "speedup": 1.8519242774778744 + }, + { + "mode": "rgb", + "case": "halftone", + "width": 6000, + "height": 4000, + "depth": "f32", + "times": { + "before": [ + 1.283232, + 1.272004, + 1.266286 + ], + "after": [ + 0.730128, + 0.722809, + 0.72555 + ] + }, + "before": 1.272004, + "after": 0.72555, + "sha256": "d91c9f6123ea5ae9842e4761ca6c49a27371cc6101813ada6a2806e92ef61e9b", + "speedup": 1.753158293708221 + }, + { + "mode": "rgb", + "case": "halftone-small", + "width": 6000, + "height": 4000, + "depth": "u8", + "times": { + "before": [ + 1.417763, + 1.418794, + 1.407005 + ], + "after": [ + 0.720082, + 0.716397, + 0.71179 + ] + }, + "before": 1.417763, + "after": 0.716397, + "sha256": "d92f681ce4285bebda335bc3ed2288e8f1f468216f208fc5eb57654691b04ea0", + "speedup": 1.979018616772544 + }, + { + "mode": "rgb", + "case": "halftone-large", + "width": 6000, + "height": 4000, + "depth": "u8", + "times": { + "before": [ + 1.138943, + 1.138331, + 1.140677 + ], + "after": [ + 0.583799, + 0.587533, + 0.592007 + ] + }, + "before": 1.138943, + "after": 0.587533, + "sha256": "b4b7835d3eff7548e2f71a31c4f87e728bf1754d2d680269bedd7a428091916e", + "speedup": 1.9385174960385205 + }, + { + "mode": "cmyk", + "case": "facet", + "width": 6000, + "height": 4000, + "depth": "u8", + "times": { + "before": [ + 7.888404, + 7.841185, + 7.897717 + ], + "after": [ + 0.574157, + 0.569647, + 0.576679 + ] + }, + "before": 7.888404, + "after": 0.574157, + "sha256": "055e8618c5554839c6c3566a8373eca86afaaf13ac2010454828352cdf7ca425", + "speedup": 13.739106202658855 + }, + { + "mode": "lab", + "case": "halftone", + "width": 6000, + "height": 4000, + "depth": "u8", + "times": { + "before": [ + 3.576011, + 3.590308, + 3.588703 + ], + "after": [ + 0.719498, + 0.71831, + 0.714674 + ] + }, + "before": 3.588703, + "after": 0.71831, + "sha256": "f52df18bebfbed6f7a8d7b2071c0c5221629f64579ef58571893d7b50301f2b3", + "speedup": 4.996036530188916 + } +] diff --git a/docs/pixelate-performance.md b/docs/pixelate-performance.md new file mode 100644 index 0000000000..13110bac67 --- /dev/null +++ b/docs/pixelate-performance.md @@ -0,0 +1,55 @@ +# Facet and Color Halftone performance + +2026-10-10. Two changes remove repeated work from the existing Pixelate filters: + +- **Facet:** calculate luma once per source pixel and calculate each 3x3 quadrant once. A three-row ring shares that quadrant with its four output pixels. The nine-sample addition order, variance calculation, quadrant order and strict tie comparison stay the same. +- **Color Halftone:** memoize the five-tap ink calculation and dot radius in the rotated screen grid, separately for each channel. Pixels still use the same candidate dots, distances and anti-alias coverage. Lab conversion, CMYK ink direction and alpha handling stay the same. + +Facet uses about 291 KiB of additional scratch for a 256x256 output tile. The luma plane is capped at 1,048,576 samples. Halftone tables are capped at 2 MiB per channel. Scratch allocations are fallible; an incomplete halo, oversized table or unsuitable screen coordinates uses the original calculation. The default tiled filter path stays parallel on native targets and single-threaded on wasm. + +## Full filter timings + +EC2 m7i.4xlarge, 16 vCPUs (Intel Xeon Platinum 8488C), Ubuntu 24.04, rustc 1.99.0, default release profile, `RAYON_NUM_THREADS=16`. Base: `7722172585a01cbdb93c06f0f5ff2634fcb17999`. The changed algorithms are in `1bc3a9f1`. Before and after were built in separate target directories; no other builds or benchmarks ran during measurement. + +Each entry is the median of three alternating pairs: before/after, after/before, before/after. The timer covers `apply_in`, including tile reads, filtering, assembly, depth conversion, writeback and pruning. Fixture construction, the small warmup and output serialization are outside the timer. The fixture is a dense deterministic native-color image with varying alpha, negative document origin and transparent pixels. These are CPU filter application times; window presentation and undo bookkeeping are outside this measurement. + +| Filter | Size | Model + alpha | Depth | Before (ms) | After (ms) | Speedup | +|---|---:|---|---|---:|---:|---:| +| Facet | 24 MP | RGB | u8 | 684.4 | 311.5 | 2.20x | +| Halftone r8 | 24 MP | RGB | u8 | 1137.3 | 603.8 | 1.88x | +| Facet | 36 MP | RGB | u8 | 1027.5 | 454.5 | 2.26x | +| Halftone r8 | 36 MP | RGB | u8 | 1707.8 | 898.0 | 1.90x | +| Facet | 24 MP | RGB | u16 | 756.9 | 370.1 | 2.04x | +| Halftone r8 | 24 MP | RGB | u16 | 1198.9 | 664.7 | 1.80x | +| Facet | 24 MP | RGB | f32 | 806.9 | 435.7 | 1.85x | +| Halftone r8 | 24 MP | RGB | f32 | 1272.0 | 725.6 | 1.75x | +| Halftone r2 | 24 MP | RGB | u8 | 1417.8 | 716.4 | 1.98x | +| Halftone r64 | 24 MP | RGB | u8 | 1138.9 | 587.5 | 1.94x | +| Facet | 24 MP | CMYK | u8 | 7888.4 | 574.2 | 13.74x | +| Halftone r8 | 24 MP | LAB | u8 | 3588.7 | 718.3 | 5.00x | + +Every pair was compared with `cmp` over the complete output, serialized as little-endian f32 sample bits in tile order. All 36 comparisons matched exactly, including the 16-bit and float cases. [Raw three-run timings and output SHA-256 hashes](pixelate-performance.json) are committed alongside this table. + +## Reproduce + +Build the same example against the base and the changed tree. Keep their Cargo target directories separate: + +```sh +git worktree add --detach ../photocraft-pixelate-base 7722172585a01cbdb93c06f0f5ff2634fcb17999 +cp crates/algo/examples/bench_pixelate.rs ../photocraft-pixelate-base/crates/algo/examples/ +(cd ../photocraft-pixelate-base && CARGO_TARGET_DIR=target cargo build --release -p photocraft-algo --example bench_pixelate) +CARGO_TARGET_DIR=target cargo build --release -p photocraft-algo --example bench_pixelate + +RAYON_NUM_THREADS=16 ../photocraft-pixelate-base/target/release/examples/bench_pixelate facet 6000 4000 u8 1 before.bin rgb +RAYON_NUM_THREADS=16 target/release/examples/bench_pixelate facet 6000 4000 u8 1 after.bin rgb +cmp before.bin after.bin +sha256sum before.bin after.bin +``` + +Repeat each pair three times, reversing the order for the middle pair. Use `7200 5000` for 36 MP; `u16` or `f32` for the other depths; `halftone`, `halftone-small` or `halftone-large` for the screen cases; and `cmyk` or `lab` for the other color models. The example prints the timed operation separately from file writing. + +## Correctness and repository checks + +The independent scalar oracles retain the original kernels. Tests compare every sample bit across all eight color models, alpha on/off, constant colors and edges, random samples, HDR/negative values, non-finite samples, thin regions, partial halos, extreme screen offsets, extended channel counts and scratch rejection. Tiled surface tests add 8/16/32-bit storage, soft selections, tile sizes 1/7/17/256 and both transparent and repeated edges. + +Passed: 401 algorithm tests; clippy with warnings denied; formatting; dependency layering; the full `cargo xtask wasm` set; `cargo xtask perf --quick` on llvmpipe; and scorecard generation. No command, UI or format contracts changed. diff --git a/docs/quick-selection-performance.md b/docs/quick-selection-performance.md new file mode 100644 index 0000000000..86f8cf4689 --- /dev/null +++ b/docs/quick-selection-performance.md @@ -0,0 +1,129 @@ +# Quick Selection stroke performance + +Measured 2026-10-10 on an AWS c7i.4xlarge, Intel Xeon Platinum 8488C, +16 vCPUs, 32 GiB RAM, Ubuntu 24.04, Rust 1.99.0. Release builds and the +default native worker count. Builds and other tests were idle during timing. +Algorithm commit: `78dca424510bbdecb01228a4a98a971fd070b658`. + +## What changed + +The fitted Quick Selection energy, brush footprint, costs, weights, working +resolution, boundary bands and window expansion rules stay the same. + +Large finite graphs with both terminal directions use FIFO push/relabel, +implemented from [Goldberg and Tarjan (JACM 1988)](https://doi.org/10.1145/48014.61051). +Local excess pushes replace repeated augmenting paths. Periodic global +relabelling computes distances to the sink and, for the remaining vertices, +back to the source. Returning unused source flow is necessary to retain a +feasible residual graph and support another solve after adding capacities. +The final source-reachability pass returns the smallest source-side cut. +Graphs below 4096 nodes, one-terminal graphs, and graphs with nonfinite or +negative edge capacities keep the existing Boykov-Kolmogorov implementation. +Both solvers use the existing f32 residual capacities and saturation epsilon. +The new solver stores f32 reverse source capacities and f64 excess, 12 bytes +per free graph node, and reuses the graph's height, current-arc and active arrays. + +Graph construction skips weights between two fixed pixels. With fewer than +one free pixel in eight, a five-way merge enumerates just the canonical edge +sources incident to free pixels, in their original raster order. This makes +edge enumeration and weight evaluation O(free pixels), while the existing +indexing and terminal setup remain linear in the working image. Preserving +edge order also preserves f32 accumulation into fixed-neighbour terminals. + +## Raster surface strokes + +The timed call is the full `quick_select` CPU pipeline through `SurfaceSampler`, +as used by `select.quick`: raster reading and depth conversion, RGB sampling, +window expansion, footprint and distance costs, coarse-to-fine cuts, mask +upsampling and region trimming. Document construction is outside the timer. +The stroke paints three rows across a 2400 x 1200 textured object with a +180 px brush. The fixture is deterministic synthetic RGB with a contrasting +background and a small texture component. + +One warm pair, then five alternating before/after pairs per depth and document. +The table reports medians. Every run asserts identical bounds and every mask byte. + +| Document | Depth | Before (ms) | After (ms) | Speedup | +| --- | --- | --- | --- | --- | +| 6000x4000 | 8-bit | 747.60 | 201.96 | 3.70x | +| 6000x4000 | 16-bit | 747.36 | 208.29 | 3.59x | +| 6000x4000 | 32f | 738.22 | 211.35 | 3.49x | +| 7200x5000 | 8-bit | 721.61 | 207.24 | 3.48x | +| 7200x5000 | 16-bit | 719.26 | 207.96 | 3.46x | +| 7200x5000 | 32f | 732.08 | 205.96 | 3.55x | + +## Other complete CPU strokes + +These use `ImageSampler` to isolate the same complete stroke pipeline from +raster storage conversion. One warm pair and three alternating measured pairs. +The click uses a 30 px brush on a 220 x 220 object; the long stroke uses a +256 px brush across 2000 px on a 2400 x 1000 object; paint-object is the +three-row stroke above. All 24 MP and 36 MP fixtures retain normal working +resolution and automatic window expansion. + +| Document | Stroke | Before (ms) | After (ms) | Speedup | +| --- | --- | --- | --- | --- | +| 6000x4000 | click | 113.60 | 39.90 | 2.85x | +| 6000x4000 | long-stroke | 1199.74 | 360.31 | 3.33x | +| 6000x4000 | paint-object | 736.28 | 205.92 | 3.58x | +| 7200x5000 | click | 121.53 | 33.09 | 3.67x | +| 7200x5000 | long-stroke | 1168.25 | 353.19 | 3.31x | +| 7200x5000 | paint-object | 725.02 | 201.60 | 3.60x | + +## Correctness and regression checks + +- 1170 integer-capacity graphs compare flow and all labels against BK, then solve again. +- 600 arbitrary f32-capacity graphs compare flows and all labels against BK. +- 240 fractional graphs enumerate every possible cut, checking optimal energy + and the intersection of all optimal source sets independently of BK. +- A 5000-node disconnected chain checks return-to-source progress, a repeated + solve, and adding a link before another solve. +- Exhaustive small constraint masks and edge-source masks cover borders, + diagonals, degenerate dimensions and tied cuts. +- 36 full Quick Selection comparisons cover texture, low contrast, gradients, + noise, thin bars, discs, uniform areas and canvas-border strokes, with direct + and coarse-to-fine cuts. +- Every measured workflow compares full region bounds and mask bytes. + +## Reproduction + +```sh +cargo test --release -p photocraft-algo quick_selection_surface_release_comparison -- --ignored --nocapture --test-threads=1 +cargo test --release -p photocraft-algo quick_selection_workflow_release_comparison -- --ignored --nocapture --test-threads=1 +cargo test --release -p photocraft-algo solver_workload_release_comparison -- --ignored --nocapture --test-threads=1 +``` + +The private const test path keeps the previous dense weight scan and the +unchanged BK solver, sharing the rest of the full stroke pipeline. Solver-only +fixtures additionally cover chains, one-terminal grids and dense terminal grids +at 1024, 4096, 16384 and 65536 nodes. Their numbers measure just the solve, +with graph cloning outside the timer. + +## Raw samples + +```text +SURFACE 6000x4000 format=PixelFormat { mode: Rgb, sample: U8, alpha: true } old_ms=[745.840809, 746.246292, 747.604875, 747.82731, 748.878614] new_ms=[201.579251, 201.723446, 201.956769, 202.38253, 202.948418] speedup=3.70 +SURFACE 6000x4000 format=PixelFormat { mode: Rgb, sample: U16, alpha: true } old_ms=[745.974128, 746.216757, 747.364691, 748.216512, 748.33632] new_ms=[206.63495600000002, 206.932412, 208.287491, 209.847055, 210.480741] speedup=3.59 +SURFACE 6000x4000 format=PixelFormat { mode: Rgb, sample: F32, alpha: true } old_ms=[737.480891, 737.6333950000001, 738.216963, 738.513631, 739.73055] new_ms=[209.130586, 209.490552, 211.347315, 211.520884, 213.142088] speedup=3.49 +SURFACE 7200x5000 format=PixelFormat { mode: Rgb, sample: U8, alpha: true } old_ms=[720.72951, 721.2813970000001, 721.6096229999999, 722.4278710000001, 723.3628600000001] new_ms=[206.039241, 207.077233, 207.241128, 207.246913, 210.42549] speedup=3.48 +SURFACE 7200x5000 format=PixelFormat { mode: Rgb, sample: U16, alpha: true } old_ms=[718.551252, 718.5636020000001, 719.25546, 719.810975, 719.868964] new_ms=[203.704922, 205.042543, 207.958082, 208.070805, 208.25623900000002] speedup=3.46 +SURFACE 7200x5000 format=PixelFormat { mode: Rgb, sample: F32, alpha: true } old_ms=[730.028451, 731.879452, 732.082273, 733.348433, 734.600343] new_ms=[205.59655800000002, 205.75180600000002, 205.957811, 206.02760800000001, 207.490222] speedup=3.55 +QUICK 6000x4000 case=click brush=30 old_ms=[113.313339, 113.600531, 114.41872000000001] new_ms=[39.298109, 39.903393, 39.940391] speedup=2.85 bbox=Rect { x0: 2891, y0: 1891, x1: 3111, y1: 2111 } +QUICK 6000x4000 case=long-stroke brush=256 old_ms=[1184.445199, 1199.7410909999999, 1199.926849] new_ms=[352.47967, 360.312007, 367.333399] speedup=3.33 bbox=Rect { x0: 1801, y0: 1501, x1: 4201, y1: 2501 } +QUICK 6000x4000 case=paint-object brush=180 old_ms=[732.409731, 736.284987, 736.734234] new_ms=[204.055566, 205.92469899999998, 208.78787100000002] speedup=3.58 bbox=Rect { x0: 1801, y0: 1401, x1: 4201, y1: 2601 } +QUICK 7200x5000 case=click brush=30 old_ms=[121.405737, 121.530837, 122.215665] new_ms=[32.693926, 33.087069, 33.834208] speedup=3.67 bbox=Rect { x0: 3491, y0: 2391, x1: 3711, y1: 2611 } +QUICK 7200x5000 case=long-stroke brush=256 old_ms=[1166.053819, 1168.25045, 1169.190979] new_ms=[352.533248, 353.18776299999996, 355.84682899999996] speedup=3.31 bbox=Rect { x0: 2401, y0: 2001, x1: 4801, y1: 3001 } +QUICK 7200x5000 case=paint-object brush=180 old_ms=[723.5930350000001, 725.024707, 726.1529770000001] new_ms=[200.803006, 201.595578, 202.331318] speedup=3.60 bbox=Rect { x0: 2401, y0: 1901, x1: 4801, y1: 3101 } +SOLVER width=32 n=1024 case=chain old_ms=[0.026896, 0.027003, 0.02743] new_ms=[0.02681, 0.027076999999999997, 0.033721] speedup=1.00 +SOLVER width=32 n=1024 case=all-source old_ms=[0.016474, 0.01651, 0.024678] new_ms=[0.016201999999999998, 0.016773999999999997, 0.017098000000000002] speedup=0.98 +SOLVER width=32 n=1024 case=dense-grid old_ms=[0.5345420000000001, 0.541424, 0.546977] new_ms=[0.519929, 0.543284, 0.566567] speedup=1.00 +SOLVER width=64 n=4096 case=chain old_ms=[0.118043, 0.118185, 0.120604] new_ms=[0.106363, 0.106517, 0.114974] speedup=1.11 +SOLVER width=64 n=4096 case=all-source old_ms=[0.065567, 0.06612499999999999, 0.067655] new_ms=[0.071212, 0.07142100000000001, 0.073924] speedup=0.93 +SOLVER width=64 n=4096 case=dense-grid old_ms=[3.866533, 3.8864490000000003, 3.922701] new_ms=[1.256155, 1.319511, 1.3287730000000002] speedup=2.95 +SOLVER width=128 n=16384 case=chain old_ms=[0.439333, 0.43940500000000005, 0.44905] new_ms=[0.38673599999999997, 0.395393, 0.475274] speedup=1.11 +SOLVER width=128 n=16384 case=all-source old_ms=[0.26877999999999996, 0.28474099999999997, 0.295766] new_ms=[0.285945, 0.301348, 0.305836] speedup=0.94 +SOLVER width=128 n=16384 case=dense-grid old_ms=[13.136574999999999, 13.181371, 13.289321999999999] new_ms=[1.110547, 1.204107, 1.2151900000000002] speedup=10.95 +SOLVER width=256 n=65536 case=chain old_ms=[1.83223, 1.855307, 1.887005] new_ms=[1.5852430000000002, 1.624603, 1.795397] speedup=1.14 +SOLVER width=256 n=65536 case=all-source old_ms=[1.075863, 1.0908529999999999, 1.178408] new_ms=[1.16852, 1.184938, 1.224871] speedup=0.92 +SOLVER width=256 n=65536 case=dense-grid old_ms=[33.334272999999996, 33.502357, 33.531151] new_ms=[4.31308, 4.609208, 4.618033] speedup=7.27 +``` diff --git a/docs/roadmap.md b/docs/roadmap.md index 9fb544461e..8c650af3fd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,20 +1,48 @@ # Roadmap +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (Photomerge implementation status corrected; real-world quality validation remains open) · **Target:** Adobe Photoshop 2026 + - UI.5: Panels can detach, join other tab groups, split, resize and redock through shared docking transactions. Custom layouts persist with workspaces, respect workspace locking, and retain collapse flags for intact native groups. - UI.3: Shared icons, accessible icon buttons and panel tabs come from the pinned `craft-ui` library. Tab overflow stays within narrow panels; document tiling uses bounded shared geometry. Palettes and document commands remain app-owned. Existing strict-Clippy boolean/range warnings are corrected. - UI.4: Compact toolbars keep colour and mode controls visible while tool rows scroll. Long tool-options rows scroll horizontally and reveal keyboard focus; offscreen geometry and interaction regressions cover all themes and 1×/1.5×/2× display scales. -Status legend: ✅ done · 🟡 in progress · ⬜ not started. Updated 2026-10-08. -**Parity metrics.** `cargo xtask parity` measures how much of Photoshop's menu tree is *wired to a -command* and writes [`parity.md`](parity.md). It does **not** measure whether those commands behave -like Photoshop, feel right, or survive real files. Read it next to the PSD composite oracle, the -test count, and the [honest parity assessment](#honest-parity-assessment-2026-10-05) below, which -is the reference answer to "how close are we really". The weighted feature estimate in -[`parity-estimate.md`](parity-estimate.md) (~82%) counts feature surface and overstates user-facing -readiness; treat it as an upper bound. +Forward-looking plan: milestones, the **Current focus** list, and what's next with estimates. +Where we stand today is in [`target-app-parity.md`](target-app-parity.md) (two numbers, by +dimension and area) and the ranked work list in [`gaps.md`](gaps.md); the one-page summary is +[`ROADMAP.md`](../ROADMAP.md). Measured numbers: [`scorecard.md`](scorecard.md) +(`cargo xtask scorecard`: performance budgets, corpus floors, per-area checklists, dead +preferences) and [`parity-checklist.md`](parity-checklist.md) (`cargo xtask parity`: menu wiring, +628/628, which says nothing about behaviour). + +Status legend: ✅ done · 🟡 in progress · ⬜ not started. Hours are Opus 5.5 agent wall-clock hours +for one agent working sequentially (calibration in +[`target-app-parity.md`](target-app-parity.md#remaining-effort-and-calibration)). + +**Stage: alpha** (ready for real work ~45%). Beta needs ~75% and reliable PSD: ~1,100–1,800 h. + +## Alpha gate + +The core workflows a typical Photoshop professional runs every day, checked end to end on the main +platform (macOS, 0.6.0), including saving and reopening the work. Any **no**, or a **partial** +that blocks the workflow, would make PhotoCraft pre-alpha. + +| Core workflow | Works end to end? | Evidence | Hours to pass | +|---|---|---|---:| +| Photo retouch: open JPEG/raw, heal/clone/remove, adjustment layers, export JPEG, save and reopen | yes | Spot Healing, Healing, Clone, Patch, Remove tools; 16 adjustment layers; raw for DNG/CR2/NEF/ARW/RAF; JPEG export; MCP workflow tests (`automation/tests/agent_tasks.rs`). Quality complaints (#1092, #2104) and no CR3 don't stop the workflow | 0 | +| Composite: layers, masks, selections, blend modes, smart objects; save as PSD and `.pcraft` and reopen | yes | PSD round trip ≥ 169/170, 308/309, 258/256 corpus files; `.pcraft` atomic save and crash recovery. Multi-layer Smart Object conversion (#2604) and mask targeting (#2458) are partial but have workarounds | 0 | +| Cut-out: select a subject, refine the edge, mask, export PNG with transparency | partial (not blocking) | Object/Quick Selection, Select Subject (classical), Refine Edge dialog, layer masks, PNG export. Select and Mask is a dialog, not the workspace; no Refine Hair (#2671) | 0 (depth: 80–140 h, G7) | +| Design: type, shapes, layer styles, artboards; export PNG/JPEG; save and reopen | partial (not blocking) | Point/paragraph type, shapes with stroke controls, all 10 layer effects, artboards, Export As. One macOS report of a 60 s freeze on choosing the Type tool (#2518, not reproduced); no type on a path | 0 (fix #2518: 2–6 h) | +| Paint with a pen tablet: pressure brushes, layers, save and reopen | partial (not blocking) | Pressure, tilt, rotation on macOS (AppKit monitor); every Brush Settings section. Lag with big brushes (#2619) and feel gaps ([brush-parity.md](brush-parity.md)) | 0 (feel: 100–180 h, G4) | +| Exchange PSDs with Photoshop users: open a client PSD, edit, send a PSD back | partial (not blocking for alpha; blocking for beta) | Photoshop-authored oracle 133/256 render as Photoshop does; most exports open in Photoshop (smart objects verified), but some don't (#1281, #2469) | 0 for alpha; 150–250 h for beta (G1) | + +**Result: passes.** Every core workflow completes end to end and the work saves and reopens in +PhotoCraft; the partial rows are depth and fidelity gaps with workarounds, not blockers. Ready for +real work is ~45%, inside the alpha band (~40–75%), but close to its floor. + +## Milestones | M | Status | Where we are | |---|---|---| @@ -22,209 +50,44 @@ readiness; treat it as an upper bound. | M1 Foundation | ✅ | geom, color (27 blend modes), raster (COW tiles, any depth), doc, ops, cms (ICC) | | M2 PSD v1 | ✅ | photocraft-psd: 134/135 real files byte-exact round trip | | M3 Viewer app | ✅ | egui shell (Pro / Studio / Classic themes), 13+ codecs, native and web (trunk) builds | -| M4 Native format + engine | ✅ | 500+ commands, `.pcraft` (incremental, autosave, crash recovery), CLI, persistent preferences | +| M4 Native format + engine | ✅ | ~700 commands, `.pcraft` (incremental, autosave, crash recovery), CLI, persistent preferences | | M5 GPU compositor | 🟡 | wgpu compositor drives the canvas, layer effects, vector masks, artboards, pattern fills and every clip case included (≤1/255 vs CPU); Multichannel documents fall back to the CPU | | M6 Paint + select | 🟡 | brush engine, all selection tools, multi-layer selection, snapping + smart guides, free transform + warp; stylus pressure on Windows (WM_POINTER via winit), the web (Pointer Events, with tilt/twist), macOS (AppKit event monitor: pressure, tilt, rotation, eraser end) and Linux X11 (XInput2 raw valuators); on Linux Wayland a pen opens the window through Xwayland (native tablet-v2 still open, #79) | | M7 Adjust + filters | 🟡 | 16 adjustment layers + destructive-only adjustments, 70+ filters incl. Blur Gallery, Actions record/replay, Fade | -| M8 PSD v2 | 🟡 | adjustments (incl. Selective Color, Color Lookup), fills, effects, patterns, text, shapes, smart objects, alpha channels; oracle 111/170 | +| M8 PSD v2 | 🟡 | adjustments (incl. Selective Color, Color Lookup), fills, effects, patterns, text, shapes, smart objects, alpha channels; oracle floors 146/170 (io), 236/309 (psd-tools), 133/256 (Photoshop-authored) | | M9 Text, vector, styles | 🟡 | type engine + Warp Text, shapes / pen / paths, all 10 effects on CPU and GPU (parity ≤1/255, 30/31 corpus effect files on the GPU) | | M10 Smart features | 🟡 | classical Select Subject / Object, content-aware fill and scale, healing, auto-align / auto-blend; ML backend not started | | M11 Automation + formats | 🟡 | MCP (headless + live bridge), batch, Image Processor, prefs over MCP; DoD test passes (10 agent tasks over MCP, `automation/tests/agent_tasks.rs`); since 2026-10-07 the CLI rejects unknown flags and answers ` --help`, batch runs report same-name outputs instead of overwriting them, MCP `command_batch` steps can start background jobs, and headless saves never flatten over the opened file; JP2 / DICOM / DPX / C2PA pending | -| M12 Pro parity | 🟡 | CMYK / Lab / Indexed / Bitmap / Duotone, ICC + soft proofing, channels + Quick Mask, smart-object stack modes, artboards, layer comps; print, HDR, photomerge, timeline pending | +| M12 Pro parity | 🟡 | CMYK / Lab / Indexed / Bitmap / Duotone, ICC + soft proofing, channels + Quick Mask, smart-object stack modes, artboards, layer comps; print, HDR, timeline pending; Photomerge is implemented, with real-world quality validation still open | -**Menu parity: 532 / 625 (85.1%)** on 2026-10-01, up from 224 (35.8%) the day before. See [`parity.md`](parity.md). +Releases: 0.1.0 (2026-10-02), 0.2.0 (2026-10-05, first signed and notarized), 0.3.0 (10-07), +0.5.0 (10-08), 0.6.0 (10-10). -2026-10-08: Affinity `.af`, `.afdesign`, `.afphoto` and `.afpub` documents (container versions -8–12) open **natively** ([format notes](../crates/affinity/README.md)): pages and artboards, layers -and groups, curves and geometric shapes with fills, gradients and strokes, text as type layers, -placed images as smart objects, pixel layers and masks, as an 8-bit RGB document without a source -save path. Measured against the thumbnail Affinity embeds in each file, 21 pinned public documents -(`cargo xtask corpus --affinity`) differ by 0–4.6 of 255 on average. Layer effects, adjustments, -live filters, brush strokes, special shapes, master pages and CMYK/Lab/16-bit document colour are -approximated or left out, each with a warning; damaged or unknown files fall back to the embedded -preview. Affinity writing is not implemented: no Affinity installation was available to check -written files, so `.af` export stays unsupported. +## Current focus -2026-10-09: `corpus/affinity/` now has 39 pinned documents and 21 PNGs (20 rendered references and -one bitmap-fill texture): the prior 21 public documents plus 18 CC0 samples for #1606. The new -samples compare against their exported PNGs; -mean differences range from 0.44/255 (conical gradients) to 86.05/255 (RGB/32 reduced to 8-bit). -Clouds, hearts, cogs, callouts, arrows, double stars, tears, crescents, diamonds and circular segments -now import as parametric shapes; the special-shapes sample measures 5.68/255 against its PNG. The -remaining gaps in #1606 stay tracked with per-sample corpus ceilings. +Ranked; each item links to its gap entry. Pick from the top unless an issue is assigned to you. -## Honest parity assessment (2026-10-05) +1. **PSD that Photoshop trusts** ([G1](gaps.md#g1-psd-fidelity-and-photoshop-acceptance)): + fix Photoshop refusing our files (#1281, #2469; text-layer case #2374 closed); add a + Photoshop re-open check (FILE-216-4); Photoshop-oracle floor 133/256 → 200+. 150–250 h. +2. **Performance budgets** ([G2](gaps.md#g2-interactive-performance)): the 22 over-budget + scenarios, the P12 GPU crash at 150 layers, brush and cursor lag (#2619, #2566). 200–350 h. +3. **User bug backlog** ([G3](gaps.md#g3-user-reported-bug-backlog)): 579 open issues, most from + 0.5/0.6 users; triage weekly, fix regressions first. 250–400 h. +4. **Brush and transform feel** ([G4](gaps.md#g4-brush-feel), [G5](gaps.md#g5-transform-handles-and-modifiers)): + [`brush-parity.md`](brush-parity.md) and [`ui-parity.md`](ui-parity.md) rows marked partial + or missing. 150–250 h. +5. **Missing tools** ([G6](gaps.md#g6-missing-tools)): 15 of Photoshop's 68. 60–100 h. -This is the reference answer to "how close are we to Photoshop parity, really". Agents: read it -before picking work. Update it (with dated measurements) when the numbers move; don't restate the -menu-parity number in its place. +## What's next, with estimates -**Measured numbers live in the [scorecard](scorecard.md)** (`cargo xtask scorecard`, kept current -by CI): performance scenarios against their budgets, corpus floors, per-area checklists with -done / partial / missing counts, and the number of settings that do nothing. Where the scorecard -and the estimates below disagree, the scorecard's numbers supersede them. As of 2026-10-05 it -counts 67 of 129 preferences that nothing reads; 3 of the 25 budgeted performance scenarios meet -their #209–#211 targets (first baseline, taken on a heavily loaded machine), and the 150-layer -nudge scenario crashes the GPU compositor; the open area issues' checklists (#203–#220) are -almost entirely missing or partial. - -2026-10-07: UI Font Size now applies to interface text, including CJK fallbacks, independently -of display and canvas zoom (#532). The current preference audit drops from 59 to 58 unread -settings out of 135; see the regenerated scorecard. - -2026-10-09: Output-preserving Radial Blur direct sampling measured on a local -i7-9750H, 12 workers, 24 MP RGBA8, Good quality, amount 1: Spin 66.10 → 16.43 s -(4.02×), Zoom 19.19 → 5.35 s (3.59×), one paired run each, exact output equality. -These are local measurements at amount 1; see [method and limits](radial-blur-performance.md). - -2026-10-10: native selection distance transforms measured 7.59–10.57x faster on six -24–36 MP synthetic masks on an AWS c7i.4xlarge (16 workers, three paired release runs). -The transform also corrects f32 envelope errors past coordinate 4096, including nonzero -distances at selected pixels. The parallel path needs an additional four bytes per pixel; -see [measurements, correctness and limits](selection-distance-performance.md). - -**Bottom line.** Two days after 0.2.0 we had merged ~96 PRs and closed ~48 issues, but **real -Photoshop parity is still well below 50%**. The biggest gaps are AI, missing tools, professional -workflow depth and the plug-in ecosystem. Most fixes since 0.2.0 have passed our tests but have -**not yet been validated by users**, and this week proved our tests miss what users hit: -on first public use they found broken basics (text selection offset, 214 shortcut failures, panels -resizing themselves, an immovable crop frame, folders that wouldn't collapse, lag on layout PSDs) -that all counted as "live" in `parity.md`. - -**Overall.** Feature surface: roughly **60–70%** of what a typical Photoshop user touches exists in -some form. "A professional could switch for daily work": roughly **25–35%**. True 1:1 parity is -**many months** of focused work, and some areas need product decisions rather than effort. -Confidence: moderate — the next users of 0.2.x will move these numbers either way. - -### By dimension - -2026-10-10: [Shape stroke controls](shape-strokes.md) expose existing Solid/Dashed/Dotted, -custom dash/gap, offset, caps, joins, alignment, miter-limit and opacity capabilities in -Shape/Pen options and Properties. Properties stages a cached canvas preview before one -undoable edit. Gradient fill controls from #1053 remain open; the Line tool still draws a -filled bar. This extends UI reachability and does not claim Photoshop-authored stroke parity. - -| Dimension | Measured / evidence (2026-10-05) | Grade | Notes | -|---|---|---|---| -| Menu wiring | 626/626 menu items dispatch a command (`parity.md`) | high but shallow | Says nothing about behaviour. | -| PSD fidelity (rendering) | Corpus oracle 115/170 (68%): 30 differ, 26 have no usable reference, 1 import error. 2026-10-07: io corpus 146/170, psd-tools corpus 236/309 (was 229: Advanced Blending knockouts), Photoshop oracles 132/258 | medium | Push to 170/170 under way (effects/strokes, multi-instance effects, 16/32-bit and colour modes, references for skipped files). | -| PSD round trip | 169/169 re-import identically; every adjustment layer and blend mode round-trips | high (within corpus) | Floors in `crates/io/tests/corpus.rs`; raise, never lower. Corpora: `cargo xtask corpus --all` (ours: https://github.com/storytold/photocraft-corpus). | -| OpenRaster read/write | 2026-10-09: 34 Krita 5.2.9-authored `.ora` files (27 blend modes, groups, pass-through, offsets, opacity, visibility, masks, 16-bit, gray) render within 3/255 of Krita's merged image (30) or differ for known reasons (4: Krita's lighter/darker-colour tie-break, Hard Mix at exactly 1, a linear-light 16-bit document); Krita re-opens our exports and renders them identically to its originals (33/34; Soft Light is written as `svg:soft-light`) | medium (one authoring app) | MyPaint and GIMP files untested. Masks are applied to pixels and layer styles dropped on save (reported). See [OpenRaster](ora.md). | -| Paint.NET import | 2026-10-09: 14/14 local PDN3 textures preserve editable layers exactly through `.pcraft` and render within 2.142/255 of full-size previews; a supplied 5.x document (1280×720, 11 layers) passes editing/history/native saves and matches its thumbnail within 0.418/255 mean; all 14 blend modes exercised synthetically | medium (limited corpus) | Import only; metadata omitted. More current-version files and full-size exports needed. See [PDN support](pdn.md). | -| Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. | -| Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. | -| UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418). 2026-10-09: canvas zoom is physical at any display scale — 100% is one document pixel per physical display pixel, and every screen-space overlay, cursor, scrollbar, navigator and fit follows it (#1943) | low–medium | Needs recurring visual QA with realistic documents. | -| Tools | 2026-10-09: Pencil, Mixer Brush, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush, Freeform/Curvature Pen, Add/Delete Anchor Point tools, single row/column marquee, Color Sampler, Perspective Crop, type masks and Frame | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | -| Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Native Wayland pen input open (#79; a pen opens the window through Xwayland meanwhile); X11 pressure confirmed by a user, macOS not yet verified on tablet hardware. | -| Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. | -| Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. | -| Performance | 14k+ px on the GPU at ~⅓ the memory; adjustment preview 285 ms → 4–9 ms; font-size edits 297 ms → 4.6 ms; 2026-10-07: 30 MP TIFF open (banded, parallel strip/tile decode) Deflate 345 → 32 ms, LZW 428 → 43 ms, BigTIFF and every IFD readable; 2026-10-09: 4.2 MP Indexed Color, 256 colours, full-resolution CPU preview p50 3018 → 1061 ms (Ryzen AI 7 350, three measured runs; GPU upload excluded) | medium-high on rasters | Complex layout documents still laggy (#125/#128); >16384 px GPU tiling in progress (#49). Native Indexed Color previews run on one background worker with stale-result rejection; large palettes can still take about a second to compute. Web previews remain synchronous. | -| Stability | Never-crash lint series, crash guard, `panic_hunt` fuzzing in the gate | medium-high | No field crash data yet. | -| Camera RAW | DNG, CR2, Sony ARW (lossless + compressed), Nikon NEF (lossless + lossy compressed), RW2, uncompressed ORF; 2026-10-09: uncompressed Fujifilm RAF, Bayer and X-Trans (edge-directed X-Trans demosaic; 26 MP X-Trans develop 306 ms on 12 cores) | medium | CR3, compressed RAF, Nikon "lossy after split" and calibrated colour for non-DNG cameras (no Fuji colour calibration: neutral fallback) still open (#50). | -| AI / generative | none | ~0% | Deferred by decision (#41). | -| Ecosystem | Sandboxed WebAssembly plug-ins instead of .8BF; no ExtendScript/UXP/.atn; no Adobe Fonts/Libraries/cloud docs | low | By design for 8BF; scripting compatibility open. | -| Platforms | macOS (notarized), Windows, Linux (AppImage/deb/rpm/Flatpak bundle), web | medium-high | Flathub later (#173); Windows signing material pending. | -| Localisation | 2026-10-07: 10 UI languages; menu, `tl!`, blend mode, preference and brush-section coverage enforced by tests; live switching and scoped Preferences previews | medium | Engine errors/status messages still partly English; CJK web fonts, browser-locale detection, and RTL remain open. | - -2026-10-08: Motion Blur adds adaptive FFT convolution for wide streaks while retaining the -row kernel merged in [#902](https://github.com/storytold/photocraft/pull/902) for shorter -streaks and unsupported cases. Native release medians of three paired runs on an Intel -i7-9750H with 12 Rayon workers, synthetic RGBA8 at 30° / distance 1000: 24 MP -22.594 → 9.785 s (2.31×), and 1.5 MP -2.455 → 0.592 s (4.15×). -Full-image comparisons differ by at most one U8 code level; distance 64/256 retain the -byte-identical row output. These measure decode/halo/filter/write/prune, excluding UI proxy -creation, compositing and upload; the UI remains synchronous. FFT work is scheduled within -a conservative 512 MiB working-set estimate, excluding stored Surface tiles and row fallback -memory. The crossover is a heuristic; images dominated by near-cutoff alpha can need extra -scalar work. These measurements do not establish Photoshop filter parity. - -2026-10-10: Proximity Match search medians on an AWS c7i.4xlarge improved 1.54–3.82x -for four synthetic stroke regions (50–300 px holes), with identical source displacements. -Bounding the mask table to the hole and pruning losing SSD candidates reduced table storage -by about 90–93 percent there. Full 24–36 MP kernel inputs improved 4.60–6.24x, but normal -engine strokes already use a cropped region. See [method and limits](proximity-match-performance.md). - -2026-10-09: the Remove tool is in the J flyout (`paint.remove`, a background job). A stroke -around an object also removes what it encloses. The fill is non-local patch completion with -texture features (A. Newson et al., IPOL 2017: `crates/algo/src/nonlocal.rs`), finished by a -best-patch copy and gradient-domain seam hiding. On 20 holes in public-domain photos -(`remove_quality`, `docs/development.md` › Remove Tool quality) the fill keeps 0.89 of the -original's texture (Wexler/PatchMatch completion as Content-Aware Fill uses it: 0.54). On a 24 MP -document a 100 px ring takes 120 ms and a 1000 px ring 2.5 s (`remove_bench`). It is not -generative: large objects over complex structure fill less convincingly than Photoshop's AI mode -(#41). - -2026-10-08: the tools update above is checked against the current toolbar groups in -[`panels.rs`](../crates/ui-egui/src/panels.rs), the Pencil and tool-cycle tests in -[`pencil_tests.rs`](../crates/ui-egui/src/pencil_tests.rs), Patch's live-preview test in -[`patch_preview.rs`](../crates/ui-egui/src/patch_preview.rs), and Vertical Type's point/paragraph -creation and undo test in [`type_tool_tests.rs`](../crates/ui-egui/src/type_tool_tests.rs). -These implementations remove the tools from the missing list; they do not establish Photoshop -behavioural parity. The historical estimates and corpus measurements in this assessment are -unchanged; current measured floors remain in the scorecard. - -2026-10-07: Camera Raw PSD mapping covers relative custom white balance, Light/Presence, -parametric and four point curves, HSL, Color Grading, sharpening/noise detail, grain and numeric -post-crop vignette controls. Two revisions of one supplied Photoshop ACR 18.4 PSD preserve their -filter descriptors without edits. The updated revision stays opaque because of active manual -Optics and unverified vignette style; a descriptor projection verifies its supported controls. -Synthetic 8/16/32-bit PSD → edit → `.pcraft` → PSD tests preserve the filter stack, mask and -undo/redo. Unmapped Camera Raw fields/versions remain opaque; Photoshop -acceptance of generated exports and pixel parity are still unverified. Corpus floors above are -unchanged. - -2026-10-10: Select and Mask's Shift Edge now computes the exact octagonal footprint -with a square window extreme plus a dyadically built Manhattan ball. On a c7i.4xlarge -(16 vCPUs), full tiled CPU refinement at radius 64 / Shift Edge +100% takes -3.019 s -> 0.762 s at 24 MP (3.96x), -3.706 s -> 0.921 s at 36 MP (4.02x). -Three alternating paired release runs include guide sampling, guided filtering, -boundary distances, quantization and assembly. Every output mask matches exactly. -The dense fractional 24 MP radius-64 dilation kernel improves 36.00x; -circular soft-mask kernels improve 20.76-172.16x at 24-36 MP, radii 4-64. -Bounded halo tiles reduce the large-mask workspace allocation bound from 192/288 MB -to about 115/163 MB, including output and 16 workers' scratch, excluding the source. -See [method and results](octagonal-mask-performance.md). - -### Where we're going (priority order) - -1. **Ship the fixes:** cut 0.2.1 once the current batch lands, so users validate them. -2. **PSD fidelity to 170/170** with round trips, plus the Photoshop-authored reference set for smart - filters, the text engine and effect shapes (https://github.com/storytold/photocraft-corpus, fetched by - `cargo xtask corpus --all`); enforce floors in `corpus.rs`. -3. **Workflow acceptance tests:** 30–50 real tasks (retouch a portrait, social post with text and - effects, composite with masks and adjustment layers, CMYK print prep…) scripted end to end and - checked against Photoshop's output on every build. Their pass rate becomes the headline parity - number. -4. **Tool coverage and workflow depth:** Pen variants (Direct Selection landed, #790) and - Perspective Crop; finish Content-Aware Move's Transform On Drop and live result preview. - Pencil, Mixer Brush, Patch, Vertical Type, Pattern Stamp and Rotate View are already available on the toolbar. -5. **Complex-document performance** (#125/#128) and GPU tiling beyond the texture limit (#49). -6. **Recurring visual QA** (`cargo run -p photocraft-engine --example designer_psd`) and fast - turnaround on user reports (OS, document size, layer count, screenshot). -7. Later / needs decisions: generative AI backend (#41), scripting compatibility (ExtendScript / - UXP / .atn), Flathub (#173), Wayland pen pressure (#79). - -## Current focus (infrastructure before the long tail) - -Landed on 2026-10-01: -- multi-layer selection, live smart objects + smart filters, alpha channels + Quick Mask; -- patterns, Warp, preferences, snapping, ~33 filters, the remaining core adjustments; -- Layer Comps and Artboards, GPU layer effects, Liquify / Puppet Warp / Perspective Warp; -- the PSD fidelity pass (oracle 102 → 111), the M11 MCP acceptance test, and the release pipeline - (`docs/releasing.md`). - -Next: -1. **Follow "Where we're going" in the assessment above.** 0.2.0 shipped signed and notarized on - 2026-10-05 (`docs/releasing.md`); Windows code-signing material still needs to be obtained. -2. **Fidelity**: the PSD oracle (113/170). Modern Brightness/Contrast and grayscale Levels - curves; chisel-soft / stroke-emboss bevel shapes; Photoshop's 8-bit blend rounding; non-Normal - modes in Lab documents; smart-filter maths against Photoshop's (re-render oracle 5/30). -3. **GPU**: only Multichannel documents and regions over the texture limit fall back to the CPU. -4. **Vanishing Point, Camera Raw / Lens Correction, Face-Aware Liquify** (needs a landmark model). -5. **Panels**: Patterns, Styles, Glyphs, Character/Paragraph Styles, Timeline; Custom Shape tool. -6. Print, Photomerge, Merge to HDR, video layers. +| Next milestone | Scope | Estimate | +|---|---|---:| +| 0.7 "files you can trust" | Current focus 1 plus JPEG Options, AVIF read, Photoshop PDF read | 200–320 h | +| 0.8 "fast enough" | Current focus 2; GPU tiling past the texture limit (#49); complex layout documents | 200–350 h | +| 0.9 "feels like Photoshop" | Current focus 4 and 5; transform context menu; floating panels; Color panel | 300–500 h | +| Beta | All of the above, type depth, Select and Mask / Content-Aware Fill workspaces, Hindi/Arabic/Vietnamese + RTL, bug backlog under 100 | 1,100–1,800 h total | +| Later, needs owner decisions | AI backend (#41), plug-in hosting (.8BF / UXP), scripting compatibility | 550–1,050 h | ## Milestone definitions @@ -244,4 +107,14 @@ Each milestone has a **definition of done (DoD)** and must leave `main` green on | **M9** | Text, vector, styles | parley text layers, shapes/pen/paths, all 10 layer effects on GPU | Visual goldens; PSD text round-trip | | **M10** | Smart features | `ml` (ort native / ort-web on the web), Select Subject/Object/Sky, Remove BG, Remove tool, content-aware fill, healing, AI denoise, RAW develop | Quality benchmarks on a public dataset; timing budgets | | **M11** | Automation + formats | MCP server, batch, scripting, remaining formats (JP2, DICOM, DPX…), C2PA | An agent completes 10 scripted edit tasks via MCP | -| **M12** | Pro parity | CMYK/Lab UI, print, HDR display, photomerge/HDR merge, timeline, layer comps, artboards, symmetry, neural filters | `xtask parity` ≥ 90% of Photoshop menu checklist | +| **M12** | Pro parity | CMYK/Lab UI, print, HDR display/merge, Photomerge quality validation, timeline, layer comps, artboards, symmetry, neural filters | `xtask parity` ≥ 90% of Photoshop menu checklist | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Photomerge is implemented; real-world quality validation remains open | +| 2026-10-10 | minor | Alpha gate table (core-workflow gate from the progress-docs standard): passes, stage stays alpha | +| 2026-10-10 | major | Progress-docs standard: honest assessment and dated measurements moved to `target-app-parity.md`, Affinity notes to `file-format-parity.md`; new Current focus and estimates; `parity.md` renamed `parity-checklist.md` | +| 2026-10-08 | minor | Tools, Affinity import and performance measurements added | +| 2026-10-05 | major | Honest parity assessment added | diff --git a/docs/scorecard.md b/docs/scorecard.md index f9b7015120..6c7f540004 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,12 +7,12 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 15 not run, 7 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 5 | 11 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | -| [Tools](#tools) | 20 | 15 | 6 | | -| [Workspace and UI](#workspace-and-ui) | 5 | 7 | 11 | settings that do nothing: 46 of 149 (target 0) | +| [File compatibility](#file-compatibility) | 5 | 11 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | +| [Tools](#tools) | 21 | 16 | 5 | | +| [Workspace and UI](#workspace-and-ui) | 6 | 7 | 10 | settings that do nothing: 43 of 150 (target 0) | | [Type](#type) | 0 | 3 | 6 | | -| [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) | -| [Reliability](#reliability) | 5 | 0 | 2 | never-crash lints on 28 of 28 crates; panic_hunt present | +| [Automation](#automation) | 3 | 2 | 6 | 630 of 630 menu items live (100.0%) | +| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 4 | 4 | 3 | | | [Open bugs](#open-bugs) | 22 | 2 | 8 | | @@ -93,7 +93,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | Corpus | Directory | Files | Oracle pass floor | Round-trip floor | |---|---|---:|---:|---:| | io corpus | `corpus/psd` | 170 | 146 | 169 | -| psd-tools corpus | `corpus/psd-tools` | 309 | 236 | 307 | +| psd-tools corpus | `corpus/psd-tools` | 309 | 236 | 308 | | photoshop oracles | `corpus/photoshop` | 256 | 133 | 258 | | Id | Target | Status | Issue | Note | @@ -148,12 +148,12 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Opens the generic select.refineEdge dialog (menus.rs:308) | | TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas.rs marquee_corners: Shift square or circle, Alt from the centre, live W x H readout beside the cursor (marquee_readout); marquee_tests.rs | | TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Fuzziness-0 tests cover midtones/shadows only (selection_cmds.rs:444-447) | -| TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | missing | [#214](https://github.com/storytold/photocraft/issues/214) | move_mods.rs covers Shift/Alt/arrows only | +| TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline done (Preferences › Tools › Show Highlight on Rollover, #2834); box-select layers, double-click fit and drag to a tab open | | TOOL-214-5 | Right-click menu during Free Transform (scale, rotate, skew, warp, flip, rotate 90/180) | missing | [#214](https://github.com/storytold/photocraft/issues/214) | No context menu in canvas.rs or transform_tool.rs | | TOOL-214-6 | Puppet Warp mesh follows the object outline (constrained triangulation) | missing | [#214](https://github.com/storytold/photocraft/issues/214) | algo/puppet.rs:136 builds a block-grid mesh | | TOOL-214-7 | Content-Aware Scale through the Free Transform box with a live proxy preview | partial | [#214](https://github.com/storytold/photocraft/issues/214) | edit.contentAwareScale (edit_menu_cmds.rs:899) behind a parameter dialog | | TOOL-214-8 | A measured ruler angle prefills Image Rotation > Arbitrary (0.01 deg) | missing | [#214](https://github.com/storytold/photocraft/issues/214) | Arbitrary rotation is a generic dialog; ruler only feeds straighten | -| TOOL-214-9 | Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Ratio dropdown and fields, Straighten (TOOL-214-21); no W x H x Resolution with units or saved presets | +| TOOL-214-9 | Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Ratio dropdown and fields, Straighten (TOOL-214-21); W x H x Resolution with units, built-in size presets and Front Image (crop_size.rs, image.crop targetWidth/targetHeight/resolution, #2443); no New/Delete Crop Preset | | TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); no Shift 45 deg, Backspace or Alt while drawing | | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | partial | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke brush, pencil, eraser (vector_cmds.rs:1170); brush-only button, no dialog | | TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.convertToShape with tolerance (vector_cmds.rs:1256); radius as a field only | @@ -165,18 +165,19 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-17 | Rotate View tool: drag to turn the view, Reset View, Match Rotation copies the angle | done | [#214](https://github.com/storytold/photocraft/issues/214) | Tool::RotateView in the Hand flyout; view.rotateView / view.resetView; Match Rotation copies views[].rotation; enhanced_controls.rotate_view_with_trackpad is read | | TOOL-1649-1 | Eyedropper Sample Size (Point, 3×3 … 101×101 Average), Sample layers, Show Sampling Ring | done | [#1649](https://github.com/storytold/photocraft/issues/1649) | document.sampleColor (sample_cmds.rs): average of the square clipped to the canvas, in the document's model and depth; eyedropper_ui.rs options bar; shared with Alt-click, dialog eyedroppers and the Info panel; ui.set eyedropperSampleSize/eyedropperSample/eyedropperRing. Color Sampler tool still missing | | TOOL-214-20 | Crop overlay options: Rule of Thirds, Grid, Diagonal, Triangle, Golden Ratio, Golden Spiral; Auto / Always / Never Show Overlay; O cycles the overlay, Shift+O its orientation | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_overlay.rs: unit-square guides shared by upright and turned frames (canvas.rs), options-bar menu, O / Shift+O ahead of the tool keys in shortcuts.rs; ToolOptions crop_overlay / crop_overlay_show / crop_overlay_orientation, ui.set cropOverlay / cropOverlayShow / cropOverlayOrientation | -| TOOL-214-18 | Crop: drag outside the frame to rotate it (Shift 15 deg steps, angle readout); Enter rotates and crops in one step | done | [#1792](https://github.com/storytold/photocraft/issues/1792) | image.crop angle param (image_cmds.rs: Image Rotation > Arbitrary + crop, one undo step); crop_ui.rs turns, moves and resizes the frame along its axes (Classic Mode visuals); UiState.crop_angle | +| TOOL-214-18 | Crop: drag outside the frame to rotate it (Shift 15 deg steps, angle readout); Enter rotates and crops in one step | done | [#1792](https://github.com/storytold/photocraft/issues/1792) | image.crop angle param (image_cmds.rs: Image Rotation > Arbitrary + crop, one undo step); crop_ui.rs turns, moves and resizes the frame along its axes (Classic Mode; default mode turns the image instead, TOOL-214-23); UiState.crop_angle | | TOOL-214-19 | Crop: arrow keys nudge the crop box (Shift 10 px), X swaps portrait/landscape, W x H readout while drawing or resizing | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_ui.rs keys/nudge/swap_orientation (ahead of the selection nudge and tools.swapColors in shortcuts.rs; X also swaps the options-bar ratio) and sizing (canvas.rs draws it with the marquee readout); turned frames included | | TOOL-214-21 | Crop: Straighten button in the options bar and Cmd/Ctrl-drag draw a line; the frame turns so the line ends up level (or plumb when nearer vertical), with an angle readout | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_straighten.rs: one-shot options-bar button, Cmd/Ctrl held is a temporary Straighten (ui.pointer command flag); the Ruler's straighten_angle convention; the frame is fitted to the largest of its proportions inside the image (Classic Mode); a pending edit committed by Enter | -| TOOL-214-22 | Crop gear menu: Show Cropped Area (H), Enable Crop Shield with Match Canvas / custom colour, opacity and Auto Adjust Opacity | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_shield.rs: fill() resolves the shield (canvas.rs paints it around upright and turned frames), gear popup in the options bar, H ahead of the Hand key in shortcuts.rs, Custom… opens the Color Picker; ToolOptions crop_shield, ui.set cropShield. No Classic Mode / Auto Center Preview (PhotoCraft is always Classic Mode) | +| TOOL-214-22 | Crop gear menu: Show Cropped Area (H), Enable Crop Shield with Match Canvas / custom colour, opacity and Auto Adjust Opacity | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_shield.rs: fill() resolves the shield (canvas.rs paints it around upright and turned frames), gear popup in the options bar, H ahead of the Hand key in shortcuts.rs, Custom… opens the Color Picker; ToolOptions crop_shield, ui.set cropShield. Use Classic Mode / Auto Center Preview: TOOL-214-23 | +| TOOL-214-23 | Crop: Photoshop's default mode (the image turns and moves behind an upright box, Auto Center Preview keeps a resized box's centre in place and centres a drawn one) and Use Classic Mode (P) in the gear menu | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_mode.rs: the crop model (crop_rect + crop_angle) and image.crop are shared by both modes; default mode sets the view's rotation to -crop_angle while the tool shows a frame (picking the tool, in either mode, turns a Rotate View turn upright about the view centre, not restored after Enter/Esc or by P; Classic Mode keeps the camera upright with the frame drawn turned; as Photoshop 25.1), pivoting so the box stays put, moves view and frame together for drags inside and arrow keys, keeps a resized box's centre in place live (Auto Center Preview; a drawn box is centred on release, a moved one never), readout = the image's turn; gear menu order and Classic Mode greying as Photoshop 25.1; CropShield classic_mode / auto_center_preview (ui.set cropShield), P ahead of the Pen key | | TOOL-1053-1 | Shape stroke controls: solid/dashed/dotted, custom dash/gap/offset, caps, joins, alignment, miter limit and opacity | done | [#1053](https://github.com/storytold/photocraft/issues/1053) | shape_stroke_ui.rs: shared staged editor, renderer samples, cached Properties canvas preview; existing shape.create/edit/info; serde tool defaults, ui.set shapeStroke; pcraft and vstk style round trips. Gradient fill UI and saved stroke libraries remain open; Line retains filled-bar geometry | | TOOL-213-19 | Remove tool: paint over an object (or circle it) and it is filled from its surroundings | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Missing: generative AI mode, Remove after each stroke off, live preview of the result | ## Workspace and UI -**Settings that do nothing: 46 of 149** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 43 of 150** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.beep_when_done`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `file_handling.image_previews`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.text_engine`, `type_.font_preview`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.beep_when_done`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `file_handling.image_previews`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.text_engine`, `type_.font_preview`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| @@ -198,7 +199,7 @@ Unread: `general.color_picker`, `general.beep_when_done`, `general.resize_image_ | UI-217-16 | Hue/Saturation: Saturation and Lightness sliders get hue-following colour tracks | done | [#217](https://github.com/storytold/photocraft/issues/217) | saturation_track/lightness_track (adjust_editors.rs): grey -> the hue and black -> the hue -> white, recolouring as the Hue slider moves; saturation_and_lightness_tracks_follow_the_hue | | UI-217-17 | Color panel: wheel plus RGB/HSB/CMYK/Lab/Gray/Web slider modes; resizes with its group | missing | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs:1837 single field + hex/RGB readout | | UI-217-18 | Alt+wheel zooms in gentle ~5 % steps; zoom controls uncramped | missing | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs:1107-1125 no Alt handling | -| UI-217-19 | Shortcut hints rendered per platform; Reveal label per OS | missing | [#217](https://github.com/storytold/photocraft/issues/217) | ~33 hard-coded modifier glyph strings in ui-egui; menu_catalog.rs:341 one label | +| UI-217-19 | Shortcut hints rendered per platform; Reveal label per OS | done | [#217](https://github.com/storytold/photocraft/issues/217) | hints and tooltips go through shortcuts::pretty (6266b7a9, #262; pretty_uses_the_platform_notation); menus::reveal_label names Reveal in Finder per OS (Show in Explorer / Show in Folder) in the Smart Objects menu and the tab menu; reveal_label_names_the_platform_file_manager + smart_objects_reveal_item_names_the_platform_file_manager | | UI-217-20 | 'Show rulers in new documents' preference; guides stored as f64 | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No rulers pref; doc lib.rs:523 guides are Vec | | UI-217-21 | One Preset Manager for every preset kind with folders, search, reorder, load/save sets | partial | [#217](https://github.com/storytold/photocraft/issues/217) | edit_menu_cmds.rs:714 covers brushes, custom shapes, patterns with reorder | | UI-217-22 | Stamp Visible into a new layer keeping originals; Alt+Merge keeps originals | missing | [#217](https://github.com/storytold/photocraft/issues/217) | Only layer.mergeVisible (edit_cmds.rs:416) | @@ -220,11 +221,11 @@ Unread: `general.color_picker`, `general.beep_when_done`, `general.resize_image_ ## Automation -Menu wiring (`docs/parity.md`): 627 of 627 menu items live (100.0%). This counts dispatch, not behaviour. +Menu wiring (`docs/parity-checklist.md`): 630 of 630 menu items live (100.0%). This counts dispatch, not behaviour. | Id | Target | Status | Issue | Note | |---|---|---|---|---| -| AUTO-1 | Every menu item dispatches a command id (UI, CLI, control channel and MCP share them) | done | | docs/parity.md: all menu items live; menus.rs is_live | +| AUTO-1 | Every menu item dispatches a command id (UI, CLI, control channel and MCP share them) | done | | docs/parity-checklist.md: all menu items live; menus.rs is_live | | AUTO-219-1 | Record actions in a document-independent form (implicit layer, merged type keystrokes) | missing | [#219](https://github.com/storytold/photocraft/issues/219) | actions.rs:47 copies raw journal entries with explicit layer ids | | AUTO-219-2 | Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import | missing | [#219](https://github.com/storytold/photocraft/issues/219) | Panel deletes whole actions only (actions.rs:146) | | AUTO-219-3 | actions.record/stop/play/list commands for agents and the CLI | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions.list/get/record/stop/play/delete in actions_cmds.rs; the panel, CLI and MCP share them. Nested steps pass the session authorize hook. | @@ -246,9 +247,9 @@ Never-crash attribute on 28 of 28 crate roots. | REL-2 | Every workspace crate carries the never-crash deny(clippy::unwrap_used, ...) attribute | done | | Counted automatically above (crate roots under crates/ and apps/); testkit gained it in #221 | | REL-3 | The app catches escaped panics around command dispatch and import/export | done | | a_panic_in_a_command_... runs the test-only `test.panic` spec through Session::execute and asserts Err plus an unchanged document (engine jobs_tests.rs); the worker guard is covered by a_panic_in_the_worker_...; crash_guard.rs guard_turns_a_panic_into_an_error covers import/export (apps/photocraft) | | REL-4 | Atomic save (temp, fsync, rename) for every document write | done | [#203](https://github.com/storytold/photocraft/issues/203) | photocraft_format::atomic_write (format/src/atomic.rs, #230): same-dir temp, sync_all, rename with Windows PermissionDenied retry, Unix dir fsync; every document write site routes through it and format/src/atomic/tests.rs failure-injects write, rename and read-only-dir errors | -| REL-212-1 | Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error | missing | [#212](https://github.com/storytold/photocraft/issues/212) | try_reserve only in io/flat.rs:221; codec max_alloc limits cover decode only | +| REL-212-1 | Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; save/import boundaries preserve existing state, but encoder/decoder internals still need a broader fallible-allocation audit | | REL-212-2 | Export switches to PSB when the estimated file size exceeds 2 GB | done | [#212](https://github.com/storytold/photocraft/issues/212) | PSD export uses checked size estimates for raster, masks, channels, and retained metadata | -| REL-212-3 | Tests: an allocation-limit injection makes each command Err with the document unchanged | missing | [#212](https://github.com/storytold/photocraft/issues/212) | Only codec/raw max_alloc tests (codecs/tests/limits.rs:48) | +| REL-212-3 | Tests: an allocation-limit injection makes each command Err with the document unchanged | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Engine failpoint tests cover paste, erase, fill, transform, import and save transaction boundaries; codec-level allocator failure coverage remains open | ## Distribution @@ -276,7 +277,7 @@ Never-crash attribute on 28 of 28 crate roots. | BUG-203-4 | Unwritable directory errors clearly, never falls back to writing in place | done | [#203](https://github.com/storytold/photocraft/issues/203) | atomic.rs maps read-only dir, disk-full and cross-device rename errors to messages that say the original was not changed; there is no in-place fallback path | | BUG-203-5 | On any failure the original file is untouched and the temp file removed | done | [#203](https://github.com/storytold/photocraft/issues/203) | atomic.rs removes the temp file on every failure; tests assert the original bytes survive write and rename failures | | BUG-203-6 | Failure-injection tests: mid-write, rename and read-only dir errors keep original bytes | done | [#203](https://github.com/storytold/photocraft/issues/203) | format/src/atomic/tests.rs: Faulty AtomicFs injects mid-write StorageFull, rename failure and a read-only dir; locked-target retries, symlink and file-mode preservation covered too | -| BUG-204-1 | Interface prefs (UI scale, font size, channels in colour, sliders, tooltips) wired or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | prefs_ui.rs tick reads ui_scale (sync_display_scale), ui_font_size (theme::set_ui_font_size) and show_tooltips (sync_tooltips); show_channels_in_color is wired in channel_thumbs; dynamic_color_sliders is in prefs::HIDDEN_UNTIL_IMPLEMENTED | +| BUG-204-1 | Interface prefs (UI scale, font size, channels in colour, sliders, tooltips) wired or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | prefs_ui.rs tick reads ui_scale (sync_display_scale), ui_font_size (theme::set_ui_font_size) and show_tooltips (sync_tooltips); show_channels_in_color is wired in channel_thumbs and the canvas channel view (#2385); dynamic_color_sliders is in prefs::HIDDEN_UNTIL_IMPLEMENTED | | BUG-204-2 | workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | workspace.large_tabs controls document tab height in canvas.rs; auto_show_home_screen drives the Home screen; animated_zoom is in prefs::HIDDEN_UNTIL_IMPLEMENTED | | BUG-204-3 | RAW prefs and ask_before_saving_layered_tiff read, or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults.open_in_camera_raw drives the Camera Raw dialog (camera_raw_ui.rs); the other raw_defaults fields are in prefs::HIDDEN_UNTIL_IMPLEMENTED | | BUG-204-4 | Recent files persisted through prefs across restarts | done | [#204](https://github.com/storytold/photocraft/issues/204) | crates/ui-egui/src/lib.rs::push_recent stores the list in preferences; prefs_ui::load restores it and recent_files_survive_a_restart_and_honour_the_count verifies reload | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md new file mode 100644 index 0000000000..dadaca6d20 --- /dev/null +++ b/docs/target-app-parity.md @@ -0,0 +1,535 @@ +# PhotoCraft vs Adobe Photoshop: target-app parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment into this file) · **Target:** Adobe Photoshop 2026 + +The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The +one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); +the measured checklists are [`scorecard.md`](scorecard.md) (`cargo xtask scorecard`) and +[`parity-checklist.md`](parity-checklist.md) (`cargo xtask parity`). Detail by dimension: +[UI](ui-parity.md), [brushes and painting](brush-parity.md), [context menus](context-menu-parity.md), +[file formats](file-format-parity.md), [hardware](hardware-parity.md), +[localization](localization-parity.md). + +## Headline (2026-10-10, PhotoCraft 0.6.0, `main` at 3e000bc1) + +| Number | Value | Kind | +|---|---|---| +| **Feature breadth** (does each Photoshop feature exist?) | **~76%** | partly measured (menus, tools, panels, formats), weights below | +| **Ready for real work** (could a professional replace Photoshop with it?) | **~45%** (range 40–50%) | estimated, weights and evidence below | +| **Mainstream practitioner** (typical pro, weekly areas only) | **~43%** (38–48%) | estimated, [method](#mainstream-practitioner-43) | +| **Essentials user** (core features only) | **~61%** (55–67%) | estimated, [method](#essentials-user-61) | +| **Stage** | **alpha** | ready-for-real-work is inside the ~40–75% band and the [alpha gate](roadmap.md#alpha-gate) passes (six core workflows end to end); PSD exchange with Photoshop is not yet reliable enough for beta | +| Remaining to **beta** (~75% ready, PSD reliable) | **~1,100–1,800 Opus 5.5 agent-hours** | estimated, calibrated below | +| Remaining to **full parity** (~95–100%) | **~2,300–4,100 Opus 5.5 agent-hours** | estimated; includes AI and ecosystem, which need owner decisions | + +These numbers replace the 2026-10-03 estimate (82% "of the way", 140–220 agent-hours to 95%, +kept below for the record). That estimate counted feature surface and was far too optimistic: +about 1,230 issues have been filed since the first release (579 open on 2026-10-10), most of them +against features already counted as present. The 2026-10-05 honest assessment (feature surface 60–70%, +ready for real work 25–35%) was closer; ~700 merged PRs since then moved both numbers up. + +### Readiness by audience + +| Audience | Ready % | Opus 5.5 agent-hours to ~95% | Work that dominates | +|---|---:|---:|---| +| Full target (ready for real work) | ~45% (additive weighted sum: 46%) | 2,300–4,100 | Everything below, plus AI tier, plug-in/scripting ecosystem, video, localization, hardware | +| Mainstream practitioner | ~43% | 1,700–2,800 | Depth in the 11 weekly areas (1,180–2,000), performance budgets, stability on real machines, PSD exchange with Photoshop | +| Essentials user | ~61% | 400–700 | Startup and GPU robustness, brush and transform responsiveness, Type tool, discoverability (flyouts, commit buttons), AVIF/PDF open | + +Hours calibrated as in [Remaining effort](#remaining-effort-and-calibration) (2–9 h per checklist +item, 8–20 h per format or subsystem, from the repo's 1,004 merged PRs); each tier is a subset of +the one above. About 80% parallelizes (independent crates and command modules); performance +architecture and the PSD writer are the serial parts. + +## Target measured + +- **Adobe Photoshop 2026, version 27.11.0** (build 20260929.r.26, macOS universal), installed at + `/Applications/Adobe Photoshop 2026`, with Camera Raw 18.7. Inspected without launching it: + `Info.plist` document types, the file-format plug-ins' PiPL read/write flags, `.lproj` and + `Locales/` language packs, `Default Keyboard Shortcuts.kys` (67 toolbox tools, 107 menu commands + with default shortcuts), `Required/` plug-ins (85 filter entries, 12 format plug-ins), presets, + and strings in the main binary (81 "… Tool" titles; HDR/EDR, Metal, Touch Bar, pen features). +- PhotoCraft's own Photoshop menu tree (`crates/ui-egui/src/menu_catalog.rs`, 628 items) was + written from Photoshop's menus; it is the denominator of the menu number. Photoshop's binary + `Default Menus.mnu` was not parsed, so items added in 27.x that the catalog lacks would not show + (Generative and cloud items are the likely ones). +- User reports on GitHub (`storytold/photocraft`, 579 open issues on 2026-10-10) as evidence of + depth and feel. Adobe's documentation and release notes for behaviour not visible in the bundle. + +## Feature breadth: ~76% (weighted) + +| Component | Weight | Ours / Photoshop | % | Kind | +|---|---:|---|---:|---| +| Menu commands | 30% | 628 / 628 catalog items dispatch a live command | 100% | measured (`cargo xtask parity`) | +| Toolbox tools | 15% | 53 / 68 (Photoshop's 67 `.kys` tools plus Remove, Triangle, Curvature Pen; minus legacy Rounded Rectangle and Targeted Adjustment) | 78% | measured (`Tool` enum in `ui-egui/src/state.rs`, `TOOL_SECTIONS` in `panels.rs`) | +| Panels | 10% | 30 / 35 Window-menu panels | 86% | measured (`view_cmds.rs`, panel modules) | +| Filters and adjustments | 10% | every Filter and Image › Adjustments item live; Filter Gallery 47 looks | 100% | measured (menu catalog) | +| File formats (usage-weighted) | 15% | 13 of 31 Photoshop formats supported in Photoshop's directions, 4 partial; the common ones (PSD/PSB, TIFF, JPEG, PNG, WebP) all present | ~80% (48% by count) | measured list, estimated weights ([file-format-parity.md](file-format-parity.md)) | +| AI / generative | 10% | classical Select Subject/Sky, Remove tool, Content-Aware; no Generative Fill/Expand, Neural Filters, Sky Replacement, Super Zoom | ~15% | estimated | +| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions; no .8BF, UXP/ExtendScript, .atn import, Libraries | ~20% | estimated | +| **Weighted** | 100% | | **~76%** | | + +## Ready for real work: ~45% (weighted) + +Weights reflect what a working Photoshop user depends on (photographers, retouchers, designers). + +| Dimension | Weight | % ready | Remaining (agent-h) | Evidence | Detail | +|---|---:|---:|---:|---|---| +| Features in depth (tools, layers, selections, type, filters, transforms) | 25% | 55% | 600–1,000 | Scorecard checklists: Tools 20 done / 15 partial / 6 missing, Type 0 / 3 / 6, Automation 3 / 2 / 6. 15 tools missing; Select and Mask, Vanishing Point and Content-Aware Fill are parameter dialogs, not workspaces; type on a path, manual kerning, RTL paragraphs missing | [gaps.md](gaps.md) | +| UI / UX fidelity and feel | 15% | 40% | 350–600 | ~150 open UI issues; Workspace checklist 5 / 7 / 11; 46 of 149 preferences do nothing; no floating panel groups, thin Color panel, no transform context menu, brush lag and pressure jumps reported | [ui-parity.md](ui-parity.md), [brush-parity.md](brush-parity.md) | +| File formats and fidelity | 15% | 60% | 300–500 | PSD oracle on Photoshop-authored files 133/256 (52%); io corpus 146/170 (86%); psd-tools 236/309 (76%); round trips ~100% of corpus; but users report Photoshop refusing our files (#1281, #2469; a text-layer case, #2374, was fixed 2026-10-10); no Photoshop PDF, EPS, JPEG 2000, JPEG XL, AVIF read, CR3 | [file-format-parity.md](file-format-parity.md) | +| Performance | 10% | 25% | 200–350 | 3 of 25 budgeted scenarios meet budget (baseline 2026-10-05); 150-layer nudge crashes the GPU path (P12); layer move on 15000×10000 16-bit 2.7 s p50; users report brush and cursor lag (#2619, #2566, #1994) | [scorecard.md](scorecard.md#performance) | +| Stability | 10% | 45% | 150–250 | Never-crash lints on 28/28 crates, `panic_hunt` in the gate, atomic saves; but field reports of freezes (#2518 Type tool 60 s on macOS), GPU panics (#2020, #1215), no-GPU start failures (#2519, #2412) | [gaps.md](gaps.md) | +| Hardware | 5% | 50% | 80–150 | GPU compositor (wgpu), pen pressure on all desktops but tilt/rotation not on Windows, Wayland pen broken (#2622), no HDR/EDR output, monitor profile auto-detected on macOS only | [hardware-parity.md](hardware-parity.md) | +| Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi, Arabic, Vietnamese absent; no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) | +| Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; no Windows ARM64, no in-app updates | [scorecard.md](scorecard.md#distribution) | +| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript, no .atn import; .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | +| AI features | 5% | 10% | 300–600 | No ML runtime at all (scorecard AUTO-219-7); classical substitutes only. Needs an owner decision and openly licensed models (#41) | [gaps.md](gaps.md) | +| **Weighted** | 100% | **~46%** | **2,340–4,100** | | | + +## Mainstream practitioner: ~43% + +The same question for the typical professional Photoshop user (photographer, retoucher, +designer), not the whole product. Method: craftrules `standards/progress-docs.md`. Areas a +professional uses weekly (the [alpha-gate](roadmap.md#alpha-gate) workflows plus their everyday +tools), depth from the [feature areas](#feature-areas) table. Left out: AI, plug-ins and +scripting, Libraries/cloud, video, specialist hardware (tilt, Touch Bar), languages. + +| Area | Weight | Depth | +|---|---:|---:| +| Layers, masks, blend modes, smart objects, styles | 20% | 65% | +| Adjustments and filters | 15% | 70% | +| Selections and masking | 15% | 55% | +| Retouching | 12% | 50% | +| Transform and warp | 8% | 55% | +| Type | 8% | 40% | +| Painting and brushes | 7% | 55% | +| Vector shapes and paths | 5% | 55% | +| Camera Raw | 4% | 45% | +| Colour management | 3% | 70% | +| Export, Save for Web | 3% | 50% | +| **Weighted depth** | 100% | **57%** | + +Discounts for what still stops real work (estimated, each with evidence): + +| Discount | Factor | Evidence | +|---|---:|---| +| Interaction fidelity | ×0.92 | ~150 open UI issues; transform context menu, spring-loaded tools, panel tear-off missing ([ui-parity.md](ui-parity.md)); brush lag and pressure reports ([brush-parity.md](brush-parity.md)) | +| Stability and speed on real machines | ×0.88 | 3 of 25 performance budgets met; 150-layer GPU crash (P12); Type tool freeze (#2518); GPU start failures (#2519, #2412, #2020); 41 open performance issues | +| Exchanging files with Photoshop users | ×0.92 | Photoshop-authored oracle 133/256; exports refused by Photoshop (#1281, #2469); smart-filter re-render 5/30 | + +**57% × 0.92 × 0.88 × 0.92 ≈ 43%** (range 38–48), estimated. + +It is not higher than ready-for-real-work (~45%) because the areas it leaves out carry little +weight in the full number (AI, ecosystem, localization and hardware together 20%, averaging +~33%), while the discounts apply to every workflow. The two numbers agree within their error; +the full number stays where its written weights put it. + +**User evidence** (GitHub, 2026-10-10; search hits include maintainer replies, so they are upper +bounds): 0 issues mention switching from or replacing Photoshop; praise words appear in ~19 +("love"), 10 ("amazing"), 6 ("awesome"), 4 ("great work") issues. Of 579 open issues, 136 are +feature requests and 443 bugs or reports, of which ~244 are on core paths (layers, selection, +brush, transform, type, save/open, performance) by title. Users are trying it for real work, +and most of what they report is core-path friction, not niche requests. + +## Essentials user: ~61% + +An occasional user who touches only the essentials: open or create a file, the most used tools +and commands, undo, save and export, default settings. Excluded: advanced options, pro +workflows, PSD exchange edge cases, and everything excluded above. + +| Core feature | Weight | Depth | Notes | +|---|---:|---:|---| +| Save, Save As, Export JPEG/PNG | 12% | 85% | | +| Layers (add, reorder, hide, opacity) | 12% | 80% | | +| New / open a photo | 10% | 90% | | +| Select (marquee, lasso, magic wand, object) | 10% | 75% | | +| Move and Free Transform | 10% | 70% | no commit/cancel buttons (#2442) | +| Brush and eraser | 10% | 70% | lag with big brushes (#2619) | +| Undo, History | 8% | 90% | | +| Basic adjustments (brightness, levels, curves, hue/saturation) | 8% | 80% | | +| Crop and straighten | 6% | 85% | | +| Text | 6% | 55% | freeze report (#2518) | +| Spot healing, Remove | 5% | 65% | | +| Blur and sharpen filters | 3% | 85% | | +| **Weighted depth** | 100% | **78%** | | + +Discounts: launch and stability ×0.90 (start failures without a usable GPU, #2519, #2412, +#1859; Type tool freeze, #2518); discoverability and UI clarity ×0.92 (language option not +found, #2532; flyouts only on right-click, #2301; toolbar hover issue, #2603); opening files +people send ×0.95 (JPEG, PNG, HEIC, WebP, PSD open; AVIF and PDF don't, #463, #2541). +**78% × 0.90 × 0.92 × 0.95 ≈ 61%** (range 55–67), estimated. + +## Feature areas + +Percent is ready-for-real-work for that area (depth and fidelity, not presence). Hours are to full +parity for the area and are already contained in the dimension rows above. + +| Area | % | Hours | Notes | +|---|---:|---:|---| +| Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: multi-layer Smart Object conversion (#2604, #2648), layer clipboard between documents (UI-217-23), Stamp Visible, contour editor, smart-object perspective (FILE-215-6), mask targeting by command (#2458) | +| Selections, Select and Mask, channels | 55% | 120–200 | All selection tools, single row/column marquee included (#2837). Select and Mask is a generic dialog (TOOL-214-1), no Refine Hair (#2671); Select Subject/Sky classical, 559 ms p50 on 24 MP vs a 100 ms budget | +| Painting and brush engine | 55% | 150–250 | Every Brush Settings section exists; feel is the gap: lag on big brushes, pressure jumps, smoothing and edge quality reports ([brush-parity.md](brush-parity.md)) | +| Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 | Remove tool non-generative; Content-Aware Fill has no workspace (TOOL-214-15); live stroke preview only for Brush/Eraser (TOOL-213-7) | +| Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right; no transform context menu (TOOL-214-5), no skew fields, puppet mesh is a block grid (TOOL-214-6), Liquify called "useless" by a user (#2606) | +| Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | +| Type | 40% | 120–200 | Engine works; no type on a path, manual kerning, RTL paragraphs, OpenType panel UI, live Warp Text preview; Type tool freeze on macOS (#2518) | +| Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Missing Freeform/Curvature Pen, anchor tools, on-canvas corner radius, path-point nudge; shape parity tracking issue #2496 | +| Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | +| Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | +| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2), no .atn, no ExtendScript/UXP | +| File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | +| Print, export, Save for Web | 50% | 60–100 | JPEG Options dialog missing (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | +| Video / timeline, data-driven graphics | 30% | 100–200 | Menu items live; frame model persists in `.pcraft`; no PSD video-layer mapping | +| Workspace, panels, preferences | 40% | 150–250 | 46 of 149 prefs do nothing; no floating/OS-window panel groups; Libraries, Comments, Contextual Task Bar missing | +| AI / generative | 10% | 300–600 | See the dimension row | + +## Remaining effort and calibration + +**Calibration.** The repository's history from the first commit (2026-09-30) to 2026-10-10: 1,152 +commits on `main` and 1,004 merged PRs in 10.5 days. Median PR open-to-merge time is 3.4 h +(p75 6.4 h, p90 14.3 h; that includes CI and review queueing). Comparable features: + +| Feature | PRs | Lines added | PR hours | +|---|---|---:|---:| +| Crop tool parity set (#1919: nudge/swap/readout, overlays, Straighten, shield) | #1958, #2026, #2127, #2128 | ~2,050 | 6.5 | +| Crop rotation (#1792) | #1879 | 710 | 2.8 | +| Remove tool with non-local patch completion | #2193 | 2,005 | 9.0 | +| OpenRaster open and save | #2257 | 1,331 | 8.9 | +| Paint.NET import | #1998 | 1,461 | 7.0 | +| Shape stroke controls | #2218 | 1,506 | 9.0 | +| Red Eye tool | #1084 | 1,232 | 17.9 | +| Korean catalog and live switching | #582, #604 | ~3,800 | 13.0 | + +So one checklist item of tool or UI depth costs about 2–9 agent-hours including tests, and a new +format or subsystem 8–20. With roughly 2–4 agent-hours per merged PR, the ~1,000 PRs so far +represent about **2,000–4,000 agent-hours**, which took ready-for-real-work from 0 to ~45%. The +remaining points are harder per point (fidelity against Photoshop's unpublished maths, feel, +performance on large documents, user-reported long tail), so the remaining estimate is not lower +than the work already done. + +**Totals.** + +| Milestone | Scope | Opus 5.5 agent-hours | +|---|---|---:| +| **Beta** (~75% ready, PSD reliable) | PSD fidelity and Photoshop re-open check, the performance budgets, the user bug backlog, the 15 missing tools, Select and Mask / Content-Aware Fill workspaces, type depth, UI feel (brush, transform, panels), JPEG/PDF/AVIF/JXL formats, Hindi/Arabic/Vietnamese and RTL | **1,100–1,800** | +| **Full parity** | the above plus the AI tier, plug-in/scripting ecosystem, video depth, long-tail fidelity | **2,300–4,100** | + +**Parallelism.** About 80% parallelizes: areas are independent crates and command modules, and +the repository has sustained 10–20 concurrent agents. At ~12 agents, beta is roughly 100–150 +wall-clock hours. Serial parts: GPU compositor and performance architecture, the PSD writer. + +**Needs a human.** Owner decisions on the AI backend (#41: which openly licensed models, local +vs remote) and plug-in hosting (.8BF needs native code under the isolated-`unsafe` rule; UXP is a +JavaScript runtime, which AGENTS.md forbids in the UI); Windows code-signing material; tablet +hardware checks (Wacom/XP-Pen on Windows, Wayland, macOS); native-speaker review of every +catalog; checking exports by opening them in Photoshop (the owner's machine has it installed). + +## How to re-measure + +1. `cargo xtask parity` and `cargo xtask scorecard` for the measured rows. +2. Tools: diff `Tool` in `crates/ui-egui/src/state.rs` against the `` entries of + Photoshop's `Locales/en_US/Support Files/Shortcuts/Mac/Default Keyboard Shortcuts.kys`. +3. Formats: Photoshop's `Info.plist` `CFBundleDocumentTypes` and the PiPL flags of + `Contents/PlugIns/Required/File Formats`, against `crates/codecs/src/format.rs` and + `crates/io/src/lib.rs`. +4. Languages: `cargo xtask i18n-coverage`. +5. Re-grade the estimated rows from the open issues by area and the corpus floors. Move a number + only with new evidence, and add a revision-history row. + +--- + +# Earlier assessments (kept for the record) + +The two sections below are superseded by the tables above. They are kept because they hold dated +evidence that later numbers build on. + +## Feature-parity estimate of 2026-10-03 (from the former `docs/parity-estimate.md`) + +How close PhotoCraft is to Adobe Photoshop 2026, and how much Opus 5.5 work remains. Generated by +assessment, not a tool; revisit when a major arc lands. Menu coverage is from `cargo xtask parity`; +fidelity is from the PSD oracle (`cargo xtask corpus` / `oracle_diff`). + +### Headline + +> **Update 2026-10-03:** menu/command breadth reached **100% (625/625)** — every Photoshop +> menu item now dispatches a live command (incl. Variables/Data Sets, Trap, the Timeline panel and +> Layer › Video Layers). Remaining parity work is depth and fidelity, not breadth: PSD oracle +> (~66%), Camera Raw/video-layer depth, and the AI/Neural tier. See the table below. + + +- **Menu / command breadth:** 625 / 625 Photoshop menu items live = **100%** (every menu item has a live command). +- **PSD rendering fidelity (oracle):** ~111 / 170 corpus files pixel-match = **~65%**. +- **Overall functional parity (weighted, mainstream workflows):** **~86%** (menu breadth complete; depth + fidelity are the remaining gap). +- **Overall including Adobe's AI / Firefly features:** **~75%** (these are mostly cloud/ML and the + hardest to reach clean-room). + +Menu coverage (96%) overstates true parity because many live commands are faithful-but-approximate +(Camera Raw without masking/calibration, Vanishing Point without its UI, heuristic Select Subject/Sky +vs Adobe's ML, Content-Aware Fill basic). The weighted figure accounts for depth and fidelity, not +just presence. + +### Where we stand, by area + +| Area | Parity | Notes | +|---|---:|---| +| Layers, masks, groups, blend modes, smart objects, layer styles | ~92% | Broad + GPU-accelerated; bevel/satin shapes and a few blend edge cases remain | +| Selections, paths, vector shapes, pen | ~90% | Select menu 100% of items; Select Subject/Sky are heuristic, not ML | +| Paint / brush engine, retouch (clone, healing) | ~85% | Solid; no Mixer Brush depth, no Content-Aware healing | +| Adjustments + adjustment layers | ~90% | All kinds present; Camera Raw depth and a few toe-curve models approximate | +| Filters (incl. Filter Gallery ×47, Liquify, blurs, render) | ~88% | Gallery looks are approximations; no Neural Filters | +| Type / text engine, character & paragraph styles, glyphs | ~90% | Vertical type laid out and rendered (#199; no mojikumi or upright-Roman option yet); OpenType alternates thin | +| Color management (ICC v2/v4, intents, soft-proof) | ~90% | Own pure-Rust CMS; no Adobe CMYK profiles (synthetic) | +| File I/O: PSD, native .pcraft, flats (png/jpg/tiff/webp/exr/…) | ~85% | PSD oracle ~65% pixel-exact; most structure round-trips | +| Transform, warp, puppet, perspective, liquify | ~88% | No Face-Aware Liquify (needs a landmark model) | +| Automation: CLI, MCP, headless server, control channel | ~95% | Ahead of Photoshop here (see below) | +| Image modes (RGB/Gray/CMYK/Lab/Indexed/Duotone/Multichannel) | ~85% | Multichannel display-only; no ink-plate model | +| Print / export / Save for Web / slices / Generator | ~85% | No Separations/bleed; droplets basic | +| Computational photo (Photomerge, HDR, Lens, Camera Raw, Wide Angle) | ~70% | Classical implementations; generic lens profiles only | +| Data-driven graphics (Variables, Data Sets) | ~0% | Not started (5 menu items) | +| Video / Timeline / animation | ~0% | Not started (13 menu items) — a whole subsystem | +| AI / Neural / Generative (Firefly, Sensei, Neural Filters) | ~5% | Mostly absent; needs licensed/trained models | +| Prepress niche (Trap, measurement scale pro) | ~30% | Trap, ink spread not modelled | + +### The remaining 25 menu items, grouped + +- **Video & Timeline (13):** Video Layers (new/insert/duplicate/delete/replace/interpret/restore/ + reload/rasterize), Rasterize › Video, Timeline panel, Render Video, Import Video Frames to Layers. + One cohesive subsystem: a frame/time model, a video decoder/encoder (clean-room Rust, no ffmpeg), + onion-skinning, and a timeline UI. +- **Data-driven graphics (5):** Variables › Define / Data Sets, Apply Data Set, Export Data Sets as + Files, Import Variable Data Sets. A template-binding model over layers + a CSV/data-set engine. +- **Niche / platform (7):** Trap (prepress ink spread), Frame from Layers, Migrate Presets, WIA + Support (Windows scanners), and two already-adjacent items. + +### Effort to parity (Opus 5.5) + +Two clocks: **agent-hours** (total model work) and **wall-clock** (with this session's observed +~6–8× parallelism across independent arcs). This session landed ~70 menu items + major subsystems +in a handful of wall-clock hours using 8 parallel agents, so wall-clock ≈ agent-hours ÷ 6. + +| Milestone | Scope | Agent-hours | Wall-clock | +|---|---|---:|---:| +| **To 90% functional** | remaining non-AI/non-video menu items (Variables, Trap, Frame, Migrate, WIA), Camera Raw depth, Vanishing Point UI, selection quality, polish | 60–90 | 10–15 h | +| **To 95% functional** | the above + PSD oracle 65% → ~90% (bevel/satin shapes, Lab blending, noise-gradient RNG, toe curves), broad UI-parity pass, perf pass | +80–130 | +15–22 h | +| **Video / Timeline subsystem** | frame model, clean-room decode/encode, timeline, animation, Render Video | 50–80 | 10–14 h | +| **Credible AI tier** | ML Select Subject/Sky, Content-Aware Fill (PatchMatch++), basic generative fill/expand — needs model integration; some features may be infeasible fully clean-room without licensed models | 120–250+ | 25–45 h | + +**Totals:** +- To **~95% functional parity** (everything except video and AI): **~140–220 agent-hours ≈ 25–40 + wall-clock hours** of Opus 5.5 with parallel agents. +- To **~98%** incl. the video subsystem: **+50–80 agent-hours ≈ +10–14 wall-clock hours**. +- To **~99%** incl. a credible AI tier: **+120–250 agent-hours ≈ +25–45 wall-clock hours**, with the + caveat that pixel-for-pixel Firefly/Sensei parity is not achievable clean-room without trained + models; we target functional equivalents. + +**Best single number:** PhotoCraft is **~82% of the way** to full Photoshop parity, and reaching a +**95%, ships-for-most-users** state is roughly **25–40 wall-clock hours** of parallel Opus 5.5 work. +Closing the last 5% (video + AI) is the long, uncertain tail. + +### Assumptions & caveats + +- "Parity" = functional + visual equivalence of what a user can do, not byte-identical output. Clean- + room means no Adobe code, profiles, shaders, or trained models; AI features target equivalents. +- Wall-clock assumes 6–8 independent arcs in parallel with their own target dirs, as this session ran. + Serial work is ~6× longer. +- Adobe removed 3D/Substance from Photoshop, so 3D is intentionally out of scope. +- Fidelity hours are the least certain: the remaining oracle failures are proprietary RNGs and + unpublished curve models fitted by observation. + +## Honest parity assessment of 2026-10-05, with dated evidence to 2026-10-10 (from the former section of `docs/roadmap.md`) + +This is the reference answer to "how close are we to Photoshop parity, really". Agents: read it +before picking work. Update it (with dated measurements) when the numbers move; don't restate the +menu-parity number in its place. + +**Measured numbers live in the [scorecard](scorecard.md)** (`cargo xtask scorecard`, kept current +by CI): performance scenarios against their budgets, corpus floors, per-area checklists with +done / partial / missing counts, and the number of settings that do nothing. Where the scorecard +and the estimates below disagree, the scorecard's numbers supersede them. As of 2026-10-05 it +counts 67 of 129 preferences that nothing reads; 3 of the 25 budgeted performance scenarios meet +their #209–#211 targets (first baseline, taken on a heavily loaded machine), and the 150-layer +nudge scenario crashes the GPU compositor; the open area issues' checklists (#203–#220) are +almost entirely missing or partial. + +2026-10-07: UI Font Size now applies to interface text, including CJK fallbacks, independently +of display and canvas zoom (#532). The current preference audit drops from 59 to 58 unread +settings out of 135; see the regenerated scorecard. + +2026-10-09: Output-preserving Radial Blur direct sampling measured on a local +i7-9750H, 12 workers, 24 MP RGBA8, Good quality, amount 1: Spin 66.10 → 16.43 s +(4.02×), Zoom 19.19 → 5.35 s (3.59×), one paired run each, exact output equality. +These are local measurements at amount 1; see [method and limits](radial-blur-performance.md). + +2026-10-10: native selection distance transforms measured 7.59–10.57x faster on six +24–36 MP synthetic masks on an AWS c7i.4xlarge (16 workers, three paired release runs). +The transform also corrects f32 envelope errors past coordinate 4096, including nonzero +distances at selected pixels. The parallel path needs an additional four bytes per pixel; +see [measurements, correctness and limits](selection-distance-performance.md). + +**Bottom line.** Two days after 0.2.0 we had merged ~96 PRs and closed ~48 issues, but **real +Photoshop parity is still well below 50%**. The biggest gaps are AI, missing tools, professional +workflow depth and the plug-in ecosystem. Most fixes since 0.2.0 have passed our tests but have +**not yet been validated by users**, and this week proved our tests miss what users hit: +on first public use they found broken basics (text selection offset, 214 shortcut failures, panels +resizing themselves, an immovable crop frame, folders that wouldn't collapse, lag on layout PSDs) +that all counted as "live" in `parity-checklist.md`. + +**Overall.** Feature surface: roughly **60–70%** of what a typical Photoshop user touches exists in +some form. "A professional could switch for daily work": roughly **25–35%**. True 1:1 parity is +**many months** of focused work, and some areas need product decisions rather than effort. +Confidence: moderate — the next users of 0.2.x will move these numbers either way. + +### By dimension + +2026-10-10: Iris Blur pin preparation, saturated-distance evaluation and trailing +Gaussian-level halo trimming measure 1.18–1.76× faster across eight local 24 MP +RGB U8/U16/F32 cases (Ryzen 7 9700X, Windows, eight workers). Default blur 15: +626.8 → 426.9 ms; two pins: 1789.9 → 1016.2 ms. The default 1500×1000 filtered +proxy for 24 MP, document blur 15/80: 28.7/51.8 → 24.9/42.6 ms. At 36 MP, +two pins: 2595.8 → 1822.8 ms; small/medium U8 gains are inconclusive. +Differential tests preserve exact finite pixel bits. +These are computation timings, not GUI frame latency or new Photoshop parity; +see [measurements, upstream SIMD findings and limits](iris-blur-performance.md). + +2026-10-10: Quick Selection object strokes through raster surfaces measured +3.46-3.70x faster at 8-bit, 16-bit and 32f on 24-36 MP documents (AWS +c7i.4xlarge, five paired release runs), with identical region bounds and mask bytes. +Hybrid FIFO push/relabel and sparse edge enumeration preserve the fitted energy. +See [stroke measurements and reproduction](quick-selection-performance.md). + +2026-10-10: Pixelate's Facet shares exact 3x3 quadrant statistics and Color Halftone +shares dot radii within each rotated screen grid. Full tiled CPU application at 24 MP +on a 16-vCPU m7i.4xlarge improves Facet from 0.684 s to 0.312 s in RGB8 +(2.20x), and from 7.888 s to 0.574 s in CMYK8 (13.74x). +Color Halftone improves from 1.137 s to 0.604 s in RGB8 (1.88x) +and from 3.589 s to 0.718 s in Lab8 (5.00x). +Three alternating paired release runs also cover 36 MP RGB, 16/32-bit storage, +and screen radii 2/8/64. All 36 complete output comparisons match exactly. +See [method, raw timings and checks](pixelate-performance.md). + +2026-10-10: Native CMYK solid and gradient fills share depth-quantized samples between +rendering and PSD export. The uncached 16-bit CMYK gradient now round-trips with zero +rendered error; psd-tools round trips rise from 307 to 308. Mixed and Photoshop round +trips remain 169 and 258; rendering oracle counts remain 146, 237 and 133. +Exact 8-bit CMYK row reuse reduces 1 MP conversion-read time by 77% for smooth ramps, +33% with dithering and 10% for random colours (M4 Max, release optimization level 3). + +2026-10-10: [Shape stroke controls](shape-strokes.md) expose existing Solid/Dashed/Dotted, +custom dash/gap, offset, caps, joins, alignment, miter-limit and opacity capabilities in +Shape/Pen options and Properties. Properties stages a cached canvas preview before one +undoable edit. Gradient fill controls from #1053 remain open; the Line tool still draws a +filled bar. This extends UI reachability and does not claim Photoshop-authored stroke parity. + +| Dimension | Measured / evidence (2026-10-05) | Grade | Notes | +|---|---|---|---| +| Menu wiring | 626/626 menu items dispatch a command (`parity-checklist.md`) | high but shallow | Says nothing about behaviour. | +| PSD fidelity (rendering) | Corpus oracle 115/170 (68%): 30 differ, 26 have no usable reference, 1 import error. 2026-10-07: io corpus 146/170, psd-tools corpus 236/309 (was 229: Advanced Blending knockouts), Photoshop oracles 132/258 | medium | Push to 170/170 under way (effects/strokes, multi-instance effects, 16/32-bit and colour modes, references for skipped files). | +| PSD round trip | 169/169 re-import identically; every adjustment layer and blend mode round-trips | high (within corpus) | Floors in `crates/io/tests/corpus.rs`; raise, never lower. Corpora: `cargo xtask corpus --all` (ours: https://github.com/storytold/photocraft-corpus). | +| OpenRaster read/write | 2026-10-09: 34 Krita 5.2.9-authored `.ora` files (27 blend modes, groups, pass-through, offsets, opacity, visibility, masks, 16-bit, gray) render within 3/255 of Krita's merged image (30) or differ for known reasons (4: Krita's lighter/darker-colour tie-break, Hard Mix at exactly 1, a linear-light 16-bit document); Krita re-opens our exports and renders them identically to its originals (33/34; Soft Light is written as `svg:soft-light`) | medium (one authoring app) | MyPaint and GIMP files untested. Masks are applied to pixels and layer styles dropped on save (reported). See [OpenRaster](ora.md). | +| Paint.NET import | 2026-10-09: 14/14 local PDN3 textures preserve editable layers exactly through `.pcraft` and render within 2.142/255 of full-size previews; a supplied 5.x document (1280×720, 11 layers) passes editing/history/native saves and matches its thumbnail within 0.418/255 mean; all 14 blend modes exercised synthetically | medium (limited corpus) | Import only; metadata omitted. More current-version files and full-size exports needed. See [PDN support](pdn.md). | +| Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. | +| Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. | +| UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418). 2026-10-09: canvas zoom is physical at any display scale — 100% is one document pixel per physical display pixel, and every screen-space overlay, cursor, scrollbar, navigator and fit follows it (#1943) | low–medium | Needs recurring visual QA with realistic documents. | +| Tools | 2026-10-09: Pencil, Mixer Brush, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush, Freeform/Curvature Pen, Add/Delete Anchor Point tools, Color Sampler, Perspective Crop, type masks and Frame | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | +| Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Native Wayland pen input open (#79; a pen opens the window through Xwayland meanwhile); X11 pressure confirmed by a user, macOS not yet verified on tablet hardware. | +| Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. | +| Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. | +| Performance | 14k+ px on the GPU at ~⅓ the memory; adjustment preview 285 ms → 4–9 ms; font-size edits 297 ms → 4.6 ms; 2026-10-07: 30 MP TIFF open (banded, parallel strip/tile decode) Deflate 345 → 32 ms, LZW 428 → 43 ms, BigTIFF and every IFD readable; 2026-10-09: 4.2 MP Indexed Color, 256 colours, full-resolution CPU preview p50 3018 → 1061 ms (Ryzen AI 7 350, three measured runs; GPU upload excluded) | medium-high on rasters | Complex layout documents still laggy (#125/#128); >16384 px GPU tiling in progress (#49). Native Indexed Color previews run on one background worker with stale-result rejection; large palettes can still take about a second to compute. Web previews remain synchronous. | +| Stability | Never-crash lint series, crash guard, `panic_hunt` fuzzing in the gate | medium-high | No field crash data yet. | +| Camera RAW | DNG, CR2, Sony ARW (lossless + compressed), Nikon NEF (lossless + lossy compressed), RW2, uncompressed ORF; 2026-10-09: uncompressed Fujifilm RAF, Bayer and X-Trans (edge-directed X-Trans demosaic; 26 MP X-Trans develop 306 ms on 12 cores) | medium | CR3, compressed RAF, Nikon "lossy after split" and calibrated colour for non-DNG cameras (no Fuji colour calibration: neutral fallback) still open (#50). | +| AI / generative | none | ~0% | Deferred by decision (#41). | +| Ecosystem | Sandboxed WebAssembly plug-ins instead of .8BF; no ExtendScript/UXP/.atn; no Adobe Fonts/Libraries/cloud docs | low | By design for 8BF; scripting compatibility open. | +| Platforms | macOS (notarized), Windows, Linux (AppImage/deb/rpm/Flatpak bundle), web | medium-high | Flathub later (#173); Windows signing material pending. | +| Localisation | 2026-10-07: 10 UI languages; menu, `tl!`, blend mode, preference and brush-section coverage enforced by tests; live switching and scoped Preferences previews | medium | Engine errors/status messages still partly English; CJK web fonts, browser-locale detection, and RTL remain open. | + +2026-10-08: Motion Blur adds adaptive FFT convolution for wide streaks while retaining the +row kernel merged in [#902](https://github.com/storytold/photocraft/pull/902) for shorter +streaks and unsupported cases. Native release medians of three paired runs on an Intel +i7-9750H with 12 Rayon workers, synthetic RGBA8 at 30° / distance 1000: 24 MP +22.594 → 9.785 s (2.31×), and 1.5 MP +2.455 → 0.592 s (4.15×). +Full-image comparisons differ by at most one U8 code level; distance 64/256 retain the +byte-identical row output. These measure decode/halo/filter/write/prune, excluding UI proxy +creation, compositing and upload; the UI remains synchronous. FFT work is scheduled within +a conservative 512 MiB working-set estimate, excluding stored Surface tiles and row fallback +memory. The crossover is a heuristic; images dominated by near-cutoff alpha can need extra +scalar work. These measurements do not establish Photoshop filter parity. + +2026-10-10: Proximity Match search medians on an AWS c7i.4xlarge improved 1.54–3.82x +for four synthetic stroke regions (50–300 px holes), with identical source displacements. +Bounding the mask table to the hole and pruning losing SSD candidates reduced table storage +by about 90–93 percent there. Full 24–36 MP kernel inputs improved 4.60–6.24x, but normal +engine strokes already use a cropped region. See [method and limits](proximity-match-performance.md). + +2026-10-09: the Remove tool is in the J flyout (`paint.remove`, a background job). A stroke +around an object also removes what it encloses. The fill is non-local patch completion with +texture features (A. Newson et al., IPOL 2017: `crates/algo/src/nonlocal.rs`), finished by a +best-patch copy and gradient-domain seam hiding. On 20 holes in public-domain photos +(`remove_quality`, `docs/development.md` › Remove Tool quality) the fill keeps 0.89 of the +original's texture (Wexler/PatchMatch completion as Content-Aware Fill uses it: 0.54). On a 24 MP +document a 100 px ring takes 120 ms and a 1000 px ring 2.5 s (`remove_bench`). It is not +generative: large objects over complex structure fill less convincingly than Photoshop's AI mode +(#41). + +2026-10-08: the tools update above is checked against the current toolbar groups in +[`panels.rs`](../crates/ui-egui/src/panels.rs), the Pencil and tool-cycle tests in +[`pencil_tests.rs`](../crates/ui-egui/src/pencil_tests.rs), Patch's live-preview test in +[`patch_preview.rs`](../crates/ui-egui/src/patch_preview.rs), and Vertical Type's point/paragraph +creation and undo test in [`type_tool_tests.rs`](../crates/ui-egui/src/type_tool_tests.rs). +These implementations remove the tools from the missing list; they do not establish Photoshop +behavioural parity. The historical estimates and corpus measurements in this assessment are +unchanged; current measured floors remain in the scorecard. + +2026-10-07: Camera Raw PSD mapping covers relative custom white balance, Light/Presence, +parametric and four point curves, HSL, Color Grading, sharpening/noise detail, grain and numeric +post-crop vignette controls. Two revisions of one supplied Photoshop ACR 18.4 PSD preserve their +filter descriptors without edits. The updated revision stays opaque because of active manual +Optics and unverified vignette style; a descriptor projection verifies its supported controls. +Synthetic 8/16/32-bit PSD → edit → `.pcraft` → PSD tests preserve the filter stack, mask and +undo/redo. Unmapped Camera Raw fields/versions remain opaque; Photoshop +acceptance of generated exports and pixel parity are still unverified. Corpus floors above are +unchanged. + +2026-10-10: Select and Mask's Shift Edge now computes the exact octagonal footprint +with a square window extreme plus a dyadically built Manhattan ball. On a c7i.4xlarge +(16 vCPUs), full tiled CPU refinement at radius 64 / Shift Edge +100% takes +3.019 s -> 0.762 s at 24 MP (3.96x), +3.706 s -> 0.921 s at 36 MP (4.02x). +Three alternating paired release runs include guide sampling, guided filtering, +boundary distances, quantization and assembly. Every output mask matches exactly. +The dense fractional 24 MP radius-64 dilation kernel improves 36.00x; +circular soft-mask kernels improve 20.76-172.16x at 24-36 MP, radii 4-64. +Bounded halo tiles reduce the large-mask workspace allocation bound from 192/288 MB +to about 115/163 MB, including output and 16 workers' scratch, excluding the source. +See [method and results](octagonal-mask-performance.md). + +### Where we're going (priority order) + +1. **Ship the fixes:** cut 0.2.1 once the current batch lands, so users validate them. +2. **PSD fidelity to 170/170** with round trips, plus the Photoshop-authored reference set for smart + filters, the text engine and effect shapes (https://github.com/storytold/photocraft-corpus, fetched by + `cargo xtask corpus --all`); enforce floors in `corpus.rs`. +3. **Workflow acceptance tests:** 30–50 real tasks (retouch a portrait, social post with text and + effects, composite with masks and adjustment layers, CMYK print prep…) scripted end to end and + checked against Photoshop's output on every build. Their pass rate becomes the headline parity + number. +4. **Tool coverage and workflow depth:** Pen variants (Direct Selection landed, #790) and + Perspective Crop; finish Content-Aware Move's Transform On Drop and live result preview. + Pencil, Mixer Brush, Patch, Vertical Type, Pattern Stamp and Rotate View are already available on the toolbar. +5. **Complex-document performance** (#125/#128) and GPU tiling beyond the texture limit (#49). +6. **Recurring visual QA** (`cargo run -p photocraft-engine --example designer_psd`) and fast + turnaround on user reports (OS, document size, layer count, screenshot). +7. Later / needs decisions: generative AI backend (#41), scripting compatibility (ExtendScript / + UXP / .atn), Flathub (#173), Wayland pen pressure (#79). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Readiness-by-audience table: percent and hours for full, mainstream and essentials; full number confirmed as the additive weighted sum over dimensions (46%, reported ~45%) | +| 2026-10-10 | minor | Added mainstream-practitioner (~43%) and essentials-user (~61%) numbers with written weights and discounts; full ready-for-real-work rechecked against its written weights (46%, reported ~45%), unchanged | +| 2026-10-10 | minor | Stage checked against the core-workflow alpha gate in `roadmap.md`: passes | +| 2026-10-10 | major | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected); two numbers, weights, per-dimension and per-area hours calibrated from git history; merged `parity-estimate.md` and the roadmap's honest assessment into this file | +| 2026-10-05 | major | Honest parity assessment in `docs/roadmap.md`: surface 60–70%, ready for real work 25–35% | +| 2026-10-03 | major | `docs/parity-estimate.md`: menu breadth 625/625, weighted ~82–86% | diff --git a/docs/ui-design.md b/docs/ui-design.md index 7e4a616a72..f897c83da0 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -12,12 +12,16 @@ | Adwaita | GNOME's libadwaita light palette on the Studio layout: white header bar and cards on a grey sidebar, GNOME blue accent (#3584e4), 6 / 9 / 12 px radii, grey (not red) close button | | Adwaita Dark | The libadwaita dark palette on the same layout | | Solarized Dark | Ethan Schoonover's Solarized palette: base03 canvas, base02 panels, base0 text, Solarized blue accent | +| Breeze Light | An adaptation of KDE Plasma's Breeze Light on the Studio layout: cool light blue-grey surfaces a few levels from Breeze's so the app blends into a Plasma desktop, Breeze-style blue accent, 5 px radii, red close button on hover; text and status colours are darker than Breeze's for contrast | +| Breeze Dark | The same for Breeze Dark: cool dark blue-grey surfaces (darkest canvas, then dock, cards, chrome and controls) near Breeze's, blue accent, 5 px radii; secondary text and status colours are lighter than Breeze's for contrast | + +Both Breeze themes are our own values close to Plasma's (not a copy of its colour schemes) and put text readability first: every text colour holds 4.5:1 on every surface and on hovered, pressed and selected states (`theme.rs` tests), so their secondary text, accent and status colours are darker (Light) or lighter (Dark) than Breeze's own, and the primary button is a deeper blue with white text. Edit → Preferences → Interface shows an Appearance Mode selector (Sync with system, Dark, Light) above separate light and dark theme cards with PhotoCraft editor previews and radio choices. Auto is opt-in and follows the operating system while the app is running. On Linux it reads the desktop portal once and then listens for its `SettingChanged` signal (no polling, and the UI repaints only when the value changes); `gsettings` runs at most once at start-up, by absolute path, when the portal gives no answer. A missing system appearance falls back to Dark. New installs keep Photoshop's default: Dark mode with Pro Medium (Studio Light is the saved light theme). Existing saved single-theme preferences migrate to a fixed Dark or Light mode with their chosen theme. The header appearance button cycles Auto → Light → Dark; its icon shows a monitor, moon or sun for the selected mode. Window → Theme and `ui.set {"theme":"classic"}` select a theme and fix the mode to its light or dark family. Window → Theme and macOS PhotoCraft → Appearance also offer Sync with system. Its checkmark follows the saved Auto mode rather than the displayed palette; choosing a manual theme selects its fixed appearance mode. -The toolbar's foreground and background colour chips follow the `Tokens::round_chips` flag. Pro and Classic leave it off and draw Photoshop's overlapping squares with Default Colors and Switch Colors above them. Studio, Studio Light, Solarized Dark, Adwaita and Adwaita Dark (the Studio-layout themes) turn it on and draw large round chips with a curved Switch Colors arrow centred right under them, its heads touching the chips: stacked vertically in one tool column, with Default Colors at their top-right, and side by side in two tool columns, with Default Colors at the toolbar's left edge. Another non-Pro theme can opt in by setting the flag. +The toolbar's foreground and background colour chips follow the `Tokens::round_chips` flag. Pro and Classic leave it off and draw Photoshop's overlapping squares with Default Colors and Switch Colors above them. Studio, Studio Light, Solarized Dark, Adwaita, Adwaita Dark, Breeze Light and Breeze Dark (the Studio-layout themes) turn it on and draw large round chips with a curved Switch Colors arrow centred right under them, its heads touching the chips: stacked vertically in one tool column, with Default Colors at their top-right, and side by side in two tool columns, with Default Colors at the toolbar's left edge. Another non-Pro theme can opt in by setting the flag. ## Rules @@ -50,7 +54,7 @@ curl -sfL -o assets/icons/.svg https://raw.githubusercontent.com/lucide-ic | Free Transform | `transform_tool.rs` | ⌘T. Corners and edges scale proportionally (⇧ frees them: an edge then stretches one axis); ⌥ scales about the reference point; ⌘-corner distorts; dragging outside rotates (⇧ snaps to 15°); dragging inside moves. Preview = document without the moving pixels + a textured 24×24 mesh. ↩ or a double-click commits via `edit.transform {rect, quad}`. | | Quick layer pick | `quick_pick.rs` | macOS: ⌘⌥⌃-click with any tool selects the topmost visible layer with pixels under the pointer (`layer.pickAt`) without switching tools; the drag and release are swallowed. Windows/Linux have no third modifier distinct from the ⌃⌥ brush resize, so the gesture is a no-op there (use the Move tool's Auto-Select). A failed pick is a status-bar error. | | Layers rows | `panels.rs` | Double-click: on the name renames in place; on the Background makes it a normal layer; on an adjustment or fill thumbnail opens its Properties; on a Smart Object thumbnail opens its contents (Edit Contents); anywhere else on the row opens Layer Style. | -| Layer masks | `panels.rs` | Clicking the mask thumbnail targets the mask (corner-bracket frame; the tab reads "Layer, Layer Mask/8"). Brush, eraser (paints background colour), gradient and bucket then send `"target": "mask"`. Adjustment and fill layers target their mask automatically. | +| Layer masks | `panels.rs` | Clicking the mask thumbnail targets the mask (corner-bracket frame; the tab reads "Layer, Layer Mask/8"). Brush, eraser (paints background colour), gradient, bucket and moving the selected pixels (⌘-drag, `select.float`; the hole takes the background colour) then send `"target": "mask"`. Adjustment and fill layers target their mask automatically. | | Levels / Curves | `tone.rs` | Histogram of the image *below* the adjustment. Curves: click to add a point, drag out to delete. Every change is a coalesced `layer.setAdjustment`, so one drag = one undo step and the canvas updates at full resolution on the GPU. | While editing type, hold Ctrl (Windows/Linux) or Command (macOS) for an oriented transform @@ -102,6 +106,16 @@ panels. These offscreen checks do not establish native window or platform input ## Menus +An embedding application's `panels.menu_bar` and `panels.rail` flags belong to its current +session. Restoring a saved workspace or remembered panel layout preserves both flags, including +when the serialized layout contains their values. Panel visibility, dock layout, tabs and Timeline +visibility follow the saved layout. + +The Pro status bar's Document Dimensions readout follows Preferences › Units & Rulers › Rulers, +including changes made from a ruler's context menu. It uses the document resolution for physical +units, each side's own extent for percentages and the selected point-size convention for points +and picas. The readout retains the document's pixels-per-inch value. + `menu_catalog.rs` holds Photoshop's menu tree (standard command names, order, separators, default shortcuts). Items whose id matches an engine or UI command are live; others render disabled until implemented. Give new commands the catalogue's id (for example `image.imageSize`) and they light up in the right place automatically. Menus never run off the window: the menu bar's menus and submenus scroll with arrows (`menu_nav::level`), and long right-click menus (Layers, canvas tools, Channels, Paths, document tabs) wrap their rows in `widgets::menu_scroll`, so they move up to fit and scroll only when taller than the visible window. @@ -114,7 +128,7 @@ Use `ui.click {x,y}`, `ui.move`, `ui.key` and `ui.type` (synthetic input in scre The Type options bar, Character panel, Character/Paragraph Style editors and Glyphs panel share a searchable family picker. Each visible row shows an `AaBbCc` sample rendered by -PhotoCraft's text engine in that family (script/symbol fonts use characters they support). +PhotoCraft's text engine in that family (script/symbol fonts use characters they support). A family whose face also covers Arabic (alef, lam, meem, ain and the sample letters) shows a short `أبجد هوز` sample left of it, shaped right-to-left by the same engine; its row reserves the extra width once that row has been painted, and Latin-only families are unchanged. Samples are cached with a bounded cache and follow display scale and theme text colour. Builds without system fonts preview the bundled fonts. While the menu is open, Up/Down applies the previous/next matching family and scrolls it into diff --git a/docs/ui-parity.md b/docs/ui-parity.md new file mode 100644 index 0000000000..27fe0bf318 --- /dev/null +++ b/docs/ui-parity.md @@ -0,0 +1,206 @@ +# UI parity: tools, handles, modifiers and feel + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: tools, transform, nudging, snapping, modifiers, navigation, shortcuts, panels audited from source and open issues) · **Target:** Adobe Photoshop 2026 + +How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the +handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, +shortcuts and panels. Painting feel has its own checklist in [`brush-parity.md`](brush-parity.md) +and right-click menus in [`context-menu-parity.md`](context-menu-parity.md). The design system +(themes, tokens, widgets) is [`ui-design.md`](ui-design.md). + +Status: **done** (behaves as in Photoshop, with a test), **partial** (exists, differs or untested), +**missing**. Evidence is a file under `crates/ui-egui/src/` unless another path is given, a +scorecard id, or an issue. Modifier names are macOS first (⌘ ⌥ ⇧); Windows/Linux use Ctrl and Alt. + +**Summary (estimated, weights = how often the gesture is used):** ~40% ready. Tools 53/68 +present (78%, measured). Free Transform modifiers and Crop are close to Photoshop; the gaps are +in the long tail of modifiers, in-transform UI, spring-loaded tools, panels and preferences. +Remaining: 60–110 h for transforms and modifiers (gap G5), 60–100 h for tools (G6), 150–250 h +for panels and workspace (G9). + +## Tools + +Measured: the `Tool` enum (`state.rs:64`) and `TOOL_SECTIONS` (`panels.rs:17`, 22 flyout slots) +against the 67 `` entries of Photoshop's `Default Keyboard Shortcuts.kys` plus Remove, +Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustment) = 68. + +| Group | Present | Missing | +|---|---|---| +| Move, artboard | Move | Artboard (commands `layer.new.artboard*` exist) | +| Marquee | Rectangular, Elliptical, Single Row, Single Column | | +| Lasso | Lasso, Polygonal, Magnetic | | +| Selection | Object Selection, Quick Selection, Magic Wand | | +| Crop and slice | Crop, Slice, Slice Select | Perspective Crop | +| Frame | | Frame (`layer.new.frameFromLayers` exists) | +| Eyedropper | Eyedropper, Ruler, Note, Count | Color Sampler (#1046) | +| Retouch | Remove, Spot Healing, Healing, Patch, Content-Aware Move, Red Eye | | +| Brush | Brush, Pencil, Mixer Brush | Color Replacement (`paint.colorReplacement` exists) | +| Stamp | Clone Stamp, Pattern Stamp | | +| History | History Brush | Art History Brush | +| Eraser | Eraser, Background Eraser, Magic Eraser | | +| Fill | Gradient, Paint Bucket | | +| Focus, toning | Blur, Sharpen, Smudge, Dodge, Burn, Sponge | | +| Pen | Pen | Freeform Pen, Curvature Pen (#1054), Add Anchor Point, Delete Anchor Point, Convert Point (⌥ with Pen only) | +| Type | Horizontal, Vertical | Horizontal Type Mask, Vertical Type Mask | +| Path selection | Path Selection, Direct Selection | | +| Shapes | Rectangle, Ellipse, Triangle, Polygon, Line, Custom Shape | (Star is a Polygon option in Photoshop) | +| View | Hand, Rotate View, Zoom | | +| Toolbar controls | Edit Toolbar, colour chips, Quick Mask, Screen Mode | | + +| Toolbar behaviour | Status | Evidence | +|---|---|---| +| Letter shortcuts, ⇧+letter cycles the group, "Use Shift Key for Tool Switch" | done | `shortcuts.rs:413` | +| Group remembers the last tool used | partial | L always resets to Lasso (#2608) | +| Press-and-hold or click-drag opens the flyout | missing | right-click only (#2301) | +| Edit Toolbar customization | done | `edit.toolbar` | + +## Free Transform (⌘T) and transform handles + +`transform_tool.rs` (3,158 lines), behaviour summary in [`ui-design.md`](ui-design.md#interaction-models-match-photoshop-cc). + +| Behaviour | Status | Evidence | +|---|---|---| +| Eight handles, drag inside moves, outside rotates with rotation cursor and live angle | done | #1948 | +| Corners and edges proportional by default; ⇧ frees them (edge stretches one axis) | done | #1873 | +| ⌥ scales about the reference point; ⌥-click places it; draggable reference point | done | | +| ⌘-corner distort, ⌘-edge skew, ⌘⌥⇧ perspective, ⇧ rotate in 15° steps | done | | +| Warp: presets, bend, split crosswise/vertical/horizontal, 3×3–5×5 and custom grids | done | | +| Options bar X/Y, W/H % with link, angle | done | | +| Options bar H/V skew fields | missing | | +| Interpolation in the options bar | partial | Bicubic/Bilinear/Nearest; no Bicubic Smoother/Sharper/Automatic, Preserve Details | +| Commit/cancel buttons in the options bar | missing | #2442 | +| Right-click menu during transform (Scale, Rotate, Skew, Distort, Perspective, Warp, flips, rotates) | missing | TOOL-214-5 | +| Arrow keys nudge the box 1 px, ⇧ 10 px | done | `move_mods.rs` | +| Preview keeps layer order, blend mode and mask | partial | mask yes (BUG-205-2); order/blend not (#2439) | +| Preview clipped to the canvas, follows edits while open | done | #2201, #2099 | +| Hand (space) mid-transform doesn't interrupt it | partial | #2302 | +| Blend mode / opacity edits while transforming | missing | #1384 | +| Locked layer refused before the drag | missing | fails only at commit (#2585) | +| Transform a copy (⌥⌘T), step and repeat (⌥⇧⌘T) | done | 2026-10-07 | +| Transform a targeted alpha channel | missing | BUG-207-4 | +| Smart object perspective (non-affine) | missing | FILE-215-6, #579 | +| Type-layer transform while editing (⌘ held), no ⌘-corner distort on type | done | `ui-design.md` | +| Puppet Warp mesh follows the outline | missing | block grid (TOOL-214-6) | + +## Move tool and nudging + +| Behaviour | Status | Evidence | +|---|---|---| +| Arrow 1 px, ⇧-arrow 10 px for layers | done | `move_mods.rs` | +| Arrow nudge of a selection outline (selection tools) | done | #1502 | +| Arrow nudge of the crop box, X swaps orientation | done | TOOL-214-19 | +| Arrow nudge of path points with Direct Selection | missing | no arrow handling in `direct_select.rs` | +| ⌘-arrow nudges the layer from any tool | missing | #2474 | +| ⌥-drag duplicates, ⇧ constrains to 45° | done | `move_mods.rs` | +| Auto-Select with hover outline, box-select layers, double-click fit | missing | TOOL-214-4 | +| Auto-Select bounding box and snapping follow the picked layer | partial | follow the previous layer (#2666) | +| ⌘-drag temporary Move shows the box and snaps | partial | #2681 | +| ⌘-right-click layer list under the pointer | done | `layer_pick_ui.rs` | +| Moving a selection on a mask moves the mask, not the pixels | partial | #2468 | + +## Selection modifiers + +| Behaviour | Status | Evidence | +|---|---|---| +| ⇧ square/circle, ⌥ from centre, live W × H readout (marquee) | done | `marquee_tests.rs` | +| ⇧ add, ⌥ subtract, ⇧⌥ intersect from modifiers at press | partial | works; only a badge test (BUG-208-4) | +| Space repositions the marquee while dragging | done | `hold_keys.rs` | +| Polygonal Lasso ⇧ snaps to 45° | missing | #2352 | +| Autoscroll at the window edge while selecting | missing | #2231 | +| Single-pixel rows and columns kept on release | partial | #2391 | +| Esc deselects / ⌘Z undoes the last selection | partial | #2674, #1432 | +| Select › Modify live preview | missing | #1752 | + +## Pen, paths and shapes + +| Behaviour | Status | Evidence | +|---|---|---| +| Click corner, drag smooth, ⌥ breaks a handle, ⌘ direct-selects, ⇧ 45° | partial | TOOL-214-10 | +| Handle on the last point, close-drag handle | partial | #1482 | +| Path operations in the options bar (combine, subtract, intersect, exclude) | partial | `layer.combineShapes.*`; Pen options #1397 | +| On-canvas corner-radius handles on live shapes | missing | TOOL-214-12 | +| Shape stroke controls (dash, caps, joins, align) | done | TOOL-1053-1, [`shape-strokes.md`](shape-strokes.md) | +| Rotated shape selection hints correct | partial | #2512 | +| Curved shape bounds exact | partial | one pixel too wide (#2537) | + +## Crop + +| Behaviour | Status | Evidence | +|---|---|---| +| Drag outside rotates (⇧ 15°), angle readout; ↵ rotates and crops in one undo step | done | TOOL-214-18 | +| Straighten button and ⌘-drag line | done | TOOL-214-21 | +| Overlays (thirds, grid, diagonal, triangle, golden ratio/spiral), O / ⇧O | done | TOOL-214-20 | +| Crop shield, Show Cropped Area (H) | done | TOOL-214-22 | +| Default mode: the image turns and moves behind an upright box; Auto Center Preview; Use Classic Mode | done | TOOL-214-23 | +| W × H × Resolution with units, built-in size presets, Front Image | done | TOOL-214-9 (`crop_size.rs`) | +| Saved custom crop presets | missing | TOOL-214-9 | +| Content-Aware crop fill, Delete Cropped Pixels off | partial | | + +## Snapping, guides, rulers, precision + +| Behaviour | Status | Evidence | +|---|---|---| +| Snap to guides, grid, layers, document bounds and centre, selection, slices; ⌘ disables while dragging | done | `engine/src/snap.rs`, `snap_ui.rs` | +| Snap vector tools and transforms to the pixel grid | done | `snap_ui.rs:196` | +| Smart guides (magenta alignment lines) | done | | +| Smart guide distance and spacing labels | missing | | +| Ruler units px/in/cm/mm/pt/pica/% | done | | +| Status bar follows the ruler unit | partial | #2602 | +| Ruler corner and gaps drawn as Photoshop | partial | #2600, #2601, #2551 | +| Guides stored as f64 | missing | `Vec` (UI-217-20) | +| Geometry precision | partial | `geom::Point` f64, paths f64; paint internals mostly f32 | +| Numeric fields: arithmetic, scrubby labels | partial | units in expressions and double-click reset missing (UI-217-13) | + +## Navigation + +| Behaviour | Status | Evidence | +|---|---|---| +| Space = Hand, ⌘Space / ⌘⌥Space = zoom in/out, spring-loaded while held | done | `hold_keys.rs` | +| Hold any tool letter for a temporary tool | missing | #2672 | +| Scrubby zoom | done | `zoom_tool.rs` | +| Flick panning | missing | `tools.enable_flick_panning` unread | +| Animated zoom | missing | unread pref; #566 | +| Double-click Zoom tool = 100%, double-click Hand = fit | partial | Zoom missing (#2508) | +| ⌥-wheel zoom in gentle steps | missing | UI-217-18 | +| Wheel, smooth scrolling and pinch as in Photoshop | partial | #2485, #2463, #1989 | +| Trackpad rotate drives Rotate View | done | `rotate_view.rs:136` | +| Zoom levels to 3200% and beyond | partial | #1813 | +| Physical 100% at any display scale | done | #1943 | +| ⌃Tab / ⌃⇧Tab switch document tabs | missing | #2340 | + +## Keyboard shortcuts + +| Measure | Value | Kind | +|---|---|---| +| Photoshop default shortcuts (`.kys`) | 58 tool keys, 107 menu commands with 115 shortcuts, 25 tool-area commands | measured | +| PhotoCraft | ~199 shortcut assignments, 113 distinct modifier/F-key combos, 20 tool letters, 3 temporary-tool bindings | measured (source) | +| Shortcut audit | 214 → 0 failures (2026-10-07) | measured then; not re-run here | +| `.kys` import | done (`engine/src/kys.rs`) | | +| Shortcut glyphs per platform | done (UI-217-19) | | + +## Panels and workspace + +30 of 35 Window-menu panels exist (measured). Missing: Libraries, Comments, Contextual Task Bar, +Discover, Generative variations. Dock tabs: Layers, Channels, Paths, History, Actions, Layer +Comps, Properties, Adjustments, Navigator, Histogram, Info, Color, Swatches, Character, +Paragraph; the rest open as in-app floating windows. + +| Behaviour | Status | Evidence | +|---|---|---| +| Panel groups float and tear off into OS windows | missing | UI-217-5, #1828, #1213 | +| Drag panels to reorder and regroup | partial | #2272, #2305 | +| Docked resize pushes the neighbour | partial | #2573 | +| Color panel wheel and RGB/HSB/CMYK/Lab sliders | missing | UI-217-17, #2569 | +| Window size and dock width remembered | done | UI-217-2 | +| Command palette with recents | done | UI-217-7 | +| Every preference takes effect | partial | 46 of 149 do nothing (scorecard) | +| UI scale presets | missing | UI-217-9, #2492 | +| Themes: Photoshop's four greys | partial | five themes; #2661 | +| Document tabs: overflow, context menu, reorder | done | UI-217-6; middle-click close #2570 | +| Layers panel: wheel steps blend modes, drop on New/Trash | partial | UI-217-14 | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First version, from a source audit against Photoshop 2026 27.11.0's shortcuts file and the open issues | diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 32c28b5b5a..002adc76db 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -1,5 +1,5 @@ # Scorecard checklist: automation (#219). The command count and the menu wiring number come from -# docs/parity.md. +# docs/parity-checklist.md. # Statuses must be true on main. Verified against 4b53f62 on 2026-10-05. area = "automation" title = "Automation" @@ -9,7 +9,7 @@ order = 6 id = "AUTO-1" target = "Every menu item dispatches a command id (UI, CLI, control channel and MCP share them)" status = "done" -note = "docs/parity.md: all menu items live; menus.rs is_live" +note = "docs/parity-checklist.md: all menu items live; menus.rs is_live" [[item]] id = "AUTO-219-1" diff --git a/scorecard/bugs.toml b/scorecard/bugs.toml index de1b5934a5..e9f5a01ebc 100644 --- a/scorecard/bugs.toml +++ b/scorecard/bugs.toml @@ -53,7 +53,7 @@ id = "BUG-204-1" target = "Interface prefs (UI scale, font size, channels in colour, sliders, tooltips) wired or hidden" status = "done" issue = 204 -note = "prefs_ui.rs tick reads ui_scale (sync_display_scale), ui_font_size (theme::set_ui_font_size) and show_tooltips (sync_tooltips); show_channels_in_color is wired in channel_thumbs; dynamic_color_sliders is in prefs::HIDDEN_UNTIL_IMPLEMENTED" +note = "prefs_ui.rs tick reads ui_scale (sync_display_scale), ui_font_size (theme::set_ui_font_size) and show_tooltips (sync_tooltips); show_channels_in_color is wired in channel_thumbs and the canvas channel view (#2385); dynamic_color_sliders is in prefs::HIDDEN_UNTIL_IMPLEMENTED" [[item]] id = "BUG-204-2" diff --git a/scorecard/reliability.toml b/scorecard/reliability.toml index a777e62378..081af4461f 100644 --- a/scorecard/reliability.toml +++ b/scorecard/reliability.toml @@ -33,9 +33,9 @@ note = "photocraft_format::atomic_write (format/src/atomic.rs, #230): same-dir t [[item]] id = "REL-212-1" target = "Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error" -status = "missing" +status = "partial" issue = 212 -note = "try_reserve only in io/flat.rs:221; codec max_alloc limits cover decode only" +note = "Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; save/import boundaries preserve existing state, but encoder/decoder internals still need a broader fallible-allocation audit" [[item]] id = "REL-212-2" @@ -47,6 +47,6 @@ note = "PSD export uses checked size estimates for raster, masks, channels, and [[item]] id = "REL-212-3" target = "Tests: an allocation-limit injection makes each command Err with the document unchanged" -status = "missing" +status = "partial" issue = 212 -note = "Only codec/raw max_alloc tests (codecs/tests/limits.rs:48)" +note = "Engine failpoint tests cover paste, erase, fill, transform, import and save transaction boundaries; codec-level allocator failure coverage remains open" diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 27648245ea..c5190643a4 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -134,9 +134,9 @@ note = "Fuzziness-0 tests cover midtones/shadows only (selection_cmds.rs:444-447 [[item]] id = "TOOL-214-4" target = "Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab" -status = "missing" +status = "partial" issue = 214 -note = "move_mods.rs covers Shift/Alt/arrows only" +note = "Auto-Select hover outline done (Preferences › Tools › Show Highlight on Rollover, #2834); box-select layers, double-click fit and drag to a tab open" [[item]] id = "TOOL-214-5" @@ -171,7 +171,7 @@ id = "TOOL-214-9" target = "Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar" status = "partial" issue = 214 -note = "Ratio dropdown and fields, Straighten (TOOL-214-21); no W x H x Resolution with units or saved presets" +note = "Ratio dropdown and fields, Straighten (TOOL-214-21); W x H x Resolution with units, built-in size presets and Front Image (crop_size.rs, image.crop targetWidth/targetHeight/resolution, #2443); no New/Delete Crop Preset" [[item]] id = "TOOL-214-10" @@ -255,7 +255,7 @@ id = "TOOL-214-18" target = "Crop: drag outside the frame to rotate it (Shift 15 deg steps, angle readout); Enter rotates and crops in one step" status = "done" issue = 1792 -note = "image.crop angle param (image_cmds.rs: Image Rotation > Arbitrary + crop, one undo step); crop_ui.rs turns, moves and resizes the frame along its axes (Classic Mode visuals); UiState.crop_angle" +note = "image.crop angle param (image_cmds.rs: Image Rotation > Arbitrary + crop, one undo step); crop_ui.rs turns, moves and resizes the frame along its axes (Classic Mode; default mode turns the image instead, TOOL-214-23); UiState.crop_angle" [[item]] id = "TOOL-214-19" @@ -276,7 +276,14 @@ id = "TOOL-214-22" target = "Crop gear menu: Show Cropped Area (H), Enable Crop Shield with Match Canvas / custom colour, opacity and Auto Adjust Opacity" status = "done" issue = 1919 -note = "crop_shield.rs: fill() resolves the shield (canvas.rs paints it around upright and turned frames), gear popup in the options bar, H ahead of the Hand key in shortcuts.rs, Custom… opens the Color Picker; ToolOptions crop_shield, ui.set cropShield. No Classic Mode / Auto Center Preview (PhotoCraft is always Classic Mode)" +note = "crop_shield.rs: fill() resolves the shield (canvas.rs paints it around upright and turned frames), gear popup in the options bar, H ahead of the Hand key in shortcuts.rs, Custom… opens the Color Picker; ToolOptions crop_shield, ui.set cropShield. Use Classic Mode / Auto Center Preview: TOOL-214-23" + +[[item]] +id = "TOOL-214-23" +target = "Crop: Photoshop's default mode (the image turns and moves behind an upright box, Auto Center Preview keeps a resized box's centre in place and centres a drawn one) and Use Classic Mode (P) in the gear menu" +status = "done" +issue = 1919 +note = "crop_mode.rs: the crop model (crop_rect + crop_angle) and image.crop are shared by both modes; default mode sets the view's rotation to -crop_angle while the tool shows a frame (picking the tool, in either mode, turns a Rotate View turn upright about the view centre, not restored after Enter/Esc or by P; Classic Mode keeps the camera upright with the frame drawn turned; as Photoshop 25.1), pivoting so the box stays put, moves view and frame together for drags inside and arrow keys, keeps a resized box's centre in place live (Auto Center Preview; a drawn box is centred on release, a moved one never), readout = the image's turn; gear menu order and Classic Mode greying as Photoshop 25.1; CropShield classic_mode / auto_center_preview (ui.set cropShield), P ahead of the Pen key" [[item]] id = "TOOL-1053-1" diff --git a/scorecard/ui.toml b/scorecard/ui.toml index eca4a7d48c..e05a6a7a06 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -134,9 +134,9 @@ note = "canvas.rs:1107-1125 no Alt handling" [[item]] id = "UI-217-19" target = "Shortcut hints rendered per platform; Reveal label per OS" -status = "missing" +status = "done" issue = 217 -note = "~33 hard-coded modifier glyph strings in ui-egui; menu_catalog.rs:341 one label" +note = "hints and tooltips go through shortcuts::pretty (6266b7a9, #262; pretty_uses_the_platform_notation); menus::reveal_label names Reveal in Finder per OS (Show in Explorer / Show in Folder) in the Smart Objects menu and the tab menu; reveal_label_names_the_platform_file_manager + smart_objects_reveal_item_names_the_platform_file_manager" [[item]] id = "UI-217-20" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 50f3b6b13b..6c0f016464 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -35,7 +35,7 @@ commands: --pixls (or -p raw) also fetches corpus/pixls and runs the raw corpus tests; --local takes corpus/photoshop from the photocraft-corpus authoring clone stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) - parity Photoshop menu parity; rewrites docs/parity.md + parity Photoshop menu parity; rewrites docs/parity-checklist.md i18n-coverage report stable UI translation coverage for registered languages perf [--quick] [--update-baseline] [--threshold PCT] [--bench NAME]... [--skip-build] [--reuse] run the release benches, merge them by scenario id into target/perf/results.json, @@ -182,7 +182,7 @@ fn cmd_wasm() -> Result<(), String> { fn cmd_parity() -> Result<(), String> { let mut c = cargo(); - c.args(["run", "-q", "-p", "photocraft-ui-egui", "--example", "parity", "--", "--write", "docs/parity.md"]); + c.args(["run", "-q", "-p", "photocraft-ui-egui", "--example", "parity", "--", "--write", "docs/parity-checklist.md"]); run(c, "cargo run -p photocraft-ui-egui --example parity") } diff --git a/xtask/src/scorecard.rs b/xtask/src/scorecard.rs index 1e7c2711a5..f7f270bd00 100644 --- a/xtask/src/scorecard.rs +++ b/xtask/src/scorecard.rs @@ -7,7 +7,7 @@ //! - `crates/io/tests/corpus.rs`: the corpus floors (`Source { .. }` constants). //! - `scorecard/*.toml`: per-area checklists (id, target, status, issue, note). //! - the prefs audit: `Preferences` fields that no code outside `prefs.rs` / `prefs_ui.rs` reads. -//! - counted from the tree: never-crash attribute coverage, `docs/parity.md`'s live count. +//! - counted from the tree: never-crash attribute coverage, `docs/parity-checklist.md`'s live count. //! //! `--check` fails when `docs/scorecard.md` differs from what would be generated. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -432,9 +432,9 @@ fn never_crash(root: &Path) -> (usize, Vec) { (roots.len(), missing) } -/// `**626 of 626 menu items live (100.0%).**` from `docs/parity.md`. +/// `**626 of 626 menu items live (100.0%).**` from `docs/parity-checklist.md`. fn parity_line(root: &Path) -> Option { - let s = std::fs::read_to_string(root.join("docs/parity.md")).ok()?; + let s = std::fs::read_to_string(root.join("docs/parity-checklist.md")).ok()?; s.lines().find(|l| l.contains("menu items live")).map(|l| l.trim().trim_matches('*').trim_end_matches('.').to_string()) } @@ -610,7 +610,7 @@ It is a heuristic lower bound: a generic name can look read when it isn't.\n\n" } "automation" => { if let Some(p) = &inp.parity { - s.push_str(&format!("Menu wiring (`docs/parity.md`): {p}. This counts dispatch, not behaviour.\n\n")); + s.push_str(&format!("Menu wiring (`docs/parity-checklist.md`): {p}. This counts dispatch, not behaviour.\n\n")); } } "reliability" => {