diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c3597f5eea..d0f8c9770d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,7 +162,7 @@ jobs: PHOTOCRAFT_NO_PRINT=1 LPDEST=photocraft-no-such-printer timeout-minutes: 100 - # fmt, clippy, layers, scorecard, tests, panic_hunt, doctests and the wasm32 check, with + # fmt, clippy, layers, panel-commands, scorecard, tests, panic_hunt, doctests and the wasm32 check, with # `--locked --features heif`, then the missing-translations report (xtask/src/main.rs). command: python3 scripts/test-ci-gpu-lavapipe.py && cargo xtask ci && python3 scripts/ci-gpu-lavapipe.py diff --git a/AGENTS.md b/AGENTS.md index 70cbd9e3550..e2b8e229a7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,6 +148,7 @@ When parity rises, raise `FLOOR` in `crates/ui-egui/src/parity.rs` (never lower cargo test -p cargo clippy -p --all-targets -- -D warnings cargo xtask layers +cargo xtask panel-commands # if you touched ui-egui: click handlers dispatch command ids (rule 1) cargo xtask wasm # if you touched L0–L6 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) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index f11c7f30e0f..4b38673804e 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -42,7 +42,7 @@ next to them. | `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) | | `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`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg` (115 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg`, `vertical-align-*.svg` (115 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | @@ -58,6 +58,7 @@ next to them. | `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/vertical-align-{top,middle,bottom}.svg` | Type options-bar alignment glyphs for vertical text: three columns hanging from a top line, centred on a middle line, or standing on a bottom line, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/align-edge-*.svg`, `assets/icons/distribute-*.svg` | Move tool options-bar glyphs: Align (six edges/centres) and Distribute (edges, centres, spacing), a guide line or edge lines with bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | @@ -166,3 +167,16 @@ are adapted from Skyfuller1303’s MIT OR Apache-2.0 implementation in `897f20072762fdf0eb688adcdb8d8cda7b38ce66`. Don Omondi’s embedded AI/PDF/EPS extension and current-main integration build on that source. Clipboard and unrelated Shape Tools changes from that branch are not included. + +## Assets bundled by the PDF reader dependency (craft-pdf) + +PhotoCraft imports PDF pages through `craft-pdf`, pinned to the craft-libs revision +`c5a1cbfb5f9049f9270955caffb5bee5997aebf7`. The renderer vendors Hayro and the assets below; +they ship inside the dependency rather than being copied into this repository. PDFium is no +longer used or shipped. + +| Crate / file | Author | Source | Licence | Use | +| --- | --- | --- | --- | --- | +| `hayro-interpret 0.7.0 / assets/Foxit*.pfb` (13 standard-14 font files) | Foxit Software Inc. / PDFium Authors | | BSD-3-Clause | Renders non-embedded standard-14 PDF fonts | +| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | | CC0-1.0 | Default CMYK colour conversion | +| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | | BSD-3-Clause | Character-code mapping for CJK PDFs | diff --git a/Cargo.lock b/Cargo.lock index cec58ffb71e..186be182eb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,7 +22,7 @@ dependencies = [ "accesskit", "accesskit_consumer", "atspi-common", - "phf", + "phf 0.14.0", "serde", "zvariant", ] @@ -826,6 +826,17 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + [[package]] name = "brotli-decompressor" version = "5.0.3" @@ -1443,6 +1454,18 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-pdf" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "bytemuck", + "hayro", + "hayro-interpret", + "hayro-syntax", + "thiserror 2.0.21", +] + [[package]] name = "craft-print" version = "0.1.0" @@ -1472,7 +1495,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "serde", - "skrifa", + "skrifa 0.44.0", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -2107,7 +2130,7 @@ dependencies = [ "ecolor", "emath", "epaint_default_fonts", - "font-types", + "font-types 0.12.5", "harfrust", "log", "nohash-hasher", @@ -2115,13 +2138,13 @@ dependencies = [ "profiling", "self_cell", "serde", - "skrifa", + "skrifa 0.44.0", "smallvec", "unicode-bidi", "unicode-general-category", "unicode-properties", "unicode-segmentation", - "vello_cpu", + "vello_cpu 0.2.0", ] [[package]] @@ -2377,6 +2400,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + [[package]] name = "font-types" version = "0.12.5" @@ -2423,7 +2455,7 @@ dependencies = [ "objc2-core-text", "objc2-foundation 0.3.2", "parlance", - "read-fonts", + "read-fonts 0.41.0", "roxmltree 0.21.1", "smallvec", "windows", @@ -2717,6 +2749,23 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "skrifa 0.42.1", + "smallvec", + "vello_common 0.0.9", +] + [[package]] name = "glifo" version = "0.3.0" @@ -2728,9 +2777,9 @@ dependencies = [ "hashbrown 0.17.1", "log", "peniko", - "skrifa", + "skrifa 0.44.0", "smallvec", - "vello_common", + "vello_common 0.2.0", ] [[package]] @@ -2879,7 +2928,7 @@ checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" dependencies = [ "bitflags 2.13.2", "bytemuck", - "read-fonts", + "read-fonts 0.41.0", "smallvec", ] @@ -2912,6 +2961,94 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hayro" +version = "0.7.1" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "bytemuck", + "hayro-interpret", + "image", + "kurbo 0.13.1", + "log", + "pic-scale", + "vello_cpu 0.0.8", +] + +[[package]] +name = "hayro-ccitt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" + +[[package]] +name = "hayro-cmap" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a14afde3344be69225fec4f6f44f6a219a3a492f9d8301eb25ef608fe4b7014c" +dependencies = [ + "brotli", + "hayro-postscript", +] + +[[package]] +name = "hayro-interpret" +version = "0.7.0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "bitflags 2.13.2", + "hayro-cmap", + "hayro-syntax", + "kurbo 0.13.1", + "moxcms", + "phf 0.13.1", + "rustc-hash 2.1.3", + "siphasher", + "skrifa 0.47.0", + "smallvec", + "yoke", +] + +[[package]] +name = "hayro-jbig2" +version = "0.3.0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "fearless_simd", + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" +dependencies = [ + "fearless_simd", +] + +[[package]] +name = "hayro-postscript" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc2d80da85f31c3fdc0366431f44cfd645a9da1e242ed1f4a61f213d9765660f" + +[[package]] +name = "hayro-syntax" +version = "0.7.2" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "flate2", + "hayro-ccitt", + "hayro-jbig2", + "hayro-jpeg2000", + "memchr", + "rustc-hash 2.1.3", + "smallvec", + "stringprep", + "zune-jpeg 0.5.15", +] + [[package]] name = "heck" version = "0.5.0" @@ -5001,7 +5138,7 @@ dependencies = [ "linebender_resource_handle", "parlance", "parley_data", - "skrifa", + "skrifa 0.44.0", ] [[package]] @@ -5080,17 +5217,37 @@ dependencies = [ "indexmap", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.14.0", + "phf_shared 0.14.0", "serde", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.14.0" @@ -5098,7 +5255,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.14.0", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -5107,13 +5277,22 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.14.0" @@ -5242,6 +5421,7 @@ dependencies = [ "clap_complete", "clap_mangen", "photocraft-automation", + "photocraft-cms", "photocraft-codecs", "photocraft-doc", "photocraft-engine", @@ -5434,9 +5614,11 @@ version = "0.20.1" dependencies = [ "blake3", "craft-docpath", + "craft-pdf", "crc32fast", "flate2", "imagesize", + "kurbo 0.13.1", "lopdf", "miniz_oxide 0.9.1", "photocraft-affinity", @@ -5585,7 +5767,7 @@ dependencies = [ "photocraft-psd", "photocraft-raster", "rayon", - "skrifa", + "skrifa 0.44.0", ] [[package]] @@ -5681,6 +5863,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "pic-scale" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb0ff645d35accaec252de081d55984a0d6c952cb1e5dc2b9afa7c78f07288" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -6306,6 +6498,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + [[package]] name = "read-fonts" version = "0.41.0" @@ -6313,7 +6515,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types", + "font-types 0.12.5", + "once_cell", +] + +[[package]] +name = "read-fonts" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" +dependencies = [ + "bytemuck", + "font-types 0.12.5", "once_cell", ] @@ -7260,6 +7473,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts 0.39.2", +] + [[package]] name = "skrifa" version = "0.44.0" @@ -7267,7 +7490,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.41.0", +] + +[[package]] +name = "skrifa" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "526e654b303ff4ad73a5a40b25de2b3ce3d039c5e3b6da5f89ae6e57004cb40f" +dependencies = [ + "bytemuck", + "read-fonts 0.44.0", ] [[package]] @@ -8272,6 +8505,40 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vello_common" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "smallvec", + "thiserror 2.0.21", +] + [[package]] name = "vello_common" version = "0.2.0" @@ -8287,6 +8554,19 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "vello_cpu" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" +dependencies = [ + "bytemuck", + "glifo 0.1.1", + "hashbrown 0.17.1", + "png 0.18.1", + "vello_common 0.0.8", +] + [[package]] name = "vello_cpu" version = "0.2.0" @@ -8294,9 +8574,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", - "glifo", + "glifo 0.3.0", "hashbrown 0.17.1", - "vello_common", + "vello_common 0.2.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e01bcf175ab..f5fafa868d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ photocraft-ipp = { path = "crates/ipp" } # external craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["raster"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } craft-pcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "b8f7d93b00dfb8f172938f3d2d796ae62dcfe123" } @@ -203,3 +204,8 @@ eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc79968157 egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } +# craft-pdf's vendored Hayro trees: consumers patch the same craft-libs revision (crates/pdf README). +hayro = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } diff --git a/NOTICE b/NOTICE index 9ab6b0a1cf3..d51db72c912 100644 --- a/NOTICE +++ b/NOTICE @@ -70,3 +70,51 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Optional PDF reader (craft-pdf) + +PhotoCraft imports PDF pages through craft-pdf (https://github.com/storytold/craft-libs, +MIT OR Apache-2.0), pinned to revision c5a1cbfb5f9049f9270955caffb5bee5997aebf7. It vendors +Hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0 and +hayro-cmap 0.1.0 (https://github.com/LaurenzV/hayro, MIT OR Apache-2.0). The reader bundles +the standard-14 Foxit fonts (BSD-3-Clause), Adobe CMaps (BSD-3-Clause) and a Compact ICC +profile by Clinton Ingram (CC0-1.0); per-file sources and hashes are in craft-libs +ATTRIBUTION.md. No PDF assets are copied into this repository, and PDFium is no longer used or +shipped. + +Licence texts for the optional PDF reader + +Hayro (MIT): +Copyright (c) The Hayro Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Standard-14 Foxit fonts (BSD-3-Clause): +Copyright 2014 PDFium Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +* Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Adobe CMaps (BSD-3-Clause): +Copyright 1990-2023 Adobe. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +Neither the name of Adobe nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Compact ICC profile: CC0 1.0 Universal (public domain dedication), by Clinton Ingram (saucecontrol/Compact-ICC-Profiles). diff --git a/README.md b/README.md index b1b453a3180..c1789ab7d22 100644 --- a/README.md +++ b/README.md @@ -185,8 +185,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
-

🧰 68 tools

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

🧰 69 tools

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

🖌️ A real brush engine

diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index 4542a71da95..7640483f440 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -33,7 +33,7 @@ required-features = ["gpu"] photocraft-automation = { path = "../../crates/automation" } photocraft-doc = { workspace = true } photocraft-engine = { workspace = true } -photocraft-io = { workspace = true } +photocraft-io = { workspace = true, features = ["pdf-editable"] } photocraft-raster = { workspace = true } photocraft-format = { path = "../../crates/format" } photocraft-codecs = { workspace = true } @@ -47,6 +47,8 @@ serde = { workspace = true } tempfile = "3" # Reading back a saved PSD's merged-image flag (#8064). photocraft-psd = { workspace = true } +# A real ICC profile for the --metadata test (#9466). +photocraft-cms = { workspace = true } # Synthetic camera raws (#7789). photocraft-raw = { workspace = true, features = ["testgen"] } # Shell completions and man pages (examples/man-and-completions.rs, for the packages); not in diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index 87c0b5599c3..2baf000039b 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -17,7 +17,7 @@ use clap::parser::ValueSource; use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand}; use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, conflict, control_port, name_env_vars}; use photocraft_codecs::{JpegFormat, TiffCompression}; -use photocraft_io::{DdsCompression, DpxBits, TgaBits}; +use photocraft_io::{DdsCompression, DpxBits, TgaBits, XmpEmbed}; use serde_json::Value; /// photocraft-cli: headless PhotoCraft @@ -93,6 +93,12 @@ pub(crate) struct ExportArgs { /// real image #[arg(long)] pub(crate) no_maximize_compatibility: bool, + /// The document metadata a flat export embeds: all (the default) keeps the document's XMP + /// packet and free-form text where the format holds them, as Save As does; none leaves them + /// out, as Export As's Metadata: None. Nothing else changes: the ICC profile, EXIF and + /// resolution are written as they are without it. PSD, PSB and .pcraft always keep the XMP + #[arg(long, value_name = "none|all", value_parser = metadata)] + pub(crate) metadata: Option, } /// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …) @@ -113,6 +119,16 @@ pub(crate) struct ConvertArgs { /// Explicitly approve high-memory PSD/PSB imports within the hard memory budget #[arg(long)] pub(crate) approve_high_memory: bool, + /// PDF import resolution in DPI (default 300) + #[arg(long, value_name = "DPI", value_parser = pdf_dpi)] + pub(crate) pdf_dpi: Option, + /// PDF pages to import, 1-based: "3" or "2-7" (default all) + #[arg(long)] + pub(crate) pdf_pages: Option, + /// Import PDF paths and embedded-font text as editable layers (experimental; unsupported + /// pages fall back to raster, and embedded fonts may change when edited or reopened) + #[arg(long)] + pub(crate) pdf_editable: bool, #[command(flatten)] pub(crate) gpu: GpuArgs, #[command(flatten)] @@ -312,8 +328,12 @@ enum ValueError { TiffCompression, #[error("expected baseline, optimized or progressive")] JpegFormat, + #[error("expected none or all")] + Metadata, #[error("expected on or off")] OnOff, + #[error("expected a positive number")] + PdfDpi, /// Why a `--bridge` value, or a PHOTOCRAFT_CONTROL_PORT value for `mcp`, is refused. #[error("expected a port, 0-65535 (--bridge also takes 127.0.0.1:)")] Bridge, @@ -382,10 +402,24 @@ fn on_off(s: &str) -> Result { } } +/// A PDF import DPI: a finite number greater than zero. The importer's fallback to 300 DPI is +/// for absent preferences, not for a mistyped flag. +fn pdf_dpi(s: &str) -> Result { + s.parse::().ok().filter(|dpi| dpi.is_finite() && *dpi > 0.0).ok_or(ValueError::PdfDpi) +} + fn jpeg_format(s: &str) -> Result { JpegFormat::parse(s).ok_or(ValueError::JpegFormat) } +fn metadata(s: &str) -> Result { + match s { + "none" => Ok(XmpEmbed::None), + "all" => Ok(XmpEmbed::All), + _ => Err(ValueError::Metadata), + } +} + /// Where `mcp --bridge` connects. Shown as the address: `127.0.0.1:` for a port. #[derive(Clone)] pub(crate) enum Bridge { @@ -532,3 +566,19 @@ fn check_server(sub: &mut clap::Command, m: &ArgMatches, has_transport: bool, tr } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// `--pdf-dpi` only takes a positive finite number: a mistyped value is refused at parse time + /// instead of silently becoming the importer's 300 DPI fallback. + #[test] + fn pdf_dpi_rejects_non_positive_and_non_finite_values() { + assert!(pdf_dpi("300").is_ok()); + assert!(pdf_dpi("0.5").is_ok()); + for bad in ["0", "-1", "nan", "inf", "-inf", "abc"] { + assert!(pdf_dpi(bad).is_err(), "{bad} should be refused"); + } + } +} diff --git a/apps/photocraft-cli/src/document.rs b/apps/photocraft-cli/src/document.rs index 95e8d4d8e2e..5541da11a62 100644 --- a/apps/photocraft-cli/src/document.rs +++ b/apps/photocraft-cli/src/document.rs @@ -26,11 +26,29 @@ fn refuse_overwriting_input(in_place: bool, input: &Path, output: &Path) -> Resu Ok(()) } +/// `N` or `N-M`, 1-based and inclusive, into a normalized `(first, last)`. +fn parse_pdf_pages(s: &str) -> Result<(u32, u32)> { + let (a, b) = s.split_once('-').unwrap_or((s, s)); + let bad = || CliError::Other(format!("invalid --pdf-pages {s:?}; use N or N-M")); + let a = a.trim().parse::().map_err(|_| bad())?; + let b = b.trim().parse::().map_err(|_| bad())?; + if a == 0 || b == 0 { + return Err(CliError::Other(format!("--pdf-pages is 1-based; {s:?} starts at 0"))); + } + Ok((a.min(b), a.max(b))) +} + pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write) -> Result { let (input, output) = (&a.input, &a.output); let opts = ExportOptions::from(&a.export); refuse_overwriting_input(a.in_place, input, output)?; - let import_opts = photocraft_io::ImportOptions { psd_high_memory_approved: a.approve_high_memory, ..Default::default() }; + let import_opts = photocraft_io::ImportOptions { + psd_high_memory_approved: a.approve_high_memory, + pdf_dpi: a.pdf_dpi.unwrap_or(300.0), + pdf_pages: a.pdf_pages.as_deref().map(parse_pdf_pages).transpose()?, + pdf_editable: a.pdf_editable, + ..Default::default() + }; let mut o = files::open_with_options(input, &import_opts)?; // Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw. photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document); diff --git a/apps/photocraft-cli/src/output.rs b/apps/photocraft-cli/src/output.rs index 05861d7477e..1628e8a5cb7 100644 --- a/apps/photocraft-cli/src/output.rs +++ b/apps/photocraft-cli/src/output.rs @@ -34,6 +34,10 @@ impl From<&ExportArgs> for ExportOptions { } o.encode.dds.mipmaps = a.dds_mipmaps; o.psd_maximize_compatibility = !a.no_maximize_compatibility; + // Conversions keep the whole packet unless asked, as Save As does (#733, #9466). + if let Some(m) = a.metadata { + o.xmp = m; + } o } } diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index a0c72c39b24..2cb64e6e551 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -980,6 +980,88 @@ fn jpeg_format_chooses_the_scans() { } } +/// #9466: `--metadata none` leaves the document's XMP packet and free-form text out of a flat +/// export from `convert`, `run --out` and `batch`, as Export As's Metadata: None does, and changes +/// nothing else (pixels, ICC profile, resolution). The default, and `all`, keep them as before (the +/// text where the format holds it, as PNG does), and a PSD or `.pcraft` keeps the XMP either way. +/// A value it doesn't name is a usage error and nothing is written. +#[test] +fn metadata_none_leaves_out_the_document_xmp() { + const XMP: &str = "\ +\ +xmp.did:11111111-2222-3333-4444-555555555555\ +"; + let d = tmp("metadata"); + let input = d.join("in"); + std::fs::create_dir_all(&input).unwrap(); + let a = input.join("a.png"); + let data: Vec = (0..8 * 6 * 4).map(|i| (i as u8).wrapping_mul(7).wrapping_add(3)).collect(); + let mut img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgba, data).unwrap(); + img.meta.xmp = Some(XMP.into()); + img.meta.dpi = Some((300.0, 300.0)); + img.meta.text = vec![("Comment".into(), "free-form-note".into())]; + // Display P3 rather than sRGB, so no export can pass for keeping it by writing its default. + let p3 = photocraft_cms::Builtin::DisplayP3.profile().to_bytes().to_vec(); + img.icc = Some(p3.clone()); + std::fs::write(&a, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); + let ancestors = |p: &Path| String::from_utf8_lossy(&std::fs::read(p).unwrap()).contains("DocumentAncestors"); + let decoded = |p: &Path| photocraft_codecs::decode(&std::fs::read(p).unwrap()).unwrap(); + let note = |p: &Path| decoded(p).meta.text.iter().any(|(_, v)| v == "free-form-note"); + assert!(ancestors(&a) && note(&a), "the input carries the packet and the text"); + let invert = r#"[{"command":"image.adjustments.invert"}]"#; + for ext in ["png", "webp", "jpg", "tif"] { + let mut converted_files = Vec::new(); + for flag in [None, Some("all"), Some("none")] { + let extra: Vec<&str> = flag.map(|f| vec!["--metadata", f]).unwrap_or_default(); + let name = format!("{ext}-{}", flag.unwrap_or("default")); + let converted = d.join(format!("convert-{name}.{ext}")); + ok(bin().arg("convert").arg(&a).arg(&converted).args(&extra)); + let ran = d.join(format!("run-{name}.{ext}")); + ok(bin().arg("run").arg(&a).arg("--out").arg(&ran).args(&extra)); + let o = batch_with(&d, &input, &format!("batch-{name}"), invert, &[&["--format", ext][..], &extra[..]].concat()); + assert!(o.status.success(), "batch {name}: {}", String::from_utf8_lossy(&o.stderr)); + let batched = d.join(format!("batch-{name}")).join(format!("a.{ext}")); + for out in [&converted, &ran, &batched] { + assert_eq!(ancestors(out), flag != Some("none"), "{name}: {}", out.display()); + // PNG holds free-form text; JPEG, WebP and TIFF (but for its named tags) don't. + assert_eq!(note(out), ext == "png" && flag != Some("none"), "{name} text: {}", out.display()); + } + converted_files.push(converted); + } + // Only the XMP and text go: the pixels, profile and resolution are the default export's. + let (default, none) = (decoded(&converted_files[0]), decoded(&converted_files[2])); + assert_eq!(none.data(), default.data(), "{ext}: pixels"); + assert_eq!(default.icc.as_deref(), Some(&p3[..]), "{ext}: the default export keeps the input's Display P3 profile"); + assert_eq!(none.icc, default.icc, "{ext}: ICC profile"); + assert_eq!(none.meta.dpi, default.meta.dpi, "{ext}: resolution"); + assert!(none.meta.dpi.is_some_and(|(x, y)| (x - 300.0).abs() < 0.5 && (y - 300.0).abs() < 0.5), "{ext}: {:?}", none.meta.dpi); + } + // A layered save keeps the document's XMP whatever the option. + for ext in ["psd", "pcraft"] { + let out = d.join(format!("layered.{ext}")); + ok(bin().arg("convert").arg(&a).arg(&out).args(["--metadata", "none"])); + let back = d.join(format!("layered-{ext}.png")); + ok(bin().arg("convert").arg(&out).arg(&back)); + assert!(ancestors(&back), "{ext} kept the packet"); + } + for bad in ["None", "exif", "0", ""] { + let out = d.join("bad.png"); + let (mut convert, mut run) = (bin(), bin()); + convert.arg("convert").arg(&a).arg(&out).args(["--metadata", bad]); + run.arg("run").arg(&a).arg("--out").arg(&out).args(["--metadata", bad]); + for cmd in [&mut convert, &mut run] { + let o = cmd.output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "--metadata {bad}: {err}"); + assert!(err.contains("--metadata") && err.contains("expected none or all"), "--metadata {bad}: {err}"); + assert!(!out.exists(), "--metadata {bad} wrote a file"); + } + let o = batch_with(&d, &input, "bad-batch", invert, &["--metadata", bad]); + assert_eq!(o.status.code(), Some(2), "batch --metadata {bad}"); + assert!(!d.join("bad-batch").exists(), "batch --metadata {bad} wrote a folder"); + } +} + /// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before /// anything is written, unless `--in-place` asks for it. #[test] diff --git a/apps/photocraft/src/cursor.rs b/apps/photocraft/src/cursor.rs index 719e8239c91..72548ca6383 100644 --- a/apps/photocraft/src/cursor.rs +++ b/apps/photocraft/src/cursor.rs @@ -6,7 +6,7 @@ //! - **macOS**: `NSEvent.mouseLocation`, converted into the content view of our window under it. //! - **X11**: `QueryPointer` on our window (a second, pure-Rust X connection). //! - **Windows**: `GetCursorPos`, relative to the window's client area. -//! - **Wayland**: winit 0.30 delivers no file drops there, so there's nothing to place. +//! - **Wayland**: winit delivers file drops, and egui's last pointer position is used as fallback. //! //! No `unsafe`: every OS call goes through a safe binding. //! diff --git a/assets/icons/vertical-align-bottom.svg b/assets/icons/vertical-align-bottom.svg new file mode 100644 index 00000000000..09931ffd772 --- /dev/null +++ b/assets/icons/vertical-align-bottom.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/assets/icons/vertical-align-middle.svg b/assets/icons/vertical-align-middle.svg new file mode 100644 index 00000000000..019f70872dc --- /dev/null +++ b/assets/icons/vertical-align-middle.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/assets/icons/vertical-align-top.svg b/assets/icons/vertical-align-top.svg new file mode 100644 index 00000000000..c5269fe99ad --- /dev/null +++ b/assets/icons/vertical-align-top.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/book/src/automation/cli.md b/book/src/automation/cli.md index 6137b12466a..c7278455ef5 100644 --- a/book/src/automation/cli.md +++ b/book/src/automation/cli.md @@ -35,6 +35,27 @@ Warnings do not change the saved document's font declarations and do not stop the operation. To list or replace the missing families intentionally, use `type.resolveMissingFonts` on the document. +### Document metadata + +`convert`, `run --out` and `batch` keep the document's XMP packet in a flat +export whose format holds one (PNG, JPEG, WebP, TIFF, …), and its free-form +text where the format holds that too (PNG text chunks, TIFF's named text tags +such as ImageDescription; JPEG and WebP have no place for it), as Save As +does. `--metadata none` leaves them out, as Export As's Metadata: None does: +a Photoshop file's XMP can list every placed document's id +(`DocumentAncestors`) and the text of every type layer, and can be far larger +than the image. `--metadata all` is the default. The option changes nothing +else: the ICC profile, EXIF and resolution are written as they are without +it, where the format holds them. An Indexed Color document saved as a palette +PNG carries no XMP, text, ICC profile or EXIF with either value, only its +resolution. PSD, PSB and `.pcraft` saves always keep the XMP. It is not a full +metadata scrub. + +```sh +photocraft-cli batch --actions resize.json --in masters/ --out web/ \ + --format webp --metadata none +``` + ### Export selected adjustment layers as a .cube LUT The engine command `file.export.colorLookupTables` creates a 3D LUT from an diff --git a/contributors/people.toml b/contributors/people.toml index a2ead57598d..5995994db31 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -315,3 +315,7 @@ sync_github_name = false [people.davidvhk] real_name = "David Vanhoucke" sync_github_name = true + +[people.rmsff] +real_name = "Serikov Roman" +sync_github_name = false diff --git a/crates/algo/src/puppet.rs b/crates/algo/src/puppet.rs index a1b8cbd91d2..ba5e2631f8d 100644 --- a/crates/algo/src/puppet.rs +++ b/crates/algo/src/puppet.rs @@ -1117,6 +1117,50 @@ pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp) puppet_warp_with_mask(src, bounds, w, interp, None).map(|(out, _)| out) } +/// How many times over a stored mesh's triangles may cover its deformed box, counting each +/// triangle's bounding box: a real mesh tiles the box about twice (a fold adds a layer), a +/// crafted one stacks large triangles to multiply the drawing work. +const MAX_MESH_COVERAGE: f64 = 16.0; + +/// Draws `src` (content inside `bounds`) through a mesh solved elsewhere, as a Photoshop file +/// stores its Puppet Warp: `mesh.verts` move to `deformed`, the triangles drawn in their stored +/// order. `None` for a malformed mesh (sizes that don't match, an index out of range, a vertex +/// that isn't finite), one whose deformed box spans more than [`MAX_WARP_PIXELS`] or whose +/// triangles cover that box more than [`MAX_MESH_COVERAGE`] times. +pub fn warp_with_mesh(src: &Surface, bounds: Rect, mesh: &PuppetMesh, deformed: &[[f64; 2]], interp: Interp) -> Option { + let n = mesh.verts.len(); + if mesh.tris.is_empty() || deformed.len() != n || mesh.tris.iter().flatten().any(|&i| i >= n) { + return None; + } + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for p in mesh.verts.iter().chain(deformed) { + if !(p[0].is_finite() && p[1].is_finite()) { + return None; + } + } + for p in deformed { + (x0, y0, x1, y1) = (x0.min(p[0]), y0.min(p[1]), x1.max(p[0]), y1.max(p[1])); + } + let area = (x1 - x0 + 1.0) * (y1 - y0 + 1.0); + if area > MAX_WARP_PIXELS { + return None; + } + let covered: f64 = mesh + .tris + .iter() + .map(|t| { + let (xs, ys) = (t.map(|i| deformed[i][0]), t.map(|i| deformed[i][1])); + let span = |v: [f64; 3]| v.iter().fold(f64::MIN, |m, &a| m.max(a)) - v.iter().fold(f64::MAX, |m, &a| m.min(a)) + 1.0; + span(xs) * span(ys) + }) + .sum(); + if covered > MAX_MESH_COVERAGE * area { + return None; + } + let order: Vec = (0..mesh.tris.len()).collect(); + Some(render(src, bounds, mesh, deformed, &order, interp)) +} + /// [`puppet_warp`], also moving `mask` (a one-channel layer mask linked to `src`) with the same /// mesh, as Photoshop moves a linked mask with its layer (#4910). The mask is `None` in the /// result when none was given. @@ -1267,6 +1311,50 @@ mod tests { assert!(!in_mesh(&m, [40.0, 50.0]) && !in_mesh(&m, [60.0, 20.0])); } + #[test] + fn a_stored_mesh_is_drawn_as_it_is() { + // A Photoshop file's Puppet Warp keeps its solved mesh: two triangles over a 40 × 40 box, + // sheared and moved. A dot lands where the mesh's affine map puts it; pixels outside the + // mesh are dropped. + let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true)); + for y in 0..48 { + for x in 0..48 { + let dot = (f64::from(x) + 0.5 - 12.0).hypot(f64::from(y) + 0.5 - 20.0) <= 2.0; + s.write_pixel(x, y, &if dot { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] }); + } + } + let mesh = PuppetMesh { verts: vec![[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0]], tris: vec![[0, 1, 2], [0, 2, 3]], spacing: 20.0 }; + let map = |p: [f64; 2]| [p[0] + 0.25 * p[1] + 3.0, p[1] + 5.0]; + let deformed: Vec<[f64; 2]> = mesh.verts.iter().map(|v| map(*v)).collect(); + let b = s.content_bounds(); + let out = warp_with_mesh(&s, b, &mesh, &deformed, Interp::Bilinear).expect("a valid mesh draws"); + let ob = out.content_bounds(); + let (mut sw, mut sx, mut sy) = (0.0, 0.0, 0.0); + for (i, px) in out.read_region(ob).as_chunks::<4>().0.iter().enumerate() { + let w = f64::from(px[0] * px[3]); + let (x, y) = (f64::from(ob.x0) + (i % ob.width() as usize) as f64 + 0.5, f64::from(ob.y0) + (i / ob.width() as usize) as f64 + 0.5); + (sw, sx, sy) = (sw + w, sx + w * x, sy + w * y); + } + let want = map([12.0, 20.0]); + assert!((sx / sw - want[0]).abs() < 0.05 && (sy / sw - want[1]).abs() < 0.05, "dot at {:?}, want {want:?}", [sx / sw, sy / sw]); + // The source column x = 44 is outside the mesh: nothing lands right of the sheared box. + assert!(ob.x1 <= 54 && ob.y1 <= 45, "{ob:?}"); + // Malformed meshes are refused rather than drawn. + assert!(warp_with_mesh(&s, b, &mesh, &deformed[..3], Interp::Bilinear).is_none()); + let bad = PuppetMesh { tris: vec![[0, 1, 4]], ..mesh.clone() }; + assert!(warp_with_mesh(&s, b, &bad, &deformed, Interp::Bilinear).is_none()); + let mut far = deformed.clone(); + far[2] = [f64::NAN, 0.0]; + assert!(warp_with_mesh(&s, b, &mesh, &far, Interp::Bilinear).is_none()); + far[2] = [1.0e6, 1.0e6]; + assert!(warp_with_mesh(&s, b, &mesh, &far, Interp::Bilinear).is_none()); + // The same two triangles stacked 40 times: far more drawing than the box needs. + let stacked = PuppetMesh { tris: mesh.tris.repeat(40), ..mesh.clone() }; + assert!(warp_with_mesh(&s, b, &stacked, &deformed, Interp::Bilinear).is_none()); + let none = PuppetMesh { tris: Vec::new(), ..mesh }; + assert!(warp_with_mesh(&s, b, &none, &deformed, Interp::Bilinear).is_none()); + } + #[test] fn no_pins_or_unmoved_pins_are_identity() { for st in [SampleType::U8, SampleType::U16, SampleType::F32] { diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index daf7a2526af..0d8866e18ef 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -219,11 +219,44 @@ pub fn magic_wand(px: &[[f32; 4]], area: Rect, seed: (i32, i32), tolerance: f32, } } if anti_alias { - antialias(&mut m, w, h); + let tol = (tolerance + 1e-3).floor().max(0.0); + let src = m.clone(); + for (i, v) in m.iter_mut().enumerate() { + if src[i] == 0.0 && touches_region(i % w, i / w, w, h, |x, y| src[y * w + x] > 0.0) { + let p = px[i]; + let over = (0..3).map(|c| (p[c] - target[c]).abs() * 255.0).fold(0.0f32, f32::max) - tol; + *v = f32::from(fringe_coverage(over, p[3].min(target[3]) * 255.0)) / 255.0; + } + } } m } +/// How many levels past the tolerance the anti-aliased fringe fades over, from fully selected to +/// nothing. Measured in the reference application (#11776): 3 levels past gives 251, 5 gives +/// 249 and 123 gives 110; a straight fade to zero at 216 levels gives all three. +const FRINGE_LEVELS: f32 = 216.0; + +/// Coverage (0–255) of an anti-aliased fringe pixel: a neighbour of the region whose most +/// different colour channel is `over` levels past the tolerance, and `alpha` (0–255) the lower +/// of its and the clicked pixel's opacity. Photoshop keeps the region itself fully selected and +/// softens only the pixels just outside it, fading with the distance past the tolerance; a +/// neighbour without colour (transparent, next to a shape on its own layer) gets none. +fn fringe_coverage(over: f32, alpha: f32) -> u8 { + let k = (1.0 - over.max(0.0) / FRINGE_LEVELS).clamp(0.0, 1.0) * (alpha / 255.0).clamp(0.0, 1.0); + (k * 255.0).round() as u8 +} + +/// True when one of the 8 neighbours of `(x, y)` (inside a `w` x `h` area) is in the region. +fn touches_region(x: usize, y: usize, w: usize, h: usize, selected: impl Fn(usize, usize) -> bool) -> bool { + (-1i32..=1).any(|dy| { + (-1i32..=1).any(|dx| { + let (nx, ny) = (x as i32 + dx, y as i32 + dy); + (dx, dy) != (0, 0) && nx >= 0 && ny >= 0 && (nx as usize) < w && (ny as usize) < h && selected(nx as usize, ny as usize) + }) + }) +} + /// Straight RGBA8 copy of `area` of a surface, read in parallel bands on native. pub fn rgba8_image(s: &Surface, area: Rect) -> Vec<[u8; 4]> { let (w, h) = (area.width() as usize, area.height() as usize); @@ -270,6 +303,22 @@ impl Region { /// byte mark per pixel, then anti-aliases just the edges inside the touched bounding box. /// Matches [`magic_wand`] for 8-bit sources. `None` if the seed is outside `area`. pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32, contiguous: bool, anti_alias: bool) -> Option { + wand_region_within(img, area, seed, tolerance, contiguous, anti_alias, None) +} + +/// [`wand_region`] whose contiguous fill only passes through pixels at least half selected in +/// `within` (the current selection's coverage over `area`, row-major): Subtract and Intersect +/// grow the region from the seed through the selection only (#11779). A seed outside it finds +/// nothing. A global match (`contiguous` off) ignores `within`. +pub fn wand_region_within( + img: &[[u8; 4]], + area: Rect, + seed: (i32, i32), + tolerance: f32, + contiguous: bool, + anti_alias: bool, + within: Option<&[f32]>, +) -> Option { if !area.contains(seed.0, seed.1) { return None; } @@ -277,6 +326,11 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32 if w.checked_mul(h).is_none_or(|n| n > img.len()) { return None; } + let within = within.filter(|_| contiguous).filter(|m| m.len() >= w * h); + let inside = |i: usize| within.is_none_or(|m| m.get(i).is_some_and(|v| *v >= 0.5)); + if !inside((seed.1 - area.y0) as usize * w + (seed.0 - area.x0) as usize) { + return None; + } let target = img[(seed.1 - area.y0) as usize * w + (seed.0 - area.x0) as usize]; let tol = (tolerance + 1e-3).floor().max(0.0) as i32; // Copying a selection can leave RGB under zero alpha. Those invisible colours must not @@ -293,10 +347,10 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32 continue; } let (mut l, mut r) = (x, x); - while l > 0 && marks[row + l - 1] == 0 && similar(img[row + l - 1]) { + while l > 0 && marks[row + l - 1] == 0 && similar(img[row + l - 1]) && inside(row + l - 1) { l -= 1; } - while r + 1 < w && marks[row + r + 1] == 0 && similar(img[row + r + 1]) { + while r + 1 < w && marks[row + r + 1] == 0 && similar(img[row + r + 1]) && inside(row + r + 1) { r += 1; } marks[row + l..=row + r].fill(255); @@ -308,7 +362,7 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32 let nrow = ny * w; let mut in_run = false; for nx in l..=r { - let ok = marks[nrow + nx] == 0 && similar(img[nrow + nx]); + let ok = marks[nrow + nx] == 0 && similar(img[nrow + nx]) && inside(nrow + nx); if ok && !in_run { stack.push((nx, ny)); } @@ -356,25 +410,12 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32 let x = x0 + xx; let v = at(x, y); *o = v; - if !anti_alias { - continue; - } - // Same rule as `antialias`: edge pixels blend with their 3×3 neighbourhood; neighbours - // outside the area count as the pixel itself. - let mut sum = 0u32; - let mut edge = false; - for dy in -1i32..=1 { - for dx in -1i32..=1 { - let (nx, ny) = (x as i32 + dx, y as i32 + dy); - let n = if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 { v } else { at(nx as usize, ny as usize) }; - edge |= n != v; - sum += n as u32; - } - } - if edge { - let avg = sum as f32 / (9.0 * 255.0); - let f = if v > 127 { 0.5 + 0.5 * avg } else { 0.5 * avg }; - *o = (f * 255.0).round() as u8; + // Same rule as `magic_wand`: the region stays fully selected; a pixel just outside + // it gets a fringe by how far its colour is past the tolerance. + if anti_alias && v == 0 && touches_region(x, y, w, h, |nx, ny| at(nx, ny) != 0) { + let p = img[y * w + x]; + let d = (0..3).map(|c| (p[c] as i32 - target[c] as i32).abs()).max().unwrap_or(0); + *o = fringe_coverage((d - tol) as f32, f32::from(p[3].min(target[3]))); } } }; @@ -435,32 +476,6 @@ pub fn combine_region(old: Option<&Surface>, new: Option<&Region>, mode: Selecti } } -/// Softens hard mask edges: edge pixels become the 3×3 average. -fn antialias(m: &mut [f32], w: usize, h: usize) { - let src = m.to_vec(); - for y in 0..h { - for x in 0..w { - let i = y * w + x; - let mut sum = 0.0; - let mut cnt = 0.0; - let mut edge = false; - for dy in -1i32..=1 { - for dx in -1i32..=1 { - let (xx, yy) = (x as i32 + dx, y as i32 + dy); - let v = if xx < 0 || yy < 0 || xx >= w as i32 || yy >= h as i32 { src[i] } else { src[yy as usize * w + xx as usize] }; - edge |= v != src[i]; - sum += v; - cnt += 1.0; - } - } - if edge { - // Keep selected pixels mostly selected, add a soft fringe. - m[i] = if src[i] > 0.5 { 0.5 + 0.5 * sum / cnt } else { 0.5 * sum / cnt }; - } - } - } -} - /// Colour Range: coverage falls off linearly with the per-channel colour /// distance; `fuzziness` in levels (0–200). pub fn color_range(px: &[[f32; 4]], color: [f32; 3], fuzziness: f32) -> Vec { @@ -780,10 +795,13 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec { mod distance; #[cfg(test)] mod ellipse_aa_edge_tests; + mod inverse; mod lasso; mod marquee; mod morphology; +#[cfg(test)] +mod wand_antialias_tests; pub use distance::edt; pub use inverse::inverse_surface; diff --git a/crates/algo/src/selection/tests.rs b/crates/algo/src/selection/tests.rs index 4e7bdcbe069..5f02b53a58e 100644 --- a/crates/algo/src/selection/tests.rs +++ b/crates/algo/src/selection/tests.rs @@ -106,7 +106,9 @@ fn wand_contiguous_vs_global() { let g = magic_wand(&px, a, (3, 3), 10.0, false, false); assert_eq!(g.iter().filter(|v| **v > 0.0).count(), 80); let aa = magic_wand(&px, a, (3, 3), 10.0, true, true); - assert!(aa[3 * 20 + 6] > 0.0 && aa[3 * 20 + 6] < 1.0); + // #11776: anti-aliasing keeps the region whole and adds no fringe next to a colour this far + // past the tolerance (white is 245 levels from red, beyond the fringe's fade). + assert_eq!((aa[3 * 20 + 5], aa[3 * 20 + 6]), (1.0, 0.0)); assert!(magic_wand(&px, a, (50, 3), 10.0, true, false).iter().all(|v| *v == 0.0)); } diff --git a/crates/algo/src/selection/wand_antialias_tests.rs b/crates/algo/src/selection/wand_antialias_tests.rs new file mode 100644 index 00000000000..6d435e12cfd --- /dev/null +++ b/crates/algo/src/selection/wand_antialias_tests.rs @@ -0,0 +1,128 @@ +//! The Magic Wand's Anti-alias against the reference measurement (#11776): the region itself +//! stays fully selected, and a one-pixel soft fringe lies only outside it, next to colours just +//! past the tolerance. Document 800 x 600: a solid red rectangle 100,100–300,250 on a transparent +//! layer, and a grey ramp of ten 40 px blocks (grey 100, 105, …, 145) from x 100 at y 350–450, +//! on its own transparent layer or composited on white (Sample All Layers). + +use photocraft_geom::Rect; + +use super::{Region, magic_wand, wand_region}; + +const AREA: Rect = Rect { x0: 0, y0: 0, x1: 800, y1: 600 }; +const W: usize = 800; +const H: usize = 600; + +fn red_rectangle() -> Vec<[u8; 4]> { + let mut img = vec![[0u8; 4]; W * H]; + for y in 100..250 { + for x in 100..300 { + img[y * W + x] = [220, 30, 30, 255]; + } + } + img +} + +/// The grey ramp at y 350–450 on a transparent layer, or on white when `on_white`. +fn ramp(on_white: bool) -> Vec<[u8; 4]> { + let mut img = vec![if on_white { [255u8; 4] } else { [0u8; 4] }; W * H]; + for y in 350..450 { + for b in 0..10u8 { + let g = 100 + 5 * b; + for x in 100 + 40 * b as usize..140 + 40 * b as usize { + img[y * W + x] = [g, g, g, 255]; + } + } + } + img +} + +/// (bounds of every touched pixel, fully selected count, partly selected count). +fn stats(r: &Region) -> (Rect, usize, usize) { + let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN); + let (mut full, mut part) = (0, 0); + for y in r.bbox.y0..r.bbox.y1 { + for x in r.bbox.x0..r.bbox.x1 { + let v = (r.at(x, y) * 255.0).round() as u8; + if v == 0 { + continue; + } + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1)); + if v == 255 { full += 1 } else { part += 1 } + } + } + (Rect::new(x0, y0, x1, y1), full, part) +} + +fn coverage(r: &Region, x: i32, y: i32) -> u8 { + (r.at(x, y) * 255.0).round() as u8 +} + +#[test] +fn a_shape_next_to_a_clearly_different_colour_gets_no_fringe() { + let img = red_rectangle(); + for aa in [true, false] { + let r = wand_region(&img, AREA, (150, 150), 32.0, true, aa).unwrap(); + assert_eq!(stats(&r), (Rect::new(100, 100, 300, 250), 30000, 0), "anti-alias {aa}"); + } +} + +#[test] +fn the_grey_ramp_gets_one_nearly_selected_column_past_the_tolerance() { + let img = ramp(false); + // Tolerance 32 from grey 100: blocks 100 to 130 (x 100–380) fully selected; the 135 block + // is 3 levels past it, so its first column is nearly selected. + let r = wand_region(&img, AREA, (110, 400), 32.0, true, true).unwrap(); + let (bounds, full, part) = stats(&r); + assert_eq!((full, part), (280 * 100, 100), "{bounds:?}"); + assert_eq!(bounds, Rect::new(100, 350, 381, 450)); + for y in [350, 400, 449] { + assert_eq!(coverage(&r, 379, y), 255, "the region's last column stays fully selected"); + assert_eq!(coverage(&r, 380, y), 251, "one fringe column of the next block, as measured"); + assert_eq!(coverage(&r, 381, y), 0, "only one column"); + } + // Transparent pixels above and below the ramp have no colour: no fringe there. + assert_eq!(coverage(&r, 200, 349), 0); + assert_eq!(coverage(&r, 200, 450), 0); + // Tolerance 10: x 100–220 plus one partly selected column of the 115 block. + let r = wand_region(&img, AREA, (110, 400), 10.0, true, true).unwrap(); + let (bounds, full, part) = stats(&r); + assert_eq!((bounds, full, part), (Rect::new(100, 350, 221, 450), 120 * 100, 100)); + assert_eq!(coverage(&r, 220, 400), 249, "5 levels past tolerance 10, as measured"); +} + +#[test] +fn anti_alias_off_has_no_partly_selected_pixels() { + let img = ramp(false); + let r = wand_region(&img, AREA, (110, 400), 32.0, true, false).unwrap(); + assert_eq!(stats(&r), (Rect::new(100, 350, 380, 450), 28000, 0)); +} + +#[test] +fn the_8_bit_and_f32_wands_draw_the_same_fringe() { + let img = ramp(false); + let f: Vec<[f32; 4]> = img.iter().map(|p| p.map(|v| f32::from(v) / 255.0)).collect(); + for (tol, contiguous) in [(32.0, true), (10.0, true), (32.0, false)] { + let r = wand_region(&img, AREA, (110, 400), tol, contiguous, true).unwrap(); + let m = magic_wand(&f, AREA, (110, 400), tol, contiguous, true); + for y in 340..460 { + for x in 90..520 { + let want = (m[y as usize * W + x as usize] * 255.0).round() as u8; + assert_eq!(coverage(&r, x, y), want, "tolerance {tol}, contiguous {contiguous} at {x},{y}"); + } + } + } +} + +#[test] +fn on_the_composite_the_white_around_the_ramp_gets_the_measured_fringe() { + // Sample All Layers: the ramp on white. White is 155 levels from grey 100, 123 past + // tolerance 32; the reference application's fringe there reads 110 (Photoshop 2025, the + // same points; the issue reports 131–177 around the ramp). + let img = ramp(true); + let r = wand_region(&img, AREA, (110, 400), 32.0, true, true).unwrap(); + for (x, y) in [(200, 349), (200, 450), (99, 400), (99, 349)] { + assert_eq!(coverage(&r, x, y), 110, "{x},{y}"); + } + assert_eq!(coverage(&r, 380, 400), 251, "the near-miss block still gets the stronger fringe"); + assert_eq!(coverage(&r, 200, 400), 255, "inside stays fully selected"); +} diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs index 3e0f5e7d82f..17590167268 100644 --- a/crates/automation/src/workspace/tests.rs +++ b/crates/automation/src/workspace/tests.rs @@ -732,7 +732,9 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool { | "path.info" | "path.addToSelection" | "path.subtractFromSelection" + | "path.intersectWithSelection" | "path.set" + | "actions.insertPath" | "path.transform" | "path.moveAnchors" | "path.moveHandle" diff --git a/crates/compose/src/cmyk/tests.rs b/crates/compose/src/cmyk/tests.rs index 8deaa68de31..952d29be63d 100644 --- a/crates/compose/src/cmyk/tests.rs +++ b/crates/compose/src/cmyk/tests.rs @@ -60,7 +60,11 @@ fn over(b: Cmyka, s: Cmyka, opacity: f32) -> Cmyka { } let mut o = [0.0; 5]; for i in 0..4 { - o[i] = (s[i] * sa + b[i] * b[4] * (1.0 - sa)) / a; + // Over nothing, under a covering source, or where both inks agree, the result is the + // source's ink exactly: `s * sa / sa` can be one ulp off, which a later mode or + // rounding step can turn into a whole level. + let shows_through = b[4] > 0.0 && sa < 1.0; + o[i] = if !shows_through || b[i] == s[i] { s[i] } else { (s[i] * sa + b[i] * b[4] * (1.0 - sa)) / a }; } o[4] = a; o @@ -762,6 +766,37 @@ fn clips_onto_pass_through(layers: &[LayerSpec]) -> bool { }) || layers.iter().any(|l| matches!(l, LayerSpec::Group { children, .. } if clips_onto_pass_through(children))) } +/// A proptest case from main's CI: a masked shape below 100% Fill, then Posterize in Color +/// Burn (Paint.NET) inside an isolated group. The burned brightness is exactly 73.5/255. +#[test] +fn posterize_in_paint_net_color_burn_on_a_rounding_tie_matches_the_reference() { + let shape = LayerSpec::Pixels { + kind: Kind::Shape, + blend: BlendMode::Normal, + fills: vec![(Rect::new(0, 0, 1, 1), [0.47422728, 0.0, 0.0, 0.0, 1.0])], + opacity: 1.0, + fill: 0.876696, + clipped: false, + visible: true, + mask: Some(MaskSpec { rects: vec![], default: 0.06425183, density: 1.0, enabled: true }), + }; + let posterize = LayerSpec::Pixels { + kind: Kind::Adjust(AdjSpec::Posterize(4)), + blend: BlendMode::PaintNetColorBurn, + fills: vec![(Rect::new(0, 0, 1, 1), [0.0; 5])], + opacity: 1.0, + fill: 1.0, + clipped: false, + visible: true, + mask: None, + }; + let group = LayerSpec::Group { children: vec![shape, posterize], pass_through: false, blend: BlendMode::Normal, opacity: 1.0, fill: 1.0, mask: None }; + let s = Spec { w: 8, h: 6, layers: vec![group] }; + let mut d = doc(s.w as u32, s.h as u32); + d.layers = s.layers.iter().map(build).collect(); + assert_matches_reference(&d); +} + proptest! { #![proptest_config(ProptestConfig { cases: 400, ..ProptestConfig::default() })] diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index b351bdef06d..30c64c7bdfc 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -344,6 +344,9 @@ impl Tile { /// Most taps per axis of the minification filter (the WGSL mirror uses the same cap). const MAX_TAPS: usize = 8; +/// The smallest pattern scale a placement draws at (a fraction): keeps tile coordinates finite. +pub const MIN_PLACEMENT_SCALE: f64 = 1e-6; + /// Taps per axis for a placement whose inverse scale is `inv_scale`: one pixel covers /// `inv_scale` tile pixels, so that many taps (capped) box-filter it. 1 at 100 % and above. fn pixel_taps(inv_scale: f64) -> usize { @@ -387,7 +390,9 @@ impl Placement { let base = if link { anchor } else { (0.0, 0.0) }; let origin = (base.0 + f64::from(phase.0), base.1 + f64::from(phase.1)); let s = f64::from(scale); - let inv_scale = if s.is_finite() && s > 1e-3 { 1.0 / s } else { 1.0 }; + // Any positive scale draws: a preview at a reduced work scale takes a stored 2 % pattern + // well below 0.1 %. Only 0, negatives and non-finite values fall back to 100 %. + let inv_scale = if s.is_finite() && s > 0.0 { 1.0 / s.max(MIN_PLACEMENT_SCALE) } else { 1.0 }; let a = f64::from(angle).to_radians(); Placement { origin, cs: (a.cos(), a.sin()), inv_scale } } @@ -608,6 +613,31 @@ mod tests { assert_eq!((row[0][0], row[1][0]), (0.0, 1.0)); } + /// Review of #9256: a preview at 4 % work scale draws a stored 2 % pattern at 0.08 %, which + /// used to fall back to 100 % and show the stripes full size. + #[test] + fn placement_keeps_scales_below_a_tenth_of_a_percent() { + let place = Placement::new(Rect::EMPTY, false, (0.0, 0.0), 0.0008, 0.0); + let (u, v) = place.map(1.0, 2.0); + assert!((u - 1250.0).abs() < 1e-3 && (v - 2500.0).abs() < 1e-3, "{u},{v}"); + // 1-px dark / light stripes: at 100 % the pixels alternate 0 / 1; at 0.08 % each pixel + // covers 1250 of them, so every pixel is their mean. + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]); + for x in (0..8).step_by(2) { + s.fill_rect(Rect::new(x, 0, x + 1, 8), &[0.0, 0.0, 0.0, 1.0]); + } + let t = Tile::new(&Pattern::new("stripes", s, 8, 8)).unwrap(); + for p in render(&t, &place, Rect::new(0, 0, 8, 1)) { + assert!((p[0] - 0.5).abs() < 0.1, "{p:?}"); + } + // 0, negative and non-finite scales still draw at 100 %. + for bad in [0.0, -0.5, f32::NAN, f32::INFINITY] { + let p = Placement::new(Rect::EMPTY, false, (0.0, 0.0), bad, 0.0); + assert_eq!(p.map(3.0, 0.0).0, 3.0, "{bad}"); + } + } + #[test] fn sampling_at_hostile_phases_does_not_overflow() { let patterns = [checker()]; diff --git a/crates/doc/src/effects.rs b/crates/doc/src/effects.rs index 4e3f32a97f0..a71aa883ddf 100644 --- a/crates/doc/src/effects.rs +++ b/crates/doc/src/effects.rs @@ -10,6 +10,10 @@ use serde::{Deserialize, Serialize}; use crate::adjust::CurvePoint; +/// The range of a style's pattern scale as a fraction (1 = 100 %): Pattern Overlay, a pattern +/// Stroke and the Bevel & Emboss Texture all take 1..1000 % in the Layer Style dialog. +pub const PATTERN_SCALE_RANGE: (f32, f32) = (0.01, 10.0); + /// Gradient geometry (fill layers, gradient overlays, gradient strokes). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum GradientStyle { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index aa376fea025..9cc2dd7b486 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -564,6 +564,12 @@ impl SmartObject { perspective: None, } } + + /// Where the placed source lands (transform, perspective and warp), as JSON: the placement a + /// smart filter stored in document pixels (an imported Puppet Warp mesh) was made for. + pub fn placement_json(&self) -> serde_json::Value { + serde_json::json!({"transform": self.transform.m, "perspective": self.perspective, "warp": self.warp}) + } } #[derive(Clone, Debug, PartialEq)] diff --git a/crates/engine/src/brush_clear_background_tests.rs b/crates/engine/src/brush_clear_background_tests.rs new file mode 100644 index 00000000000..e295e68c0b5 --- /dev/null +++ b/crates/engine/src/brush_clear_background_tests.rs @@ -0,0 +1,71 @@ +//! #11897: the Brush's chosen Clear on the Background paints as Normal. The options bar turns +//! Clear off at the press there (`clear_off_on_background` in ui-egui), so the engine never sees +//! it; a `"mode": "clear"` stroke that does reach the Background is a held `~`, which keeps +//! erasing to the background colour. On a pixel layer Clear still erases, and the Eraser on the +//! Background still paints the background colour. + +use crate::Session; +use serde_json::{Value, json}; + +const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0]; +const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0]; +const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 32, "background": "white"})).unwrap(); + s.tools.foreground = BLACK; + s.tools.background = GREEN; + s +} + +fn pixel(s: &mut Session, x: i32, y: i32) -> [f32; 4] { + let v = s.execute("document.pixel", json!({"x": x, "y": y})).unwrap(); + let a: Vec = v.as_array().unwrap().iter().map(|c| c.as_f64().unwrap() as f32).collect(); + [a[0], a[1], a[2], a[3]] +} + +fn close(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= 0.02) +} + +/// A hard stroke across (10..30, 10), as the canvas sends it in Clear mode. +fn clear_stroke() -> Value { + json!({"points": [[10, 10], [30, 10]], "size": 8, "hardness": 1.0, "erase": true, "mode": "clear"}) +} + +/// A held `~` on the Background erases to the background colour, as before #11897. +#[test] +fn a_clear_stroke_on_the_background_still_paints_the_background_colour() { + let mut s = session(); + s.execute("paint.stroke", clear_stroke()).unwrap(); + let p = pixel(&mut s, 20, 10); + assert!(close(p, GREEN), "painted {p:?}, not the foreground"); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Brush Tool")); +} + +#[test] +fn a_normal_stroke_on_the_background_paints_the_foreground() { + let mut s = session(); + s.execute("paint.stroke", json!({"points": [[10, 10], [30, 10]], "size": 8, "hardness": 1.0})).unwrap(); + let p = pixel(&mut s, 20, 10); + assert!(close(p, BLACK), "{p:?}"); +} + +#[test] +fn clear_on_a_pixel_layer_still_erases() { + let mut s = session(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": [1.0, 0.0, 0.0, 1.0]})).unwrap(); + s.execute("paint.stroke", clear_stroke()).unwrap(); + let p = pixel(&mut s, 20, 10); + assert!(close(p, WHITE), "erased down to the white Background: {p:?}"); +} + +#[test] +fn the_eraser_on_the_background_still_paints_the_background_colour() { + let mut s = session(); + s.execute("paint.stroke", json!({"points": [[10, 10], [30, 10]], "size": 8, "hardness": 1.0, "erase": true})).unwrap(); + let p = pixel(&mut s, 20, 10); + assert!(close(p, GREEN), "{p:?}"); +} diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 93bc4654b9e..ff40ea8a8b1 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -242,9 +242,27 @@ pub(crate) fn transform_doc_marks(doc: &mut Document, a: &Affine) { if let Some(wp) = &mut doc.work_path { *wp = wp.transform(a); } + // A note's icon point maps with the pixels; its popup keeps its size (#11795). Under Image Size + // and Canvas Size it also keeps its offset from the icon, as Photoshop's does. Where it sits + // after a turn or flip wasn't measured, so it keeps today's origin there: the top-left of the + // mapped rectangle. for n in &mut doc.notes { + // An all-zero popup means none is stored (PSD export writes its default one): keep it. + if n.popup == [0.0; 4] { + n.position = map_pt(a, n.position); + continue; + } + let [px, py] = n.position; + let (dx, dy, w, h) = (n.popup[0] - px, n.popup[1] - py, n.popup[2] - n.popup[0], n.popup[3] - n.popup[1]); n.position = map_pt(a, n.position); - n.popup = map_box(a, n.popup); + let [m0, m1, m2, m3, ..] = a.m; + let [x, y] = if m1.abs() < EPS && m2.abs() < EPS && m0 > 0.0 && m3 > 0.0 { + [n.position[0] + dx, n.position[1] + dy] + } else { + let [l, t, ..] = map_box(a, n.popup); + [l, t] + }; + n.popup = [x, y, x + w, y + h]; } for g in &mut doc.measurement.count_groups { for p in &mut g.points { diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index c4addf4c22f..34fc19846b2 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -69,6 +69,8 @@ pub mod comps_cmds; pub mod contour_lib_cmds; #[cfg(test)] mod count_delete_group_tests; +#[cfg(test)] +mod count_history_names_tests; pub mod cutout_cmds; pub mod distort_cmds; pub mod document_preset_cmds; @@ -114,6 +116,8 @@ pub(super) mod migrate_cmds; pub mod mode_cmds; pub mod multichannel_cmds; pub mod notes_cmds; +#[cfg(test)] +mod notes_stored_values_tests; mod ops; pub mod paint_cmds; mod params; @@ -150,6 +154,8 @@ pub mod type_area_cmds; pub mod type_caret_cmds; pub mod type_cmds; pub mod type_extra_cmds; +#[cfg(test)] +mod type_indexed_mode_tests; pub mod type_mask_cmds; pub mod type_spell_cmds; pub mod type_styles_cmds; diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index 3f27d91137f..3392c5361a4 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -94,6 +94,9 @@ pub const MAX_PLAYBACK_STEPS: u64 = 100_000; #[serde(default)] pub struct ActionState { pub list: Vec, + /// Painting-tool strokes are recorded only when the user enables them for this session. + #[serde(skip)] + pub record_tools: bool, /// Journal length when recording started, and the action index being recorded into. #[serde(skip)] pub recording: Option<(usize, usize)>, @@ -165,7 +168,7 @@ pub fn playback_params(s: &Session, id: &str, p: &Value) -> Result { } } } - if id.starts_with("filter.gallery.") + if (id.starts_with("filter.gallery.") || id == crate::atn_cmds::MENU_ITEM) && let Some(object) = p.as_object_mut() { object.remove(crate::atn_cmds::RECORDED_EVENT); @@ -276,6 +279,63 @@ pub fn playback_plan(s: &Session, p: &Value) -> Result<(Action, usize)> { Ok((action, from)) } +/// Visit calls in the current namespace, leaving embedded inline Batch scopes untouched. +fn visit_calls(steps: &mut [(String, Value)], f: &mut impl FnMut(&str, &mut Value, bool)) { + let mut pending: Vec<_> = steps.iter_mut().rev().map(|(id, params)| (id.clone(), params)).collect(); + while let Some((id, params)) = pending.pop() { + let scoped = id == "file.automate.batch" && params.get("calledActions").is_some_and(|v| !v.is_null()); + if id == "file.automate.batch" { + let inline = if params.get("steps").is_some() { Some("steps") } else { params.get("action").is_some_and(Value::is_array).then_some("action") }; + if let Some(key) = inline { + if scoped { + continue; + } + // A present invalid `steps` still wins over `action`, just as at runtime. + let Some(steps) = params.get_mut(key).and_then(Value::as_array_mut) else { continue }; + for step in steps.iter_mut().rev() { + let call = match step { + Value::Array(a) => { + let Some(id) = a.first().and_then(Value::as_str).map(str::to_owned) else { continue }; + a.get_mut(1).map(|params| (id, params)) + } + Value::Object(o) => { + let Some(id) = o.get("command").or_else(|| o.get("id")).and_then(Value::as_str).map(str::to_owned) else { continue }; + o.get_mut("params").map(|params| (id, params)) + } + // String steps and omitted params use `{}`, with no action target. + _ => None, + }; + if let Some(call) = call { + pending.push(call); + } + } + continue; + } + } + f(&id, params, scoped); + } +} + +/// Shift numeric selectors in the session list, including calls in unscoped inline Batch steps. +pub(crate) fn shift_action_calls(list: &mut [Action], at: usize, count: usize) { + for action in list { + visit_calls(&mut action.steps, &mut |id, params, _| { + if !matches!(id, "actions.play" | "file.automate.batch") { + return; + } + let Some(value) = params.get("action") else { return }; + let Some(index) = num(value).and_then(|i| usize::try_from(i).ok()) else { return }; + if index >= at { + params["action"] = if let Some(fractional) = value.as_f64().filter(|_| value.is_f64()) { + json!(fractional + count as f64) + } else { + json!(index.saturating_add(count)) + }; + } + }); + } +} + /// Capture transitive Play Action, Conditional and Batch dependencies for a standalone droplet /// (#2794, #6804). Ordinary references become names; normalized copies keep source-list order. /// A named Batch with its own action list selects an untouched source copy at an exported index, @@ -286,42 +346,6 @@ pub fn called_actions(list: &[Action], steps: &mut [(String, Value)]) -> Vec Result { if id == "file.automate.batch" { crate::file_cmds::batch_action(list, params) } else { resolve(list, params, id) } } - /// Visit calls in the current namespace, leaving embedded inline Batch scopes untouched. - fn visit_calls(steps: &mut [(String, Value)], f: &mut impl FnMut(&str, &mut Value, bool)) { - let mut pending: Vec<_> = steps.iter_mut().rev().map(|(id, params)| (id.clone(), params)).collect(); - while let Some((id, params)) = pending.pop() { - let scoped = id == "file.automate.batch" && params.get("calledActions").is_some_and(|v| !v.is_null()); - if id == "file.automate.batch" { - let inline = if params.get("steps").is_some() { Some("steps") } else { params.get("action").is_some_and(Value::is_array).then_some("action") }; - if let Some(key) = inline { - if scoped { - continue; - } - // A present invalid `steps` still wins over `action`, just as at runtime. - let Some(steps) = params.get_mut(key).and_then(Value::as_array_mut) else { continue }; - for step in steps.iter_mut().rev() { - let call = match step { - Value::Array(a) => { - let Some(id) = a.first().and_then(Value::as_str).map(str::to_owned) else { continue }; - a.get_mut(1).map(|params| (id, params)) - } - Value::Object(o) => { - let Some(id) = o.get("command").or_else(|| o.get("id")).and_then(Value::as_str).map(str::to_owned) else { continue }; - o.get_mut("params").map(|params| (id, params)) - } - // String steps and omitted params use `{}`, with no action target. - _ => None, - }; - if let Some(call) = call { - pending.push(call); - } - } - continue; - } - } - f(&id, params, scoped); - } - } /// Capture ordinary calls transitively, but retain an untouched source for scoped named Batch. fn targets(list: &[Action], steps: &mut [(String, Value)], depth: usize, queue: &mut VecDeque<(usize, usize)>, scoped: &mut BTreeSet) { if depth > 16 { @@ -482,7 +506,7 @@ fn from_step(p: &Value, len: usize) -> Result { fn list(s: &mut Session, _p: &Value) -> Result { let actions: Vec = s.actions.list.iter().map(|a| json!({"name": a.name, "steps": a.steps.len(), "set": a.set})).collect(); let recording = s.actions.recording.and_then(|(_, i)| s.actions.list.get(i).map(|a| a.name.clone())); - Ok(json!({"actions": actions, "sets": crate::actions_edit_cmds::set_names(s), "recording": recording})) + Ok(json!({"actions": actions, "sets": crate::actions_edit_cmds::set_names(s), "recording": recording, "toolRecording": s.actions.record_tools})) } fn get(s: &mut Session, p: &Value) -> Result { @@ -545,7 +569,32 @@ const RECORDED_WITH_DIALOG: &[&str] = &["edit.checkSpelling"]; /// Replayable journal entries not yet committed to this action while recording. pub fn pending_steps(s: &Session, action: usize) -> impl Iterator { let from = s.actions.recording.filter(|(_, i)| *i == action).map_or(s.journal.len(), |(from, _)| from); - s.journal.iter().skip(from).filter(|(id, p)| recorded(id, p)) + s.journal.iter().skip(from).filter(|(id, p)| recorded(id, p) && (s.actions.record_tools || !tool_stroke(id))) +} + +/// The point-based painting tools committed by the canvas; fills and gradients remain commands. +pub(crate) fn tool_stroke(id: &str) -> bool { + matches!( + id, + "paint.stroke" + | "paint.pencil" + | "paint.mixerBrush" + | "paint.colorReplacement" + | "paint.blur" + | "paint.sharpen" + | "paint.smudge" + | "paint.dodge" + | "paint.burn" + | "paint.sponge" + | "paint.cloneStamp" + | "paint.healingBrush" + | "paint.historyBrush" + | "paint.artHistoryBrush" + | "paint.patternStamp" + | "paint.backgroundEraser" + | "paint.spotHealing" + | "paint.remove" + ) } pub(crate) fn flush_recording(s: &mut Session) -> Result<()> { @@ -1094,7 +1143,7 @@ pub fn specs() -> Vec { label: "List Actions", menu: &[], shortcut: None, - params: "{} → {actions:[{name, steps:count, set:name|null}], sets:[name…] (every set in panel order, empty ones too), recording:name|null}", + params: "{} → {actions:[{name, steps:count, set:name|null}], sets:[name…] (every set in panel order, empty ones too), recording:name|null, toolRecording:bool}", enabled: always, run: list, journal: false, diff --git a/crates/engine/src/cmd/actions_cmds/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs index 6a18a871f1d..ed5da88e7db 100644 --- a/crates/engine/src/cmd/actions_cmds/tests.rs +++ b/crates/engine/src/cmd/actions_cmds/tests.rs @@ -334,11 +334,11 @@ fn record_drops_the_steps_of_a_command_that_failed_and_was_taken_back() { assert_eq!(s.execute("actions.stop", json!({})).unwrap()["steps"], 0, "no Duplicate step for a copy that was taken back"); // Straighten rolls back when its crop is refused after the rotation ran. - ruled_background(&mut s, 8); + ruled_layer(&mut s, 8); s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); s.authorize = Some(|id, _| if id == "image.crop" { Err(EngineError::Other("refused".into())) } else { Ok(()) }); s.execute("actions.record", json!({"name": "Refused"})).unwrap(); - assert!(s.execute("image.analysis.straightenLayer", json!({})).is_err()); + assert!(s.execute("image.analysis.straightenLayer", json!({"crop": true})).is_err()); assert_eq!(s.active().unwrap().doc.size.width, 80, "the rotation was taken back"); assert_eq!(s.execute("actions.stop", json!({})).unwrap()["steps"], 0, "no rotation step for a rotation that was taken back"); } @@ -500,23 +500,32 @@ fn ruled_background(s: &mut Session, depth: u32) { .unwrap(); } +/// The same, with a pixel layer on top: since #12033 the Background alone greys Straighten Layer +/// out, so the tests that run the command target a layer and pass `crop` explicitly. +fn ruled_layer(s: &mut Session, depth: u32) { + ruled_background(s, depth); + s.execute("layer.new.layer", json!({"name": "Paint"})).unwrap(); +} + #[test] fn record_command_that_runs_commands_replays_it_once() { // Straighten Layer runs Image Rotation and Crop itself: only its own call is recorded, // so playback rotates once, not three times (rotate, crop, then straighten again). for depth in [8, 16] { let mut s = Session::new(); - ruled_background(&mut s, depth); + ruled_layer(&mut s, depth); s.execute("actions.record", json!({"name": "Level"})).unwrap(); s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); - s.execute("image.analysis.straightenLayer", json!({})).unwrap(); + // #12033: the crop is asked for, as automation does; the Background it used to default to + // now greys the command out. + s.execute("image.analysis.straightenLayer", json!({"crop": true})).unwrap(); s.execute("actions.stop", json!({})).unwrap(); let got = s.execute("actions.get", json!({"action": "Level"})).unwrap(); let ids: Vec<&str> = got["steps"].as_array().unwrap().iter().map(|st| st[0].as_str().unwrap()).collect(); assert_eq!(ids, ["image.analysis.rulerTool", "image.analysis.straightenLayer"], "depth {depth}"); let direct = s.active().unwrap().doc.clone(); - ruled_background(&mut s, depth); + ruled_layer(&mut s, depth); let before = s.active().unwrap().history.past_len(); let played = s.execute("actions.play", json!({"action": "Level"})).unwrap(); assert!(played.get("failed").is_none(), "depth {depth}: {played}"); diff --git a/crates/engine/src/cmd/actions_edit_cmds.rs b/crates/engine/src/cmd/actions_edit_cmds.rs index e9936199b99..d527b6d7735 100644 --- a/crates/engine/src/cmd/actions_edit_cmds.rs +++ b/crates/engine/src/cmd/actions_edit_cmds.rs @@ -13,6 +13,11 @@ use crate::actions_cmds::{flush_recording, not_playing, pending_steps, resolve}; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; +mod insert_path; +mod record_again; +pub use insert_path::selected_path as selected_recording_path; +pub use record_again::{RecordAgainTarget, target as record_again_target}; + fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } @@ -21,6 +26,35 @@ fn always(_: &Session) -> std::result::Result<(), String> { Ok(()) } +/// Flush under the old mode so changing the toggle never retroactively records or loses a stroke. +fn allow_tool_recording(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.allowToolRecording"; + not_playing(s, cmd)?; + let enabled = match p.get("enabled") { + None | Some(Value::Null) => !s.actions.record_tools, + Some(Value::Bool(enabled)) => *enabled, + Some(_) => return Err(bad(cmd, "`enabled` must be true or false")), + }; + if enabled != s.actions.record_tools { + if s.actions.recording.is_some() && s.tool_stroke_job_running() { + return Err(bad(cmd, "wait for the current tool stroke before changing recording")); + } + flush_recording(s)?; + s.actions.record_tools = enabled; + } + Ok(json!({"enabled": enabled})) +} + +/// Insert a menu event with absent settings, reusing the ordinary validated step insertion. +fn insert_menu_item(s: &mut Session, p: &Value) -> Result { + if p.as_object().is_some_and(|o| o.keys().any(|k| !matches!(k.as_str(), "action" | "at" | "id" | "eventKind"))) { + return Err(bad("actions.insertMenuItem", "menu items accept no recorded settings")); + } + let params = json!({"id":p.get("id"),"eventKind":p.get("eventKind").cloned().unwrap_or(json!("name"))}); + crate::atn_cmds::menu_item_command(¶ms)?; + s.execute("actions.insert", json!({"action":p.get("action"),"at":p.get("at"),"id":crate::atn_cmds::MENU_ITEM,"params":params})) +} + fn index(p: &Value, key: &str, cmd: &str) -> Result> { match p.get(key) { None | Some(Value::Null) => Ok(None), @@ -66,18 +100,113 @@ fn insert(s: &mut Session, p: &Value) -> Result { if s.actions.recording.is_some_and(|(_, i)| i == idx) { flush_recording(s)?; } - let action = &mut s.actions.list[idx]; - action.steps.insert(at, (id.clone(), params)); - action.remap_steps(|i| Some(if i >= at { i + 1 } else { i })); - // An inserted Stop has its dialog toggle on, so it shows its message in the app. - if id == crate::atn_cmds::STOP_MESSAGE { - action.dialogs.insert(at); + let is_stop = id == crate::atn_cmds::STOP_MESSAGE; + let result = insert_step(s, idx, at, (id, params)); + // A newly inserted Stop shows its message in the app. Duplicates keep their own flags. + if is_stop { + s.actions.list[idx].dialogs.insert(at); } + Ok(result) +} + +/// Insert an already validated step without normalizing a copied step's payload. +fn insert_step(s: &mut Session, idx: usize, at: usize, step: (String, Value)) -> Value { + let action = &mut s.actions.list[idx]; + let id = step.0.clone(); + action.steps.insert(at, step); + action.remap_steps(|i| Some(if i >= at { i + 1 } else { i })); let result = json!({"action": action.name, "step": at, "id": id, "steps": action.steps.len()}); s.actions.touch(); + result +} + +/// Duplicate a step or action below its source, preserving settings and per-step flags. +fn duplicate(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.duplicate"; + not_playing(s, cmd)?; + if let Some(set) = p.get("set") { + if p.get("action").is_some() || p.get("step").is_some() { + return Err(bad(cmd, "select a set or an action step, not both")); + } + return duplicate_set(s, set.as_str().ok_or_else(|| bad(cmd, "set must be a name"))?); + } + let idx = resolve(&s.actions.list, p, cmd)?; + if let Some(step) = index(p, "step", cmd)? { + let len = s.actions.list[idx].steps.len().saturating_add(pending_steps(s, idx).count()); + if step >= len { + return Err(bad(cmd, "step is out of range")); + } + if s.actions.recording.is_some_and(|(_, i)| i == idx) { + flush_recording(s)?; + } + let action = &s.actions.list[idx]; + let copied = action.steps.get(step).cloned().ok_or_else(|| bad(cmd, "step is out of range"))?; + let (disabled, dialog) = (action.disabled.contains(&step), action.dialogs.contains(&step)); + let at = step + 1; + let mut result = insert_step(s, idx, at, copied); + let action = &mut s.actions.list[idx]; + if disabled { + action.disabled.insert(at); + } + if dialog { + action.dialogs.insert(at); + } + result["index"] = json!(idx); + return Ok(result); + } + if s.actions.recording.is_some() { + return Err(bad(cmd, "stop recording before duplicating an action")); + } + let mut copied = s.actions.list[idx].clone(); + let mut names = crate::atn_cmds::UniqueNames::new(s.actions.list.iter().map(|a| a.name.clone())); + copied.name = names.claim(&format!("{} copy", copied.name)); + let at = idx + 1; + let result = json!({"action": copied.name, "index": at, "steps": copied.steps.len()}); + s.actions.list.insert(at, copied); + crate::actions_cmds::shift_action_calls(&mut s.actions.list, at, 1); + s.actions.touch(); Ok(result) } +/// Copy the named group represented by main, including a session-only empty New Set. +fn duplicate_set(s: &mut Session, source: &str) -> Result { + let cmd = "actions.duplicate"; + if s.actions.recording.is_some() { + return Err(bad(cmd, "stop recording before duplicating a set")); + } + let sets = set_names(s); + if !sets.iter().any(|name| name == source) { + return Err(bad(cmd, "the selected set no longer exists")); + } + let name = crate::atn_cmds::UniqueNames::new(sets).claim(&format!("{source} copy")); + if let Some(first) = s.actions.list.iter().position(|action| action.set.as_deref() == Some(source)) { + let mut names = crate::atn_cmds::UniqueNames::new(s.actions.list.iter().map(|action| action.name.clone())); + let copies: Vec<_> = s + .actions + .list + .iter() + .filter(|action| action.set.as_deref() == Some(source)) + .cloned() + .map(|mut action| { + action.name = names.claim(&format!("{} copy", action.name)); + action.set = Some(name.clone()); + action + }) + .collect(); + // Classic groups rows by name even when a set's actions are interleaved. + let at = first + s.actions.list[first..].iter().take_while(|action| action.set.as_deref() == Some(source)).count(); + let count = copies.len(); + s.actions.list.splice(at..at, copies); + crate::actions_cmds::shift_action_calls(&mut s.actions.list, at, count); + s.actions.touch(); + return Ok(json!({"set": name, "index": at, "actions": count})); + } + let at = s.actions.sets.iter().position(|set| set == source).ok_or_else(|| bad(cmd, "the selected set no longer exists"))? + 1; + s.actions.sets.insert(at, name.clone()); + s.actions.touch(); + Ok(json!({"set": name, "actions": 0})) +} + /// The panel's dialog toggle column. With `step`, switch one step's dialog on or off; without /// it, every step (all on unless all already are, like clicking the action's own toggle). fn dialog(s: &mut Session, p: &Value) -> Result { @@ -277,6 +406,56 @@ fn conditional(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ + CommandSpec { + id: "actions.insertMenuItem", + label: "Insert Menu Item…", + menu: &[], + shortcut: None, + params: r##"{"action":name|index,"id":menu command,"at":index?,"eventKind":"name"|"code"="name"} → {action,step,id,steps}. Records an unambiguous supported menu event without settings. Image Size and mapped filter menu events are supported. Desktop playback opens the existing editor when settings are needed; no-settings items run immediately."##, + enabled: always, + run: insert_menu_item, + journal: false, + }, + CommandSpec { + id: "actions.insertPath", + label: "Insert Path", + menu: &[], + shortcut: None, + params: r##"{"action":name|index,"name":"work"|"layer"|saved path name,"at":index?} → {action,step,id,steps}. Snapshot exactly the selected path as a path.set work-path step, preserving cubic handles and path options. No fallback from a missing Work Path to a layer path. The measured Work Path ATN descriptor supports absolute coordinates, cubic handles, even-odd subpaths and Add/Subtract/XOR. Paths outside that measured form still play natively and are reported as unsupported by ATN export."##, + enabled: always, + run: insert_path::run, + journal: false, + }, + CommandSpec { + id: "actions.allowToolRecording", + label: "Allow Tool Recording", + menu: &[], + shortcut: None, + params: r##"{"enabled":bool?=toggle} → {enabled:bool}. Opt in to recording painting-tool strokes (off initially). Existing pending steps are committed under the previous mode. Painting, history and the journal are unchanged. actions.list reports toolRecording."##, + enabled: always, + run: allow_tool_recording, + journal: false, + }, + CommandSpec { + id: "actions.recordAgain", + label: "Record Again", + menu: &[], + shortcut: None, + params: r##"{"action":name|index,"step":index,"params":{new settings},"revision":u64?} → {action,step,id,result}. Replays the selected command and stores its new settings only after success, preserving its enabled/dialog flags. The panel opens the command's editor at the recorded settings. Stop recording or playback first; revision optionally rejects stale library state."##, + enabled: always, + run: record_again::run, + journal: false, + }, + CommandSpec { + id: "actions.duplicate", + label: "Duplicate Action", + menu: &[], + shortcut: None, + params: r##"{"action":name|index,"step":index?} or {"set":name} → {action?,index?,step?,steps?,set?,actions?}. Copy an action, step or named set below the source. Copies retain settings and flags, and leave shortcuts with the originals."##, + enabled: always, + run: duplicate, + journal: false, + }, CommandSpec { id: "actions.insert", label: "Insert Step", diff --git a/crates/engine/src/cmd/actions_edit_cmds/insert_path.rs b/crates/engine/src/cmd/actions_edit_cmds/insert_path.rs new file mode 100644 index 00000000000..9be10d94adb --- /dev/null +++ b/crates/engine/src/cmd/actions_edit_cmds/insert_path.rs @@ -0,0 +1,26 @@ +//! Capture full native geometry without substituting another path for a stale selection. +use super::{Result, Session, Value, bad, json}; +use photocraft_doc::{LayerContent, Path}; + +/// Borrow exactly the path the panel selected, without Work Path's ordinary layer fallback. +pub fn selected_path<'a>(s: &'a Session, name: &str) -> Result<&'a Path> { + let st = s.active().ok_or(crate::EngineError::NoDocument)?; + let path = match name { + "work" => st.doc.work_path.as_ref(), + "layer" => st.active_layer.and_then(|id| st.doc.layer(id)).and_then(|l| match &l.content { + LayerContent::Shape(shape) => Some(&shape.path), + _ => l.vector_mask.as_ref().map(|v| &v.path), + }), + name => st.doc.paths.iter().find(|p| p.name == name).map(|p| &p.path), + }; + path.ok_or_else(|| bad("actions.insertPath", "the selected path no longer exists")) +} + +/// The native path step is also the source for a future independently verified ATN wire mapping. +pub(super) fn run(s: &mut Session, p: &Value) -> Result { + let name = p.get("name").and_then(Value::as_str).ok_or_else(|| bad("actions.insertPath", "select a path"))?; + let path = selected_path(s, name)?; + photocraft_vector::flatten::validate_path(path).map_err(|e| bad("actions.insertPath", e))?; + let path = crate::vector_cmds::path_json(path); + s.execute("actions.insert", json!({"action":p.get("action"),"at":p.get("at"),"id":"path.set","params":{"name":"work","path":path}})) +} diff --git a/crates/engine/src/cmd/actions_edit_cmds/record_again.rs b/crates/engine/src/cmd/actions_edit_cmds/record_again.rs new file mode 100644 index 00000000000..b6049967bbe --- /dev/null +++ b/crates/engine/src/cmd/actions_edit_cmds/record_again.rs @@ -0,0 +1,55 @@ +//! Record Again executes the selected command before replacing its stored settings. +use super::{Result, Session, Value, bad, index, json, not_playing, resolve}; + +const COMMAND: &str = "actions.recordAgain"; + +/// A validated selection shared by the engine and the panel's seeded editor. +pub struct RecordAgainTarget { + pub action: usize, + pub step: usize, + pub command: String, + pub params: Value, +} + +/// Resolve only executable steps, without flushing recording or editing the library. +pub fn target(s: &Session, p: &Value) -> Result { + not_playing(s, COMMAND)?; + if s.actions.recording.is_some() { + return Err(bad(COMMAND, "stop recording before recording a step again")); + } + if let Some(revision) = p.get("revision") { + let revision = revision.as_u64().ok_or_else(|| bad(COMMAND, "`revision` must be an unsigned integer"))?; + if revision != s.actions.rev { + return Err(bad(COMMAND, "the actions changed; select the step again")); + } + } + let action = resolve(&s.actions.list, p, COMMAND)?; + let step = index(p, "step", COMMAND)?.ok_or_else(|| bad(COMMAND, "select a step"))?; + let (id, params) = s.actions.list.get(action).and_then(|a| a.steps.get(step)).ok_or_else(|| bad(COMMAND, "step is out of range"))?; + let (command, params) = + if id == crate::atn_cmds::MENU_ITEM { (crate::atn_cmds::menu_item_command(params)?.to_string(), json!({})) } else { (id.clone(), params.clone()) }; + if command.starts_with("actions.") || !crate::commands::find(&command).is_some_and(|c| c.journal) { + return Err(bad(COMMAND, "the selected step has no editable engine command")); + } + Ok(RecordAgainTarget { action, step, command, params }) +} + +/// Keep the original definition on invalid settings, denied execution or changed ownership. +pub(super) fn run(s: &mut Session, p: &Value) -> Result { + let selected = target(s, p)?; + let params = p.get("params").filter(|p| p.is_object()).cloned().ok_or_else(|| bad(COMMAND, "supply the step's new settings in `params`"))?; + let original = s.actions.list.get(selected.action).and_then(|a| a.steps.get(selected.step)).cloned().ok_or_else(|| bad(COMMAND, "step is out of range"))?; + let revision = s.actions.rev; + // Resolve portable recorded targets for execution; keep their portable form in the step. + let resolved = crate::actions_cmds::playback_params(s, &selected.command, ¶ms)?; + let result = s.execute(&selected.command, resolved)?; + if s.actions.rev != revision || s.actions.list.get(selected.action).and_then(|a| a.steps.get(selected.step)) != Some(&original) { + return Err(bad(COMMAND, "the action changed during execution; its step was not overwritten")); + } + let action = s.actions.list.get_mut(selected.action).ok_or_else(|| bad(COMMAND, "the action no longer exists"))?; + let step = action.steps.get_mut(selected.step).ok_or_else(|| bad(COMMAND, "the step no longer exists"))?; + *step = (selected.command.clone(), params); + let name = action.name.clone(); + s.actions.touch(); + Ok(json!({"action": name, "step": selected.step, "id": selected.command, "result": result})) +} diff --git a/crates/engine/src/cmd/analysis_cmds.rs b/crates/engine/src/cmd/analysis_cmds.rs index d9d7d1510c8..51986b48942 100644 --- a/crates/engine/src/cmd/analysis_cmds.rs +++ b/crates/engine/src/cmd/analysis_cmds.rs @@ -13,6 +13,11 @@ //! values the luminosity (0.30 R + 0.59 G + 0.11 B) of the composite in the document's value //! range (0–255 at 8 bits, 0–32768 at 16 bits, 0–1 at 32 bits), integrated density area × mean. //! Lengths and areas are scaled by the measurement scale. +//! +//! Straighten Layer is offered where the reference application offers it: the ruler line is needed, +//! and the target must be a single layer that is not the Background, not a group and not locked in +//! place ([`straighten_disabled`], #12033). A hidden target layer keeps the button and is refused +//! with [`STRAIGHTEN_HIDDEN_ERROR`] before anything changes. use std::sync::Arc; @@ -138,6 +143,60 @@ fn has_ruler(s: &Session) -> std::result::Result<(), String> { s.active().and_then(|d| d.doc.measurement.ruler).map(|_| ()).ok_or_else(|| "no ruler line".into()) } +/// The reference's alert for Straighten Layer on a hidden target layer (#12033), measured: one +/// alert window with an OK button, and the document and its History stay as they were. The button +/// is *enabled* for a hidden layer, so this is the command's refusal and not a greyed state; the +/// options bar shows the same text through `tl!` (English is the catalog source). +pub const STRAIGHTEN_HIDDEN_ERROR: &str = "Could not straighten the layer because the target layer is hidden."; + +/// The enabled check of `image.analysis.straightenLayer`: a ruler line, and a target the reference +/// straightens ([`straighten_disabled`], #12033). +fn has_straightenable_layer(s: &Session) -> std::result::Result<(), String> { + has_ruler(s)?; + straighten_disabled(s).map_or(Ok(()), |why| Err(why.into())) +} + +/// Why Straighten Layer is greyed out for the current target, `None` when its button is live +/// (#12033). Measured with a ruler line drawn: the Background, two or more selected layers, a +/// group, a layer with Lock All and a layer with Lock Position all grey it out. +/// +/// Deliberately not here: a hidden target layer keeps the button enabled and is refused by +/// [`straighten`] with [`STRAIGHTEN_HIDDEN_ERROR`], and the states the ticket marks *not measured* +/// (a layer with a mask, a shape, vector or adjustment layer, artboards, and the lock kinds other +/// than Lock All and Lock Position) keep today's behaviour. A document with no active layer keeps +/// turning the whole canvas as it does now; an active id the document no longer holds is refused. +fn straighten_disabled(s: &Session) -> Option<&'static str> { + let st = s.active()?; + if st.selected_layers().len() > 1 { + return Some("several layers selected"); + } + // The active layer is the one the Layers panel targets; with none, the command turns the + // canvas, which the reference was not measured for. + let id = st.active_layer?; + // An id the document no longer holds: `Session::edit` keeps the *selected* layers valid but + // not the active one, so an edit that drops the active layer can leave a stale id behind. + // Turning and cropping the whole canvas for a layer that is not there is the worse answer, and + // `select_layer` and `restore_layer_selection` both refuse such an id. + let Some(layer) = st.doc.layer(id) else { + return Some("no active layer"); + }; + if st.doc.is_background(id) { + return Some("the Background"); + } + if layer.is_group() { + return Some("a group"); + } + // Locks inherited from a group count, as they do for the Move tool. + let locks = st.doc.effective_locks(id); + if locks.all { + return Some("the layer is locked"); + } + if locks.position { + return Some("the layer's position is locked"); + } + None +} + fn has_log(s: &Session) -> std::result::Result<(), String> { if s.analysis.log.is_empty() { Err("the Measurement Log is empty".into()) } else { Ok(()) } } @@ -718,6 +777,12 @@ pub fn straighten_angle(r: &Ruler) -> f64 { fn straighten(s: &mut Session, p: &Value) -> Result { let d = doc(s)?; let r = d.measurement.ruler.ok_or_else(|| EngineError::Other("no ruler line".into()))?; + // A hidden target layer keeps its button and gets this alert instead of a rotation (#12033). + // Asked before anything is read for the edit, so the document, its History and the ruler line + // stay exactly as they were; a layer inside a hidden group is hidden too (#571). + if s.active().and_then(|st| st.active_layer).is_some_and(|id| d.path_of(id).is_some_and(|path| !d.shown_at(&path))) { + return Err(EngineError::Other(STRAIGHTEN_HIDDEN_ERROR.into())); + } let rot = straighten_angle(&r); if rot.abs() < 1e-9 { return Ok(json!({"angle": 0.0})); @@ -796,7 +861,7 @@ fn count_add(s: &mut Session, p: &Value) -> Result { if gi.is_some_and(|i| i >= group_count) { return Err(bad("count.add", "no such group")); } - let n = s.edit("Count", |d, _| { + let n = s.edit("Add Count Object", |d, _| { if d.measurement.count_groups.is_empty() { let g = new_group(d, None, None); d.measurement.count_groups.push(g); @@ -846,7 +911,7 @@ fn locate(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> { fn count_remove(s: &mut Session, p: &Value) -> Result { let (g, i) = locate(s, p, "count.remove")?; - s.edit("Count", |d, _| { + s.edit("Delete Count Object", |d, _| { d.measurement.count_groups[g].points.remove(i); Ok(()) })?; @@ -856,7 +921,7 @@ fn count_remove(s: &mut Session, p: &Value) -> Result { fn count_move(s: &mut Session, p: &Value) -> Result { let to = point(p.get("to")).ok_or_else(|| bad("count.move", "pass `to`: [x, y]"))?; let (g, i) = locate(s, p, "count.move")?; - s.edit("Count", |d, _| { + s.edit("Drag Count Object", |d, _| { d.measurement.count_groups[g].points[i] = to; Ok(()) })?; @@ -881,7 +946,7 @@ fn count_clear(s: &mut Session, p: &Value) -> Result { fn count_new_group(s: &mut Session, p: &Value) -> Result { let name = p.get("name").and_then(Value::as_str).map(str::to_string); let color = color_of("count.newGroup", p, "color")?; - s.edit("New Count Group", |d, _| { + s.edit("Add Count Group", |d, _| { let g = new_group(d, name, color); d.measurement.count_groups.push(g); d.measurement.active_count_group = d.measurement.count_groups.len() - 1; @@ -949,8 +1014,24 @@ fn count_set_group(s: &mut Session, p: &Value) -> Result { } let activate = p.get("active").and_then(Value::as_bool) == Some(true) || p.as_object().is_some_and(|o| o.len() == 1 && o.contains_key("group")); let changed = grp != d.measurement.count_groups[g]; - if changed || (activate && d.measurement.active_count_group != g) { - s.edit("Count Group Options", |d, _| { + let switches = activate && d.measurement.active_count_group != g; + if changed || switches { + // One field changed: Photoshop's name for it (#11763). Several fields, or a switch of the + // current group (not measured), keep "Count Group Options". + let old = &d.measurement.count_groups[g]; + let fields = [ + (grp.name != old.name, "Set Count Group Name"), + (grp.visible != old.visible, "Set Count Group Visibility"), + (grp.color != old.color, "Set Count Draw Color"), + (grp.marker_size != old.marker_size, "Set Count Marker Size"), + (grp.label_size != old.label_size, "Set Count Font Size"), + ]; + let mut one = fields.iter().filter(|(c, _)| *c).map(|(_, l)| *l); + let label = match (one.next(), one.next()) { + (Some(l), None) if !switches => l, + _ => "Count Group Options", + }; + s.edit(label, |d, _| { d.measurement.count_groups[g] = grp; if activate { d.measurement.active_count_group = g; @@ -1297,8 +1378,8 @@ pub fn specs() -> Vec { "image.analysis.straightenLayer", "Straighten Layer", [], - r##"{"crop":bool=(active layer is the Background)} (rotates so the ruler line is level; crop = rotate the canvas and crop to the image)"##, - has_ruler, + r##"{"crop":bool=(active layer is the Background)} (rotates so the ruler line is level; crop = rotate the canvas and crop to the image; off for the Background, two or more selected layers, a group, Lock All and Lock Position, and a hidden target layer answers "Could not straighten the layer because the target layer is hidden." with nothing changed (#12033))"##, + has_straightenable_layer, straighten, true ), @@ -1332,5 +1413,8 @@ pub fn specs() -> Vec { ] } +#[cfg(test)] +#[path = "analysis_straighten_enabled_tests.rs"] +mod analysis_straighten_enabled_tests; #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/analysis_cmds/tests.rs b/crates/engine/src/cmd/analysis_cmds/tests.rs index dc76470bc90..6978b4d0597 100644 --- a/crates/engine/src/cmd/analysis_cmds/tests.rs +++ b/crates/engine/src/cmd/analysis_cmds/tests.rs @@ -231,10 +231,13 @@ fn straighten_layer_and_image() { assert_eq!(s.active().unwrap().history.past_len(), before + 1, "one history step"); assert_eq!(d(&s).size.width, 80); assert!(d(&s).measurement.ruler.is_none()); - // The Background: rotate the canvas and crop to the image. + // The Background used to rotate the canvas and crop it to the image; #12033 greys that state + // out, so the same crop now happens on a layer and only when it is asked for. let mut s = session(80, 60, 16); s.execute("image.analysis.rulerTool", json!({"start": [0, 0], "end": [40, 40]})).unwrap(); - let r = s.execute("image.analysis.straightenLayer", json!({})).unwrap(); + assert!(!s.is_enabled("image.analysis.straightenLayer"), "the Background is greyed out (#12033)"); + s.execute("layer.new.layer", json!({})).unwrap(); + let r = s.execute("image.analysis.straightenLayer", json!({"crop": true})).unwrap(); assert_eq!(r["cropped"], true); assert_eq!(r["angle"].as_f64().unwrap().abs(), 45.0); let nd = d(&s); diff --git a/crates/engine/src/cmd/analysis_straighten_enabled_tests.rs b/crates/engine/src/cmd/analysis_straighten_enabled_tests.rs new file mode 100644 index 00000000000..b2d107d7bb7 --- /dev/null +++ b/crates/engine/src/cmd/analysis_straighten_enabled_tests.rs @@ -0,0 +1,283 @@ +//! Straighten Layer's enabled states and its hidden-layer refusal, measured against the reference +//! application (#12033). +//! +//! With a ruler line drawn the reference greys the button out for the Background, two or more +//! selected layers, a group, a layer with Lock All and a layer with Lock Position. A hidden target +//! layer keeps the button enabled: the click answers an alert ("Could not straighten the layer +//! because the target layer is hidden.") and nothing changes. Pixel, text and smart object layers +//! stay enabled and are rotated. +//! +//! A layer with a mask, a shape or vector layer, an adjustment layer, artboards and the other lock +//! kinds are *not measured* there: they keep today's behaviour, so the tests below only pin that +//! the new check does not reach them. + +use photocraft_doc::LayerId; + +use super::*; + +const CMD: &str = "image.analysis.straightenLayer"; + +/// A white document with a Background, as File > New makes one. +fn session(w: u32, h: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "background": "white"})).unwrap(); + s +} + +/// The same document in CMYK at 16 bits, which the reference also straightens. +fn cmyk_16(w: u32, h: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "mode": "cmyk", "depth": 16, "background": "white"})).unwrap(); + s +} + +/// A ruler line to straighten by: without one the command is off for every target. +fn rule(s: &mut Session) { + s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); +} + +/// A block of opaque pixels on the layer: an empty layer has nothing to transform, which would +/// hide the state under test behind "nothing to transform". The colour has the layer's own channel +/// count, so it works for RGB, Grayscale and CMYK at 8 and 16 bits alike. +fn paint(s: &mut Session, l: LayerId) { + s.edit("paint", |doc, _| { + let surf = doc.layer_mut(l).unwrap().surface_mut().unwrap(); + let mut px = vec![0.0; surf.channels()]; + if let Some(first) = px.first_mut() { + *first = 1.0; + } + if let Some(last) = px.last_mut() { + *last = 1.0; + } + surf.fill_rect(Rect::new(10, 10, 50, 20), &px); + Ok(()) + }) + .unwrap(); +} + +/// A new pixel layer with something on it, which `layer.new.layer` makes the active one. +fn pixel_layer(s: &mut Session, name: &str) -> LayerId { + let l = LayerId(s.execute("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap()); + paint(s, l); + l +} + +/// A type layer over the canvas. +fn text_layer(s: &mut Session) -> LayerId { + LayerId(s.execute("type.create", json!({"x": 40, "y": 50, "text": "Hi", "size": 20})).unwrap()["layer"].as_u64().unwrap()) +} + +/// A pixel layer turned into a smart object. +fn smart_layer(s: &mut Session) -> LayerId { + let l = pixel_layer(s, "Smart"); + s.execute("layer.smartObjects.convertToSmartObject", json!({"layer": l.0})).unwrap(); + l +} + +fn doc_of(s: &Session) -> Arc { + s.active().unwrap().doc.clone() +} + +fn steps(s: &Session) -> usize { + s.active().unwrap().history.past_len() +} + +/// A state the reference greys the button out for: the engine says the command is off, so the menu +/// item and the options-bar button follow, and a call answered anyway (the CLI, the control channel, +/// an action step) is refused as a disabled command rather than run. Nothing is touched: the +/// document is the same `Arc`, no History step is added and the ruler line stays. +fn assert_greyed(s: &mut Session, what: &str) { + let (before, history) = (doc_of(s), steps(s)); + assert!(!s.is_enabled(CMD), "{what}: the button is greyed out"); + let e = s.execute(CMD, json!({})).unwrap_err(); + let disabled = matches!(&e, EngineError::Disabled(id, _) if id == CMD); + assert!(disabled, "{what}: refused as a disabled command, got {e}"); + assert!(Arc::ptr_eq(&before, &doc_of(s)), "{what}: the document was not touched"); + assert_eq!(steps(s), history, "{what}: no History step"); + assert!(doc_of(s).measurement.ruler.is_some(), "{what}: the ruler line is still there"); +} + +/// The angle the ruler line of [`rule`] straightens by. +const ROT: f64 = 4.7636; + +#[test] +fn the_background_greys_straighten_layer_out() { + // Today it rotates the canvas and crops it (800 x 600 becomes 500 x 375); the reference has no + // button for it at all. + let mut s = session(100, 80); + assert!(doc_of(&s).is_background(s.active().unwrap().active_layer.unwrap()), "the Background is the target"); + rule(&mut s); + assert_greyed(&mut s, "the Background"); +} + +#[test] +fn two_selected_layers_grey_straighten_layer_out() { + let mut s = session(100, 80); + let a = pixel_layer(&mut s, "One"); + let b = pixel_layer(&mut s, "Two"); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers().len(), 2, "both selected"); + rule(&mut s); + assert_greyed(&mut s, "two selected layers"); + // Back to one layer, the button is live again. + s.execute("layer.select", json!({"layer": b.0})).unwrap(); + assert!(s.is_enabled(CMD), "one selected layer"); +} + +#[test] +fn a_group_greys_straighten_layer_out() { + let mut s = session(100, 80); + pixel_layer(&mut s, "Inner"); + s.execute("layer.groupLayers", json!({})).unwrap(); + let g = s.active().unwrap().active_layer.unwrap(); + assert!(doc_of(&s).layer(g).unwrap().is_group(), "the new group is the target"); + rule(&mut s); + assert_greyed(&mut s, "a group"); + // A layer inside the group is a layer, so the button is on for it. + let inner = doc_of(&s).layer(g).unwrap().children().unwrap()[0].id; + s.execute("layer.select", json!({"layer": inner.0})).unwrap(); + assert!(s.is_enabled(CMD), "a layer in a group"); +} + +#[test] +fn lock_all_and_lock_position_grey_straighten_layer_out() { + // Measured: both grey the button. Today it is enabled and the command fails with + // `layer "Colors" is locked`, which is what a greyed button cannot do any more. + for key in ["all", "position"] { + let mut s = session(100, 80); + let l = pixel_layer(&mut s, "Colors"); + s.execute("layer.setProps", json!({"layer": l.0, "locks": {key: true}})).unwrap(); + rule(&mut s); + assert_greyed(&mut s, &format!("Lock {}", if key == "all" { "All" } else { "Position" })); + } +} + +#[test] +fn the_other_lock_kinds_are_not_reached_by_the_new_check() { + // Not measured in the reference, so they keep today's behaviour: the button stays on and the + // command answers as it does now. + for key in ["transparency", "pixels", "artboard"] { + let mut s = session(100, 80); + let l = pixel_layer(&mut s, "Colors"); + s.execute("layer.setProps", json!({"layer": l.0, "locks": {key: true}})).unwrap(); + rule(&mut s); + assert!(s.is_enabled(CMD), "Lock {key}: not measured, still enabled"); + } +} + +#[test] +fn a_hidden_layer_stays_enabled_and_is_refused_with_the_alert() { + // The one measured refusal: the button is on, the click shows the alert and nothing changes. + let mut s = session(100, 80); + let l = pixel_layer(&mut s, "Gone"); + s.execute("layer.setProps", json!({"layer": l.0, "visible": false})).unwrap(); + rule(&mut s); + let (before, history) = (doc_of(&s), steps(&s)); + assert!(s.is_enabled(CMD), "a hidden layer keeps the button on"); + let e = s.execute(CMD, json!({})).unwrap_err(); + assert_eq!(e.to_string(), STRAIGHTEN_HIDDEN_ERROR, "the reference's alert text"); + assert!(matches!(e, EngineError::Other(_)), "an alert, not a greyed state: {e}"); + assert!(Arc::ptr_eq(&before, &doc_of(&s)), "nothing changed"); + assert_eq!(steps(&s), history, "no History step"); + assert!(doc_of(&s).measurement.ruler.is_some(), "a straighten that never ran leaves the ruler line"); +} + +#[test] +fn a_layer_hidden_with_its_group_is_refused_the_same_way() { + // A layer inside a hidden group is hidden, as for the painting tools (#571). + let mut s = session(100, 80); + let l = pixel_layer(&mut s, "Inner"); + s.execute("layer.groupLayers", json!({})).unwrap(); + let g = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.setProps", json!({"layer": g.0, "visible": false})).unwrap(); + s.execute("layer.select", json!({"layer": l.0})).unwrap(); + rule(&mut s); + let (before, history) = (doc_of(&s), steps(&s)); + assert!(s.is_enabled(CMD), "the target is a layer, not the group"); + assert_eq!(s.execute(CMD, json!({})).unwrap_err().to_string(), STRAIGHTEN_HIDDEN_ERROR); + assert!(Arc::ptr_eq(&before, &doc_of(&s))); + assert_eq!(steps(&s), history); +} + +#[test] +fn without_a_ruler_line_the_button_stays_greyed_for_every_layer() { + let mut s = session(100, 80); + pixel_layer(&mut s, "Paint"); + assert!(!s.is_enabled(CMD), "no ruler line"); + s.execute("layer.setProps", json!({"layer": s.active().unwrap().active_layer.unwrap().0, "visible": false})).unwrap(); + assert!(!s.is_enabled(CMD), "a hidden layer without a ruler line stays greyed, not enabled"); +} + +#[test] +fn an_active_layer_the_document_no_longer_holds_is_refused() { + // `Session::edit` prunes the selected layers but not the active id, so an edit that drops the + // active layer can leave one behind (`select_layer` and `restore_layer_selection` both refuse + // such an id). The command must then say it is off, not rotate and crop the whole canvas for a + // layer that is not there. + let mut s = session(100, 80); + let l = pixel_layer(&mut s, "Gone"); + s.edit("drop", |doc, _| { + doc.layers.retain(|layer| layer.id != l); + Ok(()) + }) + .unwrap(); + assert_eq!(s.active().unwrap().active_layer, Some(l), "the target still points at the dropped layer"); + assert!(doc_of(&s).layer(l).is_none(), "and the document does not hold it"); + rule(&mut s); + assert_greyed(&mut s, "an active layer that is gone"); + // The destructive case this closes: `crop` asks for the canvas, which would turn and shrink for + // a target that no longer exists. + let size = (doc_of(&s).size.width, doc_of(&s).size.height); + assert!(s.execute(CMD, json!({"crop": true})).is_err()); + assert_eq!((doc_of(&s).size.width, doc_of(&s).size.height), size, "the canvas kept its size"); +} + +#[test] +fn a_single_visible_layer_still_rotates_without_cropping() { + // The pin for the state that stays: the layer turns about the canvas centre, the canvas does not. + let mut s = session(100, 80); + pixel_layer(&mut s, "Paint"); + rule(&mut s); + let (history, size) = (steps(&s), (doc_of(&s).size.width, doc_of(&s).size.height)); + assert!(s.is_enabled(CMD), "a layer is what the button is for"); + let r = s.execute(CMD, json!({})).unwrap(); + assert_eq!(r["cropped"], false, "{r}"); + assert!((r["angle"].as_f64().unwrap() - ROT).abs() < 1e-3, "{r}"); + assert_eq!((doc_of(&s).size.width, doc_of(&s).size.height), size, "the canvas keeps its size"); + assert_eq!(steps(&s), history + 1, "one History step"); + assert!(doc_of(&s).measurement.ruler.is_none(), "the ruler line is cleared"); + assert!(s.undo()); + assert_eq!((doc_of(&s).size.width, doc_of(&s).size.height), size, "and one Undo takes it back"); +} + +#[test] +fn pixel_text_and_smart_object_layers_stay_enabled_and_rotate() { + // Measured, also in a CMYK 16-bit document. + type Maker = (&'static str, fn(&mut Session) -> LayerId); + let make: [Maker; 3] = [("pixel", |s| pixel_layer(s, "Paint")), ("type", text_layer), ("smart object", smart_layer)]; + for (what, make_layer) in make { + for new in [session as fn(u32, u32) -> Session, cmyk_16] { + let mut s = new(100, 80); + make_layer(&mut s); + rule(&mut s); + assert!(s.is_enabled(CMD), "{what}: enabled"); + let r = s.execute(CMD, json!({})); + let r = r.unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(r["cropped"], false, "{what}: rotated, not cropped"); + assert!((r["angle"].as_f64().unwrap() - ROT).abs() < 1e-3, "{what}: {r}"); + } + } +} + +#[test] +fn an_explicit_crop_still_crops_for_automation() { + // The `crop` param stays available to automation where the command is enabled. + let mut s = session(100, 80); + pixel_layer(&mut s, "Paint"); + rule(&mut s); + let r = s.execute(CMD, json!({"crop": true})).unwrap(); + assert_eq!(r["cropped"], true, "{r}"); + let d = doc_of(&s); + assert!(d.size.width < 100 && d.size.height < 80, "the canvas is rotated and cropped to {:?}", [d.size.width, d.size.height]); +} diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index a51022dceb8..0e90e5d3dc8 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -32,6 +32,7 @@ mod descriptors; mod events; mod fields; mod files; +mod menu_item; mod steps_artboard; mod steps_batch; @@ -41,6 +42,7 @@ mod steps_content_aware; mod steps_direct_adjustments; mod steps_distort; mod steps_doc; +mod steps_droplet; mod steps_gallery; mod steps_gradient; mod steps_image; @@ -48,6 +50,7 @@ mod steps_layer; mod steps_layer_style; mod steps_lookup; mod steps_make; +mod steps_path; mod steps_perspective_warp; mod steps_type_path; @@ -62,6 +65,7 @@ mod tables; #[cfg(test)] use adjustments::{levels_from, selective_from}; +pub(crate) use convert::UniqueNames; pub use convert::{Imported, Skipped, Unsupported, export, function_key_name, import, import_with_encoding, parse_function_key}; #[cfg(test)] use convert::{MAX_NAME_CHARS, export_item, normalise}; @@ -76,6 +80,7 @@ use events::{EVENTS, event_and_descriptor, event_for}; #[cfg(test)] use fields::Mapped; use files::{load, save}; +pub use menu_item::{MENU_ITEM, menu_item_command, menu_item_commands, menu_item_needs_settings}; #[cfg(test)] use steps_doc::crop_from; pub(crate) use steps_gallery::RECORDED_EVENT; @@ -150,6 +155,16 @@ pub fn specs() -> Vec { run: stop_message, journal: true, }, + CommandSpec { + id: MENU_ITEM, + label: "Menu Item", + menu: &[], + shortcut: None, + params: r##"{"id":menu command,"eventKind":"name"|"code"="name"} (a descriptor-free Image Size or unambiguous mapped filter menu event; desktop playback asks for settings when needed, headless playback only runs stateless menu items)"##, + enabled: always, + run: menu_item::run, + journal: true, + }, CommandSpec { id: PHOTOSHOP_STEP, label: "Photoshop Step", @@ -177,3 +192,6 @@ mod selection_tests; #[cfg(test)] mod render_tests; + +#[cfg(test)] +mod recorded_tools_tests; diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 8757b6ba04c..71b64db4cbd 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -69,10 +69,12 @@ fn convert_action(file: &AtnFile, a: &AtnAction, set: &str, unsupported: &mut Ve let mut action = Action::new(name.clone(), Vec::new()); action.set = Some(clip_name(set)); for (i, item) in a.items.iter().enumerate() { - let mapped = match (event_for(&item.event), &item.descriptor) { - (Some(e), Some(d)) => (e.from)(d), - (Some(e), None) => (e.from)(&Descriptor::default()), - (None, _) => Mapped::Unsupported { harmless: false }, + let menu = item.descriptor.is_none().then(|| super::menu_item::from_event(&item.event)).flatten(); + let mapped = match (menu, event_for(&item.event), &item.descriptor) { + (Some(params), _, _) => Mapped::Steps(vec![(super::MENU_ITEM.into(), params)]), + (_, Some(e), Some(d)) => (e.from)(d), + (_, Some(e), None) => (e.from)(&Descriptor::default()), + _ => Mapped::Unsupported { harmless: false }, }; let (new_steps, enabled) = match mapped { Mapped::Steps(mut s) => { @@ -136,6 +138,9 @@ pub struct Skipped { } pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option { + if id == super::MENU_ITEM { + return super::menu_item::export(params); + } if id == PHOTOSHOP_STEP { let event = jstr(params, "event")?; let event = match params.get("eventKind") { @@ -166,7 +171,7 @@ pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option, next_suffix: std::collections::HashMap, } impl UniqueNames { - pub(super) fn new>(existing: I) -> Self { + pub(crate) fn new>(existing: I) -> Self { UniqueNames { taken: existing.into_iter().collect(), next_suffix: std::collections::HashMap::new() } } /// Reserves and returns a unique name derived from `name`, recording it as taken. - pub(super) fn claim(&mut self, name: &str) -> String { + pub(crate) fn claim(&mut self, name: &str) -> String { if self.taken.insert(name.to_string()) { return name.to_string(); } diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 67469e4005a..162bf22f8a9 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -910,6 +910,7 @@ pub(super) const EVENTS: &[Event] = &[ "layer.new.layerFromBackground", "layer.layerStyle.blendingOptions", "layer.smartFilter.blendingOptions", + "path.set", ], from: set_from, to: set_to, @@ -1199,6 +1200,15 @@ pub(super) const EVENTS: &[Event] = &[ from: super::steps_batch::batch_from, to: super::steps_batch::batch_to, }, + // Create Droplet (#10710): Photoshop has no string ID for it. + Event { + code: "CrtD", + name: "CrtD", + label: "Create Droplet", + ids: &["file.automate.createDroplet"], + from: super::steps_droplet::droplet_from, + to: super::steps_droplet::droplet_to, + }, Event { code: "", name: "applyComp", label: "Apply Layer Comp", ids: &["layerComp.apply"], from: apply_comp_from, to: apply_comp_to }, Event { code: "Stop", name: "stop", label: "Stop", ids: &[STOP_MESSAGE], from: stop_from, to: stop_to }, ]; diff --git a/crates/engine/src/cmd/atn_cmds/files.rs b/crates/engine/src/cmd/atn_cmds/files.rs index 1c8633b995b..14be0473743 100644 --- a/crates/engine/src/cmd/atn_cmds/files.rs +++ b/crates/engine/src/cmd/atn_cmds/files.rs @@ -51,21 +51,22 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { names.push(name); added.push(a); } - // Play, Conditional and Batch steps call actions by name: follow the renames in the actions just - // loaded, each target looked up once by the name the file gave it. + // Play, Conditional, Batch and Create Droplet steps call actions by name: follow the renames in + // the actions just loaded, each target looked up once by the name the file gave it. for a in &mut added { for (id, params) in &mut a.steps { - // A qualified Batch reference belongs to the file's original set, not to a - // foreign set which happens to have the destination override's name. - if id == "file.automate.batch" && params.get("set").and_then(Value::as_str) == Some(original_set.as_str()) { + // A qualified Batch or Create Droplet reference belongs to the file's original set, not + // to a foreign set which happens to have the destination override's name. + let by_set = matches!(id.as_str(), "file.automate.batch" | "file.automate.createDroplet"); + if by_set && params.get("set").and_then(Value::as_str) == Some(original_set.as_str()) { params["set"] = json!(imp.set); - } else if id == "file.automate.batch" && params.get("set").is_some_and(|set| !set.is_null()) { + } else if by_set && params.get("set").is_some_and(|set| !set.is_null()) { continue; } let keys: &[&str] = match id.as_str() { "actions.play" => &["action"], "actions.conditional" => &["then", "else"], - "file.automate.batch" => &["action"], + "file.automate.batch" | "file.automate.createDroplet" => &["action"], _ => &[], }; for key in keys { diff --git a/crates/engine/src/cmd/atn_cmds/menu_item.rs b/crates/engine/src/cmd/atn_cmds/menu_item.rs new file mode 100644 index 00000000000..69ed161cd61 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/menu_item.rs @@ -0,0 +1,90 @@ +//! Descriptor-free menu intent is distinct from a command with recorded settings. +//! Image Size and unambiguous mapped filter events keep absent settings distinct from empty ones. + +use super::events::{EVENTS, Event, event_for}; +use super::{AtnEvent, AtnItem, EngineError, Result, Session, Value, bad, json}; + +/// Find a supported, unambiguous event for a menu step with no settings. +fn menu_event(id: &str) -> Option<&'static Event> { + // Camera Raw's ATN mapping requires a verified settings descriptor (#8714). + if id == crate::lens_cmds::RAW || (id != "image.imageSize" && !id.starts_with("filter.")) { + return None; + } + EVENTS.iter().find(|e| { + e.ids == [id] + && !e.name.is_empty() + && event_for(&AtnEvent::name(e.name)).is_some_and(|mapped| mapped.code == e.code && mapped.ids == e.ids) + && crate::commands::find(id).is_some() + }) +} + +/// Menu choices use existing event mappings rather than inventing event names from command ids. +pub fn menu_item_commands() -> Vec<&'static str> { + let mut seen = std::collections::HashSet::new(); + EVENTS.iter().flat_map(|e| e.ids.iter().copied()).filter(|id| menu_event(id).is_some() && seen.insert(*id)).collect() +} + +/// Stateless filter menu items execute immediately; settings-bearing ones require the editor. +pub fn menu_item_needs_settings(id: &str) -> bool { + crate::commands::find(id).is_none_or(|c| !c.params.trim_start().starts_with("{}")) +} + +/// An Insert Menu Item step with no recorded command descriptor. +pub const MENU_ITEM: &str = "actions.menuItem"; + +/// Validate menu identity before the shell gates and opens its actual command. +pub fn menu_item_command(p: &Value) -> Result<&str> { + let object = p.as_object().ok_or_else(|| bad(MENU_ITEM, "parameters must be an object"))?; + if object.keys().any(|key| !matches!(key.as_str(), "id" | "eventKind" | super::RECORDED_EVENT)) { + return Err(bad(MENU_ITEM, "only `id` and `eventKind` are supported; a menu item has no recorded settings")); + } + if object.get("eventKind").is_some_and(|kind| !matches!(kind.as_str(), Some("code" | "name"))) { + return Err(bad(MENU_ITEM, "`eventKind` must be name or code")); + } + let id = p.get("id").and_then(Value::as_str).ok_or_else(|| bad(MENU_ITEM, "select a menu command"))?; + let event = menu_event(id).ok_or_else(|| bad(MENU_ITEM, "the command has no unambiguous supported menu event"))?; + if p.get("eventKind").and_then(Value::as_str) == Some("code") { + let code = event.code.as_bytes().try_into().ok().map(AtnEvent::Code); + if !code.as_ref().and_then(event_for).is_some_and(|mapped| mapped.name == event.name && mapped.ids == event.ids) { + return Err(bad(MENU_ITEM, "this menu event has no unambiguous four-character code")); + } + } + Ok(id) +} + +/// Preserve the exact code/name kind only for the supported descriptor-free event. +pub(super) fn from_event(event: &AtnEvent) -> Option { + let mapped = event_for(event)?; + let id = *mapped.ids.first()?; + let canonical = menu_event(id)?; + if canonical.name != mapped.name || canonical.code != mapped.code { + return None; + } + let kind = match event { + AtnEvent::Code(code) if mapped.code.as_bytes() == code => "code", + AtnEvent::Name(name) if mapped.name.as_bytes() == name => "name", + _ => return None, + }; + Some(json!({"id":id,"eventKind":kind})) +} + +/// Write absent settings as `None`, never as an empty or current-target descriptor. +pub(super) fn export(p: &Value) -> Option { + let mapped = menu_event(menu_item_command(p).ok()?)?; + let event = if p.get("eventKind").and_then(Value::as_str) == Some("code") { + AtnEvent::Code(mapped.code.as_bytes().try_into().ok()?) + } else { + AtnEvent::name(mapped.name) + }; + let (event, dictionary) = super::steps_gallery::recorded_event(super::MENU_ITEM, p).unwrap_or_else(|| (event, mapped.label.as_bytes().to_vec())); + Some(AtnItem { event, dictionary, descriptor: None, ..Default::default() }) +} + +/// Headless execution runs stateless items and refuses settings deliberately left for a dialog. +pub(super) fn run(s: &mut Session, p: &Value) -> Result { + let id = menu_item_command(p)?; + if !menu_item_needs_settings(id) { + return s.execute(id, json!({})); + } + Err(EngineError::Other(format!("interactive-required: `{id}` needs its menu item dialog; no recorded settings were supplied"))) +} diff --git a/crates/engine/src/cmd/atn_cmds/path_tests.rs b/crates/engine/src/cmd/atn_cmds/path_tests.rs new file mode 100644 index 00000000000..25efc33c0d3 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/path_tests.rs @@ -0,0 +1,227 @@ +use super::super::{Action, AtnAction, AtnEvent, AtnFile, AtnItem, UnicodeString, atn}; +use super::*; + +/// Construct a pixel point directly from the measured #10706 wire format. +fn wire_point(x: f64, y: f64) -> D { + D::Descriptor(Descriptor::new("Pnt ").with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: x }).with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: y })) +} + +/// Encode a fixture corner without cubic handles. +fn corner(x: f64, y: f64) -> D { + D::Descriptor(Descriptor::new("Pthp").with("Anch", wire_point(x, y))) +} + +/// Build the measured outer and inner contours independently of the exporter. +fn fixture(operation: &str, joined: bool, reversed: bool) -> Descriptor { + let mut outer = vec![corner(50.0, 250.0), corner(50.0, 100.0), corner(350.0, 100.0), corner(350.0, 250.0)]; + if !joined { + outer[1] = D::Descriptor( + Descriptor::new("Pthp") + .with("Anch", wire_point(50.0, 100.0)) + .with("Fwd ", wire_point(50.0, 100.0)) + .with("Bwd ", wire_point(100.0, 20.0)) + .with("Smoo", D::Boolean(false)), + ); + outer[2] = D::Descriptor( + Descriptor::new("Pthp") + .with("Anch", wire_point(350.0, 100.0)) + .with("Fwd ", wire_point(300.0, 20.0)) + .with("Bwd ", wire_point(350.0, 100.0)) + .with("Smoo", D::Boolean(false)), + ); + } + let mut inner = vec![corner(150.0, 150.0), corner(250.0, 150.0), corner(250.0, 220.0), corner(150.0, 220.0)]; + if reversed { + inner.reverse(); + } + let sub = |points| D::Descriptor(Descriptor::new("Sbpl").with("Clsp", D::Boolean(true)).with("Pts ", D::List(points))); + let component = |op, subs| { + D::Descriptor( + Descriptor::new("PaCm") + .with("shapeOperation", D::Enumerated { type_id: Id::new("shapeOperation"), value: Id::new(op) }) + .with("SbpL", D::List(subs)), + ) + }; + let components = if joined { + vec![component("Add ", vec![sub(outer), sub(inner)])] + } else { + vec![component("Add ", vec![sub(outer)]), component(operation, vec![sub(inner)])] + }; + Descriptor::new(if joined { "null" } else { "setd" }) + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Path"), key: Id::new("WrPt") }])) + .with("T ", D::List(components)) +} + +/// Extract the single executable Work Path assignment from a measured descriptor. +fn params(d: &Descriptor) -> Value { + let Mapped::Steps(steps) = from(d) else { panic!("measured path must map") }; + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].0, "path.set"); + steps[0].1.clone() +} + +/// Sample the filled contour, its hole and the untouched exterior independently. +fn check_area(path: &Path) { + let pixels = photocraft_vector::fill_rasterizer(path, photocraft_vector::DEFAULT_TOLERANCE).render(photocraft_geom::Rect::new(0, 0, 400, 300)); + for (x, y, expected) in [(100, 185, 1.0), (200, 185, 0.0), (10, 20, 0.0)] { + assert_eq!(pixels[y * 400 + x], expected, "sample {x},{y}"); + } +} + +#[test] +fn measured_curves_and_component_operations_round_trip_and_cut_holes() { + for (operation, expected) in [("Sbtr", PathOp::Subtract), ("xor", PathOp::Exclude)] { + let d = fixture(operation, false, false); + let p = params(&d); + let path = crate::vector_cmds::parse_path(&p["path"]).unwrap(); + assert_eq!(path.fill_rule, FillRule::EvenOdd); + assert_eq!(path.subpaths[1].op, expected); + let knots = &path.subpaths[0].knots; + assert_eq!(knots[1].out_ctrl, Point::new(50.0, 100.0)); + assert_eq!(knots[1].in_ctrl, Point::new(100.0, 20.0)); + assert_eq!(knots[2].out_ctrl, Point::new(300.0, 20.0)); + assert_eq!(knots[2].in_ctrl, Point::new(350.0, 100.0)); + assert_eq!(to(&p), Some(d)); + check_area(&path); + } +} + +#[test] +fn one_component_has_even_odd_holes_with_either_inner_winding() { + for reversed in [false, true] { + let d = fixture("Add ", true, reversed); + let p = params(&d); + let path = crate::vector_cmds::parse_path(&p["path"]).unwrap(); + assert_eq!(path.components(), vec![0..2]); + assert_eq!(path.subpaths[1].op, PathOp::Join); + check_area(&path); + assert_eq!(to(&p), Some(d)); + } +} + +#[test] +fn whole_atn_and_native_insert_path_replay_keep_geometry() { + let original = fixture("Sbtr", false, false); + let file = AtnFile { + actions: vec![AtnAction { + items: vec![AtnItem { event: AtnEvent::name("set"), descriptor: Some(original.clone()), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }; + let imported = super::super::import(&atn::write(&file).unwrap()).unwrap(); + assert!(imported.unsupported.is_empty()); + let (bytes, skipped) = super::super::export("S", &[&imported.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(original.clone())); + let expected = path_from(&original).unwrap(); + let mut s = crate::Session::new(); + s.execute("file.new", json!({"width":400,"height":300})).unwrap(); + s.execute("path.set", json!({"name":"Source","path":crate::vector_cmds::path_json(&expected)})).unwrap(); + s.actions.list.push(Action::new("Captured", Vec::new())); + let revision = s.active().unwrap().revision; + s.execute("actions.insertPath", json!({"action":0,"name":"Source"})).unwrap(); + assert_eq!(s.active().unwrap().revision, revision); + let (bytes, skipped) = super::super::export("S", &[&s.actions.list[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = super::super::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + s.actions.list = imported.actions; + s.execute("path.set", json!({"name":"Source","path":{"subpaths":[]}})).unwrap(); + let played = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(s.active().unwrap().doc.work_path.as_ref(), Some(&expected)); + check_area(s.active().unwrap().doc.work_path.as_ref().unwrap()); +} + +#[test] +fn source_wire_identity_is_retained_only_for_unchanged_geometry() { + let mut d = fixture("xor", false, false); + d.name = UnicodeString::new_nul("Set"); + d.items.reverse(); + let mut p = params(&d); + assert_eq!(to(&p).unwrap().to_bytes(), d.to_bytes()); + p["path"]["subpaths"][0]["knots"][0]["anchor"][0] = json!(51.0); + assert!(to(&p).is_none()); +} + +#[test] +fn unknown_and_unrepresented_settings_are_not_silently_lost() { + let base = fixture("Sbtr", false, false); + let mut empty_components = base.clone(); + let (_, components) = empty_components.items.iter_mut().find(|(key, _)| key.is("T ")).unwrap(); + *components = D::List(Vec::new()); + for d in [base.clone().with("extra", D::Boolean(true)), empty_components, fixture("Intr", false, false)] { + assert!(matches!(from(&d), Mapped::Unsupported { harmless: false })); + } + let p = params(&base); + let mut empty = p.clone(); + empty["path"]["subpaths"] = json!([]); + empty.as_object_mut().unwrap().remove(SOURCE); + assert!(to(&empty).is_none()); + for key in ["inverted", "fillRule"] { + let mut changed = p.clone(); + changed["path"][key] = if key == "inverted" { json!(true) } else { json!("nonzero") }; + changed.as_object_mut().unwrap().remove(SOURCE); + assert!(to(&changed).is_none()); + } + let mut changed = p.clone(); + changed["path"]["unrepresented"] = json!(1); + assert!(to(&changed).is_none()); + let mut changed = p; + changed[SOURCE] = json!("not base64"); + assert!(to(&changed).is_none()); +} + +#[test] +fn malformed_coordinates_and_oversized_paths_stay_unsupported() { + fn list(value: &mut D) -> &mut Vec { + match value { + D::List(v) => v, + _ => panic!("fixture list"), + } + } + fn object(value: &mut D) -> &mut Descriptor { + match value { + D::Descriptor(v) => v, + _ => panic!("fixture object"), + } + } + for bad in [D::UnitFloat { unit: *b"#Prc", value: 50.0 }, D::UnitFloat { unit: *b"#Pxl", value: f64::NAN }, D::Double(50.0)] { + let mut d = fixture("Sbtr", false, false); + let components = list(&mut d.items[1].1); + let subs = list(&mut object(&mut components[0]).items[1].1); + let points = list(&mut object(&mut subs[0]).items[1].1); + let anchor = object(&mut points[0]); + let coordinates = object(&mut anchor.items[0].1); + coordinates.items[0].1 = bad; + assert!(matches!(from(&d), Mapped::Unsupported { harmless: false })); + } + let mut p = params(&fixture("Sbtr", false, false)); + p.as_object_mut().unwrap().remove(SOURCE); + let knot = p["path"]["subpaths"][0]["knots"][0].clone(); + p["path"]["subpaths"][0]["knots"] = json!(vec![knot.clone(); MAX_PARTS - 4]); + assert!(to(&p).is_some(), "the existing 4096-knot ceiling remains representable"); + p["path"]["subpaths"][0]["knots"] = json!(vec![knot; MAX_PARTS + 1]); + assert!(to(&p).is_none()); +} + +#[test] +fn oversized_descriptor_names_are_rejected_before_retained_wire_serialization() { + // Each name meets the local limit, but their aggregate exceeds the carrier. + // Keep this typed input independent of the serializer under test. + let mut point = + Descriptor::new("Pnt ").with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: 50.0 }).with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: 50.0 }); + point.name = UnicodeString(vec![b'x' as u16; 4096]); + let mut knot = Descriptor::new("Pthp").with("Anch", D::Descriptor(point)); + knot.name = UnicodeString(vec![b'x' as u16; 4096]); + let sub = Descriptor::new("Sbpl").with("Clsp", D::Boolean(true)).with("Pts ", D::List(vec![D::Descriptor(knot); 300])); + let component = Descriptor::new("PaCm") + .with("shapeOperation", D::Enumerated { type_id: Id::new("shapeOperation"), value: Id::new("Add ") }) + .with("SbpL", D::List(vec![D::Descriptor(sub)])); + let d = Descriptor::new("setd") + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Path"), key: Id::new("WrPt") }])) + .with("T ", D::List(vec![D::Descriptor(component)])); + assert!(path_from(&d).is_none(), "recognition must reject before from() serializes the retained descriptor"); + assert!(matches!(from(&d), Mapped::Unsupported { harmless: false })); +} diff --git a/crates/engine/src/cmd/atn_cmds/recorded_tools_tests.rs b/crates/engine/src/cmd/atn_cmds/recorded_tools_tests.rs new file mode 100644 index 00000000000..c63c863b32a --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/recorded_tools_tests.rs @@ -0,0 +1,214 @@ +//! #10710: Actions steps recorded with the Gradient tool and Create Droplet, as the issue's table +//! gives them (measured in `docs/atn.md` › "Measured through dialogs and recordings"; once mapped, +//! the events are listed with the supported events instead). + +use super::*; + +fn action(items: Vec) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Recorded"), + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + ..Default::default() + }) + .unwrap() +} + +fn step(event: &[u8; 4], label: &str, d: Descriptor) -> AtnItem { + AtnItem { event: AtnEvent::Code(*event), dictionary: label.as_bytes().to_vec(), descriptor: Some(d), ..Default::default() } +} + +fn saved(s: &mut Session) -> Vec { + let r = s.execute("actions.save", json!({"action": "A"})).unwrap(); + assert_eq!(r["skipped"], json!([]), "{r}"); + atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap().actions.remove(0).items +} + +/// The gradient object the Gradient tool records: dither, classic interpolation, angle, type, +/// scale and offset in percent, then the two-stop gradient. +fn tool_gradient(angle: f64, scale: f64, offset: (f64, f64)) -> Descriptor { + let stop = |hex: &str, at: i32| { + D::Descriptor( + desc("Clrt") + .with("Clr ", D::Descriptor(color_desc(hex).unwrap())) + .with("Type", enm("Clry", "UsrS")) + .with("Lctn", D::Integer(at)) + .with("Mdpn", D::Integer(50)), + ) + }; + let trn = |at: i32| D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(at)).with("Mdpn", D::Integer(50))); + let grad = desc("Grdn") + .with("Nm ", text("Custom")) + .with("GrdF", enm("GrdF", "CstS")) + .with("Intr", D::Double(4096.0)) + .with("Clrs", D::List(vec![stop("#102030", 0), stop("#f0e0d0", 4096)])) + .with("Trns", D::List(vec![trn(0), trn(4096)])); + desc("gradientLayer") + .with("Dthr", D::Boolean(true)) + .with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", "Gcls")) + .with("Angl", ang(angle)) + .with("Type", enm("GrdT", "Lnr ")) + .with("Scl ", pct(scale)) + .with("Ofst", D::Descriptor(desc("Pnt ").with("Hrzn", pct(offset.0)).with("Vrtc", pct(offset.1)))) + .with("Grad", D::Descriptor(grad)) +} + +/// The Gradient tool (default mode) records a Make of a gradient fill layer at the first click and +/// a Set with the finished drag (`Algn` false). Both load as their commands, play, and save back +/// as recorded. +#[test] +fn gradient_tool_steps_load_play_and_save_back() { + let make = desc("null") + .with("null", class_ref("contentLayer")) + .with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(tool_gradient(90.0, 100.0, (0.0, 0.0)))))); + let set = desc("null") + .with("null", target_ref("contentLayer")) + .with("T ", D::Descriptor(tool_gradient(-35.0, 140.0, (12.5, -8.0)).with("Algn", D::Boolean(false)))); + let items = vec![step(b"Mk ", "Make", make.clone()), step(b"setd", "Set", set.clone())]; + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&action(items))})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["layer.newFillLayer.gradient", "gradient.fill.set"]); + let set_params = &s.actions.list[0].steps[1].1; + assert_eq!( + (set_params["angle"].as_f64(), set_params["scale"].as_f64(), set_params["align"].as_bool()), + (Some(-35.0), Some(140.0), Some(false)), + "{set_params}" + ); + + s.execute("file.new", json!({"width": 20, "height": 12})).unwrap(); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(played["ran"], 2, "{played}"); + + let back = saved(&mut s); + assert_eq!(back.len(), 2); + assert_eq!((back[0].event.id(), back[0].descriptor.as_ref()), ("Mk ".to_string(), Some(&make))); + assert_eq!((back[1].event.id(), back[1].descriptor.as_ref()), ("setd".to_string(), Some(&set))); +} + +/// A Create Droplet step as the reference application records it with a Folder destination and +/// Override Action "Save As" on. +fn droplet(file: &str, output: &str, extra: &[(&str, D)]) -> Descriptor { + let reference = D::Reference(vec![ + ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("Sharpen") }, + ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul("Tools") }, + ]); + let mut d = desc("null").with("In ", path_value(file)).with("Fldr", D::Boolean(false)).with("Usng", reference); + for (k, v) in extra.iter().take_while(|(k, _)| *k == "OvrO") { + d = d.with(k, v.clone()); + } + d = d.with("T ", path_value(output)).with("Ovrd", D::Boolean(true)); + for (k, v) in extra.iter().filter(|(k, _)| *k != "OvrO") { + d = d.with(k, v.clone()); + } + d +} + +/// Create Droplet loads as `file.automate.createDroplet`, plays (writes the droplet with the +/// action's steps) and saves back as recorded. +#[test] +fn create_droplet_step_loads_plays_and_saves_back() { + let dir = std::env::temp_dir().join(format!("pc-10710-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("Sharpen.pcdroplet").to_string_lossy().into_owned(); + let out = dir.join("out").to_string_lossy().into_owned(); + let recorded = droplet(&file, &out, &[("OvrO", D::Boolean(true))]); + let mut s = Session::new(); + // The action the droplet runs. + s.actions.list.push(crate::actions_cmds::Action::new("Sharpen", vec![("filter.sharpen.sharpen".into(), json!({}))])); + s.actions.list[0].set = Some("Tools".into()); + let loaded = s + .execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&action(vec![step(b"CrtD", "Create Droplet", recorded.clone())]))})) + .unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + let a = s.actions.list.iter().find(|a| a.name == "A").unwrap(); + assert_eq!( + a.steps, + [("file.automate.createDroplet".to_string(), json!({"path": file, "action": "Sharpen", "set": "Tools", "output": out, "overrideOpen": true}))] + ); + let r = s.execute("file.automate.createDroplet", a.steps[0].1.clone()).unwrap(); + assert_eq!(r["path"], json!(file)); + let written: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap(); + assert_eq!(written["action"]["steps"], json!([["filter.sharpen.sharpen", {}]])); + assert_eq!((written["options"]["output"].as_str(), written["options"]["overrideOpen"].as_bool()), (Some(out.as_str()), Some(true))); + let r = s.execute("actions.save", json!({"action": "A"})).unwrap(); + let back = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); + let item = back.actions.iter().find(|a| a.name.to_string_lossy() == "A").unwrap().items[0].clone(); + assert_eq!((item.event.id(), item.descriptor), ("CrtD".to_string(), Some(recorded))); + let _ = std::fs::remove_dir_all(&dir); +} + +/// What the droplet cannot do keeps the Photoshop step: Include All Subfolders, Save and Close, +/// no destination, Save As steps left to save where recorded, suppressed dialogs, custom naming +/// and an error log. +#[test] +fn create_droplet_steps_the_droplet_cannot_play_are_kept() { + let kept = |d: Descriptor| matches!(steps_droplet::droplet_from(&d), Mapped::Unsupported { harmless: false }); + let base = || droplet("/d/S.pcdroplet", "/d/out", &[]); + let replaced = |key: &str, v: D| { + let mut d = base(); + d.items.retain(|(k, _)| !k.is(key)); + d.with(key, v) + }; + assert!(!kept(base()), "the plain form maps"); + assert!(kept(replaced("Fldr", D::Boolean(true)))); + assert!(kept(replaced("Ovrd", D::Boolean(false)))); + let mut no_destination = base(); + no_destination.items.retain(|(k, _)| !k.is("T ")); + assert!(kept(no_destination.clone())); + assert!(kept(no_destination.with("SvAn", D::Boolean(true)))); + assert!(kept(base().with("suppressOpenOptions", D::Boolean(true)))); + assert!(kept(base().with("fileNamingRules", D::Descriptor(desc("fileNamingRules"))))); + assert!(kept(base().with("Log ", path_value("/d/logs")))); +} + +/// Settings the event has no key for refuse the step on save rather than replay differently. +#[test] +fn create_droplet_steps_that_cannot_be_written_are_refused() { + let p = json!({"path": "/d/S.pcdroplet", "action": "Sharpen", "output": "/d/out"}); + assert!(export_item("file.automate.createDroplet", &p, "S").is_some()); + for extra in [json!({"format": "png"}), json!({"quality": 8}), json!({"steps": [["filter.sharpen.sharpen", {}]]}), json!({"name": "Other"})] { + let mut q = p.clone(); + q.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + assert!(export_item("file.automate.createDroplet", &q, "S").is_none(), "{q}"); + } + let mut no_output = p.clone(); + no_output.as_object_mut().unwrap().remove("output"); + assert!(export_item("file.automate.createDroplet", &no_output, "S").is_none()); +} + +/// Loading a set whose action clashes with one already in the panel renames the loaded copy; its +/// Create Droplet steps follow the rename, as Batch steps do, whether the reference names the +/// action alone or the action in the file's own set (CodeRabbit review on steps_droplet.rs:59). +#[test] +fn create_droplet_steps_follow_a_renamed_loaded_action() { + let with_ref = |items: Vec| { + let mut d = droplet("/d/S.pcdroplet", "/d/out", &[]); + d.items.retain(|(k, _)| !k.is("Usng")); + d.with("Usng", D::Reference(items)) + }; + let actn = || ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("Sharpen") }; + let own_set = ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul("Recorded") }; + let file = atn::write(&AtnFile { + name: UnicodeString::new_nul("Recorded"), + actions: vec![ + AtnAction { name: UnicodeString::new_nul("Sharpen"), ..Default::default() }, + AtnAction { + name: UnicodeString::new_nul("A"), + items: vec![step(b"CrtD", "Create Droplet", with_ref(vec![actn()])), step(b"CrtD", "Create Droplet", with_ref(vec![actn(), own_set]))], + ..Default::default() + }, + ], + ..Default::default() + }) + .unwrap(); + let mut s = Session::new(); + s.actions.list.push(crate::actions_cmds::Action::new("Sharpen", vec![])); + s.actions.list[0].set = Some("Tools".into()); + let r = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&file), "set": "Loaded"})).unwrap(); + assert_eq!(r["actions"], json!(["Sharpen 2", "A"]), "{r}"); + let a = s.actions.list.iter().find(|a| a.name == "A").unwrap(); + assert_eq!(a.steps[0].1["action"], "Sharpen 2", "{:?}", a.steps[0]); + assert_eq!((&a.steps[1].1["action"], &a.steps[1].1["set"]), (&json!("Sharpen 2"), &json!("Loaded")), "{:?}", a.steps[1]); +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_droplet.rs b/crates/engine/src/cmd/atn_cmds/steps_droplet.rs new file mode 100644 index 00000000000..beee14d2f93 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_droplet.rs @@ -0,0 +1,85 @@ +//! File › Automate › Create Droplet (`CrtD`, #10710): the droplet file (`In `), the action +//! (`Usng`) and the Batch keys, as #10710 lists them from the reference application. Only the form +//! `file.automate.createDroplet` plays the same way maps; any other stays a Photoshop step. + +use super::descriptors::{class, desc, jstr, path_text, path_value}; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, Map, ReferenceItem, UnicodeString, Value, json}; + +/// The keys a recorded Create Droplet step can carry (the Batch keys, with `In ` for the source). +const KEYS: &[&str] = &["In ", "Fldr", "Usng", "OvrO", "suppressWarnings", "suppressOpenOptions", "T ", "SvAn", "Ovrd", "fileNamingRules", "Log ", "Nm "]; + +fn flag(d: &Descriptor, key: &str) -> Option { + match d.get(key) { + None => Some(false), + Some(D::Boolean(b)) => Some(*b), + Some(_) => None, + } +} + +/// A droplet that saves each file into a folder and overrides the action's Save As steps maps: +/// that is what `file.automate.createDroplet` writes (its output folder replaces recorded saves). +/// Include All Subfolders (the droplet lists a folder's own images only), Save and Close or no +/// destination, Save As steps left to save where they were recorded, suppressed warnings or open +/// options, custom file naming and an error log have no field in the droplet, so they keep the +/// Photoshop step. +pub(super) fn droplet_from(d: &Descriptor) -> Mapped { + read(d).map_or(Mapped::Unsupported { harmless: false }, |p| step("file.automate.createDroplet", Value::Object(p))) +} + +fn read(d: &Descriptor) -> Option> { + if !d.items.iter().all(|(k, _)| KEYS.iter().any(|s| k.is(s))) { + return None; + } + let off = |k: &str| flag(d, k) == Some(false); + if !off("Fldr") || !off("SvAn") || !off("suppressWarnings") || !off("suppressOpenOptions") || flag(d, "Ovrd") != Some(true) { + return None; + } + if ["fileNamingRules", "Log ", "Nm "].iter().any(|k| d.get(k).is_some()) { + return None; + } + let mut p = Map::new(); + p.insert("path".into(), json!(path_text(d.get("In ")?)?)); + let D::Reference(items) = d.get("Usng")? else { return None }; + match items.as_slice() { + [ReferenceItem::Name { class: action, name }, ReferenceItem::Name { class: set, name: set_name }] + if action.class_id.is("Actn") && set.class_id.is("ASet") => + { + p.insert("action".into(), json!(name.to_string_lossy())); + p.insert("set".into(), json!(set_name.to_string_lossy())); + } + [ReferenceItem::Name { class: action, name }] if action.class_id.is("Actn") => { + p.insert("action".into(), json!(name.to_string_lossy())); + } + _ => return None, + } + p.insert("output".into(), json!(path_text(d.get("T ")?)?)); + if flag(d, "OvrO")? { + p.insert("overrideOpen".into(), json!(true)); + } + Some(p) +} + +/// Written as `read` reads it: the droplet file, Include All Subfolders off, the action by name +/// (in its set when the step names one), Override Action "Open" only when on, the destination +/// folder and Override Action "Save As" on. Any other setting refuses the step. +pub(super) fn droplet_to(_: &str, p: &Value) -> Option { + let allowed = ["path", "action", "set", "output", "overrideOpen"]; + if p.as_object()?.iter().any(|(k, v)| !v.is_null() && !allowed.contains(&k.as_str())) { + return None; + } + let path = jstr(p, "path").filter(|s| !s.is_empty())?; + let action = jstr(p, "action").filter(|s| !s.is_empty())?; + let output = jstr(p, "output").filter(|s| !s.is_empty())?; + let mut reference = vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul(action) }]; + if let Some(set) = p.get("set").filter(|v| !v.is_null()) { + reference.push(ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul(set.as_str()?) }); + } + let mut d = desc("null").with("In ", path_value(path)).with("Fldr", D::Boolean(false)).with("Usng", D::Reference(reference)); + match p.get("overrideOpen") { + None | Some(Value::Null) | Some(Value::Bool(false)) => {} + Some(Value::Bool(true)) => d = d.with("OvrO", D::Boolean(true)), + Some(_) => return None, + } + Some(d.with("T ", path_value(output)).with("Ovrd", D::Boolean(true))) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs index 215fab4a672..e844ce7daf4 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs @@ -600,11 +600,12 @@ pub(crate) const RECORDED_EVENT: &str = "_atnEvent"; /// Remember the event of a step loaded from an effect's own event (`AccE`, `accentedEdges`). /// A `GEfc` step carries nothing and saves as `GEfc`, as does a step made in PhotoCraft. pub(super) fn remember_event(item: &AtnItem, steps: &mut [(String, Value)]) { - if !is_effect(&item.event) { - return; - } let [(id, Value::Object(p))] = steps else { return }; - if effect_command(&item.event) != Some(id.as_str()) { + let menu = id == super::MENU_ITEM + && item.descriptor.is_none() + && super::menu_item::from_event(&item.event) + .is_some_and(|expected| p.get("id") == expected.get("id") && p.get("eventKind") == expected.get("eventKind")); + if !menu && effect_command(&item.event) != Some(id.as_str()) { return; } let kind = match item.event { @@ -617,8 +618,8 @@ pub(super) fn remember_event(item: &AtnItem, steps: &mut [(String, Value)]) { p.insert(RECORDED_EVENT.into(), json!({"event": item.event.id(), "eventKind": kind, "name": item.dictionary(), "dictionary": dictionary})); } -/// The event and panel name bytes a step of command `id` remembered by [`remember_event`] saves -/// as; `None` (save as `GEfc`) when it has none or it is not the own event of `id`'s effect. +/// Return remembered event/name bytes only while they still match the current command and kind. +/// Otherwise the exporter uses that command's canonical event and name. pub(super) fn recorded_event(id: &str, params: &Value) -> Option<(AtnEvent, Vec)> { let r = params.get(RECORDED_EVENT)?; let event = jstr(r, "event")?; @@ -631,7 +632,13 @@ pub(super) fn recorded_event(id: &str, params: &Value) -> Option<(AtnEvent, Vec< Some(bytes) => bytes, None => jstr(r, "name").unwrap_or("").as_bytes().to_vec(), }; - (effect_command(&event) == Some(id)).then_some((event, name)) + let matches = if id == super::MENU_ITEM { + super::menu_item::from_event(&event) + .is_some_and(|expected| params.get("id") == expected.get("id") && params.get("eventKind") == expected.get("eventKind")) + } else { + effect_command(&event) == Some(id) + }; + matches.then_some((event, name)) } /// A Filter Gallery step: the effect `GEfk` names, every key clamped or looked up, Photoshop's diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index 7e26df4c162..c331d1c585c 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -1164,6 +1164,7 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; let unsupported = Mapped::Unsupported { harmless: false }; match (ref_class(r).as_deref(), target(r)) { + (Some("Path"), Target::Property(p)) if p == "WrPt" => super::steps_path::from(d), (Some("Chnl"), Target::Property(p)) if p == "fsel" => selection_from(d), // Quick Mask mode on (`set` of the document's quick-mask property); `clearEvent` turns it off. (Some("Prpr"), Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": true})), @@ -1270,6 +1271,7 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option { let fsel = D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }]); let sel = |t: D| desc("null").with("null", fsel.clone()).with("T ", t); Some(match id { + "path.set" => return super::steps_path::to(p), "select.all" => sel(enm("Ordn", "Al ")), "select.deselect" => sel(enm("Ordn", "None")), "select.reselect" => sel(enm("Ordn", "Prvs")), diff --git a/crates/engine/src/cmd/atn_cmds/steps_path.rs b/crates/engine/src/cmd/atn_cmds/steps_path.rs new file mode 100644 index 00000000000..584f5b6c9d3 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_path.rs @@ -0,0 +1,212 @@ +//! Measured Insert Path: per-component operations, with even-odd subpaths in each component. +use super::descriptors::{PX, class, desc, enm, get_list, get_ref, jstr, px}; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, Id, ReferenceItem, Value, json}; +use photocraft_doc::{FillRule, Knot, Path, PathOp, Subpath}; +use photocraft_geom::Point; + +pub(super) const SOURCE: &str = "_atnInsertPathDescriptor"; +// The native path validator admits at most 4096 knots/subpaths. Larger wire +// records remain complete kept steps rather than secondary geometry allocations. +const MAX_PARTS: usize = 4096; +const MAX_SOURCE_BYTES: usize = 4 * 1024 * 1024; + +#[cfg(test)] +#[path = "path_tests.rs"] +mod tests; + +/// Bound the retained wire form before serializing it into a second allocation. +/// Names use UTF-16; 64 bytes per measured field conservatively covers its fixed +/// key/type/scalar or list header. Nested descriptors charge their own fields. +fn keys(d: &Descriptor, required: &[&str], optional: &[&str], budget: &mut usize) -> bool { + let size = d.name.0.len().checked_mul(2).and_then(|names| d.items.len().checked_mul(64)?.checked_add(names)?.checked_add(32)); + let Some(remaining) = size.and_then(|size| budget.checked_sub(size)) else { return false }; + *budget = remaining; + d.name.0.len() <= 4096 + && d.items.iter().all(|(k, _)| required.iter().chain(optional).any(|key| k.is(key))) + && required.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() == 1) + && optional.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() <= 1) +} + +/// Require the recorded object class before interpreting its fields as geometry. +fn object<'a>(value: &'a D, class_id: &str) -> Option<&'a Descriptor> { + match value { + D::Descriptor(d) if d.class_id.is(class_id) => Some(d), + _ => None, + } +} + +/// Read a pixel point within the native coordinate range and charge its fields to the wire budget. +fn point(value: &D, budget: &mut usize) -> Option { + let d = object(value, "Pnt ")?; + if !keys(d, &["Hrzn", "Vrtc"], &[], budget) { + return None; + } + let coordinate = |key| match d.get(key)? { + D::UnitFloat { unit, value } if *unit == PX && value.is_finite() && value.abs() <= f64::from(f32::MAX) => Some(*value), + _ => None, + }; + Some(Point::new(coordinate("Hrzn")?, coordinate("Vrtc")?)) +} + +/// A corner stores only its anchor. A cubic knot must supply both handles and its smooth flag. +fn knot(value: &D, budget: &mut usize) -> Option { + let d = object(value, "Pthp")?; + if !keys(d, &["Anch"], &["Fwd ", "Bwd ", "Smoo"], budget) { + return None; + } + let anchor = point(d.get("Anch")?, budget)?; + match (d.get("Fwd "), d.get("Bwd "), d.get("Smoo")) { + (None, None, None) => Some(Knot::corner(anchor.x, anchor.y)), + (Some(out), Some(input), Some(D::Boolean(smooth))) => { + Some(Knot { anchor, out_ctrl: point(out, budget)?, in_ctrl: point(input, budget)?, smooth: *smooth }) + } + _ => None, + } +} + +/// Decode a Work Path property assignment, preserving the grouping and operation of each component. +fn path_from(d: &Descriptor) -> Option { + let mut budget = MAX_SOURCE_BYTES; + if !(d.class_id.is("null") || d.class_id.is("setd")) || !keys(d, &["null", "T "], &[], &mut budget) { + return None; + } + let Some([ReferenceItem::Property { class, key }]) = get_ref(d, "null") else { return None }; + if !class.class_id.is("Path") || class.name.0.len() > 4096 || !key.is("WrPt") { + return None; + } + budget = budget.checked_sub(class.name.0.len().checked_mul(2)?.checked_add(64)?)?; + let components = get_list(d, "T ")?; + if components.is_empty() || components.len() > MAX_PARTS { + return None; + } + let mut path = Path { fill_rule: FillRule::EvenOdd, ..Default::default() }; + let mut count = 0usize; + for value in components { + let component = object(value, "PaCm")?; + if !keys(component, &["shapeOperation", "SbpL"], &[], &mut budget) { + return None; + } + let op = match component.get("shapeOperation")? { + D::Enumerated { type_id, value } if type_id.is("shapeOperation") => { + if value.is("Add ") { + PathOp::Combine + } else if value.is("Sbtr") { + PathOp::Subtract + } else if value.is("xor") { + PathOp::Exclude + } else { + return None; + } + } + _ => return None, + }; + if path.subpaths.is_empty() && op != PathOp::Combine { + return None; + } + let subpaths = get_list(component, "SbpL")?; + if subpaths.is_empty() || path.subpaths.len().checked_add(subpaths.len())? > MAX_PARTS { + return None; + } + for (i, value) in subpaths.iter().enumerate() { + let sub = object(value, "Sbpl")?; + if !keys(sub, &["Clsp", "Pts "], &[], &mut budget) { + return None; + } + let D::Boolean(closed) = sub.get("Clsp")? else { return None }; + let points = get_list(sub, "Pts ")?; + count = count.checked_add(points.len())?; + if count > MAX_PARTS { + return None; + } + let knots = points.iter().map(|value| knot(value, &mut budget)).collect::>>()?; + path.subpaths.push(Subpath { closed: *closed, knots, op: if i == 0 { op } else { PathOp::Join } }); + } + } + photocraft_vector::flatten::validate_path(&path).ok()?; + Some(path) +} + +/// Decode only the measured Work Path form; unsupported data stays a complete kept step. +pub(super) fn from(d: &Descriptor) -> Mapped { + let Some(path) = path_from(d) else { return Mapped::Unsupported { harmless: false } }; + let raw = d.to_bytes(); + if raw.len() > MAX_SOURCE_BYTES { + return Mapped::Unsupported { harmless: false }; + } + step("path.set", json!({"name":"work", "path":crate::vector_cmds::path_json(&path), SOURCE:photocraft_paint::tile::b64_encode(&raw)})) +} + +/// Write a point using pixel units for both coordinates. +fn point_to(p: Point) -> D { + D::Descriptor(desc("Pnt ").with("Hrzn", px(p.x)).with("Vrtc", px(p.y))) +} + +/// Emit the compact corner form only when the handles coincide and the knot has no smooth flag. +fn knot_to(k: &Knot) -> D { + let mut d = desc("Pthp").with("Anch", point_to(k.anchor)); + if k.in_ctrl != k.anchor || k.out_ctrl != k.anchor || k.smooth { + d = d.with("Fwd ", point_to(k.out_ctrl)).with("Bwd ", point_to(k.in_ctrl)).with("Smoo", D::Boolean(k.smooth)); + } + D::Descriptor(d) +} + +/// Export representable native geometry or an unchanged validated source descriptor. +/// Nonzero winding, inversion and unmeasured operations cannot silently become even-odd. +pub(super) fn to(p: &Value) -> Option { + if jstr(p, "name") != Some("work") || p.as_object()?.keys().any(|k| !matches!(k.as_str(), "name" | "path" | SOURCE)) { + return None; + } + let value = p.get("path")?; + let subpaths = value.get("subpaths")?.as_array()?; + if subpaths.is_empty() + || subpaths.len() > MAX_PARTS + || subpaths.iter().try_fold(0usize, |n, s| n.checked_add(s.get("knots")?.as_array()?.len()))? > MAX_PARTS + { + return None; + } + let path = crate::vector_cmds::parse_path(value).ok()?; + photocraft_vector::flatten::validate_path(&path).ok()?; + let mut canonical = crate::vector_cmds::path_json(&path); + let mut supplied = value.clone(); + super::convert::normalise(&mut canonical); + super::convert::normalise(&mut supplied); + if path.inverted || path.fill_rule != FillRule::EvenOdd || canonical != supplied { + return None; + } + if let Some(source) = p.get(SOURCE) { + let text = source.as_str()?; + if text.len() > MAX_SOURCE_BYTES.div_ceil(3) * 4 { + return None; + } + let raw = photocraft_paint::tile::b64_decode(text)?; + if raw.len() > MAX_SOURCE_BYTES { + return None; + } + let original = Descriptor::from_bytes(&raw).ok()?; + return (path_from(&original)? == path).then_some(original); + } + let mut components = Vec::new(); + for range in path.components() { + let first = path.subpaths.get(range.start)?; + let op = match first.op { + PathOp::Combine => "Add ", + PathOp::Subtract => "Sbtr", + PathOp::Exclude => "xor", + _ => return None, + }; + if components.is_empty() && first.op != PathOp::Combine { + return None; + } + let subs = path + .subpaths + .get(range)? + .iter() + .map(|s| D::Descriptor(desc("Sbpl").with("Clsp", D::Boolean(s.closed)).with("Pts ", D::List(s.knots.iter().map(knot_to).collect())))) + .collect(); + components.push(D::Descriptor(desc("PaCm").with("shapeOperation", enm("shapeOperation", op)).with("SbpL", D::List(subs)))); + } + Some( + desc("setd").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Path"), key: Id::new("WrPt") }])).with("T ", D::List(components)), + ) +} diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 3ea13e0243e..27b6f6500be 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -741,6 +741,7 @@ fn every_mapped_command_exports_and_imports_back() { ("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})), ("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})), ("filter.blur.blur", json!({})), + ("file.automate.createDroplet", json!({"path": "/d/Sharpen.pcdroplet", "action": "Sharpen", "set": "Tools", "output": "/d/out", "overrideOpen": true})), ("filter.distort.shear", json!({"points": [[0,0], [0.5,0.25], [1,0]], "undefinedAreas": "repeat"})), ("filter.distort.pinch", json!({"amount": -30})), ("filter.distort.polarCoordinates", json!({"mode": "polarToRectangular"})), @@ -834,6 +835,13 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})), ("type.convertToShape", json!({})), ("type.createWorkPath", json!({})), + ( + "path.set", + json!({"name":"work","path":{"fillRule":"evenodd","inverted":false,"subpaths":[{"closed":false,"op":"combine","knots":[ + {"anchor":[1,2],"in":[1,2],"out":[3,4],"smooth":false}, + {"anchor":[8,9],"in":[6,7],"out":[8,9],"smooth":false} + ]}]}}), + ), ("filter.blur.shapeBlur", json!({})), ("filter.blur.shapeBlur", json!({"radius": 10})), ("layer.new.group", json!({"name": "G"})), @@ -1114,6 +1122,10 @@ fn every_mapped_command_exports_and_imports_back() { // Executable values match; independent public tests verify the source carrier. back.as_object_mut().unwrap().remove("__atnClassicGradient"); } + if *id == "path.set" { + // The imported step retains the original descriptor alongside its playable path. + back.as_object_mut().unwrap().remove(super::steps_path::SOURCE).unwrap(); + } if *id == "edit.perspectiveWarp" { back.as_object_mut().unwrap().remove("__atnPerspectiveWarp"); } @@ -1407,6 +1419,7 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("GdMp")))))), ), item("GsnB", "Gaussian Blur", None), + item("GsnB", "Gaussian Blur", Some(desc("null"))), // Duplicate into another open document: the `T` reference means nothing at // replay, so the step is kept verbatim instead of misplayed as a same-doc copy. item( @@ -1428,8 +1441,11 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { }; let imp = import(&atn::write(&file).unwrap()).unwrap(); let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect(); - assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]); - assert_eq!(imp.unsupported.len(), 5); + assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, MENU_ITEM, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]); + assert_eq!(imp.actions[0].steps[4].1["id"], "filter.blur.gaussianBlur"); + assert_eq!(imp.actions[0].steps[4].1["eventKind"], "code"); + assert_eq!(imp.actions[0].steps[5].1, json!({})); + assert_eq!(imp.unsupported.iter().map(|step| step.step).collect::>(), [0, 1, 2, 3, 6]); assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); } diff --git a/crates/engine/src/cmd/brush_cmds.rs b/crates/engine/src/cmd/brush_cmds.rs index e09ff2098ca..87a4bcdd41e 100644 --- a/crates/engine/src/cmd/brush_cmds.rs +++ b/crates/engine/src/cmd/brush_cmds.rs @@ -832,6 +832,21 @@ fn mixer_load_pixel(s: &mut Session, p: &Value) -> Result { /// channel or Quick Mask `target` is refused rather than recolouring the layer instead. fn color_replacement_setup(s: &Session, p: &Value) -> Result<(Stroke, ReplaceSettings, photocraft_doc::LayerId)> { let cmd = "paint.colorReplacement"; + // The modes and depths the reference application refuses, with its words (#11923). The live + // stroke runs this setup too, so it previews nothing in them. 32-bit Grayscale, CMYK and Lab, + // Multichannel and Duotone weren't measured and keep painting. + if let Some(st) = s.active() { + use photocraft_color::{ColorMode, SampleType}; + let why = match (st.doc.mode, st.doc.depth) { + (ColorMode::Indexed, _) => Some("it does not work with Indexed Color images (convert image to RGB Color mode to edit)."), + (ColorMode::Bitmap, _) | (ColorMode::Grayscale, SampleType::U8 | SampleType::U16) => Some("it only works in full color modes."), + (ColorMode::Rgb, SampleType::F32) => Some("it does not work with 32 bit per channel images (convert image to 8 or 16 bit per channel to edit)."), + _ => None, + }; + if let Some(why) = why { + return Err(EngineError::Other(format!("Could not use the Color Replacement tool because {why}"))); + } + } if let Some(t) = p.get("target").filter(|t| t.as_str() != Some("pixels")) && crate::color_channels::explicit(p, s.active().map_or(0, |st| crate::channel_cmds::color_count(&st.doc)))?.is_none() { @@ -855,9 +870,14 @@ fn color_replacement_setup(s: &Session, p: &Value) -> Result<(Stroke, ReplaceSet Ok((Stroke { brush, points: pts }, rs, id)) } +/// The History state (and recorded step) name of a Color Replacement edit. +pub const COLOR_REPLACEMENT_STEP: &str = "Color Replacement Tool"; + fn color_replacement(s: &mut Session, p: &Value) -> Result { let (stroke, rs, id) = color_replacement_setup(s, p)?; - let dmg = s.edit("Color Replacement", |doc, _| { + // The reference application names every click, stroke and Shift+click segment "Color + // Replacement Tool" (#11933); the command's label is the same, so recorded actions match. + let dmg = s.edit(COLOR_REPLACEMENT_STEP, |doc, _| { let sel = doc.selection.clone(); let lock = doc.effective_locks(id).transparency; let surf = paint_surface(doc, id, &json!({}))?; @@ -1105,6 +1125,8 @@ pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result { } b = merge_brush(&b, &patch, cmd)?; validate_brush(&b, cmd)?; + // The painting tools' Opacity takes 1-100 %, as in Photoshop: a 0 % brush paints nothing (#11812). + b.opacity = b.opacity.max(0.01); // Loading a stored tip can fail. Finish preparing the brush before changing its identity or // foreground colour, so a failed pick leaves the entire current brush state intact. s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?; @@ -1170,8 +1192,8 @@ pub fn remembered_settings(b: &BrushSettings) -> Option { /// A brush remembered by [`remembered_settings`], read back at launch: `None` when the value is /// not a brush or fails the checks `tools.setBrush` makes (a size or scatter out of range). The -/// options-bar values are clamped to the ranges their controls allow (size 1..max px; hardness, -/// opacity, flow and smoothing 0..100 %), and stored tips load when the store is attached (else +/// options-bar values are clamped to the ranges their controls allow (size 1..max px; opacity +/// 1..100 %; hardness, flow and smoothing 0..100 %), and stored tips load when the store is attached (else /// they paint with their previews until `Session::attach_preset_store`). pub fn restored_settings(s: &Session, v: &Value) -> Option { if !v.is_object() { @@ -1183,6 +1205,9 @@ pub fn restored_settings(s: &Session, v: &Value) -> Option { for f in [&mut b.hardness, &mut b.opacity, &mut b.flow, &mut b.smoothing.amount] { *f = if f.is_nan() { 1.0 } else { f.clamp(0.0, 1.0) }; } + // Opacity takes 1-100 %, as `tools.setBrush` floors it: a brush remembered at 0 % (saved + // before that floor) would show 1 % but paint nothing (#11812). + b.opacity = b.opacity.max(0.01); let _ = s.load_brush_tips(&mut b); Some(b) } @@ -1335,7 +1360,7 @@ pub fn specs() -> Vec { ), spec!( "paint.colorReplacement", - "Color Replacement", + "Color Replacement Tool", r##"{"points":[…],"brush":{…}?,"size":px?,"mode":"hue|saturation|color|luminosity"="color","sampling":"continuous|once|backgroundSwatch"="continuous","limits":"contiguous|discontiguous|findEdges"="contiguous","tolerance":0..100=30,"antiAlias":bool=true,"color":"#rrggbb"?=foreground,"target":"pixels"="pixels","seed":u64?}"##, crate::commands::has_paintable, color_replacement, @@ -1365,7 +1390,7 @@ pub fn specs() -> Vec { spec!( "tools.setBrush", "Set Brush", - r##"{"preset":name?,"reset":bool?,…BrushSettings fields (camelCase, deep-merged; pose.overrideTiltX/Y:bool; pose.overrideTilt:legacy combined alias)}"##, + r##"{"preset":name?,"reset":bool?,…BrushSettings fields (camelCase, deep-merged; pose.overrideTiltX/Y:bool; pose.overrideTilt:legacy combined alias; opacity floors at 0.01)}"##, always, set_brush, true @@ -1376,5 +1401,13 @@ pub fn specs() -> Vec { #[cfg(test)] mod tests; +#[cfg(test)] +#[path = "color_replacement_modes_tests.rs"] +mod color_replacement_modes_tests; + #[cfg(test)] mod mixer_live_tests; + +#[cfg(test)] +#[path = "color_replacement_history_name_tests.rs"] +mod color_replacement_history_name_tests; diff --git a/crates/engine/src/cmd/brush_cmds/mixer_live.rs b/crates/engine/src/cmd/brush_cmds/mixer_live.rs index bfe409c471c..f0abb81995f 100644 --- a/crates/engine/src/cmd/brush_cmds/mixer_live.rs +++ b/crates/engine/src/cmd/brush_cmds/mixer_live.rs @@ -151,6 +151,7 @@ impl LiveMixer { /// Validate the params and capture the document and paint load, without rendering a dab. fn prepare(s: &Session, p: &Value, preview_limits: bool) -> Result<(Self, Vec)> { + mode_refusal(s)?; let captured = crate::channel_cmds::inject_target(s, CMD, p.clone()); let p = &captured; has_paintable(s).map_err(EngineError::Other)?; @@ -330,9 +331,26 @@ impl LiveMixer { } } +/// The reference's alert for a Mixer Brush press in an Indexed Color document (#11958). +pub const INDEXED_REFUSAL: &str = + "Could not use the Mixer Brush tool because it does not work with Indexed Color images (convert image to RGB Color mode to edit)."; +/// The reference's alert for a Mixer Brush press in a Bitmap document (#11958). +pub const BITMAP_REFUSAL: &str = "Could not use the Mixer Brush tool because it does not work with Bitmap images (convert image to Grayscale mode to edit)."; + +/// The Mixer Brush refuses Indexed Color and Bitmap documents before anything is painted, with the +/// reference's alert text; other modes (Duotone and Multichannel were not measured) paint as before. +fn mode_refusal(s: &Session) -> Result<()> { + match s.active().map(|st| st.doc.mode) { + Some(photocraft_color::ColorMode::Indexed) => Err(EngineError::Other(INDEXED_REFUSAL.into())), + Some(photocraft_color::ColorMode::Bitmap) => Err(EngineError::Other(BITMAP_REFUSAL.into())), + _ => Ok(()), + } +} + /// Consume a matching prepared result or replay without the preview tile cap, then commit one history step. /// The completed stroke also applies the automatic clean/load settings to the stored paint load. pub(super) fn run(s: &mut Session, p: &Value) -> Result { + mode_refusal(s)?; let points = parse_points(p, CMD)?; let cached = s.live_commit.take().filter(|live| live.matches(s, CMD, p, &points) && live.mixer.as_ref().is_some_and(|(before, _)| *before == s.tools.mixer)); diff --git a/crates/engine/src/cmd/brush_cmds/tests.rs b/crates/engine/src/cmd/brush_cmds/tests.rs index f72a0e207be..3abf9328236 100644 --- a/crates/engine/src/cmd/brush_cmds/tests.rs +++ b/crates/engine/src/cmd/brush_cmds/tests.rs @@ -1380,6 +1380,22 @@ fn restored_brush_settings_are_clamped_or_rejected() { } } +/// #11812: a brush remembered at 0 % opacity (saved before the 1 % floor) reads back at 1 %, the +/// value its options-bar field shows, so it paints instead of laying down nothing. +#[test] +fn restored_brush_opacity_floors_at_one_percent() { + let s = Session::new(); + for opacity in [0.0, -1.0, 0.004] { + let b = restored_settings(&s, &json!({"opacity": opacity})).unwrap(); + assert_eq!(b.opacity, 0.01, "{opacity}"); + } + assert_eq!(restored_settings(&s, &json!({"opacity": 0.37})).unwrap().opacity, 0.37, "values above the floor are kept"); + let mut t = Session::new(); + t.prefs.edit(|p| p.last_brush = json!({"brush": {"opacity": 0.0}})); + restore_brush(&mut t); + assert_eq!(t.tools.brush.opacity, 0.01, "the remembered session brush"); +} + #[test] fn remembered_brushes_never_embed_large_bitmaps() { use std::sync::Arc; diff --git a/crates/engine/src/cmd/color_cmds.rs b/crates/engine/src/cmd/color_cmds.rs index c7c7942d0bf..9fd1762c45d 100644 --- a/crates/engine/src/cmd/color_cmds.rs +++ b/crates/engine/src/cmd/color_cmds.rs @@ -899,7 +899,9 @@ fn ink_surface(s: &Surface, d: &Duotone) -> Surface { } /// Image › Mode › RGB/Grayscale/CMYK/Lab through the CMS. Params: `profile` (destination, -/// default the mode's working profile), `intent` (default relative), `bpc` (default true). +/// default the mode's working profile; Indexed→RGB without an explicit profile instead expands +/// the already-RGB storage without changing its encoded samples or ICC bytes), `intent` +/// (default relative), `bpc` (default true). pub fn convert_mode(s: &mut Session, mode: ColorMode, p: &Value) -> Result { let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; if doc.mode == ColorMode::Multichannel && mode != ColorMode::Multichannel { @@ -908,8 +910,13 @@ pub fn convert_mode(s: &mut Session, mode: ColorMode, p: &Value) -> Result s.color.resolve(spec, Some(doc), Some(mode))?, + None if preserve_indexed_rgb => document_profile(doc), None => s.color.working(mode), }; if space_mode(dst.color_space) @@ -923,7 +930,16 @@ pub fn convert_mode(s: &mut Session, mode: ColorMode, p: &Value) -> Result Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#0000ff"})).unwrap(); + s +} + +fn last(s: &Session) -> (usize, Option) { + let h = &s.active().unwrap().history; + (h.past_len(), h.entries().last().map(|e| e.to_string())) +} + +#[test] +fn every_click_stroke_and_segment_is_one_color_replacement_tool_state() { + for on_background in [false, true] { + let mut s = session(); + if !on_background { + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#dc1e1e"})).unwrap(); + } else { + s.execute("edit.fill", json!({"color": "#dc1e1e"})).unwrap(); + s.execute("layer.lockLayers", json!({"transparency": true})).unwrap(); + } + for points in [json!([[20, 20]]), json!([[20, 50], [80, 50]]), json!([[80, 50], [80, 80]])] { + let (n, _) = last(&s); + s.execute("paint.colorReplacement", json!({"points": points, "size": 15})).unwrap(); + assert_eq!(last(&s), (n + 1, Some(COLOR_REPLACEMENT_STEP.to_string())), "{points} (background {on_background})"); + } + } + assert_eq!(COLOR_REPLACEMENT_STEP, "Color Replacement Tool"); +} + +#[test] +fn recorded_steps_use_the_same_name() { + let spec = crate::commands::find("paint.colorReplacement").expect("the command"); + assert_eq!(spec.label, COLOR_REPLACEMENT_STEP); +} diff --git a/crates/engine/src/cmd/color_replacement_modes_tests.rs b/crates/engine/src/cmd/color_replacement_modes_tests.rs new file mode 100644 index 00000000000..b9c454fb338 --- /dev/null +++ b/crates/engine/src/cmd/color_replacement_modes_tests.rs @@ -0,0 +1,71 @@ +//! #11923: the Color Replacement tool refuses RGB/32, Grayscale, Bitmap and Indexed documents with +//! the reference application's messages, painting nothing and adding no history; the live stroke +//! is refused the same way. RGB/8, RGB/16, CMYK and Lab paint. + +use photocraft_color::{Color, ColorMode, SampleType}; +use photocraft_doc::{Document, Size}; +use serde_json::json; + +use super::*; + +const DEPTH_32: &str = + "Could not use the Color Replacement tool because it does not work with 32 bit per channel images (convert image to 8 or 16 bit per channel to edit)."; +const NOT_FULL_COLOR: &str = "Could not use the Color Replacement tool because it only works in full color modes."; +const INDEXED: &str = "Could not use the Color Replacement tool because it does not work with Indexed Color images (convert image to RGB Color mode to edit)."; + +/// A session with one 64 × 64 document of `mode` and `depth`, filled red. +fn session(mode: ColorMode, depth: SampleType) -> Session { + let mut s = Session::new(); + s.add_document(Document::with_background("t", Size::new(64, 64), mode, depth, Color::rgb(220.0 / 255.0, 30.0 / 255.0, 30.0 / 255.0)), None); + s.tools.foreground = [0.0, 0.0, 1.0, 1.0]; + s +} + +fn stroke() -> Value { + json!({"points": [[20, 32], [44, 32]], "size": 20, "hardness": 100}) +} + +fn steps(s: &Session) -> usize { + s.active().unwrap().history.past_len() +} + +fn pixels(s: &Session) -> Vec { + let st = s.active().unwrap(); + let surf = st.doc.layers[0].surface().unwrap(); + (0..64).flat_map(|y| (0..64).flat_map(move |x| surf.pixel(x, y))).collect() +} + +#[test] +fn refused_modes_say_why_and_paint_nothing() { + for (mode, depth, want) in [ + (ColorMode::Rgb, SampleType::F32, DEPTH_32), + (ColorMode::Grayscale, SampleType::U8, NOT_FULL_COLOR), + (ColorMode::Grayscale, SampleType::U16, NOT_FULL_COLOR), + (ColorMode::Bitmap, SampleType::U8, NOT_FULL_COLOR), + (ColorMode::Indexed, SampleType::U8, INDEXED), + ] { + let mut s = session(mode, depth); + let (before, n) = (pixels(&s), steps(&s)); + let err = s.execute("paint.colorReplacement", stroke()).unwrap_err().to_string(); + assert_eq!(err, want, "{mode:?}/{depth:?}"); + assert_eq!(pixels(&s), before, "{mode:?}/{depth:?}: nothing painted"); + assert_eq!(steps(&s), n, "{mode:?}/{depth:?}: no history"); + // The live stroke previews nothing either. + let live = live_color_replacement(&s, &stroke()).err().map(|e| e.to_string()); + assert_eq!(live.as_deref(), Some(want), "{mode:?}/{depth:?}: live"); + } +} + +#[test] +fn full_colour_documents_still_paint() { + for (mode, depth) in + [(ColorMode::Rgb, SampleType::U8), (ColorMode::Rgb, SampleType::U16), (ColorMode::Cmyk, SampleType::U8), (ColorMode::Lab, SampleType::U8)] + { + let mut s = session(mode, depth); + let (before, n) = (pixels(&s), steps(&s)); + s.execute("paint.colorReplacement", stroke()).unwrap_or_else(|e| panic!("{mode:?}/{depth:?}: {e}")); + assert_ne!(pixels(&s), before, "{mode:?}/{depth:?}: painted"); + assert_eq!(steps(&s), n + 1, "{mode:?}/{depth:?}"); + assert!(live_color_replacement(&s, &stroke()).is_ok(), "{mode:?}/{depth:?}: live"); + } +} diff --git a/crates/engine/src/cmd/count_history_names_tests.rs b/crates/engine/src/cmd/count_history_names_tests.rs new file mode 100644 index 00000000000..2cfa357ab4b --- /dev/null +++ b/crates/engine/src/cmd/count_history_names_tests.rs @@ -0,0 +1,100 @@ +//! #11763: each Count tool edit gets the reference application's History name. A `count.setGroup` +//! that changes exactly one field takes that field's name; several fields at once, or a switch of +//! the current group, keep "Count Group Options". + +use serde_json::{Value, json}; + +use crate::Session; + +/// A 100 × 100 document with one count group holding one marker at 10,10 (its first state is +/// "Add Count Object"). +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("count.add", json!({"x": 10, "y": 10})).unwrap(); + s +} + +fn steps(s: &Session) -> usize { + s.active().unwrap().history.past_len() +} + +/// Run `id` and return the History name of the one step it added. +fn named(s: &mut Session, id: &str, p: Value) -> String { + let before = steps(s); + s.execute(id, p.clone()).unwrap_or_else(|e| panic!("{id} {p}: {e}")); + assert_eq!(steps(s), before + 1, "{id} {p} adds exactly one step"); + s.active().unwrap().history.undo_label().unwrap_or_default().to_string() +} + +#[test] +fn marker_edits_are_add_drag_and_delete_count_object() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + // The first click makes "Count Group 1" too, still one "Add Count Object" step. + assert_eq!(named(&mut s, "count.add", json!({"x": 10, "y": 10})), "Add Count Object"); + assert_eq!(named(&mut s, "count.add", json!({"x": 40, "y": 40})), "Add Count Object"); + assert_eq!(named(&mut s, "count.move", json!({"x": 10, "y": 10, "to": [20, 20]})), "Drag Count Object"); + assert_eq!(named(&mut s, "count.remove", json!({"x": 40, "y": 40})), "Delete Count Object"); +} + +#[test] +fn group_edits_keep_or_take_their_names() { + let mut s = session(); + assert_eq!(named(&mut s, "count.newGroup", json!({"name": "Leaves"})), "Add Count Group"); + assert_eq!(named(&mut s, "count.clear", json!({})), "Clear Count"); + assert_eq!(named(&mut s, "count.deleteGroup", json!({})), "Delete Count Group"); +} + +#[test] +fn a_single_field_change_uses_that_fields_name() { + for (p, want) in [ + (json!({"name": "Seeds"}), "Set Count Group Name"), + (json!({"visible": false}), "Set Count Group Visibility"), + (json!({"color": "#204080"}), "Set Count Draw Color"), + (json!({"markerSize": 5}), "Set Count Marker Size"), + (json!({"labelSize": 20}), "Set Count Font Size"), + ] { + let mut s = session(); + assert_eq!(named(&mut s, "count.setGroup", p.clone()), want, "{p}"); + } +} + +#[test] +fn several_fields_at_once_keep_count_group_options() { + let mut s = session(); + assert_eq!(named(&mut s, "count.setGroup", json!({"markerSize": 5, "labelSize": 20})), "Count Group Options"); + // A field sent with its current value doesn't count as a change. + let mut s = session(); + let name = s.execute("image.analysis.countTool", json!({})).unwrap()["groups"][0]["name"].clone(); + assert_eq!(named(&mut s, "count.setGroup", json!({"name": name, "markerSize": 6})), "Set Count Marker Size"); +} + +#[test] +fn switching_the_current_group_keeps_todays_name() { + let mut s = session(); + s.execute("count.newGroup", json!({"name": "Two"})).unwrap(); + assert_eq!(named(&mut s, "count.setGroup", json!({"group": 0})), "Count Group Options", "only `group`"); + assert_eq!(named(&mut s, "count.setGroup", json!({"group": 1, "active": true})), "Count Group Options", "`active: true`, no field change"); + // A field change together with a switch is two changes: today's name too. + assert_eq!(named(&mut s, "count.setGroup", json!({"group": 0, "active": true, "visible": false})), "Count Group Options"); +} + +#[test] +fn no_change_adds_no_step_and_bad_params_stay_errors() { + let mut s = session(); + let before = steps(&s); + // The current group's own values: nothing changes, no step. + s.execute("count.setGroup", json!({"group": 0})).unwrap(); + assert_eq!(steps(&s), before); + for (id, p) in [ + ("count.setGroup", json!({"group": 9, "name": "x"})), + ("count.setGroup", json!({"markerSize": 2.5})), + ("count.move", json!({"x": 90, "y": 90, "to": [1, 1]})), + ("count.remove", json!({"x": 90, "y": 90})), + ("count.add", json!({"x": 1, "y": 1, "group": 7})), + ] { + assert!(s.execute(id, p.clone()).is_err(), "{id} {p}"); + assert_eq!(steps(&s), before, "{id} {p} adds no step"); + } +} diff --git a/crates/engine/src/cmd/distort_cmds.rs b/crates/engine/src/cmd/distort_cmds.rs index 7d820f06205..6eb7949c573 100644 --- a/crates/engine/src/cmd/distort_cmds.rs +++ b/crates/engine/src/cmd/distort_cmds.rs @@ -14,7 +14,7 @@ use std::cell::RefCell; use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, apply_liquify, auto_cell}; use photocraft_algo::perspective::{PerspectiveMap, Plane, Straighten, straighten}; -use photocraft_algo::puppet::{PuppetDensity, PuppetMode, PuppetPin, PuppetWarp, puppet_warp, puppet_warp_with_mask}; +use photocraft_algo::puppet::{PuppetDensity, PuppetMesh, PuppetMode, PuppetPin, PuppetWarp, puppet_warp, puppet_warp_with_mask, warp_with_mesh}; use photocraft_algo::transform::Interp; use photocraft_algo::warp::{warp_mesh_gray, warp_mesh_surface}; use photocraft_color::PixelFormat; @@ -86,6 +86,51 @@ pub fn puppet_params(cmd: &str, p: &Value) -> Result { Ok(PuppetWarp { pins, mode, density, expansion }) } +/// The most vertices a stored Puppet Warp mesh may have (Photoshop's meshes have hundreds); the +/// PSD importer reads no more. +const MAX_STORED_VERTS: usize = 1 << 16; + +/// A Puppet Warp's stored mesh, as a Photoshop file keeps it (`mesh`: `{"pins", "verts", +/// "deformed", "tris"}`, document pixels): the mesh solved for `pins`, drawn as it is instead of +/// solving again. `None` when there is none, it is malformed, or the filter was edited since: its +/// pins are no longer the ones it was solved for, or a solver setting is set (imported params +/// set none). +pub fn stored_puppet_mesh(p: &Value) -> Option<(PuppetMesh, Vec<[f64; 2]>)> { + let m = p.get("mesh")?; + if m.get("pins") != p.get("pins") || ["mode", "density", "expansion"].iter().any(|k| p.get(*k).is_some()) { + return None; + } + let points = |k: &str| -> Option> { + let v = m.get(k)?.as_array()?; + if v.len() > MAX_STORED_VERTS { + return None; + } + v.iter().map(|q| serde_json::from_value::<[f64; 2]>(q.clone()).ok()).collect() + }; + let (verts, deformed) = (points("verts")?, points("deformed")?); + let tris = m.get("tris")?.as_array()?; + if tris.len() > 2 * MAX_STORED_VERTS { + return None; + } + let tris: Vec<[usize; 3]> = tris.iter().map(|t| serde_json::from_value(t.clone()).ok()).collect::>()?; + Some((PuppetMesh { verts, tris, spacing: 0.0 }, deformed)) +} + +/// `params` of smart filter `command` on `sm`, without a stored Puppet Warp mesh made for another +/// placement than `sm`'s (`mesh.placement`, recorded on import): a mesh in document pixels doesn't +/// fit a smart object moved, transformed or warped since, so its pins are solved instead. +pub fn placed_puppet_params<'a>(command: &str, params: &'a Value, sm: &photocraft_doc::SmartObject) -> std::borrow::Cow<'a, Value> { + let stale = command == PUPPET_SMART && params.get("mesh").is_some_and(|m| m.get("placement") != Some(&sm.placement_json())); + if !stale { + return std::borrow::Cow::Borrowed(params); + } + let mut p = params.clone(); + if let Some(o) = p.as_object_mut() { + o.remove("mesh"); + } + std::borrow::Cow::Owned(p) +} + /// Perspective Warp params (planes, after the optional straighten). pub fn perspective_params(cmd: &str, p: &Value) -> Result> { let mut planes: Vec = match p.get("planes") { @@ -182,7 +227,17 @@ pub fn apply_to_surface(id: &str, params: &Value, surf: &Surface, canvas: Rect) Some(liquify_surface(surf, &strokes, cell, canvas)) } // A stored warp too far to render leaves the smart object as it is (#1019). - PUPPET | PUPPET_SMART => Some(puppet_surface(surf, &puppet_params(id, params).ok()?, interp(params)).unwrap_or_else(|| surf.clone())), + PUPPET | PUPPET_SMART => { + // A mesh stored with the filter (from a Photoshop file) is drawn as it is; one that + // can't be drawn leaves the pins to solve. + let b = surf.content_bounds(); + if let Some((mesh, deformed)) = stored_puppet_mesh(params).filter(|_| !b.is_empty()) + && let Some(out) = warp_with_mesh(surf, b, &mesh, &deformed, interp(params)) + { + return Some(out); + } + Some(puppet_surface(surf, &puppet_params(id, params).ok()?, interp(params)).unwrap_or_else(|| surf.clone())) + } PERSPECTIVE | PERSPECTIVE_SMART => Some(perspective_surface(surf, &perspective_params(id, params).ok()?, interp(params))), _ => None, } diff --git a/crates/engine/src/cmd/distort_cmds/tests.rs b/crates/engine/src/cmd/distort_cmds/tests.rs index 81d17c28c2c..851b7858146 100644 --- a/crates/engine/src/cmd/distort_cmds/tests.rs +++ b/crates/engine/src/cmd/distort_cmds/tests.rs @@ -473,3 +473,71 @@ fn unlocked_or_position_locked_smart_objects_take_distortions() { } } } + +#[test] +fn a_stored_puppet_mesh_is_drawn_while_its_pins_hold() { + // A Photoshop file's Puppet Warp carries its solved mesh: it is drawn as it is (here a whole + // move by (2, 1), although one pin alone would move the block by (6, 4)), until the pins + // change; then the pins are solved. + let surf = active_surface(&session(8)); + let canvas = Rect::new(0, 0, 80, 60); + let pins = json!([{"src": [30, 25], "dst": [36, 29]}]); + let verts = [[8.0, 8.0], [62.0, 8.0], [62.0, 42.0], [8.0, 42.0]]; + let moved: Vec<[f64; 2]> = verts.iter().map(|v| [v[0] + 2.0, v[1] + 1.0]).collect(); + let mesh = json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 2], [0, 2, 3]]}); + let shifted = |dx, dy| photocraft_algo::resample::translate_surface(&surf, dx, dy); + let p = json!({"pins": pins, "interpolation": "bilinear", "mesh": mesh}); + let out = apply_to_surface(PUPPET_SMART, &p, &surf, canvas).unwrap(); + assert_eq!(out.content_bounds(), Rect::new(12, 11, 62, 41)); + assert!(worst(&out, &shifted(2, 1), Rect::new(12, 11, 62, 41)) <= 1.0 / 255.0); + // Pins edited after import: the stored mesh no longer applies. + let mut edited = p.clone(); + edited["mesh"]["pins"][0]["dst"] = json!([40, 29]); + let out = apply_to_surface(PUPPET_SMART, &edited, &surf, canvas).unwrap(); + assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44)); + // So does a solver setting set after import (imported params set none). + for (k, v) in [("mode", json!("rigid")), ("density", json!("more")), ("expansion", json!(2.0))] { + let mut q = p.clone(); + q[k] = v; + let out = apply_to_surface(PUPPET_SMART, &q, &surf, canvas).unwrap(); + assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44), "{k}"); + } + // A malformed mesh is ignored the same way. + for bad in [ + json!({"pins": pins, "verts": verts, "deformed": [[0, 0]], "tris": [[0, 1, 2]]}), + json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 9]]}), + ] { + let mut q = p.clone(); + q["mesh"] = bad; + let out = apply_to_surface(PUPPET_SMART, &q, &surf, canvas).unwrap(); + assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44)); + } +} + +#[test] +fn a_stored_puppet_mesh_is_drawn_only_on_the_placement_it_was_made_for() { + // The mesh sits in document pixels where the layer was on import (`mesh.placement`); once the + // smart object is moved, transformed or warped, its pins are solved instead. + let surf = active_surface(&session(8)); + let canvas = Rect::new(0, 0, 80, 60); + let pins = json!([{"src": [30, 25], "dst": [36, 29]}]); + let verts = [[8.0, 8.0], [62.0, 8.0], [62.0, 42.0], [8.0, 42.0]]; + let moved: Vec<[f64; 2]> = verts.iter().map(|v| [v[0] + 2.0, v[1] + 1.0]).collect(); + let src = photocraft_doc::SmartSource::Linked { path: "x.png".into() }; + let mut sm = photocraft_doc::SmartObject::new(src, photocraft_geom::Affine::IDENTITY, None); + let mesh = json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 2], [0, 2, 3]], "placement": sm.placement_json()}); + let params = json!({"pins": pins, "interpolation": "bilinear", "mesh": mesh}); + sm.smart_filters.push(SmartFilter { command: PUPPET_SMART.into(), params, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }); + let render = |sm: &photocraft_doc::SmartObject| crate::smart_cmds::apply_smart_filters(&surf, sm, canvas).unwrap().content_bounds(); + assert_eq!(render(&sm), Rect::new(12, 11, 62, 41), "the stored mesh"); + let mut placed = sm.clone(); + placed.transform = photocraft_geom::Affine { m: [1.0, 0.0, 0.0, 1.0, 4.0, 0.0] }; + assert_eq!(render(&placed), Rect::new(16, 14, 66, 44), "moved: solved"); + let mut warped = sm.clone(); + warped.warp = Some(photocraft_geom::warp::Warp::preset(photocraft_geom::warp::WarpStyle::Arc, 10.0, [0.0, 0.0, 50.0, 30.0])); + assert_eq!(render(&warped), Rect::new(16, 14, 66, 44), "warped: solved"); + // A mesh with no placement recorded is treated the same way. + let mut bare = sm.clone(); + bare.smart_filters[0].params["mesh"].as_object_mut().unwrap().remove("placement"); + assert_eq!(render(&bare), Rect::new(16, 14, 66, 44), "no placement: solved"); +} diff --git a/crates/engine/src/cmd/edit_cmds.rs b/crates/engine/src/cmd/edit_cmds.rs index 38eb4aed5ef..d460524c257 100644 --- a/crates/engine/src/cmd/edit_cmds.rs +++ b/crates/engine/src/cmd/edit_cmds.rs @@ -989,12 +989,16 @@ fn guide_position(s: &Session, p: &Value, vertical: bool) -> Result> let px = match (v.as_f64(), v.as_str()) { (Some(n), _) => n, (None, Some(text)) => { - let (n, unit) = crate::prefs::Unit::parse_length(text).ok_or_else(|| { - EngineError::Other(format!("`position` {text:?} is not a length: give a number of pixels, or a number with px, in, cm, mm, pt, pc or %")) - })?; let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; let extent = if vertical { doc.size.width } else { doc.size.height }; - unit.unwrap_or(crate::prefs::Unit::Pixels).to_px(n, doc.resolution_dpi as f64, extent as f64, s.prefs().units_and_rulers.point_size.per_inch()) + let per_inch = s.prefs().units_and_rulers.point_size.per_inch(); + // A length, or arithmetic on lengths as Photoshop's field takes ("100% / 3", #10440). + let to_px = |n: f64, unit: crate::prefs::Unit| unit.to_px(n, doc.resolution_dpi as f64, extent as f64, per_inch); + crate::prefs::Unit::eval_length(text, crate::prefs::Unit::Pixels, to_px).ok_or_else(|| { + EngineError::Other(format!( + "`position` {text:?} is not a length: give a number of pixels, a number with px, in, cm, mm, pt, pc or %, or arithmetic on them (+ - * /)" + )) + })? } _ => return Err(EngineError::Other("`position` must be a number of pixels or text with a unit".into())), }; @@ -1263,8 +1267,8 @@ pub fn specs() -> Vec { has_clip, |s, p| paste(s, p, true) ), - spec!("layer.new.layerViaCopy", "Layer via Copy", &["Layer", "New"], Some("Cmd+J"), "{}", has_doc, |s, _| layer_via(s, false)), - spec!("layer.new.layerViaCut", "Layer via Cut", &["Layer", "New"], Some("Cmd+Shift+J"), "{}", has_pixels, |s, _| layer_via(s, true)), + spec!("layer.new.layerViaCopy", "Layer Via Copy", &["Layer", "New"], Some("Cmd+J"), "{}", has_doc, |s, _| layer_via(s, false)), + spec!("layer.new.layerViaCut", "Layer Via Cut", &["Layer", "New"], Some("Cmd+Shift+J"), "{}", has_pixels, |s, _| layer_via(s, true)), spec!("layer.mergeVisible", "Merge Visible", &["Layer"], Some("Cmd+Shift+E"), "{}", has_doc, |s, _| merge_visible(s)), spec!("image.autoTone", "Auto Tone", &["Image"], Some("Cmd+Shift+L"), "{}", has_pixels, |s, _| auto_adjust(s, "tone")), spec!("image.autoContrast", "Auto Contrast", &["Image"], Some("Cmd+Alt+Shift+L"), "{}", has_pixels, |s, _| auto_adjust(s, "contrast")), diff --git a/crates/engine/src/cmd/edit_cmds/tests.rs b/crates/engine/src/cmd/edit_cmds/tests.rs index 288dcb52662..089a9873ba3 100644 --- a/crates/engine/src/cmd/edit_cmds/tests.rs +++ b/crates/engine/src/cmd/edit_cmds/tests.rs @@ -1292,6 +1292,56 @@ fn guide_positions_take_photoshop_units() { assert_eq!(s.active().unwrap().doc.guides.horizontal[0], 50.0); } +/// #10440: the position takes arithmetic as Photoshop's New Guide field does. Measured in +/// Photoshop 2025 on a 600 × 400, 72 ppi document: "100% / 3" → 200, "100% - 50px" → 550, +/// "300 / 2" → 150 (pixels), "1in + 10" → 792 (the bare 10 is in the first operand's unit). +#[test] +fn guide_positions_take_arithmetic() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 600, "height": 400, "resolution": 72})).unwrap(); + for (text, px) in [ + ("100% / 3", 200.0), + ("100% - 50px", 550.0), + ("300 / 2", 150.0), + ("1in + 10", 792.0), + ("2 * 50 px", 100.0), + ("10 + 2 * 3", 16.0), + ("-50px + 100", 50.0), + ] { + assert!((new_guide(&mut s, "vertical", json!(text)) - px).abs() < 1e-6, "{text}"); + } + // Horizontal guides take percent of the height. + assert!((new_guide(&mut s, "horizontal", json!("50% + 10px")) - 210.0).abs() < 1e-6); + for bad in ["100% /", "1in * 2in", "10 / 0", "10 / 2px", "5 ^ 2", "+", "50% 3"] { + let err = s.execute("view.newGuide", json!({"orientation": "vertical", "position": bad})).unwrap_err(); + assert!(err.to_string().contains("not a length"), "{bad}: {err}"); + } +} + +/// The Unicode minus (U+2212, what some keyboards and pasted text give) is a minus, as in the +/// numeric fields' own evaluator. +#[test] +fn guide_position_arithmetic_takes_the_unicode_minus() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 600, "height": 400, "resolution": 72})).unwrap(); + for (text, px) in [("100% − 50px", 550.0), ("−50px + 100", 50.0), ("1in − −10", 792.0), ("−100", -100.0)] { + assert!((new_guide(&mut s, "vertical", json!(text)) - px).abs() < 1e-6, "{text}"); + } +} + +/// Text longer than the numeric fields' 1024-byte cap is refused before it is tokenised. +#[test] +fn guide_position_arithmetic_is_capped_at_1024_bytes() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 600, "height": 400, "resolution": 72})).unwrap(); + let fits = format!("{}100", "0 + ".repeat(255)); + assert_eq!(fits.len(), 1023); + assert_eq!(new_guide(&mut s, "vertical", json!(fits)), 100.0); + let long = format!("{}100", "0 + ".repeat(256)); + let err = s.execute("view.newGuide", json!({"orientation": "vertical", "position": long})).unwrap_err(); + assert!(err.to_string().contains("not a length"), "{err}"); +} + #[test] fn new_guides_keep_their_own_colour_through_moves_deletes_and_undo() { let mut s = Session::new(); @@ -1381,6 +1431,26 @@ fn editing_a_guide_changes_what_is_given_in_one_undoable_step() { assert_eq!(g.color(true, 200.0), Some([0xff, 0x00, 0xff])); } +/// #10440: Edit Selected Guides reads its position as New Guide does, arithmetic included; % is of +/// the new orientation's extent. +#[test] +fn editing_a_guide_takes_arithmetic_in_its_position() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 100})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 20})).unwrap(); + s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "position": "100% / 4"})).unwrap(); + s.execute("view.editGuide", json!({"orientation": "vertical", "index": 1, "position": "1in + 1"})).unwrap(); + assert_eq!(s.active().unwrap().doc.guides.vertical, vec![100.0, 200.0]); + s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "newOrientation": "horizontal", "position": "50% − 10px"})).unwrap(); + let g = &s.active().unwrap().doc.guides; + assert_eq!((g.vertical.clone(), g.horizontal.clone()), (vec![200.0], vec![90.0])); + let before = g.clone(); + let err = s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "position": "1in * 2in"})).unwrap_err(); + assert!(err.to_string().contains("not a length"), "{err}"); + assert_eq!(s.active().unwrap().doc.guides, before); +} + #[test] fn bad_guide_edits_are_refused_without_a_history_step() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/edit_menu_cmds.rs b/crates/engine/src/cmd/edit_menu_cmds.rs index b4287e649e6..98ea8b46194 100644 --- a/crates/engine/src/cmd/edit_menu_cmds.rs +++ b/crates/engine/src/cmd/edit_menu_cmds.rs @@ -313,7 +313,7 @@ pub use content_aware::{CafPlan, FillEdit, MAX_CAF_EDITS, MAX_CAF_POINTS, SelOp, use content_aware::{can_caf, content_aware_fill, delete_and_fill}; pub use content_aware_scale::ContentAwareScalePreview; use content_aware_scale::content_aware_scale; -pub use fade::fade_surface; +pub use fade::{FadePreview, fade_preview, fade_surface}; use fade::{can_fade, fade}; pub use find_replace::find_matches; use find_replace::{find_replace, has_type_layer}; diff --git a/crates/engine/src/cmd/edit_menu_cmds/fade.rs b/crates/engine/src/cmd/edit_menu_cmds/fade.rs index 14f9b8365de..ad767c584c7 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/fade.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/fade.rs @@ -1,5 +1,5 @@ use super::{ - BlendMode, EngineError, FadeTarget, Rect, Result, Session, Surface, Value, bad, blend_from_str, f32_or, from_rgba_into, json, str_or, surface_of, + BlendMode, Document, EngineError, FadeTarget, Rect, Result, Session, Surface, Value, bad, blend_from_str, f32_or, from_rgba_into, json, str_or, surface_of, surface_of_mut, to_rgba, }; @@ -67,7 +67,18 @@ pub fn fade_surface(before: &Surface, after: &mut Surface, area: Rect, mode: Ble Ok(()) } -pub(super) fn fade(s: &mut Session, p: &Value) -> Result { +/// What Edit › Fade with `p` blends: the layer, mask or channel of the last step, its pixels +/// before it, the area to blend, the mode and the opacity. +struct FadeJob { + target: FadeTarget, + label: String, + before: Surface, + area: Rect, + mode: BlendMode, + opacity: f32, +} + +fn fade_job(s: &Session, p: &Value) -> Result { let cmd = "edit.fade"; let src = s.edit_state.fade.clone().ok_or_else(|| bad(cmd, "nothing to fade"))?; let opacity = f32_or(p, "opacity", 100.0).clamp(0.0, 100.0) / 100.0; @@ -82,12 +93,45 @@ pub(super) fn fade(s: &mut Session, p: &Value) -> Result { // A uniform mask can change every pixel without storing a single tile. In that case // the content bounds are empty, but Fade must still blend across the canvas. let area = if before.default_pixel() != now.default_pixel() { st.doc.bounds() } else { before.content_bounds().union(&now.content_bounds()) }; - let label = format!("Fade {}", src.label); - s.edit(&label, |doc, _| { - let surf = surface_of_mut(doc, src.target).ok_or_else(|| EngineError::Other("the faded layer or channel no longer exists".into()))?; - fade_surface(&before, surf, area, mode, opacity)?; - surf.prune(); - Ok(()) - })?; - Ok(json!({"faded": src.label, "opacity": opacity * 100.0, "mode": mode.label()})) + Ok(FadeJob { target: src.target, label: src.label, before, area, mode, opacity }) +} + +fn apply_fade(doc: &mut Document, j: &FadeJob) -> Result<()> { + let surf = surface_of_mut(doc, j.target).ok_or_else(|| EngineError::Other("the faded layer or channel no longer exists".into()))?; + fade_surface(&j.before, surf, j.area, j.mode, j.opacity)?; + surf.prune(); + Ok(()) +} + +pub(super) fn fade(s: &mut Session, p: &Value) -> Result { + let j = fade_job(s, p)?; + let label = format!("Fade {}", j.label); + s.edit(&label, |doc, _| apply_fade(doc, &j))?; + Ok(json!({"faded": j.label, "opacity": j.opacity * 100.0, "mode": j.mode.label()})) +} + +/// Edit › Fade with given params, captured from the session without a history step: the Fade +/// dialog's live preview (#8230). [`FadePreview::render`] does the blend and can run off the +/// session (the preview worker). The document copy shares its tiles with the session's, and +/// only the tiles in the faded area are blended and rewritten. +pub struct FadePreview { + doc: Document, + job: FadeJob, +} + +impl FadePreview { + /// The active document as Edit › Fade would leave it. + pub fn render(mut self) -> Result { + apply_fade(&mut self.doc, &self.job)?; + Ok(self.doc) + } +} + +/// What Edit › Fade with `p` would do to the active document. `Err` when Fade isn't available +/// or `p` is invalid. +pub fn fade_preview(s: &Session, p: &Value) -> Result { + can_fade(s).map_err(|e| bad("edit.fade", e))?; + let job = fade_job(s, p)?; + let doc = Document::clone(&s.active().ok_or(EngineError::NoDocument)?.doc); + Ok(FadePreview { doc, job }) } diff --git a/crates/engine/src/cmd/edit_menu_cmds/tests.rs b/crates/engine/src/cmd/edit_menu_cmds/tests.rs index cff0d0d3153..069ff2573c6 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/tests.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/tests.rs @@ -23,6 +23,33 @@ fn active(s: &Session) -> &Layer { st.doc.layer(st.active_layer.unwrap()).unwrap() } +/// #8230: the Fade dialog's preview is the document Fade would leave, at every depth and in a +/// blend mode, with no history step and nothing changed until OK; with nothing to fade or a bad +/// mode it is an error, not a panic. +#[test] +fn fade_preview_matches_fade_without_a_step() { + for depth in [8, 16, 32] { + assert!(fade_preview(&session(depth), &json!({"opacity": 50})).is_err(), "nothing to fade yet"); + for p in [json!({"opacity": 25}), json!({"opacity": 60, "mode": "difference"}), json!({})] { + let mut s = session(depth); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + let (steps, revision) = (s.active().unwrap().history.past_len(), s.active().unwrap().revision); + let preview = fade_preview(&s, &p).and_then(FadePreview::render).unwrap(); + assert_eq!((s.active().unwrap().history.past_len(), s.active().unwrap().revision), (steps, revision), "depth {depth} {p}: no step"); + assert_eq!(px(&s, 5, 5)[0], 0.0, "depth {depth} {p}: the layer is untouched"); + let id = s.active().unwrap().active_layer.unwrap(); + let shown = preview.layer(id).unwrap().surface().unwrap().pixel(5, 5); + s.execute("edit.fade", p.clone()).unwrap(); + assert_eq!(shown, px(&s, 5, 5), "depth {depth} {p}: the preview is what OK applies"); + } + let mut s = session(depth); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + assert!(fade_preview(&s, &json!({"mode": "nonsense"})).is_err()); + s.execute("layer.new.layer", json!({})).unwrap(); + assert!(fade_preview(&s, &json!({})).is_err(), "not right after the step"); + } +} + #[test] fn fade_blends_last_step_at_all_depths() { for depth in [8, 16, 32] { @@ -119,7 +146,11 @@ fn fade_an_alpha_channel_or_the_quick_mask_at_all_depths() { assert!((channel(&s, 5, 5) - (1.0 - was)).abs() < 0.01, "depth {depth}, quick mask {quick_mask}: inverted"); let expected = was + (1.0 - 2.0 * was) * k; let steps = s.active().unwrap().history.past_len(); + // The Fade dialog's preview (#8230) shows the channel as OK leaves it. + let preview = fade_preview(&s, &json!({"opacity": opacity})).and_then(FadePreview::render).unwrap(); + let shown = if quick_mask { preview.quick_mask.as_ref() } else { preview.channels.first() }.unwrap().surface.pixel(5, 5)[0]; s.execute("edit.fade", json!({"opacity": opacity})).unwrap(); + assert_eq!(shown, channel(&s, 5, 5), "depth {depth}, quick mask {quick_mask}, {opacity} %: the preview is what OK applies"); for (x, y) in [(0, 0), (5, 5), (63, 47)] { let v = channel(&s, x, y); assert!((v - expected).abs() < 0.02, "depth {depth}, quick mask {quick_mask}, {opacity} %, ({x}, {y}): {v}"); diff --git a/crates/engine/src/cmd/eraser_cmds.rs b/crates/engine/src/cmd/eraser_cmds.rs index 1433498078b..bf08c18ac24 100644 --- a/crates/engine/src/cmd/eraser_cmds.rs +++ b/crates/engine/src/cmd/eraser_cmds.rs @@ -140,7 +140,7 @@ fn background_eraser_setup(s: &Session, p: &Value) -> Result<(Stroke, BgEraseSet let bs = BgEraseSettings { sampling: enum_param(p, CMD, "sampling", Sampling::Continuous, "continuous|once|backgroundSwatch")?, limits: enum_param(p, CMD, "limits", Limits::Contiguous, "discontiguous|contiguous|findEdges")?, - tolerance: number(p, CMD, "tolerance", 50.0, 0.0..=100.0)? as f32 / 100.0, + tolerance: number(p, CMD, "tolerance", 50.0, 1.0..=100.0)? as f32 / 100.0, protect_foreground: flag(p, "protectForegroundColor", false), foreground: s.tools.foreground, background: s.tools.background, @@ -267,7 +267,7 @@ pub fn specs() -> Vec { label: "Background Eraser", menu: &[], shortcut: None, - params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":0..100=50,"protectForegroundColor":bool=false,"seed":u64?}"##, + params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":1..100=50,"protectForegroundColor":bool=false,"seed":u64?}"##, enabled: has_pixel_layer, run: background_eraser, journal: true, diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index 495f9d18923..b1ec4df7d65 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -24,6 +24,7 @@ mod batch_run; mod export; mod info; mod open; +mod open_as_smart_object; mod paths; mod place_steps; mod processing; @@ -50,6 +51,8 @@ pub(crate) use open::{BatchOpen, batch_open_notice, batch_open_params, remember_ pub use open::{PLACE_EMBEDDED, open_bytes_as, open_bytes_as_with_options, place_bytes, raw_defaults}; pub(crate) use open::{PlaceInput, apply_place, import, import_with_options, tag_exr_colorspace}; use open::{approve_memory, close_all, close_others, open_as, place, revert}; +pub use open_as_smart_object::admit_as_smart_object; +use open_as_smart_object::open_as_smart_object; use paths::is_file; #[cfg(test)] use paths::openable_name; @@ -285,6 +288,15 @@ pub fn specs() -> Vec { native, open_as ), + spec!( + "file.openAsSmartObject", + "Open as Smart Object…", + &["File"], + None, + r##"{"path":str,"confirmDamage":bool=false (a damaged PSD waits for an answer, as File > Open's does)} (opens the file as a new untitled document named "-N" with the file's size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer named after the file) → {document, docId, name, layer, warnings, color, smartObject} (or {held, name, warnings, color, damaged, smartObject} while a prompt waits)"##, + native, + open_as_smart_object + ), // One-shot permission for one file, not something to replay later: never journaled or // recorded in an action, since replaying it in another session/on another file would // grant memory use nobody approved (#10897). It also releases the queued source on both @@ -421,6 +433,10 @@ mod tests; #[path = "file_cmds/place_job_tests.rs"] mod place_job_tests; +#[cfg(test)] +#[path = "file_cmds/open_as_smart_object_tests.rs"] +mod open_as_smart_object_tests; + #[cfg(test)] mod aces_exr_tests { use super::*; diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index 6121180e22e..282eae0e4ee 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -74,9 +74,15 @@ fn import_result(name: &str, bytes: &[u8]) -> Result Result { +pub(super) fn import_result_with(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result { + import_result_for_open(name, bytes, opts).map(|(r, _)| r) +} + +/// [`import_result_with`], also telling whether a PSD/PSB had damaged data it recovered from, as +/// File › Open's job learns it (`photocraft_io::import_for_open`), so an interactive caller can ask. +pub(super) fn import_result_for_open(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result<(photocraft_io::ImportResult, bool)> { crate::allocation::checkpoint("importing document")?; - let r = photocraft_io::import_with_options(name, bytes, opts).map_err(|e| crate::error::io_open_error(name, e))?; + let (r, damaged) = photocraft_io::import_for_open(name, bytes, opts).map_err(|e| crate::error::io_open_error(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. if r.preview_only { @@ -84,7 +90,7 @@ fn import_result_with(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOpti "{name}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here" ))); } - Ok(r) + Ok((r, damaged)) } // ---------- close / revert / save a copy / open as ---------- diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs new file mode 100644 index 00000000000..57bd8ce39f7 --- /dev/null +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object.rs @@ -0,0 +1,115 @@ +//! File › Open as Smart Object… (#11678): a file opens as a new untitled document with the file's +//! size, resolution, colour mode, bit depth and profile, holding one embedded smart object layer. +//! +//! The layer is built as File › Place Embedded builds one (an embedded smart object at the file's own +//! size and position `0, 0`) and put into the document before it is admitted, so the opening +//! snapshot already shows it. + +use super::open::import_result_for_open; +use super::paths::{file_name, read_file, stem, untitled_name}; +use super::{Affine, Arc, Document, Layer, LayerContent, Result, Session, SmartObject, SmartSource, Value, flattened_checked, json, str_param}; +use crate::layer_place::Place; + +const CMD: &str = "file.openAsSmartObject"; + +/// The history step's name. +const LABEL: &str = "Open As Smart Object"; + +/// Opens the file as a document holding one embedded smart object. The read, decode and flatten +/// run on the job's worker (inline under `execute`); the document is built, given File › Open's +/// colour policy and admitted, or held for its profile prompt, when the job is applied. The reply +/// has the shape of File › Open's job: `{document, docId, name, layer, warnings, color}`, or +/// `{held, name, warnings, color, smartObject}` while the profile prompt waits. +pub(super) fn open_as_smart_object(s: &mut Session, p: &Value) -> Result { + let path = str_param(p, "path", CMD)?.to_string(); + let name = file_name(&path); + let opts = crate::jobs::import_options(s.prefs()); + // An interactive caller (the app) asks before opening a PSD that had damaged data, as File > Open does. + let confirm_damage = p.get("confirmDamage").and_then(Value::as_bool).unwrap_or(false); + crate::jobs::run( + s, + &format!("Opening {name}"), + false, + move |ctx| { + ctx.progress(0.0, "Reading"); + let bytes = read_file(&path)?; + ctx.check()?; + ctx.progress(0.05, "Decoding"); + let (r, damaged) = import_result_for_open(&name, &bytes, &opts)?; + ctx.check()?; + ctx.progress(0.9, "Flattening"); + // The file as one image: the smart object's rendered contents. + let pixels = flattened_checked(&r.document, r.document.pixel_format(), CMD)?; + Ok((path, name, Arc::new(bytes), r, pixels, confirm_damage && damaged)) + }, + |s, (path, name, bytes, r, pixels, damaged)| apply(s, &path, &name, bytes, r, pixels, damaged), + ) +} + +fn apply( + s: &mut Session, + path: &str, + file: &str, + bytes: Arc>, + r: photocraft_io::ImportResult, + pixels: photocraft_raster::Surface, + damaged: bool, +) -> Result { + let warnings = r.warnings; + let mut src = r.document; + super::tag_exr_colorspace(s, file, &mut src); + super::raw_defaults(s, &warnings, &mut src); + let layer_name = stem(path); + // "-1", the next free number while a document with that name is open. + let name = untitled_name(&layer_name, |n| s.documents().iter().any(|d| d.doc.name == n) || s.held_open_named(n)); + + let mut doc = Document::new(name.clone(), src.size, src.mode, src.depth); + doc.resolution_dpi = src.resolution_dpi; + doc.icc_profile = src.icc_profile.clone(); + doc.color_table = src.color_table.clone(); + doc.duotone = src.duotone.clone(); + + // Embedded (not linked): the original file name keeps its extension, so Edit Contents opens it. + let source = SmartSource::Embedded { file_name: file.to_string(), bytes }; + let so = SmartObject::new(source, Affine { m: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0] }, Some(pixels)); + let layer = Layer::new(layer_name, LayerContent::Smart(so)); + let layer_id = layer.id; + crate::layer_place::insert_new(&mut doc, None, layer, Place::AutoEditable); + + // File › Open's completion: a raw has no embedded profile to ask about; otherwise the profile + // prompt holds the document, and without a question the Color Settings policy is applied. + let raw = warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)); + let question = s.color.open_question(&doc).filter(|_| !raw); + if damaged || question.is_some() { + let held = s.hold_open(doc, false); + return Ok(json!({"held": held, "name": name, "warnings": warnings, "color": question, "damaged": damaged, "smartObject": true})); + } + let color = s.color.open_policy(&mut doc, s.prefs().file_handling.ignore_exif_profile_tag); + let index = admit_as_smart_object(s, doc); + let id = s.documents().get(index).map(|st| st.doc.id.0); + // Admission numbers the document again, so the reply reads the name it was admitted under. + let name = s.documents().get(index).map_or(name, |st| st.doc.name.clone()); + Ok(json!({"document": index, "docId": id, "name": name, "layer": layer_id.0, "warnings": warnings, "color": color, "smartObject": true})) +} + +/// Admits a document built by Open as Smart Object (one smart object layer) as an untitled +/// document: the opening snapshot already shows the placed layer, is named after it, and the +/// history is the single state "Open As Smart Object", as File › Open's is one state. +pub fn admit_as_smart_object(s: &mut Session, mut doc: Document) -> usize { + let layer_name = doc.layers.first().map(|l| l.name.clone()); + // The number was free when the file was read, but documents held for their prompts did not count + // then, and one of them may have been cancelled since: take the first free one now, as each + // document is admitted. + if let Some(base) = &layer_name { + doc.name = untitled_name(base, |n| s.documents().iter().any(|d| d.doc.name == n)); + } + // Untitled: no path, so Save asks where to save. + let index = s.add_document(doc, None); + if let Some(st) = s.active_mut() { + if let (Some(first), Some(name)) = (st.snapshots.iter_mut().find(|snap| snap.id == 0), layer_name) { + first.name = name; + } + st.history.set_current_label(LABEL); + } + index +} diff --git a/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs new file mode 100644 index 00000000000..c205af3d71b --- /dev/null +++ b/crates/engine/src/cmd/file_cmds/open_as_smart_object_tests.rs @@ -0,0 +1,367 @@ +//! File › Open as Smart Object… (#11678): a file opens as a new untitled document holding one +//! embedded smart object layer. + +use super::*; + +fn tmp(name: &str) -> String { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let d = std::env::temp_dir().join(format!("photocraft-open-as-so-{}-{name}-{id}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d.to_string_lossy().into_owned() +} + +/// A small PNG with a gradient, so the pixels differ from place to place. +fn write_png(dir: &str, name: &str, w: u32, h: u32) -> String { + let px: Vec = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x * 255 / w) as u8, (y * 255 / h) as u8, ((x + y) % 256) as u8])).collect(); + let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgb, px).unwrap(); + let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let path = join(dir, name); + std::fs::write(&path, bytes).unwrap(); + path +} + +/// A two-layer 16-bit PSD at 300 ppi tagged with Display P3, made with the engine's own save. +fn write_psd16(dir: &str, name: &str, w: u32, h: u32) -> String { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "depth": 16})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("image.imageSize", json!({"resolution": 300, "resample": "none"})).unwrap(); + s.execute("edit.assignProfile", json!({"profile": "display-p3"})).unwrap(); + let path = join(dir, name); + save_doc(&s.active().unwrap().doc, &path, None).unwrap(); + path +} + +fn imported(path: &str) -> Document { + photocraft_io::import(&file_name(path), &std::fs::read(path).unwrap()).unwrap().document +} + +fn active(s: &Session) -> &Document { + &s.active().unwrap().doc +} + +fn names(s: &Session) -> Vec { + s.documents().iter().map(|d| d.doc.name.clone()).collect() +} + +/// The one layer of the active document, which must be an embedded smart object. +fn embedded(s: &Session) -> (String, String, Arc>) { + let d = active(s); + assert_eq!(d.layers.len(), 1, "one layer, not a Background plus a layer"); + let layer = &d.layers[0]; + match &layer.content { + LayerContent::Smart(so) => match &so.source { + SmartSource::Embedded { file_name, bytes } => (layer.name.clone(), file_name.clone(), Arc::clone(bytes)), + other => panic!("expected an embedded smart object, got {other:?}"), + }, + _ => panic!("the layer is not a smart object"), + } +} + +#[test] +fn a_png_opens_as_an_untitled_document_with_one_embedded_smart_object() { + let dir = tmp("png"); + let path = write_png(&dir, "a_test.png", 64, 48); + let mut s = Session::new(); + let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(r["name"], "a_test-1", "{r}"); + assert_eq!(s.documents().len(), 1); + let st = s.active().unwrap(); + assert_eq!(st.doc.name, "a_test-1"); + assert_eq!(st.path, None, "untitled: Save asks where to save"); + + let file = imported(&path); + let d = &st.doc; + assert_eq!((d.size.width, d.size.height), (64, 48)); + assert_eq!(d.resolution_dpi, file.resolution_dpi); + assert_eq!((d.mode, d.depth), (file.mode, file.depth)); + assert_eq!(d.icc_profile, file.icc_profile); + + let (layer, original, bytes) = embedded(&s); + assert_eq!(layer, "a_test", "named after the file without its extension"); + assert_eq!(original, "a_test.png", "Edit Contents opens the original file name"); + assert_eq!(*bytes, std::fs::read(&path).unwrap(), "the file itself is embedded"); +} + +#[test] +fn the_smart_object_shows_the_file() { + let dir = tmp("pixels"); + let path = write_png(&dir, "grad.png", 32, 16); + let mut s = Session::new(); + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let want = photocraft_compose::render(&imported(&path), Rect::new(0, 0, 32, 16)).px.clone(); + let got = photocraft_compose::render(active(&s), Rect::new(0, 0, 32, 16)).px.clone(); + assert_eq!(got, want); +} + +#[test] +fn a_16_bit_psd_keeps_size_resolution_mode_depth_and_profile() { + let dir = tmp("psd"); + let path = write_psd16(&dir, "c.layered.psd", 40, 30); + let mut s = Session::new(); + // The files were measured in the working spaces, where nothing asks; Preserve keeps Display P3. + s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap(); + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let file = imported(&path); + assert_eq!(file.resolution_dpi, 300.0); + assert!(file.icc_profile.is_some()); + let d = active(&s); + assert_eq!(d.name, "c.layered-1", "only the last extension is dropped"); + assert_eq!((d.size.width, d.size.height), (40, 30)); + assert_eq!(d.resolution_dpi, 300.0); + assert_eq!((d.mode, d.depth), (file.mode, file.depth)); + assert_eq!(d.depth, photocraft_doc::SampleType::U16); + assert_eq!(d.icc_profile, file.icc_profile); + let (layer, original, _) = embedded(&s); + assert_eq!((layer.as_str(), original.as_str()), ("c.layered", "c.layered.psd")); +} + +#[test] +fn history_is_the_single_state_open_as_smart_object() { + let dir = tmp("history"); + let path = write_png(&dir, "a_test.png", 16, 16); + let mut s = Session::new(); + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.history.entries(), ["Open As Smart Object"], "one state, as File > Open has"); + assert_eq!(st.snapshots.first().map(|snap| snap.name.as_str()), Some("a_test"), "the opening snapshot is named after the layer"); +} + +#[test] +fn the_opening_snapshot_shows_the_placed_smart_object() { + let dir = tmp("snapshot"); + let path = write_png(&dir, "a_test.png", 16, 16); + let mut s = Session::new(); + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let st = s.active().unwrap(); + let first = st.snapshots.first().unwrap(); + assert_eq!(first.doc.layers.len(), 1, "snapshot 0 already holds the smart object"); + assert!(matches!(first.doc.layers[0].content, LayerContent::Smart(_))); + assert_eq!(first.layers.active, Some(first.doc.layers[0].id)); + assert_eq!(st.active_layer, Some(first.doc.layers[0].id)); + assert_eq!(st.history.entries(), ["Open As Smart Object"]); +} + +#[test] +fn the_number_is_the_next_free_one_and_starts_again_after_they_close() { + let dir = tmp("numbers"); + let path = write_png(&dir, "a_test.png", 8, 8); + let mut s = Session::new(); + for _ in 0..3 { + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + } + assert_eq!(names(&s), ["a_test-1", "a_test-2", "a_test-3"]); + while !s.documents().is_empty() { + s.close(0); + } + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(names(&s), ["a_test-1"]); +} + +#[test] +fn a_plain_open_of_the_same_file_is_unchanged() { + let dir = tmp("plain"); + let path = write_png(&dir, "a_test.png", 8, 8); + let mut s = Session::new(); + s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + open_bytes_as(&mut s, &path, &std::fs::read(&path).unwrap(), None, Some(path.clone())).unwrap(); + assert_eq!(names(&s), ["a_test-1", "a_test.png"]); + assert!(matches!(active(&s).layers[0].content, LayerContent::Raster(_))); +} + +#[test] +fn several_files_open_one_document_each() { + let dir = tmp("several"); + let a = write_png(&dir, "a_test.png", 8, 8); + let b = write_psd16(&dir, "d_other.psd", 12, 10); + let mut s = Session::new(); + s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap(); + s.execute("file.openAsSmartObject", json!({"path": a})).unwrap(); + s.execute("file.openAsSmartObject", json!({"path": b})).unwrap(); + assert_eq!(names(&s), ["a_test-1", "d_other-1"]); + for st in s.documents() { + assert_eq!(st.doc.layers.len(), 1); + assert!(matches!(st.doc.layers[0].content, LayerContent::Smart(_))); + } +} + +#[test] +fn a_missing_or_unreadable_file_is_an_error_not_a_crash() { + let dir = tmp("bad"); + let junk = join(&dir, "notes.png"); + std::fs::write(&junk, b"this is not an image").unwrap(); + let text = join(&dir, "readme.txt"); + std::fs::write(&text, b"hello").unwrap(); + let mut s = Session::new(); + for path in [join(&dir, "missing.png"), junk, text] { + let r = s.execute("file.openAsSmartObject", json!({"path": path})); + assert!(r.is_err(), "{path}: {r:?}"); + } + assert!(s.documents().is_empty(), "a failed open leaves no document behind"); +} + +#[test] +fn bad_params_are_errors() { + let mut s = Session::new(); + for params in [json!({}), json!({"path": 7}), json!({"path": null})] { + let r = s.execute("file.openAsSmartObject", params.clone()); + assert!(r.is_err(), "{params}: {r:?}"); + } + assert!(s.documents().is_empty()); +} + +fn job(r: crate::jobs::Started) -> crate::jobs::JobId { + match r { + crate::jobs::Started::Job(id) => id, + crate::jobs::Started::Done(v) => panic!("expected a background job, ran inline: {v}"), + } +} + +/// Poll until job `id` ends (or 60 s pass); returns its event. +fn wait_event(s: &mut Session, id: crate::jobs::JobId) -> crate::jobs::JobEvent { + let t = std::time::Instant::now(); + loop { + if let Some(e) = s.poll_jobs().into_iter().find(|e| e.id == id) { + return e; + } + assert!(t.elapsed() < std::time::Duration::from_secs(60), "job {id:?} did not finish"); + std::thread::sleep(std::time::Duration::from_millis(2)); + } +} + +#[test] +fn the_import_runs_as_a_job_when_started() { + let dir = tmp("job"); + let path = write_png(&dir, "a_test.png", 32, 24); + let mut s = Session::new(); + let id = job(s.start("file.openAsSmartObject", json!({"path": path})).unwrap()); + assert!(s.documents().is_empty(), "nothing is admitted before the job is applied"); + let e = wait_event(&mut s, id); + let crate::jobs::JobOutcome::Done(v) = &e.outcome else { panic!("{e:?}") }; + assert!(v["docId"].is_u64(), "{v}"); + assert_eq!(v["name"], "a_test-1", "{v}"); + assert_eq!(s.documents().len(), 1); + embedded(&s); +} + +#[test] +fn the_colour_policy_matches_file_open() { + let dir = tmp("policy"); + let path = write_psd16(&dir, "c.layered.psd", 40, 30); + let settings = json!({"askOnMismatch": false, "policyRgb": "convert"}); + let mut open = Session::new(); + open.execute("edit.colorSettings", settings.clone()).unwrap(); + open.execute("file.openAs", json!({"path": path})).unwrap(); + let mut so = Session::new(); + so.execute("edit.colorSettings", settings).unwrap(); + so.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let (a, b) = (active(&open), active(&so)); + assert_eq!(a.icc_profile, b.icc_profile, "Convert is applied as File > Open applies it"); + assert_eq!((a.mode, a.depth, a.size), (b.mode, b.depth, b.size)); + assert_eq!(a.resolution_dpi, b.resolution_dpi); +} + +#[test] +fn ask_when_opening_holds_the_document_like_file_open() { + let dir = tmp("ask"); + let path = write_psd16(&dir, "c.layered.psd", 40, 30); + let mut s = Session::new(); + let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(r["color"]["ask"], true, "{r}"); + assert_eq!(r["smartObject"], true, "{r}"); + assert!(r["held"].is_u64(), "{r}"); + assert!(s.documents().is_empty(), "held for the profile prompt, not admitted"); + let (doc, _) = s.take_held_open(r["held"].as_u64().unwrap()).unwrap(); + assert_eq!(doc.name, "c.layered-1"); + assert_eq!(doc.layers.len(), 1); + assert!(matches!(doc.layers[0].content, LayerContent::Smart(_))); +} + +#[test] +fn the_reply_flags_a_smart_object_so_a_queued_document_keeps_its_history_label() { + let dir = tmp("flag"); + let path = write_png(&dir, "a_test.png", 16, 12); + let mut s = Session::new(); + let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(r["smartObject"], true, "{r}"); +} + +#[test] +fn a_damaged_psd_is_held_for_confirmation_when_asked_and_opens_otherwise() { + let dir = tmp("damaged"); + let path = join(&dir, "d.psd"); + std::fs::write(&path, crate::jobs::psd_open_fixture::make_psd(true)).unwrap(); + let mut s = Session::new(); + s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap(); + let r = s.execute("file.openAsSmartObject", json!({"path": path, "confirmDamage": true})).unwrap(); + assert_eq!(r["damaged"], true, "{r}"); + assert_eq!(r["smartObject"], true, "{r}"); + assert!(r["held"].is_u64(), "{r}"); + assert!(s.documents().is_empty(), "held for the damage prompt, not admitted"); + let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert!(r["docId"].is_u64(), "a headless caller opens the recovered content: {r}"); +} + +#[test] +fn a_document_held_for_its_prompt_does_not_share_its_name_with_the_next() { + let dir = tmp("held-name"); + let path = write_psd16(&dir, "a_test.psd", 20, 20); + let mut s = Session::new(); + let first = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let second = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(first["name"], "a_test-1"); + assert_eq!(second["name"], "a_test-2", "the held document's name is taken: {second}"); + // Both held docs leave the session for the app's queue; admitting them one by one numbers each. + let (a, _) = s.take_held_open(first["held"].as_u64().unwrap()).unwrap(); + let (b, _) = s.take_held_open(second["held"].as_u64().unwrap()).unwrap(); + admit_as_smart_object(&mut s, a); + admit_as_smart_object(&mut s, b); + assert_eq!(names(&s), ["a_test-1", "a_test-2"]); + let mut t = Session::new(); + let (c, _) = { + let r = t.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + t.take_held_open(r["held"].as_u64().unwrap()).unwrap() + }; + let (d, _) = { + t.execute("edit.colorSettings", json!({"askOnMismatch": true})).unwrap(); + let r = t.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + t.take_held_open(r["held"].as_u64().unwrap()).unwrap() + }; + assert_eq!(c.name, d.name, "taken out of the session, both are named a_test-1 when read"); + admit_as_smart_object(&mut t, c); + admit_as_smart_object(&mut t, d); + assert_eq!(names(&t), ["a_test-1", "a_test-2"], "the second takes the next free number when admitted"); +} + +#[test] +fn a_cancelled_hold_frees_its_number_for_the_one_admitted_after_it() { + let dir = tmp("cancelled-hold"); + let path = write_psd16(&dir, "photo.psd", 20, 20); + let mut s = Session::new(); + let first = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + let second = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!((first["name"].as_str(), second["name"].as_str()), (Some("photo-1"), Some("photo-2"))); + // photo-1 is cancelled while photo-2 waits; photo-2 is admitted with nothing open. + s.take_held_open(first["held"].as_u64().unwrap()).unwrap(); + let (b, _) = s.take_held_open(second["held"].as_u64().unwrap()).unwrap(); + admit_as_smart_object(&mut s, b); + assert_eq!(names(&s), ["photo-1"], "the first free number is taken at admission, with no collision"); +} + +#[test] +fn the_reply_names_the_document_as_admitted() { + let dir = tmp("reply-name"); + let path = write_psd16(&dir, "photo.psd", 20, 20); + let mut s = Session::new(); + s.execute("edit.colorSettings", json!({"askOnMismatch": true})).unwrap(); + let held = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(held["name"], "photo-1", "{held}"); + assert!(held["held"].is_u64(), "{held}"); + // The next open skips the prompt: photo-1 is held, so the number read was 2, but admission takes the first free one. + s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap(); + let r = s.execute("file.openAsSmartObject", json!({"path": path})).unwrap(); + assert_eq!(names(&s), ["photo-1"], "{r}"); + assert_eq!(r["name"], "photo-1", "the reply carries the name the document was admitted under: {r}"); +} diff --git a/crates/engine/src/cmd/file_cmds/raw_open.rs b/crates/engine/src/cmd/file_cmds/raw_open.rs index 905f359aaa0..c252acc1de0 100644 --- a/crates/engine/src/cmd/file_cmds/raw_open.rs +++ b/crates/engine/src/cmd/file_cmds/raw_open.rs @@ -5,9 +5,12 @@ use photocraft_io::{ImportResult, raw::RawTuning}; use super::{EngineError, Result, Session, Value, json}; +/// The prior settings of a sidecar, and its crop (in the raw's stored orientation). +type Prior = (CameraRaw, Option); + /// Read only a bounded regular XMP file. No ambient sidecar reads are granted to gated sessions. #[cfg(not(target_arch = "wasm32"))] -fn prior_settings(path: Option<&str>) -> Result> { +fn prior_settings(path: Option<&str>) -> Result> { use std::io::Read; let Some(path) = path else { return Ok(None) }; let sidecar = match path.rsplit_once('.') { @@ -31,11 +34,11 @@ fn prior_settings(path: Option<&str>) -> Result> { if !report.ignored.is_empty() { return Err(EngineError::Other(format!("{sidecar}: unsupported prior RAW settings: {}", report.ignored.join(", ")))); } - Ok(Some(settings)) + Ok(Some((settings, report.crop))) } #[cfg(target_arch = "wasm32")] -fn prior_settings(path: Option<&str>) -> Result> { +fn prior_settings(path: Option<&str>) -> Result> { if path.is_some() { Err(EngineError::Other("cannot read RAW sidecars on the web".into())) } else { Ok(None) } } @@ -67,6 +70,8 @@ pub(super) fn prepare(s: &Session, name: &str, path: Option<&str>, bytes: &[u8]) return Err(EngineError::Other("RAW sidecar opening requires a trusted session; no implicit read capability is available".into())); } let prior = prior_settings(path)?; + let crop = prior.as_ref().and_then(|(_, crop)| *crop); + let prior = prior.map(|(settings, _)| settings); let mut settings = prior.clone().unwrap_or_default(); let opts = crate::jobs::import_options(s.prefs()); let mut imported = photocraft_io::import_with_options(name, bytes, &opts).map_err(|error| EngineError::Other(format!("{name}: {error}")))?; @@ -87,6 +92,8 @@ pub(super) fn prepare(s: &Session, name: &str, path: Option<&str>, bytes: &[u8]) clarity: settings.clarity, texture: settings.texture, dehaze: settings.dehaze, + // The sidecar's crop, as the interactive open applies it: before the Camera Raw step. + crop, }; if tuning != (RawTuning { demosaic: opts.demosaic, ..Default::default() }) { super::open::raw_defaults(s, &imported.warnings, &mut imported.document); diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index d42d333128d..33b1f608752 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -734,6 +734,27 @@ fn batch_quiet_raw_applies_prior_settings_and_workflow_without_touching_inputs() assert!(s.batch_open_notices.is_none()); } +/// #9985: quiet RAW opening crops to the sidecar's crop, as the interactive open does. +#[test] +fn batch_quiet_raw_applies_the_sidecar_crop() { + use photocraft_raw::testgen::{DngSpec, mosaic, scene}; + let dir = tmp("batch-quiet-raw-crop"); + let path = join(&dir, "raw.dng"); + let (w, h) = (24, 16); + let bytes = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build(); + std::fs::write(&path, &bytes).unwrap(); + let crop = photocraft_io::camera_raw_xmp::Crop { top: 0.25, left: 0.125, bottom: 0.75, right: 0.625 }; + let xmp = photocraft_io::camera_raw_xmp::save_with_crop(&photocraft_algo::camera_raw::CameraRaw::default(), Some(crop)); + std::fs::write(join(&dir, "raw.xmp"), &xmp).unwrap(); + let mut s = Session::new(); + let output = join(&dir, "out"); + let p = json!({"input":[path],"output":output,"format":"psd","suppressOpenOptions":true,"suppressWarnings":true,"steps":[]}); + let result = s.execute("file.automate.batch", p).unwrap(); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + let actual = photocraft_io::import("raw.psd", &std::fs::read(join(&output, "raw.psd")).unwrap()).unwrap().document; + assert_eq!((actual.size.width, actual.size.height), (12, 8), "the 24x16 frame cropped to x 3..15, y 4..12"); +} + fn tmp(name: &str) -> String { static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index fa6a50e0a1a..9eff784a429 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -134,9 +134,21 @@ pub(crate) fn reidentify_copied_layers(doc: &mut Document) { } } -/// Scale every size and distance of a style by `k` (Image Size's Scale Styles, Layer Style › -/// Scale Effects, and previews that draw a style at a reduced resolution). +/// Scale every size and distance of a style by `k` (Image Size's Scale Styles and Layer Style › +/// Scale Effects), with pattern scales kept within the dialog's 1..1000 %. pub fn scale_effects(fx: &mut Effects, k: f32) { + scale_effects_with(fx, k, true); +} + +/// [`scale_effects`] for a preview that draws a style at a reduced resolution: pattern scales +/// follow `k` exactly, so the preview matches the style it stands for (a 2 % pattern drawn at a +/// quarter of the size is 0.5 %, not the dialog's 1 % minimum). +pub fn scale_effects_preview(fx: &mut Effects, k: f32) { + scale_effects_with(fx, k, false); +} + +fn scale_effects_with(fx: &mut Effects, k: f32, clamp: bool) { + let pattern = |scale: &mut f32| scale_pattern(scale, k, clamp); for e in &mut fx.items { match e { Effect::DropShadow(s) | Effect::InnerShadow(s) => { @@ -152,14 +164,17 @@ pub fn scale_effects(fx: &mut Effects, k: f32) { Effect::BevelEmboss(b) => { b.size *= k; b.soften *= k; + if let Some(t) = &mut b.texture { + pattern(&mut t.scale); + } } - Effect::PatternOverlay { scale, .. } => *scale *= k, + Effect::PatternOverlay { scale, .. } => pattern(scale), Effect::GradientOverlay { .. } | Effect::ColorOverlay { .. } => {} } if let Effect::Stroke(s) = e && let FxPaint::Pattern { scale, .. } = &mut s.paint { - *scale *= k; + pattern(scale); } } // Effects changed: the preserved PSD block no longer applies. @@ -168,6 +183,16 @@ pub fn scale_effects(fx: &mut Effects, k: f32) { } } +/// Scale a style's pattern scale; with `clamp`, kept within the 1..1000 % the Layer Style dialog +/// and the reference application allow (a 10 % pattern reduced to 5 % stays at 1 %). +fn scale_pattern(scale: &mut f32, k: f32, clamp: bool) { + let (min, max) = photocraft_doc::effects::PATTERN_SCALE_RANGE; + let v = *scale * k; + if !v.is_nan() { + *scale = if clamp { v.clamp(min, max) } else { v }; + } +} + /// Image Size's resampling methods; `None` changes the resolution only. #[derive(Clone, Copy)] enum Method { @@ -755,6 +780,7 @@ fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result let unlock = !delete && !content_aware && !keep_background; // Values beyond i32 would wrap through the narrowing casts into a rectangle unrelated to // the numbers passed; reject them (see `commands::int_i32`). + let given = ["x", "y", "width", "height"].into_iter().any(|k| p.get(k).is_some_and(|v| !v.is_null())); let explicit = match ( crate::commands::int_i32("image.crop", p, "x")?, crate::commands::int_i32("image.crop", p, "y")?, @@ -775,6 +801,14 @@ fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result }; Some(Rect::new(x, y, end(x, w, "x", "width")?, end(y, h, "y", "height")?)) } + // A rectangle that is passed but incomplete or empty is refused: falling back to the + // selection would crop somewhere the caller didn't ask for (#9279). + _ if given => { + return Err(bad( + "image.crop", + "`x`, `y`, `width` and `height` go together: pass all four as numbers of pixels, with `width` and `height` rounding to at least 1", + )); + } _ => None, }; let target = crop_target(p)?; @@ -1206,7 +1240,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,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; deleteCroppedPixels false: the pixels past the frame are kept and, unless contentAware, the Background becomes a normal layer, "Layer 0", so an extension is transparent on every layer (with contentAware the Background stays); contentAware: the Crop tool's Fill › Content-Aware Fill uses surrounding pixels to fill areas uncovered by a rotated or extending crop. The fill targets the Background, or the active pixel layer if there is no Background. With Delete Cropped Pixels on, it also targets a separate active pixel layer if it is visible and unlocked.)"##, + r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (x/y/width/height, all four or none: without them the selection's bounds are cropped to; 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; deleteCroppedPixels false: the pixels past the frame are kept and, unless contentAware, the Background becomes a normal layer, "Layer 0", so an extension is transparent on every layer (with contentAware the Background stays); contentAware: the Crop tool's Fill › Content-Aware Fill uses surrounding pixels to fill areas uncovered by a rotated or extending crop. The fill targets the Background, or the active pixel layer if there is no Background. With Delete Cropped Pixels on, it also targets a separate active pixel layer if it is visible and unlocked.)"##, has_doc, crop ), @@ -1222,7 +1256,7 @@ pub fn specs() -> Vec { "image.mode.rgb", "RGB Color", ["Image", "Mode"], - r##"{"profile":"|working|/path/to/profile.icc"=working,"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##, + r##"{"profile":"|working|/path/to/profile.icc"=working (Indexed→RGB preserves the document profile unless explicit),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##, has_doc, |s, p| convert_mode(s, ColorMode::Rgb, p) ), diff --git a/crates/engine/src/cmd/image_cmds/tests.rs b/crates/engine/src/cmd/image_cmds/tests.rs index 1e0fe5f2234..2bc3214c1d4 100644 --- a/crates/engine/src/cmd/image_cmds/tests.rs +++ b/crates/engine/src/cmd/image_cmds/tests.rs @@ -429,6 +429,101 @@ fn crop_to_explicit_rect_can_extend_canvas() { assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(20, 5), vec![1.0, 0.0, 0.0, 1.0]); } +/// A rectangle that is passed but invalid is refused, not replaced by the selection (#9279). +#[test] +fn crop_refuses_a_bad_explicit_rect_instead_of_cropping_to_the_selection() { + let bad = [ + json!({"x": 10, "y": 10, "width": 0, "height": 50}), + json!({"x": 10, "y": 10, "width": 20, "height": 0}), + json!({"x": 10, "y": 10, "width": -50, "height": 50}), + json!({"x": 10, "y": 10, "width": "100", "height": 50}), + json!({"x": 10, "y": 10, "width": true, "height": 50}), + json!({"width": 20, "height": 10}), + json!({"x": 1, "y": 1}), + json!({"x": 1, "y": null, "width": 20, "height": 10}), + json!({"height": 10}), + ]; + for p in bad { + for selection in [true, false] { + let mut s = session(); + if selection { + s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap(); + } + let (size, past) = (doc(&s).size, s.active().unwrap().history.past_len()); + let err = s.execute("image.crop", p.clone()).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{p} (selection: {selection}): {err:?}"); + assert_eq!(doc(&s).size, size, "{p} (selection: {selection})"); + assert_eq!(doc(&s).selection.is_some(), selection, "{p}: the selection stays"); + assert_eq!(s.active().unwrap().history.past_len(), past, "{p} (selection: {selection})"); + } + } + // No rectangle at all (or every side null) still crops to the selection. + for p in [json!({}), json!({"x": null, "y": null, "width": null, "height": null})] { + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap(); + s.execute("image.crop", p.clone()).unwrap(); + assert_eq!(doc(&s).size, Size::new(20, 10), "{p}"); + } + // Whole-number floats, as the Crop tool sends them, are a valid rectangle. + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap(); + s.execute("image.crop", json!({"x": 1.0, "y": 1.0, "width": 30.0, "height": 15.0})).unwrap(); + assert_eq!(doc(&s).size, Size::new(30, 15)); + // Fractional pixels round, like the rectangle's `x`/`y` and every other pixel param + // (`commands::int`); a side that rounds to 0 is refused. + let mut s = session(); + s.execute("image.crop", json!({"x": 0.4, "y": 0.6, "width": 20.6, "height": 9.5})).unwrap(); + assert_eq!(doc(&s).size, Size::new(21, 10)); + let err = s.execute("image.crop", json!({"x": 0, "y": 0, "width": 0.4, "height": 5})).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}"); + assert_eq!(doc(&s).size, Size::new(21, 10)); +} + +/// The explicit rectangle of #9279's edge cases, each with a selection elsewhere that a fallback +/// would crop to: `0` is a given origin, numeric strings are not numbers (refused, as `x`/`y` +/// already were), and a rectangle partly past the canvas is used as passed, never clamped. +#[test] +fn crop_rect_edge_cases_never_fall_back_to_the_selection() { + let with_selection = || { + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap(); + s + }; + // `x`/`y` of 0 count as given: the red square (10, 5)..(20, 15) stays where it was, where a + // crop to the selection would have moved it to (5, 3). + let mut s = with_selection(); + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 15, "height": 10})).unwrap(); + assert_eq!(doc(&s).size, Size::new(15, 10)); + assert_eq!(doc(&s).layers[1].surface().unwrap().content_bounds(), Rect::new(10, 5, 15, 10)); + // Numeric strings are refused on every side, with or without a selection. + for p in [ + json!({"x": "0", "y": "0", "width": "15", "height": "10"}), + json!({"x": "0", "y": 0, "width": 15, "height": 10}), + json!({"x": 0, "y": 0, "width": 15, "height": "10"}), + ] { + for mut s in [with_selection(), session()] { + let err = s.execute("image.crop", p.clone()).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{p}: {err:?}"); + assert_eq!(doc(&s).size, Size::new(40, 20), "{p}"); + } + } + // Partly outside the canvas: the canvas grows to the rectangle, not to its overlap with the + // canvas or the selection. + let mut s = with_selection(); + s.execute("image.crop", json!({"x": -10, "y": -5, "width": 30, "height": 40})).unwrap(); + assert_eq!(doc(&s).size, Size::new(30, 40)); + assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(20, 10), vec![1.0, 0.0, 0.0, 1.0]); + // Reveal All passes all four sides through the same check (here x = y = 0: the hidden pixels + // are past the right and bottom edges), so a selection doesn't change what it reveals. + let mut s = with_selection(); + s.execute("image.canvasSize", json!({"width": 12, "height": 8, "anchor": "topLeft"})).unwrap(); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 4, "height": 4})).unwrap(); + assert_eq!(s.execute("image.revealAll", json!({})).unwrap()["changed"], true); + let size = doc(&s).size; + assert!(size.width >= 20 && size.height >= 15, "{size:?}: the red square is revealed"); + assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(19, 14), vec![1.0, 0.0, 0.0, 1.0]); +} + #[test] fn crop_extension_fills_the_background_with_the_background_colour() { let mut shrink = Session::new(); diff --git a/crates/engine/src/cmd/layer_menu_cmds/tests.rs b/crates/engine/src/cmd/layer_menu_cmds/tests.rs index c967f869644..0dcd7c23338 100644 --- a/crates/engine/src/cmd/layer_menu_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_menu_cmds/tests.rs @@ -140,6 +140,119 @@ fn global_light_blending_options_scale_effects() { } } +#[test] +fn scale_effects_scales_the_bevel_texture_with_the_pattern_overlay() { + let mut s = session(8, "rgb"); + paint(&mut s, disc); + s.execute("layer.layerStyle.patternOverlay", json!({"scale": 100})).unwrap(); + s.execute("layer.layerStyle.bevelEmboss", json!({"texture": "Bubbles", "textureScale": 100})).unwrap(); + s.execute("layer.layerStyle.scaleEffects", json!({"scale": 50})).unwrap(); + let fx = &active(&s).effects.items; + let overlay = fx.iter().find_map(|e| match e { + Effect::PatternOverlay { scale, .. } => Some(*scale), + _ => None, + }); + let texture = fx.iter().find_map(|e| match e { + Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale), + _ => None, + }); + // Both patterns tile at half size, so a texture aligned with the overlay stays aligned. + assert_eq!((overlay, texture), (Some(0.5), Some(0.5))); +} + +/// The pattern scales of Pattern Overlay, the Bevel & Emboss Texture and a pattern Stroke, in +/// that order. +fn pattern_scales(s: &Session) -> (Option, Option, Option) { + let fx = &active(s).effects.items; + let overlay = fx.iter().find_map(|e| match e { + Effect::PatternOverlay { scale, .. } => Some(*scale), + _ => None, + }); + let texture = fx.iter().find_map(|e| match e { + Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale), + _ => None, + }); + let stroke = fx.iter().find_map(|e| match e { + Effect::Stroke(st) => match &st.paint { + photocraft_doc::FxPaint::Pattern { scale, .. } => Some(*scale), + _ => None, + }, + _ => None, + }); + (overlay, texture, stroke) +} + +/// A layer with a Pattern Overlay, a Bevel & Emboss Texture and a pattern Stroke, all at `pct` %. +fn pattern_styled(pct: f64) -> Session { + let mut s = session(8, "rgb"); + paint(&mut s, disc); + s.execute("layer.layerStyle.patternOverlay", json!({"scale": pct})).unwrap(); + s.execute("layer.layerStyle.bevelEmboss", json!({"texture": "Bubbles", "textureScale": pct})).unwrap(); + s.execute("layer.layerStyle.stroke", json!({"size": 3})).unwrap(); + s.edit("pattern stroke", |doc, active| { + let layer = doc.layer_mut(active.unwrap()).unwrap(); + for e in &mut layer.effects.items { + if let Effect::Stroke(st) = e { + st.paint = photocraft_doc::FxPaint::Pattern { name: "Bubbles".into(), id: String::new(), scale: pct as f32 / 100.0 }; + } + } + Ok(()) + }) + .unwrap(); + s +} + +#[test] +fn scale_effects_keeps_pattern_scales_within_the_dialog_range() { + // 10 % reduced to 1 % would be 0.1 %: the patterns stop at the dialog's 1 %. + let mut s = pattern_styled(10.0); + s.execute("layer.layerStyle.scaleEffects", json!({"scale": 1})).unwrap(); + assert_eq!(pattern_scales(&s), (Some(0.01), Some(0.01), Some(0.01))); + // 500 % enlarged to 1000 % would be 5000 %: they stop at 1000 %. + let mut s = pattern_styled(500.0); + s.execute("layer.layerStyle.scaleEffects", json!({"scale": 1000})).unwrap(); + assert_eq!(pattern_scales(&s), (Some(10.0), Some(10.0), Some(10.0))); + // Inside the range nothing is clamped: 200 % halved is 100 %. + let mut s = pattern_styled(200.0); + s.execute("layer.layerStyle.scaleEffects", json!({"scale": 50})).unwrap(); + assert_eq!(pattern_scales(&s), (Some(1.0), Some(1.0), Some(1.0))); +} + +/// Previews at a reduced resolution scale the patterns exactly, past the dialog's range: only +/// commands that store the style clamp them. +#[test] +fn preview_scaling_leaves_pattern_scales_unclamped() { + let s = pattern_styled(2.0); + let mut fx = active(&s).effects.clone(); + crate::image_cmds::scale_effects_preview(&mut fx, 0.25); + let mut clamped = active(&s).effects.clone(); + crate::image_cmds::scale_effects(&mut clamped, 0.25); + let scales = |fx: &photocraft_doc::Effects| { + fx.items + .iter() + .filter_map(|e| match e { + Effect::PatternOverlay { scale, .. } => Some(*scale), + Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale), + Effect::Stroke(st) => match &st.paint { + photocraft_doc::FxPaint::Pattern { scale, .. } => Some(*scale), + _ => None, + }, + _ => None, + }) + .collect::>() + }; + assert_eq!(scales(&fx), vec![0.005; 3]); + assert_eq!(scales(&clamped), vec![0.01; 3]); +} + +#[test] +fn image_size_scale_styles_keeps_pattern_scales_within_the_dialog_range() { + // 40x30 -> 400x300 with Scale Styles multiplies by 10: 500 % would become 5000 %. + let mut s = pattern_styled(500.0); + s.execute("image.imageSize", json!({"width": 400, "height": 300, "scaleStyles": true})).unwrap(); + assert_eq!(pattern_scales(&s), (Some(10.0), Some(10.0), Some(10.0))); +} + #[test] fn global_light_rejects_numbers_beyond_f32() { let mut s = session(8, "rgb"); diff --git a/crates/engine/src/cmd/layer_multi_cmds.rs b/crates/engine/src/cmd/layer_multi_cmds.rs index 4ca3074b132..5076d665ffe 100644 --- a/crates/engine/src/cmd/layer_multi_cmds.rs +++ b/crates/engine/src/cmd/layer_multi_cmds.rs @@ -101,7 +101,15 @@ pub fn specs() -> Vec { } Ok(Value::Null) }), - spec!("select.findLayers", "Find Layers", &["Select"], Some("Cmd+Alt+Shift+F"), r##"{"name":str (case-insensitive substring)}"##, has_doc, find_layers), + spec!( + "select.findLayers", + "Find Layers", + &["Select"], + Some("Cmd+Alt+Shift+F"), + r##"{"name":str?} (filters the Layers panel by Name, a case-insensitive substring, selecting none of the matches; without `name` the panel's Name text is kept; replies with the matching layers)"##, + has_doc, + find_layers + ), spec!("layer.selectLinkedLayers", "Select Linked Layers", &["Layer"], None, "{}", has_linked, |s, _| select_linked(s)), spec!("layer.align.topEdges", "Top Edges", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "topEdges")), spec!("layer.align.verticalCenters", "Vertical Centers", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "verticalCenters")), diff --git a/crates/engine/src/cmd/layer_multi_cmds/selection.rs b/crates/engine/src/cmd/layer_multi_cmds/selection.rs index cb3dad8c75a..378b356190b 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/selection.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/selection.rs @@ -336,16 +336,27 @@ pub(super) fn select_all_layers(s: &mut Session) -> Result { Ok(json!({"selected": n})) } +/// Select › Find Layers (#10884): as in Photoshop it filters the Layers panel by Name and leaves the +/// layer selection alone. The reply's `layerFilter` is the Name filter the app applies (`name` only +/// when one is given) and `selected` lists the layers it matches: part of the name, trimmed and +/// ignoring case, as the panel matches: every layer for a blank name, which the panel doesn't filter +/// by (none without a name). pub(super) fn find_layers(s: &mut Session, p: &Value) -> Result { - let needle = p.get("name").and_then(Value::as_str).ok_or_else(|| bad("select.findLayers", "missing `name`"))?.to_lowercase(); + let name = match p.get("name") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_str().ok_or_else(|| bad("select.findLayers", "`name` must be a string"))?), + }; let d = s.active().ok_or(EngineError::NoDocument)?; - let ids: Vec = d.doc.walk().into_iter().filter(|(_, _, l)| l.name.to_lowercase().contains(&needle)).map(|(_, _, l)| l.id).collect(); - if ids.is_empty() { - return Err(EngineError::Other(format!("no layer name contains \"{needle}\""))); + let needle = name.map(|n| n.trim().to_lowercase()); + let ids: Vec = match &needle { + Some(n) => d.doc.walk().into_iter().filter(|(_, _, l)| l.name.to_lowercase().contains(n.as_str())).map(|(_, _, l)| l.id.0).collect(), + None => Vec::new(), + }; + let mut filter = json!({"by": "name"}); + if let Some(n) = name { + filter["name"] = json!(n); } - let out = ids.iter().map(|l| l.0).collect::>(); - set_selection(s, ids, None, None)?; - Ok(json!({"selected": out})) + Ok(json!({"selected": ids, "layerFilter": filter})) } pub(super) fn select_linked(s: &mut Session) -> Result { diff --git a/crates/engine/src/cmd/layer_multi_cmds/tests.rs b/crates/engine/src/cmd/layer_multi_cmds/tests.rs index 0d27dab4a3b..fc39d25f5dc 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/tests.rs @@ -586,9 +586,23 @@ fn find_layers_by_name() { s.execute("layer.renameLayer", json!({"layer": a.0, "name": "Sky photo"})).unwrap(); s.execute("layer.renameLayer", json!({"layer": b.0, "name": "Photo of sea"})).unwrap(); assert_eq!(doc(&s).layer(a).unwrap().name, "Sky photo"); - let r = s.execute("select.findLayers", json!({"name": "PHOTO"})).unwrap(); - assert_eq!(r["selected"], json!([a.0, b.0])); - assert!(s.execute("select.findLayers", json!({"name": "nothing"})).is_err()); + // #10884: Find Layers filters the Layers panel by Name and leaves the selection alone. + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + let r = s.execute("select.findLayers", json!({"name": " PHOTO "})).unwrap(); + assert_eq!(r["selected"], json!([a.0, b.0]), "the matching layers, trimmed and ignoring case"); + assert_eq!(r["layerFilter"], json!({"by": "name", "name": " PHOTO "}), "the app sets the Name text as given"); + assert_eq!(s.active().unwrap().selected_layers(), [a], "the selection is unchanged"); + let r = s.execute("select.findLayers", json!({"name": "nothing"})).unwrap(); + assert_eq!(r["selected"], json!([]), "no match lists nothing and is no error"); + let all: Vec = doc(&s).walk().into_iter().map(|(_, _, l)| l.id.0).collect(); + for blank in ["", " "] { + let r = s.execute("select.findLayers", json!({"name": blank})).unwrap(); + assert_eq!(r["selected"], json!(all), "a blank name filters nothing, as in the panel: {blank:?}"); + } + let r = s.execute("select.findLayers", json!({})).unwrap(); + assert_eq!((&r["selected"], &r["layerFilter"]), (&json!([]), &json!({"by": "name"})), "without a name it only switches to Name"); + assert_eq!(s.active().unwrap().selected_layers(), [a]); + assert!(s.execute("select.findLayers", json!({"name": 3})).is_err()); assert!(s.execute("layer.renameLayer", json!({})).is_err()); } diff --git a/crates/engine/src/cmd/mode_cmds.rs b/crates/engine/src/cmd/mode_cmds.rs index ea6504f7a8c..2adde916859 100644 --- a/crates/engine/src/cmd/mode_cmds.rs +++ b/crates/engine/src/cmd/mode_cmds.rs @@ -9,6 +9,7 @@ use photocraft_algo::quantize::{self, BitmapMethod, Dither, Forced, HalftoneShape, PaletteKind}; use photocraft_algo::transform::{Homography, Interp}; +use photocraft_cms::{ColorSpace, TransformOptions}; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::adjust::CurvePoint; use photocraft_doc::{ColorTable, Document, Duotone, DuotoneInk, Layer, LayerContent, LayerId, Size}; @@ -255,6 +256,56 @@ fn parse_dither(s: &str) -> Option { }) } +/// Convert a tool colour from the working RGB space into the samples used by the +/// flattened document. RGB and Indexed documents keep their own document profile; +/// grayscale output is a scalar sample replicated into the flattened RGB view. +fn working_matte_color(s: &Session, doc: &Document, color: [f32; 4]) -> Result<[f32; 3]> { + let destination = match crate::color_cmds::mode_space(doc.mode) { + ColorSpace::Rgb | ColorSpace::Gray => crate::color_cmds::document_profile(doc), + // CMYK and Lab flatten through the compositor's sRGB view on the pinned main path. + _ => crate::color_cmds::composite_profile(doc), + }; + let source = s.color.working(ColorMode::Rgb); + let options = TransformOptions { intent: s.color.settings.intent(), bpc: s.color.settings.bpc, ..Default::default() }; + let transform = photocraft_cms::transform::cached(&source, &destination, options) + .map_err(|e| bad("image.mode.indexedColor", format!("cannot convert matte colour: {e}")))?; + let mut output = [0.0; 16]; + transform.eval(&color[..3], &mut output); + let matte = if destination.color_space == ColorSpace::Gray { [output[0]; 3] } else { [output[0], output[1], output[2]] }; + if matte.iter().any(|v| !v.is_finite()) { + return Err(bad("image.mode.indexedColor", "cannot convert matte colour to finite document samples")); + } + Ok(matte.map(|v| v.clamp(0.0, 1.0))) +} + +fn indexed_matte(s: &Session, p: &Value) -> Result> { + const CMD: &str = "image.mode.indexedColor"; + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let matte = match p.get("matte") { + None | Some(Value::Null) => return Ok(None), + Some(Value::String(value)) => match value.as_str() { + "none" => return Ok(None), + "white" => [1.0; 3], + "black" => [0.0; 3], + "gray" => [128.0 / 255.0; 3], + "netscapeGray" => [192.0 / 255.0; 3], + "foreground" => working_matte_color(s, doc, s.tools.foreground)?, + "background" => working_matte_color(s, doc, s.tools.background)?, + value if value.len() == 7 && value.starts_with('#') => p + .get("matte") + .and_then(parse_color) + .ok_or_else(|| bad(CMD, "matte must be none, foreground, background, white, black, gray, netscapeGray or #rrggbb"))? + .map(|v| f32::from(v) / 255.0), + _ => return Err(bad(CMD, "matte must be none, foreground, background, white, black, gray, netscapeGray or #rrggbb")), + }, + _ => return Err(bad(CMD, "matte must be a colour string")), + }; + if matte.iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) { + return Err(bad(CMD, "matte channels must be finite and in 0..=1")); + } + Ok(Some(matte)) +} + fn indexed_color(s: &mut Session, p: &Value) -> Result { const CMD: &str = "image.mode.indexedColor"; let kind = PaletteKind::from_id(str_or(p, "palette", "selective")) @@ -273,8 +324,23 @@ fn indexed_color(s: &mut Session, p: &Value) -> Result { }; let amount = num(p, "amount", 75.0).clamp(0.0, 100.0) / 100.0; let transparency = p.get("transparency").and_then(Value::as_bool).unwrap_or(true); + let matte = indexed_matte(s, p)?; let (pal_len, transparent) = s.edit("Indexed Color", |doc, active| { - let mut px = composite(doc, !transparency); + let mut px = composite(doc, !transparency && matte.is_none()); + if let Some(matte) = matte { + for pixel in &mut px { + let alpha = pixel[3].clamp(0.0, 1.0); + if alpha == 1.0 { + continue; + } + if !transparency || alpha > 0.0 { + for (channel, background) in pixel[..3].iter_mut().zip(matte) { + *channel = *channel * alpha + background * (1.0 - alpha); + } + pixel[3] = 1.0; + } + } + } let has_clear = transparency && px.iter().any(|q| q[3] < 0.5); let mut pal = quantize::build_palette(&px, kind, if has_clear { colors.saturating_sub(1).max(2) } else { colors }, forced).map_err(|e| bad(CMD, e))?; let transparent = if has_clear && pal.len() < 256 { @@ -689,7 +755,7 @@ pub fn specs() -> Vec { "image.mode.indexedColor", "Indexed Color…", ["Image", "Mode"], - r##"{"palette":"selective|perceptual|adaptive|exact|systemMac|systemWindows|web|uniform"="selective","colors":2..256=256,"forced":"blackWhite|none|primaries|web"="blackWhite","transparency":bool=true,"dither":"diffusion|none|pattern|noise"="diffusion","amount":0..100=75} (flattens)"##, + r##"{"palette":"selective|perceptual|adaptive|exact|systemMac|systemWindows|web|uniform"="selective","colors":2..256=256,"forced":"blackWhite|none|primaries|web"="blackWhite","transparency":bool=true,"matte":color,"dither":"diffusion|none|pattern|noise"="diffusion","amount":0..100=75} (flattens; matte: none|foreground|background|white|black|gray|netscapeGray|#rrggbb; gray=50% Gray (#808080), netscapeGray=Netscape Gray (#c0c0c0))"##, |s| mode_is( s, &[ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Indexed, ColorMode::Duotone, ColorMode::Cmyk, ColorMode::Lab], diff --git a/crates/engine/src/cmd/mode_cmds/tests.rs b/crates/engine/src/cmd/mode_cmds/tests.rs index 8f190f445cb..e5dc8aac33d 100644 --- a/crates/engine/src/cmd/mode_cmds/tests.rs +++ b/crates/engine/src/cmd/mode_cmds/tests.rs @@ -247,6 +247,160 @@ fn indexed_color_palettes_and_dithers() { } } +#[test] +fn indexed_exact_preserves_profile_and_samples_through_default_rgb() { + for depth in [8, 16, 32] { + for profile in ["adobe-rgb-compat", "display-p3", "prophoto-compat", "none"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1, "height": 1, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.assignProfile", json!({"profile": profile})).unwrap(); + paint(&mut s, |_, _| [100.0 / 255.0, 150.0 / 255.0, 200.0 / 255.0, 1.0]); + let source_profile = doc(&s).icc_profile.clone(); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "transparency": false})).unwrap(); + assert_eq!(doc(&s).icc_profile, source_profile, "{profile} at {depth}-bit: Indexed profile"); + assert_eq!(doc(&s).color_table.as_ref().unwrap().colors, vec![[100, 150, 200]]); + let indexed_pixel = doc(&s).layers[0].surface().unwrap().rgba(0, 0); + s.execute("image.mode.rgb", json!({})).unwrap(); + assert_eq!(doc(&s).mode, ColorMode::Rgb); + assert_eq!(doc(&s).icc_profile, source_profile, "{profile} at {depth}-bit: RGB profile"); + assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(0, 0), indexed_pixel); + assert!(doc(&s).color_table.is_none()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(doc(&s).mode, ColorMode::Indexed); + assert_eq!(doc(&s).icc_profile, source_profile); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(doc(&s).mode, ColorMode::Rgb); + assert_eq!(doc(&s).icc_profile, source_profile); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("image.mode.rgb", json!({"profile": "srgb"})).unwrap(); + assert_eq!(doc(&s).icc_profile, Some(photocraft_cms::Builtin::Srgb.profile().to_bytes()), "an explicit target still converts"); + if profile != "none" { + assert_ne!(doc(&s).layers[0].surface().unwrap().rgba(0, 0), indexed_pixel); + } + } + } +} + +#[test] +fn indexed_matte_blends_edges_without_replacing_clear_pixels() { + for depth in [8, 16, 32] { + for (matte, color) in [ + ("black", [0.0, 0.0, 0.0]), + ("white", [1.0, 1.0, 1.0]), + ("#0000ff", [0.0, 0.0, 1.0]), + ("foreground", [0.0, 0.0, 1.0]), + ("background", [0.0, 1.0, 0.0]), + ] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 1, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#0000ff", "background": "#00ff00"})).unwrap(); + paint(&mut s, |x, _| match x { + 0 => [0.0, 1.0, 0.0, 1.0], + 1 => [1.0, 0.0, 0.0, 0.75], + 2 => [1.0, 0.0, 0.0, 0.25], + _ => [0.0; 4], + }); + let source = [1, 2].map(|x| doc(&s).layers[0].surface().unwrap().rgba(x, 0)); + let expected = source.map(|pixel| [0, 1, 2].map(|i| pixel[i] * pixel[3] + color[i] * (1.0 - pixel[3]))); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": matte})).unwrap(); + let surface = doc(&s).layers[0].surface().unwrap(); + let edges = [1, 2].map(|x| surface.rgba(x, 0)); + for (edge, expected) in edges.iter().zip(expected) { + for i in 0..3 { + assert!((edge[i] - expected[i]).abs() <= 1.0 / 255.0, "{depth}-bit {matte}: {edge:?} vs {expected:?}"); + } + assert_eq!(edge[3], 1.0); + } + assert_eq!(surface.rgba(3, 0)[3], 0.0, "{matte}: fully transparent pixels stay clear"); + assert_eq!(surface.rgba(0, 0), [0.0, 1.0, 0.0, 1.0]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!([1, 2].map(|x| doc(&s).layers[0].surface().unwrap().rgba(x, 0)), source); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!([1, 2].map(|x| doc(&s).layers[0].surface().unwrap().rgba(x, 0)), edges); + } + } +} + +#[test] +fn indexed_matte_converts_working_rgb_to_document_profile_at_every_depth() { + use photocraft_cms::Transform; + + for depth in [8, 16, 32] { + for (matte, color) in [("foreground", [0.0, 1.0, 0.0]), ("background", [1.0, 0.0, 0.0])] { + for (intent, bpc) in [("relative", false), ("absolute", true)] { + let mut s = Session::new(); + s.execute("edit.colorSettings", json!({"workingRgb": "display-p3", "intent": intent, "bpc": bpc})).unwrap(); + s.execute("file.new", json!({"width": 2, "height": 1, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#00ff00", "background": "#ff0000"})).unwrap(); + paint(&mut s, |x, _| if x == 0 { [1.0, 0.0, 0.0, 0.5] } else { [0.0; 4] }); + let pixel = doc(&s).layers[0].surface().unwrap().rgba(0, 0); + let profile = doc(&s).icc_profile.clone(); + let source = s.color.working(ColorMode::Rgb); + let destination = crate::color_cmds::document_profile(doc(&s)); + let transform = Transform::new(&source, &destination, s.color.settings.intent(), bpc).unwrap(); + let mut converted = [0.0; 16]; + transform.eval(&color, &mut converted); + let expected = [0, 1, 2].map(|i| ((pixel[i] * pixel[3] + converted[i].clamp(0.0, 1.0) * (1.0 - pixel[3])) * 255.0).round() as u8); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": matte})).unwrap(); + let edge = doc(&s).layers[0].surface().unwrap().rgba(0, 0); + assert_eq!([edge[0], edge[1], edge[2]].map(|v| (v * 255.0).round() as u8), expected, "{depth}-bit {matte} {intent}, bpc={bpc}"); + assert_eq!(edge[3], 1.0); + assert_eq!(doc(&s).icc_profile, profile); + assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(1, 0)[3], 0.0); + } + } + } +} + +#[test] +fn indexed_matte_hex_stays_in_document_rgb_coordinates() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1, "height": 1, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap(); + paint(&mut s, |_, _| [1.0, 0.0, 0.0, 0.5]); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": "#3366cc"})).unwrap(); + let edge = doc(&s).layers[0].surface().unwrap().rgba(0, 0); + let expected = [0.5 + 0.5 * 0.2, 0.5 * 0.4, 0.5 * 0.8]; + for (got, want) in edge[..3].iter().zip(expected) { + assert!((got - want).abs() <= 2.0 / 255.0, "{depth}-bit: {edge:?} vs {expected:?}"); + } + assert_eq!(edge[3], 1.0); + } +} + +#[test] +fn indexed_matte_named_gray_choices_use_their_document_samples() { + for (matte, channel) in [("gray", 128.0 / 255.0), ("netscapeGray", 192.0 / 255.0)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1, "height": 1, "depth": 32, "background": "transparent"})).unwrap(); + paint(&mut s, |_, _| [1.0, 0.0, 0.0, 0.5]); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": matte})).unwrap(); + let edge = doc(&s).layers[0].surface().unwrap().rgba(0, 0); + assert!((edge[0] - (0.5 + channel * 0.5)).abs() <= 1.0 / 255.0, "{matte}: {edge:?}"); + for value in edge[1..3].iter() { + assert!((*value - channel * 0.5).abs() <= 2.0 / 255.0, "{matte}: {edge:?}"); + } + assert_eq!(edge[3], 1.0); + } +} + +#[test] +fn indexed_matte_without_transparency_fills_clear_pixels_and_rejects_bad_input_atomically() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 1, "background": "transparent"})).unwrap(); + let before = s.active().unwrap().revision; + for matte in [json!(false), json!(12), json!("#gg0000"), json!("#000000ff"), json!("unknown")] { + assert!(s.execute("image.mode.indexedColor", json!({"matte": matte})).is_err(), "{matte}"); + assert_eq!(s.active().unwrap().revision, before); + assert_eq!(doc(&s).mode, ColorMode::Rgb); + } + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "transparency": false, "matte": "#0000ff"})).unwrap(); + assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(0, 0), [0.0, 0.0, 1.0, 1.0]); + assert_eq!(doc(&s).color_table.as_ref().unwrap().transparent, None); +} + #[test] fn indexed_exact_transparency_and_sources() { // Two colours + transparency: exact palette with a transparent entry. diff --git a/crates/engine/src/cmd/notes_cmds.rs b/crates/engine/src/cmd/notes_cmds.rs index 2f589f3390a..133417649d2 100644 --- a/crates/engine/src/cmd/notes_cmds.rs +++ b/crates/engine/src/cmd/notes_cmds.rs @@ -22,14 +22,33 @@ fn has_notes(s: &Session) -> std::result::Result<(), String> { if s.active().is_some_and(|d| !d.doc.notes.is_empty()) { Ok(()) } else { Err("the document has no notes".into()) } } -/// PDF-style date (`D:YYYYMMDDhhmmssZ`) as Photoshop stores in notes; empty without a clock. +/// Photoshop's default note colour (#11795): RGB 255, 255, 131 (stored as 16-bit 0x8383 blue). +pub const DEFAULT_NOTE_COLOR: [f32; 3] = [1.0, 1.0, 131.0 / 255.0]; + +/// The PDF date (`D:YYYYMMDDHHMMSS+HH'mm'`) Photoshop stores in a note: local time with its +/// offset from UTC (#11795), through chrono. Empty without a clock (the web build). fn pdf_date() -> String { - let iso = crate::analysis_cmds::now_iso(); - if iso.len() < 19 { - return String::new(); + #[cfg(not(target_arch = "wasm32"))] + { + let now = chrono::Local::now(); + pdf_date_at(&now.with_timezone(now.offset())) } - let digits: String = iso[..19].chars().filter(char::is_ascii_digit).collect(); - format!("D:{digits}Z") + #[cfg(target_arch = "wasm32")] + { + String::new() + } +} + +/// [`pdf_date`] for the moment `at`: its own wall-clock time and UTC offset. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) fn pdf_date_at(at: &chrono::DateTime) -> String +where + Tz::Offset: std::fmt::Display, +{ + use chrono::Offset; + let off = at.offset().fix().local_minus_utc(); + let (sign, off) = if off < 0 { ('-', -off) } else { ('+', off) }; + format!("D:{}{sign}{:02}'{:02}'", at.format("%Y%m%d%H%M%S"), off / 3600, off % 3600 / 60) } pub fn note_json(i: usize, n: &Note) -> Value { @@ -44,7 +63,8 @@ fn color_of(cmd: &str, p: &Value) -> Result> { if p.get("color").is_none() { return Ok(None); } - let c = crate::commands::color_param(cmd, p, "color", [1.0, 1.0, 0.51, 1.0])?; + let [r, g, b] = DEFAULT_NOTE_COLOR; + let c = crate::commands::color_param(cmd, p, "color", [r, g, b, 1.0])?; Ok(Some(Color::rgb(c[0], c[1], c[2]))) } @@ -65,10 +85,11 @@ fn add(s: &mut Session, p: &Value) -> Result { author: p.get("author").and_then(Value::as_str).unwrap_or("").to_string(), text: p.get("text").and_then(Value::as_str).unwrap_or("").to_string(), position: [x, y], - popup: [x + 20.0, y + 20.0, x + 260.0, y + 160.0], + // Photoshop's popup: 240 × 140, at the icon's top-left + (8, 10) (#11795). + popup: [x + 8.0, y + 10.0, x + 248.0, y + 150.0], + color: Color::rgb(DEFAULT_NOTE_COLOR[0], DEFAULT_NOTE_COLOR[1], DEFAULT_NOTE_COLOR[2]), open: p.get("open").and_then(Value::as_bool).unwrap_or(true), modified: pdf_date(), - ..Default::default() }; if let Some(c) = color_of(CMD, p)? { n.color = c; @@ -270,7 +291,8 @@ mod tests { let d = s.active().unwrap().doc.clone(); assert_eq!(d.notes[0].text, "fixed"); assert_eq!(d.notes[0].position, [20.0, 12.0]); - assert_eq!(d.notes[0].popup[0], 40.0); + // The popup travels with its icon: 20 + 8 (#11795). + assert_eq!(d.notes[0].popup[0], 28.0); // Unchanged edits don't add history. let before = s.active().unwrap().history.past_len(); assert_eq!(s.execute("notes.set", json!({"index": 0, "text": "fixed"})).unwrap()["changed"], false); diff --git a/crates/engine/src/cmd/notes_stored_values_tests.rs b/crates/engine/src/cmd/notes_stored_values_tests.rs new file mode 100644 index 00000000000..1db8e5681bb --- /dev/null +++ b/crates/engine/src/cmd/notes_stored_values_tests.rs @@ -0,0 +1,116 @@ +//! #11795: what a new note stores, as in Photoshop: colour 255, 255, 131, a 240 × 140 popup at the +//! icon's top-left + (8, 10), and a local-time PDF date with its offset. Document operations move +//! the popup with its icon and keep its size. + +use chrono::{FixedOffset, TimeZone}; +use serde_json::json; + +use super::notes_cmds::{DEFAULT_NOTE_COLOR, pdf_date_at}; +use crate::Session; + +fn session(w: u32, h: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "background": "white"})).unwrap(); + s +} + +fn note(s: &Session, i: usize) -> photocraft_doc::Note { + s.active().unwrap().doc.notes[i].clone() +} + +#[test] +fn a_new_note_has_the_default_colour_and_popup() { + let mut s = session(600, 400); + s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap(); + let n = note(&s, 0); + let rgb = n.color.to_rgb(); + assert_eq!(rgb.map(|v| (v * 255.0).round() as i32), [255, 255, 131], "{rgb:?}"); + assert_eq!(DEFAULT_NOTE_COLOR.map(|v| (v * 65535.0).round() as u32), [0xffff, 0xffff, 0x8383], "the 16-bit components Photoshop saves"); + assert_eq!(n.popup, [108.0, 110.0, 348.0, 250.0], "icon + (8, 10), 240 × 140"); + // A note on the pasteboard keeps its popup there too. + s.execute("notes.add", json!({"x": -178, "y": 300})).unwrap(); + assert_eq!(note(&s, 1).popup, [-170.0, 310.0, 70.0, 450.0]); + // An explicit colour still wins. + s.execute("notes.add", json!({"x": 10, "y": 10, "color": [0.25, 0.5, 1.0]})).unwrap(); + assert_eq!(note(&s, 2).color.to_rgb(), [0.25, 0.5, 1.0]); +} + +#[test] +fn the_date_is_local_time_with_its_offset() { + let at = |offset: i32| FixedOffset::east_opt(offset).unwrap().with_ymd_and_hms(2026, 11, 6, 20, 14, 55).unwrap(); + assert_eq!(pdf_date_at(&at(3600)), "D:20261106201455+01'00'"); + assert_eq!(pdf_date_at(&at(0)), "D:20261106201455+00'00'"); + assert_eq!(pdf_date_at(&at(-(5 * 3600 + 30 * 60))), "D:20261106201455-05'30'"); + // A new note's own date has that shape. + let mut s = session(100, 100); + s.execute("notes.add", json!({"x": 10, "y": 10})).unwrap(); + let d = note(&s, 0).modified; + let b = d.as_bytes(); + assert!(d.len() == 23 && d.starts_with("D:") && matches!(b[16], b'+' | b'-') && b[19] == b'\'' && b[22] == b'\'', "{d}"); + assert!(d[2..16].chars().all(|c| c.is_ascii_digit()), "{d}"); +} + +#[test] +fn image_size_moves_the_icon_and_keeps_the_popup_size() { + let mut s = session(600, 400); + s.execute("notes.add", json!({"x": 100, "y": 99})).unwrap(); + let before = note(&s, 0); + s.execute("image.imageSize", json!({"width": 300, "height": 200})).unwrap(); + let n = note(&s, 0); + assert_eq!(n.position, [50.0, 49.5], "the icon point scales as today"); + let size = |p: [f64; 4]| [p[2] - p[0], p[3] - p[1]]; + assert_eq!(size(n.popup), size(before.popup), "240 × 140 still"); + assert_eq!([n.popup[0] - n.position[0], n.popup[1] - n.position[1]], [8.0, 10.0], "still at icon + (8, 10)"); +} + +#[test] +fn canvas_size_and_a_quarter_turn_keep_the_popup_size() { + let mut s = session(600, 400); + s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap(); + s.execute("image.canvasSize", json!({"width": 400, "height": 300, "anchor": "center"})).unwrap(); + let n = note(&s, 0); + assert_eq!(n.position, [0.0, 50.0], "moved by the canvas offset"); + assert_eq!(n.popup, [8.0, 60.0, 248.0, 200.0]); + s.execute("image.imageRotation.90cw", json!({})).unwrap(); + let r = note(&s, 0); + assert_eq!([r.popup[2] - r.popup[0], r.popup[3] - r.popup[1]], [240.0, 140.0], "a turn doesn't swap the popup's sides"); +} + +#[test] +fn a_mode_change_leaves_notes_alone() { + let mut s = session(100, 100); + s.execute("notes.add", json!({"x": 10, "y": 20})).unwrap(); + let before = note(&s, 0); + s.execute("image.mode.cmyk", json!({})).unwrap(); + assert_eq!(note(&s, 0), before); +} + +#[test] +fn a_turn_keeps_todays_popup_origin_and_the_popup_size() { + // Where the popup sits after a turn wasn't measured (#11795), so it keeps today's origin (the + // top-left of the turned rectangle) and only the size rule applies. + let mut s = session(600, 400); + s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap(); + // The popup [108, 110, 348, 250] turned 90° clockwise on a 400 px tall canvas spans x 150..290, + // y 108..348; it starts there and stays 240 × 140. + s.execute("image.imageRotation.90cw", json!({})).unwrap(); + assert_eq!(note(&s, 0).popup, [150.0, 108.0, 390.0, 248.0]); + // A flip keeps today's mirrored popup, which already has its size. + let mut s = session(600, 400); + s.execute("notes.add", json!({"x": 100, "y": 100})).unwrap(); + s.execute("image.imageRotation.flipCanvasHorizontal", json!({})).unwrap(); + assert_eq!(note(&s, 0).popup, [252.0, 110.0, 492.0, 250.0]); +} + +#[test] +fn an_unset_popup_stays_unset_under_image_size() { + // An all-zero popup means "none stored": PSD export writes its default popup for it, so Image + // Size must not turn it into a 0 × 0 rectangle somewhere else. + let mut s = session(600, 400); + let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc); + doc.notes.push(photocraft_doc::Note { position: [100.0, 100.0], ..Default::default() }); + s.execute("image.imageSize", json!({"width": 300, "height": 200})).unwrap(); + let n = note(&s, 0); + assert_eq!(n.position, [50.0, 50.0]); + assert_eq!(n.popup, [0.0; 4]); +} diff --git a/crates/engine/src/cmd/preset_import_cmds.rs b/crates/engine/src/cmd/preset_import_cmds.rs index 3adbee6f06b..4430f4d1d2f 100644 --- a/crates/engine/src/cmd/preset_import_cmds.rs +++ b/crates/engine/src/cmd/preset_import_cmds.rs @@ -176,6 +176,9 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io && let Some(pr) = photocraft_paint::presets::find(&s.tools.presets, first) { let mut b = pr.brush.clone().picked_over(&s.tools.brush); + // Opacity takes 1-100 %, as `tools.setBrush` floors it: a preset saved at 0 % would show + // 1 % but paint nothing (#11812). + b.opacity = b.opacity.max(0.01); s.load_brush_tips(&mut b).map_err(|e| bad("brush.presets.importAbr", e))?; s.tools.brush = b; s.tools.brush_changed(); @@ -477,6 +480,24 @@ mod tests { .unwrap() } + /// #11812: selecting an imported brush whose tool options hold 0 % opacity floors it at 1 %, + /// the value its options-bar field shows, so it paints instead of laying down nothing. + #[test] + fn selecting_an_imported_zero_opacity_brush_floors_it_at_one_percent() { + use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value as D}; + let preset = |opacity: i32| { + Descriptor::new("brushPreset") + .with("Nm ", D::Text(UnicodeString::new_nul("Faint"))) + .with("toolOptions", D::Descriptor(Descriptor::new("currentToolOptions").with("Opct", D::Integer(opacity)))) + }; + for (opacity, expected) in [(0, 0.01), (37, 0.37)] { + let abr = photocraft_psd::abr::write_v6_folders(2, &[], &[], &[preset(opacity)], &[vec![]], true).unwrap(); + let mut s = Session::new(); + s.execute("brush.presets.importAbr", json!({"data": photocraft_paint::tile::b64_encode(&abr), "select": true})).unwrap(); + assert!((s.tools.brush.opacity - expected).abs() < 1e-6, "{opacity}: {}", s.tools.brush.opacity); + } + } + #[test] fn import_adds_a_group_and_paints() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/retouch_cmds.rs b/crates/engine/src/cmd/retouch_cmds.rs index 0746ca7440c..115b8c40f91 100644 --- a/crates/engine/src/cmd/retouch_cmds.rs +++ b/crates/engine/src/cmd/retouch_cmds.rs @@ -260,7 +260,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: brush_params!( - r#","source":[sx,sy] | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|…"="normal" → {"damage","offset","aligned","nextSource"}"# + r#","source":[sx,sy] | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|…"="normal","wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges) → {"damage","offset","aligned","nextSource"}"# ), enabled: has_pixel_layer, run: healing_brush, @@ -397,3 +397,6 @@ pub fn specs() -> Vec { #[cfg(test)] mod tests; + +#[cfg(test)] +mod patch_history_names_tests; diff --git a/crates/engine/src/cmd/retouch_cmds/clone.rs b/crates/engine/src/cmd/retouch_cmds/clone.rs index 6a8a6e2b059..66cf2763329 100644 --- a/crates/engine/src/cmd/retouch_cmds/clone.rs +++ b/crates/engine/src/cmd/retouch_cmds/clone.rs @@ -130,26 +130,19 @@ pub(super) fn clone_stamp(s: &mut Session, p: &Value) -> Result { let from_live = live.is_some(); let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); - let dmg = - run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| { - if let Some(l) = live { - *surf = l.surface; - return Ok(l.bounds); - } - if flag(p, "wrap", false) { - let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0); - renderer.push(&stroke.points); - renderer.finish(); - let before = surf.clone(); - let bounds = renderer.bounds(); - return periodic::Composite { target: surf, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection: sel, lock, mode } - .draw(bounds, &stroke.brush, &stroke.points, |r| clone_sample(pre, id, &before, which, r, &map, true, CMD)); - } - let (bounds, cov) = coverage(CMD, &stroke)?; - // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. - let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?; - Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) - })?; + let dmg = run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| { + if let Some(l) = live { + *surf = l.surface; + return Ok(l.bounds); + } + if flag(p, "wrap", false) { + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, CMD, |before, r| clone_sample(pre, id, before, which, r, &map, true, CMD)); + } + let (bounds, cov) = coverage(CMD, &stroke)?; + // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. + let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?; + Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) + })?; let mut r = clone_result(dmg, off, aligned, p, &stroke); if from_live { r["fromLive"] = json!(true); @@ -272,11 +265,14 @@ impl LiveRetouch { /// Everything the stroke has touched so far. pub fn bounds(&self) -> Rect { let bounds = self.renderer.bounds().union(&self.tail); - if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) { - periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b)) - } else { - bounds - } + if self.wraps() { periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b)) } else { bounds } + } + + /// Whether the stroke repeats across the canvas edges (View › Pattern Preview). Only the Clone + /// Stamp and the Healing Brush do: the reference application's other retouching tools stop + /// at the edge. + fn wraps(&self) -> bool { + matches!(self.cmd.as_str(), "paint.cloneStamp" | "paint.healingBrush") && flag(&self.params, "wrap", false) } /// Every point rendered so far, in order. @@ -328,7 +324,7 @@ impl LiveRetouch { let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect(); self.points.extend_from_slice(&pts); self.renderer.push(&pts); - if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) { + if self.wraps() { return self.push_periodic(); } let old = std::mem::replace(&mut self.tail, Rect::EMPTY); @@ -360,6 +356,8 @@ impl LiveRetouch { let LivePaint::Clone { which, map, cmd } = &self.paint else { return Err(bad(&self.cmd, "periodic painting requires a clone source")); }; + let (pre, id, pre_surf, live_cmd) = (&self.pre, self.id, &self.pre_surf, self.cmd.as_str()); + let paint = |r| clone_sample(pre, id, pre_surf, *which, r, map, true, cmd); let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; periodic::Composite { target: surf, @@ -369,8 +367,9 @@ impl LiveRetouch { selection: self.sel.as_ref(), lock: self.lock, mode: self.mode, + cmd: live_cmd, } - .draw(dirty, &self.brush, &self.points, |r| clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, true, cmd)) + .draw(dirty, &self.brush, &self.points, paint) } /// Redraw `r` from the pre-stroke pixels with the clone source through coverage `cov`. diff --git a/crates/engine/src/cmd/retouch_cmds/heal.rs b/crates/engine/src/cmd/retouch_cmds/heal.rs index a260443caf0..b26decfd69d 100644 --- a/crates/engine/src/cmd/retouch_cmds/heal.rs +++ b/crates/engine/src/cmd/retouch_cmds/heal.rs @@ -1,4 +1,5 @@ use super::clone::{blend_param, clamp_samples, clone_mapping, clone_result, clone_sample}; +use super::periodic; use super::sampling::{SampleLayers, composite_region, sample_all_layers, sample_layers}; use super::{ BlendMode, CompleteParams, Document, EngineError, LayerId, PixelFormat, Rect, Region, Result, Session, Stroke, Surface, Value, apply_coverage, bad, @@ -53,15 +54,30 @@ pub(super) fn healing_brush(s: &mut Session, p: &Value) -> Result { let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); let dmg = run_stroke(s, "Healing Brush", id, p, |pre, surf, sel, lock| { + // Pattern Preview: source and destination repeat across the canvas edges, so a stroke over + // a seam heals as one patch and goes on wherever it repeats. + let wrap = flag(p, "wrap", false); let (bounds, cov) = coverage(CMD, &stroke)?; let (g, cov) = pad_coverage(bounds, &cov, 2); let fmt = surf.format(); - let src = clone_sample(pre, id, surf, which, g, &map, false, CMD)?; + let src = clone_sample(pre, id, surf, which, g, &map, wrap, CMD)?; // The destination the texture is fitted to: the layer itself, or what the user sees when // sampling several layers (so healing onto an empty layer matches the composite). - let dst = if which == SampleLayers::Current { Region::read(surf, g) } else { composite_region(pre, id, which, g, fmt) }; + let dst = if wrap { + periodic::sample(pre, id, surf, which, g, CMD)? + } else if which == SampleLayers::Current { + Region::read(surf, g) + } else { + composite_region(pre, id, which, g, fmt) + }; let mask: Vec = cov.iter().map(|c| *c > 0.0).collect(); let healed = heal_region(&fmt, &src, &dst, &mask); + if wrap { + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, CMD, |_, r| { + let part = healed.crop(r); + if part.rect == r { Ok(part) } else { Err(bad(CMD, "the healed patch does not cover the stroke")) } + }); + } Ok(apply_coverage(surf, g, &cov, stroke.brush.opacity, sel, lock, &healed, mode)) })?; Ok(clone_result(dmg, off, aligned, p, &stroke)) diff --git a/crates/engine/src/cmd/retouch_cmds/patch.rs b/crates/engine/src/cmd/retouch_cmds/patch.rs index 1cfcd3bfca4..1f1c6b023ee 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch.rs @@ -23,6 +23,8 @@ use super::{ }; const CMD: &str = "paint.patch"; +/// The History step of applying a patch, in every mode. +const HISTORY_NAME: &str = "Patch Selection"; /// Largest patch, in pixels of the selection's bounding box. The solve holds the two regions, the /// result and per-channel float planes of it (on the order of 150 bytes per pixel with four @@ -151,6 +153,7 @@ struct Plan { /// Shift of the repaired area from the selection, and of the texture from the repaired area. dst_shift: (i32, i32), src_shift: (i32, i32), + /// Destination mode: a hard rectangular selection moves with the patch. destination: bool, offset: (i32, i32), } @@ -211,16 +214,63 @@ fn patch_surface(surf: &mut Surface, sel: &Surface, plan: &Plan, lock: bool, ste apply_coverage(surf, g, &cov, 1.0, None, lock, &healed, BlendMode::Normal) } +/// Rows scanned per band by [`is_hard_rect_within`], so the coverage buffer stays small. +const RECT_BAND_ROWS: i32 = 64; + +/// Whether `sel` is a hard-edged rectangle: nothing selected outside its own `bounds` and every pixel +/// inside fully selected (the only Destination selection measured to move with the patch). The +/// bounds may reach past the canvas; the patched area is then only the part on the canvas. Bounds +/// of more than [`MAX_PIXELS`] pixels (the patch's own cap) are never classified as a hard +/// rectangle, so a huge selection moved mostly off the canvas is not scanned and stays in place. +fn is_hard_rect(sel: &Surface, bounds: Rect) -> bool { + is_hard_rect_within(sel, bounds, MAX_PIXELS) +} + +/// [`is_hard_rect`] with the pixel `limit` given. Scans `bounds` in bands of [`RECT_BAND_ROWS`] rows +/// and stops at the first pixel that is not fully selected. +fn is_hard_rect_within(sel: &Surface, bounds: Rect, limit: u64) -> bool { + let (w, h) = ((i64::from(bounds.x1) - i64::from(bounds.x0)).max(0) as u64, (i64::from(bounds.y1) - i64::from(bounds.y0)).max(0) as u64); + if w.saturating_mul(h) > limit || sel.default_pixel().first().copied().unwrap_or(0.0) > 0.0 { + return false; + } + let mut y = bounds.y0; + while y < bounds.y1 { + let y1 = y.saturating_add(RECT_BAND_ROWS).min(bounds.y1); + if !coverage(sel, Rect::new(bounds.x0, y, bounds.x1, y1)).iter().all(|c| *c >= 1.0) { + return false; + } + y = y1; + } + true +} + pub(super) fn patch(s: &mut Session, p: &Value) -> Result { if flag(p, "contentAware", false) { return patch_content_aware(s, p); } let plan = plan(s, p)?; - let label = if plan.destination { "Patch (Destination)" } else { "Patch" }; - let dmg = run_stroke(s, label, plan.id, p, |pre, surf, _, lock| { - let sel = pre.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?; - Ok(patch_surface(surf, sel, &plan, lock, 1)) - })?; + let label = HISTORY_NAME; + let dmg = if plan.destination { + // Destination: a plain rectangular selection moves with the patch, in the same history state. + let dmg = s.edit(label, |doc, _| { + let sel = doc.selection.clone().ok_or_else(|| bad(CMD, "select the area to patch first"))?; + let (surf, lock) = crate::channel_cmds::target_surface(doc, plan.id, p)?; + let dmg = patch_surface(surf, &sel, &plan, lock, 1); + let bounds = sel.content_bounds(); + if is_hard_rect(&sel, bounds) { + let (dx, dy) = plan.offset; + doc.selection = Some(sel.translated(dx, dy, bounds)); + } + Ok(dmg) + })?; + s.hint_damage(dmg); + dmg + } else { + run_stroke(s, label, plan.id, p, |pre, surf, _, lock| { + let sel = pre.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?; + Ok(patch_surface(surf, sel, &plan, lock, 1)) + })? + }; Ok(json!({ "damage": damage_json(dmg), "offset": [plan.offset.0, plan.offset.1] })) } @@ -263,6 +313,7 @@ fn level(p: &Value, k: &str, default: u8, lo: u8, hi: u8) -> Result { /// docs), as a background job. fn patch_content_aware(s: &mut Session, p: &Value) -> Result { use super::content_aware_move::{Target, land, mask, read, window}; + // The job's progress text; its History step is `HISTORY_NAME`. const LABEL: &str = "Patch (Content-Aware)"; let plan = plan(s, p)?; let structure = level(p, "structure", 4, 1, 7)?; @@ -294,7 +345,7 @@ fn patch_content_aware(s: &mut Session, p: &Value) -> Result { } }, move |s, (surf, damage)| { - s.edit(LABEL, |doc, _| { + s.edit(HISTORY_NAME, |doc, _| { *crate::channel_cmds::target_surface(doc, target.id, &p)?.0 = surf; Ok(()) })?; @@ -303,3 +354,7 @@ fn patch_content_aware(s: &mut Session, p: &Value) -> Result { }, ) } + +#[cfg(test)] +#[path = "patch_destination_selection_tests.rs"] +mod patch_destination_selection_tests; diff --git a/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs new file mode 100644 index 00000000000..7ed9e54efa6 --- /dev/null +++ b/crates/engine/src/cmd/retouch_cmds/patch_destination_selection_tests.rs @@ -0,0 +1,203 @@ +//! Patch in Destination mode moves a plain rectangular selection with the patch (#12119). + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 800, "height": 600, "depth": 8, "mode": "rgb"})).unwrap(); + s.edit("setup", |doc, active| { + let b = doc.bounds(); + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let fmt = surf.format(); + let mut data = Vec::new(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let v = if (x / 8 + y / 8) % 2 == 0 { 0.2 } else { 0.8 }; + let px = if x >= 400 { [0.1, 0.6, 0.2, 1.0] } else { [v, v, v, 1.0] }; + data.extend(photocraft_raster::from_rgba(&fmt, px)); + } + } + surf.write_region(b, &data); + Ok(()) + }) + .unwrap(); + s +} + +fn bounds(s: &mut Session) -> Value { + s.execute("document.inspect", json!({})).unwrap()["selectionBounds"].clone() +} + +fn px(s: &Session, x: i32, y: i32) -> [f32; 4] { + let d = s.active().unwrap(); + d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y) +} + +fn history_len(s: &Session) -> usize { + s.active().unwrap().history.past_len() +} + +fn select_rect(s: &mut Session) { + s.execute("select.rect", json!({"x": 120, "y": 120, "width": 40, "height": 40})).unwrap(); +} + +const DESTINATION: fn() -> Value = || json!({"mode": "destination", "offset": [460, 280]}); + +#[test] +fn destination_moves_the_selection_with_the_patch_and_undo_restores_both() { + let mut s = session(); + select_rect(&mut s); + let before = px(&s, 600, 420); + let source_before: Vec<[f32; 4]> = [(130, 130), (140, 140), (148, 140)].iter().map(|&(x, y)| px(&s, x, y)).collect(); + let r = s.execute("paint.patch", DESTINATION()).unwrap(); + assert_eq!(r["offset"], json!([460, 280])); + // The damage covers the patched area (the 40 px square at the destination, plus the solve's ring). + assert_eq!(r["damage"], json!([578, 398, 44, 44])); + assert_eq!(bounds(&mut s), json!([580, 400, 40, 40])); + assert_ne!(px(&s, 600, 420), before, "pixels changed too"); + // Source pixels untouched. + let source_after: Vec<[f32; 4]> = [(130, 130), (140, 140), (148, 140)].iter().map(|&(x, y)| px(&s, x, y)).collect(); + assert_eq!(source_after, source_before); + // The destination carries the source texture: the step between two neighbouring checker cells is kept + // in the same direction and at a similar strength (the seamless clone adds a smooth colour correction, and the + // red channel clamps at 0 on the dark green, so the step shrinks). + let step_src = px(&s, 140, 140)[0] - px(&s, 148, 140)[0]; + let step_dst = px(&s, 600, 420)[0] - px(&s, 608, 420)[0]; + assert!(step_src * step_dst > 0.0 && step_dst.abs() > 0.2, "texture kept: source step {step_src}, destination step {step_dst}"); + // The moved selection is still a hard-edged rectangle exactly filling its bounds. + let d = s.active().unwrap(); + let sel = d.doc.selection.as_ref().unwrap(); + assert!(is_hard_rect(sel, sel.content_bounds())); + // One history state: a single undo brings back the pixels and the old selection. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); + assert_eq!(px(&s, 600, 420), before); +} + +#[test] +fn destination_selection_stays_when_it_is_not_a_plain_rectangle() { + let cases: [(&str, Value, Value); 4] = [ + ("feathered", json!({"x": 120, "y": 120, "width": 40, "height": 40, "feather": 6.0}), json!(null)), + ("ellipse", json!({"x": 120, "y": 120, "width": 40, "height": 40, "ellipse": true}), json!(null)), + ("lasso", json!({"points": [[120, 120], [160, 130], [140, 165]]}), json!("lasso")), + ("rectangle with a hole", json!({"x": 120, "y": 120, "width": 40, "height": 40}), json!("hole")), + ]; + for (name, params, kind) in cases { + let mut s = session(); + let cmd = if kind == json!("lasso") { "select.lasso" } else { "select.rect" }; + s.execute(cmd, params).unwrap(); + if kind == json!("hole") { + // Same bounds as the rectangle, but not every pixel inside is fully selected. + s.execute("select.rect", json!({"x": 135, "y": 135, "width": 10, "height": 10, "mode": "subtract"})).unwrap(); + } + let sel_before = bounds(&mut s); + let area = |s: &Session| -> Vec<[f32; 4]> { (400..440).flat_map(|y| (580..620).map(move |x| (x, y))).map(|(x, y)| px(s, x, y)).collect() }; + let before = area(&s); + s.execute("paint.patch", DESTINATION()).unwrap(); + assert_eq!(bounds(&mut s), sel_before, "{name}: the selection stays in place"); + assert_ne!(area(&s), before, "{name}: the patch still lands"); + } +} + +#[test] +fn partial_coverage_and_oversized_rectangles_are_not_constructible_apart_from_the_hole_case() { + // `plan.area` is the selection's own content bounds, so a rectangle larger than the area cannot + // exist, and the area is never wider than the selection. The only way the bounds equal the area + // yet coverage falls short is a hole (covered above). An inverted selection makes the area the + // whole canvas, which no non-zero drag can keep on the canvas, so Patch refuses it. + let mut s = session(); + select_rect(&mut s); + s.execute("select.inverse", json!({})).unwrap(); + assert!(s.execute("paint.patch", DESTINATION()).is_err()); +} + +#[test] +fn destination_without_a_selection_fails_and_leaves_history_alone() { + let mut s = session(); + // A marker step after the setup, so a stray undo would be visible. + s.edit("marker", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 10, 20, 20), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let h = history_len(&s); + assert!(s.execute("paint.patch", DESTINATION()).is_err()); + assert_eq!(history_len(&s), h, "no history entry"); + // Undo reverts the marker (the last real step), not the setup. + s.execute("edit.undo", json!({})).unwrap(); + assert_ne!(px(&s, 15, 15), [1.0, 0.0, 0.0, 1.0], "the marker step was undone"); + assert!((px(&s, 450, 300)[0] - 0.1).abs() < 0.01 && (px(&s, 450, 300)[1] - 0.6).abs() < 0.01, "the setup step is still there"); +} + +#[test] +fn redo_restores_the_patched_pixels_and_the_moved_selection() { + let mut s = session(); + select_rect(&mut s); + let before = px(&s, 600, 420); + s.execute("paint.patch", DESTINATION()).unwrap(); + let patched = px(&s, 600, 420); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); + assert_eq!(px(&s, 600, 420), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(bounds(&mut s), json!([580, 400, 40, 40])); + assert_eq!(px(&s, 600, 420), patched); +} + +#[test] +fn source_mode_keeps_the_selection() { + let mut s = session(); + select_rect(&mut s); + s.execute("paint.patch", json!({"offset": [260, 0]})).unwrap(); + assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); +} + +#[test] +fn content_aware_keeps_the_selection_and_changes_the_pixels() { + let mut s = session(); + select_rect(&mut s); + // Content-Aware ignores the mode and fills the selection itself from the dragged-to place (the + // green half), so the pixels under the selection change. + let before = px(&s, 140, 140); + s.execute("paint.patch", json!({"offset": [400, 0], "contentAware": true})).unwrap(); + assert_eq!(bounds(&mut s), json!([120, 120, 40, 40])); + assert_ne!(px(&s, 140, 140), before, "the selected area was filled"); +} + +#[test] +fn destination_moves_a_hard_rectangle_that_extends_past_the_canvas() { + let mut s = session(); + // 60x40 rectangle with 20 columns off the left edge; the part on the canvas is x 0..40. + s.execute("select.rect", json!({"x": 0, "y": 100, "width": 60, "height": 40})).unwrap(); + s.execute("select.transformSelection", json!({"dx": -20, "dy": 0})).unwrap(); + let sel_before = bounds(&mut s); + let before = px(&s, 480, 400); + s.execute("paint.patch", DESTINATION()).unwrap(); + assert_ne!(px(&s, 480, 400), before, "the patch lands on the canvas part"); + let d = s.active().unwrap(); + let sel = d.doc.selection.as_ref().unwrap(); + assert_eq!(sel.content_bounds(), Rect::new(440, 380, 500, 420), "the whole rectangle moved, off-canvas part included"); + assert!(sel.read_region(Rect::new(440, 380, 500, 420)).chunks_exact(sel.channels().max(1)).all(|p| p[0] >= 1.0)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(bounds(&mut s), sel_before); + assert_eq!(px(&s, 480, 400), before); +} + +#[test] +fn hard_rect_check_is_capped_and_scans_every_band() { + let mut s = session(); + // 40x150 rectangle: three bands of 64 rows (64 + 64 + 22). + s.execute("select.rect", json!({"x": 100, "y": 100, "width": 40, "height": 150})).unwrap(); + let d = s.active().unwrap(); + let sel = d.doc.selection.as_ref().unwrap(); + let b = sel.content_bounds(); + assert_eq!(b, Rect::new(100, 100, 140, 250)); + assert!(!is_hard_rect_within(sel, b, 5999), "over the limit is never a hard rectangle"); + assert!(is_hard_rect_within(sel, b, 6000), "at the limit it is checked as usual"); + assert!(is_hard_rect_within(sel, b, u64::MAX)); + // A hole in the third band must still be found. + let mut holed = sel.clone(); + let fmt = holed.format(); + holed.write_region(Rect::new(120, 240, 121, 241), &photocraft_raster::from_rgba(&fmt, [0.0, 0.0, 0.0, 0.0])); + assert!(!is_hard_rect_within(&holed, b, u64::MAX), "a hole past the first band fails the check"); +} diff --git a/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs b/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs new file mode 100644 index 00000000000..2fcee6e1d73 --- /dev/null +++ b/crates/engine/src/cmd/retouch_cmds/patch_history_names_tests.rs @@ -0,0 +1,100 @@ +//! History step names of the Patch Tool (#12117): "Patch Tool" for the lasso it draws with and +//! "Patch Selection" for applying a patch in every mode; the plain Lasso tool keeps "Lasso". + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 128, "height": 64, "depth": 8, "mode": "rgb"})).unwrap(); + s +} + +fn last_step(s: &Session) -> Option { + s.active().unwrap().history.undo_label().map(str::to_string) +} + +#[test] +fn applying_a_patch_is_one_step_named_patch_selection_in_every_mode() { + let modes = [ + json!({"offset": [30, 0]}), + json!({"offset": [30, 0], "mode": "source"}), + json!({"offset": [30, 0], "mode": "destination"}), + json!({"offset": [30, 0], "contentAware": true}), + json!({"offset": [30, 0], "mode": "destination", "contentAware": true}), + ]; + for p in modes { + let mut s = session(); + s.execute("select.rect", json!({"x": 16, "y": 22, "width": 20, "height": 20})).unwrap(); + let steps = s.active().unwrap().history.past_len(); + s.execute("paint.patch", p.clone()).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{p}: one step"); + assert_eq!(last_step(&s).as_deref(), Some("Patch Selection"), "{p}"); + } +} + +const TRIANGLE: [[i32; 2]; 3] = [[16, 22], [36, 22], [16, 42]]; + +#[test] +fn the_lasso_is_named_for_the_tool_that_drew_it() { + let mut s = session(); + s.execute("select.lasso", json!({"points": TRIANGLE})).unwrap(); + assert_eq!(last_step(&s).as_deref(), Some("Lasso"), "the Lasso tool sends no name"); + for mode in ["replace", "add", "subtract", "intersect"] { + s.execute("select.lasso", json!({"points": TRIANGLE, "mode": mode, "historyName": "Patch Tool"})).unwrap(); + assert_eq!(last_step(&s).as_deref(), Some("Patch Tool"), "{mode}"); + } + s.execute("select.lasso", json!({"points": TRIANGLE, "polygonal": true, "historyName": "Patch Tool"})).unwrap(); + assert_eq!(last_step(&s).as_deref(), Some("Polygonal Lasso"), "the Polygonal Lasso keeps its own name"); +} + +#[test] +fn a_hostile_history_name_is_refused_without_a_step() { + let mut s = session(); + let steps = s.active().unwrap().history.past_len(); + let invisible = ["\u{200B}", " \u{200B}\u{FEFF} ", "\u{3164}", "\u{FE0F}", "\u{E0020}", "\u{00AD}", "\u{2800}"]; + let hidden = ["Patch\u{202E}looT", "\u{FEFF}Patch Tool", "Patch Tool\u{200B}", "\u{2066}Patch", "Patch\u{2028}Tool", "a\u{2029}b"]; + let ends = ["Patch Tool\n", "\r\nPatch Tool", "\tPatch", "\u{0085}Patch", "Patch\u{2028}"]; + let others = [json!(""), json!(" "), json!("x".repeat(65)), json!("a\nb"), json!(7), json!(["Patch Tool"])]; + let others = ends.iter().map(|n| json!(n)).chain(others); + for name in invisible.iter().chain(&hidden).map(|n| json!(n)).chain(others) { + assert!(s.execute("select.lasso", json!({"points": TRIANGLE, "historyName": name})).is_err(), "{name}"); + let polygonal = json!({"points": TRIANGLE, "polygonal": true, "historyName": name}); + assert!(s.execute("select.lasso", polygonal).is_err(), "polygonal {name}"); + } + assert_eq!(s.active().unwrap().history.past_len(), steps); +} + +#[test] +fn a_name_joining_its_letters_with_a_zero_width_joiner_is_kept() { + let mut s = session(); + for name in ["\u{0645}\u{200C}\u{06CC}", "\u{1F469}\u{200D}\u{1F4BB}", "Patch Tool \u{2713}"] { + s.execute("select.lasso", json!({"points": TRIANGLE, "historyName": name})).unwrap(); + assert_eq!(last_step(&s).as_deref(), Some(name)); + } +} + +#[test] +fn spaces_and_joiners_at_the_ends_are_trimmed_and_only_what_is_left_counts() { + let mut s = session(); + let padded = format!("{}{}", " ".repeat(10), "x".repeat(60)); + let joined = format!("Patch Tool{}", "\u{200D}".repeat(60)); + let cases = [ + ("Patch Tool\u{200D}", "Patch Tool"), + (" \u{200C}Patch Tool ", "Patch Tool"), + ("Love \u{2764}\u{FE0F}", "Love \u{2764}\u{FE0F}"), + (joined.as_str(), "Patch Tool"), + ("Patch Tool\u{2800}", "Patch Tool"), + ("\u{3164}\u{2800}Patch Tool\u{FFA0} \u{115F}", "Patch Tool"), + ("Patch Tool\u{2065}\u{FFF0}", "Patch Tool"), + ("\u{17B4}Patch Tool", "Patch Tool"), + ("Patch Tool \u{FE0F}", "Patch Tool"), + ("\u{FE0F}Patch Tool", "Patch Tool"), + ("Patch \u{2764}\u{FE0F}\u{2800}", "Patch \u{2764}\u{FE0F}"), + ("Patch \u{1100}\u{1160}", "Patch \u{1100}\u{1160}"), + ("\u{115F}\u{1161} Patch", "\u{115F}\u{1161} Patch"), + ]; + for (name, stored) in cases.iter().copied().chain([(padded.as_str(), &padded[10..])]) { + s.execute("select.lasso", json!({"points": TRIANGLE, "historyName": name})).unwrap(); + assert_eq!(last_step(&s).as_deref(), Some(stored), "{name:?}"); + } +} diff --git a/crates/engine/src/cmd/retouch_cmds/periodic.rs b/crates/engine/src/cmd/retouch_cmds/periodic.rs index 242579198ec..63c3b40f076 100644 --- a/crates/engine/src/cmd/retouch_cmds/periodic.rs +++ b/crates/engine/src/cmd/retouch_cmds/periodic.rs @@ -1,6 +1,6 @@ //! Bounded reads from the repeating document tile used by Pattern Preview. -use super::{BlendMode, BrushSettings, Document, LayerId, Rect, Region, Result, SampleLayers, StrokePoint, Surface, alpha_index, apply_coverage, bad}; +use super::{BlendMode, BrushSettings, Document, LayerId, Rect, Region, Result, SampleLayers, Stroke, StrokePoint, Surface, alpha_index, apply_coverage, bad}; /// At most four disjoint rectangles in the document tile. Projecting the dirty area separately /// from the stroke bounds avoids scanning the whole document when a small dab crosses a seam. @@ -23,6 +23,33 @@ pub(super) fn project(rect: Rect, tile: Rect) -> Vec { ys.into_iter().flat_map(|(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::new(x0, y0, x1, y1))).collect() } +/// A composite tool's stroke wrapped around the document: `paint` gives the pixels for a +/// rectangle in the stroke's own coordinates, from the target as it was before the stroke, and +/// they go on through the stroke's coverage wherever it repeats on the document. +#[allow(clippy::too_many_arguments)] +pub(super) fn stroke( + pre: &Document, + target: &mut Surface, + stroke: &Stroke, + selection: Option<&Surface>, + lock: bool, + mode: BlendMode, + cmd: &str, + mut paint: impl FnMut(&Surface, Rect) -> Result, +) -> Result { + let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0); + renderer.push(&stroke.points); + renderer.finish(); + let before = target.clone(); + let bounds = renderer.bounds(); + Composite { target, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection, lock, mode, cmd }.draw( + bounds, + &stroke.brush, + &stroke.points, + |r| paint(&before, r), + ) +} + /// Composite periodic copies of one stroke, restoring each dirty patch from pre-stroke pixels. /// Each dab wraps once, then builds flow towards its opacity ceiling. Stroke opacity and /// selection are applied once after all dabs, including repeated visits through other tiles. @@ -34,6 +61,8 @@ pub(super) struct Composite<'a> { pub selection: Option<&'a Surface>, pub lock: bool, pub mode: BlendMode, + /// The command an over-budget stroke's error names. + pub cmd: &'a str, } impl Composite<'_> { @@ -138,7 +167,7 @@ impl Composite<'_> { ); *work = work.saturating_add(r.width() as u64 * r.height() as u64); if *work > 64 * 1024 * 1024 { - return Err(bad("paint.cloneStamp", "wrapped stroke exceeds its coverage budget")); + return Err(bad(self.cmd, "wrapped stroke exceeds its coverage budget")); } ctx.rasterize_into(dab, dual, r, &mut values, wet.then_some(&mut depths)); let mut sampled = None; diff --git a/crates/engine/src/cmd/retouch_cmds/tests.rs b/crates/engine/src/cmd/retouch_cmds/tests.rs index 25ebcc8a971..f1980bae0de 100644 --- a/crates/engine/src/cmd/retouch_cmds/tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/tests.rs @@ -1022,7 +1022,7 @@ fn patch_content_aware_copies_the_dragged_to_content_into_the_selection() { let (got, want) = (rgba(&s, x, y), texture(x, y)); assert!((got[0] - want[0]).abs() <= tol(depth), "depth {depth}: untouched {x},{y}: {got:?}"); } - assert_eq!(s.active().unwrap().history.undo_label(), Some("Patch (Content-Aware)")); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Patch Selection")); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(rgba(&s, 44, 24)[1], 0.0, "depth {depth}: undo brings the blemish back"); } @@ -2532,3 +2532,192 @@ fn periodic_clone_interior_matches_ordinary_brush_masks_and_transparency() { } } } + +// --------------------------------------------------------------------------------------------- +// View › Pattern Preview: Clone Stamp and Healing Brush wrap across the canvas edges (#8883) +// --------------------------------------------------------------------------------------------- + +/// The canvas the Pattern Preview tests wrap around. +const WRAP: (u32, u32) = (48, 36); + +/// `texture` repeated every canvas, so each copy of a pixel holds the same colour. +fn tiled_texture(x: i32, y: i32) -> [f32; 4] { + texture(x.rem_euclid(WRAP.0 as i32), y.rem_euclid(WRAP.1 as i32)) +} + +/// A stroke from just inside the corner where the middle copy of a three-by-three tiling meets +/// the next copies, out across both of its edges. +fn corner_stroke(extra: &Value) -> Value { + let mut p = json!({"points": [[84, 64], [106, 79]], "size": 12, "hardness": 100}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + p +} + +/// The stroke tools that wrap, with their options. Both composite a paint image through the +/// stroke's coverage (`periodic::Composite`), which matches the ordinary composite only within +/// rounding. +fn wrap_cases() -> [(&'static str, Value); 2] { + [("paint.healingBrush", json!({"offset": [-20, -14]})), ("paint.cloneStamp", json!({"offset": [-20, -14]}))] +} + +/// The native-channel difference `periodic::Composite` may leave against the ordinary composite. +fn composite_tolerance(depth: u64) -> f32 { + match depth { + 8 => 1.1 / 255.0, + 16 => 1.1 / 65535.0, + _ => 0.00001, + } +} + +#[test] +fn every_wrapping_stroke_paints_the_canvas_as_on_the_canvas_tiled_three_by_three() { + // A stroke with `"wrap": true` gives each pixel of the canvas what the same stroke gives the + // copy of that pixel it goes over, on a document holding the canvas tiled three by three. + let (w, h) = (WRAP.0 as usize, WRAP.1 as usize); + for depth in DEPTHS { + for (cmd, extra) in wrap_cases() { + let mut canvas = session(WRAP.0, WRAP.1, depth, "rgb"); + paint_layer(&mut canvas, tiled_texture); + let mut tiled = session(3 * WRAP.0, 3 * WRAP.1, depth, "rgb"); + paint_layer(&mut tiled, tiled_texture); + let (mut wrapped, plain) = (corner_stroke(&extra), corner_stroke(&extra)); + wrapped["wrap"] = json!(true); + canvas.execute(cmd, wrapped).unwrap_or_else(|e| panic!("{cmd} at {depth}: {e}")); + let before = layer_pixels(&tiled); + tiled.execute(cmd, plain).unwrap(); + let (got, want) = (layer_pixels(&canvas), layer_pixels(&tiled)); + let n = got.len() / (w * h); + let at = |x: usize, y: usize| (y * 3 * w + x) * n..(y * 3 * w + x + 1) * n; + let tolerance = composite_tolerance(depth); + let (mut moved, mut edges) = (0, [false; 4]); + for y in 0..h { + for x in 0..w { + let copies = (0..3).flat_map(|j| (0..3).map(move |i| (x + i * w, y + j * h))); + let changed: Vec<_> = copies.filter(|&(u, v)| want[at(u, v)] != before[at(u, v)]).collect(); + let (pixel, expected) = match changed.as_slice() { + [] => (&got[(y * w + x) * n..][..n], &before[at(x, y)]), + [(u, v)] => (&got[(y * w + x) * n..][..n], &want[at(*u, *v)]), + _ => panic!("{cmd}: ({x}, {y}) has two copies under the stroke"), + }; + let error = pixel.iter().zip(expected).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(error <= tolerance, "{cmd} {extra} at {depth}-bit: ({x}, {y}) is off by {error}"); + if !changed.is_empty() { + moved += 1; + edges[0] |= x < 8; + edges[1] |= x >= w - 8; + edges[2] |= y < 8; + edges[3] |= y >= h - 8; + } + } + } + assert!(moved > 150 && edges.iter().all(|&e| e), "{cmd} {extra} at {depth}-bit: the stroke crossed both edges ({moved} px, {edges:?})"); + } + } +} + +#[test] +fn a_wrapped_stroke_inside_the_canvas_paints_as_an_ordinary_one() { + let p = json!({"points": [[130, 110], [150, 118], [168, 112]], "size": 14, "hardness": 60}); + for depth in [8, 32] { + for (cmd, extra) in wrap_cases() { + let run = |wrap: bool| { + let mut s = session(300, 230, depth, "rgb"); + paint_layer(&mut s, tiled_texture); + let mut q = p.clone(); + for (k, v) in extra.as_object().unwrap() { + q[k] = v.clone(); + } + q["wrap"] = json!(wrap); + s.execute(cmd, q).unwrap(); + layer_pixels(&s) + }; + let (plain, wrapped) = (run(false), run(true)); + let error = plain.iter().zip(&wrapped).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(error <= composite_tolerance(depth), "{cmd} {extra} at {depth}-bit: off by {error}"); + } + } +} + +#[test] +fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() { + for (cmd, extra) in wrap_cases() { + let mut s = session(16, 12, 8, "rgb"); + paint_layer(&mut s, tiled_texture); + let before = layer_pixels(&s); + let mut p = json!({"points": [[8, 6], [30, 20]], "size": 60, "hardness": 0, "wrap": true}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + let r = s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}")); + let d = r["damage"].as_array().map(|d| d.iter().filter_map(Value::as_i64).collect::>()).unwrap_or_default(); + assert!(matches!(d.as_slice(), [x, y, w, h] if *x >= 0 && *y >= 0 && x + w <= 16 && y + h <= 12), "{cmd}: damage {d:?} is on the canvas"); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + assert!([(-1, 5), (16, 5), (5, -1), (5, 12), (40, 30)].iter().all(|&(x, y)| surf.rgba(x, y)[3] == 0.0), "{cmd}: nothing lands off the canvas"); + assert!(layer_pixels(&s) != before, "{cmd} {extra}: the stroke changed the canvas"); + } +} + +#[test] +fn the_tools_the_reference_application_does_not_wrap_stop_at_the_canvas_edge() { + // Measured in the reference application: in Pattern Preview the Clone Stamp and Healing + // Brush carry a stroke on across the canvas edges, but Blur … Sponge, Color Replacement, + // Background Eraser, Pattern Stamp and History Brush warn that they don't wrap, and Spot + // Healing stops at the edge too. Their `wrap` stroke over the corner paints as the same + // stroke without it. + let points = [[36.0, 24.0], [58.0, 39.0]]; + let cases = [ + ("paint.blur", json!({"strength": 80})), + ("paint.sharpen", json!({"strength": 60})), + ("paint.smudge", json!({"strength": 70})), + ("paint.dodge", json!({"exposure": 60})), + ("paint.burn", json!({"exposure": 40})), + ("paint.sponge", json!({"mode": "saturate"})), + ("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60})), + ("paint.backgroundEraser", json!({"tolerance": 40})), + ("paint.patternStamp", json!({"pattern": "Checkerboard"})), + ("paint.patternStamp", json!({"pattern": "Bricks", "impressionist": true, "seed": 7})), + ("paint.historyBrush", json!({"state": 1})), + ("paint.spotHealing", json!({"type": "contentAware"})), + ]; + for (cmd, extra) in cases { + let run = |wrap: bool| { + let mut s = session(WRAP.0, WRAP.1, 8, "rgb"); + paint_layer(&mut s, tiled_texture); + // Painted over since history state 1, so the History Brush has something to bring back. + paint_layer(&mut s, |x, y| tiled_texture(x + 7, y + 5)); + let mut p = dab_opts(&points, 100, &extra); + p["size"] = json!(12); + p["wrap"] = json!(wrap); + let before = layer_pixels(&s); + s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}")); + (before, layer_pixels(&s)) + }; + let ((before, plain), (_, wrapped)) = (run(false), run(true)); + assert!(plain != before, "{cmd} {extra}: the stroke paints inside the canvas"); + assert!(wrapped == plain, "{cmd} {extra}: nothing comes back at the opposite edges"); + } +} + +#[test] +fn live_history_and_pattern_strokes_stop_at_the_canvas_edge() { + // The History Brush and aligned Pattern Stamp previews stop at the edge as their commits do. + let points = [[36.0, 24.0], [58.0, 39.0]]; + let pts: Vec<_> = points.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect(); + for (cmd, extra) in [("paint.historyBrush", json!({"state": 1})), ("paint.patternStamp", json!({"pattern": "Checkerboard"}))] { + let bounds = |wrap: bool| { + let mut s = session(WRAP.0, WRAP.1, 8, "rgb"); + paint_layer(&mut s, tiled_texture); + paint_layer(&mut s, |_, _| [0.15, 0.2, 0.7, 1.0]); + let mut p = dab_opts(&points[..1], 100, &extra); + p["size"] = json!(12); + p["wrap"] = json!(wrap); + let mut live = LiveRetouch::begin(&s, cmd, &p).unwrap(); + live.push(&pts[1..]).unwrap(); + live.bounds() + }; + assert_eq!(bounds(true), bounds(false), "{cmd}: the live stroke stays where it was drawn"); + } +} diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index 13737e70bd4..619a1af7ae7 100644 --- a/crates/engine/src/cmd/selection_cmds.rs +++ b/crates/engine/src/cmd/selection_cmds.rs @@ -17,6 +17,8 @@ mod modify_window; mod color_range_performance; #[cfg(test)] mod color_range_window_tests; +#[cfg(test)] +mod wand_seed_outside_tests; fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) @@ -139,9 +141,15 @@ fn magic_wand(s: &mut Session, p: &Value) -> Result { Some(plane) => plane, None => sample_rgba8(s, b(p, "sampleAllLayers", false))?, }; - let region = sel::wand_region(&img, area, (x, y), f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true)); - drop(img); let m = mode(p); + // Subtract and Intersect grow the region from the seed through the selection only (#11779). + // A global match (Contiguous off) ignores the limit, so it skips the full-area mask. + let contiguous = b(p, "contiguous", true); + let within = (contiguous && matches!(m, SelectionMode::Subtract | SelectionMode::Intersect)) + .then(|| s.active().and_then(|st| st.doc.selection.as_ref()).map(|sel| sel::mask_from_surface(Some(sel), area))) + .flatten(); + let region = sel::wand_region_within(&img, area, (x, y), f(p, "tolerance", 32.0), contiguous, b(p, "antiAlias", true), within.as_deref()); + drop(img); let selected = s.edit("Magic Wand", |doc, _| { doc.selection = sel::combine_region(doc.selection.as_ref(), region.as_ref(), m); Ok(doc.selection.is_some()) @@ -622,10 +630,33 @@ fn lasso(s: &mut Session, p: &Value) -> Result { return Err(EngineError::BadParams { cmd: "select.lasso".into(), msg: "needs at least 3 points".into() }); } // The History step is named after the tool: the Polygonal Lasso sends `polygonal` (Photoshop). - let step = if b(p, "polygonal", false) { "Polygonal Lasso" } else { "Lasso" }; + // The Patch and Content-Aware Move tools draw with this command and send their own name as + // `historyName`, so the plain Lasso tool keeps "Lasso". + // A bad name is refused even when `polygonal` picks the step's name. + let named = match p.get("historyName") { + None | Some(Value::Null) => None, + Some(Value::String(n)) if history_name(n).is_some() => history_name(n), + Some(_) => { + let msg = "historyName must be 1-64 characters with a visible one, and no control, line-break or hidden format characters"; + return Err(EngineError::BadParams { cmd: "select.lasso".into(), msg: msg.into() }); + } + }; + let step = if b(p, "polygonal", false) { "Polygonal Lasso" } else { named.unwrap_or("Lasso") }; polygon_selection(s, "select.lasso", step, &pts, p) } +/// The History step name for `historyName` `n`: refused when it has a control or line-break +/// character anywhere; otherwise without its invisible end padding (see +/// [`photocraft_plugins::manifest::trim_name`]), so a name that looks like "Patch Tool" is "Patch Tool", +/// and then held to the plug-in menu-label rule. +fn history_name(n: &str) -> Option<&str> { + if n.chars().any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}')) { + return None; + } + let t = photocraft_plugins::manifest::trim_name(n); + photocraft_plugins::manifest::printable_name(t).then_some(t) +} + /// Selects the polygon `pts` with the lasso tools' `mode`, `antiAlias` and `feather` params, as /// one history step called `label`. pub(crate) fn polygon_selection(s: &mut Session, cmd: &str, label: &str, pts: &[(f32, f32)], p: &Value) -> Result { @@ -756,7 +787,7 @@ pub fn specs() -> Vec { "select.lasso", "Lasso", [], - r##"{"points":[[x,y],…],"mode":"replace|add|subtract|intersect"="replace","antiAlias":bool=true,"feather":px=0,"polygonal":bool=false}"##, + r##"{"points":[[x,y],…],"mode":"replace|add|subtract|intersect"="replace","antiAlias":bool=true,"feather":px=0,"polygonal":bool=false,"historyName"?:text (1..64 characters with a visible one, no control, line-break or hidden format characters, invisible padding at the ends (spaces, joiners, fillers, blank Braille and other default-ignorable blanks, and selectors cut off by them) trimmed; the History step name when a tool other than the Lasso draws it, e.g. "Patch Tool"; default "Lasso")}"##, has_doc, lasso ), diff --git a/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs new file mode 100644 index 00000000000..51540e3cfde --- /dev/null +++ b/crates/engine/src/cmd/selection_cmds/wand_seed_outside_tests.rs @@ -0,0 +1,113 @@ +//! #11779: with Contiguous on, the Magic Wand's Subtract and Intersect grow the region from the +//! seed through the current selection only. A seed outside the selection subtracts nothing and +//! intersects to nothing. + +use serde_json::json; + +use crate::Session; + +/// 800 × 600, white, with a grey ramp of ten 40 px blocks from x 100 (100, 105, …, 145). +fn ramp() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 800, "height": 600, "background": "white"})).unwrap(); + for k in 0..10 { + let v = 100 + 5 * k; + s.execute("select.rect", json!({"x": 100 + 40 * k, "y": 350, "width": 40, "height": 100})).unwrap(); + s.execute("edit.fill", json!({"color": format!("#{v:02x}{v:02x}{v:02x}")})).unwrap(); + } + s.execute("select.deselect", json!({})).unwrap(); + s +} + +fn wand(s: &mut Session, x: i32, tolerance: f64, mode: &str) { + s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode})).unwrap(); +} + +fn wand_global(s: &mut Session, x: i32, tolerance: f64, mode: &str) { + s.execute("select.magicWand", json!({"x": x, "y": 400, "tolerance": tolerance, "mode": mode, "contiguous": false})).unwrap(); +} + +/// The columns of row 400 that are at least half selected: (first, end), or `None`. Every +/// column in between must be selected too, so a hole in the run fails the test. +fn selected(s: &Session) -> Option<(i32, i32)> { + let doc = &s.active().unwrap().doc; + let row = photocraft_geom::Rect::new(0, 400, 800, 401); + let m = photocraft_algo::selection::mask_from_surface(doc.selection.as_ref(), row); + let first = m.iter().position(|v| *v >= 0.5)?; + let last = m.iter().rposition(|v| *v >= 0.5)?; + assert!(m[first..=last].iter().all(|v| *v >= 0.5), "a hole in the selected run {first}..={last}"); + Some((first as i32, last as i32 + 1)) +} + +/// Greys 100 to 130 (x 100–380) selected, as tolerance 32 on the first block selects them, plus +/// the Anti-alias fringe: column 380 of grey 135 at about 251/255, as measured in #11776. +fn ramp_selected() -> Session { + let mut s = ramp(); + wand(&mut s, 110, 32.0, "replace"); + assert_eq!(selected(&s), Some((100, 381))); + s +} + +#[test] +fn a_seed_outside_the_selection_subtracts_nothing() { + let mut s = ramp_selected(); + // Grey 145 (x 460–500) is outside the selection, though its own region reaches into it. + wand(&mut s, 480, 32.0, "subtract"); + assert_eq!(selected(&s), Some((100, 381)), "unchanged"); +} + +#[test] +fn a_seed_outside_the_selection_intersects_to_nothing() { + let mut s = ramp_selected(); + wand(&mut s, 480, 32.0, "intersect"); + assert_eq!(selected(&s), None); +} + +#[test] +fn a_seed_inside_the_selection_works_as_before() { + // Grey 125 with tolerance 10 reaches greys 115–135; inside the selection that is x 220–380. + // With the default Anti-alias fringe, as measured in #11779: Subtract leaves 100–219 (x 219 + // keeps about 6/255, under the 0.5 cut) and Intersect 219–381. + let mut s = ramp_selected(); + wand(&mut s, 320, 10.0, "subtract"); + assert_eq!(selected(&s), Some((100, 219))); + let mut s = ramp_selected(); + wand(&mut s, 320, 10.0, "intersect"); + assert_eq!(selected(&s), Some((219, 381))); +} + +#[test] +fn contiguous_off_keeps_the_global_match() { + // Without Contiguous the seed's colour matches everywhere, as before: greys 115–145 are + // x 220–500, wherever the seed lies. + let mut s = ramp_selected(); + wand_global(&mut s, 480, 32.0, "subtract"); + assert_eq!(selected(&s), Some((100, 219))); + let mut s = ramp_selected(); + wand_global(&mut s, 480, 32.0, "intersect"); + assert_eq!(selected(&s), Some((219, 381))); +} + +#[test] +fn without_a_selection_subtract_and_intersect_select_nothing() { + for mode in ["subtract", "intersect"] { + let mut s = ramp(); + wand(&mut s, 320, 10.0, mode); + assert_eq!(selected(&s), None, "{mode}"); + } +} + +#[test] +fn the_fill_passes_only_pixels_at_least_half_selected() { + use photocraft_algo::selection::wand_region_within; + use photocraft_geom::Rect; + // One flat grey row of four pixels; only the coverage limit decides the region. + let img = [[128u8, 128, 128, 255]; 4]; + let area = Rect::new(0, 0, 4, 1); + let fill = |within: [f32; 4], seed: i32| wand_region_within(&img, area, (seed, 0), 0.0, true, false, Some(&within)); + assert!(fill([0.49, 1.0, 1.0, 1.0], 0).is_none(), "a seed just under half selected finds nothing"); + let r = fill([0.5, 1.0, 1.0, 1.0], 0).unwrap(); + assert_eq!((0..4).map(|x| r.at(x, 0) >= 0.5).collect::>(), [true; 4], "half selected counts"); + let r = fill([1.0, 1.0, 0.49, 1.0], 0).unwrap(); + assert_eq!((0..4).map(|x| r.at(x, 0) >= 0.5).collect::>(), [true, true, false, false], "the fill stops at 0.49"); +} diff --git a/crates/engine/src/cmd/smart_cmds/rendering.rs b/crates/engine/src/cmd/smart_cmds/rendering.rs index 0e88a2a5156..e922e125ef4 100644 --- a/crates/engine/src/cmd/smart_cmds/rendering.rs +++ b/crates/engine/src/cmd/smart_cmds/rendering.rs @@ -129,7 +129,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) { - cur = apply_smart_filter(f, &cur, filter_bounds(f, placed, canvas), extent, &JobCtx::new())?; + cur = apply_smart_filter(f, sm, &cur, filter_bounds(f, placed, canvas), extent, &JobCtx::new())?; } Ok(match sm.filter_mask.as_ref().filter(|m| m.enabled) { Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m), @@ -155,8 +155,9 @@ fn renders_in_object(f: &SmartFilter) -> bool { /// One visible smart filter over `cur`, blended with its blend mode and opacity. `Err` when the /// requesting session's authority denies the filter's params: callers surface the error instead of /// rendering (and caching) unfiltered pixels. -fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect, ctx: &JobCtx) -> Result { - let out = match crate::filters::apply_filter_to_surface_in(&f.command, &f.params, cur, canvas, None, extent, ctx)? { +fn apply_smart_filter(f: &SmartFilter, sm: &SmartObject, cur: &Surface, canvas: Rect, extent: Rect, ctx: &JobCtx) -> Result { + let params = crate::distort_cmds::placed_puppet_params(&f.command, &f.params, sm); + let out = match crate::filters::apply_filter_to_surface_in(&f.command, ¶ms, cur, canvas, None, extent, ctx)? { // Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone. Some(out) => out, None => cur.clone(), @@ -367,7 +368,7 @@ pub(super) fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Resul Some(b) => b, None => *object_bounds.insert(filter_bounds(f, &placed()?.surface, canvas)), }; - cur = Stage { surface: apply_smart_filter(f, &cur.surface, bounds, cur.extent, ctx)?, extent: cur.extent }; + cur = Stage { surface: apply_smart_filter(f, sm, &cur.surface, bounds, cur.extent, ctx)?, extent: cur.extent }; stack_cache::put(*key, &cur); } ctx.progress(1.0, ""); diff --git a/crates/engine/src/cmd/transform_cmds/multi_tests.rs b/crates/engine/src/cmd/transform_cmds/multi_tests.rs index a50e4239fdb..ec0c817b125 100644 --- a/crates/engine/src/cmd/transform_cmds/multi_tests.rs +++ b/crates/engine/src/cmd/transform_cmds/multi_tests.rs @@ -223,24 +223,24 @@ fn bad_layers_params_are_errors_not_panics() { } #[test] -fn straighten_layer_rotates_every_selected_layer() { - // Photoshop's Straighten Layer turns all the selected layers, not just the active one; it - // goes through `edit.transform` without a layer, so it takes the selection like Free Transform. +fn straighten_layer_wants_one_selected_layer() { + // #12033: the reference greys Straighten Layer out while two layers are selected, so it no + // longer turns the whole selection the way Free Transform does + // (`free_transform_moves_every_selected_layer_in_one_step` covers that). let (mut s, paint, text) = two_layers(); let (p0, m0) = (bounds(&s, paint), text_matrix(&s, text)); s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); let steps = s.active().unwrap().history.past_len(); - let r = s.execute("image.analysis.straightenLayer", json!({})).unwrap(); - assert_eq!(r["cropped"], false); - assert!(bounds(&s, paint) != p0, "the selected pixel layer turned too"); - assert!(text_matrix(&s, text)[1].abs() > 0.05 && m0[1].abs() < 1e-9, "the active type layer turned"); - assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one history step"); - assert!(s.undo()); - assert_eq!((bounds(&s, paint), text_matrix(&s, text)), (p0, m0), "and one Undo takes both back"); + assert_eq!(s.active().unwrap().selected_layers().len(), 2, "both selected, the type layer active"); + assert!(!s.is_enabled("image.analysis.straightenLayer"), "two selected layers"); + assert!(s.execute("image.analysis.straightenLayer", json!({})).is_err()); + assert_eq!((bounds(&s, paint), text_matrix(&s, text)), (p0, m0), "nothing turned"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "and no history step"); // With one layer selected, only that layer turns. s.execute("layer.select", json!({"layer": paint.0})).unwrap(); s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); + assert!(s.is_enabled("image.analysis.straightenLayer"), "a single layer"); s.execute("image.analysis.straightenLayer", json!({})).unwrap(); assert!(bounds(&s, paint) != p0); assert_eq!(text_matrix(&s, text), m0, "an unselected layer stays put"); diff --git a/crates/engine/src/cmd/type_cmds.rs b/crates/engine/src/cmd/type_cmds.rs index d05c05f46ae..f1f8aa53c4f 100644 --- a/crates/engine/src/cmd/type_cmds.rs +++ b/crates/engine/src/cmd/type_cmds.rs @@ -8,7 +8,7 @@ use std::sync::Arc; -use photocraft_color::{Color, PixelFormat}; +use photocraft_color::{Color, ColorMode, PixelFormat}; use photocraft_doc::text::{ AntiAlias, Caps, CharStyle, FontBaseline, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape, @@ -937,6 +937,56 @@ pub(crate) fn new_text_layer(s: &Session, p: &Value, cmd: &str) -> Result Type > "Fill new type layers with placeholder +/// text" (on by default) already gives that first call non-empty text. Branching there would make +/// the click produce a selection and no layer, so there would be nothing to type into - while the +/// reference application still lets you click and type in these modes and shows the text while +/// editing. Only its commit differs. +/// - `type.create` is also what automation, actions and a script's new text layer go through, and +/// the measured difference is the tool's. +pub fn commits_as_selection(mode: ColorMode, t: &TextLayer) -> bool { + let measured = matches!(t.shape, TextShape::Point) && t.orientation == Orientation::Horizontal; + measured && matches!(mode, ColorMode::Indexed | ColorMode::Bitmap) +} + +/// Name the step the Type tool's commit made in such a document (#12041). The commit runs the Type +/// Mask tools' `type.createSelection` with `removeLayer` - the same conversion, coalesced into the +/// one step of the typing session - and that command names the step "Type Mask", while Photoshop's +/// history state here is the type layer's own name. +/// +/// An edit that does not carry the session's `coalesce` key (the Alt+arrow kerning step, which is +/// deliberately one state per press) ends the session's step, exactly as it does for the Type Mask +/// tools and for the plain type layer's rename on commit; the conversion is then a state of its own +/// and takes this name too. Photoshop repeats a state name whenever the same thing happens again, +/// so the two states read as the two type-layer steps they are. +pub fn name_selection_commit(s: &mut Session) { + if let Some(st) = s.active_mut() { + st.history.set_current_label(NEW_TYPE_LAYER_LABEL); + } +} + const CHAR_PARAMS: &str = r##""font":str,"fontStyle":str,"weight":100..900,"italic":bool,"size":0.1..=1296 pt,"color":"#rrggbb"|[r,g,b,a],"tracking":-1000..=10000 (1/1000 em),"leading":pt|"auto","baselineShift":pt,"horizontalScale":%,"verticalScale":%,"underline":bool,"strikethrough":bool,"fauxBold":bool,"fauxItalic":bool,"kerning":1/1000em (manual, after each character)|"metrics"|"optical"|"off","caps":"normal|small|all","baseline":"normal|superscript|subscript","ligatures":bool,"discretionaryLigatures":bool,"features":{"ss01":1},"variations":{"wght":650},"language":str"##; pub fn specs() -> Vec { @@ -953,7 +1003,7 @@ pub fn specs() -> Vec { let mut t = new_text_layer(s, p, "type.create")?; let given = p.get("name").and_then(Value::as_str).map(str::to_string); let name = given.clone().unwrap_or_else(|| layer_name(&t.text)); - let id = s.edit("New Type Layer", |doc, active| { + let id = s.edit(NEW_TYPE_LAYER_LABEL, |doc, active| { refresh(doc, &mut t); let id = crate::layer_place::insert_new(doc, *active, Layer::new(name, LayerContent::Text(t)), crate::layer_place::Place::AutoEditable); *active = Some(id); diff --git a/crates/engine/src/cmd/type_indexed_mode_tests.rs b/crates/engine/src/cmd/type_indexed_mode_tests.rs new file mode 100644 index 00000000000..8f6a8058fb7 --- /dev/null +++ b/crates/engine/src/cmd/type_indexed_mode_tests.rs @@ -0,0 +1,287 @@ +//! #12041: committing Type-tool text in an Indexed Color or a Bitmap document. +//! +//! The reference application still lets you click and type there and shows the text while editing; +//! the commit leaves **no type layer** and a selection in the shape of the glyphs, as one history +//! state named "New Type Layer", and paints nothing. [`super::type_cmds::commits_as_selection`] is +//! that mode check and [`super::type_cmds::name_selection_commit`] the step's name; the conversion +//! is the Type Mask tools' `type.createSelection` with `removeLayer`, coalesced into the typing +//! session, which is what [`commit`] below runs and what `ui-egui`'s `commit_here` now does for the +//! Type tool in these modes. +//! +//! `type.create` itself stays mode-agnostic on purpose, and +//! [`the_type_layer_command_keeps_making_the_tools_working_layer`] pins that: the tools make the +//! layer they edit and show on canvas with that command, on the first click already with text in it +//! when Preferences > Type > "Fill new type layers with placeholder text" is on (the default), so a +//! mode branch there would take away the layer the session types into. +//! +//! The issue measures the horizontal point text you click and type, and lists paragraph text and +//! vertical type under *Not measured*, where its rule is that today's behaviour stays: +//! [`only_the_measured_horizontal_point_text_converts_in_these_modes`] pins that half of the check, +//! and [`an_uncoalesced_edit_between_typing_and_the_commit_splits_the_states`] what happens to the +//! states when an edit outside the session's coalesce group comes between the typing and the commit. + +use super::type_cmds::{commits_as_selection, name_selection_commit, new_text_layer}; +use super::*; +use photocraft_doc::text::{Orientation, TextShape}; + +/// The typing session's coalesce key, as `ui-egui`'s `session_key` gives one. +const SESSION: &str = "type-session-12041"; + +/// The text the tool commits, at a baseline inside every document used here. `coalesce` for the +/// tool's own calls (one step for the session), without it for a script's or the pin's. +fn typed(coalesce: bool) -> Value { + let mut p = json!({ "text": "Hi", "x": 20, "y": 80, "size": 48 }); + if coalesce { + p["coalesce"] = json!(SESSION); + } + p +} + +/// A blank RGB document, to be converted to the mode under test. +fn blank() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 240, "height": 120, "background": "transparent"})).unwrap(); + s +} + +fn indexed() -> Session { + let mut s = blank(); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "dither": "none", "forced": "none"})).unwrap(); + assert_eq!(mode(&s), ColorMode::Indexed, "the setup made an Indexed Color document"); + s +} + +fn bitmap() -> Session { + let mut s = blank(); + s.execute("image.mode.grayscale", json!({})).unwrap(); + s.execute("image.mode.bitmap", json!({"method": "threshold", "frequency": 12, "shape": "diamond"})).unwrap(); + assert_eq!(mode(&s), ColorMode::Bitmap, "the setup made a Bitmap document"); + s +} + +fn mode(s: &Session) -> ColorMode { + s.active().unwrap().doc.mode +} + +/// The Type tool's commit of the session's working layer in a document [`commits_as_selection`] +/// names: the Type Mask tools' conversion, in the session's one step, which keeps the name the step +/// was opened with. +fn commit(s: &mut Session, layer: u64) { + s.execute("type.createSelection", json!({"layer": layer, "removeLayer": true, "operation": "new", "coalesce": SESSION})).unwrap(); + name_selection_commit(s); +} + +fn text_layers(s: &Session) -> usize { + s.active().unwrap().doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).count() +} + +fn layers(s: &Session) -> usize { + s.active().unwrap().doc.walk().len() +} + +/// The selection's coverage over the whole canvas, in document pixels. +fn coverage(s: &Session) -> Vec { + let d = s.active().unwrap().doc.clone(); + let b = d.bounds(); + let mut out = Vec::new(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + out.push(d.selection.as_ref().map_or(0.0, |m| m.sample_channel(x, y, 0))); + } + } + out +} + +/// The selection's bounds, as its corners. +fn selection_corners(s: &Session) -> Option<[i32; 4]> { + s.active().unwrap().doc.selection.as_ref().map(|m| m.content_bounds()).map(|r| [r.x0, r.y0, r.x1, r.y1]) +} + +/// Every layer's pixels, quantised: what the commit must leave exactly as it was. +fn pixels(s: &Session) -> Vec { + let d = s.active().unwrap().doc.clone(); + let b = d.bounds(); + let mut out = Vec::new(); + for (_, _, l) in d.walk() { + let Some(surface) = l.surface() else { continue }; + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + out.extend(surface.rgba(x, y).map(|v| (v * 255.0).round() as u8)); + } + } + } + out +} + +fn history(s: &Session) -> Vec { + s.active().unwrap().history.entries() +} + +#[test] +fn committing_in_indexed_color_or_bitmap_leaves_the_glyphs_selected_and_no_layer() { + for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] { + let mut s = make(); + let (steps, layers_before, pixels_before) = (history(&s).len(), layers(&s), pixels(&s)); + + // The tool's session: a type layer to edit and show while typing, as measured. + let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(text_layers(&s), 1, "{name}: typing happens on a layer the tool can show"); + + commit(&mut s, layer); + + assert_eq!(text_layers(&s), 0, "{name}: committing leaves no type layer"); + assert_eq!(layers(&s), layers_before, "{name}: the working layer went with it"); + assert!(s.active().unwrap().doc.selection.is_some(), "{name}: the glyphs are selected"); + let selected = s.active().unwrap().doc.selection.as_ref().unwrap(); + assert!(!selected.content_bounds().is_empty(), "{name}: the selection has a shape"); + assert!(coverage(&s).iter().any(|v| *v > 0.0), "{name}: the selected pixels are the glyphs'"); + assert_eq!(pixels(&s), pixels_before, "{name}: the commit paints nothing"); + + // One step for the whole session, named as Photoshop names it. + let h = history(&s); + assert_eq!(h.len(), steps + 1, "{name}: the session is one history state: {h:?}"); + assert_eq!(h.last().map(String::as_str), Some("New Type Layer"), "{name}: its name"); + + // And undoing that one state gives the document back as it was. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none(), "{name}: undo takes the selection"); + assert_eq!(layers(&s), layers_before, "{name}: undo takes the whole session"); + assert_eq!(pixels(&s), pixels_before, "{name}: nothing else was changed"); + } +} + +#[test] +fn the_selection_is_the_glyph_shape_the_type_mask_of_the_same_text_makes() { + for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] { + let mut s = make(); + let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap(); + commit(&mut s, layer); + let (got, got_bounds) = (coverage(&s), selection_corners(&s)); + + // The pin: the same text straight through the Type Mask command. + let mut pin = make(); + pin.execute("type.createSelection", typed(false)).unwrap(); + assert_eq!(got_bounds, selection_corners(&pin), "{name}: same selection bounds"); + assert_eq!(got, coverage(&pin), "{name}: same selection coverage"); + } +} + +#[test] +fn rgb_grayscale_cmyk_and_lab_keep_making_type_layers_at_every_depth() { + for (id, want) in [("rgb", ColorMode::Rgb), ("gray", ColorMode::Grayscale), ("cmyk", ColorMode::Cmyk), ("lab", ColorMode::Lab)] { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 240, "height": 120, "mode": id, "depth": depth, "background": "transparent"})).unwrap(); + assert_eq!(mode(&s), want, "{id} at {depth} bit: the document really is that mode"); + let point = new_text_layer(&s, &typed(false), "type.create").unwrap(); + assert!(!commits_as_selection(want, &point), "{id} keeps type layers"); + + let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap(); + // Today's commit: rename the session's layer in the same step, select nothing. + let name = super::type_cmds::layer_name("Hi"); + s.execute("type.edit", json!({"layer": layer, "name": name, "coalesce": SESSION})).unwrap(); + assert_eq!(text_layers(&s), 1, "{id} at {depth} bit keeps its type layer"); + assert!(s.active().unwrap().doc.selection.is_none(), "{id} at {depth} bit selects nothing"); + let h = history(&s); + assert_eq!(h.len(), 2, "{id} at {depth} bit: open plus one step for the session: {h:?}"); + assert_eq!(h.last().map(String::as_str), Some("New Type Layer"), "{id} at {depth} bit keeps the step's name"); + } + } +} + +#[test] +fn the_type_layer_command_keeps_making_the_tools_working_layer_in_these_modes() { + // `type.create` is the primitive the Type tools create the layer they edit with (and scripts and + // actions their text layers with), so the colour-mode branch is the commit's, not its own. + for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] { + let mut s = make(); + let id = s.execute("type.create", typed(false)).unwrap()["layer"].as_u64().unwrap(); + assert!(matches!(s.active().unwrap().doc.layer(LayerId(id)).unwrap().content, LayerContent::Text(_)), "{name}: the command still makes the layer"); + assert!(s.active().unwrap().doc.selection.is_none(), "{name}: and selects nothing itself"); + assert_eq!(history(&s).last().map(String::as_str), Some("New Type Layer"), "{name}: its own step name"); + } +} + +#[test] +fn a_refused_conversion_leaves_the_typed_text_and_the_document_alone() { + // The commit's fallback in `ui-egui` keeps today's type layer when the conversion is refused, + // so the typed text is not lost with a selection that was never made. Here the document would + // have to keep no layer at all, which is what `type.createSelection` with `removeLayer` refuses. + for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] { + let mut s = make(); + let only = s.active().unwrap().doc.layers[0].id.0; + let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.delete", json!({"layer": only})).unwrap(); + let (pixels_before, steps, label) = (pixels(&s), history(&s).len(), history(&s).last().cloned()); + + assert!( + s.execute("type.createSelection", json!({"layer": layer, "removeLayer": true, "operation": "new", "coalesce": SESSION})).is_err(), + "{name}: the conversion is refused" + ); + // `commit_here` stops there and keeps the layer, so the step is never renamed either. + assert_eq!(text_layers(&s), 1, "{name}: the typed text stays as a type layer"); + assert!(s.active().unwrap().doc.selection.is_none(), "{name}: nothing was selected"); + assert_eq!(pixels(&s), pixels_before, "{name}: the refused step painted nothing"); + assert_eq!(history(&s).len(), steps, "{name}: the refused step is no new state"); + assert_eq!(history(&s).last().cloned(), label, "{name}: the refused conversion renames nothing"); + } +} + +/// The measurement covers horizontal point text. Paragraph (box) text and vertical type are on the +/// issue's *Not measured* list, where its rule is that today's behaviour stays - and a coverage mask +/// could not give a reflowing box or a stacked column back anyway, so their editable layer stays. +#[test] +fn only_the_measured_horizontal_point_text_converts_in_these_modes() { + let s = indexed(); + let point = new_text_layer(&s, &typed(false), "type.create").unwrap(); + assert!(matches!(point.shape, TextShape::Point) && point.orientation == Orientation::Horizontal, "the fixture is horizontal point text"); + + let mut box_params = typed(false); + box_params["box"] = json!([20.0, 20.0, 180.0, 80.0]); + let paragraph = new_text_layer(&s, &box_params, "type.create").unwrap(); + assert!(matches!(paragraph.shape, TextShape::Box { .. }), "the fixture is paragraph text"); + + let mut vertical_params = typed(false); + vertical_params["orientation"] = json!("vertical"); + let vertical = new_text_layer(&s, &vertical_params, "type.create").unwrap(); + assert_eq!(vertical.orientation, Orientation::Vertical, "the fixture is vertical text"); + + for mode in [ColorMode::Indexed, ColorMode::Bitmap] { + assert!(commits_as_selection(mode, &point), "{mode:?}: the measured text becomes the selection"); + assert!(!commits_as_selection(mode, ¶graph), "{mode:?}: paragraph text keeps its type layer"); + assert!(!commits_as_selection(mode, &vertical), "{mode:?}: vertical text keeps its type layer"); + } +} + +/// An edit that does not carry the session's `coalesce` key ends its coalesce group - in the tool +/// that is the deliberately per-press Alt+arrow kerning step. The commit then makes a state of its +/// own, which is what the Type Mask tools' commit and the plain layer's rename do in the same +/// situation today; nothing is mis-named and nothing else moves. +#[test] +fn an_uncoalesced_edit_between_typing_and_the_commit_splits_the_states() { + for (name, make) in [("Indexed Color", indexed as fn() -> Session), ("Bitmap", bitmap as fn() -> Session)] { + let mut s = make(); + let layer = s.execute("type.create", typed(true)).unwrap()["layer"].as_u64().unwrap(); + // The stand-in for that kerning press: a real edit under no coalesce key. + s.execute("type.edit", json!({"layer": layer, "antialias": "sharp"})).unwrap(); + let before = history(&s); + assert_eq!(before.last().map(String::as_str), Some("Edit Type"), "{name}: the unkeyed edit is a state of its own"); + + commit(&mut s, layer); + + let h = history(&s); + assert_eq!(h.len(), before.len() + 1, "{name}: the commit is a further state once the group ended: {h:?}"); + assert_eq!(h.last().map(String::as_str), Some("New Type Layer"), "{name}: named for the type layer either way"); + assert_eq!(h[h.len() - 2].as_str(), "Edit Type", "{name}: the edit between them keeps its own name"); + assert_eq!(text_layers(&s), 0, "{name}: still no type layer is left"); + assert!(s.active().unwrap().doc.selection.is_some(), "{name}: still the glyphs are selected"); + + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none(), "{name}: one undo takes the conversion"); + assert_eq!(text_layers(&s), 1, "{name}: which brings the working layer back, as the mask tools' undo does"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(text_layers(&s), 1, "{name}: the next undo takes the unkeyed edit, not the session"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(text_layers(&s), 0, "{name}: and the third one takes the typing session"); + } +} diff --git a/crates/engine/src/cmd/vector_cmds.rs b/crates/engine/src/cmd/vector_cmds.rs index bec8a3f242f..1f1cfb86221 100644 --- a/crates/engine/src/cmd/vector_cmds.rs +++ b/crates/engine/src/cmd/vector_cmds.rs @@ -230,6 +230,17 @@ pub fn specs() -> Vec { run: |s, p| path_to_selection_with_mode(s, p, "subtract"), journal: true, }, + // ⇧⌥⌘↩ / Ctrl+Alt+Shift+Enter intersects the path with the selection (#7726). + CommandSpec { + id: "path.intersectWithSelection", + label: "Intersect Path with Selection", + menu: &[], + shortcut: Some("Cmd+Alt+Shift+Enter"), + params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "intersect")"##, + enabled: has_doc, + run: |s, p| path_to_selection_with_mode(s, p, "intersect"), + journal: true, + }, spec!( "path.toShape", "Make Shape from Path", diff --git a/crates/engine/src/cmd/vector_cmds/paths.rs b/crates/engine/src/cmd/vector_cmds/paths.rs index 7c7f550a298..e54c38fd198 100644 --- a/crates/engine/src/cmd/vector_cmds/paths.rs +++ b/crates/engine/src/cmd/vector_cmds/paths.rs @@ -371,7 +371,8 @@ pub(super) fn path_to_selection(s: &mut Session, p: &Value) -> Result { Ok(json!({ "selected": selected })) } -/// `path.toSelection` with the combine mode fixed by the command (⌘⇧↩ adds, ⌘⌥↩ subtracts); +/// `path.toSelection` with the combine mode fixed by the command (⌘⇧↩ adds, ⌘⌥↩ subtracts, +/// ⇧⌥⌘↩ intersects); /// a `mode` in the params is overridden, the rest is forwarded and validated as usual. pub(super) fn path_to_selection_with_mode(s: &mut Session, p: &Value, mode: &str) -> Result { let mut q = match p { diff --git a/crates/engine/src/cmd/vector_cmds/tests.rs b/crates/engine/src/cmd/vector_cmds/tests.rs index 5a44943345d..93d220a1de1 100644 --- a/crates/engine/src/cmd/vector_cmds/tests.rs +++ b/crates/engine/src/cmd/vector_cmds/tests.rs @@ -568,6 +568,26 @@ fn open_pen_paths_make_the_same_selection_as_closed_paths() { } } +/// ⇧⌘↩, ⌥⌘↩ and ⇧⌥⌘↩'s commands are Make Selection from Path with their mode (#7726), whatever +/// `mode` the params ask for. +#[test] +fn the_fixed_mode_path_commands_combine_as_their_mode() { + let mut s = session(80, 80, 8); + let square = |x: f64| path_json(&vector::shapes::rect(x, 20.0, 30.0, 30.0)); + s.execute("path.set", json!({"name": "Sq", "path": square(30.0)})).unwrap(); + for (id, mode) in [("path.addToSelection", "add"), ("path.subtractFromSelection", "subtract"), ("path.intersectWithSelection", "intersect")] { + s.execute("select.rect", json!({"x": 10, "y": 20, "width": 30, "height": 30, "antiAlias": false})).unwrap(); + s.execute("path.toSelection", json!({"name": "Sq", "mode": mode, "antiAlias": false})).unwrap(); + let want = selection_mask(&s); + s.execute("select.rect", json!({"x": 10, "y": 20, "width": 30, "height": 30, "antiAlias": false})).unwrap(); + s.execute(id, json!({"name": "Sq", "mode": "replace", "antiAlias": false})).unwrap(); + assert_eq!(selection_mask(&s), want, "{id} is mode {mode}"); + } + // Intersecting keeps only the 10 px both squares cover. + let mask = selection_mask(&s); + assert_eq!(mask.iter().filter(|v| **v > 0.5).count(), 10 * 30); +} + #[test] fn selection_to_work_path_roundtrip_iou() { let mut s = session(240, 180, 8); diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 06123cbab11..cb9bc5fe8a4 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -200,7 +200,7 @@ pub struct ToolState { impl ToolState { /// How many CMYK picks are remembered. - const CMYK_PICKS: usize = 8; + pub const CMYK_PICKS: usize = 8; /// Remember that sRGB colour `rgb` was picked as the inks `cmyk` (each 0..1). pub fn remember_cmyk(&mut self, rgb: [f32; 3], cmyk: [f32; 4]) { diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 8654a38507f..2197d57f910 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -507,9 +507,9 @@ struct Running { label: String, params: Value, journal: bool, - /// The recording (`ActionState::take`) running when the job started, if any (set by - /// `dispatch`, once recording resumes). Only that recording gets the job's step: one started - /// later must not pick up an edit made before it. + /// The take active at dispatch, including strokes started with tool recording off. + started_in_take: Option, + /// The take eligible to receive this job's step under the mode at dispatch. recorded_by: Option, pre_selected: Option>, before: crate::actions_record::Before, @@ -863,6 +863,7 @@ fn new_job( label: label.to_string(), params: Value::Null, journal: true, + started_in_take: None, recorded_by: None, pre_selected: None, before: crate::actions_record::Before::capture(s), @@ -956,6 +957,14 @@ pub fn import_options(prefs: &crate::prefs::Preferences) -> photocraft_io::Impor photocraft_io::ImportOptions { max_group_depth: prefs.file_handling.rasterize_svg_groups_deeper_than as usize, demosaic: prefs.raw_defaults.demosaic.method(), + pdf_dpi: prefs.file_handling.pdf_dpi, + pdf_pages: match (prefs.file_handling.pdf_first_page, prefs.file_handling.pdf_last_page) { + (0, 0) => None, + (first, last) => { + let first = first.max(1); + Some((first, if last == 0 { u32::MAX } else { last.max(first) })) + } + }, ..Default::default() } } @@ -1031,6 +1040,11 @@ pub struct PendingPsdPreflight { } impl Session { + /// A recording mode change must not reclassify an in-flight stroke from this take. + pub(crate) fn tool_stroke_job_running(&self) -> bool { + self.jobs.running.iter().any(|job| job.started_in_take == Some(self.actions.take) && crate::actions_cmds::tool_stroke(&job.command)) + } + /// Run the heavy part of inline (not background) job commands under `ctx`, so another thread /// can cancel them: a live preview whose parameters changed stops its stale computation /// (`Err(Cancelled)`, the document unchanged) instead of finishing it. `None` restores the @@ -1814,7 +1828,8 @@ impl Session { job.command = id.to_string(); job.params = params; job.journal = spec.journal; - job.recorded_by = self.actions.recording.map(|_| self.actions.take); + job.started_in_take = self.actions.recording.map(|_| self.actions.take); + job.recorded_by = job.started_in_take.filter(|_| self.actions.record_tools || !crate::actions_cmds::tool_stroke(id)); job.pre_selected = pre_selected.clone(); job.before = std::mem::take(&mut record_before); let jid = job.id; @@ -2141,7 +2156,7 @@ mod tests; #[cfg(test)] #[path = "../../io/tests/support/recovery_psd.rs"] -mod psd_open_fixture; +pub(crate) mod psd_open_fixture; #[cfg(test)] mod psd_open_confirmation_tests { diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 9bb6ab76933..f89779954bc 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -2,6 +2,7 @@ use photocraft_color::{BlendMode, Color}; use photocraft_doc::adjust::CurvePoint; +use photocraft_doc::effects::PATTERN_SCALE_RANGE; use photocraft_doc::{ Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Satin, Shadow, StrokeFx, StrokePosition, @@ -804,7 +805,7 @@ fn overlay_pattern(fx: &mut Effect, p: &Value) { *id = String::new(); } if let Some(x) = f_of(p, "scale") { - *scale = (x / 100.0).clamp(0.01, 10.0); + *scale = (x / 100.0).clamp(PATTERN_SCALE_RANGE.0, PATTERN_SCALE_RANGE.1); } if let Some(x) = f_of(p, "angle") { *angle = x; @@ -912,7 +913,7 @@ fn overlay_bevel(bv: &mut Bevel, p: &Value) { bv.texture = Some(photocraft_doc::BevelTexture { name: t.to_string(), id: t.to_string(), - scale: (f(p, "textureScale", 100.0) / 100.0).clamp(0.01, 10.0), + scale: (f(p, "textureScale", 100.0) / 100.0).clamp(PATTERN_SCALE_RANGE.0, PATTERN_SCALE_RANGE.1), depth: (f(p, "textureDepth", 100.0) / 100.0).clamp(-10.0, 10.0), invert: b(p, "textureInvert", false), link: b(p, "textureLink", true), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8f281472172..24187b53bd6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -346,6 +346,8 @@ mod embed_all_linked_tests; #[cfg(test)] mod fill_layer_mode_tests; #[cfg(test)] +mod mixer_indexed_bitmap_tests; +#[cfg(test)] mod pattern_mode_tests; #[cfg(test)] mod second_keys_zoom_actions_tests; @@ -355,4 +357,6 @@ mod tests; #[cfg(test)] mod fx_mode_tests; +#[cfg(test)] +mod brush_clear_background_tests; pub mod command_hints; diff --git a/crates/engine/src/mixer_indexed_bitmap_tests.rs b/crates/engine/src/mixer_indexed_bitmap_tests.rs new file mode 100644 index 00000000000..8f31a41cd5d --- /dev/null +++ b/crates/engine/src/mixer_indexed_bitmap_tests.rs @@ -0,0 +1,60 @@ +//! The Mixer Brush refuses Indexed Color and Bitmap documents with the reference's alert texts +//! (#11958): `paint.mixerBrush` returns the error and leaves the document and History unchanged. +//! RGB, CMYK, Lab and Grayscale documents still paint. + +use crate::Session; +use crate::brush_cmds::mixer_live::{BITMAP_REFUSAL, INDEXED_REFUSAL}; +use serde_json::json; + +fn stroke(s: &mut Session) -> crate::Result { + s.execute("paint.mixerBrush", json!({"points": [[10, 10], [40, 20]], "size": 12, "wet": 0, "mix": 0})) +} + +fn assert_refused(s: &mut Session, text: &str) { + let (doc, steps) = (s.active().unwrap().doc.clone(), s.active().unwrap().history.past_len()); + let err = stroke(s).unwrap_err().to_string(); + assert_eq!(err, text); + assert!(std::sync::Arc::ptr_eq(&doc, &s.active().unwrap().doc), "nothing painted"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "no history step"); +} + +#[test] +fn indexed_color_documents_are_refused_with_the_alert() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 32, "background": "white"})).unwrap(); + s.execute("image.mode.indexedColor", json!({})).unwrap(); + assert_refused(&mut s, INDEXED_REFUSAL); + assert_eq!( + INDEXED_REFUSAL, + "Could not use the Mixer Brush tool because it does not work with Indexed Color images (convert image to RGB Color mode to edit)." + ); +} + +#[test] +fn bitmap_documents_are_refused_with_the_alert() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 32, "background": "white"})).unwrap(); + s.execute("image.mode.grayscale", json!({})).unwrap(); + s.execute("image.mode.bitmap", json!({})).unwrap(); + assert_refused(&mut s, BITMAP_REFUSAL); + assert_eq!(BITMAP_REFUSAL, "Could not use the Mixer Brush tool because it does not work with Bitmap images (convert image to Grayscale mode to edit)."); +} + +#[test] +fn rgb_cmyk_lab_and_grayscale_documents_still_paint() { + let mut painted = 0; + for mode in ["rgb", "cmyk", "lab", "grayscale"] { + for depth in [8, 16, 32] { + let mut s = Session::new(); + // Not every mode has every depth (as in the reference, CMYK and Lab stop at 16 bits). + if s.execute("file.new", json!({"width": 64, "height": 32, "mode": mode, "depth": depth, "background": "white"})).is_err() { + continue; + } + let steps = s.active().unwrap().history.past_len(); + stroke(&mut s).unwrap_or_else(|e| panic!("{mode} {depth}: {e}")); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{mode} {depth}"); + painted += 1; + } + } + assert!(painted >= 8, "only {painted} mode/depth combinations painted"); +} diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index ba36f1f850c..95566aa0436 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -188,6 +188,78 @@ impl Unit { let unit = if suffix.is_empty() { None } else { Some(Unit::from_suffix(suffix)?) }; Some((v, unit)) } + /// A length typed as Photoshop's numeric fields take arithmetic (#10440): lengths and numbers + /// joined by `+`, `-`, `*` and `/`, e.g. "100% / 3", "100% - 50px", "1in + 10", "2 * 50 px". + /// `*` and `/` bind tighter, and `−` (U+2212) is a minus. A number without a unit added or + /// subtracted takes the first operand's unit (Photoshop: "1in + 10" is 11 in), or `default` + /// when the first has none; one multiplied or divided is a plain number. `to_px` converts a + /// value in a unit. `None` when the text is over 1024 bytes (the numeric fields' cap) or isn't + /// such an expression, multiplies two lengths, divides by a length or by zero, or the result + /// isn't finite. + pub fn eval_length(text: &str, default: Unit, to_px: impl Fn(f64, Unit) -> f64) -> Option { + if text.len() > 1024 { + return None; + } + let text = text.replace('−', "-"); + // Tokens: operators, and operands (a number with an optional unit suffix). + let mut ops = Vec::new(); + let mut operands: Vec<(f64, Option)> = Vec::new(); + let mut rest = text.trim(); + let mut want_operand = true; + while !rest.is_empty() { + if want_operand { + let num_end = rest + .char_indices() + .find(|&(i, c)| !(c.is_ascii_digit() || matches!(c, '.' | ',') || (i == 0 && matches!(c, '-' | '+')))) + .map_or(rest.len(), |(i, _)| i); + let after = &rest[num_end..]; + let unit_end = after.find(['+', '-', '*', '/']).unwrap_or(after.len()); + let (v, unit) = Unit::parse_length(&rest[..num_end + unit_end])?; + operands.push((v, unit)); + rest = after[unit_end..].trim_start(); + want_operand = false; + } else { + let op = rest.chars().next()?; + if !matches!(op, '+' | '-' | '*' | '/') { + return None; + } + ops.push(op); + rest = rest[1..].trim_start(); + want_operand = true; + } + } + if want_operand || operands.is_empty() { + return None; + } + let first_unit = operands[0].1.unwrap_or(default); + // Fold the products and quotients into terms: (sum of px for unit factors, scalar, has a unit). + let mut terms: Vec<(char, f64, Option)> = Vec::new(); + let (mut sign, mut value, mut unit) = ('+', operands[0].0, operands[0].1); + for (op, (v, u)) in ops.iter().zip(operands.iter().skip(1)) { + match op { + '*' => { + if unit.is_some() && u.is_some() { + return None; + } + value *= v; + unit = unit.or(*u); + } + '/' => { + if u.is_some() || *v == 0.0 { + return None; + } + value /= v; + } + _ => { + terms.push((sign, value, unit)); + (sign, value, unit) = (*op, *v, *u); + } + } + } + terms.push((sign, value, unit)); + let px: f64 = terms.iter().map(|&(s, v, u)| if s == '-' { -1.0 } else { 1.0 } * to_px(v, u.unwrap_or(first_unit))).sum(); + px.is_finite().then_some(px) + } /// The unit a typed suffix names, in any case: `px`, `in` or `"`, `cm`, `mm`, `pt`, `pc` or /// `pica`, `%`, and their long forms (`pixels`, `inches`, `centimetres`…). The one table every /// length field reads. @@ -498,6 +570,12 @@ pub struct FileHandling { pub recover_on_launch: bool, /// Open a file with no ICC profile as untagged even when its EXIF ColorSpace tag says sRGB. pub ignore_exif_profile_tag: bool, + /// PDF import resolution in DPI; the document keeps it so the page's print size is preserved. + pub pdf_dpi: f32, + /// The first PDF page to import (1-based; 0 imports from the first page). + pub pdf_first_page: u32, + /// The last PDF page to import (1-based; 0 imports to the last page). + pub pdf_last_page: u32, /// Most renderers write ACEScg EXRs without stating the colour space. With this on, an EXR /// that would otherwise open as linear sRGB opens as ACEScg (AP1, linear) instead. pub exr_aces_cg: bool, @@ -535,6 +613,9 @@ impl Default for FileHandling { autosave_minutes: 10, recover_on_launch: true, ignore_exif_profile_tag: false, + pdf_dpi: 300.0, + pdf_first_page: 0, + pdf_last_page: 0, exr_aces_cg: false, ask_before_saving_layered_tiff: true, maximize_psd_compatibility: Ask::Always, @@ -558,6 +639,10 @@ pub struct Export { pub webp_quality: u32, pub metadata: ExportMetadata, pub convert_to_srgb: bool, + /// The folder the last successful export through a save dialog went to (Export As, Quick + /// Export, Save for Web, ...). The next export dialog starts there; empty until the first + /// export, and ignored while the folder no longer exists (#10192). + pub last_folder: String, } impl Default for Export { @@ -570,6 +655,7 @@ impl Default for Export { webp_quality: 85, metadata: ExportMetadata::Copyright, convert_to_srgb: true, + last_folder: String::new(), } } } @@ -1301,6 +1387,9 @@ pub struct Preferences { /// Last selected tool, restored at launch. A tool-name string owned and validated by the /// shell; null in older files. Kept separate from tool options so neither overwrites the other. pub selected_tool: Value, + /// Foreground/background swatches for pixels and masks and exact CMYK picks, restored by the shell at launch. + /// Null in older files, which retain the default black/white pixel colours. + pub tool_colors: Value, /// The tools used last, most recent first, restored at launch so each toolbar slot shows the /// tool it was left on and each tool key brings it back, as in Photoshop (#3569). Tool-name /// strings owned and validated by the shell; null in older files. diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index c92528de226..0bb3a138576 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -612,6 +612,61 @@ fn tool_presets_save_filter_select() { assert!(s.execute("tool.presets.new", json!({"name": "x"})).is_err()); } +#[test] +fn tool_presets_reject_invalid_colors_before_applying_the_brush() { + let mut invalid = vec![ + json!(null), + json!(true), + json!(7), + json!("red"), + json!({}), + json!([]), + json!([1]), + json!([1, 0]), + json!([1, 0, 0, 1, 0]), + json!([null, 0.4, 0.6]), + ]; + for channel in 0..4 { + for bad_channel in [json!(1e100), json!(-1e100), json!(null), json!("red"), json!(true), json!({}), json!([])] { + let mut values = json!([0.2, 0.4, 0.6, 0.8]); + values[channel] = bad_channel; + invalid.push(values); + } + } + for values in invalid { + let mut s = Session::new(); + s.execute("tools.setColors", json!({"foreground": [0.2, 0.4, 0.6, 0.8]})).unwrap(); + let color = s.tools.foreground; + let brush = s.tools.brush.clone(); + let brush_rev = s.tools.brush_rev; + s.execute( + "tool.presets.new", + json!({ + "name": "Invalid", "tool": "brush", "options": {"brush": {"size": 77}, "foreground": values} + }), + ) + .unwrap(); + assert!(s.execute("tool.presets.select", json!({"preset": "Invalid"})).is_err(), "{values}"); + assert_eq!(s.tools.foreground, color, "{values}"); + assert_eq!(s.tools.brush, brush, "{values}"); + assert_eq!(s.tools.brush_rev, brush_rev, "{values}"); + } +} + +#[test] +fn tool_presets_keep_finite_hdr_colors_and_leave_absent_colors_unchanged() { + let mut s = Session::new(); + for (options, expected) in [ + (json!({"foreground": [1.5, -0.1, 0.6, 0.8]}), [1.5, -0.1, 0.6, 0.8]), + (json!({"foreground": [0.2, 0.4, 0.6]}), [0.2, 0.4, 0.6, 1.0]), + (json!({}), [0.2, 0.4, 0.6, 1.0]), + ] { + let preset = s.execute("tool.presets.new", json!({"tool": "move", "options": options})).unwrap(); + s.execute("tool.presets.select", json!({"preset": preset["name"]})).unwrap(); + assert_eq!(s.tools.foreground, expected); + } +} + /// An Art History Brush preset captures the brush's opacity and selecting it restores it. #[test] fn art_history_brush_preset_carries_the_opacity() { diff --git a/crates/engine/src/presets/tools.rs b/crates/engine/src/presets/tools.rs index d2fd6c52d27..25b70eb185d 100644 --- a/crates/engine/src/presets/tools.rs +++ b/crates/engine/src/presets/tools.rs @@ -158,13 +158,21 @@ fn select(s: &mut Session, p: &Value) -> Result { .find(|t| t.name == name) .cloned() .ok_or_else(|| bad(CMD, format!("no tool preset \"{name}\" (see tool.presets.list)")))?; + // Validate before applying the brush too: an invalid colour must leave the preset unapplied. + let foreground = match t.options.get("foreground") { + None => s.tools.foreground, + Some(value) => { + let valid = value.as_array().is_some_and(|channels| matches!(channels.len(), 3 | 4) && channels.iter().all(Value::is_number)); + if !valid { + return Err(bad(CMD, "`foreground` must be [r, g, b] or [r, g, b, a] numbers")); + } + crate::commands::color_param(CMD, &t.options, "foreground", s.tools.foreground)? + } + }; if let Some(b) = t.options.get("brush").filter(|b| b.is_object()) { super::call(s, "tools.setBrush", json!({"brush": b}))?; } - if let Some(c) = t.options.get("foreground").and_then(Value::as_array).filter(|a| a.len() >= 3) { - let v: Vec = c.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect(); - s.tools.foreground = [v[0], v[1], v[2], v.get(3).copied().unwrap_or(1.0)]; - } + s.tools.foreground = foreground; Ok(json!({"name": t.name, "tool": t.tool, "options": t.options})) } diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 49f8205079a..b5b82a4753a 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -193,6 +193,11 @@ impl Session { self.next_held_open } + /// Whether a document held back for its prompt already has `name`. + pub(crate) fn held_open_named(&self, name: &str) -> bool { + self.held_opens.iter().any(|(_, d, _)| d.name == name) + } + /// Takes a document a background open held back for the profile prompt: the document and /// whether its source is read-only. `None` when there is no such document (already taken). pub fn take_held_open(&mut self, id: u64) -> Option<(Document, bool)> { diff --git a/crates/engine/src/shell_commands.rs b/crates/engine/src/shell_commands.rs index 49db9509e52..7aaf58ffcd6 100644 --- a/crates/engine/src/shell_commands.rs +++ b/crates/engine/src/shell_commands.rs @@ -169,6 +169,7 @@ pub const TOOLS: &[(&str, &str, Option<&str>)] = &[ ("tools.select.ellipseShape", "Ellipse Tool", Some("U")), ("tools.select.triangle", "Triangle Tool", Some("U")), ("tools.select.polygon", "Polygon Tool", Some("U")), + ("tools.select.star", "Star Tool", Some("U")), ("tools.select.line", "Line Tool", Some("U")), ("tools.select.customShape", "Custom Shape Tool", Some("U")), ("tools.select.slice", "Slice Tool", Some("C")), diff --git a/crates/engine/tests/image_size_scale_styles_atn.rs b/crates/engine/tests/image_size_scale_styles_atn.rs index 4f65bcc34eb..72b91241a43 100644 --- a/crates/engine/tests/image_size_scale_styles_atn.rs +++ b/crates/engine/tests/image_size_scale_styles_atn.rs @@ -125,7 +125,7 @@ fn native_default_is_written_explicitly_and_keeps_its_behavior() { } } -/// Unsupported flags and empty steps remain complete descriptors instead of becoming resizes. +/// Only an absent descriptor is menu intent. Empty or invalid descriptors remain kept steps. #[test] fn malformed_or_empty_steps_are_kept_without_becoming_resizes() { for event in ["ImgS", "imageSize"] { @@ -143,12 +143,20 @@ fn malformed_or_empty_steps_are_kept_without_becoming_resizes() { let original = atn::parse(&source).unwrap(); let mut s = Session::new(); let loaded = load(&mut s, &source); - assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1); - assert_eq!(s.actions.list[0].steps.len(), 1); - assert_eq!(s.actions.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let (step, params) = &s.actions.list[0].steps[0]; + if original.actions[0].items[0].descriptor.is_none() { + assert_eq!(loaded["unsupported"], json!([])); + assert_eq!(step, atn_cmds::MENU_ITEM); + assert_eq!(params["id"], "image.imageSize"); + assert_eq!(params["eventKind"], if event == "ImgS" { "code" } else { "name" }); + } else { + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(step, atn_cmds::PHOTOSHOP_STEP); + } let saved = atn::parse(&save(&mut s)).unwrap(); assert_eq!(saved.actions[0].items[0].descriptor, original.actions[0].items[0].descriptor); assert_eq!(saved.actions[0].items[0].event, original.actions[0].items[0].event); + assert_eq!(saved.actions[0].items[0].dictionary, original.actions[0].items[0].dictionary); } } } diff --git a/crates/engine/tests/it/actions_duplicate.rs b/crates/engine/tests/it/actions_duplicate.rs new file mode 100644 index 00000000000..e5c97d294d3 --- /dev/null +++ b/crates/engine/tests/it/actions_duplicate.rs @@ -0,0 +1,122 @@ +//! Duplicate keeps action identities, complete settings and per-step flags stable. +use photocraft_engine::{Session, actions_cmds::Action}; +use serde_json::{Value, json}; + +/// A tiny document to distinguish definition edits from document mutations and playback. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + s +} + +#[test] +fn action_copies_follow_the_source_without_retargeting_calls_or_function_keys() { + let mut s = session(); + let mut source = Action::new("Source", vec![("actions.play".into(), json!({"action": 1})), ("layer.select".into(), json!({"layer": "Photo"}))]); + source.disabled.insert(1); + source.dialogs.insert(0); + source.set = Some("Retouch".into()); + source.color = "red".into(); + let foreign = json!({"actions": [{"name": "Foreign", "steps": [["actions.play", {"action": 1}]]}]}); + s.actions.list = vec![ + source.clone(), + Action::new("Target", vec![("select.all".into(), json!({}))]), + Action::new( + "Caller", + vec![ + ("actions.play".into(), json!({"action": 1})), + ("actions.play".into(), json!({"action": 3})), // Unresolved before and after insertion. + ("actions.play".into(), json!({"action": 1.0})), + ("file.automate.batch".into(), foreign.clone()), + ("actions.conditional".into(), json!({"if": "always", "then": "Target", "else": "Source"})), + ], + ), + ]; + s.prefs.edit(|p| { + p.shortcuts.insert("actions.play:Source".into(), "F6".into()); + }); + let revision = s.active().unwrap().revision; + let result = s.execute("actions.duplicate", json!({"action": "Source"})).unwrap(); + assert_eq!(result["index"], 1); + assert_eq!(s.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["Source", "Source copy", "Target", "Caller"]); + let mut expected = source; + expected.name = "Source copy".into(); + expected.steps[0].1["action"] = json!(2); + assert_eq!(s.actions.list[1], expected); + assert_eq!(s.actions.list[0].steps[0].1["action"], 2); + let caller = &s.actions.list[3]; + assert_eq!(caller.steps[0].1["action"], 2); + assert_eq!(caller.steps[1].1["action"], 4); + assert_eq!(caller.steps[2].1["action"], 2.0); + assert_eq!(caller.steps[3].1, foreign, "embedded libraries keep their own indices"); + assert_eq!(caller.steps[4].1, json!({"if": "always", "then": "Target", "else": "Source"})); + assert_eq!(s.prefs().shortcuts.get("actions.play:Source").map(String::as_str), Some("F6")); + assert!(!s.prefs().shortcuts.contains_key("actions.play:Source copy")); + assert_eq!(s.active().unwrap().revision, revision); + let played = s.execute("actions.play", json!({"action": "Source copy"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert!(s.active().unwrap().doc.selection.is_some(), "the copied call still plays Target rather than itself"); + s.execute("actions.duplicate", json!({"action": 0})).unwrap(); + assert_eq!(s.actions.list[1].name, "Source copy 2"); + assert_eq!(s.actions.list[2].name, "Source copy"); + assert_eq!(s.actions.list[1].steps[0].1["action"], 3); +} + +#[test] +fn step_copies_preserve_payloads_and_shift_both_flag_columns() { + let mut s = session(); + let payload = json!({"layer": "Photo", "_actionLayer": {"name": "Photo", "ordinal": 2}}); + let mut a = Action::new("Steps", vec![("layer.select".into(), payload.clone()), ("select.all".into(), Value::Null), ("select.deselect".into(), json!({}))]); + a.disabled.extend([0, 2]); + a.dialogs.extend([0, 1]); + s.actions.list.push(a); + let revision = s.active().unwrap().revision; + let result = s.execute("actions.duplicate", json!({"action": 0, "step": 0})).unwrap(); + assert_eq!(result["step"], 1); + assert_eq!(result["index"], 0); + let a = &s.actions.list[0]; + assert_eq!(a.name, "Steps"); + assert_eq!(a.steps[0], a.steps[1]); + assert_eq!(a.disabled.iter().copied().collect::>(), [0, 1, 3]); + assert_eq!(a.dialogs.iter().copied().collect::>(), [0, 1, 2]); + s.execute("actions.duplicate", json!({"action": 0, "step": 2})).unwrap(); + assert!(s.actions.list[0].steps[2].1.is_null()); + assert!(s.actions.list[0].steps[3].1.is_null(), "copying does not normalize null settings to an empty object"); + s.actions.list[0].steps[1].1["layer"] = json!(99); + assert_eq!(s.actions.list[0].steps[0].1, payload, "the copy owns its settings"); + assert_eq!(s.active().unwrap().revision, revision); +} + +#[test] +fn invalid_copies_do_not_flush_recording_and_valid_step_copies_keep_recording() { + let mut s = session(); + s.execute("actions.record", json!({"name": "Recording"})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + let before = s.actions.list.clone(); + let recording = s.actions.recording; + let revision = s.active().unwrap().revision; + for params in [ + json!({}), + json!({"action": 9}), + json!({"action": 0}), + json!({"action": 0, "step": -1}), + json!({"action": 0, "step": 5}), + json!({"action": 0, "step": "x"}), + ] { + assert!(s.execute("actions.duplicate", params.clone()).is_err(), "{params}"); + assert_eq!(s.actions.list, before); + assert_eq!(s.actions.recording, recording); + assert_eq!(s.active().unwrap().revision, revision); + } + s.execute("actions.duplicate", json!({"action": 0, "step": 0})).unwrap(); + assert_eq!(s.actions.list[0].steps, vec![("select.all".into(), json!({})); 2]); + assert!(s.actions.recording.is_some()); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps.len(), 3); + s.actions.list.push(Action::new("Edit during playback", vec![("actions.duplicate".into(), json!({"action": 0}))])); + let before = s.actions.list.clone(); + let played = s.execute("actions.play", json!({"action": "Edit during playback"})).unwrap(); + assert!(played["failed"]["error"].as_str().is_some_and(|e| e.contains("playback")), "{played}"); + assert_eq!(s.actions.list, before); +} diff --git a/crates/engine/tests/it/actions_panel_menu.rs b/crates/engine/tests/it/actions_panel_menu.rs new file mode 100644 index 00000000000..6b65f2f08d4 --- /dev/null +++ b/crates/engine/tests/it/actions_panel_menu.rs @@ -0,0 +1,200 @@ +//! The remaining panel commands compose with the same definitions, journal and ATN boundary. +use photocraft_engine::{Session, actions_cmds::Action, actions_edit_cmds, atn_cmds, jobs::Started}; +use serde_json::json; + +/// Tiny real document with one empty action keeps recording and background jobs observable. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":40,"height":30})).unwrap(); + s.actions.list.push(Action::new("A", vec![])); + s +} + +/// A painted stripe gives both the remove tool and blur a real input before recording starts. +fn stroke(s: &mut Session, color: &str) { + s.execute("paint.stroke", json!({"points":[[8,15],[32,15]],"size":3,"color":color})).unwrap(); +} + +/// Mode changes flush under the previous mode without altering painting or ordinary recording. +#[test] +fn tool_recording_is_opt_in_and_toggles_do_not_reclassify_pending_steps() { + let mut s = session(); + s.execute("actions.record", json!({"action":"A"})).unwrap(); + stroke(&mut s, "#ff0000"); + s.execute("select.all", json!({})).unwrap(); + let original = s.actions.clone(); + assert!(s.execute("actions.allowToolRecording", json!({"enabled":"yes"})).is_err()); + assert_eq!(s.actions, original, "invalid mode must not flush pending work"); + s.execute("actions.allowToolRecording", json!({"enabled":true})).unwrap(); + stroke(&mut s, "#00ff00"); + s.execute("actions.allowToolRecording", json!({"enabled":false})).unwrap(); + stroke(&mut s, "#0000ff"); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps.iter().map(|s| s.0.as_str()).collect::>(), ["select.all", "paint.stroke", "select.deselect"]); + assert_eq!(s.actions.list[0].steps[1].1["color"], "#00ff00"); + assert_eq!(s.journal.iter().filter(|s| s.0 == "paint.stroke").count(), 3, "all actual painting remains journaled"); + assert_eq!(s.execute("actions.list", json!({})).unwrap()["toolRecording"], false); +} + +/// A stroke started while off never joins a later enabled recording; ordinary jobs keep their owner. +#[test] +fn background_stroke_ownership_does_not_suppress_ordinary_filter_jobs() { + for command in ["paint.remove", "filter.blur.gaussianBlur"] { + let mut s = session(); + stroke(&mut s, "#bb3300"); + s.execute("actions.record", json!({"action":"A"})).unwrap(); + let params = if command == "paint.remove" { json!({"points":[[20,15],[21,15]],"size":3}) } else { json!({"radius":1}) }; + let Started::Job(job) = s.start(command, params).unwrap() else { panic!("expected the real background job: {command}") }; + let changed = s.execute("actions.allowToolRecording", json!({"enabled":true})); + if command == "paint.remove" { + assert!(changed.is_err(), "the in-flight stroke retains the mode it started under"); + assert!(!s.actions.record_tools); + } else { + changed.unwrap(); + } + s.wait_job(job).unwrap(); + if command == "paint.remove" { + s.execute("actions.allowToolRecording", json!({"enabled":true})).unwrap(); + } + s.execute("actions.stop", json!({})).unwrap(); + let ids = s.actions.list[0].steps.iter().map(|s| s.0.as_str()).collect::>(); + if command == "paint.remove" { + assert!(ids.is_empty()); + } else { + assert_eq!(ids, [command]); + } + } +} + +/// A Remove job started outside the current take neither blocks its toggle nor joins its steps. +#[test] +fn background_strokes_from_outside_the_current_take_do_not_block_the_toggle() { + for previous_take in [false, true] { + let mut s = session(); + stroke(&mut s, "#bb3300"); + if previous_take { + s.execute("actions.record", json!({"action":"A"})).unwrap(); + s.execute("actions.allowToolRecording", json!({"enabled":true})).unwrap(); + } + let Started::Job(job) = s.start("paint.remove", json!({"points":[[20,15],[21,15]],"size":3})).unwrap() else { + panic!("expected the real Remove job"); + }; + if previous_take { + s.execute("actions.stop", json!({})).unwrap(); + s.execute("actions.allowToolRecording", json!({"enabled":false})).unwrap(); + } + s.execute("actions.record", json!({"action":"A"})).unwrap(); + assert_eq!(s.execute("actions.allowToolRecording", json!({"enabled":true})).unwrap()["enabled"], true); + s.wait_job(job).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert!(s.actions.list[0].steps.is_empty()); + assert_eq!(s.journal.iter().filter(|step| step.0 == "paint.remove").count(), 1); + } +} + +/// Interleaved membership and numeric calls expose incorrect folder placement or target rewriting. +#[test] +fn set_duplication_preserves_metadata_call_targets_and_empty_folder_order() { + let mut s = session(); + s.actions.list.clear(); + for (name, set) in [("A", "S"), ("Other", "T"), ("B", "S")] { + let mut a = Action::new(name, vec![("actions.play".into(), json!({"action":2}))]); + a.set = Some(set.into()); + a.color = "red".into(); + a.disabled.insert(0); + a.dialogs.insert(0); + s.actions.list.push(a); + } + s.prefs.edit(|p| { + p.shortcuts.insert("actions.play:A".into(), "F5".into()); + }); + let result = s.execute("actions.duplicate", json!({"set":"S"})).unwrap(); + assert_eq!(result["set"], "S copy"); + assert_eq!(actions_edit_cmds::set_names(&s), ["S", "S copy", "T"]); + assert_eq!(s.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["A", "A copy", "B copy", "Other", "B"]); + for a in &s.actions.list { + assert_eq!(a.steps[0].1["action"], 4); + assert_eq!(a.color, "red"); + assert!(a.disabled.contains(&0)); + assert!(a.dialogs.contains(&0)); + } + assert!(!s.prefs().shortcuts.contains_key("actions.play:A copy")); + let mut empty = Session::new(); + for name in ["E", "F"] { + empty.execute("actions.newSet", json!({"name":name})).unwrap(); + } + empty.execute("actions.duplicate", json!({"set":"E"})).unwrap(); + assert_eq!(actions_edit_cmds::set_names(&empty), ["E", "E copy", "F"]); + let original = empty.actions.clone(); + for params in [json!({"set":"Missing"}), json!({"set":"E","action":0}), json!({"set":false})] { + assert!(empty.execute("actions.duplicate", params).is_err()); + assert_eq!(empty.actions, original); + } +} + +/// Menu insertion keeps descriptor absence and kind; empty descriptors and ambiguous commands differ. +#[test] +fn inserted_menu_events_roundtrip_absent_settings_and_stateless_items_execute() { + assert!(!atn_cmds::menu_item_needs_settings("filter.noise.despeckle")); + assert!(atn_cmds::menu_item_needs_settings("filter.blur.gaussianBlur")); + for id in ["image.imageSize", "filter.blur.gaussianBlur", "filter.noise.despeckle"] { + for kind in ["name", "code"] { + let mut s = session(); + s.execute("actions.insertMenuItem", json!({"action":"A","id":id,"eventKind":kind})).unwrap(); + let bytes = atn_cmds::export("S", &[&s.actions.list[0]], &[], false).unwrap().0; + let raw = photocraft_psd::atn::parse(&bytes).unwrap(); + assert!(raw.actions[0].items[0].descriptor.is_none()); + let imported = atn_cmds::import(&bytes).unwrap(); + let (step, params) = &imported.actions[0].steps[0]; + assert_eq!(step, atn_cmds::MENU_ITEM); + assert_eq!(params["id"], id); + assert_eq!(params["eventKind"], kind); + let (saved, skipped) = atn_cmds::export("S", &[&imported.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + let saved = photocraft_psd::atn::parse(&saved).unwrap(); + assert_eq!(saved.actions[0].items[0], raw.actions[0].items[0]); + let played = s.execute("actions.play", json!({"action":"A"})).unwrap(); + assert_eq!(played.get("failed").is_none(), !atn_cmds::menu_item_needs_settings(id), "{id}: {played}"); + } + } + let mut s = session(); + let original = s.actions.clone(); + for params in + [json!({"action":"A","id":"layer.setProps"}), json!({"action":"A","id":"file.open"}), json!({"action":"A","id":"image.imageSize","params":{"width":4}})] + { + assert!(s.execute("actions.insertMenuItem", params).is_err()); + assert_eq!(s.actions, original); + } + let mut raw = photocraft_psd::atn::AtnFile::default(); + raw.actions.push(photocraft_psd::atn::AtnAction { + items: vec![photocraft_psd::atn::AtnItem { + event: photocraft_psd::atn::AtnEvent::name("gaussianBlur"), + descriptor: Some(photocraft_psd::descriptor::Descriptor::new("null")), + ..Default::default() + }], + ..Default::default() + }); + let loaded = atn_cmds::import(&photocraft_psd::atn::write(&raw).unwrap()).unwrap(); + assert_eq!(loaded.actions[0].steps[0].0, "filter.blur.gaussianBlur"); +} + +/// A full curve is snapshotted independently of later source edits and replays as the work path. +#[test] +fn insert_path_captures_cubic_geometry_without_editing_the_document() { + let mut s = session(); + let path = json!({"fillRule":"evenodd","inverted":true,"subpaths":[{"closed":false,"op":"combine","knots":[{"anchor":[1.5,2.25],"in":[-1.0,3.0],"out":[5.0,8.0],"smooth":true},{"anchor":[20.0,18.0],"in":[12.0,5.0],"out":[21.0,19.0]}]}]}); + s.execute("path.set", json!({"name":"Curve","path":path})).unwrap(); + let expected = s.execute("path.info", json!({"name":"Curve"})).unwrap()["path"].clone(); + let revision = s.active().unwrap().revision; + assert!(s.execute("actions.insertPath", json!({"action":"A","name":"work"})).is_err()); + assert!(s.actions.list[0].steps.is_empty()); + s.execute("actions.insertPath", json!({"action":"A","name":"Curve"})).unwrap(); + assert_eq!(s.active().unwrap().revision, revision); + assert_eq!(s.actions.list[0].steps[0].1["path"], expected); + s.execute("path.set", json!({"name":"Curve","path":{"subpaths":[]}})).unwrap(); + let played = s.execute("actions.play", json!({"action":"A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(s.execute("path.info", json!({"name":"work"})).unwrap()["path"], expected); + assert!(atn_cmds::export("S", &[&s.actions.list[0]], &[], false).is_err(), "the measured ATN descriptor cannot represent inversion"); +} diff --git a/crates/engine/tests/it/actions_record_again.rs b/crates/engine/tests/it/actions_record_again.rs new file mode 100644 index 00000000000..9e3b02367b2 --- /dev/null +++ b/crates/engine/tests/it/actions_record_again.rs @@ -0,0 +1,77 @@ +//! Record Again edits one definition only after its concrete command succeeds. +use photocraft_engine::{EngineError, Session, actions_cmds::Action, atn_cmds}; +use serde_json::{Value, json}; + +/// A disabled, dialog-enabled resize followed by another step exposes flag and ordering loss. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + let mut a = Action::new("A", vec![("image.imageSize".into(), json!({"width": 4, "height": 3, "resample": "nearest"})), ("select.all".into(), json!({}))]); + a.disabled.insert(0); + a.dialogs.insert(0); + s.actions.list.push(a); + s +} + +/// Only the selected step runs, retains its flags, and exports its new settings to ATN. +#[test] +fn successful_rerecording_updates_one_step_and_roundtrips_new_settings() { + for menu in [false, true] { + let mut s = session(); + if menu { + s.actions.list[0].steps[0] = (atn_cmds::MENU_ITEM.into(), json!({"id": "image.imageSize"})); + } + let revision = s.actions.rev; + s.execute("actions.recordAgain", json!({"action": "A", "step": 0, "revision": revision, "params": {"width": 6, "height": 5, "resample": "nearest"}})) + .unwrap(); + assert_eq!(s.active().unwrap().doc.size, photocraft_engine::doc::Size::new(6, 5)); + assert!(s.active().unwrap().doc.selection.is_none(), "the following step did not play"); + let a = &s.actions.list[0]; + assert_eq!(a.steps.len(), 2); + assert_eq!(a.steps[0].0, "image.imageSize"); + assert_eq!(a.steps[0].1["width"], 6); + assert!(a.disabled.contains(&0)); + assert!(a.dialogs.contains(&0)); + assert_eq!(s.actions.rev, revision + 1); + assert_eq!(s.journal.last().unwrap().0, "image.imageSize"); + let bytes = atn_cmds::export("Set", &[a], &[], false).unwrap().0; + let loaded = atn_cmds::import(&bytes).unwrap(); + assert_eq!(loaded.actions[0].steps[0].1["width"].as_f64(), Some(6.0)); + assert!(loaded.actions[0].disabled.contains(&0)); + assert!(loaded.actions[0].dialogs.contains(&0)); + } +} + +/// Permit the wrapper while denying its concrete edit, proving nested authorization remains active. +fn deny_resize(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if id == "image.imageSize" { Err(EngineError::Other("resize denied".into())) } else { Ok(()) } +} + +/// Invalid settings, stale selections, denied execution and recording preserve the original step. +#[test] +fn failed_or_stale_rerecording_never_replaces_the_original_step() { + let mut s = session(); + let original = s.actions.list.clone(); + let revision = s.active().unwrap().revision; + for request in [ + json!({"action":"A","step":0}), + json!({"action":"A","step":0,"params":[]}), + json!({"action":"A","step":0,"params":{"percent":-1}}), + json!({"action":"A","step":99,"params":{}}), + json!({"action":"Missing","step":0,"params":{}}), + json!({"action":"A","step":0,"revision":1,"params":{}}), + ] { + assert!(s.execute("actions.recordAgain", request.clone()).is_err(), "{request}"); + assert_eq!(s.actions.list, original); + assert_eq!(s.actions.rev, 0); + assert_eq!(s.active().unwrap().revision, revision); + } + s.authorize = Some(deny_resize); + assert!(s.execute("actions.recordAgain", json!({"action":"A","step":0,"params":{"width":6,"height":5}})).is_err()); + assert_eq!(s.actions.list, original); + s.authorize = None; + s.execute("actions.record", json!({"action":"A"})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + assert!(s.execute("actions.recordAgain", json!({"action":"A","step":0,"params":{}})).is_err()); + assert_eq!(s.actions.list, original, "recording was not flushed by a refused edit"); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 7cad24d146c..b53745ab2e9 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -1,5 +1,8 @@ //! The crate's integration tests, linked into one test binary. +mod actions_duplicate; +mod actions_panel_menu; +mod actions_record_again; mod actions_single_history; mod affinity; mod artboard_export_names; diff --git a/crates/engine/tests/multi_channel_editing.rs b/crates/engine/tests/multi_channel_editing.rs index 5780c1318ef..09cf6edc979 100644 --- a/crates/engine/tests/multi_channel_editing.rs +++ b/crates/engine/tests/multi_channel_editing.rs @@ -99,6 +99,12 @@ fn adjustments_filters_and_drawing_preserve_other_encoded_components() { let mut s = setup(mode, depth); subset(&mut s); let before = surface(&s); + // Color Replacement refuses 32-bit RGB, as the reference application does (#11923). + if *cmd == "paint.colorReplacement" && mode == "rgb" && depth == 32 { + assert!(s.execute(cmd, p.clone()).is_err(), "{mode} {depth} {cmd} is refused"); + assert_eq!(surface(&s), before, "{mode} {depth} {cmd}: nothing changed"); + continue; + } s.execute(cmd, p.clone()).unwrap_or_else(|e| panic!("{mode} {depth} {cmd}: {e}")); let after = surface(&s); unchanged(&before, &after); @@ -669,7 +675,8 @@ fn last_filter_rejects_non_object_journal_params_without_panicking() { #[test] fn first_single_channel_retouch_preview_preserves_every_other_sample() { for cmd in ["paint.dodge", "paint.burn", "paint.sponge", "paint.blur", "paint.sharpen", "paint.smudge", "paint.colorReplacement"] { - let mut s = setup("rgb", 32); + // Color Replacement refuses 32-bit RGB (#11923): it is checked at 16 bits. + let mut s = setup("rgb", if cmd == "paint.colorReplacement" { 16 } else { 32 }); s.execute("channel.target", json!({"channel":{"color":0}})).unwrap(); let before = surface(&s); let p = json!({"points":[[6,7,1],[23,15,0.7]],"size":9,"color":"#ffffff","tolerance":100}); @@ -765,7 +772,11 @@ fn lab_single_channel_tone_parameters_round_trip_without_applying_lightness_twic } fn flat_rgb() -> Session { - let mut s = setup("rgb", 32); + flat_rgb_at(32) +} + +fn flat_rgb_at(depth: u32) -> Session { + let mut s = setup("rgb", depth); s.edit("flat", |doc, id| { doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 24), &[0.25, 0.5, 0.25, 1.0]); Ok(()) @@ -894,7 +905,8 @@ fn rgb_component_exposure_uses_the_document_transfer_at_all_depths() { #[test] fn color_replacement_live_preview_forwards_explicit_component_and_pixel_targets() { for target in [json!({"colors":[1]}), json!("pixels")] { - let mut s = flat_rgb(); + // 16-bit: Color Replacement refuses 32-bit RGB (#11923). + let mut s = flat_rgb_at(16); subset(&mut s); let params = json!({"points":[[8,8],[22,12]],"size":9,"tolerance":100,"mode":"luminosity","color":"#ffffff","seed":42,"target":target}); let live = photocraft_engine::brush_cmds::live_color_replacement(&s, ¶ms).unwrap(); diff --git a/crates/gpu/src/plan/lower.rs b/crates/gpu/src/plan/lower.rs index 202f0216e1d..4bda862af1f 100644 --- a/crates/gpu/src/plan/lower.rs +++ b/crates/gpu/src/plan/lower.rs @@ -24,7 +24,7 @@ pub(super) fn placement(anchor: (f64, f64), link: bool, phase: (f32, f32), scale let base = if link { anchor } else { (0.0, 0.0) }; let origin = (base.0 + f64::from(phase.0), base.1 + f64::from(phase.1)); let s = f64::from(scale); - let inv_scale = if s.is_finite() && s > 1e-3 { 1.0 / s } else { 1.0 }; + let inv_scale = if s.is_finite() && s > 0.0 { 1.0 / s.max(photocraft_compose::pattern::MIN_PLACEMENT_SCALE) } else { 1.0 }; let a = f64::from(angle).to_radians(); Placement { origin, cs: (a.cos(), a.sin()), inv_scale } } diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index dcd86482db4..2090bdb00f1 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -16,6 +16,9 @@ corpus = [] heif = ["photocraft-codecs/heif"] # Test support for synthetic memory injection, estimate charges and verification across crates. testgen = [] +# Editable PDF import (experimental): painted paths and embedded-font text become editable +# layers; unsupported constructs keep the raster page. +pdf-editable = ["dep:kurbo"] [dependencies] photocraft-geom = { workspace = true } @@ -51,6 +54,11 @@ imagesize = "0.13" resvg = { version = "0.45", default-features = false, features = ["text", "system-fonts", "raster-images"] } # The SVG fallback font search keeps each font file's character map (already in the tree through usvg). ttf-parser = { version = "0.25", default-features = false } +# PDF import (pdf_import.rs): craft-pdf rasterizes pages in pure Rust; no shared library ships. +craft-pdf = { workspace = true } +# Editable PDF import (pdf_editable.rs): craft-pdf's Device trait uses kurbo paths and transforms. +# This is the same kurbo 0.13 that craft-pdf already links; naming it adds no crate to the graph. +kurbo = { version = "0.13", optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] } diff --git a/crates/io/src/annotations_map.rs b/crates/io/src/annotations_map.rs index 27b6c044775..9c418991705 100644 --- a/crates/io/src/annotations_map.rs +++ b/crates/io/src/annotations_map.rs @@ -13,13 +13,14 @@ //! (`pixelLength`, `logicalLength`, `logicalUnits`) follow Photoshop's scripting names for the //! measurement scale; no sample file was available, so parsing is lenient and the raw resource //! is kept byte-exact while the scale is unchanged. -//! * Count information: image resource 1080 is kept verbatim (its layout is not public) unless -//! the document has its own count markers, which only `.pcraft` stores. +//! * Count information: image resource 1080 (a version-16 `Cnt ` descriptor) maps to the +//! document's ordered Count groups. Unreadable resources are kept while there are no groups. use std::sync::Arc; -use photocraft_doc::{Color, Document, MeasurementScale, Note}; -use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor}; +use photocraft_doc::{Color, CountGroup, Document, MeasurementScale, Note}; +use photocraft_psd::ImageResource; +use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; pub const MEASUREMENT_SCALE: u16 = 1074; pub const COUNT_INFO: u16 = 1080; @@ -341,17 +342,118 @@ pub fn fresh_scale_resource(doc: &Document) -> Option> { (!doc.measurement.scale.is_default()).then(|| write_scale_resource(&doc.measurement.scale)) } +/// Decode the measured resource as a whole so unknown or damaged groups stay opaque. +fn count_groups_from_resource(data: &[u8]) -> Option> { + let d = VersionedDescriptor::from_bytes(data).ok()?.descriptor; + if !d.class_id.is("Cnt ") || d.items.len() != 2 || d.get("Vrsn") != Some(&Value::Integer(1)) { + return None; + } + let Value::List(groups) = d.get("countGroupList")? else { return None }; + groups.iter().map(count_group_from_descriptor).collect() +} + +/// Decode the fields the Count tool can use without coercing unrecognized data. +fn count_group_from_descriptor(value: &Value) -> Option { + let Value::Descriptor(d) = value else { return None }; + if !d.class_id.is("countGroup") || d.items.len() != 8 { + return None; + } + let integer = |key| match d.get(key)? { + Value::Integer(n) => u32::try_from(*n).ok(), + _ => None, + }; + let channel = |key| u8::try_from(integer(key)?).ok().map(|n| f32::from(n) / 255.0); + let color = Color::rgb(channel("Rd ")?, channel("Grn ")?, channel("Bl ")?); + let Value::Text(name) = d.get("Nm ")? else { return None }; + let Value::Boolean(visible) = d.get("Vsbl")? else { return None }; + let Value::List(objects) = d.get("countObjectList")? else { return None }; + let marker_size = integer("Rds ")?; + let label_size = integer("fontSize")?; + // Keep out-of-range sizes opaque instead of passing them to the marker and text renderers. + if !(1..=10).contains(&marker_size) || !(8..=72).contains(&label_size) { + return None; + } + let name = String::from_utf16(name.0.strip_suffix(&[0]).unwrap_or(&name.0)).ok()?; + let points = objects.iter().map(count_point_from_descriptor).collect::>()?; + Some(CountGroup { name, color, marker_size, label_size, visible: *visible, points }) +} + +/// Keep document coordinates as doubles and reject nonfinite values. +fn count_point_from_descriptor(value: &Value) -> Option<[f64; 2]> { + let Value::Descriptor(d) = value else { return None }; + if !d.class_id.is("countObject") || d.items.len() != 2 { + return None; + } + let (Value::Double(x), Value::Double(y)) = (d.get("X ")?, d.get("Y ")?) else { return None }; + (x.is_finite() && y.is_finite()).then_some([*x, *y]) +} + +/// Consume readable Count data. Keep only opaque bytes when no resource can be read. +pub(crate) fn import_count_resource(doc: &mut Document) { + if let Some(groups) = doc.metadata.psd_resources.iter().filter(|(id, _, _)| *id == COUNT_INFO).find_map(|(_, _, data)| count_groups_from_resource(data)) { + doc.measurement.count_groups = groups; + doc.metadata.psd_resources.retain(|(id, _, _)| *id != COUNT_INFO); + } +} + +/// Resource 1080 from the current groups, warning when PSD changes sizes or omits points. +pub(crate) fn fresh_count_resource(doc: &Document, warnings: &mut Vec) -> Option { + if doc.measurement.count_groups.is_empty() { + return None; + } + let groups = doc.measurement.count_groups.iter().map(|group| count_group_descriptor(group, warnings)).collect(); + let d = Descriptor::new("Cnt ").with("Vrsn", Value::Integer(1)).with("countGroupList", Value::List(groups)); + Some(ImageResource::new(COUNT_INFO, VersionedDescriptor::new(d).to_bytes())) +} + +/// Write the measured group layout and preserve marker order. +fn count_group_descriptor(group: &CountGroup, warnings: &mut Vec) -> Value { + let marker_size = group.marker_size.clamp(1, 10); + let label_size = group.label_size.clamp(8, 72); + if marker_size != group.marker_size || label_size != group.label_size { + warnings.push(format!( + "Count group {:?} sizes adjusted for PSD (marker {} to {marker_size}, label {} to {label_size})", + group.name, group.marker_size, group.label_size + )); + } + let [r, g, b, _] = group.color.to_rgba8(); + let objects: Vec<_> = group + .points + .iter() + .filter(|[x, y]| x.is_finite() && y.is_finite()) + .map(|&[x, y]| Value::Descriptor(Descriptor::new("countObject").with("X ", Value::Double(x)).with("Y ", Value::Double(y)))) + .collect(); + let omitted = group.points.len() - objects.len(); + if omitted != 0 { + warnings.push(format!("Count group {:?} had nonfinite points omitted from PSD export ({omitted} total)", group.name)); + } + Value::Descriptor( + Descriptor::new("countGroup") + .with("Rd ", Value::Integer(i32::from(r))) + .with("Grn ", Value::Integer(i32::from(g))) + .with("Bl ", Value::Integer(i32::from(b))) + .with("Nm ", Value::Text(UnicodeString::new_nul(&group.name))) + .with("Rds ", Value::Integer(marker_size as i32)) + .with("fontSize", Value::Integer(label_size as i32)) + .with("Vsbl", Value::Boolean(group.visible)) + .with("countObjectList", Value::List(objects)), + ) +} + /// Whether to keep a preserved resource with this id on export. pub fn keep_resource(doc: &Document, id: u16) -> bool { match id { MEASUREMENT_SCALE => keep_raw_scale(doc), - // Our count markers can't be written into Photoshop's (unpublished) layout; a stale copy - // would contradict them, so it is dropped once the document has markers of its own. - COUNT_INFO => doc.measurement.count_total() == 0, + // An opaque copy is only used when there are no document groups to write. + COUNT_INFO => doc.measurement.count_groups.is_empty(), _ => true, } } +#[cfg(test)] +#[path = "count_resource_tests.rs"] +mod count_resource_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/io/src/camera_raw_xmp.rs b/crates/io/src/camera_raw_xmp.rs index 65df97e898c..76fd8bb0ca0 100644 --- a/crates/io/src/camera_raw_xmp.rs +++ b/crates/io/src/camera_raw_xmp.rs @@ -12,6 +12,13 @@ //! `IncrementalTemperature` / `IncrementalTint` (−100..=100), which are PhotoCraft's Temperature //! and Tint. The absolute `Temperature` (kelvin) / `Tint` of raw-file settings have no //! counterpart and are ignored. +//! +//! Crop (#9985): `crs:HasCrop="True"` with `crs:CropTop` / `CropLeft` / `CropBottom` / +//! `CropRight`, fractions of the developed frame in the raw's stored orientation (before its +//! Orientation tag turns it upright), is read into [`LoadReport::crop`]; the raw open crops the +//! document with it ([`crate::raw::RawTuning::crop`]). The settings have no crop (Camera Raw +//! Filter has none). Without `HasCrop`, or with `HasCrop="False"`, the crop keys are stale and +//! mean nothing. A straightened crop (`CropAngle` other than 0) isn't applied. use photocraft_algo::camera_raw::{CameraRaw, validate_curve}; @@ -126,7 +133,6 @@ const METADATA: &[&str] = &[ "Version", "ProcessVersion", "HasSettings", - "HasCrop", "WhiteBalance", "ToneCurveName2012", "CameraProfile", @@ -147,16 +153,114 @@ const METADATA: &[&str] = &[ "SortName", "Group", "Description", + // The raw file the sidecar belongs to (Lightroom and Camera Raw name it). + "RawFileName", ]; +/// The crop's keys besides `HasCrop`, and their values without a crop (the whole frame, level). +pub const CROP_KEYS: [&str; 5] = ["CropTop", "CropLeft", "CropBottom", "CropRight", "CropAngle"]; +const NO_CROP: [f32; 5] = [0.0, 0.0, 1.0, 1.0, 0.0]; + +/// A Camera Raw crop: fractions of the developed frame in the raw's stored orientation, before +/// its Orientation tag turns it upright, with `0 <= left < right <= 1` and +/// `0 <= top < bottom <= 1`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Crop { + pub top: f32, + pub left: f32, + pub bottom: f32, + pub right: f32, +} + +impl Crop { + /// The crop of the frame turned upright by TIFF orientation `o` (1–8; anything else is 1), + /// as fractions of the upright frame: the corners map as the pixels do. + pub fn oriented(self, o: u16) -> Crop { + let map = |u: f32, v: f32| match o { + 2 => (1.0 - u, v), + 3 => (1.0 - u, 1.0 - v), + 4 => (u, 1.0 - v), + 5 => (v, u), + 6 => (1.0 - v, u), + 7 => (1.0 - v, 1.0 - u), + 8 => (v, 1.0 - u), + _ => (u, v), + }; + let (ax, ay) = map(self.left, self.top); + let (bx, by) = map(self.right, self.bottom); + Crop { top: ay.min(by), left: ax.min(bx), bottom: ay.max(by), right: ax.max(bx) } + } + + /// The crop on a `w × h` frame in pixels, `(x0, y0, x1, y1)` with `x1` and `y1` exclusive: + /// at least one pixel, inside the frame. `None` for an empty frame. + pub fn pixels(self, w: usize, h: usize) -> Option<(usize, usize, usize, usize)> { + let edge = |f: f32, n: usize| ((f64::from(f).clamp(0.0, 1.0) * n as f64).round() as usize).min(n); + let span = |a: f32, b: f32, n: usize| { + let lo = edge(a, n).min(n.checked_sub(1)?); + Some((lo, edge(b, n).max(lo + 1))) + }; + let (x0, x1) = span(self.left, self.right, w)?; + let (y0, y1) = span(self.top, self.bottom, h)?; + Some((x0, y0, x1, y1)) + } +} + /// What [`load`] applied. #[derive(Clone, Debug, Default, PartialEq)] pub struct LoadReport { - /// How many settings were applied. + /// How many settings were applied (a crop counts as one). pub applied: usize, /// `crs:` settings without a PhotoCraft control (set to something other than their neutral - /// value), or with an unusable value, in file order. + /// value), or with an unusable value, in file order; then the keys of a crop that isn't + /// applied (straightened, unreadable or empty). pub ignored: Vec, + /// The file's crop, when it has one that isn't the whole frame. The settings hold no crop, + /// so the caller applies it (a raw open crops the document) or reports it. + pub crop: Option, +} + +/// The crop keys as read, resolved by [`crop`] once the whole file is read. +#[derive(Default)] +struct CropFields { + has: Option, + values: [Option; 5], +} + +/// The file's crop from its keys: applied when `HasCrop` is `True` and the values make a level, +/// non-empty rectangle (fractions are clamped to 0..1); otherwise its keys are reported. +fn crop(fields: &CropFields, report: &mut LoadReport) { + match fields.has.as_deref().map(str::trim) { + // No crop: the other keys are what Camera Raw left from an earlier or reset crop. + None => return, + Some(v) if v.eq_ignore_ascii_case("false") => return, + Some(v) if v.eq_ignore_ascii_case("true") => {} + Some(_) => { + report.ignored.push("HasCrop".into()); + return; + } + } + let mut v = NO_CROP; + let mut usable = true; + for (slot, text) in v.iter_mut().zip(&fields.values) { + if let Some(text) = text { + match number(text) { + Some(n) => *slot = n, + None => usable = false, + } + } + } + let [top, left, bottom, right] = [v[0], v[1], v[2], v[3]].map(|f| f.clamp(0.0, 1.0)); + // A straightened crop's rectangle is measured on the rotated frame: not applied. + usable &= v[4] == 0.0 && top < bottom && left < right; + if !usable { + let present = CROP_KEYS.iter().zip(&fields.values).filter(|(_, t)| t.is_some()); + report.ignored.extend(present.map(|(k, _)| k.to_string())); + return; + } + if [top, left, bottom, right] != [0.0, 0.0, 1.0, 1.0] { + report.crop = Some(Crop { top, left, bottom, right }); + report.applied += 1; + } } fn number(s: &str) -> Option { @@ -197,8 +301,14 @@ fn neutral(name: &str, value: &str) -> bool { } } -fn apply(params: &mut CameraRaw, report: &mut LoadReport, name: &str, value: &str) { - if let Some((_, field, lo, hi)) = SCALARS.iter().find(|(n, ..)| *n == name) { +fn apply(params: &mut CameraRaw, report: &mut LoadReport, crop: &mut CropFields, name: &str, value: &str) { + if name == "HasCrop" { + crop.has = Some(value.to_string()); + } else if let Some(i) = CROP_KEYS.iter().position(|k| *k == name) { + if let Some(slot) = crop.values.get_mut(i) { + *slot = Some(value.to_string()); + } + } else if let Some((_, field, lo, hi)) = SCALARS.iter().find(|(n, ..)| *n == name) { match number(value) { Some(v) => { *field(params) = v.clamp(*lo, *hi); @@ -232,10 +342,11 @@ pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result let mut next = params.clone(); let mut report = LoadReport::default(); let mut found = false; + let mut crop_fields = CropFields::default(); for desc in doc.descendants().filter(|n| n.is_element() && n.tag_name().namespace() == Some(RDF_NS) && n.tag_name().name() == "Description") { for a in desc.attributes().filter(|a| a.namespace() == Some(CRS_NS)) { found = true; - apply(&mut next, &mut report, a.name(), a.value()); + apply(&mut next, &mut report, &mut crop_fields, a.name(), a.value()); } for c in desc.children().filter(|c| c.is_element() && c.tag_name().namespace() == Some(CRS_NS)) { found = true; @@ -254,13 +365,14 @@ pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result report.ignored.push(name.to_string()); } } else { - apply(&mut next, &mut report, name, c.text().unwrap_or("")); + apply(&mut next, &mut report, &mut crop_fields, name, c.text().unwrap_or("")); } } } if !found { return Err("no Camera Raw settings in the file".into()); } + crop(&crop_fields, &mut report); *params = next; Ok(report) } @@ -295,6 +407,11 @@ pub fn is_photocraft_xmp(bytes: &[u8]) -> bool { /// Write `params` as a Camera Raw settings file Photoshop's Load Settings… reads. pub fn save(params: &CameraRaw) -> String { + save_with_crop(params, None) +} + +/// [`save`] with a raw file's crop, so rewriting a sidecar keeps the crop it was opened with. +pub fn save_with_crop(params: &CameraRaw, crop: Option) -> String { let mut p = params.clone(); let wb = if p.temperature != 0.0 || p.tint != 0.0 { "Custom" } else { "As Shot" }; let mut out = String::from( @@ -315,6 +432,12 @@ pub fn save(params: &CameraRaw) -> String { } let style = STYLES.iter().find(|(_, s)| *s == p.vignette_style).map_or("1", |(n, _)| n); out.push_str(&format!(" crs:PostCropVignetteStyle=\"{}\"\n", escape(style))); + if let Some(c) = crop { + out.push_str(&format!( + " crs:HasCrop=\"True\"\n crs:CropTop=\"{:.6}\"\n crs:CropLeft=\"{:.6}\"\n crs:CropBottom=\"{:.6}\"\n crs:CropRight=\"{:.6}\"\n crs:CropAngle=\"0\"\n", + c.top, c.left, c.bottom, c.right + )); + } out.push_str(" crs:ToneCurveName2012=\"Custom\"\n crs:HasSettings=\"True\">\n"); for (name, field) in CURVES { let points = field(&mut p); @@ -433,7 +556,7 @@ mod tests { abc"#; let r = load(&mut p, xmp).unwrap(); assert_eq!((p.exposure, p.contrast, p.clarity), (-5.0, 40.0, 0.0)); - assert_eq!(r, LoadReport { applied: 1, ignored: vec!["Clarity2012".into()] }); + assert_eq!(r, LoadReport { applied: 1, ignored: vec!["Clarity2012".into()], crop: None }); } #[test] @@ -479,4 +602,111 @@ mod tests { assert!(!is_photocraft_xmp(other), "{}", String::from_utf8_lossy(other)); } } + + /// A settings file with the `crs:` attributes `attrs`. + fn with(attrs: &str) -> String { + format!( + "" + ) + } + + fn report(attrs: &str) -> LoadReport { + load(&mut CameraRaw::default(), with(attrs).as_bytes()).unwrap() + } + + /// #9985: Lightroom's sidecar named the crop's bottom and right edges and the raw's file name + /// as unsupported; the crop is read now, and the file name is bookkeeping. + #[test] + fn reads_the_crop_and_not_the_file_name() { + let r = report(r#"crs:HasCrop="True" crs:CropTop="0.1" crs:CropLeft="0.2" crs:CropBottom="0.9" crs:CropRight="0.75" crs:CropAngle="0""#); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + assert_eq!(r.crop, Some(Crop { top: 0.1, left: 0.2, bottom: 0.9, right: 0.75 })); + assert_eq!(r.applied, 1); + // Element form, and the keys spread over two descriptions, read the same. + let xmp = format!( + "True0.5" + ); + let r = load(&mut CameraRaw::default(), xmp.as_bytes()).unwrap(); + assert_eq!(r.crop, Some(Crop { top: 0.0, left: 0.0, bottom: 0.5, right: 0.5 }), "missing edges are the frame's"); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + } + + #[test] + fn no_crop_without_has_crop_true() { + let keys = r#"crs:CropTop="0.1" crs:CropLeft="0.2" crs:CropBottom="0.9" crs:CropRight="0.75" crs:CropAngle="3""#; + for has in ["", r#"crs:HasCrop="False""#, r#"crs:HasCrop="false""#] { + let r = report(&format!("{has} {keys}")); + assert_eq!((r.crop, r.ignored.clone(), r.applied), (None, Vec::new(), 0), "{has:?}: {r:?}"); + } + // The whole frame is no crop, and nothing to report. + let r = report(r#"crs:HasCrop="True" crs:CropTop="0" crs:CropLeft="0" crs:CropBottom="1" crs:CropRight="1" crs:CropAngle="0""#); + assert_eq!((r.crop, r.ignored.len()), (None, 0)); + let r = report(r#"crs:HasCrop="yes" crs:CropBottom="0.5""#); + assert_eq!((r.crop, r.ignored), (None, vec!["HasCrop".to_string()])); + } + + /// Hostile and straightened crops never panic: out-of-range fractions are clamped to the + /// frame; an inverted, empty, unreadable or straightened crop isn't applied and its keys are + /// named. + #[test] + fn unusable_crops_are_reported_not_applied() { + let r = report(r#"crs:HasCrop="True" crs:CropTop="-0.5" crs:CropLeft="0.25" crs:CropBottom="7" crs:CropRight="1.0000001""#); + assert_eq!(r.crop, Some(Crop { top: 0.0, left: 0.25, bottom: 1.0, right: 1.0 })); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + let all = || CROP_KEYS.iter().map(|k| k.to_string()).collect::>(); + for bad in [ + r#"crs:CropTop="0.8" crs:CropLeft="0.1" crs:CropBottom="0.2" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="0.5" crs:CropLeft="0.1" crs:CropBottom="0.5" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="2" crs:CropLeft="0.1" crs:CropBottom="3" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="NaN" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="0.1" crs:CropLeft="inf" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="" crs:CropAngle="0""#, + r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="1.38""#, + r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="NaN""#, + ] { + let r = report(&format!(r#"crs:HasCrop="True" {bad}"#)); + assert_eq!((r.crop, r.ignored, r.applied), (None, all(), 0), "{bad}"); + } + let r = report(r#"crs:HasCrop="True" crs:CropAngle="-2.5""#); + assert_eq!(r.ignored, vec!["CropAngle".to_string()], "only the keys the file has"); + } + + /// Corners map as `photocraft-raw`'s orientation maps pixels (`develop::source_xy`). + #[test] + fn crops_follow_the_orientation() { + let c = Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 }; + let want = [ + (1, Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 }), + (2, Crop { top: 0.1, left: 0.5, bottom: 0.6, right: 0.8 }), + (3, Crop { top: 0.4, left: 0.5, bottom: 0.9, right: 0.8 }), + (4, Crop { top: 0.4, left: 0.2, bottom: 0.9, right: 0.5 }), + (5, Crop { top: 0.2, left: 0.1, bottom: 0.5, right: 0.6 }), + (6, Crop { top: 0.2, left: 0.4, bottom: 0.5, right: 0.9 }), + (7, Crop { top: 0.5, left: 0.4, bottom: 0.8, right: 0.9 }), + (8, Crop { top: 0.5, left: 0.1, bottom: 0.8, right: 0.6 }), + (0, Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 }), + ]; + for (o, w) in want { + let got = c.oriented(o); + let close = [got.top - w.top, got.left - w.left, got.bottom - w.bottom, got.right - w.right].iter().all(|d| d.abs() < 1e-6); + assert!(close, "orientation {o}: {got:?}"); + } + assert_eq!(c.pixels(10, 20), Some((2, 2, 5, 12))); + assert_eq!(Crop { top: 0.5, left: 0.5, bottom: 0.5001, right: 0.5001 }.pixels(10, 10), Some((5, 5, 6, 6)), "at least a pixel"); + assert_eq!(Crop { top: 0.999, left: 0.999, bottom: 1.0, right: 1.0 }.pixels(10, 10), Some((9, 9, 10, 10)), "inside the frame"); + assert_eq!(c.pixels(0, 10), None); + } + + #[test] + fn save_with_crop_round_trips() { + let p = CameraRaw { contrast: 20.0, ..CameraRaw::default() }; + let crop = Crop { top: 0.125, left: 0.25, bottom: 0.875, right: 0.5 }; + let text = save_with_crop(&p, Some(crop)); + assert!(is_photocraft_xmp(text.as_bytes())); + let mut back = CameraRaw::default(); + let r = load(&mut back, text.as_bytes()).unwrap(); + assert_eq!((back, r.crop), (p.clone(), Some(crop))); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + assert_eq!(load(&mut CameraRaw::default(), save(&p).as_bytes()).unwrap().crop, None); + } } diff --git a/crates/io/src/count_resource_tests.rs b/crates/io/src/count_resource_tests.rs new file mode 100644 index 00000000000..fa84fc22330 --- /dev/null +++ b/crates/io/src/count_resource_tests.rs @@ -0,0 +1,213 @@ +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Color, ColorTable, CountGroup, Document}; +use photocraft_geom::Size; +use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; +use photocraft_psd::{ColorMode as PsdMode, ImageResource, PsdFile}; + +use super::{COUNT_INFO, count_groups_from_resource}; + +fn measured_resource(second_y: f64) -> Vec { + let point = |x, y| Value::Descriptor(Descriptor::new("countObject").with("X ", Value::Double(x)).with("Y ", Value::Double(y))); + let first = Descriptor::new("countGroup") + .with("Rd ", Value::Integer(51)) + .with("Grn ", Value::Integer(102)) + .with("Bl ", Value::Integer(204)) + .with("Nm ", Value::Text(UnicodeString::new_nul("Cells"))) + .with("Rds ", Value::Integer(3)) + .with("fontSize", Value::Integer(12)) + .with("Vsbl", Value::Boolean(true)) + .with("countObjectList", Value::List(vec![point(9.5, 10.5), point(1.125, second_y), point(3.5, 4.5)])); + let second = Descriptor::new("countGroup") + .with("Rd ", Value::Integer(255)) + .with("Grn ", Value::Integer(0)) + .with("Bl ", Value::Integer(0)) + .with("Nm ", Value::Text(UnicodeString::new_nul("Café"))) + .with("Rds ", Value::Integer(10)) + .with("fontSize", Value::Integer(72)) + .with("Vsbl", Value::Boolean(false)) + .with("countObjectList", Value::List(vec![point(7.5, 8.5), point(5.5, 6.5)])); + VersionedDescriptor::new( + Descriptor::new("Cnt ").with("Vrsn", Value::Integer(1)).with("countGroupList", Value::List(vec![Value::Descriptor(first), Value::Descriptor(second)])), + ) + .to_bytes() +} + +fn groups() -> Vec { + vec![ + CountGroup { + name: "Cells".into(), + color: Color::rgb(51.0 / 255.0, 102.0 / 255.0, 204.0 / 255.0), + marker_size: 3, + label_size: 12, + visible: true, + points: vec![[9.5, 10.5], [1.125, 2.75], [3.5, 4.5]], + }, + CountGroup { + name: "Café".into(), + color: Color::rgb(1.0, 0.0, 0.0), + marker_size: 10, + label_size: 72, + visible: false, + points: vec![[7.5, 8.5], [5.5, 6.5]], + }, + ] +} + +fn document(mode: ColorMode) -> Document { + Document::new("Counts", Size::new(16, 16), mode, SampleType::U8) +} + +fn to_psd(doc: &Document) -> Vec { + crate::export(doc, "counts.psd", &crate::ExportOptions::default()).unwrap().bytes +} + +fn import_counts(data: Vec) -> Document { + let mut file = PsdFile::from_bytes(&to_psd(&document(ColorMode::Rgb))).unwrap(); + file.resources.push(ImageResource::new(COUNT_INFO, data)); + crate::import("counts.psd", &file.to_bytes().unwrap()).unwrap().document +} + +/// Two independently constructed groups preserve every option and the order of all five points. +#[test] +fn parses_hand_built_count_descriptor() { + assert_eq!(count_groups_from_resource(&measured_resource(2.75)), Some(groups())); + assert!(count_groups_from_resource(&measured_resource(f64::INFINITY)).is_none()); + let mut trailing = measured_resource(2.75); + trailing.push(1); + assert!(count_groups_from_resource(&trailing).is_none()); +} + +/// Count groups round-trip through the real PSD reader and both export routes. +#[test] +fn count_groups_round_trip_through_psd() { + for (mode, wire_mode) in [(ColorMode::Rgb, PsdMode::Rgb), (ColorMode::Bitmap, PsdMode::Bitmap), (ColorMode::Multichannel, PsdMode::Multichannel)] { + let mut doc = document(mode); + doc.measurement.count_groups = groups(); + let exported = crate::export(&doc, "counts.psd", &crate::ExportOptions::default()).unwrap(); + assert!(!exported.warnings.iter().any(|warning| warning.starts_with("Count group "))); + let bytes = exported.bytes; + let file = PsdFile::from_bytes(&bytes).unwrap(); + assert_eq!(file.header.color_mode, wire_mode); + let resources: Vec<_> = file.resources.iter().filter(|r| r.id == COUNT_INFO).collect(); + assert_eq!(resources.len(), 1); + assert_eq!(resources[0].data, measured_resource(2.75)); + let reopened = crate::import("counts.psd", &bytes).unwrap().document; + assert_eq!(reopened.measurement.count_groups, doc.measurement.count_groups); + assert!(!reopened.metadata.psd_resources.iter().any(|(id, _, _)| *id == COUNT_INFO)); + } +} + +/// Both export routes report clamped sizes and preserve the original Count groups. +#[test] +fn count_size_clamps_warn_without_changing_source_groups() { + for mode in [ColorMode::Rgb, ColorMode::Bitmap] { + let mut doc = document(mode); + let mut original = groups(); + original[0].marker_size = 0; + original[0].label_size = 73; + original[1].marker_size = 11; + original[1].label_size = 7; + doc.measurement.count_groups = original.clone(); + + let exported = crate::export(&doc, "counts.psd", &crate::ExportOptions::default()).unwrap(); + let warnings: Vec<_> = exported.warnings.iter().filter(|warning| warning.starts_with("Count group ")).map(String::as_str).collect(); + assert_eq!( + warnings, + [ + "Count group \"Cells\" sizes adjusted for PSD (marker 0 to 1, label 73 to 72)", + "Count group \"Café\" sizes adjusted for PSD (marker 11 to 10, label 7 to 8)", + ] + ); + assert_eq!(doc.measurement.count_groups, original); + + let reopened = crate::import("counts.psd", &exported.bytes).unwrap().document; + let mut expected = original; + expected[0].marker_size = 1; + expected[0].label_size = 72; + expected[1].marker_size = 10; + expected[1].label_size = 8; + assert_eq!(reopened.measurement.count_groups, expected); + } +} + +/// Opaque data survives an untouched save and an add-then-delete, but current groups win. +#[test] +fn unreadable_count_resource_is_preserved_without_groups() { + let raw = vec![0, 0, 0, 16, 0]; + let mut doc = import_counts(raw.clone()); + assert!(doc.measurement.count_groups.is_empty()); + let check_raw = |doc: &Document| { + let file = PsdFile::from_bytes(&to_psd(doc)).unwrap(); + assert_eq!(file.resource(COUNT_INFO).unwrap().data, raw); + }; + check_raw(&doc); + doc.measurement.count_groups.push(CountGroup::default()); + let file = PsdFile::from_bytes(&to_psd(&doc)).unwrap(); + let mut resources = file.resources.iter().filter(|r| r.id == COUNT_INFO); + assert_eq!(count_groups_from_resource(&resources.next().unwrap().data), Some(doc.measurement.count_groups.clone())); + assert!(resources.next().is_none()); + doc.measurement.count_groups.clear(); + check_raw(&doc); +} + +/// Deleting the imported groups cannot bring back the readable resource. +#[test] +fn deleting_all_readable_count_groups_writes_no_resource() { + let mut doc = import_counts(measured_resource(2.75)); + assert_eq!(doc.measurement.count_groups, groups()); + assert!(!doc.metadata.psd_resources.iter().any(|(id, _, _)| *id == COUNT_INFO)); + doc.measurement.count_groups.clear(); + let file = PsdFile::from_bytes(&to_psd(&doc)).unwrap(); + assert!(file.resource(COUNT_INFO).is_none()); +} + +/// Indexed export uses the shared layerless writer and keeps exactly one Count resource. +#[test] +fn indexed_count_groups_round_trip_through_psd() { + let mut doc = document(ColorMode::Indexed); + doc.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 255, 255]], transparent: None }); + let opaque = vec![0, 0, 0, 16, 0]; + doc.metadata.psd_resources.push((COUNT_INFO, String::new(), std::sync::Arc::new(opaque.clone()))); + let file = PsdFile::from_bytes(&to_psd(&doc)).unwrap(); + assert_eq!(file.header.color_mode, PsdMode::Indexed); + assert_eq!(file.resource(COUNT_INFO).unwrap().data, opaque); + + doc.measurement.count_groups = groups(); + let bytes = to_psd(&doc); + let file = PsdFile::from_bytes(&bytes).unwrap(); + assert_eq!(file.header.color_mode, PsdMode::Indexed); + assert!(file.layer_info.is_none()); + let resources: Vec<_> = file.resources.iter().filter(|r| r.id == COUNT_INFO).collect(); + assert_eq!(resources.len(), 1); + assert_eq!(resources[0].data, measured_resource(2.75)); + let reopened = crate::import("counts.psd", &bytes).unwrap().document; + assert_eq!(reopened.color_table, doc.color_table); + assert_eq!(reopened.measurement.count_groups, doc.measurement.count_groups); +} + +/// Both PSD routes report omitted points, keeping the finite points in order. +#[test] +fn nonfinite_count_points_warn_on_psd_export() { + for (mode, wire_mode) in [(ColorMode::Rgb, PsdMode::Rgb), (ColorMode::Bitmap, PsdMode::Bitmap)] { + let mut doc = document(mode); + doc.measurement.count_groups = groups(); + let options = crate::ExportOptions::default(); + let clean = crate::export(&doc, "counts.psd", &options).unwrap(); + assert!(!clean.warnings.iter().any(|warning| warning.starts_with("Count group "))); + let reopened = crate::import("counts.psd", &clean.bytes).unwrap().document; + assert_eq!(reopened.measurement.count_groups, doc.measurement.count_groups); + + let mut expected = groups(); + expected[0].points.remove(1); + doc.measurement.count_groups[0].points[1][1] = f64::INFINITY; + let original = doc.measurement.count_groups.clone(); + let exported = crate::export(&doc, "counts.psd", &options).unwrap(); + let warnings: Vec<_> = exported.warnings.iter().filter(|warning| warning.starts_with("Count group ")).map(String::as_str).collect(); + assert_eq!(warnings, ["Count group \"Cells\" had nonfinite points omitted from PSD export (1 total)"]); + assert_eq!(doc.measurement.count_groups, original); + let file = PsdFile::from_bytes(&exported.bytes).unwrap(); + assert_eq!(file.header.color_mode, wire_mode); + let reopened = crate::import("counts.psd", &exported.bytes).unwrap().document; + assert_eq!(reopened.measurement.count_groups, expected); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 68b1754e614..c796b7cffa1 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -65,6 +65,9 @@ pub use ora::losses as ora_losses; pub mod pattern_map; mod pcraft_cache; pub mod pdf; +#[cfg(all(feature = "pdf-editable", not(target_arch = "wasm32")))] +mod pdf_editable; +mod pdf_import; mod pdn; pub use pcraft_cache::PcraftExportCache; pub mod pixels; @@ -513,8 +516,8 @@ pub enum ImportPreflight { /// A flat raster decoded into one image, bounded by /// [`ImportOptions::flat_max_pixel_bytes`]. Flat, - /// Affinity, OpenRaster, Krita, XCF, PDN, SVG, camera raw and `.pcraft` files: this build has - /// no header-only estimate of their pixel memory before decoding. + /// Affinity, OpenRaster, Krita, XCF, PDN, SVG, camera raw, PDF and `.pcraft` files: this + /// build has no header-only estimate of their pixel memory before decoding. Unbounded, } @@ -555,6 +558,11 @@ pub fn import_preflight(name: &str, bytes: &[u8]) -> ImportPreflight { if is_psd_input(name, bytes) { return ImportPreflight::Psd; } + // PDF (craft-pdf, pure Rust). `.ai` files that start with `%PDF-` are PDFs and take this + // importer too; a recognized raster signature wins over a misleading `.pdf` suffix. + if pdf_import::is_pdf(bytes) || (has_extension(name, "pdf") && photocraft_codecs::detect(bytes).is_none()) { + return ImportPreflight::Unbounded; + } if photocraft_codecs::detect(bytes) == Some(photocraft_codecs::Format::Tiff) { ImportPreflight::Tiff } else { ImportPreflight::Flat } } @@ -601,6 +609,18 @@ pub struct ImportOptions { /// Decoded-pixel allowance for a flat raster (the buffer a codec decodes into). May lower /// the codec's default cap; 0 uses it. PSD/PSB and layered TIFF use `psd_max_pixel_bytes`. pub flat_max_pixel_bytes: u64, + /// Resolution (dots per inch) PDF pages are rendered at on import; the document keeps it so + /// the page's print size is preserved. Defaults to 300. + pub pdf_dpi: f32, + /// The 1-based, inclusive page range to import; `None` imports every page (up to the cap). + pub pdf_pages: Option<(u32, u32)>, + /// Import PDF painted paths and embedded-font text as editable [`photocraft_doc::ShapeLayer`]s + /// and text layers instead of a page raster (experimental). Images, complex clips/masks, + /// patterns, non-normal blends and unembedded/Type 3 text make that page fall back to the + /// raster importer with a warning. Text that uses fonts embedded in the PDF may change font + /// when edited or reopened, because the document format does not carry the font bytes. Off by + /// default. + pub pdf_editable: bool, /// Explicit one-shot authorization for imports that exceed the soft budget but fit within the hard budget. pub psd_high_memory_approved: bool, /// Optional estimate limit for high-memory approval. When set, approval only applies if @@ -615,6 +635,9 @@ impl Default for ImportOptions { demosaic: raw::Demosaic::default(), psd_max_pixel_bytes: 0, flat_max_pixel_bytes: 0, + pdf_dpi: 300.0, + pdf_pages: None, + pdf_editable: false, psd_high_memory_approved: false, psd_approved_estimate: None, } @@ -975,6 +998,16 @@ pub(crate) fn route(name: &str, bytes: &[u8]) -> Route { /// Dispatches format-specific importers and propagates a structured PSD damage /// flag alongside the ordinary best-effort import result. fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, opts: &ImportOptions, damaged: &mut bool) -> Result { + // SVG content wins over a misleading `.pdf` suffix, matching the preflight (which checks SVG + // before PDF). + if is_svg_input(name, bytes) { + return svg::import_svg_with_group_depth(name, bytes, opts.max_group_depth); + } + // PDF (craft-pdf, pure Rust). `.ai` files that start with `%PDF-` are PDFs and take this + // importer too; a recognized raster signature wins over a misleading `.pdf` suffix. + if pdf_import::is_pdf(bytes) || (has_extension(name, "pdf") && photocraft_codecs::detect(bytes).is_none()) { + return pdf_import::import(name, bytes, opts, ctl); + } match route(name, bytes) { Route::Pcraft => { let document = photocraft_format::load_from_bytes(bytes)?; @@ -999,10 +1032,28 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o pub fn is_svg_input(name: &str, bytes: &[u8]) -> bool { // Keep the SVG extension as a fallback for malformed files so the SVG parser can return an // actionable SVG error. A recognized raster signature still wins over a misleading `.svg` - // suffix (for example, a WebP downloaded as Firefox_logo.svg). + // suffix (for example, a WebP downloaded as Firefox_logo.svg), and so does every other + // format's signature: a PDF named `.svg` must reach the PDF importer. svg::is_svg(bytes) - || (has_extension(name, "svg") && photocraft_codecs::detect(bytes).is_none()) - || (has_extension(name, "svgz") && bytes.starts_with(&[0x1f, 0x8b])) + || (has_extension(name, "svg") && photocraft_codecs::detect(bytes).is_none() && !has_format_signature(name, bytes)) + || (has_extension(name, "svgz") && bytes.starts_with(&[0x1f, 0x8b]) && !has_format_signature(name, bytes)) +} + +/// A non-raster format's signature or declared extension, checked before the `.svg` suffix +/// fallback so a misnamed file keeps its own importer. +fn has_format_signature(name: &str, bytes: &[u8]) -> bool { + is_psd(bytes) + || pdf_import::is_pdf(bytes) + || is_psd_input(name, bytes) + || raw::is_raw(bytes) + || affinity::is_affinity(bytes) + || ora::is_ora(bytes) + || kra::is_kra(bytes) + || xcf::is_xcf(bytes) + || has_extension(name, "pdn") + || bytes.starts_with(b"PDN3") + || photocraft_format::is_pcraft(bytes) + || has_extension(name, photocraft_format::EXTENSION) } fn extension(name_or_ext: &str) -> String { @@ -1154,7 +1205,7 @@ fn export_stages_with_renderer( return Ok(res.into()); } if ext == "pdf" { - let mut res = pdf::export(doc)?; + let mut res = pdf::export(doc, ctl)?; if let Some(r) = renderer { res.warnings.push("PDF export uses CPU compositor; GPU rendering applies to flat formats".into()); res.warnings.extend(r.drain_diagnostics()); @@ -1570,4 +1621,49 @@ mod tests { assert_eq!(d.bounds().height(), 8, "{name}"); } } + + /// A PDF named `.svg` (a save-as mistake) must reach the PDF importer, not the SVG parser; + /// SVG content still wins over a `.pdf` suffix. + #[test] + fn a_pdf_named_svg_stays_a_pdf() { + let pdf = b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + assert!(!is_svg_input("drawing.svg", pdf)); + assert!(!is_svg_input("drawing.svgz", pdf)); + assert!(is_svg_input("drawing.svg", b"")); + assert_eq!(crate::route("drawing.svg", pdf), crate::Route::Flat); + } + + /// `.ai` files that start with `%PDF-` are PDFs: both preflight and dispatch send them to the + /// PDF importer rather than the flat decoder. + #[test] + fn an_ai_with_pdf_content_takes_the_pdf_importer() { + use lopdf::{Document as PdfDocument, Object, Stream, dictionary}; + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); + let content = b"0 0 1 rg 10 10 20 20 re f"; + let content_id = pdf.add_object(Stream::new(dictionary! {}, content.to_vec())); + let page_id = pdf.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(100.0), Object::Real(100.0)]), + "Contents" => content_id, + "Resources" => dictionary! {}, + }); + pdf.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => Object::Array(vec![Object::Reference(page_id)]), + "Count" => 1, + }), + ); + let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + pdf.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + pdf.save_to(&mut bytes).expect("writing the .ai fixture"); + assert!(crate::pdf_import::is_pdf(&bytes)); + assert_eq!(import_preflight("illustration.ai", &bytes), ImportPreflight::Unbounded); + let result = import("illustration.ai", &bytes).expect("an .ai starting with %PDF- takes the PDF importer"); + assert_eq!(result.document.artboards().len(), 1); + } } diff --git a/crates/io/src/multichannel_map.rs b/crates/io/src/multichannel_map.rs index 9f8850683a4..b227d28f1fd 100644 --- a/crates/io/src/multichannel_map.rs +++ b/crates/io/src/multichannel_map.rs @@ -196,6 +196,7 @@ pub(crate) fn layerless_file( if let Some(data) = crate::annotations_map::fresh_scale_resource(doc) { resources.push(ImageResource::new(crate::annotations_map::MEASUREMENT_SCALE, data)); } + resources.extend(crate::annotations_map::fresh_count_resource(doc, warnings)); resources.push(version_info_resource(true)); // Document-level tagged blocks (patterns, notes, ...) are valid without a layer section; a // kept layer block would describe layers the document doesn't have. Their signatures follow diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index d693ee941b1..86b03b565a9 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -44,6 +44,8 @@ pub struct PrintPage { pub marks: Marks, pub description: Option, pub label: Option, + /// The page's name in the PDF `/PageLabels` tree (Unicode-capable; not drawn on the page). + pub page_label: Option, } pub(super) fn pdf_string(s: &str) -> String { @@ -62,6 +64,16 @@ pub(super) fn pdf_string(s: &str) -> String { o } +/// A PDF text string with UTF-16BE hex (the Unicode-capable form, byte-order mark included). +fn pdf_unicode_string(s: &str) -> String { + let mut o = String::from("'); + o +} + fn deflate(data: &[u8]) -> Vec { // Fast: print PDFs are transient and large. Level 3, not 1: zlib-rs level 1 writes static // Huffman blocks only (13-30% bigger on photos); level 3 is the lowest that is never bigger @@ -109,10 +121,20 @@ fn marks_ops(m: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> String { /// A one-page PDF. pub fn print_pdf(page: &PrintPage) -> Vec { - print_pdf_with_vectors(page, "", &selection::SelectionFont::default()) + print_pdf_pages(std::slice::from_ref(page), &[String::new()], &selection::SelectionFont::default(), &photocraft_raster::Interrupt::NONE).unwrap_or_default() } -fn print_pdf_with_vectors(page: &PrintPage, vectors: &str, selection_font: &selection::SelectionFont) -> Vec { +/// A PDF with one page per entry; `vectors[i]` (when present) is drawn over page `i`. +fn print_pdf_pages( + pages: &[PrintPage], + vectors: &[String], + selection_font: &selection::SelectionFont, + ctl: &photocraft_raster::Interrupt, +) -> std::result::Result, IoError> { + if ctl.check().is_err() { + return Err(IoError::Cancelled); + } + let Some(first) = pages.first() else { return Ok(Vec::new()) }; let mut out: Vec = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(); let mut offsets: Vec = Vec::new(); let mut obj = |out: &mut Vec, body: &[u8]| { @@ -121,72 +143,112 @@ fn print_pdf_with_vectors(page: &PrintPage, vectors: &str, selection_font: &sele out.extend_from_slice(body); out.extend_from_slice(b"\nendobj\n"); }; - let img = &page.image; - let (pw, ph) = page.paper; - let (x, y, w, h) = page.rect; - let selection_base = if img.icc.is_some() { 8 } else { 7 }; - // 1 catalog, 2 pages, 3 page, 4 contents, 5 image, 6 font, 7 ICC (optional). - obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>"); - obj(&mut out, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); - obj( - &mut out, - format!( - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im0 5 0 R >> /Font << /F1 6 0 R /FSelect {selection_base} 0 R >> >> /Contents 4 0 R >>" + let n = pages.len(); + // A label tree is written only for a non-empty name: an all-empty tree would be `/Nums []`, + // and filtering the first empty name away would start the tree at page index 1, not 0. + let labels_on = pages.iter().any(|p| p.page_label.as_deref().is_some_and(|label| !label.is_empty())); + // 1 catalog, 2 pages, then per page: page, contents, image; then the shared font/ICC/selection. + let font_obj = 3 + n * 3; + let icc = first.image.icc.as_ref(); + let icc_obj = icc.map(|_| font_obj + 1); + let selection_base = font_obj + if icc.is_some() { 2 } else { 1 }; + let selection_objects = selection_font.objects(selection_base); + let labels_obj = labels_on.then_some((selection_base + selection_objects.len()) as u32); + obj(&mut out, format!("<< /Type /Catalog /Pages 2 0 R{} >>", labels_obj.map(|o| format!(" /PageLabels {o} 0 R")).unwrap_or_default()).as_bytes()); + let kids = (0..n).map(|i| format!("{} 0 R", 3 + i * 3)).collect::>().join(" "); + obj(&mut out, format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>").as_bytes()); + for (i, page) in pages.iter().enumerate() { + let page_obj = 3 + i * 3; + let content_obj = page_obj + 1; + let image_obj = page_obj + 2; + let (pw, ph) = page.paper; + let (x, y, w, h) = page.rect; + obj( + &mut out, + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im0 {image_obj} 0 R >> /Font << /F1 {font_obj} 0 R /FSelect {selection_base} 0 R >> >> /Contents {content_obj} 0 R >>" + ) + .as_bytes(), + ); + // An explicit white page, so every viewer (and rasteriser) shows paper, not transparency. + let mut content = format!("q 1 g 0 0 {pw:.2} {ph:.2} re f Q\nq {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Im0 Do Q\n"); + if let Some(v) = vectors.get(i) { + content.push_str(v); + } + content.push_str(&marks_ops(page.marks, page.rect)); + let any_marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration; + let pad = if any_marks { 30.0 } else { 6.0 }; + if let Some(l) = page.label.as_deref().filter(|l| !l.is_empty()) { + content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y + h + pad, pdf_string(l))); + } + if let Some(d) = page.description.as_deref().filter(|d| !d.is_empty()) { + content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y - pad - 8.0, pdf_string(d))); + } + let c = deflate(content.as_bytes()); + let mut body = format!("<< /Length {} /Filter /FlateDecode >>\nstream\n", c.len()).into_bytes(); + body.extend_from_slice(&c); + body.extend_from_slice(b"\nendstream"); + obj(&mut out, &body); + let img = &page.image; + let device = match img.channels { + 1 => "/DeviceGray", + 4 => "/DeviceCMYK", + _ => "/DeviceRGB", + }; + let cs = match icc_obj { + Some(obj) => format!("[/ICCBased {obj} 0 R]"), + None => device.to_string(), + }; + let z = deflate(&img.data); + let mut body = format!( + "<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {cs} /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>\nstream\n", + img.width, + img.height, + z.len() ) - .as_bytes(), - ); - // An explicit white page, so every viewer (and rasteriser) shows paper, not transparency. - let mut content = format!("q 1 g 0 0 {pw:.2} {ph:.2} re f Q\nq {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Im0 Do Q\n"); - content.push_str(vectors); - content.push_str(&marks_ops(page.marks, page.rect)); - let any_marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration; - let pad = if any_marks { 30.0 } else { 6.0 }; - if let Some(l) = page.label.as_deref().filter(|l| !l.is_empty()) { - content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y + h + pad, pdf_string(l))); - } - if let Some(d) = page.description.as_deref().filter(|d| !d.is_empty()) { - content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y - pad - 8.0, pdf_string(d))); - } - let c = deflate(content.as_bytes()); - let mut body = format!("<< /Length {} /Filter /FlateDecode >>\nstream\n", c.len()).into_bytes(); - body.extend_from_slice(&c); - body.extend_from_slice(b"\nendstream"); - obj(&mut out, &body); - let device = match img.channels { - 1 => "/DeviceGray", - 4 => "/DeviceCMYK", - _ => "/DeviceRGB", - }; - let cs = if img.icc.is_some() { "[/ICCBased 7 0 R]".to_string() } else { device.to_string() }; - let z = deflate(&img.data); - let mut body = format!( - "<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {cs} /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>\nstream\n", - img.width, - img.height, - z.len() - ) - .into_bytes(); - body.extend_from_slice(&z); - body.extend_from_slice(b"\nendstream"); - obj(&mut out, &body); - obj(&mut out, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"); - if let Some(icc) = &img.icc { - let z = deflate(icc); - let mut body = format!("<< /N {} /Alternate {device} /Filter /FlateDecode /Length {} >>\nstream\n", img.channels, z.len()).into_bytes(); + .into_bytes(); body.extend_from_slice(&z); body.extend_from_slice(b"\nendstream"); obj(&mut out, &body); } - for body in selection_font.objects(selection_base) { + obj(&mut out, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"); + if let Some(icc) = icc { + let device = match first.image.channels { + 1 => "/DeviceGray", + 4 => "/DeviceCMYK", + _ => "/DeviceRGB", + }; + let z = deflate(icc); + let mut body = format!("<< /N {} /Alternate {device} /Filter /FlateDecode /Length {} >>\nstream\n", first.image.channels, z.len()).into_bytes(); + body.extend_from_slice(&z); + body.extend_from_slice(b"\nendstream"); obj(&mut out, &body); } + for body in &selection_objects { + obj(&mut out, body); + } + if labels_obj.is_some() { + // Every page gets an entry: a number-tree range runs until the next key, so omitting an + // empty name would make that page inherit the previous page's label. An empty + // dictionary gives the page the default (decimal) label instead. + let nums = pages + .iter() + .enumerate() + .map(|(i, p)| match p.page_label.as_deref().filter(|label| !label.is_empty()) { + Some(label) => format!("{i} << /P {} >>", pdf_unicode_string(label)), + None => format!("{i} << >>"), + }) + .collect::>() + .join(" "); + obj(&mut out, format!("<< /Nums [{nums}] >>").as_bytes()); + } let xref = out.len(); out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes()); for o in &offsets { out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); } out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1).as_bytes()); - out + Ok(out) } /// The vector operators for the contiguous visible top prefix of `doc`, top to bottom, with the @@ -261,10 +323,17 @@ fn collect_vectors(doc: &Document) -> (Vec<(usize, String)>, selection::Selectio (layers, selection_font) } -/// File › Save As / Save a Copy › PDF (#3401): the flattened document, at its print size -/// (pixels ÷ resolution × 72 pt), as a one-page PDF. Layers and transparency are dropped — the -/// warning says so — and the embedded profile travels with the image. -pub fn export(doc: &Document) -> std::result::Result { +/// Pixels in `rect`, saturating on overflow. +fn rect_pixels(rect: photocraft_geom::Rect) -> u64 { + u64::from(rect.width()).saturating_mul(u64::from(rect.height())) +} + +/// File › Save As / Save a Copy › PDF (#3401): the flattened document at its print size +/// (pixels ÷ resolution × 72 pt). One page per artboard when the document has artboards, else one +/// page for the whole document. Layers and transparency are dropped — the warning says so — and +/// the embedded profile travels with the images. +pub fn export(doc: &Document, ctl: &photocraft_raster::Interrupt) -> std::result::Result { + ctl.check().map_err(|_| IoError::Cancelled)?; let (w, h) = (usize::try_from(doc.size.width).unwrap_or(0), usize::try_from(doc.size.height).unwrap_or(0)); if w == 0 || h == 0 { return Err(IoError::Unsupported("PDF needs a document with non-zero width and height; nothing was written".into())); @@ -277,6 +346,82 @@ pub fn export(doc: &Document) -> std::result::Result { }; let fmt = doc.pixel_format(); let n = fmt.channels().max(1); + let dpi = f64::from(doc.resolution_dpi.max(1.0)); + let paper = if doc.mode == ColorMode::Cmyk { 0.0 } else { 1.0 }; + let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let icc = doc.icc_profile.as_ref().map(|p| p.to_vec()); + let artboards = doc.artboards(); + if !artboards.is_empty() { + // Every page's encoded pixels are kept until the PDF is assembled; bound that peak. + const MAX_PDF_EXPORT_BYTES: u64 = 1 << 30; + let page_pixels: u64 = artboards.iter().fold(0u64, |bytes, (_, _, a)| bytes.saturating_add(rect_pixels(a.rect))); + let retained = page_pixels.saturating_mul(device as u64); + // One page renders at a time: its f32 composite surface plus the region read back from + // it, both `n` channels at 4 bytes per pixel. The whole-document canvas is never held. + let largest = artboards.iter().map(|(_, _, a)| rect_pixels(a.rect)).max().unwrap_or(0); + let temporary = largest.saturating_mul(n as u64).saturating_mul(4).saturating_mul(2); + let estimate = retained.saturating_add(temporary); + if estimate > MAX_PDF_EXPORT_BYTES { + return Err(IoError::Unsupported(format!( + "PDF export would need about {} MB for {} artboards; export them in smaller batches", + estimate >> 20, + artboards.len() + ))); + } + // One document shell is reused for every page. Each page renders a document holding only + // its artboard group (the same isolation as `artboard_document`), so an overlapping board + // cannot paint on another board's page. Layer pixels are shared with `doc`; this one + // clone and the per-page group clone hold no new pixel buffers, but they are outside the + // 1 GiB estimate because they are not retained page data. + let mut one = doc.clone(); + let mut pages = Vec::with_capacity(artboards.len()); + for (id, name, artboard) in &artboards { + ctl.check().map_err(|_| IoError::Cancelled)?; + let rect = artboard.rect; + let (aw, ah) = (rect.width(), rect.height()); + if aw == 0 || ah == 0 { + continue; + } + // A hidden artboard still gets its page, rendered from its own content rather than + // left blank by the group's visibility. + let Some(layer) = doc.layer(*id) else { continue }; + let mut group = layer.clone(); + group.visible = true; + one.layers.clear(); + one.layers.push(group); + // Render only this artboard's rect; the surface never holds the whole document. + let surface = photocraft_compose::composite_to_surface(&one, rect, PixelFormat::new(fmt.mode, fmt.sample, true), None); + let values = surface.read_region(rect); + let mut data = Vec::with_capacity((aw as usize * ah as usize).saturating_mul(device)); + for px in values.chunks(n) { + let a = px.last().copied().unwrap_or(1.0).clamp(0.0, 1.0); + for v in px.iter().take(device) { + data.push(to8(v * a + paper * (1.0 - a))); + } + } + let image = PrintImage { width: aw, height: ah, channels: device, data, icc: icc.clone() }; + let (pw, ph) = (f64::from(aw) * 72.0 / dpi, f64::from(ah) * 72.0 / dpi); + pages.push(PrintPage { + paper: (pw, ph), + image, + rect: (0.0, 0.0, pw, ph), + marks: Marks::default(), + description: None, + label: None, + page_label: Some((*name).to_string()), + }); + } + if pages.is_empty() { + return Err(IoError::Unsupported("PDF: the document's artboards have no area".into())); + } + let mut warnings = vec!["PDF was saved flattened: layers, masks-as-editable and transparency are not preserved (#3401)".into()]; + warnings.push(format!("PDF exported {} artboards as {} pages; vector/text layers on artboards are rasterised", artboards.len(), pages.len())); + if !doc.metadata.text.is_empty() { + warnings.push("free-form text metadata is not supported by PDF; it will be dropped".into()); + } + let vectors = vec![String::new(); pages.len()]; + return Ok(ExportResult { bytes: print_pdf_pages(&pages, &vectors, &selection::SelectionFont::default(), ctl)?, warnings }); + } let canvas = photocraft_geom::Rect::new(0, 0, w as i32, h as i32); // Simple topmost shapes and text layers can be drawn over the flattened backdrop without changing // compositing. Stop at the first unsupported visible layer, since it may depend on the @@ -290,10 +435,8 @@ pub fn export(doc: &Document) -> std::result::Result { } let surface = photocraft_compose::flatten_to_surface(&backdrop, PixelFormat::new(fmt.mode, fmt.sample, true), None); let values = surface.read_region(canvas); - let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; // Straight alpha, composited here over paper: white in RGB/Gray (1 = full light), zero ink // in CMYK (0 = paper). - let paper = if doc.mode == ColorMode::Cmyk { 0.0 } else { 1.0 }; let mut data = Vec::with_capacity((w * h).saturating_mul(device)); for px in values.chunks(n) { let a = px.last().copied().unwrap_or(1.0).clamp(0.0, 1.0); @@ -301,10 +444,9 @@ pub fn export(doc: &Document) -> std::result::Result { data.push(to8(v * a + paper * (1.0 - a))); } } - let image = PrintImage { width: doc.size.width, height: doc.size.height, channels: device, data, icc: doc.icc_profile.as_ref().map(|p| p.to_vec()) }; - let dpi = f64::from(doc.resolution_dpi.max(1.0)); + let image = PrintImage { width: doc.size.width, height: doc.size.height, channels: device, data, icc: icc.clone() }; let (pw, ph) = (f64::from(doc.size.width) * 72.0 / dpi, f64::from(doc.size.height) * 72.0 / dpi); - let page = PrintPage { paper: (pw, ph), image, rect: (0.0, 0.0, pw, ph), marks: Marks::default(), description: None, label: None }; + let page = PrintPage { paper: (pw, ph), image, rect: (0.0, 0.0, pw, ph), marks: Marks::default(), description: None, label: None, page_label: None }; let mut warnings = vec!["PDF was saved flattened: layers, masks-as-editable and transparency are not preserved (#3401)".into()]; if !doc.metadata.text.is_empty() { warnings.push("free-form text metadata is not supported by PDF; it will be dropped".into()); @@ -315,7 +457,7 @@ pub fn export(doc: &Document) -> std::result::Result { vectors.push_str(ops); } vectors.push_str("Q\n"); - Ok(ExportResult { bytes: print_pdf_with_vectors(&page, &vectors, &selection_font), warnings }) + Ok(ExportResult { bytes: print_pdf_pages(&[page], &[vectors], &selection_font, ctl)?, warnings }) } #[cfg(test)] diff --git a/crates/io/src/pdf/tests.rs b/crates/io/src/pdf/tests.rs index e745623e41d..247ab991928 100644 --- a/crates/io/src/pdf/tests.rs +++ b/crates/io/src/pdf/tests.rs @@ -1,10 +1,11 @@ //! PDF export: structure, pixel oracle, colour modes and refusal paths. use super::*; +use lopdf::{Object, StringFormat}; use photocraft_color::{PixelFormat, SampleType}; -use photocraft_doc::{Layer, LayerContent, Size}; +use photocraft_doc::{Artboard, Group, Layer, LayerContent, Size}; use photocraft_geom::Rect; -use photocraft_raster::Surface; +use photocraft_raster::{Interrupt, Surface}; fn solid(fmt: PixelFormat, x0: i32, y0: i32, x1: i32, y1: i32, px: &[f32]) -> Surface { let mut s = Surface::new(fmt); @@ -68,7 +69,7 @@ fn check_structure(pdf: &[u8]) { #[test] fn a_flat_rgb_document_saves_as_one_page_pdf() { let d = doc(ColorMode::Rgb); - let out = export(&d).unwrap(); + let out = export(&d, &Interrupt::NONE).unwrap(); let pdf = &out.bytes; check_structure(pdf); assert!(has(pdf, "/MediaBox [0 0 4.00 2.00]"), "page = image size at 72 pt/in"); @@ -85,7 +86,7 @@ fn layers_composite_over_white_before_the_page() { let mut d = doc(ColorMode::Rgb); let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); d.layers.push(Layer::new("Half red", LayerContent::Raster(solid(fmt, 1, 0, 3, 2, &[1.0, 0.0, 0.0, 0.5])))); - let out = export(&d).unwrap(); + let out = export(&d, &Interrupt::NONE).unwrap(); let img = stream_after(&out.bytes, "/Subtype /Image"); // Pixels 1-2 of row 0: 0.5 red over white = (1.0, 0.5, 0.5); elsewhere plain white. assert_eq!(&img[3..6], &[255, 128, 128], "the blend lands: {:?}", &img[3..6]); @@ -102,7 +103,7 @@ fn topmost_solid_shape_is_exported_as_a_vector_path() { ..Default::default() }; d.layers.push(Layer::new("Shape", LayerContent::Shape(shape))); - let out = export(&d).unwrap(); + let out = export(&d, &Interrupt::NONE).unwrap(); check_structure(&out.bytes); let content = stream_after(&out.bytes, "/Contents 4 0 R"); let content = String::from_utf8(content).unwrap(); @@ -128,7 +129,7 @@ fn topmost_text_layer_is_exported_with_selectable_text_and_vector_outlines() { let baseline = layout.lines.get(cluster.line).expect("a line").baseline; let origin = text.transform.apply(photocraft_geom::Point::new(f64::from(cluster.x), f64::from(baseline))); d.layers.push(Layer::new("Text", LayerContent::Text(text))); - let out = export(&d).unwrap(); + let out = export(&d, &Interrupt::NONE).unwrap(); check_structure(&out.bytes); let content = stream_after(&out.bytes, "/Contents 4 0 R"); let content = String::from_utf8(content).unwrap(); @@ -159,7 +160,7 @@ fn topmost_shape_and_text_layers_are_both_exported_as_vectors() { d.layers.push(Layer::new("Shape", LayerContent::Shape(shape))); let text = TextLayer { text: "Hi".into(), size_pt: 12.0, color: photocraft_color::Color::rgb(0.0, 1.0, 0.0), ..Default::default() }; d.layers.push(Layer::new("Text", LayerContent::Text(text))); - let out = export(&d).unwrap(); + let out = export(&d, &Interrupt::NONE).unwrap(); check_structure(&out.bytes); let content = stream_after(&out.bytes, "/Contents 4 0 R"); let content = String::from_utf8(content).unwrap(); @@ -173,10 +174,10 @@ fn topmost_shape_and_text_layers_are_both_exported_as_vectors() { #[test] fn greyscale_and_cmyk_pages_keep_their_channels() { - let g = export(&doc(ColorMode::Grayscale)).unwrap(); + let g = export(&doc(ColorMode::Grayscale), &Interrupt::NONE).unwrap(); assert!(has(&g.bytes, "/ColorSpace /DeviceGray"), "gray is DeviceGray"); assert_eq!(stream_after(&g.bytes, "/Subtype /Image"), vec![255; 4 * 2], "one sample per pixel"); - let c = export(&doc(ColorMode::Cmyk)).unwrap(); + let c = export(&doc(ColorMode::Cmyk), &Interrupt::NONE).unwrap(); assert!(has(&c.bytes, "/ColorSpace /DeviceCMYK"), "cmyk is DeviceCMYK"); assert_eq!(stream_after(&c.bytes, "/Subtype /Image"), vec![0; 4 * 2 * 4], "zero ink over the whole page"); } @@ -188,7 +189,7 @@ fn the_page_measures_at_the_document_resolution() { let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); d.layers = vec![Layer::new("Paper", LayerContent::Raster(solid(fmt, 0, 0, 300, 150, &[1.0, 1.0, 1.0, 1.0])))]; d.resolution_dpi = 300.0; - let out = export(&d).unwrap(); + let out = export(&d, &Interrupt::NONE).unwrap(); assert!(has(&out.bytes, "/MediaBox [0 0 72.00 36.00]"), "300 px at 300 dpi is one inch"); } @@ -196,7 +197,7 @@ fn the_page_measures_at_the_document_resolution() { fn an_embedded_profile_travels_as_iccbased() { let mut d = doc(ColorMode::Rgb); d.icc_profile = Some(std::sync::Arc::new(vec![1, 2, 3, 4, 5, 6, 7, 8])); - let out = export(&d).unwrap(); + let out = export(&d, &Interrupt::NONE).unwrap(); assert!(has(&out.bytes, "[/ICCBased 7 0 R]"), "the image refers to the profile"); assert_eq!(stream_after(&out.bytes, "/N 3 /Alternate"), vec![1, 2, 3, 4, 5, 6, 7, 8], "the profile bytes survive"); } @@ -204,7 +205,7 @@ fn an_embedded_profile_travels_as_iccbased() { #[test] fn other_modes_are_refused_with_an_actionable_error() { let d = doc(ColorMode::Lab); - let e = export(&d).unwrap_err().to_string(); + let e = export(&d, &Interrupt::NONE).unwrap_err().to_string(); assert!(e.contains("RGB") && e.contains("Mode"), "{e}"); assert!(crate::export(&d, "x.pdf", &crate::ExportOptions::default()).is_err()); } @@ -226,7 +227,7 @@ fn unicode_text_over_a_photo_extracts_through_a_pdf_reader() { ..Default::default() }), )); - let pdf = export(&d).unwrap().bytes; + let pdf = export(&d, &Interrupt::NONE).unwrap().bytes; let parsed = lopdf::Document::load_mem(&pdf).unwrap(); assert_eq!(parsed.extract_text(&[1]).unwrap().trim(), text); assert!(has(&pdf, "/Subtype /Image")); @@ -266,7 +267,7 @@ fn text_with_compositing_or_a_profile_stays_in_the_raster_backdrop() { _ => d.icc_profile = Some(std::sync::Arc::new(vec![1, 2, 3, 4])), } d.layers.push(layer); - let pdf = export(&d).unwrap().bytes; + let pdf = export(&d, &Interrupt::NONE).unwrap().bytes; let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap(); assert!(!content.contains("/ActualText"), "case {case} must retain the raster composite"); assert!(stream_after(&pdf, "/Subtype /Image").iter().any(|v| *v != 255), "case {case} must retain visible text"); @@ -304,7 +305,7 @@ fn text_outside_the_vector_prefix_does_not_consume_the_selection_font() { "Top text", LayerContent::Text(TextLayer { text: "Hi".into(), size_pt: 12.0, color: photocraft_color::Color::rgb(0.0, 1.0, 0.0), ..Default::default() }), )); - let pdf = export(&d).unwrap().bytes; + let pdf = export(&d, &Interrupt::NONE).unwrap().bytes; let parsed = lopdf::Document::load_mem(&pdf).unwrap(); assert_eq!(parsed.extract_text(&[1]).unwrap().trim(), "Hi", "case {case}: only the top text is selectable"); let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap(); @@ -355,7 +356,7 @@ fn a_text_layer_refused_at_emission_keeps_the_upper_vectors_and_stays_raster() { ..Default::default() }), )); - let pdf = export(&d).unwrap().bytes; + let pdf = export(&d, &Interrupt::NONE).unwrap().bytes; check_structure(&pdf); let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap(); assert!(content.contains("/ActualText "), "the first span retains the leading newline:\n{content}"); } + +#[test] +/// Each artboard becomes one labelled page, in document order. +fn artboards_export_as_one_page_each() { + use photocraft_doc::{Artboard, Group}; + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + let mut d = Document::new("pages.pdf", Size { width: 40, height: 30 }, ColorMode::Rgb, SampleType::F32); + d.resolution_dpi = 72.0; + for (rect, px) in [(Rect::new(0, 0, 20, 30), [1.0, 0.0, 0.0, 1.0]), (Rect::new(20, 0, 40, 30), [0.0, 0.0, 1.0, 1.0])] { + let layer = Layer::new("Pixels", LayerContent::Raster(solid(fmt, rect.x0, rect.y0, rect.x1, rect.y1, &px))); + let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; + d.layers.push(Layer::new("Page", LayerContent::Group(group))); + } + let out = export(&d, &Interrupt::NONE).unwrap(); + check_structure(&out.bytes); + let parsed = lopdf::Document::load_mem(&out.bytes).unwrap(); + let pages = parsed.get_pages(); + assert_eq!(pages.len(), 2); + for id in pages.values() { + let page = parsed.get_dictionary(*id).unwrap(); + let media = page.get(b"MediaBox").unwrap().as_array().unwrap(); + let w = media[2].as_float().or_else(|_| media[2].as_i64().map(|v| v as f32)).unwrap(); + let h = media[3].as_float().or_else(|_| media[3].as_i64().map(|v| v as f32)).unwrap(); + assert!((w - 20.0).abs() < 0.01 && (h - 30.0).abs() < 0.01, "page {w}x{h}"); + } + assert_eq!(out.bytes.windows(15).filter(|w| *w == b"/Subtype /Image").count(), 2); +} + +/// Side-by-side 2x2 artboards at 72 dpi, named by `names` (empty names are kept). +fn artboard_doc(names: &[&str]) -> Document { + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + let width = (names.len() as u32).saturating_mul(2).max(2); + let mut d = Document::new("labels.pdf", Size { width, height: 2 }, ColorMode::Rgb, SampleType::F32); + d.resolution_dpi = 72.0; + for (index, name) in names.iter().enumerate() { + let x0 = (index as i32) * 2; + let rect = Rect::new(x0, 0, x0 + 2, 2); + let layer = Layer::new("Pixels", LayerContent::Raster(solid(fmt, rect.x0, rect.y0, rect.x1, rect.y1, &[0.5, 0.5, 0.5, 1.0]))); + let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; + d.layers.push(Layer::new(*name, LayerContent::Group(group))); + } + d +} + +/// The decompressed samples of page `id`'s first image XObject (`/Im0`). +fn page_image(pdf: &lopdf::Document, id: lopdf::ObjectId) -> Vec { + let page = pdf.get_dictionary(id).expect("page dictionary"); + let resources = page.get(b"Resources").expect("page resources").as_dict().expect("resources dictionary"); + let xobjects = resources.get(b"XObject").expect("xobjects").as_dict().expect("xobject dictionary"); + let image = xobjects.get(b"Im0").expect("Im0").as_reference().expect("image reference"); + pdf.get_object(image).expect("image object").as_stream().expect("image stream").decompressed_content().expect("image samples") +} + +/// A named artboard gives every page a `/PageLabels` entry, and an empty name gets an empty +/// label dictionary rather than inheriting the previous name. +#[test] +fn page_labels_name_every_page_and_an_empty_name_does_not_inherit() { + let d = artboard_doc(&["A", "", "C"]); + let pdf = export(&d, &Interrupt::NONE).unwrap().bytes; + check_structure(&pdf); + let parsed = lopdf::Document::load_mem(&pdf).unwrap(); + let labels = parsed + .catalog() + .unwrap() + .get(b"PageLabels") + .expect("a named artboard gives the PDF page labels") + .as_reference() + .expect("the label tree is an indirect object"); + let nums = parsed.get_dictionary(labels).unwrap().get(b"Nums").unwrap().as_array().unwrap(); + assert_eq!(nums.len(), 6, "one key and one label dictionary per page"); + for page in 0..3 { + assert_eq!(nums[page * 2].as_i64().unwrap(), page as i64, "the tree starts at page index 0 and covers every page"); + } + let label = |page: usize| nums[page * 2 + 1].as_dict().unwrap(); + assert_eq!(label(0).get(b"P").unwrap(), &Object::String(vec![0xFE, 0xFF, 0x00, 0x41], StringFormat::Hexadecimal), "page 1 is A"); + assert!(label(1).get(b"P").is_err(), "the empty name gets an empty dictionary, not A"); + assert_eq!(label(2).get(b"P").unwrap(), &Object::String(vec![0xFE, 0xFF, 0x00, 0x43], StringFormat::Hexadecimal), "page 3 is C"); + assert_eq!(parsed.get_pages().len(), 3); +} + +/// With no non-empty name there is no label tree at all: an empty `/Nums` would be invalid. +#[test] +fn an_all_empty_artboard_names_document_has_no_page_labels_tree() { + let d = artboard_doc(&["", "", ""]); + let pdf = export(&d, &Interrupt::NONE).unwrap().bytes; + check_structure(&pdf); + let parsed = lopdf::Document::load_mem(&pdf).unwrap(); + assert!(parsed.catalog().unwrap().get(b"PageLabels").is_err(), "no names means no /PageLabels"); + assert!(!has(&pdf, "/PageLabels")); + assert_eq!(parsed.get_pages().len(), 3, "the pages themselves are still written"); +} + +/// Two artboards at the same rectangle: each page must hold only its own board's pixels, not +/// the other board composited over it. +#[test] +fn overlapping_artboards_export_only_their_own_pixels() { + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + let mut d = Document::new("overlap.pdf", Size { width: 2, height: 2 }, ColorMode::Rgb, SampleType::F32); + d.resolution_dpi = 72.0; + for (name, color) in [("Red", [1.0, 0.0, 0.0, 1.0]), ("Blue", [0.0, 0.0, 1.0, 1.0])] { + let layer = Layer::new("Pixels", LayerContent::Raster(solid(fmt, 0, 0, 2, 2, &color))); + let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(Rect::new(0, 0, 2, 2))), frame_stroke: None }; + d.layers.push(Layer::new(name, LayerContent::Group(group))); + } + let pdf = export(&d, &Interrupt::NONE).unwrap().bytes; + let parsed = lopdf::Document::load_mem(&pdf).unwrap(); + let pages = parsed.get_pages(); + let images = pages.values().map(|id| page_image(&parsed, *id)).collect::>(); + assert_eq!(images.len(), 2); + assert_eq!(images[0], [255u8, 0, 0].repeat(4), "the bottom board's page is red, not blue over red"); + assert_eq!(images[1], [0u8, 0, 255].repeat(4), "the top board's page is blue"); +} + +/// A large, mostly empty canvas must not be charged as if its whole area were rendered. +#[test] +fn sparse_canvas_artboards_export_without_a_false_memory_limit() { + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + let mut d = Document::new("sparse.pdf", Size { width: 9000, height: 9000 }, ColorMode::Rgb, SampleType::F32); + d.resolution_dpi = 72.0; + for index in 0..2 { + let x0 = index * 10; + let rect = Rect::new(x0, 0, x0 + 2, 2); + let layer = Layer::new("Pixels", LayerContent::Raster(solid(fmt, rect.x0, 0, rect.x1, 2, &[1.0, 0.0, 0.0, 1.0]))); + let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; + d.layers.push(Layer::new("Page", LayerContent::Group(group))); + } + let out = export(&d, &Interrupt::NONE).expect("two 2x2 boards on a 9000x9000 canvas must fit the export budget"); + let parsed = lopdf::Document::load_mem(&out.bytes).unwrap(); + assert_eq!(parsed.get_pages().len(), 2); +} + +/// Seven US Letter boards at 300 dpi with the importer's gaps: the pages are retained as encoded +/// data, but no whole-canvas buffer exists, so the old whole-canvas charge rejected this. +#[test] +fn seven_us_letter_artboards_export_without_a_false_memory_limit() { + const PAGE_W: u32 = 2550; + const PAGE_H: u32 = 3300; + const GAP: u32 = 80; + const PAGES: u32 = 7; + let mut d = Document::new("letters.pdf", Size { width: PAGES * PAGE_W + (PAGES - 1) * GAP, height: PAGE_H }, ColorMode::Rgb, SampleType::F32); + d.resolution_dpi = 300.0; + for index in 0..PAGES { + let x0 = (index * (PAGE_W + GAP)) as i32; + let rect = Rect::new(x0, 0, x0 + PAGE_W as i32, PAGE_H as i32); + let group = Group { children: Vec::new(), expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; + d.layers.push(Layer::new(format!("Letter {}", index + 1), LayerContent::Group(group))); + } + let out = export(&d, &Interrupt::NONE).expect("seven US Letter pages at 300 dpi fit the export budget"); + let parsed = lopdf::Document::load_mem(&out.bytes).unwrap(); + assert_eq!(parsed.get_pages().len(), 7); +} diff --git a/crates/io/src/pdf_editable.rs b/crates/io/src/pdf_editable.rs new file mode 100644 index 00000000000..af56f64e216 --- /dev/null +++ b/crates/io/src/pdf_editable.rs @@ -0,0 +1,1807 @@ +//! Experimental editable PDF import: a `craft-pdf` device turns painted paths into shape layers +//! and embedded-font text into text layers. +//! +//! Solid fills and strokes become [`ShapeLayer`]s in document pixels; embedded fonts whose glyphs +//! carry Unicode and a PostScript name become [`photocraft_doc::TextLayer`]s, with the font bytes +//! registered in the app's shared text engine. A page-sized axis-aligned clip that contains the +//! artboard is a no-op and is accepted. Images, other clipping paths, transparency groups, +//! patterns, soft masks, annotations and unsupported text mark the page unsupported, and the +//! caller falls back to the raster importer for that page with a warning. Nothing is ever half +//! imported. +//! +//! This is the app-owned `Device` route: Hayro's interpreter walks the page and reports each +//! paint with its real colour and geometry, so shape layers need no page raster underneath. That +//! is what makes moving or deleting a layer behave, and what avoids the black-path and +//! doubled-text failures of the old PDFium import. + +use std::collections::hash_map::DefaultHasher; +use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; +use std::panic::AssertUnwindSafe; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::time::Duration; + +use craft_pdf::interpret::color::Color as HayroColor; +use craft_pdf::interpret::font::{FontData, Glyph, OutlineFontData}; +use craft_pdf::interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, InterpreterWarning, Paint, PathDrawMode, SoftMask, + TransformExt, interpret_page, +}; +use craft_pdf::syntax::page::Page; +use kurbo::{Affine as KurboAffine, BezPath, Cap, Join, PathEl}; +use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::text::{CharStyle, TextRun}; +use photocraft_doc::vector::{FillRule, Knot, LineCap, LineJoin, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath}; +use photocraft_doc::{Fill, Layer, LayerContent, TextLayer}; +use photocraft_geom::{Affine, Point, Rect, TILE_SIZE}; +use photocraft_raster::Interrupt; + +/// Most layers one editable page may create; past it the page falls back to raster. +const MAX_LAYERS: usize = 20_000; +/// Most path knots one editable page may hold; past it the page falls back to raster. +const MAX_KNOTS: usize = 1_000_000; +/// Allocated tile bytes of all shape and text caches retained by one editable page. +const MAX_CACHE_BYTES: u64 = 256 << 20; +/// Bound text collection before building its layers and caches, including repeated overprinting. +const MAX_GLYPHS: usize = 100_000; +/// The strings in collected glyph records must also fit, not just their record count. +const MAX_GLYPH_BYTES: usize = 16 << 20; +/// Cap on fill/stroke glyph comparisons so hostile pages cannot make pairing quadratic forever. +const MAX_PAIR_CHECKS: usize = 1_000_000; +/// How often the watcher copies the interrupt into craft-pdf's cancellation flag. The Device's +/// callbacks are only reached per paint, so a page with one long path still needs this; 20 ms +/// keeps wakeups negligible while staying responsive. +const POLL: Duration = Duration::from_millis(20); +/// Largest gap (in em) inside one run before a space is inserted. +const WORD_GAP: f64 = 0.2; +/// Largest baseline offset (in em) for glyphs to share one line. +const LINE_TOLERANCE: f64 = 0.30; + +/// Why an editable page could not be built. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EditableError { + /// The caller cancelled the import. + Cancelled, + /// The page uses something this first step cannot represent; rasterise it instead. + Unsupported(&'static str), +} + +/// Builds one page's shape layers in document coordinates inside `rect`. +/// +/// `rect` is the page's artboard in document pixels; the page's displayed size comes from +/// `Page::render_dimensions()` (CropBox, rotation and UserUnit already applied), so a point maps +/// to `rect` by the per-axis scale `rect.size / render_dimensions`. Nothing here re-applies the +/// CropBox offset or rotation. +pub fn page(page: &Page<'_>, rect: Rect, ctl: &Interrupt) -> Result, EditableError> { + ctl.check().map_err(|_| EditableError::Cancelled)?; + let (width_pt, height_pt) = page.render_dimensions(); + if !width_pt.is_finite() || !height_pt.is_finite() || width_pt <= 0.0 || height_pt <= 0.0 { + return Err(EditableError::Unsupported("the page has no area")); + } + let sx = f64::from(rect.width()) / f64::from(width_pt); + let sy = f64::from(rect.height()) / f64::from(height_pt); + let doc = Affine::translate(f64::from(rect.x0), f64::from(rect.y0)).mul(&Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] }); + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + let cancelled = Arc::new(AtomicBool::new(false)); + let finished = Arc::new(AtomicBool::new(false)); + let warned = Arc::new(AtomicU8::new(0)); + let sink = Arc::clone(&warned); + let settings = InterpreterSettings { + cancelled: Some(Arc::clone(&cancelled)), + warning_sink: Arc::new(move |warning| { + let bit = match warning { + InterpreterWarning::ImageDecodeFailure => 1, + InterpreterWarning::UnsupportedFont => 2, + InterpreterWarning::ContentTruncated => 4, + }; + sink.fetch_or(bit, Ordering::Relaxed); + }), + ..InterpreterSettings::default() + }; + let cache = InterpreterCache::new(); + let mut device = PathDevice { + doc, + clip: rect, + fmt, + dpi: 72.0 * sx, + ctl: *ctl, + layers: Vec::new(), + glyphs: Vec::new(), + glyph_bytes: 0, + strokes: Vec::new(), + fonts: Vec::new(), + font_keys: HashMap::new(), + seen_fonts: HashSet::new(), + soft_mask: false, + clips: Vec::new(), + knots: 0, + cached: 0, + unsupported: None, + cancelled: false, + }; + std::thread::scope(|scope| { + let flag = Arc::clone(&cancelled); + let done = Arc::clone(&finished); + let ctl = *ctl; + scope.spawn(move || { + while !done.load(Ordering::Relaxed) { + if ctl.cancelled() { + flag.store(true, Ordering::Relaxed); + } + std::thread::sleep(POLL); + } + }); + // A hostile page must not take the process down. This only guards profiles that unwind; + // a `panic = "abort"` build aborts here just as the raster path would. + let result = std::panic::catch_unwind(AssertUnwindSafe(|| { + let bbox = kurbo::Rect::new(0.0, 0.0, f64::from(width_pt), f64::from(height_pt)); + let mut context = Context::new(page.initial_transform(true).to_kurbo(), bbox, &cache, page.xref(), settings); + interpret_page(page, &mut context, &mut device); + })); + if result.is_err() { + device.unsupported = Some("the page could not be interpreted"); + } + finished.store(true, Ordering::Relaxed); + }); + if device.cancelled || ctl.cancelled() { + return Err(EditableError::Cancelled); + } + let warned = warned.load(Ordering::Relaxed); + if warned & 4 != 0 { + return Err(EditableError::Unsupported("the page content was truncated")); + } + if warned & 2 != 0 { + return Err(EditableError::Unsupported("a font could not be used")); + } + if warned & 1 != 0 { + return Err(EditableError::Unsupported("an image could not be decoded")); + } + if let Some(reason) = device.unsupported { + return Err(EditableError::Unsupported(reason)); + } + pair_fake_bold(&mut device.glyphs, device.strokes, ctl)?; + let families = register_fonts(&device.fonts)?; + let texts = text_layers(&device.glyphs, &families, device.dpi, ctl)?; + if device.layers.len().saturating_add(texts.len()) > MAX_LAYERS { + return Err(EditableError::Unsupported("too many objects")); + } + let mut layers = merge_text_layers(device.layers, texts); + render_text_caches(&mut layers, device.dpi, device.fmt, ctl, &mut device.cached)?; + Ok(layers) +} + +/// Removes stroked glyphs that duplicate a fill at the same place, marking that glyph fake bold. +fn pair_fake_bold(glyphs: &mut [GlyphRun], strokes: Vec, ctl: &Interrupt) -> Result<(), EditableError> { + // Index fills by the exact attributes a stroke must match, so each stroke only compares + // against candidates at its own paint order/font/text instead of the whole page. + let mut index: HashMap<(usize, u128, u64), Vec> = HashMap::new(); + for (at, glyph) in glyphs.iter().enumerate() { + if at.is_multiple_of(256) && ctl.check().is_err() { + return Err(EditableError::Cancelled); + } + index.entry((glyph.order, glyph.font.key, text_hash(&glyph.text))).or_default().push(at); + } + let mut paired = vec![false; glyphs.len()]; + let mut checks = 0usize; + for stroke in strokes { + let mut found = None; + if let Some(candidates) = index.get(&(stroke.order, stroke.font.key, text_hash(&stroke.text))) { + for &at in candidates { + if paired[at] { + continue; + } + if checks == MAX_PAIR_CHECKS { + return Err(EditableError::Unsupported("too much stroked text")); + } + if checks.is_multiple_of(256) && ctl.check().is_err() { + return Err(EditableError::Cancelled); + } + checks += 1; + let glyph = &glyphs[at]; + let tolerance = 0.01 * glyph.size_px + 0.01; + if glyph.text == stroke.text + && same_style(glyph, &stroke) + && (glyph.origin.x - stroke.origin.x).abs() <= tolerance + && (glyph.origin.y - stroke.origin.y).abs() <= tolerance + { + found = Some(at); + break; + } + } + } + let Some(at) = found else { + return Err(EditableError::Unsupported("stroked text")); + }; + glyphs[at].fake_bold = true; + paired[at] = true; + } + Ok(()) +} + +/// A hash of a glyph string for the fake-bold index; equality is still checked on the string. +fn text_hash(text: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + text.hash(&mut hasher); + hasher.finish() +} + +/// Builds one point-text layer per line and style run, with the layer index it must be inserted +/// at to preserve paint order relative to paths. +fn text_layers( + glyphs: &[GlyphRun], + families: &std::collections::HashMap, + dpi: f64, + ctl: &Interrupt, +) -> Result, EditableError> { + let mut out = Vec::new(); + let mut i = 0; + while i < glyphs.len() { + ctl.check().map_err(|_| EditableError::Cancelled)?; + if out.len() >= MAX_LAYERS { + return Err(EditableError::Unsupported("too many text objects")); + } + let first = &glyphs[i]; + let family = families.get(&first.font.key).cloned().unwrap_or_else(|| first.font.family.clone()); + let (dx, dy) = (first.angle.cos(), first.angle.sin()); + let (nx, ny) = (-dy, dx); + let baseline = first.origin.x * nx + first.origin.y * ny; + let start = i; + let mut text = String::new(); + let mut positions = Vec::new(); + let mut last = first; + while i < glyphs.len() { + let glyph = &glyphs[i]; + if glyph.order != first.order || !same_style(glyph, first) { + break; + } + let line = glyph.origin.x * nx + glyph.origin.y * ny; + if (line - baseline).abs() > LINE_TOLERANCE * first.size_px { + break; + } + if i > start { + let along = (glyph.origin.x - first.origin.x) * dx + (glyph.origin.y - first.origin.y) * dy; + let prev = (last.origin.x - first.origin.x) * dx + (last.origin.y - first.origin.y) * dy; + let gap = along - (prev + last.advance); + if gap > 3.0 * first.size_px { + break; + } + if gap > WORD_GAP * first.size_px && !text.ends_with(' ') { + text.push(' '); + } + } + positions.push((text.len(), glyph.origin)); + text.push_str(&glyph.text); + last = glyph; + i += 1; + } + if !text.is_empty() && has_ink(&text) { + let layer = text_layer(text, first, out.len(), &family); + if let LayerContent::Text(text) = &layer.content { + let shared = photocraft_text::shared(); + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); + let layout = engine.layout(text, dpi as f32); + // PDF spacing is already in the glyph origins. A word-gap heuristic cannot + // preserve TJ, character spacing, or explicitly positioned glyphs. Keep a run + // editable only if the actual text layout reproduces every recorded origin. + let clusters: std::collections::HashMap<_, _> = layout.clusters.iter().map(|c| (c.range.start, c)).collect(); + for (offset, expected) in positions { + let cluster = clusters.get(&offset); + let actual = + cluster.and_then(|c| layout.lines.get(c.line).map(|line| text.transform.apply(Point::new(f64::from(c.x), f64::from(line.baseline))))); + if actual.is_none_or(|at| (at.x - expected.x).abs() > 0.5 || (at.y - expected.y).abs() > 0.5) { + return Err(EditableError::Unsupported("text positioning")); + } + } + } + out.push((first.order, layer)); + } + if i == start { + i += 1; + } + } + Ok(out) +} + +/// Whether `b` belongs to the same line and style run as `a`. +fn same_style(a: &GlyphRun, b: &GlyphRun) -> bool { + let weight = |glyph: &GlyphRun| if glyph.fake_bold { 700 } else { glyph.font.weight }; + a.font.key == b.font.key + && a.font.family == b.font.family + && a.font.italic == b.font.italic + && weight(a) == weight(b) + && (a.size_pt - b.size_pt).abs() < 0.05 + && (a.angle - b.angle).abs() < 0.02 + && (a.color.c[0] - b.color.c[0]).abs() < 0.004 + && (a.color.c[1] - b.color.c[1]).abs() < 0.004 + && (a.color.c[2] - b.color.c[2]).abs() < 0.004 + && (a.color.alpha - b.color.alpha).abs() < 0.004 +} + +/// One line's point-text layer, with the family the shared engine registered. +fn text_layer(text: String, run: &GlyphRun, number: usize, family: &str) -> Layer { + let weight = if run.fake_bold { 700 } else { run.font.weight }; + let font_style = match (weight >= 600, run.font.italic) { + (true, true) => "Bold Italic", + (true, false) => "Bold", + (false, true) => "Italic", + (false, false) => "Regular", + }; + let style = CharStyle { + font_family: family.to_string(), + font_style: font_style.to_string(), + postscript_name: Some(run.font.postscript.clone()), + weight: u16::try_from(weight).unwrap_or(400), + italic: run.font.italic, + faux_bold: run.fake_bold, + // PDF positions already contain kerning; applying font kerning again moves glyphs. + kerning: photocraft_doc::text::Kerning::Off, + size_pt: run.size_pt as f32, + color: run.color, + ..CharStyle::default() + }; + let layer = TextLayer { + text: text.clone(), + font_family: family.to_string(), + size_pt: run.size_pt as f32, + color: run.color, + transform: Affine::translate(run.origin.x, run.origin.y).mul(&Affine::rotate(run.angle)), + runs: vec![TextRun { len: text.len(), style }], + ..TextLayer::default() + }; + Layer::new(format!("Text {}", number + 1), LayerContent::Text(layer)) +} + +/// Registers each embedded face with the shared engine under its content-keyed family and returns +/// the family each font key must use. +fn register_fonts(fonts: &[(u128, String, String, FontData)]) -> Result, EditableError> { + let shared = photocraft_text::shared(); + let mut families = std::collections::HashMap::new(); + for (key, postscript, derived, data) in fonts { + // Copy before taking the engine lock; parsing still happens under it, but the UI's + // layout is not blocked while the bytes are cloned. + let bytes = data.as_ref().as_ref().to_vec(); + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); + // Prefer an installed full family; a subset only holds the glyphs the page uses. The + // Device has the font program's PostScript name, not the PDF `/BaseFont`; a content-keyed + // alias keeps two documents' subsets of one name apart for the life of the shared engine, + // and the same face imported twice reuses its registration. + let family = if engine.has_family(derived) { + derived.clone() + } else { + let alias = font_alias(postscript, *key); + if engine.has_family(&alias) { + alias + } else { + let names = engine.register_font_data_as(bytes, &alias); + if names.is_empty() && !engine.has_family(&alias) { + return Err(EditableError::Unsupported("a font could not be registered")); + } + names.first().cloned().unwrap_or(alias) + } + }; + families.insert(*key, family); + } + Ok(families) +} + +/// Renders the text caches so the page is complete before the engine refreshes it. +fn render_text_caches(layers: &mut [Layer], dpi: f64, fmt: PixelFormat, ctl: &Interrupt, cached: &mut u64) -> Result<(), EditableError> { + let shared = photocraft_text::shared(); + for layer in layers { + if ctl.check().is_err() { + return Err(EditableError::Cancelled); + } + if let LayerContent::Text(text) = &mut layer.content + && has_ink(&text.text) + { + // Re-lock per layer so a text-heavy import does not hold the UI's engine for the + // whole cache-render loop. + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); + let layout = engine.layout(text, dpi as f32); + let area = photocraft_text::render::ink_rect(&layout, &text.transform); + admit_cache(cached, area, fmt)?; + let rendered = photocraft_text::render::rasterize(&layout, &text.transform, fmt, text.antialias); + let rect = rendered.rect; + text.cache = Some(rendered.surface); + if rect.width() == 0 || rect.height() == 0 { + return Err(EditableError::Unsupported("a text cache could not be rendered")); + } + } + } + Ok(()) +} + +/// Reserves complete tiles before rendering: even one painted pixel allocates a whole tile. +fn admit_cache(cached: &mut u64, area: Rect, fmt: PixelFormat) -> Result<(), EditableError> { + let bytes = area.tile_count().saturating_mul(TILE_SIZE as u64).saturating_mul(TILE_SIZE as u64).saturating_mul(fmt.bytes_per_pixel() as u64); + let total = cached.saturating_add(bytes); + if total > MAX_CACHE_BYTES { + return Err(EditableError::Unsupported("page cache budget")); + } + *cached = total; + Ok(()) +} + +/// Puts text layers back at the path index where their glyphs were painted. +fn merge_text_layers(layers: Vec, mut texts: Vec<(usize, Layer)>) -> Vec { + texts.sort_by_key(|(order, _)| *order); + let mut out = Vec::with_capacity(layers.len() + texts.len()); + let mut texts = texts.into_iter().peekable(); + for (index, layer) in layers.into_iter().enumerate() { + while texts.peek().is_some_and(|(order, _)| *order <= index) { + if let Some((_, text)) = texts.next() { + out.push(text); + } + } + out.push(layer); + } + out.extend(texts.map(|(_, layer)| layer)); + out +} + +/// A content key for an embedded face, used to group its glyphs and to keep aliases unique. +fn font_content_key(data: &OutlineFontData) -> u128 { + let hash = blake3::hash(data.data.as_ref().as_ref()); + let mut key = [0u8; 16]; + key.copy_from_slice(&hash.as_bytes()[..16]); + u128::from_le_bytes(key) +} + +/// Whether text has a visible character (not only spaces, controls or zero-width marks). +fn has_ink(text: &str) -> bool { + text.chars().any(|c| !c.is_whitespace() && !c.is_control() && !matches!(c, '\u{200B}'..='\u{200F}' | '\u{2060}' | '\u{FEFF}')) +} + +/// The family alias an embedded face is registered under: its PostScript name plus a content +/// key, so subsets with the same name from different documents cannot replace each other. +fn font_alias(postscript: &str, key: u128) -> String { + format!("{postscript} {key:016x}") +} + +/// Collects filled and stroked paths and glyph runs; anything else marks the page unsupported. +struct PathDevice<'a> { + /// Page points to document pixels: translation to the artboard plus the per-axis scale. + doc: Affine, + /// The page's artboard in document pixels; shape caches and strokes are clipped to it. + clip: Rect, + /// Document pixel format the shape caches are rendered in. + fmt: PixelFormat, + /// Document DPI, for text-layer caches. + dpi: f64, + ctl: Interrupt<'a>, + layers: Vec, + /// Filled glyphs in content order. + glyphs: Vec, + glyph_bytes: usize, + /// Stroked glyphs, paired with a fill at the same place to recognise fake bold. + strokes: Vec, + /// Embedded fonts met on the page: cache key, PostScript name, derived family and bytes. + fonts: Vec<(u128, String, String, FontData)>, + /// Hayro's cheap font identity → our content hash, so bytes are hashed once per face. + font_keys: HashMap, + /// Content keys of fonts already in [`Self::fonts`], so glyph callbacks do not scan it. + seen_fonts: HashSet, + /// Whether a soft mask is active; the first paint under one falls back the page. + soft_mask: bool, + /// One entry per clip pushed by the interpreter: `true` for an accepted no-op clip. + clips: Vec, + knots: usize, + /// Allocated tile bytes of caches retained so far. + cached: u64, + unsupported: Option<&'static str>, + cancelled: bool, +} + +/// One drawn glyph with the style the text layer needs. +struct GlyphRun { + text: String, + /// Baseline origin in document pixels. + origin: Point, + size_pt: f64, + /// Em size in document pixels. + size_px: f64, + /// Baseline direction in radians (view space, y down). + angle: f64, + /// Advance in document pixels. + advance: f64, + font: FontInfo, + /// How many layers had been drawn when this glyph was painted; text runs split at a path + /// between glyphs so paint order is preserved. + order: usize, + color: Color, + /// Drawn both filled and stroked: word-processor fake bold. + fake_bold: bool, +} + +/// The font style a run carries. +#[derive(Clone)] +struct FontInfo { + /// Hayro's font cache key, to group glyphs of one font. + key: u128, + family: String, + postscript: String, + weight: u32, + italic: bool, +} + +impl PathDevice<'_> { + /// Stops collecting at the first thing this step cannot represent. + fn unsupported(&mut self, reason: &'static str) { + if self.unsupported.is_none() { + self.unsupported = Some(reason); + } + } + + /// Whether `clip` is an axis-aligned rectangle that contains the whole artboard, so it + /// changes nothing in document space and can be ignored. + fn no_op_clip(&self, clip: &ClipPath) -> bool { + let Some((x0, y0, x1, y1)) = axis_aligned_rect(&clip.path) else { return false }; + let a = self.doc.apply(Point::new(x0, y0)); + let b = self.doc.apply(Point::new(x1, y1)); + let (dx0, dx1) = (a.x.min(b.x), a.x.max(b.x)); + let (dy0, dy1) = (a.y.min(b.y), a.y.max(b.y)); + let board = self.clip; + dx0 <= f64::from(board.x0) + 0.5 && dy0 <= f64::from(board.y0) + 0.5 && dx1 >= f64::from(board.x1) - 0.5 && dy1 >= f64::from(board.y1) - 0.5 + } + + /// Adds one shape layer unless a page cap is reached. Renders its pixel cache within the + /// page's cache budget so the layer is visible before the engine refreshes it. + fn push_shape(&mut self, path: Path, fill: Option, stroke: Option) { + if self.ctl.check().is_err() { + self.cancelled = true; + return; + } + if path.is_empty() { + return; + } + if stroke.as_ref().is_some_and(|stroke| !stroke.dashes.is_empty()) && path.subpaths.iter().any(is_zero_length) { + // A dashed zero-length subpath cannot be reproduced as a single point. + self.unsupported("a dashed zero-length subpath"); + return; + } + if self.layers.len() >= MAX_LAYERS { + self.unsupported("too many objects"); + return; + } + let mut shape = ShapeLayer { path, fill, stroke, ..ShapeLayer::default() }; + // Use the renderer's actual ink bounds, including miter tips, and reuse the compilation. + // Control-point bounds expanded by one stroke width can clip acute joins. + let compiled = photocraft_vector::CompiledShape::new(&shape, photocraft_vector::DEFAULT_TOLERANCE, self.clip); + let area = compiled.bounds().unwrap_or(Rect::EMPTY).intersect(&self.clip); + if area.width() > 0 && area.height() > 0 { + if let Err(EditableError::Unsupported(reason)) = admit_cache(&mut self.cached, area, self.fmt) { + self.unsupported(reason); + return; + } + shape.cache = Some(compiled.render(self.fmt, area)); + } + self.layers.push(Layer::new(format!("Path {}", self.layers.len() + 1), LayerContent::Shape(shape))); + } +} + +impl<'a> Device<'a> for PathDevice<'_> { + /// A soft mask changes compositing in ways a plain layer cannot express, but it only matters + /// if something is painted while it is active. + fn set_soft_mask(&mut self, mask: Option>) { + self.soft_mask = mask.is_some(); + } + + /// Only normal blending is editable. + fn set_blend_mode(&mut self, blend_mode: BlendMode) { + if blend_mode != BlendMode::Normal { + self.unsupported("blend modes"); + } + } + + /// Turns a filled/stroked path into a shape layer, rejecting paints and strokes it cannot keep. + fn draw_path(&mut self, path: &BezPath, transform: KurboAffine, paint: &Paint<'a>, draw_mode: &PathDrawMode) { + if self.unsupported.is_some() { + return; + } + if self.soft_mask { + self.unsupported("soft masks"); + return; + } + if self.ctl.check().is_err() { + self.cancelled = true; + return; + } + let color = match paint { + Paint::Color(color) => to_color(color), + Paint::Pattern(_) => { + self.unsupported("patterns"); + return; + } + }; + let full = self.doc.mul(&knots_affine(&transform)); + let Some(path) = to_path(path, &full, &mut self.knots) else { + self.unsupported("too many path points"); + return; + }; + match draw_mode { + PathDrawMode::Fill(rule) => { + let mut path = path; + path.fill_rule = match rule { + craft_pdf::interpret::FillRule::NonZero => FillRule::NonZero, + craft_pdf::interpret::FillRule::EvenOdd => FillRule::EvenOdd, + }; + self.push_shape(path, Some(Fill::Solid(color)), None); + } + PathDrawMode::Stroke(stroke) => { + if !uniform_transform(&transform) { + self.unsupported("non-uniform stroke transform"); + return; + } + let Some(stroke) = to_stroke(stroke, color, full.determinant().abs().sqrt()) else { + self.unsupported("stroke properties"); + return; + }; + self.push_shape(path, None, Some(stroke)); + } + } + } + + /// Accepts an axis-aligned clip that contains the artboard; other clips fall back. + fn push_clip_path(&mut self, clip_path: &ClipPath) { + if self.no_op_clip(clip_path) { + self.clips.push(true); + return; + } + self.clips.push(false); + self.unsupported("clipping paths"); + } + + /// Transparency groups are not represented by the layer model. + fn push_transparency_group(&mut self, _opacity: f32, mask: Option>, blend_mode: BlendMode) { + // A group needs its own blend/opacity/mask handling; until that exists the page is + // rasterised rather than flattened into the wrong stacking order. + let _ = (mask, blend_mode); + self.unsupported("transparency groups"); + } + + /// Records a filled glyph as text, or its stroke copy for fake-bold pairing. + fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: KurboAffine, glyph_transform: KurboAffine, paint: &Paint<'a>, draw_mode: &GlyphDrawMode) { + if self.unsupported.is_some() { + return; + } + if matches!(draw_mode, GlyphDrawMode::Invisible) { + return; + } + if self.soft_mask { + self.unsupported("soft masks"); + return; + } + if self.ctl.check().is_err() { + self.cancelled = true; + return; + } + if matches!(draw_mode, GlyphDrawMode::Clip) { + self.unsupported("text used as a clipping path"); + return; + } + if self.glyphs.len().saturating_add(self.strokes.len()) >= MAX_GLYPHS { + self.unsupported("too many glyphs"); + return; + } + let text = match glyph.as_unicode() { + Some(craft_pdf::interpret::hayro_cmap::BfString::Char(c)) => c.to_string(), + Some(craft_pdf::interpret::hayro_cmap::BfString::String(s)) => s, + None => { + // No Unicode means the text layer could not be edited faithfully. + self.unsupported("text without a Unicode mapping"); + return; + } + }; + if text.is_empty() || text.chars().all(|c| c.is_control()) { + return; + } + let Glyph::Outline(outline) = glyph else { + self.unsupported("Type 3 text"); + return; + }; + let Some(data) = outline.font_data() else { + self.unsupported("text without an embedded font"); + return; + }; + if !data.embedded { + self.unsupported("text in a substituted font"); + return; + } + let Some(postscript) = data.postscript_name.clone().filter(|name| !name.is_empty()) else { + self.unsupported("text without a font name"); + return; + }; + let color = match paint { + Paint::Color(color) => to_color(color), + Paint::Pattern(_) => { + self.unsupported("text with a pattern paint"); + return; + } + }; + let t = transform * glyph_transform; + let [a, b, c, d, e, f] = t.as_coeffs(); + // A glyph space of 1000 units per em, so the determinant's square root is 1/1000 of the + // em size. Upright text has a positive a and a negative d (the view flips y). + let size_pt = (a * d - b * c).abs().sqrt() * 1000.0; + if !size_pt.is_finite() || size_pt <= 0.0 { + self.unsupported("text without a usable size"); + return; + } + // Skewed, mirrored or sheared text cannot be a point text layer; leave the page to raster. + // After the view's y flip an upright or rotated run has a ≈ -d and b ≈ c. + let tolerance = 0.01 * (a * d - b * c).abs().sqrt(); + if (a + d).abs() > tolerance || (b - c).abs() > tolerance { + self.unsupported("skewed or mirrored text"); + return; + } + if let GlyphDrawMode::Stroke(stroke) = draw_mode { + // Only a thin, solid, same-colour outline can be approximated by faux bold. + // Colour equality is checked against the fill in pair_fake_bold. + if !stroke.line_width.is_finite() + || stroke.line_width < 0.0 + || f64::from(stroke.line_width) * transform.determinant().abs().sqrt() > size_pt * 0.05 + || !stroke.dash_array.is_empty() + { + self.unsupported("stroked text geometry"); + return; + } + } + let origin = self.doc.apply(Point::new(e, f)); + let size_px = size_pt * self.doc.determinant().abs().sqrt(); + let angle = b.atan2(a); + if !angle.is_finite() || !size_px.is_finite() || size_px <= 0.0 { + self.unsupported("text without a usable transform"); + return; + } + let advance = f64::from(outline.advance_width().unwrap_or(500.0)) / 1000.0 * size_px; + let weight = font_weight(&data, &postscript); + let italic = data.is_italic || name_has(&postscript, &["italic", "oblique"]); + let identity = outline.font_cache_key(); + let key = *self.font_keys.entry(identity).or_insert_with(|| font_content_key(&data)); + let family = family_from_postscript(&postscript); + if self.seen_fonts.insert(key) { + self.fonts.push((key, postscript.clone(), family.clone(), data.data.clone())); + } + let bytes = std::mem::size_of::().saturating_add(text.len()).saturating_add(family.len()).saturating_add(postscript.len()); + self.glyph_bytes = self.glyph_bytes.saturating_add(bytes); + if self.glyph_bytes > MAX_GLYPH_BYTES { + self.unsupported("glyph memory budget"); + return; + } + let font = FontInfo { key, family, postscript, weight, italic }; + let glyph = GlyphRun { text, origin, size_pt, size_px, angle, advance, font, color, fake_bold: false, order: self.layers.len() }; + if matches!(draw_mode, GlyphDrawMode::Stroke(_)) { + self.strokes.push(glyph); + } else { + self.glyphs.push(glyph); + } + } + + /// Images are not editable at this step. + fn draw_image(&mut self, _image: Image<'a, '_>, _transform: KurboAffine) { + self.unsupported("images"); + } + + /// Balances [`Self::push_clip_path`]. + fn pop_clip_path(&mut self) { + let _ = self.clips.pop(); + } + + /// Balances [`Self::push_transparency_group`]. + fn pop_transparency_group(&mut self) {} + + /// Annotations are not part of the page's editable paint. + fn begin_annotations(&mut self) { + self.unsupported("annotations"); + } +} + +/// The weight a font is given to a text layer, from its metadata and name. +fn font_weight(data: &OutlineFontData, postscript: &str) -> u32 { + if name_has(postscript, &["bold", "semibold", "demibold", "extrabold", "ultrabold", "black", "heavy"]) { + return data.weight.unwrap_or(700).max(700); + } + data.weight.unwrap_or(400) +} + +/// Whether `name` contains any of the lowercase style words. +fn name_has(name: &str, words: &[&str]) -> bool { + let lower = name.to_ascii_lowercase(); + words.iter().any(|word| lower.contains(word)) +} + +/// A family name from a `/BaseFont`/PostScript name, per the rules PdfCraft and pdfcer use: +/// drop a six-letter subset tag, a trailing style suffix and `MT`/`PS`, then space camel case. +/// The raw name stays in `CharStyle::postscript_name`, because some systems match that spelling. +fn family_from_postscript(name: &str) -> String { + let mut s = name; + if s.len() > 7 && s.as_bytes()[6] == b'+' && s[..6].bytes().all(|b| b.is_ascii_uppercase()) { + s = &s[7..]; + } + if let Some((head, tail)) = s.rsplit_once('-') + && is_style_word(tail) + { + s = head; + } + loop { + if let Some(rest) = s.strip_suffix("PSMT") { + s = rest; + } else if let Some(rest) = s.strip_suffix("MT") { + s = rest; + } else if let Some(rest) = s.strip_suffix("PS") { + s = rest; + } else { + break; + } + } + let spaced = space_camel(s); + if spaced.is_empty() { name.to_string() } else { spaced } +} + +/// Whether a suffix is a style word (after dropping `MT`/`PS` from it). +fn is_style_word(word: &str) -> bool { + let mut w = word; + loop { + if let Some(rest) = w.strip_suffix("MT") { + w = rest; + } else if let Some(rest) = w.strip_suffix("PS") { + w = rest; + } else { + break; + } + } + matches!( + w.to_ascii_lowercase().as_str(), + "bold" + | "italic" + | "oblique" + | "bolditalic" + | "boldoblique" + | "regular" + | "medium" + | "light" + | "black" + | "semibold" + | "demibold" + | "heavy" + | "thin" + | "book" + | "roman" + | "condensed" + | "narrow" + | "extended" + ) +} + +/// `TimesNewRoman` → `Times New Roman`; acronym runs and hyphens stay as they are. +fn space_camel(name: &str) -> String { + let chars: Vec = name.chars().collect(); + let mut out = String::with_capacity(name.len() + 4); + for (i, ch) in chars.iter().copied().enumerate() { + let prev = i.checked_sub(1).and_then(|j| chars.get(j)).copied(); + let next = chars.get(i + 1).copied(); + let boundary = ch.is_ascii_uppercase() && prev.is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit()) + || ch.is_ascii_uppercase() && prev.is_some_and(|p| p.is_ascii_uppercase()) && next.is_some_and(|n| n.is_ascii_lowercase()); + if boundary && !out.is_empty() && !out.ends_with(' ') && !out.ends_with('-') { + out.push(' '); + } + out.push(ch); + } + out +} + +/// The bounds of a path that is one axis-aligned rectangle, else `None`. +fn axis_aligned_rect(path: &BezPath) -> Option<(f64, f64, f64, f64)> { + let elements = path.elements(); + if !(5..=6).contains(&elements.len()) { + return None; + } + let mut points = Vec::with_capacity(4); + for element in elements { + match *element { + PathEl::MoveTo(p) | PathEl::LineTo(p) => points.push((p.x, p.y)), + PathEl::ClosePath => {} + PathEl::QuadTo(..) | PathEl::CurveTo(..) => return None, + } + } + if points.len() != 4 { + // An explicitly closed rectangle uses four LineTo points plus the duplicate start. + if points.len() == 5 && (points[0].0 - points[4].0).abs() < 1.0e-6 && (points[0].1 - points[4].1).abs() < 1.0e-6 { + points.pop(); + } else { + return None; + } + } + let x0 = points.iter().map(|p| p.0).fold(f64::INFINITY, f64::min); + let x1 = points.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max); + let y0 = points.iter().map(|p| p.1).fold(f64::INFINITY, f64::min); + let y1 = points.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max); + if !(x1 > x0 && y1 > y0) { + return None; + } + let on_edge = |v: f64, lo: f64, hi: f64| (v - lo).abs() < 1.0e-6 || (v - hi).abs() < 1.0e-6; + if points.iter().any(|(x, y)| !on_edge(*x, x0, x1) || !on_edge(*y, y0, y1)) { + return None; + } + // A bow-tie traversal also visits the four corners, so require its shoelace area to match + // the bounding box (a real rectangle covers it exactly). + let mut twice_area = 0.0; + for i in 0..4 { + let (ax, ay) = points[i]; + let (bx, by) = points[(i + 1) % 4]; + twice_area += ax * by - bx * ay; + } + let box_area = (x1 - x0) * (y1 - y0); + if (twice_area.abs() * 0.5 - box_area).abs() > box_area * 1.0e-3 { + return None; + } + Some((x0, y0, x1, y1)) +} + +/// Whether every knot of a subpath shares the first anchor (a zero-length subpath). +fn is_zero_length(subpath: &Subpath) -> bool { + subpath.knots.first().is_some_and(|first| subpath.knots.iter().all(|knot| knot.anchor.x == first.anchor.x && knot.anchor.y == first.anchor.y)) +} + +/// Whether a path transform keeps circles circular: both axes the same length and perpendicular. +fn uniform_transform(transform: &KurboAffine) -> bool { + let [a, b, c, d, _, _] = transform.as_coeffs(); + let x = (a * a + b * b).sqrt(); + let y = (c * c + d * d).sqrt(); + if !x.is_finite() || !y.is_finite() || x <= 0.0 || y <= 0.0 { + return false; + } + let same = (x - y).abs() <= 0.01 * x.max(y); + let square = (a * c + b * d).abs() <= 0.01 * x * y; + same && square +} + +/// A PDF colour as a straight-alpha PhotoCraft colour. +fn to_color(color: &HayroColor) -> Color { + let rgba = color.to_rgba(); + // `AlphaColor` components are straight (not premultiplied), despite the raster path being + // premultiplied: `Color::to_rgba8` reads them as straight too. + let [r, g, b, a] = rgba.components(); + Color::rgba(r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0), a.clamp(0.0, 1.0)) +} + +/// A kurbo affine as PhotoCraft's affine (same `[a, b, c, d, e, f]` layout). +fn knots_affine(transform: &KurboAffine) -> Affine { + let [a, b, c, d, e, f] = transform.as_coeffs(); + Affine { m: [a, b, c, d, e, f] } +} + +/// A Hayro stroke as PhotoCraft's stroke: width in pixels, dashes in multiples of the width. +fn to_stroke(stroke: &craft_pdf::interpret::StrokeProps, color: Color, scale: f64) -> Option { + if !stroke.line_width.is_finite() || stroke.line_width < 0.0 || !scale.is_finite() || scale <= 0.0 { + return None; + } + // A zero line width is the thinnest line the device can draw (ISO 32000-2 §8.4.3.2); the + // raster path draws it one document pixel wide, so do the same and use that as the dash base. + let width = if stroke.line_width > 0.0 { stroke.line_width * scale as f32 } else { 1.0 }; + if !width.is_finite() || width <= 0.0 { + return None; + } + let mut dashes: Vec = stroke.dash_array.iter().map(|dash| dash * scale as f32 / width).collect(); + // An odd PDF dash array repeats, so [3] means 3 on, 3 off rather than a single entry. + if dashes.len() % 2 == 1 { + let copy = dashes.clone(); + dashes.extend(copy); + } + Some(ShapeStroke { + width, + paint: Fill::Solid(color), + opacity: 1.0, + align: StrokeAlign::Center, + cap: match stroke.line_cap { + Cap::Butt => LineCap::Butt, + Cap::Round => LineCap::Round, + Cap::Square => LineCap::Square, + }, + join: match stroke.line_join { + Join::Miter => LineJoin::Miter, + Join::Round => LineJoin::Round, + Join::Bevel => LineJoin::Bevel, + }, + miter_limit: stroke.miter_limit, + dashes, + dash_offset: stroke.dash_offset * scale as f32 / width, + }) +} + +/// Converts a kurbo path to PhotoCraft's cubic knot model. `None` once `knots` passes the cap. +fn to_path(bez: &BezPath, transform: &Affine, knots: &mut usize) -> Option { + let mut subpaths = Vec::new(); + let mut current: Option = None; + let mut restarted = false; + let mut last = Point::new(0.0, 0.0); + for element in bez.elements() { + match *element { + PathEl::MoveTo(p) => { + if let Some(subpath) = current.take() + && (!restarted || subpath.knots.len() > 1) + { + subpaths.push(subpath); + } + restarted = false; + current = Some(Subpath { closed: false, knots: vec![Knot::corner(p.x, p.y)], op: PathOp::Combine }); + last = Point::new(p.x, p.y); + } + PathEl::LineTo(p) => { + if let Some(subpath) = current.as_mut() { + if let Some(knot) = subpath.knots.last_mut() { + knot.out_ctrl = knot.anchor; + } + subpath.knots.push(Knot::corner(p.x, p.y)); + } + last = Point::new(p.x, p.y); + } + PathEl::QuadTo(control, p) => { + if let Some(subpath) = current.as_mut() { + let start = subpath.knots.last().map_or(last, |knot| knot.anchor); + if let Some(knot) = subpath.knots.last_mut() { + knot.out_ctrl = Point::new(start.x + 2.0 / 3.0 * (control.x - start.x), start.y + 2.0 / 3.0 * (control.y - start.y)); + } + subpath.knots.push(Knot { + anchor: Point::new(p.x, p.y), + in_ctrl: Point::new(p.x + 2.0 / 3.0 * (control.x - p.x), p.y + 2.0 / 3.0 * (control.y - p.y)), + out_ctrl: Point::new(p.x, p.y), + smooth: false, + }); + } + last = Point::new(p.x, p.y); + } + PathEl::CurveTo(c1, c2, p) => { + if let Some(subpath) = current.as_mut() { + if let Some(knot) = subpath.knots.last_mut() { + knot.out_ctrl = Point::new(c1.x, c1.y); + } + subpath.knots.push(Knot { anchor: Point::new(p.x, p.y), in_ctrl: Point::new(c2.x, c2.y), out_ctrl: Point::new(p.x, p.y), smooth: false }); + } + last = Point::new(p.x, p.y); + } + PathEl::ClosePath => { + if let Some(mut subpath) = current.take() { + let restart = subpath.knots.first().map(|knot| knot.anchor); + subpath.closed = true; + if subpath.knots.len() > 1 { + let first = subpath.knots[0].anchor; + let last_anchor = subpath.knots.last().map_or(first, |knot| knot.anchor); + if (first.x - last_anchor.x).abs() < 1e-9 && (first.y - last_anchor.y).abs() < 1e-9 { + // A straight closing segment can merge into the first knot; a curve + // that returns to its start must keep both knots to preserve the loop. + let straight = subpath.knots.last().is_some_and(|knot| { + knot.in_ctrl.x == knot.anchor.x + && knot.in_ctrl.y == knot.anchor.y + && knot.out_ctrl.x == knot.anchor.x + && knot.out_ctrl.y == knot.anchor.y + }); + if straight && let Some(knot) = subpath.knots.pop() { + subpath.knots[0].in_ctrl = knot.in_ctrl; + } + } else { + subpath.knots[0].in_ctrl = first; + } + } + // A single-point closed subpath still paints a dot with a round cap. + if !restarted || subpath.knots.len() > 1 { + subpaths.push(subpath); + } + // A close starts a new subpath at the closed subpath's start point; a + // following line/curve continues from there (PDF 32000-1 §8.5.2.1). + if let Some(start) = restart { + current = Some(Subpath { closed: false, knots: vec![Knot::corner(start.x, start.y)], op: PathOp::Combine }); + last = start; + restarted = true; + } else { + restarted = false; + } + } + } + } + } + if let Some(subpath) = current + && (!restarted || subpath.knots.len() > 1) + { + subpaths.push(subpath); + } + *knots = knots.saturating_add(subpaths.iter().map(|subpath| subpath.knots.len()).sum::()); + if *knots > MAX_KNOTS { + return None; + } + for subpath in &mut subpaths { + for knot in &mut subpath.knots { + *knot = knot.transform(transform); + } + } + Some(Path::new(subpaths)) +} + +#[cfg(test)] +mod tests { + use lopdf::{Document as PdfDocument, Object, Stream, dictionary}; + + use super::*; + use crate::{ImportOptions, IoError, import_with_options_and_interrupt}; + + /// The embedded test font. + const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + + /// A one-page 200 x 100 pt PDF whose content stream is `content`. + fn pdf(content: &str) -> Vec { + pdf_with_resources(content, dictionary! {}) + } + + /// [`pdf`] with page resources (for example an ExtGState). + fn pdf_with_resources(content: &str, resources: lopdf::Dictionary) -> Vec { + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); + let content_id = pdf.add_object(Stream::new(dictionary! {}, content.as_bytes().to_vec())); + let page_id = pdf.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(200.0), Object::Real(100.0)]), + "Contents" => content_id, + "Resources" => resources, + }); + pdf.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => Object::Array(vec![Object::Reference(page_id)]), + "Count" => 1, + }), + ); + let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + pdf.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + pdf.save_to(&mut bytes).expect("writing the fixture"); + bytes + } + + /// A one-page 200 x 100 pt PDF whose content stream is `content` and whose page uses an + /// embedded TrueType font as `/F1`. + fn text_pdf(content: &str) -> Vec { + text_pdf_named(content, "Inter-Regular") + } + + /// [`text_pdf`] with the font named `name` (the same Inter bytes, so tests can exercise the + /// name-based style rules). + fn text_pdf_named(content: &str, name: &str) -> Vec { + text_pdf_bytes(content, name, INTER) + } + + /// [`text_pdf`] with the font named `name` and the given font bytes. + fn text_pdf_bytes(content: &str, name: &str, font_bytes: &[u8]) -> Vec { + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); + let font_file = pdf.add_object(Stream::new(dictionary! { "Length1" => font_bytes.len() as i64 }, font_bytes.to_vec())); + let descriptor = pdf.add_object(dictionary! { + "Type" => "FontDescriptor", + "FontName" => Object::Name(name.as_bytes().to_vec()), + "Flags" => 32, + "FontBBox" => Object::Array(vec![Object::Integer(-100), Object::Integer(-200), Object::Integer(1000), Object::Integer(900)]), + "ItalicAngle" => 0, + "Ascent" => 900, + "Descent" => -200, + "CapHeight" => 700, + "StemV" => 80, + "FontFile2" => font_file, + }); + let font = pdf.add_object(dictionary! { + "Type" => "Font", + "Subtype" => "TrueType", + "BaseFont" => Object::Name(name.as_bytes().to_vec()), + "FirstChar" => 32, + "LastChar" => 126, + "Widths" => Object::Array((32..=126u8).map(|c| { + let Ok(face) = ttf_parser::Face::parse(font_bytes, 0) else { return Object::Integer(600); }; + let width = face.glyph_index(char::from(c)).and_then(|id| face.glyph_hor_advance(id)).unwrap_or(0); + Object::Real(f32::from(width) * 1000.0 / f32::from(face.units_per_em())) + }).collect()), + "FontDescriptor" => descriptor, + "Encoding" => "WinAnsiEncoding", + }); + let content_id = pdf.add_object(Stream::new(dictionary! {}, content.as_bytes().to_vec())); + let page_id = pdf.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(200.0), Object::Real(100.0)]), + "Contents" => content_id, + "Resources" => dictionary! { "Font" => dictionary! { "F1" => font } }, + }); + pdf.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => Object::Array(vec![Object::Reference(page_id)]), + "Count" => 1, + }), + ); + let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + pdf.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + pdf.save_to(&mut bytes).expect("writing the text fixture"); + bytes + } + + /// The artboard group's children. + fn children(document: &photocraft_doc::Document) -> &[Layer] { + match &document.layers.first().expect("a page group").content { + LayerContent::Group(group) => &group.children, + _ => panic!("the page is not a group"), + } + } + + /// The first text layer of `document`. + fn text_layer(document: &photocraft_doc::Document) -> &TextLayer { + children(document) + .iter() + .find_map(|layer| match &layer.content { + LayerContent::Text(text) => Some(text), + _ => None, + }) + .expect("a text layer") + } + + /// A solid fill or stroke colour. + fn solid(fill: &Fill) -> Color { + match fill { + Fill::Solid(color) => *color, + _ => panic!("not a solid paint"), + } + } + + /// Path bounds in document pixels. + fn bounds(path: &Path) -> (f64, f64, f64, f64) { + let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for knot in path.subpaths.iter().flat_map(|subpath| &subpath.knots) { + b.0 = b.0.min(knot.anchor.x); + b.1 = b.1.min(knot.anchor.y); + b.2 = b.2.max(knot.anchor.x); + b.3 = b.3.max(knot.anchor.y); + } + b + } + + #[test] + fn painted_paths_become_shape_layers_with_their_colours_and_dashes() { + let bytes = pdf("1 0 0 rg 20 20 60 40 re f 0 0 1 RG 3 w [9 4] 0 d 100 30 m 180 30 l S"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("paths.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable import"); + let children = children(&imported.document); + assert_eq!(children.len(), 2, "one fill and one stroke layer"); + let LayerContent::Shape(fill) = &children[0].content else { panic!("the first child is not a shape") }; + assert_eq!(solid(fill.fill.as_ref().expect("a fill")), Color::rgb(1.0, 0.0, 0.0)); + assert!(fill.stroke.is_none()); + let (x0, y0, x1, y1) = bounds(&fill.path); + assert!((x0 - 20.0).abs() < 0.01 && (x1 - 80.0).abs() < 0.01, "x bounds {x0}..{x1}"); + assert!((y0 - 40.0).abs() < 0.01 && (y1 - 80.0).abs() < 0.01, "PDF y up becomes document y down: {y0}..{y1}"); + + let LayerContent::Shape(stroke) = &children[1].content else { panic!("the second child is not a shape") }; + assert!(stroke.fill.is_none()); + let stroke = stroke.stroke.as_ref().expect("a stroke"); + assert_eq!(solid(&stroke.paint), Color::rgb(0.0, 0.0, 1.0)); + assert!((stroke.width - 3.0).abs() < 0.01, "stroke width {}", stroke.width); + assert_eq!(stroke.dashes.len(), 2); + assert!((stroke.dashes[0] - 3.0).abs() < 0.01 && (stroke.dashes[1] - 4.0 / 3.0).abs() < 0.01, "dashes {:?}", stroke.dashes); + } + + #[test] + fn the_editable_composite_matches_the_raster_page() { + let bytes = pdf("1 0 0 rg 20 20 60 40 re f 0 0 1 RG 3 w [9 4] 0 d 100 30 m 180 30 l S"); + let raster = import_with_options_and_interrupt("paths.pdf", &bytes, &ImportOptions { pdf_dpi: 72.0, ..ImportOptions::default() }, &Interrupt::NONE) + .expect("a raster import"); + let editable = import_with_options_and_interrupt( + "paths.pdf", + &bytes, + &ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }, + &Interrupt::NONE, + ) + .expect("an editable import"); + let fmt = editable.document.pixel_format(); + let a = photocraft_compose::flatten_to_surface(&editable.document, fmt, None); + let b = photocraft_compose::flatten_to_surface(&raster.document, fmt, None); + let rect = editable.document.bounds(); + let av = a.read_region(rect); + let bv = b.read_region(rect); + assert_eq!(av.len(), bv.len()); + let mut sum = 0.0f64; + let mut max = 0.0f32; + for (x, y) in av.iter().zip(&bv) { + let d = (x - y).abs(); + sum += f64::from(d); + max = max.max(d); + } + let mean = sum / av.len() as f64; + assert!(mean < 0.01, "mean pixel difference {mean:.4} (max {max:.3})"); + } + + #[test] + fn text_falls_back_to_the_raster_page_with_a_warning() { + let bytes = pdf("BT /F1 24 Tf 10 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("a fallback import"); + assert!( + imported.warnings.iter().any(|warning| warning.contains("text without an embedded font") && warning.contains("rasterised")), + "{:?}", + imported.warnings + ); + assert!(matches!(children(&imported.document).first().map(|layer| &layer.content), Some(LayerContent::Raster(_)))); + } + + #[test] + fn embedded_text_becomes_a_text_layer_with_its_style() { + let bytes = text_pdf("BT /F1 24 Tf 1 0 0 rg 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable text import"); + let text = text_layer(&imported.document); + assert_eq!(text.text, "Hi"); + assert!(text.font_family.starts_with("Inter"), "family {}", text.font_family); + assert!((f64::from(text.size_pt) - 24.0).abs() < 0.1, "size {}", text.size_pt); + assert_eq!(text.color, Color::rgb(1.0, 0.0, 0.0)); + let origin = text.transform.apply(Point::new(0.0, 0.0)); + assert!((origin.x - 20.0).abs() < 0.5 && (origin.y - 50.0).abs() < 0.5, "origin {origin:?}"); + assert!(text.cache.is_some(), "the text cache is rendered on import"); + let style = &text.runs[0].style; + assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular")); + assert_eq!(style.weight, 400); + assert!(!style.italic); + assert!(imported.warnings.iter().any(|warning| warning.contains("fonts embedded in the PDF")), "{:?}", imported.warnings); + } + + #[test] + fn imported_text_relayouts_with_the_shared_engine() { + let bytes = text_pdf("BT /F1 24 Tf 1 0 0 rg 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable text import"); + let mut text = text_layer(&imported.document).clone(); + text.cache = None; + let shared = photocraft_text::shared(); + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); + assert!(engine.has_family(&text.font_family), "the app engine must know the imported family"); + let rect = engine.render_layer(&mut text, 72.0, PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); + assert!(rect.width() > 0 && rect.height() > 0, "the layer re-lays out"); + assert!(text.cache.is_some()); + } + + #[test] + fn rotated_text_stays_editable() { + let bytes = text_pdf("BT /F1 24 Tf 0 1 -1 0 100 20 Tm (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("rotated.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + assert!(!imported.warnings.iter().any(|warning| warning.contains("skewed or mirrored")), "{:?}", imported.warnings); + assert_eq!(text_layer(&imported.document).text, "Hi"); + } + + #[test] + fn font_aliases_include_the_content_key() { + assert_ne!(font_alias("Inter-Regular", 1), font_alias("Inter-Regular", 2)); + assert!(font_alias("Inter-Regular", 0x12ab).contains("Inter-Regular")); + } + + #[test] + fn two_documents_with_one_font_name_keep_distinct_aliases() { + // Rename the bundled Inter family to one that is not installed, so the embedded face + // takes the alias path; same PostScript name and family, different bytes (a trailing byte + // changes the content key), as two different subsets of one font would. + let mut base = INTER.to_vec(); + for at in 0..base.len().saturating_sub(5) { + if &base[at..at + 5] == b"Inter" { + base[at..at + 5].copy_from_slice(b"Xntr"); + } + } + let mut other = base.clone(); + other.push(0); + let a = text_pdf_bytes("BT /F1 24 Tf 20 50 Td (Hi) Tj ET", "Xntr-Regular", &base); + let b = text_pdf_bytes("BT /F1 24 Tf 20 50 Td (Hi) Tj ET", "Xntr-Regular", &other); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let first = import_with_options_and_interrupt("a.pdf", &a, &options, &Interrupt::NONE).expect("first import"); + let second = import_with_options_and_interrupt("b.pdf", &b, &options, &Interrupt::NONE).expect("second import"); + let fa = text_layer(&first.document).font_family.clone(); + let fb = text_layer(&second.document).font_family.clone(); + assert_ne!(fa, fb, "the two embedded faces must not share an alias"); + let shared = photocraft_text::shared(); + let mut engine = shared.lock().unwrap_or_else(|error| error.into_inner()); + assert!(engine.has_family(&fa) && engine.has_family(&fb)); + } + + #[test] + fn mirrored_text_falls_back() { + let bytes = text_pdf("BT /F1 24 Tf -1 0 0 1 100 50 Tm (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("mirror.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + assert!( + imported.warnings.iter().any(|warning| warning.contains("skewed or mirrored text") && warning.contains("rasterised")), + "{:?}", + imported.warnings + ); + } + + #[test] + fn font_names_set_bold_and_italic() { + for (name, weight, italic) in [("Inter-Bold", 700, false), ("Inter-Italic", 400, true), ("Inter-BoldItalic", 700, true)] { + let bytes = text_pdf_named("BT /F1 24 Tf 20 50 Td (Hi) Tj ET", name); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable text import"); + let style = &text_layer(&imported.document).runs[0].style; + assert_eq!(style.weight, weight, "{name}"); + assert_eq!(style.italic, italic, "{name}"); + assert!(style.font_family.starts_with("Inter"), "{name}: {}", style.font_family); + } + } + + #[test] + fn a_page_sized_clip_does_not_force_a_raster_page() { + let bytes = pdf("q 0 0 200 100 re W n 1 0 0 rg 20 20 60 40 re f Q"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("clip.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + assert!(!imported.warnings.iter().any(|warning| warning.contains("clipping")), "{:?}", imported.warnings); + assert!(children(&imported.document).iter().any(|layer| matches!(layer.content, LayerContent::Shape(_)))); + } + + #[test] + fn a_zero_width_stroke_is_a_one_pixel_hairline() { + let bytes = pdf("0 0 1 RG 0 w 20 30 m 180 30 l S"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("hairline.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + let LayerContent::Shape(shape) = &children(&imported.document)[0].content else { panic!("not a shape") }; + let stroke = shape.stroke.as_ref().expect("a stroke"); + assert!((stroke.width - 1.0).abs() < 0.01, "width {}", stroke.width); + } + + #[test] + fn a_non_uniform_stroke_transform_falls_back() { + let bytes = pdf("q 2 0 0 1 0 0 cm 0 0 1 RG 4 w 20 30 m 80 30 l S Q"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("skew.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + assert!( + imported.warnings.iter().any(|warning| warning.contains("non-uniform stroke transform") && warning.contains("rasterised")), + "{:?}", + imported.warnings + ); + assert!(matches!(children(&imported.document).first().map(|layer| &layer.content), Some(LayerContent::Raster(_)))); + } + + #[test] + fn a_uniform_stroke_transform_scales_the_width() { + let bytes = pdf("q 2 0 0 2 0 0 cm 0 0 1 RG 4 w 20 30 m 80 30 l S Q"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("scaled.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + let LayerContent::Shape(shape) = &children(&imported.document)[0].content else { panic!("not a shape") }; + let stroke = shape.stroke.as_ref().expect("a stroke"); + assert!((stroke.width - 8.0).abs() < 0.01, "width {}", stroke.width); + } + + #[test] + fn extgstate_fill_and_stroke_opacity_reach_the_layers() { + let resources = dictionary! { + "ExtGState" => dictionary! { + "GS0" => dictionary! { "ca" => 0.5, "CA" => 0.5 }, + }, + }; + let bytes = pdf_with_resources("/GS0 gs 0.2 0.4 0.6 rg 20 20 60 40 re f 0 0 1 RG 4 w 20 80 m 180 80 l S", resources); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("opacity.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + let layers = children(&imported.document); + let LayerContent::Shape(fill) = &layers[0].content else { panic!("not a shape") }; + let fill = solid(fill.fill.as_ref().expect("a fill")); + assert!((fill.alpha - 0.5).abs() < 0.01); + assert!((fill.c[0] - 0.2).abs() < 0.01 && (fill.c[1] - 0.4).abs() < 0.01 && (fill.c[2] - 0.6).abs() < 0.01, "{fill:?}"); + let LayerContent::Shape(stroke) = &layers[1].content else { panic!("not a shape") }; + assert!((solid(&stroke.stroke.as_ref().expect("a stroke").paint).alpha - 0.5).abs() < 0.01); + } + + #[test] + fn odd_and_zero_dash_arrays_are_normalised() { + let mut stroke = craft_pdf::interpret::StrokeProps { + line_width: 2.0, + line_cap: Cap::Butt, + line_join: Join::Miter, + miter_limit: 10.0, + dash_array: Default::default(), + dash_offset: 0.0, + }; + stroke.dash_array.push(3.0); + let out = to_stroke(&stroke, Color::BLACK, 1.0).expect("a stroke"); + assert_eq!(out.dashes, vec![1.5, 1.5], "an odd array repeats"); + stroke.dash_array.clear(); + stroke.dash_array.push(0.0); + stroke.dash_array.push(4.0); + let out = to_stroke(&stroke, Color::BLACK, 1.0).expect("a stroke"); + assert_eq!(out.dashes, vec![0.0, 2.0], "zero-length dashes stay"); + } + + #[test] + fn fill_then_stroke_text_is_fake_bold_not_a_second_run() { + let bytes = text_pdf("BT /F1 24 Tf 2 Tr 1 0 0 rg 1 0 0 RG 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text.pdf", &bytes, &options, &Interrupt::NONE).expect("an editable text import"); + let texts: Vec<_> = children(&imported.document) + .iter() + .filter_map(|layer| match &layer.content { + LayerContent::Text(text) => Some(text), + _ => None, + }) + .collect(); + assert_eq!(texts.len(), 1, "one run, not a duplicate stroke run"); + assert_eq!(texts[0].text, "Hi"); + assert_eq!(texts[0].runs[0].style.weight, 700); + assert_eq!(texts[0].color, Color::rgb(1.0, 0.0, 0.0)); + } + + #[test] + fn font_family_rules_follow_the_base_font() { + assert_eq!(family_from_postscript("ABCDEF+TimesNewRomanPSMT"), "Times New Roman"); + assert_eq!(family_from_postscript("Arial-BoldMT"), "Arial"); + assert_eq!(family_from_postscript("Helvetica-Oblique"), "Helvetica"); + assert_eq!(family_from_postscript("MS-Gothic"), "MS-Gothic"); + assert_eq!(family_from_postscript("SimSun"), "Sim Sun"); + assert_eq!(family_from_postscript("Inter-Regular"), "Inter"); + assert_eq!(family_from_postscript("Roboto-Black"), "Roboto"); + } + + #[test] + fn the_editable_text_composite_matches_the_raster_page() { + let bytes = text_pdf("BT /F1 24 Tf 1 0 0 rg 20 50 Td (Hi) Tj ET"); + let raster = import_with_options_and_interrupt("text.pdf", &bytes, &ImportOptions { pdf_dpi: 72.0, ..ImportOptions::default() }, &Interrupt::NONE) + .expect("a raster import"); + let editable = import_with_options_and_interrupt( + "text.pdf", + &bytes, + &ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }, + &Interrupt::NONE, + ) + .expect("an editable import"); + let fmt = editable.document.pixel_format(); + let a = photocraft_compose::flatten_to_surface(&editable.document, fmt, None); + let b = photocraft_compose::flatten_to_surface(&raster.document, fmt, None); + let rect = editable.document.bounds(); + let av = a.read_region(rect); + let bv = b.read_region(rect); + let mut sum = 0.0f64; + let mut max = 0.0f32; + for (x, y) in av.iter().zip(&bv) { + let d = (x - y).abs(); + sum += f64::from(d); + max = max.max(d); + } + let mean = sum / av.len() as f64; + assert!(mean < 0.02, "mean pixel difference {mean:.4} (max {max:.3})"); + // The cache must actually contain text: a blank layer can still pass a mean comparison. + let channels = fmt.channels(); + let width = rect.width() as usize; + let mut red = 0; + for y in 30..60usize { + for x in 20..45usize { + let at = (y * width + x) * channels; + if av[at] > 0.4 && av[at + 1] < 0.4 && av[at + 2] < 0.4 { + red += 1; + } + } + } + assert!(red > 0, "the editable text cache is blank"); + } + + #[test] + fn text_keeps_paint_order_on_both_sides_of_a_path() { + let bytes = text_pdf("BT /F1 24 Tf 20 50 Td (A) Tj ET 0 0 1 rg 0 0 200 100 re f BT /F1 24 Tf 20 20 Td (B) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("order.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + let layers = children(&imported.document); + assert_eq!(layers.len(), 3); + let text = |layer: &Layer| match &layer.content { + LayerContent::Text(text) => text.text.clone(), + _ => String::new(), + }; + assert_eq!(text(&layers[0]), "A"); + assert!(matches!(layers[1].content, LayerContent::Shape(_)), "the path stays between the runs"); + assert_eq!(text(&layers[2]), "B"); + } + + #[test] + fn fake_bold_does_not_pair_across_a_path() { + let bytes = text_pdf("BT /F1 24 Tf 20 50 Td (Hi) Tj ET 0 0 1 rg 0 0 200 100 re f BT /F1 24 Tf 1 Tr 1 w 0 0 1 RG 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("pair.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + assert!(imported.warnings.iter().any(|warning| warning.contains("stroked text") && warning.contains("rasterised")), "{:?}", imported.warnings); + } + + #[test] + fn a_curve_returning_to_its_start_keeps_both_knots() { + let mut bez = BezPath::new(); + bez.move_to((0.0, 0.0)); + bez.curve_to((10.0, 0.0), (10.0, 10.0), (0.0, 0.0)); + bez.close_path(); + let mut knots = 0; + let path = to_path(&bez, &Affine::IDENTITY, &mut knots).expect("a path"); + assert_eq!(path.subpaths.len(), 1); + assert!(path.subpaths[0].knots.len() >= 2); + } + + #[test] + fn an_explicitly_closed_rectangle_is_a_no_op_clip() { + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((200.0, 0.0)); + path.line_to((200.0, 100.0)); + path.line_to((0.0, 100.0)); + path.line_to((0.0, 0.0)); + path.close_path(); + assert_eq!(axis_aligned_rect(&path), Some((0.0, 0.0, 200.0, 100.0))); + } + + #[test] + fn a_dashed_zero_length_line_falls_back() { + let bytes = pdf("[1 1] 0 d 10 10 m 20 20 l 30 30 m h S"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("dash.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + assert!( + imported.warnings.iter().any(|warning| warning.contains("dashed zero-length subpath") && warning.contains("rasterised")), + "{:?}", + imported.warnings + ); + } + + #[test] + fn a_solid_zero_length_line_stays_editable() { + let bytes = pdf("10 10 m 20 20 l 30 30 m h S"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("solid.pdf", &bytes, &options, &Interrupt::NONE).expect("import"); + let layers = children(&imported.document); + assert!(layers.iter().any(|layer| matches!(layer.content, LayerContent::Shape(_))), "{:?}", imported.warnings); + } + + #[test] + fn a_closed_single_point_subpath_is_retained() { + let mut bez = BezPath::new(); + bez.move_to((10.0, 10.0)); + bez.close_path(); + let mut knots = 0; + let path = to_path(&bez, &Affine::IDENTITY, &mut knots).expect("a path"); + assert_eq!(path.subpaths.len(), 1); + assert_eq!(path.subpaths[0].knots.len(), 1); + assert!(path.subpaths[0].closed); + } + + #[test] + fn a_bow_tie_path_is_not_a_no_op_clip() { + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((10.0, 10.0)); + path.line_to((10.0, 0.0)); + path.line_to((0.0, 10.0)); + path.close_path(); + assert!(axis_aligned_rect(&path).is_none()); + } + + #[test] + fn a_line_after_close_starts_a_new_subpath() { + let mut bez = BezPath::new(); + bez.move_to((0.0, 0.0)); + bez.line_to((10.0, 0.0)); + bez.line_to((10.0, 10.0)); + bez.close_path(); + bez.line_to((20.0, 0.0)); + let mut knots = 0; + let path = to_path(&bez, &Affine::IDENTITY, &mut knots).expect("a path"); + assert_eq!(path.subpaths.len(), 2); + assert_eq!(path.subpaths[1].knots[0].anchor, Point::new(0.0, 0.0)); + assert_eq!(path.subpaths[1].knots.last().expect("a knot").anchor, Point::new(20.0, 0.0)); + } + + #[test] + fn cancelling_between_objects_stops_the_editable_page() { + let mut content = String::from("1 0 0 rg\n"); + for i in 0..20 { + content.push_str(&format!("{} 20 20 20 re f\n", i * 8)); + } + let bytes = pdf(&content); + let calls = std::sync::atomic::AtomicUsize::new(0); + let cancel = || calls.fetch_add(1, Ordering::SeqCst) >= 3; + let ctl = Interrupt::cancel_only(&cancel); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + assert!(matches!(import_with_options_and_interrupt("objects.pdf", &bytes, &options, &ctl), Err(IoError::Cancelled))); + } + + #[test] + fn a_cancelled_editable_import_returns_cancelled() { + let bytes = pdf("1 0 0 rg 20 20 60 40 re f"); + let cancel = || true; + let ctl = Interrupt::cancel_only(&cancel); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + assert!(matches!(import_with_options_and_interrupt("paths.pdf", &bytes, &options, &ctl), Err(IoError::Cancelled))); + } + #[test] + fn positioned_pdf_text_is_not_silently_reflowed() { + let bytes = text_pdf("BT /F1 24 Tf 20 50 Td [(H) -900 (i)] TJ ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("spacing.pdf", &bytes, &options, &Interrupt::NONE).unwrap(); + assert!(children(&imported.document).iter().all(|l| matches!(l.content, LayerContent::Raster(_))), "unrepresentable spacing must rasterise"); + assert!(imported.warnings.iter().any(|w| w.contains("rasterised"))); + } + + #[test] + fn colored_text_outlines_are_not_fake_bold() { + let bytes = text_pdf("BT /F1 24 Tf 1 0 0 rg 0 0 1 RG 1 w 2 Tr 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("outline.pdf", &bytes, &options, &Interrupt::NONE).unwrap(); + assert!(children(&imported.document).iter().all(|l| matches!(l.content, LayerContent::Raster(_))), "a blue outline cannot become red bold text"); + } + + #[test] + fn a_shape_cache_keeps_the_whole_miter() { + let bytes = pdf("10 w 10 M 40 20 m 50 70 l 60 20 l S"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("miter.pdf", &bytes, &options, &Interrupt::NONE).unwrap(); + let layer = children(&imported.document).first().unwrap(); + let LayerContent::Shape(shape) = &layer.content else { panic!("expected an editable stroke") }; + let board = Rect::from_xywh(0, 0, 200, 100); + let expected = photocraft_vector::render_shape(shape, imported.document.pixel_format(), board); + assert!(shape.cache.as_ref().unwrap().read_region(board) == expected.read_region(board), "bounds must include the pointed join"); + } + + #[test] + fn cache_budget_charges_full_tiles_and_preserves_the_limit() { + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + let tile_bytes = TILE_SIZE as u64 * TILE_SIZE as u64 * fmt.bytes_per_pixel() as u64; + let mut used = MAX_CACHE_BYTES - tile_bytes; + admit_cache(&mut used, Rect::from_xywh(0, 0, 1, 1), fmt).unwrap(); + assert_eq!(used, MAX_CACHE_BYTES); + assert_eq!(admit_cache(&mut used, Rect::from_xywh(0, 0, 1, 1), fmt), Err(EditableError::Unsupported("page cache budget"))); + assert_eq!(used, MAX_CACHE_BYTES); + let mut used = 0; + admit_cache(&mut used, Rect::from_xywh(-1, -1, 2, 2), fmt).unwrap(); + assert_eq!(used, 4 * tile_bytes, "a tiny rectangle may straddle four tiles"); + } + + #[test] + fn text_cache_respects_the_budget_already_used_by_shapes() { + let bytes = text_pdf("BT /F1 24 Tf 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text-budget.pdf", &bytes, &options, &Interrupt::NONE).unwrap(); + let mut text = text_layer(&imported.document).clone(); + text.cache = None; + let mut layers = vec![Layer::new("Text", LayerContent::Text(text))]; + let mut cached = MAX_CACHE_BYTES; + assert_eq!( + render_text_caches(&mut layers, 72.0, imported.document.pixel_format(), &Interrupt::NONE, &mut cached), + Err(EditableError::Unsupported("page cache budget")) + ); + let LayerContent::Text(text) = &layers[0].content else { panic!("text layer") }; + assert!(text.cache.is_none(), "reject before allocating the text cache"); + } + + #[test] + fn multiple_text_caches_share_one_allowance() { + let bytes = text_pdf("BT /F1 24 Tf 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("text-budget.pdf", &bytes, &options, &Interrupt::NONE).unwrap(); + let mut text = text_layer(&imported.document).clone(); + text.cache = None; + let fmt = imported.document.pixel_format(); + let layout = photocraft_text::shared().lock().unwrap().layout(&text, 72.0); + let area = photocraft_text::render::ink_rect(&layout, &text.transform); + let mut one_cache = 0; + admit_cache(&mut one_cache, area, fmt).unwrap(); + assert!(one_cache > 0); + let mut layers = vec![Layer::new("First", LayerContent::Text(text.clone())), Layer::new("Second", LayerContent::Text(text))]; + let mut cached = MAX_CACHE_BYTES - one_cache; + assert_eq!(render_text_caches(&mut layers, 72.0, fmt, &Interrupt::NONE, &mut cached), Err(EditableError::Unsupported("page cache budget"))); + assert_eq!(cached, MAX_CACHE_BYTES); + assert!(matches!(&layers[0].content, LayerContent::Text(t) if t.cache.is_some())); + assert!(matches!(&layers[1].content, LayerContent::Text(t) if t.cache.is_none())); + let mut cached = 0; + render_text_caches(&mut layers, 72.0, fmt, &Interrupt::NONE, &mut cached).unwrap(); + assert_eq!(cached, one_cache * 2); + assert!(layers.iter().all(|l| matches!(&l.content, LayerContent::Text(t) if t.cache.is_some()))); + } + #[test] + fn thick_text_outlines_are_not_fake_bold() { + let bytes = text_pdf("BT /F1 24 Tf 1 0 0 rg 1 0 0 RG 5 w 2 Tr 20 50 Td (Hi) Tj ET"); + let options = ImportOptions { pdf_dpi: 72.0, pdf_editable: true, ..ImportOptions::default() }; + let imported = import_with_options_and_interrupt("thick-outline.pdf", &bytes, &options, &Interrupt::NONE).unwrap(); + assert!(children(&imported.document).iter().all(|l| matches!(l.content, LayerContent::Raster(_)))); + } +} diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs new file mode 100644 index 00000000000..b55da30bec2 --- /dev/null +++ b/crates/io/src/pdf_import.rs @@ -0,0 +1,403 @@ +//! PDF import: craft-pdf (a Rust PDF reader and renderer built on Hayro) rasterizes the +//! selected pages, and each page becomes one artboard. +//! +//! Raster pages only: editable text and vector import would need craft-pdf's `interpret` +//! device and is not part of this importer. The renderer's hard output bounds (8192 px per +//! side and 64M px per page) and a document-wide strip budget are respected by lowering the +//! effective DPI, so each page keeps its physical size: pixels / resolution * 72 pt. +//! +//! The renderer is pure Rust and runs on every desktop platform: no shared library is loaded +//! or shipped, and a malformed file is reported as an error rather than taking the process down. +//! +//! Pages are drawn with the interpreter's default annotation settings, so annotations and +//! filled-in form fields are rendered; comments are not hidden. + +use crate::{ImportOptions, ImportResult, IoError}; +#[cfg(not(target_arch = "wasm32"))] +use craft_pdf::interpret::InterpreterSettings; +#[cfg(not(target_arch = "wasm32"))] +use craft_pdf::raster::{self, RenderCache}; +#[cfg(not(target_arch = "wasm32"))] +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +#[cfg(not(target_arch = "wasm32"))] +use photocraft_doc::{Artboard, Document, Group, Layer, LayerContent}; +#[cfg(not(target_arch = "wasm32"))] +use photocraft_geom::{Rect, Size}; +use photocraft_raster::Interrupt; +#[cfg(not(target_arch = "wasm32"))] +use photocraft_raster::Surface; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; + +/// `%PDF-` magic. `.ai` files that start with it are PDFs and render through this importer. +pub fn is_pdf(bytes: &[u8]) -> bool { + bytes.starts_with(b"%PDF-") +} + +/// PDF import is not available in the web build. +#[cfg(target_arch = "wasm32")] +pub fn import(_name: &str, _bytes: &[u8], _opts: &ImportOptions, _ctl: &Interrupt) -> Result { + Err(IoError::Unsupported("PDF import needs the desktop build".into())) +} + +/// Renders the selected PDF pages and opens each as one artboard. +#[cfg(not(target_arch = "wasm32"))] +pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) -> Result { + /// The most pages one import renders. + const MAX_PAGES: usize = 16; + /// Gap between the artboards, in document pixels. + const GAP: u32 = 80; + /// The document-wide raster budget across every imported page, in pixels. + const MAX_UNION_PIXELS: u64 = 200_000_000; + /// The default pixel-memory budget when the caller sets none: a zero + /// [`ImportOptions::flat_max_pixel_bytes`] still bounds the import. + const DEFAULT_MAX_PIXEL_BYTES: u64 = 2 << 30; + + ctl.check().map_err(|_| IoError::Cancelled)?; + let requested = if opts.pdf_dpi.is_finite() && opts.pdf_dpi > 0.0 { f64::from(opts.pdf_dpi) } else { 300.0 }; + let pdf = craft_pdf::open_slice_with_options(bytes, None, &craft_pdf::OpenOptions::default()).map_err(open_error)?; + let total = pdf.pages().len(); + if total == 0 { + return Err(IoError::Unsupported("PDF has no pages".into())); + } + let (first, last) = match opts.pdf_pages { + Some((a, b)) => { + let a = a.max(1); + let b = if b == 0 { u32::MAX } else { b.max(a) }; + if u64::from(a) > total as u64 { + return Err(IoError::Unsupported(format!("PDF page range starts at {a}, but the PDF has {total} pages"))); + } + ((a as usize).min(total), (b as usize).min(total)) + } + None => (1, total), + }; + let page_count = last - first + 1; + let count = page_count.min(MAX_PAGES); + + // Page sizes in points first: the effective DPI must fit the renderer's bounds without + // changing any page's physical size. + let mut sizes = Vec::with_capacity(count); + for index in 0..count { + let page = &pdf.pages()[first - 1 + index]; + let (width, height) = page.render_dimensions(); + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return Err(IoError::Unsupported(format!("PDF page {} has no area", first + index))); + } + sizes.push((f64::from(width), f64::from(height))); + ctl.check().map_err(|_| IoError::Cancelled)?; + } + let dpi = effective_dpi(requested, &sizes, u64::from(raster::MAX_SIDE), raster::MAX_PIXELS, MAX_UNION_PIXELS); + + let mut doc_width: u64 = 0; + let mut doc_height: u64 = 0; + let mut retained: u64 = 0; + for (index, &(width_pt, height_pt)) in sizes.iter().enumerate() { + let width = page_pixels(width_pt, dpi); + let height = page_pixels(height_pt, dpi); + doc_width = doc_width.saturating_add(u64::from(width)).saturating_add(if index + 1 < count { u64::from(GAP) } else { 0 }); + doc_height = doc_height.max(u64::from(height)); + let tiles_x = (u64::from(width)).div_ceil(256); + let tiles_y = (u64::from(height)).div_ceil(256); + retained = retained.saturating_add(tiles_x.saturating_mul(tiles_y).saturating_mul(256 * 256 * 4)); + } + if doc_width == 0 || doc_height == 0 { + return Err(IoError::Unsupported("PDF pages have no area".into())); + } + if doc_width > u64::from(u32::MAX) || doc_height > u64::from(u32::MAX) { + return Err(IoError::Unsupported("PDF import is too large at this DPI; lower the import DPI".into())); + } + // Retained tiles plus the converting page's raster (4 B/px); the f32 conversion runs in + // 256-row bands, so it adds only a constant-sized buffer rather than a second full page. + let max_page = sizes.iter().map(|&(w, h)| u64::from(page_pixels(w, dpi)) * u64::from(page_pixels(h, dpi))).max().unwrap_or(0); + let estimate = retained.saturating_add(max_page.saturating_mul(4)); + let budget = if opts.flat_max_pixel_bytes > 0 { opts.flat_max_pixel_bytes } else { DEFAULT_MAX_PIXEL_BYTES }; + if estimate > budget { + return Err(IoError::Unsupported(format!( + "PDF import needs about {} MB of pixel memory; the limit is {} MB (lower the import DPI)", + estimate >> 20, + budget >> 20 + ))); + } + + let mut warnings = Vec::new(); + if first > 1 || last < total || count < page_count { + let shown_last = first + count - 1; + let reason = if count < page_count { " (the page limit is 16)" } else { "" }; + warnings.push(format!("PDF has {total} pages; imported pages {first}-{shown_last}{reason}")); + } + if dpi + 0.5 < requested { + warnings.push(format!("PDF was rendered at {:.0} dpi to fit the import limits", dpi.max(1.0))); + } + + let mut doc = Document::new(name, Size::new(doc_width as u32, doc_height as u32), ColorMode::Rgb, SampleType::U8); + doc.resolution_dpi = dpi as f32; + #[cfg(feature = "pdf-editable")] + let mut editable_text = false; + let mut x: u32 = 0; + for (index, &(width_pt, height_pt)) in sizes.iter().enumerate() { + ctl.check().map_err(|_| IoError::Cancelled)?; + let page_number = first + index; + let page = &pdf.pages()[page_number - 1]; + let width = page_pixels(width_pt, dpi); + let height = page_pixels(height_pt, dpi); + // The DPI solve keeps these inside the renderer's bounds; re-check rather than panic if + // rounding on an edge moves one pixel past them. + if width > raster::MAX_SIDE || height > raster::MAX_SIDE || u64::from(width) * u64::from(height) > raster::MAX_PIXELS { + return Err(IoError::Unsupported(format!("PDF page {page_number} is too large at {dpi:.0} dpi; lower the import DPI"))); + } + let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); + #[cfg(feature = "pdf-editable")] + if opts.pdf_editable { + match crate::pdf_editable::page(page, rect, ctl) { + Ok(layers) => { + if layers.iter().any(|layer| matches!(&layer.content, LayerContent::Text(_))) { + editable_text = true; + } + let group = Group { children: layers, expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; + doc.layers.push(Layer::new(format!("Page {page_number}"), LayerContent::Group(group))); + x = x.saturating_add(width).saturating_add(GAP); + ctl.progress((index + 1) as f32 / count as f32); + continue; + } + Err(crate::pdf_editable::EditableError::Cancelled) => return Err(IoError::Cancelled), + Err(crate::pdf_editable::EditableError::Unsupported(reason)) => { + warnings.push(format!("PDF page {page_number}: {reason}; the page was rasterised")); + } + } + } + #[cfg(not(feature = "pdf-editable"))] + if opts.pdf_editable { + warnings.push(format!("PDF page {page_number}: editable PDF import is not built into this binary; the page was rasterised")); + } + // A cache per page, deliberately: a shared one would keep decoded images and fonts alive + // across up to 16 pages outside the pixel budget, and would hide an image-decode warning + // on a later page after the page that first hit it. + let cache = RenderCache::new(); + let options = raster::Options { + scale: [(dpi / 72.0) as f32; 2], + size: Some([width, height]), + offset: [0.0, 0.0], + // PDF pages composite onto white, as every PDF viewer and the previous importer did. + // craft-pdf returns premultiplied RGBA8; an opaque background makes alpha 1, so the + // bytes below can be written as straight alpha. A transparent background would need + // un-premultiplying (dividing each colour by alpha) first. + background: [255, 255, 255, 255], + color_replacement: None, + }; + let rendered = render_page(page, &cache, &options, ctl).map_err(|error| render_error(page_number, dpi, error))?; + if rendered.diagnostics.image_decode_failed { + warnings.push(format!("PDF page {page_number}: an image could not be decoded and is missing")); + } + // craft-pdf's hayro backend does not emit this flag yet (its README says so); keep the + // warning ready so a backend that starts reporting font substitution needs no change here. + if rendered.diagnostics.unsupported_font { + warnings.push(format!("PDF page {page_number}: a font could not be used and was substituted, so text may look different")); + } + if rendered.diagnostics.content_truncated { + warnings.push(format!("PDF page {page_number}: some content was omitted (a decode or memory limit)")); + } + ctl.check().map_err(|_| IoError::Cancelled)?; + + let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); + let bytes = rendered.bytes(); + // Convert and write in row bands: the f32 buffer stays bounded instead of a second + // 16-bytes-per-pixel copy of the whole page, and cancellation is polled per band. + const BAND_ROWS: usize = 256; + let row_bytes = width as usize * 4; + let band_bytes = row_bytes.saturating_mul(BAND_ROWS); + let mut data: Vec = Vec::new(); + if data.try_reserve_exact(band_bytes).is_err() { + return Err(IoError::Unsupported(format!("PDF page {page_number} needs more memory than is available"))); + } + for (band, rows) in bytes.chunks(band_bytes).enumerate() { + ctl.check().map_err(|_| IoError::Cancelled)?; + data.clear(); + data.extend(rows.iter().map(|&value| f32::from(value) / 255.0)); + let top = (band * BAND_ROWS) as i32; + let band_height = (rows.len() / row_bytes) as u32; + surface.write_region(Rect::from_xywh(x as i32, top, width, band_height), &data); + } + let layer = Layer::new(format!("Page {page_number}"), LayerContent::Raster(surface)); + let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; + doc.layers.push(Layer::new(format!("Page {page_number}"), LayerContent::Group(group))); + x = x.saturating_add(width).saturating_add(GAP); + ctl.progress((index + 1) as f32 / count as f32); + } + #[cfg(feature = "pdf-editable")] + if editable_text { + warnings.push("PDF text uses fonts embedded in the PDF; it may change font when edited or reopened".into()); + } + + Ok(ImportResult { warnings, document: doc, source_read_only: false, preview_only: false }) +} + +/// Renders one page, forwarding `ctl`'s cancellation to craft-pdf while it interprets content. +/// +/// craft-pdf checks `InterpreterSettings::cancelled` between content operators, so a dense +/// vector page (CAD, maps) can be stopped part-way through. `Interrupt` is a borrowed callback +/// while the setting needs an owned flag, so a scoped watcher thread copies one into the other +/// every few milliseconds until the render returns. +#[cfg(not(target_arch = "wasm32"))] +fn render_page<'a>( + page: &'a craft_pdf::syntax::page::Page<'a>, + cache: &RenderCache<'a>, + options: &raster::Options, + ctl: &Interrupt, +) -> Result { + if ctl.cancelled() { + return Err(raster::Error::Cancelled); + } + let cancelled = Arc::new(AtomicBool::new(false)); + let finished = Arc::new(AtomicBool::new(false)); + let settings = InterpreterSettings { cancelled: Some(Arc::clone(&cancelled)), ..InterpreterSettings::default() }; + std::thread::scope(|scope| { + let flag = Arc::clone(&cancelled); + let done = Arc::clone(&finished); + let ctl = *ctl; + scope.spawn(move || { + while !done.load(Ordering::Relaxed) { + if ctl.cancelled() { + flag.store(true, Ordering::Relaxed); + } + std::thread::sleep(Duration::from_millis(5)); + } + }); + let rendered = raster::render(page, cache, &settings, options); + finished.store(true, Ordering::Relaxed); + rendered + }) +} + +/// One page's raster width or height in pixels at `dpi`, at least one. +#[cfg(not(target_arch = "wasm32"))] +fn page_pixels(points: f64, dpi: f64) -> u32 { + (points * dpi / 72.0).round().max(1.0) as u32 +} + +/// The highest DPI not above `requested` that keeps every page inside the renderer's side and +/// pixel bounds and the whole strip inside `max_union_pixels`. Rendering at that DPI keeps each +/// page's physical size unchanged; unreachable bounds report 1 DPI and the render call then fails +/// with a clear message. +#[cfg(not(target_arch = "wasm32"))] +fn effective_dpi(requested: f64, sizes: &[(f64, f64)], max_side: u64, max_page_pixels: u64, max_union_pixels: u64) -> f64 { + let mut dpi = requested.clamp(1.0, 2400.0); + loop { + let mut union = 0u64; + let mut fits = true; + for &(width_pt, height_pt) in sizes { + let width = u64::from(page_pixels(width_pt, dpi)); + let height = u64::from(page_pixels(height_pt, dpi)); + if width > max_side || height > max_side || width.saturating_mul(height) > max_page_pixels { + fits = false; + break; + } + union = union.saturating_add(width.saturating_mul(height)); + } + if fits && union <= max_union_pixels { + return dpi; + } + if dpi <= 1.0 { + return 1.0; + } + // The strip's gaps contribute a constant width, so a geometric step converges without + // needing a closed form per page. + dpi = (dpi * 0.9).max(1.0); + } +} + +/// Maps a craft-pdf open failure to the importer's error. +#[cfg(not(target_arch = "wasm32"))] +fn open_error(error: craft_pdf::OpenError) -> IoError { + use craft_pdf::OpenError; + match error { + OpenError::InputLimit { limit } => IoError::Unsupported(format!("PDF is larger than the {limit}-byte input limit")), + OpenError::Allocation => IoError::Unsupported("PDF needs more memory than is available".into()), + OpenError::BackendPanic => IoError::Unsupported("PDF reader failed on this file; it may be damaged".into()), + OpenError::Load(error) => IoError::Unsupported(format!("PDF: {error:?}")), + } +} + +/// Maps a craft-pdf render failure to the importer's error. +#[cfg(not(target_arch = "wasm32"))] +fn render_error(page: usize, dpi: f64, error: raster::Error) -> IoError { + use raster::Error; + match error { + Error::Cancelled => IoError::Cancelled, + Error::OutputLimit => IoError::Unsupported(format!("PDF page {page} is too large at {dpi:.0} dpi; lower the import DPI")), + Error::InvalidPage => IoError::Unsupported(format!("PDF page {page} has an empty or invalid page box")), + Error::Allocation => IoError::Unsupported(format!("PDF page {page} needs more memory than is available")), + Error::BackendPanic => IoError::Unsupported(format!("PDF renderer failed on page {page}; it may be damaged")), + Error::InvalidOptions | Error::BufferLength => IoError::Unsupported(format!("PDF page {page} could not be rendered")), + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use std::sync::atomic::AtomicUsize; + + use lopdf::{Document as PdfDocument, Object, Stream, dictionary}; + + use super::*; + + /// A one-page PDF whose content stream is `operators` short strokes: rendering costs time + /// per operator, not per pixel. + fn dense_operator_pdf(operators: usize) -> Result, lopdf::Error> { + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); + let mut content = String::with_capacity(operators.saturating_mul(24)); + content.push_str("0 0 1 RG 0.5 w\n"); + for i in 0..operators { + let x = (i % 200) as f32 * 0.5; + let y = ((i / 200) % 100) as f32 * 0.5; + content.push_str(&format!("{x:.1} {y:.1} m {:.1} {y:.1} l S\n", x + 0.4)); + } + let content_id = pdf.add_object(Stream::new(dictionary! {}, content.into_bytes())); + let page_id = pdf.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(200.0), Object::Real(100.0)]), + "Contents" => content_id, + "Resources" => dictionary! {}, + }); + pdf.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => Object::Array(vec![Object::Reference(page_id)]), + "Count" => 1, + }), + ); + let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + pdf.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + pdf.save_to(&mut bytes)?; + Ok(bytes) + } + + /// Hundreds of thousands of path operators take far longer than the watcher's polling + /// interval, so the interrupt lands mid-render and the page reports Cancelled. A million + /// strokes leaves a wide margin even on a fast, optimised test build. + #[test] + fn a_dense_page_stops_when_the_interrupt_fires_mid_render() { + let pdf = dense_operator_pdf(1_000_000).expect("writing the dense PDF"); + let document = craft_pdf::open_slice_with_options(&pdf, None, &craft_pdf::OpenOptions::default()).expect("the dense PDF opens"); + let cache = RenderCache::new(); + let options = + raster::Options { scale: [1.0, 1.0], size: Some([200, 100]), offset: [0.0, 0.0], background: [255, 255, 255, 255], color_replacement: None }; + let polls = AtomicUsize::new(0); + // `render_page` checks once before spawning; the watcher then polls every 5 ms. Ten + // polls put the cancellation about 45 ms into a render that takes much longer. + let cancel = || polls.fetch_add(1, Ordering::SeqCst) >= 9; + let ctl = Interrupt::cancel_only(&cancel); + let error = match render_page(&document.pages()[0], &cache, &options, &ctl) { + Err(error) => error, + Ok(_) => panic!("the dense page rendered to completion before the interrupt fired"), + }; + assert!(matches!(render_error(1, 72.0, error), IoError::Cancelled), "the render error maps to Cancelled"); + assert!(polls.load(Ordering::SeqCst) >= 10, "the watcher must keep polling the interrupt"); + } +} diff --git a/crates/io/src/psd_export/document.rs b/crates/io/src/psd_export/document.rs index d2728f9f24a..69231c7aee3 100644 --- a/crates/io/src/psd_export/document.rs +++ b/crates/io/src/psd_export/document.rs @@ -235,6 +235,7 @@ fn export_supported_document( if let Some(data) = crate::annotations_map::fresh_scale_resource(doc) { resources.push(ImageResource::new(crate::annotations_map::MEASUREMENT_SCALE, data)); } + resources.extend(crate::annotations_map::fresh_count_resource(doc, &mut ex.warnings)); if let Some(Some(data)) = comps_resource { resources.push(ImageResource::new(crate::comps_map::LAYER_COMPS, data)); } diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 20e366458e1..5e582ffd813 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -710,7 +710,7 @@ impl Ctx<'_> { crate::linked::embedded_payload_in_blocks(global.iter().map(|(_, key, data)| (key, data.as_slice())), &id).map(|(_, source)| source) }) }); - LayerContent::Smart(SmartObject { + let mut sm = SmartObject { contents_id, source: SmartSource::Linked { path: id }, vector_preview, @@ -723,7 +723,9 @@ impl Ctx<'_> { warp, stack_mode: None, perspective, - }) + }; + crate::smart_map::pin_puppet_meshes(&mut sm); + LayerContent::Smart(sm) } /// Typed vector mask from `vsms`/`vmsk` (non-shape layers). A user mask that Photoshop @@ -1238,6 +1240,7 @@ fn read_initial_metadata( if let Some(scale) = crate::annotations_map::raw_scale(doc) { doc.measurement.scale = scale; } + crate::annotations_map::import_count_resource(doc); let _ = layered; (damaged, alloc_error) } diff --git a/crates/io/src/puppet_map.rs b/crates/io/src/puppet_map.rs new file mode 100644 index 00000000000..f1015af30b5 --- /dev/null +++ b/crates/io/src/puppet_map.rs @@ -0,0 +1,308 @@ +//! Puppet Warp smart filters (`Fltr` class `rigidTransform`) as observed in Photoshop-authored +//! mockup PSDs. +//! +//! The filter holds a `puppetShapeList` with one `puppetShape` per mesh. Each shape stores its +//! pins (`PinP` where they were placed, `posFinalPins` where they are now, x, y pairs in document +//! pixels) and the mesh Photoshop solved for them, as raw data: +//! +//! ```text +//! originalVertexArray, deformedVertexArray: f32 LE x, y per vertex (document pixels) +//! indexArray: i32 LE, three per triangle +//! ``` +//! +//! and the corners of the layer the mesh was built on (`PuX0..3`, `PuY0..3` on the filter). +//! Replace Contents keeps the mesh: Photoshop redraws the new contents through the stored +//! triangles (measured on a dot grid: 0.1 px mean). So the mesh is imported as the +//! `layer.smartObjects.puppetWarp` filter's `mesh`, which is drawn as it is while the pins are +//! unchanged, and the whole item is kept as a template ([`TEMPLATE_KEY`]) to write back. Pins +//! alone feed PhotoCraft's own solver once they are edited; the mode, density and rotation +//! settings aren't mapped (their stored values aren't identified), so those stay at defaults. + +use photocraft_doc::SmartFilter; +use photocraft_psd::descriptor::{Descriptor, Value}; +use serde_json::{Value as J, json}; + +use super::{blend_options, from_hex, get_desc, set, to_hex}; +use crate::blocks::num; + +pub(super) const CLASS: &str = "rigidTransform"; +pub(super) const COMMAND: &str = "layer.smartObjects.puppetWarp"; +pub(super) const TEMPLATE_KEY: &str = "__puppetPsd"; +/// Far beyond the few hundred vertices Photoshop's meshes have, yet small enough that the +/// copies a hostile mesh makes (arrays, JSON params, cloned filters) stay bounded. +const MAX_VERTS: usize = 1 << 16; + +fn raw<'a>(d: &'a Descriptor, key: &str) -> Option<&'a [u8]> { + match d.get(key)? { + Value::RawData(b) => Some(b), + _ => None, + } +} + +fn floats(d: &Descriptor, key: &str) -> Option> { + let Value::List(v) = d.get(key)? else { return None }; + v.iter().map(|x| num(Some(x)).filter(|f| f.is_finite())).collect() +} + +/// `f32` LE x, y pairs; `None` unless whole, finite and at most [`MAX_VERTS`]. +fn vertices(b: &[u8]) -> Option> { + if !b.len().is_multiple_of(8) || b.len() / 8 > MAX_VERTS { + return None; + } + let f = |c: &[u8]| c.try_into().ok().map(f32::from_le_bytes).filter(|v| v.is_finite()).map(f64::from); + b.as_chunks::<8>().0.iter().map(|c| Some([f(&c[..4])?, f(&c[4..])?])).collect() +} + +/// `i32` LE triples, each index below `n`. +fn triangles(b: &[u8], n: usize) -> Option> { + if !b.len().is_multiple_of(12) || b.len() / 12 > 2 * MAX_VERTS { + return None; + } + let i = |c: &[u8]| c.try_into().ok().map(i32::from_le_bytes).and_then(|v| usize::try_from(v).ok()).filter(|&v| v < n); + b.as_chunks::<12>().0.iter().map(|c| Some([i(&c[..4])?, i(&c[4..8])?, i(&c[8..])?])).collect() +} + +/// One shape's pins (as params), vertices, deformed vertices and triangles. +type Shape = (Vec, Vec<[f64; 2]>, Vec<[f64; 2]>, Vec<[usize; 3]>); + +/// Pins and mesh of one `puppetShape`, with vertex indices offset by `base`. +fn shape(s: &Descriptor, base: usize) -> Option { + let (from, to) = (floats(s, "PinP")?, floats(s, "posFinalPins")?); + if from.len() != to.len() || !from.len().is_multiple_of(2) { + return None; + } + let pins = from.as_chunks::<2>().0.iter().zip(to.as_chunks::<2>().0).map(|(a, b)| json!({"src": a, "dst": b})).collect(); + let verts = vertices(raw(s, "originalVertexArray")?)?; + let deformed = vertices(raw(s, "deformedVertexArray")?)?; + if verts.len() != deformed.len() { + return None; + } + let tris = triangles(raw(s, "indexArray")?, verts.len())?; + Some((pins, verts, deformed, tris.into_iter().map(|t| t.map(|i| i + base)).collect())) +} + +/// Pins and mesh of a `rigidTransform` descriptor (shapes joined into one mesh), as +/// `{"pins", "mesh"}`. +fn settings(f: &Descriptor) -> Option { + let Some(Value::List(shapes)) = f.get("puppetShapeList") else { return None }; + let (mut pins, mut verts, mut deformed, mut tris) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + for s in shapes { + let Value::Descriptor(s) = s else { return None }; + let (p, v, d, t) = shape(s, verts.len())?; + pins.extend(p); + verts.extend(v); + deformed.extend(d); + tris.extend(t); + } + if tris.is_empty() || verts.len() > MAX_VERTS { + return None; + } + let pins = J::Array(pins); + Some(json!({"pins": pins, "mesh": {"pins": pins, "verts": verts, "deformed": deformed, "tris": tris}})) +} + +/// The corners of the layer the mesh was built on (`PuX0`, `PuY0` … `PuX3`, `PuY3`), as x, y +/// pairs. +pub(super) fn built_on(params: &J) -> Option<[f64; 8]> { + let item = template(params).ok()?; + let f = get_desc(&item, "Fltr")?; + let mut q = [0.0; 8]; + for i in 0..4 { + q[2 * i] = num(f.get(&format!("PuX{i}"))).filter(|v| v.is_finite())?; + q[2 * i + 1] = num(f.get(&format!("PuY{i}"))).filter(|v| v.is_finite())?; + } + Some(q) +} + +/// `layer.smartObjects.puppetWarp` params for a Puppet Warp item with a readable mesh. +pub(super) fn import_params(item: &Descriptor) -> Option { + let f = get_desc(item, "Fltr")?; + if !f.class_id.is(CLASS) { + return None; + } + let mut params = settings(f)?; + // Neutral blending in the template: the model's own fields replace it on export. + let mut template = item.clone(); + let neutral = SmartFilter { command: String::new(), params: J::Null, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; + set(&mut template, "blendOptions", blend_options(&neutral)); + set(&mut template, "enab", Value::Boolean(true)); + params.as_object_mut()?.insert(TEMPLATE_KEY.into(), json!(to_hex(&template.to_bytes()))); + Some(params) +} + +/// The template item of `params`, checked to be a Puppet Warp filter. +fn template(params: &J) -> Result { + let raw = params.get(TEMPLATE_KEY).and_then(J::as_str).ok_or("a Puppet Warp made in PhotoCraft is not written to PSD")?; + let bytes = from_hex(raw).ok_or("the Puppet Warp PSD data is not valid hex")?; + let item = Descriptor::from_bytes(&bytes).map_err(|e| format!("the Puppet Warp PSD data is unreadable ({e})"))?; + if !item.class_id.is("filterFX") || !get_desc(&item, "Fltr").is_some_and(|f| f.class_id.is(CLASS)) { + return Err("the Puppet Warp PSD data is not a Puppet Warp filter".into()); + } + Ok(item) +} + +/// The `filterFXList` item: the template, while the pins are still the imported ones. +pub(super) fn export_item(params: &J) -> Result { + let item = template(params)?; + let imported = get_desc(&item, "Fltr").and_then(settings).ok_or("the Puppet Warp PSD data has no readable mesh")?; + if params.get("pins") != imported.get("pins") { + return Err("Puppet Warp pins moved in PhotoCraft are not written to PSD".into()); + } + // Imported params set no solver settings, so a set one is an edit the template doesn't hold. + if let Some(k) = ["mode", "density", "expansion"].into_iter().find(|k| params.get(*k).is_some()) { + return Err(format!("the Puppet Warp `{k}` set in PhotoCraft is not written to PSD")); + } + Ok(item) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::smart_map::{UNSUPPORTED_FILTER, filter_from_item, item_for_filter, parse_sold}; + use photocraft_psd::descriptor::{UnicodeString, VersionedDescriptor}; + + const QUAD: [f64; 8] = [10.0, 20.0, 110.0, 22.0, 108.0, 220.0, 12.0, 218.0]; + + fn list(v: &[f64]) -> Value { + Value::List(v.iter().map(|x| Value::Double(*x)).collect()) + } + + fn f32s(v: &[[f32; 2]]) -> Value { + Value::RawData(v.iter().flatten().flat_map(|x| x.to_le_bytes()).collect()) + } + + fn i32s(v: &[i32]) -> Value { + Value::RawData(v.iter().flat_map(|x| x.to_le_bytes()).collect()) + } + + /// A Puppet Warp shape as Photoshop writes it: a box of two triangles with one pin. + fn shape(dx: f32) -> Descriptor { + let verts = [[10.0, 20.0], [110.0, 22.0], [108.0, 220.0], [12.0, 218.0]]; + let moved: Vec<[f32; 2]> = verts.iter().map(|v| [v[0] + dx, v[1]]).collect(); + Descriptor::new("puppetShape") + .with("rigidType", Value::Boolean(true)) + .with("originalVertexArray", f32s(&verts)) + .with("deformedVertexArray", f32s(&moved)) + .with("indexArray", i32s(&[0, 1, 2, 0, 2, 3])) + .with("pinOffsets", list(&[0.0, 0.0])) + .with("posFinalPins", list(&[60.0 + f64::from(dx), 120.0])) + .with("pinVertexIndices", list(&[0.0])) + .with("PinP", list(&[60.0, 120.0])) + .with("meshExpansion", Value::Integer(2)) + } + + fn item(shapes: Vec) -> Descriptor { + let mut f = Descriptor::new(CLASS) + .with("rigidType", Value::Boolean(true)) + .with("puppetShapeList", Value::List(shapes.into_iter().map(Value::Descriptor).collect())); + for i in 0..4 { + f = f.with(&format!("PuX{i}"), Value::Double(QUAD[2 * i])).with(&format!("PuY{i}"), Value::Double(QUAD[2 * i + 1])); + } + let neutral = SmartFilter { command: String::new(), params: J::Null, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Puppet Warp"))) + .with("blendOptions", blend_options(&neutral)) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("Fltr", Value::Descriptor(f)) + .with("filterID", Value::Integer(973)) + } + + #[test] + fn puppet_items_map_to_the_command_with_their_mesh() { + let src = item(vec![shape(4.0), shape(-2.0)]); + let f = filter_from_item(&src); + assert_eq!(f.command, COMMAND); + assert_eq!(f.params["pins"], json!([{"src": [60.0, 120.0], "dst": [64.0, 120.0]}, {"src": [60.0, 120.0], "dst": [58.0, 120.0]}])); + let mesh = &f.params["mesh"]; + assert_eq!(mesh["pins"], f.params["pins"]); + assert_eq!(mesh["verts"].as_array().unwrap().len(), 8); + assert_eq!(mesh["verts"][2], json!([108.0, 220.0])); + assert_eq!(mesh["deformed"][2], json!([112.0, 220.0])); + // The second shape's triangles index its own vertices. + assert_eq!(mesh["tris"], json!([[0, 1, 2], [0, 2, 3], [4, 5, 6], [4, 6, 7]])); + assert_eq!(built_on(&f.params), Some(QUAD)); + // Unchanged pins write the original item back; blending is the model's. + assert_eq!(item_for_filter(&f).unwrap(), src); + let mut faded = f.clone(); + faded.opacity = 0.5; + assert_eq!(filter_from_item(&item_for_filter(&faded).unwrap()).opacity, 0.5); + // Moved pins aren't written (the stored mesh would no longer match them). + let mut moved = f.clone(); + moved.params["pins"][0]["dst"] = json!([70.0, 120.0]); + assert!(item_for_filter(&moved).is_err()); + // Nor are solver settings set after import (the template holds the imported ones). + for (k, v) in [("mode", json!("rigid")), ("density", json!("more")), ("expansion", json!(2.0))] { + let mut edited = f.clone(); + edited.params[k] = v; + assert!(item_for_filter(&edited).is_err(), "{k}"); + } + // The import records the placement the mesh was solved for; the item written back is + // unchanged by it. + let mut sm = photocraft_doc::SmartObject::new( + photocraft_doc::SmartSource::Linked { path: String::new() }, + photocraft_geom::Affine { m: [1.0, 0.0, 0.0, 1.0, 3.0, 4.0] }, + None, + ); + sm.smart_filters.push(f.clone()); + crate::smart_map::pin_puppet_meshes(&mut sm); + assert_eq!(sm.smart_filters[0].params["mesh"]["placement"], sm.placement_json()); + assert_eq!(item_for_filter(&sm.smart_filters[0]).unwrap(), src); + // A Puppet Warp made in PhotoCraft has no template: not written, as before. + let own = SmartFilter { params: json!({"pins": f.params["pins"]}), ..f }; + assert!(item_for_filter(&own).is_err()); + } + + #[test] + fn unreadable_puppet_filters_stay_opaque() { + let with = |key: &str, v: Value| { + let mut s = shape(4.0); + set(&mut s, key, v); + item(vec![s]) + }; + for src in [ + with("originalVertexArray", Value::RawData(vec![0; 31])), + with("originalVertexArray", f32s(&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]])), + with("deformedVertexArray", f32s(&[[f32::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])), + with("indexArray", i32s(&[0, 1, 4])), + with("indexArray", i32s(&[0, 1, -1])), + with("indexArray", Value::RawData(vec![0; 10])), + with("indexArray", i32s(&[])), + with("posFinalPins", list(&[60.0])), + with("PinP", Value::Integer(3)), + item(vec![]), + ] { + let f = filter_from_item(&src); + assert_eq!(f.command, UNSUPPORTED_FILTER); + assert_eq!(item_for_filter(&f).unwrap(), src); + } + } + + #[test] + fn a_mesh_built_on_other_corners_is_dropped() { + // The mesh is in document pixels where the layer was when it was built: a layer whose + // corners differ from the stored ones keeps the pins but not the mesh. + let sold = |quad: [f64; 8]| { + let fx = Descriptor::new("filterFXStyle") + .with("enab", Value::Boolean(true)) + .with("filterFXList", Value::List(vec![Value::Descriptor(item(vec![shape(4.0)]))])); + let d = Descriptor::new("null").with("Trnf", list(&quad)).with("nonAffineTransform", list(&quad)).with("filterFX", Value::Descriptor(fx)); + let mut v = b"soLD".to_vec(); + v.extend_from_slice(&4u32.to_be_bytes()); + v.extend(VersionedDescriptor::new(d).to_bytes()); + v + }; + let filter = |quad| parse_sold(&sold(quad)).and_then(|p| p.stack).map(|s| s.filters[0].clone()).expect("a stack"); + let kept = filter(QUAD); + assert_eq!(kept.command, COMMAND); + assert!(kept.params.get("mesh").is_some()); + let mut moved = QUAD; + moved[0] += 5.0; + let f = filter(moved); + assert_eq!(f.command, COMMAND); + assert!(f.params.get("mesh").is_none()); + assert_eq!(f.params["pins"], kept.params["pins"]); + // Still written back as it was. + assert_eq!(item_for_filter(&f).unwrap(), item(vec![shape(4.0)])); + } +} diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index a1ed4aaa014..a1d1c730671 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -50,7 +50,7 @@ fn upright_preview(raw: &[u8], jpeg: &[u8], limits: &photocraft_raw::Limits) -> /// Camera Raw's Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture and Dehaze, /// applied while developing the sensor data (the open-time Camera Raw dialog) instead of to the -/// developed RGB pixels, and the demosaic method. +/// developed RGB pixels, the demosaic method, and the crop. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct RawTuning { /// −100…100, as `photocraft_algo::camera_raw::CameraRaw::temperature`. @@ -71,6 +71,9 @@ pub struct RawTuning { pub texture: f32, /// −100…100, as `CameraRaw::dehaze`. pub dehaze: f32, + /// The crop of the raw's sidecar ([`crate::camera_raw_xmp::Crop`], in the stored + /// orientation): the developed frame is cropped to it once it is upright. + pub crop: Option, } /// Develops a raw file with `tuning` applied at the raw stage: Exposure as develop exposure, @@ -106,7 +109,15 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< } None => false, }; - let dev = photocraft_raw::develop_sensor(&sensor, &opts).map_err(IoError::Raw)?; + let mut dev = photocraft_raw::develop_sensor(&sensor, &opts).map_err(IoError::Raw)?; + let cropped = match tuning.crop { + Some(crop) => { + let o = if opts.orient { dev.info.orientation } else { 1 }; + crop_developed(&mut dev, crop.oriented(o))?; + format!(", cropped to {}×{}", dev.width, dev.height) + } + None => String::new(), + }; let wb = if wb_applied { format!("as-shot white balance, temperature {:+.0}, tint {:+.0}", tuning.temperature, tuning.tint) } else { @@ -117,10 +128,26 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< .filter(|(_, v)| *v != 0.0) .map(|(k, v)| format!(", {k} {v:+.0}")) .collect::(); - let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb}, {} demosaic)", tuning.exposure, tuning.demosaic.id()); + let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb}, {} demosaic{cropped})", tuning.exposure, tuning.demosaic.id()); Ok((developed_document(name, bytes, format, dev, &summary)?, wb_applied)) } +/// Crops the upright developed frame to `crop` (fractions of it), as Camera Raw crops a raw. +fn crop_developed(dev: &mut photocraft_raw::Developed, crop: crate::camera_raw_xmp::Crop) -> Result<(), IoError> { + let (w, h) = (dev.width as usize, dev.height as usize); + let bad = || IoError::Raw(RawError::Malformed(format!("the developed {w}x{h} frame holds fewer samples than its size"))); + let Some((x0, y0, x1, y1)) = crop.pixels(w, h) else { return Ok(()) }; + let mut rgb = Vec::with_capacity((x1 - x0) * (y1 - y0) * 3); + for y in y0..y1 { + let row = y.checked_mul(w).ok_or_else(bad)?; + rgb.extend_from_slice(dev.rgb.get((row + x0) * 3..(row + x1) * 3).ok_or_else(bad)?); + } + dev.rgb = rgb; + dev.width = u32::try_from(x1 - x0).map_err(|_| bad())?; + dev.height = u32::try_from(y1 - y0).map_err(|_| bad())?; + Ok(()) +} + /// The tone curve `bytes` develop with by default: the fitted look's for a file without colour /// calibration (see [`import_raw_with`]), else the camera profile's (empty: none, or not a raw /// this crate decodes). For previews of the open-time raw-stage sliders diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 3b19606c94e..53c0c258e38 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -25,7 +25,7 @@ //! (`HStr`) maps to its adjustment command in `hue_sat_filter_map.rs`. use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; -use photocraft_doc::{LayerMask, SmartFilter}; +use photocraft_doc::{LayerMask, SmartFilter, SmartObject}; use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; use photocraft_geom::{Affine, Rect as GeomRect}; use photocraft_psd::Compression; @@ -49,6 +49,8 @@ mod displace; mod hue_sat; #[path = "perspective_warp_map.rs"] mod perspective_warp; +#[path = "puppet_map.rs"] +mod puppet; pub use displace::{embed_map as displace_embed_map, embedded_map as displace_embedded_map, without_map as displace_without_map}; pub use perspective_warp::{ @@ -133,10 +135,44 @@ pub fn parse_sold(data: &[u8]) -> Option { let d = sold_descriptor(data)?; let (idnt, transform) = crate::blocks::parse_smart(b"SoLd", data); let placed = text_of(&d, "placed").unwrap_or_default(); - let stack = get_desc(&d, "filterFX").map(filter_stack); + let mut stack = get_desc(&d, "filterFX").map(filter_stack); + if let Some(stack) = &mut stack { + drop_moved_puppet_meshes(stack, &d); + } Some(Placed { descriptor: d, idnt, placed, transform, stack }) } +/// A Puppet Warp's stored mesh sits where the layer was when it was built (document pixels); one +/// built on other corners than the layer's own doesn't fit it, so its pins are solved instead. +fn drop_moved_puppet_meshes(stack: &mut FilterStack, d: &Descriptor) { + let corners = |k: &str| match d.get(k) { + Some(Value::List(v)) if v.len() == 8 => v.iter().map(|x| num(Some(x))).collect::>>(), + _ => None, + }; + let quad = corners("nonAffineTransform").or_else(|| corners("Trnf")); + for f in stack.filters.iter_mut().filter(|f| f.command == puppet::COMMAND) { + let fits = match (&quad, puppet::built_on(&f.params)) { + (Some(q), Some(b)) => q.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01), + _ => false, + }; + if !fits && let Some(p) = f.params.as_object_mut() { + p.remove("mesh"); + } + } +} + +/// Records on each imported Puppet Warp mesh the placement it was solved for (`mesh.placement`, +/// [`SmartObject::placement_json`]): once the smart object is moved, transformed or warped, the +/// mesh no longer fits it and the pins are solved instead. +pub(crate) fn pin_puppet_meshes(sm: &mut SmartObject) { + let placement = sm.placement_json(); + for f in sm.smart_filters.iter_mut().filter(|f| f.command == puppet::COMMAND) { + if let Some(m) = f.params.get_mut("mesh").and_then(J::as_object_mut) { + m.insert("placement".into(), placement.clone()); + } + } +} + /// Reads a `filterFX` object. pub fn filter_stack(fx: &Descriptor) -> FilterStack { let filters = match fx.get("filterFXList") { diff --git a/crates/io/src/smart_map/export.rs b/crates/io/src/smart_map/export.rs index e057a7032ff..bc96de4cd4e 100644 --- a/crates/io/src/smart_map/export.rs +++ b/crates/io/src/smart_map/export.rs @@ -7,7 +7,7 @@ use super::import::{ use super::placed::enumv; use super::{ Class, Descriptor, FilterStack, Id, J, ReferenceItem, SmartFilter, UNSUPPORTED_FILTER, UnicodeString, Value, camera_raw, displace, hue_sat, - perspective_warp, + perspective_warp, puppet, }; // ---------- writing ---------- @@ -309,6 +309,12 @@ pub fn item_for_filter(f: &SmartFilter) -> Result { if perspective_warp::handles(&f.command) { return perspective_warp::export_item(f); } + if f.command == puppet::COMMAND && f.params.get(puppet::TEMPLATE_KEY).is_some() { + let mut d = puppet::export_item(&f.params)?; + set(&mut d, "blendOptions", blend_options(f)); + set(&mut d, "enab", Value::Boolean(f.visible)); + return Ok(d); + } if f.command == UNSUPPORTED_FILTER { let raw = f.params.get("psd").and_then(J::as_str).and_then(from_hex).ok_or("its filter data is missing")?; let mut d = Descriptor::from_bytes(&raw).map_err(|e| format!("its filter data is unreadable ({e})"))?; diff --git a/crates/io/src/smart_map/import.rs b/crates/io/src/smart_map/import.rs index f8f3f50e3f6..e381efe0584 100644 --- a/crates/io/src/smart_map/import.rs +++ b/crates/io/src/smart_map/import.rs @@ -2,7 +2,7 @@ use super::export::{CELL_SIZE, blend_options, is_meta, pi, rgbc, whole}; use super::placed::enumv; use super::{ BlendMode, Descriptor, Id, J, Map, ReferenceItem, SmartFilter, UNSUPPORTED_FILTER, UnicodeString, Value, bool_or, camera_raw, displace, enum_of, get_desc, - hue_sat, json, num, perspective_warp, text_of, + hue_sat, json, num, perspective_warp, puppet, text_of, }; // ---------- the filters we model ---------- @@ -867,6 +867,7 @@ pub fn filter_from_item(item: &Descriptor) -> SmartFilter { Some(displace::CLASS) => displace::import_params(item).map(|p| (displace::COMMAND, p)), Some(hue_sat::CLASS) => fltr.and_then(hue_sat::import_params).map(|p| (hue_sat::COMMAND, p)), Some(perspective_warp::CLASS) => perspective_warp::import_params(item).map(|p| (perspective_warp::COMMAND, p)), + Some(puppet::CLASS) => puppet::import_params(item).map(|p| (puppet::COMMAND, p)), Some(GALLERY_CLASS) => fltr.and_then(|f| gallery_params(item, f)).map(|p| (GALLERY_COMMAND, p)), Some(c @ ("Fbrs" | "Clds" | "DfrC")) => fltr.and_then(|f| coloured_params(c, item, f)), // A `Fltr` that is there but not a descriptor is settings we can't read: kept verbatim diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index db6fc6cdf3f..476818fa449 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -44,6 +44,7 @@ mod orientation; mod pattern_fill_psd; mod pcraft_full_composite; mod pcraft_manifest_name; +mod pdf_import; mod pdn_corpus; mod png_steps; mod png_text_metadata; diff --git a/crates/io/tests/it/pdf_import.rs b/crates/io/tests/it/pdf_import.rs new file mode 100644 index 00000000000..b614f54c970 --- /dev/null +++ b/crates/io/tests/it/pdf_import.rs @@ -0,0 +1,196 @@ +//! PDF import end-to-end through craft-pdf (pure Rust, no shared library). +//! +//! A six-page synthetic PDF (plain, CropBox offset, rotations 90/180/270 and an oversized +//! CropBox) imports at 72 and 300 DPI as one artboard per page, with the page's print size kept, +//! a blue rectangle sampled from the raster, and the renderer's side/pixel limits respected by +//! lowering the effective DPI on a huge page. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use lopdf::{Document as PdfDocument, Object, Stream, dictionary}; +use photocraft_doc::LayerContent; +use photocraft_geom::Rect; +use photocraft_io::{ImportOptions, IoError, import_with_options_and_interrupt}; +use photocraft_raster::Interrupt; + +const PAGE_W: f64 = 200.0; +const PAGE_H: f64 = 100.0; + +/// Six one-page variants: plain, CropBox offset to (10, 20, 210, 120), rotations of 90/180/270 +/// degrees and an oversized CropBox (-50, -50, 250, 150) around the 200 x 100 MediaBox. Each page +/// draws a 40 x 40 blue rectangle at (20, 30) in PDF space. +fn variant_pdf() -> Vec { + let pages: [(Option, Option<[f32; 4]>); 6] = [ + (None, None), + (None, Some([10.0, 20.0, 210.0, 120.0])), + (Some(90), None), + (Some(180), None), + (Some(270), None), + (None, Some([-50.0, -50.0, 250.0, 150.0])), + ]; + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); + let mut kids = Vec::new(); + for (rotate, crop) in pages { + let content = b"0 0 1 rg 20 30 40 40 re f"; + let content_id = pdf.add_object(Stream::new(dictionary! {}, content.to_vec())); + let mut page = dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(PAGE_W as f32), Object::Real(PAGE_H as f32)]), + "Contents" => content_id, + "Resources" => dictionary! {}, + }; + if let Some(rotate) = rotate { + page.set("Rotate", rotate); + } + if let Some([left, bottom, right, top]) = crop { + page.set("CropBox", Object::Array(vec![Object::Real(left), Object::Real(bottom), Object::Real(right), Object::Real(top)])); + } + kids.push(Object::Reference(pdf.add_object(page))); + } + let count = kids.len() as i64; + pdf.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => Object::Array(kids), + "Count" => count, + }), + ); + let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + pdf.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + pdf.save_to(&mut bytes).expect("writing the variant PDF"); + bytes +} + +/// One page of `width` x `height` points with the same blue rectangle. +fn one_page_pdf(width: f32, height: f32) -> Vec { + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); + let content_id = pdf.add_object(Stream::new(dictionary! {}, b"0 0 1 rg 20 30 40 40 re f".to_vec())); + let page_id = pdf.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(width), Object::Real(height)]), + "Contents" => content_id, + "Resources" => dictionary! {}, + }); + pdf.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => Object::Array(vec![Object::Reference(page_id)]), + "Count" => 1, + }), + ); + let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + pdf.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + pdf.save_to(&mut bytes).expect("writing the page PDF"); + bytes +} + +/// The first artboard rectangle of `document`. +fn first_artboard(document: &photocraft_doc::Document) -> Rect { + let layer = document.layers.first().expect("an imported page group"); + let group = match &layer.content { + LayerContent::Group(group) => group, + _ => panic!("{} is not a group", layer.name), + }; + group.artboard.as_ref().expect("page artboard").rect +} + +/// Each page becomes its own artboard at 72 and 300 DPI, with the print size preserved. +#[test] +fn imports_every_page_as_an_artboard_with_its_print_size() { + let pdf = variant_pdf(); + for dpi in [72.0f32, 300.0] { + let options = ImportOptions { pdf_dpi: dpi, ..Default::default() }; + let result = import_with_options_and_interrupt("variants.pdf", &pdf, &options, &Interrupt::NONE).expect("PDF import"); + assert_eq!(result.document.layers.len(), 6, "one artboard per page at {dpi} dpi"); + let mut top_left = 0i32; + for (index, layer) in result.document.layers.iter().enumerate() { + let group = match &layer.content { + LayerContent::Group(group) => group, + _ => panic!("{} is not a page group", layer.name), + }; + let artboard = group.artboard.as_ref().expect("page artboard").rect; + let expected = match index { + // CropBox [10, 20, 210, 120] intersected with the MediaBox is 190 x 80 points. + 1 => (190.0, 80.0), + // Rotations 90 and 270 swap the axes. + 2 | 4 => (100.0, 200.0), + _ => (200.0, 100.0), + }; + let print_width = f64::from(artboard.width()) * 72.0 / f64::from(dpi); + let print_height = f64::from(artboard.height()) * 72.0 / f64::from(dpi); + assert!((print_width - expected.0).abs() < 1.0, "page {} width is {print_width:.1} pt, expected {} at {dpi} dpi", index + 1, expected.0); + assert!((print_height - expected.1).abs() < 1.0, "page {} height is {print_height:.1} pt, expected {} at {dpi} dpi", index + 1, expected.1); + assert_eq!(artboard.x0, top_left, "pages lay out side by side from x=0"); + top_left = artboard.x1 + 80; + let surface = group.children.first().and_then(|layer| layer.surface()).expect("page raster layer"); + if index == 0 { + // The rectangle's centre in PDF space is (40, 50); at the document's top-left + // origin that is y = PAGE_H - 50. + let scale = f64::from(dpi) / 72.0; + let x = artboard.x0 + (40.0 * scale).round() as i32; + let y = ((PAGE_H - 50.0) * scale).round() as i32; + let mut pixel = [[0u8; 4]; 1]; + surface.read_rgba8_into(Rect::from_xywh(x, y, 1, 1), &mut pixel); + assert!(pixel[0][2] > 120 && pixel[0][0] < 120, "page 1 should be blue inside the rectangle, got {:?}", pixel[0]); + let mut background = [[0u8; 4]; 1]; + surface.read_rgba8_into(Rect::from_xywh(artboard.x0 + 2, 2, 1, 1), &mut background); + assert!(background[0][0] > 240 && background[0][1] > 240 && background[0][2] > 240, "page background should be white, got {:?}", background[0]); + } + } + } +} + +/// A page over the renderer's side limit lowers the effective DPI instead of failing. +#[test] +fn huge_pages_lower_the_effective_dpi_within_the_renderer_bounds() { + // 3000 pt at 300 dpi would be 12500 px wide, over craft-pdf's 8192 px side limit. + let pdf = one_page_pdf(3000.0, 100.0); + let options = ImportOptions { pdf_dpi: 300.0, ..Default::default() }; + let result = import_with_options_and_interrupt("wide.pdf", &pdf, &options, &Interrupt::NONE).expect("PDF import"); + assert!(result.warnings.iter().any(|warning| warning.contains("rendered at")), "warnings: {:?}", result.warnings); + let artboard = first_artboard(&result.document); + assert!(artboard.width() <= 8192, "width {} is over the renderer's side limit", artboard.width()); + assert!(result.document.resolution_dpi < 300.0, "the effective DPI should be lowered"); + let print_width = f64::from(artboard.width()) * 72.0 / f64::from(result.document.resolution_dpi); + assert!((print_width - 3000.0).abs() < 1.0, "print width {print_width:.1} pt should stay 3000 pt"); +} + +/// Cancelling at the public boundary or between pages returns Cancelled, never a document. +/// +/// The renderer's watcher polls the interrupt while a page is being interpreted, so the number +/// of checks is not fixed any more; these triggers do not depend on counting them. +#[test] +fn cancelling_before_and_between_pages_is_reported() { + let pdf = variant_pdf(); + let options = ImportOptions { pdf_dpi: 72.0, ..Default::default() }; + + // The boundary check before dispatch: already cancelled. + let always = || true; + let ctl = Interrupt::cancel_only(&always); + match import_with_options_and_interrupt("variants.pdf", &pdf, &options, &ctl) { + Err(IoError::Cancelled) => {} + other => panic!("an already-cancelled import returned {:?}", other.map(|_| ())), + } + + // The per-page loop's check: the first page's progress cancels the next iteration. + let cancelled = AtomicBool::new(false); + let cancel = || cancelled.load(Ordering::SeqCst); + let progress = |fraction: f32| { + if fraction > 0.0 { + cancelled.store(true, Ordering::SeqCst); + } + }; + let ctl = Interrupt::new(&cancel, &progress); + match import_with_options_and_interrupt("variants.pdf", &pdf, &options, &ctl) { + Err(IoError::Cancelled) => {} + other => panic!("cancelling after the first page returned {:?}", other.map(|_| ())), + } +} diff --git a/crates/io/tests/it/raw.rs b/crates/io/tests/it/raw.rs index 52250c24d7d..12a3d362e4a 100644 --- a/crates/io/tests/it/raw.rs +++ b/crates/io/tests/it/raw.rs @@ -420,6 +420,36 @@ fn uncalibrated_raw_takes_its_colour_from_the_camera_jpeg() { assert_eq!(r.document.depth, SampleType::U16); } +/// #9985: a sidecar's crop is measured on the raw as stored, before its Orientation turns it +/// upright: the developed frame is cropped to the same sensor pixels, whichever way it is turned. +#[test] +fn camera_raw_tuning_crops_in_the_stored_orientation() { + use photocraft_io::camera_raw_xmp::Crop; + use photocraft_io::raw::{RawTuning, import_raw_tuned}; + let (w, h) = (40, 24); + // Stored pixels x 4..24, y 6..18; then the upright frame's size and the same pixels in it. + let crop = Crop { top: 0.25, left: 0.1, bottom: 0.75, right: 0.6 }; + for (orientation, size, [x0, y0, x1, y1]) in + [(1, (40, 24), [4, 6, 24, 18]), (3, (40, 24), [16, 6, 36, 18]), (6, (24, 40), [6, 4, 18, 24]), (8, (24, 40), [6, 16, 18, 36])] + { + let mut spec = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)); + spec.as_shot_neutral = Some([0.6, 1.0, 0.8]); + spec.orientation = orientation; + let bytes = spec.build(); + let (full, _) = import_raw_tuned("shot.dng", &bytes, &RawTuning::default()).unwrap(); + let (cut, _) = import_raw_tuned("shot.dng", &bytes, &RawTuning { crop: Some(crop), ..Default::default() }).unwrap(); + assert_eq!((full.document.size.width, full.document.size.height), size, "orientation {orientation}"); + assert_eq!((cut.document.size.width, cut.document.size.height), (x1 - x0, y1 - y0), "orientation {orientation}"); + assert!(cut.warnings.iter().any(|n| n.contains(&format!("cropped to {}×{}", x1 - x0, y1 - y0))), "{:?}", cut.warnings); + let full = photocraft_io::document_to_image(&full.document, &mut Vec::new()).unwrap(); + let cut = photocraft_io::document_to_image(&cut.document, &mut Vec::new()).unwrap(); + // 16-bit RGB: 6 bytes a pixel. + let row = |y: u32| &full.data()[((y * size.0 + x0) * 6) as usize..((y * size.0 + x1) * 6) as usize]; + let expected: Vec = (y0..y1).flat_map(|y| row(y).to_vec()).collect(); + assert_eq!(cut.data(), expected, "orientation {orientation}: the crop is the same pixels of the uncropped develop"); + } +} + #[test] fn camera_raw_tuning_keeps_the_default_look_and_applies_user_gains_after_fitting() { use photocraft_io::raw::{RawTuning, fit_preview_look, import_raw_tuned}; diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index 323a90036aa..e639cbaa75a 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -4,6 +4,7 @@ //! Colours are compared in the surface's own colour channels (RGB, CMYK, Gray, Lab… at any depth), //! so nothing is converted through RGB; only alpha changes. +use photocraft_color::SampleType; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba}; use serde::{Deserialize, Serialize}; @@ -16,8 +17,15 @@ use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke}; #[serde(default, rename_all = "camelCase")] pub struct BgEraseSettings { pub sampling: Sampling, + /// Which matching pixels are erased. [`Limits::FindEdges`] grades the erase over the Tolerance + /// like [`Limits::Contiguous`] in this tool (#11904), unlike Color Replacement, where it turns + /// the soft falloff off. pub limits: Limits, - /// 0..1: largest per-channel difference from the sampled colour that is erased. + /// 0..1: how far from the sampled colour a colour is still partly erased. The distance is the + /// largest increase plus the largest decrease over the colour channels (`dist`); an exact + /// match is erased fully, then less the further a colour is, and colours at or beyond the + /// Tolerance are kept (`graded_weight`). Protect Foreground Color still compares the + /// foreground by the largest per-channel difference (`max_channel_dist`). pub tolerance: f32, /// Keep pixels that match the foreground colour. pub protect_foreground: bool, @@ -40,11 +48,39 @@ impl Default for BgEraseSettings { } } -/// Largest per-channel difference over the colour channels. +/// Colour distance of `a` from `b` over the colour channels: the largest increase plus the largest +/// decrease (#11901). Changes in the same direction count like the largest of them; changes in +/// opposite directions add up, as the reference application measures them (a red-to-green edge is +/// about twice as far as one channel alone). One channel (Gray) gives the absolute difference. fn dist(a: &[f32], b: &[f32]) -> f32 { + let (up, down) = a.iter().zip(b).fold((0.0f32, 0.0f32), |(up, down), (x, y)| { + let diff = x - y; + (up.max(diff), down.max(-diff)) + }); + up + down +} + +/// The largest per-channel difference of `a` from `b`: the distance Protect Foreground Color keeps +/// using (#11901 measured only the sample match; #11903 keeps today's measure for the foreground). +fn max_channel_dist(a: &[f32], b: &[f32]) -> f32 { a.iter().zip(b).fold(0.0f32, |m, (x, y)| m.max((x - y).abs())) } +/// The match weight of one dab for a colour `d` away from the sampled colour (#11901): an exact +/// match is erased fully, then the weight falls off linearly over the whole Tolerance `tol` after +/// a short plateau `d0 = min(tol / 20, 8 levels)`, and colours at or beyond the Tolerance are kept. +/// Measured in the reference application with single clicks (one dab) at Tolerance 10 to 100. +fn graded_weight(d: f32, tol: f32) -> f32 { + if !d.is_finite() || d >= tol { + return if d <= 0.0 { 1.0 } else { 0.0 }; + } + let d0 = (tol / 20.0).min(8.0 / 255.0); + if d <= d0 { + return 1.0; + } + (1.0 - (d - d0) / (tol - d0)).clamp(0.0, 1.0) +} + /// Erase along `stroke`. The brush opacity is ignored (Photoshop's Background Eraser has none); /// the selection limits the effect. Returns the damaged rectangle; empty when the surface has no /// alpha channel. @@ -70,22 +106,15 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option Vec { from_rgba(&fmt, [c[0], c[1], c[2], 1.0])[..nc].to_vec() }; let swatch = native(bs.background); let fg = native(bs.foreground); let (sampling, limits, protect_foreground) = (bs.sampling, bs.limits, bs.protect_foreground); + let float = fmt.sample == SampleType::F32; let mut once: Option>> = None; - let match_w = move |d: f32| -> f32 { - if d > tol { - 0.0 - } else if soft && tol > 0.0 { - // Soft falloff over the last quarter of the tolerance. - ((tol - d) / (tol * 0.25)).clamp(0.0, 1.0) - } else { - 1.0 - } - }; + // Find Edges grades soft edges like Contiguous does instead of cutting them hard (#11904): + // the reference application keeps at least as much of a blurred transition with it. + let match_w = move |d: f32| -> f32 { graded_weight(d, tol) }; let pre = pre.clone(); // The pixels before the stroke under the working copy, read once per working area. Sampling // and matching read these, so a dab landing on pixels earlier dabs already erased still @@ -117,27 +146,40 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option 0.0 && !protected(&c[..nc]) { + weight[i] = match_w(dist(&c[..nc], &sampled)); } - weight[(y - r.y0) as usize * w + (x - r.x0) as usize] = match_w(dist(c, &sampled)); } } if limits != Limits::Discontiguous { - keep_connected(&mut weight, w, h, (cx.clamp(r.x0, r.x1 - 1) - r.x0) as usize, (cy.clamp(r.y0, r.y1 - 1) - r.y0) as usize); + keep_connected(&mut reach, w, h, (cx.clamp(r.x0, r.x1 - 1) - r.x0) as usize, (cy.clamp(r.y0, r.y1 - 1) - r.y0) as usize); + for (wv, rv) in weight.iter_mut().zip(&reach) { + if *rv <= 0.0 { + *wv = 0.0; + } + } } for y in r.y0..r.y1 { for x in r.x0..r.x1 { @@ -145,16 +187,32 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option [f32; 4] { + [200.0 / 255.0, (40.0 + 20.0 * i as f32) / 255.0, 40.0 / 255.0, 1.0] +} + +/// Eight 100 px blocks side by side, 900 x 300; the reference's drag row y 450 is y 150 here. +fn blocks(fmt: PixelFormat) -> Surface { + let mut s = Surface::new(fmt); + for i in 0..8 { + let x = 100 * i as i32; + s.fill_rect(Rect::new(x, 0, x + 100, 300), &from_rgba(&fmt, block(i))); + } + s +} + +fn stroke(points: &[(f64, f64)]) -> Stroke { + Stroke { + brush: BrushSettings { size: 40.0, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() }, + points: points.iter().map(|&(x, y)| StrokePoint::new(x, y, 1.0)).collect(), + } +} + +fn once(tolerance_percent: f32) -> BgEraseSettings { + BgEraseSettings { sampling: Sampling::Once, limits: Limits::Discontiguous, tolerance: tolerance_percent / 100.0, ..Default::default() } +} + +fn swatch(tolerance_percent: f32) -> BgEraseSettings { + BgEraseSettings { sampling: Sampling::BackgroundSwatch, background: block(0), ..once(tolerance_percent) } +} + +/// RGBA 0-255 at block `i`'s centre. +fn rgba(s: &Surface, i: usize) -> [i32; 4] { + let p = s.rgba(100 * i as i32 + 50, 150); + p.map(|v| (v * 255.0).round() as i32) +} + +/// The reference's drag from block 0's centre to `to_x`, Sampling Once. +fn drag(fmt: PixelFormat, tolerance_percent: f32, to_x: f64) -> Surface { + let mut s = blocks(fmt); + apply_background_eraser(&mut s, &stroke(&[(50.0, 150.0), (to_x, 150.0)]), &once(tolerance_percent), None); + s +} + +/// Separate single-click strokes on every block centre, `clicks` times, Background Swatch. +fn clicks(fmt: PixelFormat, tolerance_percent: f32, clicks: usize) -> Surface { + let mut s = blocks(fmt); + for _ in 0..clicks { + for i in 0..8 { + apply_background_eraser(&mut s, &stroke(&[(100.0 * i as f64 + 50.0, 150.0)]), &swatch(tolerance_percent), None); + } + } + s +} + +fn assert_alpha_row(s: &Surface, want: &[i32], what: &str) { + let got: Vec = (0..want.len()).map(|i| rgba(s, i)[3]).collect(); + for (i, (g, w)) in got.iter().zip(want).enumerate() { + assert!((g - w).abs() <= 8, "{what}: block {i} alpha {g}, measured {w} (row {got:?})"); + } +} + +#[test] +fn the_eight_block_drag_gives_the_measured_rgba() { + const WANT: [[i32; 4]; 8] = [ + [200, 40, 40, 0], + [200, 216, 40, 29], + [200, 184, 40, 71], + [200, 175, 40, 113], + [200, 172, 40, 155], + [200, 170, 40, 196], + [200, 169, 40, 238], + [200, 180, 40, 255], + ]; + let s = drag(PixelFormat::RGBA8, 50.0, 750.0); + for (i, want) in WANT.iter().enumerate() { + let got = rgba(&s, i); + // Block 0 is fully erased: only its alpha is defined. + let channels = if want[3] == 0 { 3..4 } else { 0..4 }; + for c in channels { + assert!((got[c] - want[c]).abs() <= 8, "block {i}: got {got:?}, measured {want:?}"); + } + } +} + +#[test] +fn the_drag_table_of_11901_holds_with_decontamination() { + let rows: [(f32, [i32; 8]); 5] = [ + (10.0, [0, 194, 255, 255, 255, 255, 255, 255]), + (20.0, [0, 90, 197, 255, 255, 255, 255, 255]), + (30.0, [0, 56, 126, 196, 255, 255, 255, 255]), + (50.0, [0, 30, 75, 114, 156, 198, 238, 255]), + (70.0, [0, 18, 48, 78, 107, 137, 167, 197]), + ]; + for (tol, want) in rows { + assert_alpha_row(&drag(PixelFormat::RGBA8, tol, 750.0), &want, &format!("drag at Tolerance {tol}")); + } + // At Tolerance 100 the decontaminated colours stay inside the Tolerance, so every dab erases + // a bit more; the drag on to 790 is the measured row (block 7 is no reference at 750). + assert_alpha_row(&drag(PixelFormat::RGBA8, 100.0, 790.0), &[0, 7, 19, 32, 44, 56, 68, 81], "drag at Tolerance 100"); +} + +#[test] +fn later_strokes_match_the_decontaminated_colour() { + let fmt = PixelFormat::RGBA8; + assert_alpha_row(&clicks(fmt, 100.0, 1), &[0, 12, 33, 54, 74, 95, 116, 136], "Tolerance 100, 1 click"); + assert_alpha_row(&clicks(fmt, 100.0, 2), &[0, 10, 28, 45, 62, 80, 97, 114], "Tolerance 100, 2 clicks"); + assert_alpha_row(&clicks(fmt, 100.0, 3), &[0, 8, 23, 38, 52, 67, 81, 96], "Tolerance 100, 3 clicks"); + // At Tolerance 50 the decontaminated colours are outside the Tolerance: a second click leaves + // the block centres as they were. + let one = clicks(fmt, 50.0, 1); + assert_alpha_row(&one, &[0, 29, 71, 113, 155, 196, 238, 255], "Tolerance 50, 1 click"); + let two = clicks(fmt, 50.0, 2); + for i in 1..8 { + assert_eq!(rgba(&two, i), rgba(&one, i), "Tolerance 50, 2 clicks: block {i}"); + } +} + +#[test] +fn partly_erased_pixels_composite_back_to_their_original_colour() { + // Composited over the sampled colour, each partly erased block centre gives back its original + // colour within 2 levels, after one click and after the drag. + for s in [clicks(PixelFormat::RGBA8, 50.0, 1), drag(PixelFormat::RGBA8, 50.0, 750.0)] { + for i in 1..7 { + let [r, g, b, a] = rgba(&s, i); + assert!(a > 0 && a < 255, "block {i} partly erased, alpha {a}"); + let over = |c: i32, s: i32| (c * a + s * (255 - a)) as f32 / 255.0; + let orig = block(i).map(|v| v * 255.0); + for (k, (got, want)) in [over(r, 200), over(g, 40), over(b, 40)].iter().zip(orig).enumerate() { + assert!((got - want).abs() <= 2.0, "block {i} channel {k}: composite {got}, original {want}"); + } + } + } +} + +#[test] +fn kept_and_erased_pixels_and_the_channel_range() { + let s = drag(PixelFormat::RGBA8, 50.0, 750.0); + // Fully kept: the colour exactly; outside the brush: untouched. + assert_eq!(rgba(&s, 7), [200, 180, 40, 255]); + assert_eq!(s.rgba(350, 10), blocks(PixelFormat::RGBA8).rgba(350, 10)); + // Fully erased: alpha 0. + assert_eq!(rgba(&s, 0)[3], 0); + // At Tolerance 100 the decontaminated green clips at 255 rather than wrapping, and 32-bit + // float results stay finite and within 0..1 for in-range colours. + let t = clicks(PixelFormat::RGBA8, 100.0, 1); + assert_eq!(rgba(&t, 7)[1], 255, "clipped, not wrapped"); + let f = clicks(PixelFormat::RGBA32F, 100.0, 3); + for x in (0..900).step_by(7) { + for v in f.rgba(x, 150) { + assert!(v.is_finite() && (0.0..=1.0).contains(&v), "x {x}: {v}"); + } + } +} + +#[test] +fn it_works_in_the_documents_own_channels() { + // 16-bit RGB: the same RGBA as 8-bit. + let rgb16 = PixelFormat::new(ColorMode::Rgb, SampleType::U16, true); + let a = drag(PixelFormat::RGBA8, 50.0, 750.0); + let b = drag(rgb16, 50.0, 750.0); + for i in 0..8 { + let (x, y) = (rgba(&a, i), rgba(&b, i)); + for c in 0..4 { + assert!((x[c] - y[c]).abs() <= 2 || x[3] == 0, "16-bit block {i}: {y:?} against 8-bit {x:?}"); + } + } + // Gray (8 and 16 bit): the gray level is decontaminated the same way, so the composite over + // the sampled gray gives back the original level, and a second click at Tolerance 50 changes + // nothing at the block centres. + for sample in [SampleType::U8, SampleType::U16] { + let gray = PixelFormat::new(ColorMode::Grayscale, sample, true); + let level = |i: usize| (40.0 + 20.0 * i as f32) / 255.0; + let mut s = Surface::new(gray); + for i in 0..8 { + let x = 100 * i as i32; + s.fill_rect(Rect::new(x, 0, x + 100, 300), &[level(i), 1.0]); + } + let bs = BgEraseSettings { background: [level(0), level(0), level(0), 1.0], ..swatch(50.0) }; + let click_all = |s: &mut Surface| { + for i in 0..8 { + apply_background_eraser(s, &stroke(&[(100.0 * i as f64 + 50.0, 150.0)]), &bs, None); + } + }; + click_all(&mut s); + let first = s.clone(); + for i in 1..7 { + let p = s.pixel(100 * i as i32 + 50, 150); + let (v, a) = (p[0], p[1]); + assert!(a > 0.0 && a < 1.0, "{sample:?} block {i} partly erased, alpha {a}"); + let over = v * a + level(0) * (1.0 - a); + assert!((over - level(i)).abs() <= 2.0 / 255.0, "{sample:?} block {i}: composite {over}, original {}", level(i)); + } + click_all(&mut s); + for i in 1..8 { + let at = (100 * i + 50, 150); + assert_eq!(s.pixel(at.0, at.1), first.pixel(at.0, at.1), "{sample:?} block {i}: a second click at Tolerance 50 changes nothing"); + } + } +} diff --git a/crates/paint/src/bg_erase_find_edges_tests.rs b/crates/paint/src/bg_erase_find_edges_tests.rs new file mode 100644 index 00000000000..ee95dd8c2e2 --- /dev/null +++ b/crates/paint/src/bg_erase_find_edges_tests.rs @@ -0,0 +1,101 @@ +//! Background Eraser Limits Find Edges (#11904): a blurred edge is erased graded, keeping at least +//! as much of the transition as Contiguous does, instead of cut hard at the Tolerance. Expected +//! values are the reference application's measurements from the issue (8-bit RGB, 40 px and 60 px +//! hard round tips, Tolerance 50, Sampling Continuous). + +use photocraft_color::PixelFormat; +use photocraft_geom::Rect; +use photocraft_raster::{Surface, from_rgba}; + +use crate::bg_erase::{BgEraseSettings, apply_background_eraser}; +use crate::replace::{Limits, Sampling}; +use crate::{BrushSettings, Stroke, StrokePoint}; + +/// Measured red channel along y 70 from x 185 across the 4 px Gaussian red|green edge at x 200. +const RED: [i32; 30] = [220, 220, 220, 220, 219, 219, 217, 214, 210, 203, 194, 182, 168, 152, 134, 116, 98, 82, 68, 56, 47, 40, 36, 33, 31, 31, 30, 30, 30, 30]; +/// Alpha along y 70 from x 185 after a click at 190,70. +const FIND_EDGES: [i32; 15] = [8, 8, 8, 4, 4, 0, 0, 0, 17, 42, 76, 121, 176, 238, 255]; +const CONTIGUOUS: [i32; 15] = [0, 0, 0, 0, 0, 0, 0, 4, 21, 46, 79, 123, 178, 238, 255]; + +fn rgb(c: [i32; 3]) -> [f32; 4] { + [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0] +} + +/// The issue's test layer: red | green | blue | pink stripes (y 0-300), a white gap at y 140-160 +/// across the red stripe, and the red|green edge blurred over y 0-130. A Gaussian blur mixes every +/// channel with the same weights, so green and blue follow red's mix fraction. +fn layer() -> Surface { + let fmt = PixelFormat::RGBA8; + let mut s = Surface::new(fmt); + for (x0, c) in [(0, [220, 30, 30]), (200, [30, 180, 60]), (400, [40, 60, 220]), (600, [240, 120, 120])] { + s.fill_rect(Rect::new(x0, 0, x0 + 200, 300), &from_rgba(&fmt, rgb(c))); + } + s.fill_rect(Rect::new(0, 140, 200, 160), &from_rgba(&fmt, rgb([255, 255, 255]))); + for (i, r) in RED.iter().enumerate() { + let t = (220 - r) as f32 / 190.0; + let c = [*r as f32 / 255.0, (30.0 + 150.0 * t) / 255.0, (30.0 + 30.0 * t) / 255.0, 1.0]; + let x = 185 + i as i32; + s.fill_rect(Rect::new(x, 0, x + 1, 130), &from_rgba(&fmt, c)); + } + s +} + +fn click(at: (f64, f64), size: f32, limits: Limits) -> Surface { + let mut s = layer(); + let stroke = Stroke { + brush: BrushSettings { size, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() }, + points: vec![StrokePoint::new(at.0, at.1, 1.0)], + }; + let bs = BgEraseSettings { sampling: Sampling::Continuous, limits, tolerance: 0.5, ..Default::default() }; + apply_background_eraser(&mut s, &stroke, &bs, None); + s +} + +fn alpha(s: &Surface, x: i32, y: i32) -> i32 { + (s.rgba(x, y)[3] * 255.0).round() as i32 +} + +#[test] +fn find_edges_grades_a_blurred_edge_instead_of_cutting_it() { + let fe = click((190.0, 70.0), 40.0, Limits::FindEdges); + let co = click((190.0, 70.0), 40.0, Limits::Contiguous); + for (i, want) in FIND_EDGES.iter().enumerate() { + let x = 185 + i as i32; + let (f, c) = (alpha(&fe, x, 70), alpha(&co, x, 70)); + assert!((f - want).abs() <= 10, "x {x}: Find Edges alpha {f}, measured {want}"); + assert!(f >= c, "x {x}: Find Edges keeps {f}, less than Contiguous's {c}"); + } + // Past the transition nothing is erased (before, Find Edges cut through x 201). + for x in 200..230 { + assert_eq!(alpha(&fe, x, 70), 255, "x {x}"); + } +} + +#[test] +fn find_edges_on_a_hard_edge_gives_the_contiguous_result() { + // 60 px tip at 100,138 on red just above the white gap: column x 100 erased through y 139, the + // gap and the red below it kept. + let fe = click((100.0, 138.0), 60.0, Limits::FindEdges); + let co = click((100.0, 138.0), 60.0, Limits::Contiguous); + for y in 100..176 { + assert_eq!(alpha(&fe, 100, y), alpha(&co, 100, y), "y {y}"); + } + for y in 110..140 { + assert_eq!(alpha(&fe, 100, y), 0, "red above the gap, y {y}"); + } + for y in 140..176 { + assert_eq!(alpha(&fe, 100, y), 255, "the gap and the red below it, y {y}"); + } +} + +#[test] +fn contiguous_and_discontiguous_keep_their_results() { + // This item changes only Find Edges: the other two limits still give the measured row. + for limits in [Limits::Contiguous, Limits::Discontiguous] { + let s = click((190.0, 70.0), 40.0, limits); + for (i, want) in CONTIGUOUS.iter().enumerate() { + let x = 185 + i as i32; + assert!((alpha(&s, x, 70) - want).abs() <= 8, "{limits:?} x {x}: {} against {want}", alpha(&s, x, 70)); + } + } +} diff --git a/crates/paint/src/bg_erase_graded_alpha_tests.rs b/crates/paint/src/bg_erase_graded_alpha_tests.rs new file mode 100644 index 00000000000..dcbfab1a204 --- /dev/null +++ b/crates/paint/src/bg_erase_graded_alpha_tests.rs @@ -0,0 +1,189 @@ +//! Background Eraser graded alpha (#11901): one dab erases a colour less the further it is from the +//! sampled colour, over the whole Tolerance, not fully up to the Tolerance. Expected values are the +//! reference application's single-click measurements from the issue (8-bit RGB, 40 px hard round +//! tip, Sampling Background Swatch with background (200,40,40), alpha at the patch centre). + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::{Surface, from_rgba}; + +use crate::bg_erase::{BgEraseSettings, apply_background_eraser}; +use crate::replace::{Limits, Sampling}; +use crate::{BrushSettings, Stroke, StrokePoint}; + +/// The sampled colour of every measurement: (200,40,40). +const SAMPLE: [i32; 3] = [200, 40, 40]; + +/// Allowed deviation from the measured alpha ("within 8 levels"). +const LEVELS: i32 = 8; + +fn rgb(c: [i32; 3]) -> [f32; 4] { + [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0] +} + +fn click(at: (f64, f64)) -> Stroke { + Stroke { + brush: BrushSettings { size: 40.0, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() }, + points: vec![StrokePoint::new(at.0, at.1, 1.0)], + } +} + +fn swatch(tolerance_percent: f32, background: [f32; 4]) -> BgEraseSettings { + BgEraseSettings { + sampling: Sampling::BackgroundSwatch, + limits: Limits::Discontiguous, + tolerance: tolerance_percent / 100.0, + background, + ..Default::default() + } +} + +/// One click in the centre of a 100 px patch of `colour` (straight RGBA) in `fmt`; alpha 0-255 at +/// the centre. +fn one_click(fmt: PixelFormat, colour: [f32; 4], bs: &BgEraseSettings) -> i32 { + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(0, 0, 100, 100), &from_rgba(&fmt, colour)); + apply_background_eraser(&mut s, &click((50.0, 50.0)), bs, None); + (s.rgba(50, 50)[3] * 255.0).round() as i32 +} + +/// The measured single-click table: (Tolerance, [(green difference d, alpha)]). +fn single_click_table() -> Vec<(f32, Vec<(i32, i32)>)> { + vec![ + (10.0, vec![(10, 92), (20, 194), (30, 255), (40, 255)]), + (30.0, vec![(10, 21), (20, 56), (30, 91), (40, 126), (50, 161), (60, 196), (80, 255), (100, 255)]), + (50.0, vec![(10, 8), (20, 29), (30, 50), (40, 71), (50, 92), (60, 113), (80, 155), (100, 196), (120, 238), (140, 255)]), + (70.0, vec![(10, 3), (20, 18), (30, 33), (40, 48), (50, 63), (60, 78), (80, 107), (100, 137), (120, 167), (140, 197), (160, 227)]), + (100.0, vec![(10, 2), (20, 12), (30, 23), (40, 33), (50, 43), (60, 54), (80, 74), (100, 95), (120, 116), (140, 136), (160, 157)]), + ] +} + +fn assert_near(got: i32, want: i32, what: &str) { + assert!((got - want).abs() <= LEVELS, "{what}: got alpha {got}, measured {want}"); +} + +#[test] +fn one_click_falls_off_over_the_whole_tolerance() { + let bs = |tol| swatch(tol, rgb(SAMPLE)); + for (tol, row) in single_click_table() { + for (d, want) in row { + let got = one_click(PixelFormat::RGBA8, rgb([SAMPLE[0], SAMPLE[1] + d, SAMPLE[2]]), &bs(tol)); + assert_near(got, want, &format!("Tolerance {tol}, green +{d}")); + } + } +} + +#[test] +fn same_sign_differences_count_once_and_opposite_signs_add_up() { + // Measured at Tolerance 50, single clicks, sample (200,40,40): (difference per channel, alpha). + let rows: [([i32; 3], i32); 15] = [ + ([0, 20, 0], 29), + ([-20, 0, 0], 29), + ([0, 0, 20], 29), + ([0, 20, 20], 29), + ([0, 40, 20], 71), + ([0, 20, 40], 71), + ([0, 60, 20], 113), + ([-20, 20, 0], 71), + ([-40, 40, 0], 155), + ([-60, 60, 0], 238), + ([10, 10, 10], 8), + ([20, 20, 20], 29), + ([40, 40, 40], 71), + ([-10, -10, -10], 8), + ([-40, -40, -40], 71), + ]; + let bs = swatch(50.0, rgb(SAMPLE)); + for (diff, want) in rows { + let colour = [SAMPLE[0] + diff[0], SAMPLE[1] + diff[1], SAMPLE[2] + diff[2]]; + assert_near(one_click(PixelFormat::RGBA8, rgb(colour), &bs), want, &format!("difference {diff:?}")); + } +} + +#[test] +fn a_blurred_edge_keeps_its_transition_graded() { + // Red (220,30,30) | green (30,180,60) at x 200, blurred with a 4 px Gaussian; the measured red + // channel along y 70 from x 185. A Gaussian blur mixes every channel with the same weights, so + // green and blue follow red's mix fraction. + const RED: [i32; 30] = + [220, 220, 220, 220, 219, 219, 217, 214, 210, 203, 194, 182, 168, 152, 134, 116, 98, 82, 68, 56, 47, 40, 36, 33, 31, 31, 30, 30, 30, 30]; + const ALPHA: [i32; 15] = [0, 0, 0, 0, 0, 0, 0, 4, 21, 46, 79, 123, 178, 238, 255]; + let fmt = PixelFormat::RGBA8; + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(0, 0, 185, 140), &from_rgba(&fmt, rgb([220, 30, 30]))); + s.fill_rect(Rect::new(215, 0, 400, 140), &from_rgba(&fmt, rgb([30, 180, 60]))); + for (i, r) in RED.iter().enumerate() { + let t = (220 - r) as f32 / 190.0; + let c = [*r as f32 / 255.0, (30.0 + 150.0 * t) / 255.0, (30.0 + 30.0 * t) / 255.0, 1.0]; + let x = 185 + i as i32; + s.fill_rect(Rect::new(x, 0, x + 1, 140), &from_rgba(&fmt, c)); + } + for limits in [Limits::Contiguous, Limits::Discontiguous] { + let mut t = s.clone(); + let bs = BgEraseSettings { sampling: Sampling::Once, limits, tolerance: 0.5, ..Default::default() }; + apply_background_eraser(&mut t, &click((190.0, 70.0)), &bs, None); + for (i, want) in ALPHA.iter().enumerate() { + let x = 185 + i as i32; + let got = (t.rgba(x, 70)[3] * 255.0).round() as i32; + assert_near(got, *want, &format!("{limits:?} x {x}")); + } + for x in 200..215 { + assert_eq!(t.rgba(x, 70)[3], 1.0, "{limits:?} x {x} beyond the Tolerance kept"); + } + } +} + +#[test] +fn an_exact_match_is_erased_and_far_colours_are_kept_at_every_tolerance() { + for tol in [0.0, 1.0, 10.0, 30.0, 50.0, 70.0, 100.0] { + let bs = swatch(tol, rgb(SAMPLE)); + assert_eq!(one_click(PixelFormat::RGBA8, rgb(SAMPLE), &bs), 0, "Tolerance {tol}: the sample itself"); + if tol < 100.0 { + // Opposite-sign differences of 255 + 215: beyond every Tolerance below 100 %. + assert_eq!(one_click(PixelFormat::RGBA8, rgb([0, 255, 40]), &bs), 255, "Tolerance {tol}: a far colour"); + } + } + // Tolerance 0 erases nothing but the exact colour. + assert_eq!(one_click(PixelFormat::RGBA8, rgb([200, 41, 40]), &swatch(0.0, rgb(SAMPLE))), 255); +} + +#[test] +fn the_distance_is_measured_in_the_documents_own_channels() { + // 16-bit RGB: the same single-click table as 8-bit. + let rgb16 = PixelFormat::new(ColorMode::Rgb, SampleType::U16, true); + for (tol, row) in single_click_table() { + for (d, want) in row { + let got = one_click(rgb16, rgb([SAMPLE[0], SAMPLE[1] + d, SAMPLE[2]]), &swatch(tol, rgb(SAMPLE))); + assert_near(got, want, &format!("16-bit Tolerance {tol}, green +{d}")); + } + } + // Gray (8 and 16 bit): the distance is the absolute difference of the gray levels, so a gray + // patch d levels from the swatch erases like a one-channel difference of d in RGB, either way. + for sample in [SampleType::U8, SampleType::U16] { + let gray = PixelFormat::new(ColorMode::Grayscale, sample, true); + let level = |v: i32| [v as f32 / 255.0, v as f32 / 255.0, v as f32 / 255.0, 1.0]; + for (tol, row) in single_click_table() { + for (d, want) in row { + let bs = swatch(tol, level(60)); + if 60 + d <= 255 { + assert_near(one_click(gray, level(60 + d), &bs), want, &format!("{sample:?} gray Tolerance {tol}, +{d}")); + } + if d <= 60 { + assert_near(one_click(gray, level(60 - d), &bs), want, &format!("{sample:?} gray Tolerance {tol}, -{d}")); + } + } + } + } +} + +#[test] +fn protect_foreground_keeps_todays_distance_to_the_foreground() { + // #11903 measured the foreground only one channel away and keeps today's measure otherwise: + // the largest per-channel difference. This foreground is 100 levels off in red and in green + // (opposite signs): within Tolerance 50 by that measure, though the sample-match distance of + // #11901 adds the two to 200. The pixel is the sampled colour, so only the protection keeps it. + let bs = BgEraseSettings { protect_foreground: true, foreground: rgb([100, 140, 40]), ..swatch(50.0, rgb(SAMPLE)) }; + assert_eq!(one_click(PixelFormat::RGBA8, rgb(SAMPLE), &bs), 255); + // Protection off: the same click erases the sample fully. + assert_eq!(one_click(PixelFormat::RGBA8, rgb(SAMPLE), &BgEraseSettings { protect_foreground: false, ..bs }), 0); +} diff --git a/crates/paint/src/replace.rs b/crates/paint/src/replace.rs index 20619cd48a0..c4157fac818 100644 --- a/crates/paint/src/replace.rs +++ b/crates/paint/src/replace.rs @@ -96,6 +96,14 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut ReplaceMode::Luminosity => BlendMode::Luminosity, }; let fg = [rs.color[0], rs.color[1], rs.color[2]]; + // In a Lab/8 document Color mode keeps the pixel's L and takes the foreground's a and b, as in + // the reference application (#11922): the stored a, b channels move straight to its. Lab/16 + // wasn't measured and keeps the RGB blend. + let lab_color = fmt.mode == photocraft_color::ColorMode::Lab && fmt.sample == photocraft_color::SampleType::U8 && rs.mode == ReplaceMode::Color; + let fg_ab = { + let l = photocraft_color::convert::srgb_to_lab(fg); + [(l[1] + 128.0) / 255.0, (l[2] + 128.0) / 255.0] + }; let tol = rs.tolerance.clamp(0.0, 1.0); let soft = rs.anti_alias && rs.limits != Limits::FindEdges; let mut once: Option<[f32; 3]> = None; @@ -189,6 +197,11 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut continue; } let px = work.px_mut(x, y); + if lab_color { + px[1] += (fg_ab[0] - px[1]) * k; + px[2] += (fg_ab[1] - px[2]) * k; + continue; + } let c = to_rgba(&fmt, px); let b = blend_rgb(mode, [c[0], c[1], c[2]], fg); let o = [c[0] + (b[0] - c[0]) * k, c[1] + (b[1] - c[1]) * k, c[2] + (b[2] - c[2]) * k, c[3]]; @@ -199,6 +212,10 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut } } +#[cfg(test)] +#[path = "replace_lab_tests.rs"] +mod replace_lab_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/paint/src/replace_lab_tests.rs b/crates/paint/src/replace_lab_tests.rs new file mode 100644 index 00000000000..caf024fb401 --- /dev/null +++ b/crates/paint/src/replace_lab_tests.rs @@ -0,0 +1,100 @@ +//! #11922: in a Lab document, Color mode keeps the pixel's L and takes the foreground's a and b. +//! The setup is the issue's: red, green and pink stripes and the darker block (200,40,40), a blue +//! foreground, a 40 px hard tip, one click on each. Hue, Saturation and Luminosity keep today's RGB +//! blend. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; + +use super::*; +use crate::{BrushSettings, StrokePoint}; + +const LAB8: PixelFormat = PixelFormat { mode: ColorMode::Lab, sample: SampleType::U8, alpha: true }; +const COLOURS: [[u8; 3]; 4] = [[220, 30, 30], [30, 180, 60], [240, 120, 120], [200, 40, 40]]; +const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; + +/// A Lab/8 surface with the four colours in 100 px columns. +fn target() -> Surface { + target_in(LAB8) +} + +fn target_in(fmt: PixelFormat) -> Surface { + let mut s = Surface::new(fmt); + for (i, c) in COLOURS.iter().enumerate() { + let rgba = [f32::from(c[0]) / 255.0, f32::from(c[1]) / 255.0, f32::from(c[2]) / 255.0, 1.0]; + let x0 = i as i32 * 100; + s.fill_rect(Rect::new(x0, 0, x0 + 100, 100), &photocraft_raster::from_rgba(&fmt, rgba)); + } + s +} + +fn click(s: &mut Surface, mode: ReplaceMode, x: f64) { + let stroke = Stroke { + brush: BrushSettings { size: 40.0, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() }, + points: vec![StrokePoint::new(x, 50.0, 1.0)], + }; + let rs = ReplaceSettings { mode, color: BLUE, ..Default::default() }; + apply_color_replacement(s, &stroke, &rs, None, false); +} + +/// The stored L (0..100) and a, b (-128..127) at a pixel. +fn lab(s: &Surface, x: i32, y: i32) -> [f32; 3] { + let px = s.pixel(x, y); + [px[0] * 100.0, px[1] * 255.0 - 128.0, px[2] * 255.0 - 128.0] +} + +#[test] +fn color_keeps_l_and_takes_the_foregrounds_a_and_b() { + let fg = photocraft_color::convert::srgb_to_lab([0.0, 0.0, 1.0]); + for i in 0..COLOURS.len() { + let mut s = target(); + let x = i as i32 * 100 + 50; + let before = lab(&s, x, 50); + click(&mut s, ReplaceMode::Color, f64::from(x) + 0.5); + let after = lab(&s, x, 50); + assert!((after[0] - before[0]).abs() <= 1.0, "colour {i}: L {} → {}", before[0], after[0]); + assert!((after[1] - fg[1]).abs() <= 2.0 && (after[2] - fg[2]).abs() <= 2.0, "colour {i}: a, b {:?} vs the foreground's {:?}", &after[1..], &fg[1..]); + // Outside the tip nothing changed. + assert_eq!(lab(&s, x - 40, 50), before, "colour {i} outside the tip"); + } +} + +#[test] +fn hue_saturation_and_luminosity_keep_todays_blend() { + for mode in [ReplaceMode::Hue, ReplaceMode::Saturation, ReplaceMode::Luminosity] { + let mut s = target(); + let pre = s.pixel(150, 50).to_vec(); + click(&mut s, mode, 150.5); + // Today's path: through RGB, blended, and back. + let blend = match mode { + ReplaceMode::Hue => BlendMode::Hue, + ReplaceMode::Saturation => BlendMode::Saturation, + _ => BlendMode::Luminosity, + }; + let c = to_rgba(&LAB8, &pre); + let b = blend_rgb(blend, [c[0], c[1], c[2]], [0.0, 0.0, 1.0]); + let want = photocraft_raster::from_rgba(&LAB8, [b[0], b[1], b[2], c[3]]); + let got = s.pixel(150, 50); + for (g, w) in got.iter().zip(&want) { + assert!((g - w).abs() <= 1.0 / 255.0 + 1e-6, "{mode:?}: {got:?} vs {want:?}"); + } + } +} + +#[test] +fn lab_16_color_keeps_todays_blend() { + // Lab/16 wasn't measured: its Color mode still blends through RGB. + let lab16 = PixelFormat { sample: SampleType::U16, ..LAB8 }; + for i in 0..COLOURS.len() { + let mut s = target_in(lab16); + let x = i as i32 * 100 + 50; + let pre = s.pixel(x, 50).to_vec(); + click(&mut s, ReplaceMode::Color, f64::from(x) + 0.5); + let c = to_rgba(&lab16, &pre); + let b = blend_rgb(BlendMode::Color, [c[0], c[1], c[2]], [0.0, 0.0, 1.0]); + let want = photocraft_raster::from_rgba(&lab16, [b[0], b[1], b[2], c[3]]); + let got = s.pixel(x, 50); + for (g, w) in got.iter().zip(&want) { + assert!((g - w).abs() <= 1.0 / 65535.0 + 1e-6, "colour {i}: {got:?} vs {want:?}"); + } + } +} diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 133c0fa843c..494a4cb6a96 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -134,7 +134,10 @@ impl Manifest { return Err(Error::Manifest(format!("manifest is {} bytes (limit {MAX_MANIFEST_BYTES})", bytes.len()))); } let text = std::str::from_utf8(bytes).map_err(|_| Error::Manifest("manifest is not UTF-8".into()))?; - let man: Manifest = serde_json::from_str(text).map_err(|e| Error::Manifest(format!("manifest: {e}")))?; + let mut man: Manifest = serde_json::from_str(text).map_err(|e| Error::Manifest(format!("manifest: {e}")))?; + // The name is shown as stored, so the invisible padding at its ends (white space, line breaks, + // joiners, fillers) is dropped here. + man.name = trim_name(&man.name).to_owned(); man.validate()?; Ok(man) } @@ -143,9 +146,7 @@ impl Manifest { if !valid_id(&self.id) { return Err(Error::Manifest(format!("invalid id {:?}: use 1-64 characters from A-Z a-z 0-9 . _ -", self.id))); } - let name = self.name.trim(); - let printable = name.chars().all(|c| !c.is_control() && !hidden_format(c)) && name.chars().any(visible); - if !printable || name.chars().count() > 64 { + if !printable_name(&self.name) { return Err(Error::Manifest("`name` must be 1-64 printable characters".into())); } if self.version.len() > 32 || self.description.len() > 1024 || self.author.len() > 128 { @@ -374,18 +375,23 @@ const FORMAT: &[(char, char)] = &[ ('\u{E0020}', '\u{E007F}'), ]; -/// Default-ignorable characters outside Cf (fillers, variation selectors): allowed in a name, but they -/// draw nothing on their own. +/// Default-ignorable characters outside Cf (fillers, variation selectors, unassigned ignorables), and +/// the blank Braille pattern U+2800: allowed in a name, but they draw nothing on their own. const BLANK: &[(char, char)] = &[ ('\u{34F}', '\u{34F}'), ('\u{115F}', '\u{1160}'), ('\u{17B4}', '\u{17B5}'), ('\u{180B}', '\u{180D}'), ('\u{180F}', '\u{180F}'), + ('\u{2065}', '\u{2065}'), + ('\u{2800}', '\u{2800}'), ('\u{3164}', '\u{3164}'), ('\u{FE00}', '\u{FE0F}'), ('\u{FFA0}', '\u{FFA0}'), - ('\u{E0100}', '\u{E01EF}'), + ('\u{FFF0}', '\u{FFF8}'), + ('\u{E0000}', '\u{E0000}'), + ('\u{E0002}', '\u{E001F}'), + ('\u{E0080}', '\u{E0FFF}'), ]; fn in_ranges(c: char, ranges: &[(char, char)]) -> bool { @@ -399,6 +405,85 @@ fn hidden_format(c: char) -> bool { !matches!(c, '\u{200C}' | '\u{200D}') && in_ranges(c, FORMAT) } +/// Whether `name` (already trimmed) can be shown as a label: 1-64 characters, at least one of them +/// visible, and no control characters, line or paragraph separators, or hidden format characters. +pub fn printable_name(name: &str) -> bool { + name.chars().nth(64).is_none() + && name.chars().any(visible) + && !name.chars().any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') || hidden_format(c)) +} + +/// `name` without what pads its ends invisibly: white space, the zero-width (non-)joiners and the +/// characters that draw nothing on their own (fillers, blank Braille, unassigned ignorables, stray +/// selectors), so a name that looks like "Tone" is "Tone". What belongs to a character the name +/// keeps stays: a variation selector after it (the emoji in "Love ❤️"), and a Hangul filler that +/// is part of a syllable block (U+1160 after a leading consonant, U+115F before a vowel). +pub fn trim_name(name: &str) -> &str { + let pad = |c: char| c.is_whitespace() || matches!(c, '\u{200C}' | '\u{200D}') || in_ranges(c, BLANK); + let mut start = name.len(); + let mut chars = name.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + // The choseong filler opens a syllable before a vowel, and before the jungseong filler U+1160 + // when a final consonant follows (U+115F U+1160 + a final consonant is how a lone final + // consonant is written; U+115F U+1160 alone draws nothing). + let opens_syllable = c == '\u{115F}' + && chars.peek().is_some_and(|&(j, n)| { + vowel_jamo(n) || (n == '\u{1160}' && name.get(j + n.len_utf8()..).and_then(|r| r.chars().next()).is_some_and(trailing_jamo)) + }); + if !pad(c) || opens_syllable { + start = i; + break; + } + } + let s = name.get(start..).unwrap_or_default(); + let mut end = s.len(); + for (i, c) in s.char_indices().rev() { + if !pad(c) { + break; + } + end = i; + } + // Take back the mark that belongs to the last character kept: one per character, so a run of + // selectors after it is still padding. + let mut prev = s.get(..end).and_then(|h| h.chars().next_back()); + for c in s.get(end..).unwrap_or_default().chars() { + let belongs = match prev { + Some(p) => attaches(c) || (c == '\u{1160}' && leading_jamo(p)), + None => false, + }; + if !belongs { + break; + } + end += c.len_utf8(); + prev = if attaches(c) { None } else { Some(c) }; + } + s.get(..end).unwrap_or_default() +} + +/// The marks among [`BLANK`] that modify the character before them: the combining grapheme joiner, +/// the Khmer inherent vowels, the Mongolian free variation selectors and the variation selectors. +fn attaches(c: char) -> bool { + matches!( + c, + '\u{34F}' | '\u{17B4}' | '\u{17B5}' | '\u{180B}'..='\u{180D}' | '\u{180F}' | '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}' + ) +} + +/// A conjoining Hangul leading consonant (choseong), not the filler U+115F. +fn leading_jamo(c: char) -> bool { + matches!(c, '\u{1100}'..='\u{115E}' | '\u{A960}'..='\u{A97C}') +} + +/// A conjoining Hangul final consonant (jongseong). +fn trailing_jamo(c: char) -> bool { + matches!(c, '\u{11A8}'..='\u{11FF}' | '\u{D7CB}'..='\u{D7FB}') +} + +/// A conjoining Hangul vowel (jungseong) other than the vowel filler U+1160. +fn vowel_jamo(c: char) -> bool { + matches!(c, '\u{1161}'..='\u{11A7}' | '\u{D7B0}'..='\u{D7C6}') +} + /// A character that draws something on its own. fn visible(c: char) -> bool { !c.is_whitespace() && !c.is_control() && !in_ranges(c, FORMAT) && !in_ranges(c, BLANK) @@ -631,6 +716,14 @@ mod tests { "\u{3164}\u{FE0F}", " \u{3000} ", "Tone\u{7}", + "Tone\u{2028}Pro", + "Tone\u{2029}Pro", + "\u{2800}", + " \u{2800}\u{2800} ", + "\u{2065}", + "\u{FFF0}", + "\u{E0000}\u{E0002}", + "\u{E0080}\u{E0FFF}", ] { assert!(with_name(bad).is_err(), "{bad:?}"); } @@ -638,6 +731,35 @@ mod tests { for good in ["Tone…", "Kontrast ±", "می\u{200C}خواهم", "\u{1F469}\u{200D}\u{1F4BB} Code", "\u{2764}\u{FE0F} Love", "色调"] { assert_eq!(with_name(good).map(|m| m.name).ok().as_deref(), Some(good)); } + // White space and line breaks at the ends are dropped from the stored name, so the menu never + // shows them, and padding does not count towards the limit. + for (padded, stored) in [ + ("\nTone", "Tone"), + ("Tone\r\n", "Tone"), + ("\u{2028}Tone ", "Tone"), + (" Tone… ", "Tone…"), + ("Tone\u{2800}", "Tone"), + ("\u{3164}\u{2800}Tone\u{FFA0}", "Tone"), + ("\u{2764}\u{FE0F} Love\u{2800}", "\u{2764}\u{FE0F} Love"), + ("Tone\u{2065}\u{FFF0}", "Tone"), + ("\u{17B4}Tone", "Tone"), + // A selector right after the last kept character belongs to it and stays; one cut off by padding goes. + ("Love \u{2764}\u{FE0F}\u{2800}", "Love \u{2764}\u{FE0F}"), + ("Tone \u{FE0F}", "Tone"), + ("\u{FE0F}Tone", "Tone"), + ("Love \u{2764}\u{FE0F}\u{FE0F}\u{FE0F}", "Love \u{2764}\u{FE0F}"), + // Hangul filler jamo that complete a syllable are part of the name; the two fillers alone draw nothing. + ("\u{115F}\u{1160}Tone", "Tone"), + ("Tone \u{115F}\u{1160}", "Tone"), + ("Tone \u{1100}\u{1160}", "Tone \u{1100}\u{1160}"), + ("\u{115F}\u{1161} Tone", "\u{115F}\u{1161} Tone"), + ("\u{115F}\u{1160}\u{11A8} Tone", "\u{115F}\u{1160}\u{11A8} Tone"), + // Khmer inherent vowels belong to the consonant before them. + ("Tone \u{1780}\u{17B5}", "Tone \u{1780}\u{17B5}"), + ] { + assert_eq!(with_name(padded).map(|m| m.name).ok().as_deref(), Some(stored), "{padded:?}"); + } + assert_eq!(with_name(&format!("{}{}", "x".repeat(64), " ".repeat(1000))).map(|m| m.name.len()).ok(), Some(64)); // The documented limit counts characters, not bytes. assert!(with_name(&"é".repeat(64)).is_ok()); assert!(with_name(&"é".repeat(65)).is_err()); diff --git a/crates/raw/src/hasselblad.rs b/crates/raw/src/hasselblad.rs new file mode 100644 index 00000000000..1b9c7ec08ca --- /dev/null +++ b/crates/raw/src/hasselblad.rs @@ -0,0 +1,130 @@ +//! Hasselblad 3FR / FFF: a TIFF whose IFD0 holds the camera's JPEG preview and +//! whose CFA image sits in a SubIFD with DNG-style tags (BlackLevel, WhiteLevel, +//! DefaultCropOrigin / DefaultCropSize, AsShotNeutral, ColorMatrix1) but no +//! DNGVersion and no CFA-pattern tag — neither the TIFF/EP 33421/33422 pair nor +//! the EXIF one — so the Bayer phase comes from measurements of each body. +//! +//! Decoded today: the X2D II 100C's 16-bit uncompressed samples (Compression 1, +//! one strip covering the whole sensor, masked borders included; the crop to the +//! usable area comes from the file's DefaultCrop tags). Bodies that store +//! lossless-JPEG strips (Compression 7, the earlier X and H generations) are +//! recognised but not decoded yet. +//! +//! Clean-room: derived from the TIFF 6.0 and DNG specifications, observation of +//! sample files, and the camera's own embedded JPEG — the oracle the +//! camera-profile measurements in [`crate::cameras`] use. No code or tables +//! from dcraw, LibRaw, rawspeed, rawler or darktable. + +use crate::cr2::clip_level; +use crate::error::{RawError, Result}; +use crate::sensor::{BlackLevels, Cfa, JpegLayout, Rect, Sensor, read_plane}; +use crate::tiff::{Ifd, Tiff, tag}; +use crate::tiffep; +use crate::{Limits, RawFormat}; + +/// The Bayer colours at the data origin, row-major (0 red, 1 green, 2 blue). +/// Hasselblad records no CFA tag, so a body's phase is measured against its own +/// embedded JPEG: on an X2D II 100C dusk landscape the blue sky reads red at the +/// origin site at about half the green rate while the blue site holds three +/// quarters of it, the reverse of the warm ground. +fn cfa_for(model: Option<&str>) -> (Cfa, bool) { + let measured = model.is_some_and(|m| m.eq_ignore_ascii_case("X2D II 100C")); + (Cfa { width: 2, height: 2, colors: vec![0, 1, 1, 2], origin_x: 0, origin_y: 0 }, measured) +} + +pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { + let ifds = t.all_ifds(); + let Some(raw) = tiffep::raw_ifd(t, &ifds) else { + return Err(RawError::unsupported("Hasselblad 3FR/FFF: no CFA image found")); + }; + match t.tag_uint(&raw, tag::COMPRESSION).unwrap_or(1) { + 1 => {} + 7 => return Err(RawError::unsupported("lossless-JPEG (compressed 3FR/FFF)")), + c => return Err(RawError::unsupported(format!("raw compression scheme {c}"))), + } + let plane = read_plane(t, &raw, limits, JpegLayout::Flat)?; + if plane.samples != 1 { + return Err(RawError::unsupported(format!("CFA data with {} samples per pixel", plane.samples))); + } + let make = ifds.iter().find_map(|i| t.tag_ascii(i, tag::MAKE)); + let model = ifds.iter().find_map(|i| t.tag_ascii(i, tag::MODEL)); + let (cfa, measured) = cfa_for(model.as_deref()); + let mut warnings = Vec::new(); + if !measured { + warnings.push(format!( + "{}: this body's Bayer phase is not measured yet; assumed the X2D II 100C's (red at the data origin)", + model.as_deref().unwrap_or("Hasselblad") + )); + } + let full = Rect::new(0, 0, plane.width, plane.height); + // DNG-style ActiveArea (top, left, bottom, right) in the raw IFD; an absent, + // inverted or out-of-plane one leaves the whole plane active. + let active = match t.tag_uints(&raw, tag::ACTIVE_AREA).as_slice() { + [top, left, bottom, right] if bottom > top && right > left => { + Rect::new(*left as usize, *top as usize, (*right - *left) as usize, (*bottom - *top) as usize).intersect(&full) + } + _ => full, + }; + let active = if active.is_empty() { full } else { active }; + // DNG-style DefaultCropOrigin / DefaultCropSize in the raw IFD, relative to + // the active area as in DNG. + let mut crop = active; + if let ([ox, oy], [cw, ch]) = (t.tag_floats(&raw, tag::DEFAULT_CROP_ORIGIN).as_slice(), t.tag_floats(&raw, tag::DEFAULT_CROP_SIZE).as_slice()) { + let f = |v: f64| if v.is_finite() && v >= 0.0 { v.round() as usize } else { 0 }; + let r = Rect::new(active.x.saturating_add(f(*ox)), active.y.saturating_add(f(*oy)), f(*cw), f(*ch)).intersect(&active); + if !r.is_empty() { + crop = r; + } + } + let bl = t.tag_floats(&raw, tag::BLACK_LEVEL); + let black = match bl.as_slice() { + [b] if b.is_finite() && *b >= 0.0 => BlackLevels::uniform(*b as f32), + // A 2×2 repeat is in the CFA's own row-major order. + [r, g1, g2, b] if [*r, *g1, *g2, *b].iter().all(|v| v.is_finite() && *v >= 0.0) => { + BlackLevels { rows: 2, cols: 2, values: [r, g1, g2, b].map(|v| *v as f32).to_vec(), delta_h: Vec::new(), delta_v: Vec::new() } + } + _ => { + warnings.push("Hasselblad 3FR/FFF: black level not recorded; assumed 0".into()); + BlackLevels::uniform(0.0) + } + }; + let white = + t.tag_floats(&raw, tag::WHITE_LEVEL).first().copied().filter(|w| *w > 0.0).map(|w| w as f32).unwrap_or_else(|| clip_level(&plane.data, plane.bits)); + // AsShotNeutral (IFD0) is the camera-space colour of a neutral patch: the + // green-normalised multipliers are the other channels over green. + let camera_wb = ifds + .iter() + .find_map(|i: &Ifd| t.tag_floats(i, tag::AS_SHOT_NEUTRAL).try_into().ok()) + .and_then(|n: [f64; 3]| match n { + [r, g, b] if g > 0.0 && r > 0.0 && b > 0.0 => Some([g / r, 1.0, g / b]), + _ => None, + }) + .filter(|m| m.iter().all(|v| (0.25..8.0).contains(v))); + let ifd0 = t.ifd_at(t.first_ifd, 0); + let baseline_exposure = ifd0.as_ref().map(|i| t.tag_floats(i, tag::BASELINE_EXPOSURE).first().copied().unwrap_or(0.0)).unwrap_or(0.0); + let mut sensor = Sensor { + format: RawFormat::Fff, + make, + model, + width: plane.width, + height: plane.height, + samples: 1, + data: plane.data, + cfa: Some(cfa), + linearization: None, + black, + white: [white; 3], + active, + crop, + color: Default::default(), + camera_wb, + orientation: crate::tiff::orientation(ifd0.as_ref().and_then(|i| t.tag_uint(i, tag::ORIENTATION))), + baseline_exposure: if baseline_exposure.is_finite() { baseline_exposure.clamp(-10.0, 10.0) } else { 0.0 }, + gain_maps: Vec::new(), + tone_curve: Vec::new(), + black_point: 0.0, + warnings, + }; + crate::cameras::apply(&mut sensor, plane.bits); + Ok(sensor) +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 46bd3e4f60d..b5f241d5e22 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -33,8 +33,9 @@ //! lossless-JPEG TIFF/EP raws (NEF, ARW, PEF… when not vendor-compressed), //! Nikon compressed NEF (lossless and lossy), Sony compressed ARW (cRAW), //! Panasonic RW2 (RawFormat 5 packed and RawFormat 3/4 vendor-compressed), -//! uncompressed Olympus ORF and uncompressed Fujifilm RAF (Bayer and -//! X-Trans). Everything else reports [`RawError::Unsupported`]. +//! uncompressed Olympus ORF, uncompressed Fujifilm RAF (Bayer and +//! X-Trans), and Hasselblad 3FR/FFF (the X2D II 100C's uncompressed 16-bit CFA; +//! see [`crate::hasselblad`]). Everything else reports [`RawError::Unsupported`]. //! //! The crate is standalone (no workspace dependencies), does no I/O, builds for //! `wasm32-unknown-unknown` and never panics on hostile input: sizes are @@ -51,6 +52,7 @@ mod demosaic; mod develop; mod dng; mod error; +mod hasselblad; mod jxl; mod ljpeg; mod look; @@ -119,6 +121,8 @@ pub enum RawFormat { Rw2, /// Fujifilm RAF. Raf, + /// Hasselblad 3FR / FFF. + Fff, /// Another TIFF/EP raw. TiffEp, } @@ -135,13 +139,14 @@ impl RawFormat { RawFormat::Orf => "ORF", RawFormat::Rw2 => "RW2", RawFormat::Raf => "RAF", + RawFormat::Fff => "3FR/FFF", RawFormat::TiffEp => "TIFF/EP raw", } } } /// File extensions of the raw formats this crate recognises. -pub const EXTENSIONS: &[&str] = &["dng", "cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2", "pef", "orf", "rw2", "raf"]; +pub const EXTENSIONS: &[&str] = &["dng", "cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2", "pef", "orf", "rw2", "raf", "3fr", "fff"]; /// Decompression-bomb guards, checked on header values before allocating /// (the same shape and defaults as `photocraft-codecs`' limits). @@ -195,6 +200,8 @@ fn make_format(make: Option<&str>) -> RawFormat { RawFormat::Pef } else if m.starts_with("OLYMPUS") || m.starts_with("OM DIGITAL") { RawFormat::Orf + } else if m.starts_with("HASSELBLAD") { + RawFormat::Fff } else { RawFormat::TiffEp } @@ -249,6 +256,7 @@ pub fn decode(bytes: &[u8], limits: &Limits) -> Result { RawFormat::Cr2 => cr2::decode(&t, limits), RawFormat::Rw2 => rw2::decode(&t, limits), RawFormat::Orf => orf::decode(&t, limits), + RawFormat::Fff => hasselblad::decode(&t, limits), f => tiffep::decode(&t, f, limits), } } diff --git a/crates/raw/src/testgen.rs b/crates/raw/src/testgen.rs index 7d064593653..20ebba446fa 100644 --- a/crates/raw/src/testgen.rs +++ b/crates/raw/src/testgen.rs @@ -9,6 +9,7 @@ mod canon; mod dng; +mod hasselblad; mod lj92; mod orf; mod raf; @@ -17,6 +18,7 @@ mod tiff; pub use canon::{Cr2Spec, Cr2SrawSpec, craw_block, craw_encode, nef_compressed, sony_craw, tiff_ep}; pub use dng::{DngSpec, DngStorage, baseline_jpeg, gain_map_opcode_list, map_polynomial_opcode_list}; +pub use hasselblad::Hasselblad3frSpec; pub use lj92::{lj92_encode, lj92_encode_sraw, lj92_encode_sraw_tables, lj92_encode_tables}; pub use orf::orf; pub use raf::{RafPacking, RafSpec, XTRANS, mosaic, mosaic_cfa, scene}; diff --git a/crates/raw/src/testgen/hasselblad.rs b/crates/raw/src/testgen/hasselblad.rs new file mode 100644 index 00000000000..f2346781609 --- /dev/null +++ b/crates/raw/src/testgen/hasselblad.rs @@ -0,0 +1,106 @@ +use super::tiff::{TiffBuilder, Val, urat}; + +// ---------------------------------------------------------------- Hasselblad 3FR + +/// A synthetic Hasselblad 3FR, as the X2D II 100C writes it: IFD0 with Make, +/// Model and DNG-style AsShotNeutral, and the CFA image in a SubIFD — +/// `bits`-bit uncompressed samples with BlackLevel, WhiteLevel and DefaultCrop +/// tags and no CFA-pattern tags. `compression` and the optional tags exist for +/// the unsupported and hostile variants. +#[derive(Debug, Clone)] +pub struct Hasselblad3frSpec { + pub width: usize, + pub height: usize, + /// Row-major sensor samples. + pub data: Vec, + pub bits: u16, + pub compression: u16, + /// The BlackLevel RATIONAL; `None` leaves the tag out. + pub black: Option<(u32, u32)>, + /// The WhiteLevel; `None` leaves the tag out. + pub white: Option, + /// AsShotNeutral; `None` leaves the tag out. + pub as_shot_neutral: Option<[f64; 3]>, + /// DefaultCropOrigin (x, y) and DefaultCropSize (w, h); `None` leaves both out. + pub crop: Option<([u16; 2], [u16; 2])>, + /// ActiveArea (top, left, bottom, right); `None` leaves the tag out. + pub active_area: Option<[u32; 4]>, + pub make: String, + pub model: String, +} + +impl Hasselblad3frSpec { + /// An X2D II 100C-shaped file: 16-bit uncompressed, black 4096, white + /// 64763, the sample file's as-shot neutral, no crop. + pub fn new(width: usize, height: usize, data: Vec) -> Self { + Hasselblad3frSpec { + width, + height, + data, + bits: 16, + compression: 1, + black: Some((4096, 1)), + white: Some(64763), + as_shot_neutral: Some([0.375, 1.0, 0.617]), + crop: None, + active_area: None, + make: "Hasselblad".into(), + model: "X2D II 100C".into(), + } + } + + pub fn build(&self) -> Vec { + let mut t = TiffBuilder::default(); + let len = self.data.len() * 2; + let strip = t.blob(self.data.iter().flat_map(|&v| v.to_le_bytes()).collect()); + let mut raw = vec![ + (254, Val::Long(vec![0])), + (256, Val::Long(vec![self.width as u32])), + (257, Val::Long(vec![self.height as u32])), + (258, Val::Short(vec![self.bits])), + (259, Val::Short(vec![self.compression])), + (262, Val::Short(vec![32803])), + (273, Val::Blobs(vec![strip])), + (277, Val::Short(vec![1])), + (278, Val::Long(vec![self.height as u32])), + (279, Val::Long(vec![len as u32])), + ]; + if let Some((n, d)) = self.black { + raw.push((50714, Val::Rational(vec![(n, d)]))); + } + if let Some(w) = self.white { + raw.push((50717, Val::Short(vec![w]))); + } + if let Some((origin, size)) = &self.crop { + raw.push((50719, Val::Short(origin.to_vec()))); + raw.push((50720, Val::Short(size.to_vec()))); + } + if let Some(area) = self.active_area { + raw.push((50829, Val::Long(area.to_vec()))); + } + let raw_ifd = t.ifd(raw); + let thumb = t.blob(vec![128; 12]); + let mut ifd0 = vec![ + (254, Val::Long(vec![1])), + (256, Val::Long(vec![2])), + (257, Val::Long(vec![2])), + (258, Val::Short(vec![8, 8, 8])), + (259, Val::Short(vec![7])), + (262, Val::Short(vec![6])), + (271, Val::Ascii(self.make.clone())), + (272, Val::Ascii(self.model.clone())), + (273, Val::Blobs(vec![thumb])), + (277, Val::Short(vec![3])), + (278, Val::Long(vec![2])), + (279, Val::Long(vec![12])), + (330, Val::Ifds(vec![raw_ifd])), + (50708, Val::Ascii(format!("{} {}", self.make, self.model))), + ]; + if let Some(n) = self.as_shot_neutral { + ifd0.push((50728, Val::Rational(n.iter().map(|&v| urat(v)).collect()))); + } + let i0 = t.ifd(ifd0); + t.chain = vec![i0]; + t.build() + } +} diff --git a/crates/raw/src/tiffep.rs b/crates/raw/src/tiffep.rs index fe0ea80a943..3a12f8eff98 100644 --- a/crates/raw/src/tiffep.rs +++ b/crates/raw/src/tiffep.rs @@ -28,7 +28,7 @@ const NIKON_BLACK_LEVEL: u16 = 0x003D; const SONY_BLACK_LEVEL: u16 = 0x7310; const SONY_WB_RGGB_LEVELS: u16 = 0x7313; -fn raw_ifd(t: &Tiff, ifds: &[Ifd]) -> Option { +pub(crate) fn raw_ifd(t: &Tiff, ifds: &[Ifd]) -> Option { ifds.iter() .filter(|i| t.tag_uint(i, tag::PHOTOMETRIC) == Some(PHOTOMETRIC_CFA)) .max_by_key(|i| u64::from(t.tag_uint(i, tag::IMAGE_WIDTH).unwrap_or(0)) * u64::from(t.tag_uint(i, tag::IMAGE_LENGTH).unwrap_or(0))) diff --git a/crates/raw/tests/it/hasselblad.rs b/crates/raw/tests/it/hasselblad.rs new file mode 100644 index 00000000000..8bdf5c29572 --- /dev/null +++ b/crates/raw/tests/it/hasselblad.rs @@ -0,0 +1,143 @@ +//! Synthetic Hasselblad 3FR files (the X2D II 100C layout): identification, +//! exact samples, metadata, the DefaultCrop area, and the unsupported and +//! hostile variants. + +use photocraft_raw::testgen::{Hasselblad3frSpec, mosaic, scene}; +use photocraft_raw::*; + +const RGGB: [u8; 4] = [0, 1, 1, 2]; + +fn spec() -> Hasselblad3frSpec { + let (w, h) = (16, 12); + Hasselblad3frSpec::new(w, h, mosaic(&scene(w, h), w, RGGB, 4096, 64763)) +} + +#[test] +fn identifies_as_fff() { + let b = spec().build(); + assert_eq!(identify(&b), Some(RawFormat::Fff)); + assert!(is_raw(&b)); + assert!(EXTENSIONS.contains(&"3fr") && EXTENSIONS.contains(&"fff")); +} + +#[test] +fn decodes_exactly() { + let s = spec(); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!((sensor.width, sensor.height), (16, 12)); + assert_eq!(sensor.samples, 1); + assert_eq!(sensor.data, s.data, "the samples are the stored strip"); + let cfa = sensor.cfa.as_ref().unwrap(); + assert_eq!((cfa.width, cfa.height), (2, 2)); + for (i, &c) in RGGB.iter().enumerate() { + assert_eq!(cfa.color(i % 2, i / 2), c, "position {i}"); + } + assert!(cfa.is_bayer()); + assert_eq!(sensor.format, RawFormat::Fff); +} + +#[test] +fn metadata() { + let mut s = spec(); + s.crop = Some(([2, 2], [12, 8])); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!(sensor.crop, Rect::new(2, 2, 12, 8)); + assert_eq!(sensor.active, Rect::new(0, 0, 16, 12)); + assert_eq!(sensor.black, BlackLevels::uniform(4096.0)); + assert_eq!(sensor.white, [64763.0; 3]); + let wb = sensor.camera_wb.unwrap(); + assert!((wb[0] - 1.0 / 0.375).abs() < 1e-9, "{wb:?}"); + assert!((wb[2] - 1.0 / 0.617).abs() < 1e-9, "{wb:?}"); + assert_eq!((sensor.make.as_deref(), sensor.model.as_deref()), (Some("Hasselblad"), Some("X2D II 100C"))); + assert_eq!(sensor.orientation, 1); + assert!(sensor.warnings.is_empty(), "{:?}", sensor.warnings); +} + +#[test] +fn develops() { + let mut s = spec(); + s.crop = Some(([2, 2], [12, 8])); + let b = s.build(); + for m in [Demosaic::Bilinear, Demosaic::Mhc, Demosaic::Ahd] { + let d = develop(&b, &DevelopOptions { demosaic: m, ..Default::default() }).unwrap_or_else(|e| panic!("{m:?}: {e}")); + assert_eq!((d.width, d.height), (12, 8), "{m:?}"); + } +} + +#[test] +fn unknown_body_warns_about_the_assumed_phase() { + let mut s = spec(); + s.model = "X2D 100C".into(); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert!(sensor.warnings.iter().any(|w| w.contains("Bayer phase")), "{:?}", sensor.warnings); +} + +#[test] +fn compressed_3fr_is_reported_unsupported() { + let mut s = spec(); + s.compression = 7; + let e = decode(&s.build(), &Limits::default()).unwrap_err(); + assert!(matches!(&e, RawError::Unsupported(reason) if reason.contains("lossless-JPEG")), "{e:?}"); +} + +#[test] +fn missing_levels_fall_back() { + let mut s = spec(); + // Clipped highlights, so the clipping point stands in for the missing WhiteLevel. + for v in s.data.iter_mut().step_by(8) { + *v = u16::MAX; + } + s.black = None; + s.white = None; + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!(sensor.black, BlackLevels::uniform(0.0)); + assert!(sensor.warnings.iter().any(|w| w.contains("black level")), "{:?}", sensor.warnings); + // clip_level backs the clipped plateau off slightly. + assert!((sensor.white[0] - f32::from(u16::MAX) * 0.995).abs() < 1.0, "{:?}", sensor.white); +} + +#[test] +fn a_crop_outside_the_plane_is_dropped() { + let mut s = spec(); + s.crop = Some(([100, 100], [4, 4])); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!(sensor.crop, Rect::new(0, 0, 16, 12)); +} + +#[test] +fn active_area_bounds_the_image_and_the_crop() { + let mut s = spec(); + s.active_area = Some([1, 2, 11, 14]); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!(sensor.active, Rect::new(2, 1, 12, 10)); + assert_eq!(sensor.crop, Rect::new(2, 1, 12, 10), "no default crop: the active area"); + let d = develop(&s.build(), &DevelopOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (12, 10)); + // The default crop is relative to the active area, as in DNG. + s.crop = Some(([1, 1], [8, 6])); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!(sensor.crop, Rect::new(3, 2, 8, 6)); +} + +#[test] +fn an_active_area_outside_the_plane_is_ignored() { + let mut s = spec(); + s.active_area = Some([100, 100, 200, 200]); + let sensor = decode(&s.build(), &Limits::default()).unwrap(); + assert_eq!((sensor.active, sensor.crop), (Rect::new(0, 0, 16, 12), Rect::new(0, 0, 16, 12))); +} + +#[test] +fn truncated_strip_is_malformed() { + let mut b = spec().build(); + // The preview blob sits past the strip; cutting 60 bytes eats into the strip. + b.truncate(b.len() - 60); + assert!(matches!(decode(&b, &Limits::default()), Err(RawError::Malformed(_)))); +} + +#[test] +fn zero_dimensions_are_malformed() { + let mut s = spec(); + s.width = 0; + assert!(matches!(decode(&s.build(), &Limits::default()), Err(RawError::Malformed(_)))); +} diff --git a/crates/raw/tests/it/main.rs b/crates/raw/tests/it/main.rs index 27a1af788bc..666514c5940 100644 --- a/crates/raw/tests/it/main.rs +++ b/crates/raw/tests/it/main.rs @@ -3,6 +3,7 @@ mod cr3; mod development_limits; mod dng; +mod hasselblad; mod hostile; mod nef_packed; mod nikon_d4_depth; diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 416ae7834cf..489f2eef5ee 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -295,6 +295,12 @@ impl FontDb { self.register_blob(Blob::new(Arc::new(bytes)), None) } + /// [`Self::register_font_data`] with the family name overridden, so an embedded subset can be + /// registered under a name that cannot shadow an installed full family. Returns the names added. + pub fn register_font_data_as(&mut self, bytes: Vec, family: &str) -> Vec { + self.register_blob(Blob::new(Arc::new(bytes)), Some(family)) + } + /// Registers embedded font data without copying it. fn register_static_font(&mut self, bytes: &'static [u8]) -> Vec { self.register_blob(Blob::new(Arc::new(bytes)), None) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 4a4219e143b..75f3348b08e 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -142,6 +142,22 @@ impl TextEngine { layer.cache = Some(r.surface); r.rect } + + /// Registers font bytes (for example a font embedded in an imported PDF) for later layout. + /// Returns the family names the font provides, best first. + pub fn register_font_data(&mut self, bytes: Vec) -> Vec { + self.fonts.register_font_data(bytes) + } + + /// [`Self::register_font_data`] under `family`, which cannot shadow an installed family. + pub fn register_font_data_as(&mut self, bytes: Vec, family: &str) -> Vec { + self.fonts.register_font_data_as(bytes, family) + } + + /// Whether `family` is available to this engine. + pub fn has_family(&mut self, family: &str) -> bool { + self.fonts.has_family(family) + } } static SHARED: warm::Warm> = warm::Warm::new(); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 24a6300d04e..834bcfc5dd0 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -133,6 +133,7 @@ impl PhotocraftApp { synthetic_reinject: false, synthetic_snapshot: None, automation_input: false, + programmatic_playback: false, run_depth: 0, paused_playbacks: Default::default(), channel_thumbs: None, @@ -169,7 +170,7 @@ impl PhotocraftApp { distort: Default::default(), gradient: Default::default(), camera_raw: None, - pending_raw_open: None, + raw_shelf: Vec::new(), raw_redevelop: Vec::new(), wide_angle: None, dialog_edits: Default::default(), @@ -208,6 +209,8 @@ impl PhotocraftApp { held_memory_prompts: Default::default(), prompt_order: Default::default(), held_preset_import: None, + held_record_again: None, + held_insert_menu: None, allow_close: false, stylus: Default::default(), control_click: Default::default(), @@ -224,7 +227,6 @@ impl PhotocraftApp { histogram_panel::restore_options(&mut app); // After the saved preferences, which say whether the set was already offered. kys_import::offer_import(&mut app); - notices::wayland_file_drop_guidance(&mut app); // File › Scripts › Script Events Manager: "Start Application". photocraft_engine::automate_cmds::fire_event(&mut app.session, "startApplication"); app @@ -234,7 +236,7 @@ impl PhotocraftApp { impl eframe::App for PhotocraftApp { fn on_exit(&mut self) { if let Err(err) = tool_memory::save_on_exit(self) { - log::warn!("Preferences: could not save the final tool selection: {err}"); + log::warn!("Preferences: could not save the final tool state: {err}"); } // Release a pending screenshot's image budget and answer its control request; the // callback is inert afterwards (it holds only a weak slot). diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index ab3654e4ce3..6ad7c9fdf4f 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -6,6 +6,7 @@ use super::{ impl PhotocraftApp { /// Run an engine command, reporting errors in the status bar. pub fn run(&mut self, id: &str, params: Value) -> Result { + let params = if id == "select.findLayers" { crate::layer_filter_ui::find::params_for_run(self, params) } else { params }; if let Some(result) = delete_layer_prompt::intercept(self, id, ¶ms) { return result; } @@ -31,6 +32,13 @@ impl PhotocraftApp { } self.sync_mask_targets(); } + // Find Layers sets the Layers panel's Name filter however it runs: the menu, the shortcut, + // `engine.execute` or an action step (#10884). + if id == "select.findLayers" + && let Ok(reply) = &result + { + crate::layer_filter_ui::find::apply(self, reply); + } result } @@ -264,6 +272,7 @@ impl PhotocraftApp { | "path.toSelection" | "path.addToSelection" | "path.subtractFromSelection" + | "path.intersectWithSelection" ) && self.session.active().is_some_and(|st| st.doc.selection.is_some()) { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 26ac96f722e..82648956657 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -137,6 +137,7 @@ impl PhotocraftApp { order, question, policy_applied: false, + smart_object: false, }, ); Ok(warnings) diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 3908d3888fc..dafe8a25efc 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -121,7 +121,7 @@ impl PhotocraftApp { } let dropped = ctx.input(|i| i.raw.dropped_files.clone()); if !dropped.is_empty() { - let at = self.services.cursor_pos.as_mut().and_then(|f| f(ctx)); + let at = self.cursor_pos(ctx); self.open_dropped(ctx, dropped, at); // Files dragged in from another app's window: PhotoCraft comes to the front and takes // the keyboard, as Photoshop does, instead of staying behind the source (#5687). diff --git a/crates/ui-egui/src/background_eraser_alt_click_tests.rs b/crates/ui-egui/src/background_eraser_alt_click_tests.rs new file mode 100644 index 00000000000..aa7adbcc0cb --- /dev/null +++ b/crates/ui-egui/src/background_eraser_alt_click_tests.rs @@ -0,0 +1,207 @@ +//! ⌥ (Alt) with the Background Eraser is the Eyedropper, as with the Brush (#11906): a click or +//! drag sets the foreground colour, adds no history state and changes no pixel, and the cursor +//! is the pipette while ⌥ is held. Control+⌥ stays the brush-resize gesture. + +use egui::{Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::state::Tool; + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; +const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0]; +const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; + +fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(900.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [100.0, 50.0]; + v.fit_pending = false; + h.run_steps(2); + h +} + +fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { + let app = h.state(); + let v = &app.ui.views[0]; + let xf = ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + }; + xf.to_screen(x, y) +} + +fn mods(h: &mut Harness<'static, PhotocraftApp>, m: Modifiers) { + h.event(egui::Event::ModifiersChanged(m)); + h.run_steps(1); +} + +fn button(h: &mut Harness<'static, PhotocraftApp>, b: PointerButton, p: Pos2, down: bool, m: Modifiers) { + h.event(egui::Event::PointerButton { pos: p, button: b, pressed: down, modifiers: m }); + h.run_steps(1); +} + +/// Press `b` at document point `from` with `m` held from before the press, and move to `to`; +/// the button stays down. +fn press_and_move(h: &mut Harness<'static, PhotocraftApp>, b: PointerButton, from: [f32; 2], to: [f32; 2], m: Modifiers) { + let p = screen(h, from[0], from[1]); + mods(h, m); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + button(h, b, p, true, m); + if from != to { + for i in 1..=8 { + let t = i as f32 / 8.0; + let q = screen(h, from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t); + h.event(egui::Event::PointerMoved(q)); + h.run_steps(1); + } + } +} + +/// Release `b` at document point `at`, then let go of the modifiers. +fn release(h: &mut Harness<'static, PhotocraftApp>, b: PointerButton, at: [f32; 2], m: Modifiers) { + button(h, b, screen(h, at[0], at[1]), false, m); + h.run_steps(2); + mods(h, Modifiers::NONE); +} + +/// A drag between document points with `m` held from before the press until after the +/// release; `from == to` is a click. +fn drag(h: &mut Harness<'static, PhotocraftApp>, from: [f32; 2], to: [f32; 2], m: Modifiers) { + press_and_move(h, PointerButton::Primary, from, to, m); + release(h, PointerButton::Primary, to, m); +} + +/// Red on the left, green on the right of an unlocked layer, Background Eraser active, blue +/// foreground and white background colour. +fn background_eraser() -> 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": "white"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 0, "width": 100, "height": 100})).unwrap(); + app.run("edit.fill", json!({"color": "#00ff00"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.run("tools.setColors", json!({"foreground": BLUE, "background": [1.0, 1.0, 1.0, 1.0]})).unwrap(); + app.sync_views(); + let mut h = harness(app); + h.state_mut().ui.tool = Tool::BackgroundEraser; + h.run_steps(1); + h +} + +/// The document and the number of history states, to show nothing changed. +fn snapshot(h: &Harness<'static, PhotocraftApp>) -> (std::sync::Arc, usize) { + let st = h.state().session.active().unwrap(); + (st.doc.clone(), st.history.past_len()) +} + +fn unchanged(h: &Harness<'static, PhotocraftApp>, before: &(std::sync::Arc, usize), what: &str) { + let (doc, steps) = snapshot(h); + assert!(std::sync::Arc::ptr_eq(&doc, &before.0), "{what}: no pixel changed"); + assert_eq!(steps, before.1, "{what}: no history state"); +} + +#[test] +fn alt_click_sets_the_foreground_without_erasing() { + let mut h = background_eraser(); + let before = snapshot(&h); + drag(&mut h, [150.0, 50.0], [150.0, 50.0], Modifiers::ALT); + assert_eq!(h.state().session.tools.foreground, GREEN, "⌥-click picked green"); + assert_eq!(h.state().session.tools.background, [1.0, 1.0, 1.0, 1.0], "the background colour stays"); + unchanged(&h, &before, "⌥-click"); + assert!(!h.state().alt_sampling, "the release ends the sampling"); +} + +#[test] +fn alt_drag_follows_the_pointer_without_erasing() { + let mut h = background_eraser(); + let before = snapshot(&h); + press_and_move(&mut h, PointerButton::Primary, [150.0, 50.0], [40.0, 50.0], Modifiers::ALT); + assert_eq!(h.state().session.tools.foreground, RED, "⌥-drag samples while the button is held"); + release(&mut h, PointerButton::Primary, [40.0, 50.0], Modifiers::ALT); + assert_eq!(h.state().session.tools.foreground, RED, "⌥-drag ends on red"); + unchanged(&h, &before, "⌥-drag"); +} + +/// The same press without ⌥ still erases: the harness drives the real tool. +#[test] +fn a_click_without_alt_still_erases() { + let mut h = background_eraser(); + let (_, steps) = snapshot(&h); + drag(&mut h, [150.0, 50.0], [150.0, 50.0], Modifiers::NONE); + assert_eq!(h.state().session.tools.foreground, BLUE); + assert_eq!(snapshot(&h).1, steps + 1, "the stroke added a history state"); + let st = h.state().session.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + assert_eq!(layer.rgba(150, 50)[3], 0.0, "the clicked pixel was erased"); +} + +/// While ⌥ is held the cursor is the Eyedropper's pipette (`pipette_cursor`), where the +/// Background Eraser otherwise shows its own tip; Control+⌥ keeps the tip. +#[test] +fn alt_shows_the_eyedropper_cursor() { + let mut h = background_eraser(); + let p = screen(&h, 150.0, 50.0); + let pipette = |h: &mut Harness<'static, PhotocraftApp>, m: Modifiers| { + mods(h, m); + h.hover_at(p); + h.run_steps(2); + let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("pc-icon-cursor")); + let shown = h.ctx.memory(|memory| memory.areas().visible_layer_ids().contains(&layer)); + (shown, h.output().platform_output.cursor_icon) + }; + assert_eq!(pipette(&mut h, Modifiers::ALT), (true, egui::CursorIcon::None), "⌥: the pipette replaces the pointer"); + assert!(!pipette(&mut h, Modifiers::NONE).0, "without ⌥: the eraser's own tip"); + assert!(!pipette(&mut h, Modifiers::ALT | Modifiers::CTRL).0, "Control+⌥ is not the Eyedropper"); + assert!(pipette(&mut h, Modifiers::ALT).0, "⌥ again: the pipette again"); +} + +/// Control+⌥-drag stays the brush-resize gesture: it samples nothing and erases nothing. +#[test] +fn ctrl_alt_drag_resizes_instead_of_sampling() { + assert!(crate::canvas::alt_samples(Tool::BackgroundEraser, Modifiers::ALT)); + assert!(!crate::canvas::alt_samples(Tool::BackgroundEraser, Modifiers::ALT | Modifiers::CTRL)); + let mut h = background_eraser(); + h.state_mut().run("tools.setBrush", json!({"brush": {"size": 40}})).unwrap(); + let before = snapshot(&h); + drag(&mut h, [150.0, 50.0], [40.0, 50.0], Modifiers::ALT | Modifiers::CTRL); + assert_eq!(h.state().session.tools.foreground, BLUE, "Control+⌥ sampled nothing"); + unchanged(&h, &before, "Control+⌥-drag"); + assert_ne!(h.state().session.tools.brush.size, 40.0, "Control+⌥-drag resized the brush"); +} + +/// ⌥+right-drag (not measured in the reference application) keeps today's meaning, the brush +/// resize: it samples nothing and erases nothing. +#[test] +fn alt_right_drag_resizes_instead_of_sampling() { + let mut h = background_eraser(); + h.state_mut().run("tools.setBrush", json!({"brush": {"size": 40}})).unwrap(); + let before = snapshot(&h); + press_and_move(&mut h, PointerButton::Secondary, [150.0, 50.0], [40.0, 50.0], Modifiers::ALT); + release(&mut h, PointerButton::Secondary, [40.0, 50.0], Modifiers::ALT); + assert_eq!(h.state().session.tools.foreground, BLUE, "⌥+right-drag sampled nothing"); + unchanged(&h, &before, "⌥+right-drag"); + assert_ne!(h.state().session.tools.brush.size, 40.0, "⌥+right-drag resized the brush"); +} diff --git a/crates/ui-egui/src/background_eraser_brush_popup_tests.rs b/crates/ui-egui/src/background_eraser_brush_popup_tests.rs new file mode 100644 index 00000000000..3c0dbd2b541 --- /dev/null +++ b/crates/ui-egui/src/background_eraser_brush_popup_tests.rs @@ -0,0 +1,138 @@ +//! #11910: with the Background Eraser, the options-bar brush picker and a canvas right-click open +//! a compact popup (Size, Hardness, Spacing, the angle/roundness widget, Angle, Roundness, the +//! "Size:" and "Tolerance:" pen dropdowns with the tablet warning) and no preset list; other tools +//! keep the Brush Preset picker. + +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use super::*; +use crate::PhotocraftApp; +use crate::state::Tool; + +fn app(tool: Tool) -> 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(); + app.run("edit.fill", json!({"color": "#dc1e1e"})).unwrap(); + app.ui.tool = tool; + app.sync_views(); + app +} + +/// The canvas with the picker open as the brush chip opens it. +fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::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; + } + crate::paint_mouse::sync_tool_brush(app); + 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 open(h: &mut Harness<'static, PhotocraftApp>) { + let c = h.state().last_canvas_rect.center(); + let ctx = h.ctx.clone(); + crate::brush_picker::open(&mut h.state_mut().ui, &ctx, [c.x, c.y]); + h.run_steps(3); +} + +fn top(h: &Harness<'static, PhotocraftApp>, label: &str) -> f32 { + h.get_by_label(label).rect().top() +} + +#[test] +fn the_popup_has_the_controls_in_order_and_no_presets() { + let mut h = harness(app(Tool::BackgroundEraser)); + open(&mut h); + let order = ["Size", "Hardness", "Spacing", "Angle", "Roundness", "Size:", "Tolerance:"]; + let tops: Vec = order + .iter() + .map(|l| match *l { + // The Size field, the dropdowns by their combo boxes. + "Size" => h.get_all_by_role(Role::SpinButton).next().expect("the Size field").rect().top(), + "Size:" | "Tolerance:" => h.get_all_by_role(Role::ComboBox).find(|n| n.accesskit_node().label().as_deref() == Some(*l)).expect(l).rect().top(), + "Hardness" | "Spacing" => h.query_all_by_label(l).next().expect(l).rect().top(), + _ => top(&h, l), + }) + .collect(); + assert!(tops.windows(2).all(|w| w[0] <= w[1]), "top to bottom {order:?}: {tops:?}"); + assert!(h.query_by_label(PRESSURE_WARNING).is_some(), "the warning triangle beside Tolerance:"); + assert!(h.query_by_label("Search Brushes").is_none() && h.query_by_label("Brush Preset Options").is_none(), "no presets, search or gear"); +} + +#[test] +fn the_defaults_are_the_references() { + let app = app(Tool::BackgroundEraser); + assert_eq!(app.ui.tool_options.bg_tolerance_control, PEN_PRESSURE, "Tolerance: Pen Pressure by default"); + assert_eq!(size_control(&app.session.tools.brush), OFF, "Size: Off"); + let mut h = harness(app); + open(&mut h); + let value = |l: &str| { + h.get_all_by_role(Role::ComboBox) + .find(|n| n.accesskit_node().label().as_deref() == Some(l)) + .and_then(|n| n.accesskit_node().value()) + .unwrap_or_default() + }; + assert_eq!(value("Size:"), "Off"); + assert_eq!(value("Tolerance:"), "Pen Pressure"); +} + +#[test] +fn tolerance_is_stored_with_the_tool_and_size_with_its_brush() { + let mut h = harness(app(Tool::BackgroundEraser)); + open(&mut h); + h.get_all_by_role(Role::ComboBox).find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")).unwrap().click(); + h.run_steps(2); + h.get_all_by_label("Off").last().expect("the Off entry").click(); + h.run_steps(3); + assert_eq!(h.state().ui.tool_options.bg_tolerance_control, OFF); + h.get_all_by_role(Role::ComboBox).find(|n| n.accesskit_node().label().as_deref() == Some("Size:")).unwrap().click(); + h.run_steps(2); + h.get_all_by_label("Pen Pressure").last().expect("the Pen Pressure entry").click(); + h.run_steps(3); + assert!(h.state().session.tools.brush.pressure_size, "Size: Pen Pressure is the brush's pressure for size"); + // The mapping both ways. + let mut b = BrushSettings::default(); + for c in [STYLUS_WHEEL, PEN_PRESSURE, OFF] { + set_size_control(&mut b, c); + assert_eq!(size_control(&b), c); + } +} + +#[test] +fn escape_closes_and_a_right_click_erases_nothing() { + let mut h = harness(app(Tool::BackgroundEraser)); + let steps = h.state().session.active().unwrap().history.past_len(); + let c = h.state().last_canvas_rect.center(); + h.event(egui::Event::PointerMoved(c)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + } + assert!(h.state().ui.brush_picker.is_some(), "the right-click opens the popup"); + assert!(h.query_all_by_label("Tolerance:").next().is_some(), "the compact popup, not the preset picker"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps, "nothing erased"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().ui.brush_picker, None); +} + +#[test] +fn other_tools_keep_the_brush_preset_picker() { + let mut h = harness(app(Tool::Brush)); + open(&mut h); + assert!(h.query_by_label("Brush Preset Options").is_some(), "the Brush's picker with its gear"); + assert!(h.query_by_label(PRESSURE_WARNING).is_none()); +} diff --git a/crates/ui-egui/src/background_eraser_digit_keys_tests.rs b/crates/ui-egui/src/background_eraser_digit_keys_tests.rs new file mode 100644 index 00000000000..9cddb5a7eb3 --- /dev/null +++ b/crates/ui-egui/src/background_eraser_digit_keys_tests.rs @@ -0,0 +1,125 @@ +//! #11907: with the Background Eraser active, the number keys set its Tolerance as they set Opacity +//! for the painting tools (3 = 30%, 0 = 100%, 5 then 5 typed quickly = 55%), as in the reference +//! application. Other tools' digits are unchanged. + +use egui::{Event, Key, Modifiers}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::opacity_keys::press; +use crate::state::Tool; + +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.ui.tool = tool; + app +} + +fn tolerance(app: &PhotocraftApp) -> f32 { + app.ui.tool_options.bg_tolerance +} + +fn layer(app: &PhotocraftApp) -> (f32, f32) { + let st = app.session.active().unwrap(); + let l = st.doc.layer(st.active_layer.unwrap()).unwrap(); + (l.opacity, l.fill_opacity) +} + +#[test] +fn single_digits_set_the_tolerance() { + let mut a = app(Tool::BackgroundEraser); + assert_eq!(tolerance(&a), 50.0, "default"); + press(&mut a, 3, false, 0.0); + assert_eq!(tolerance(&a), 30.0); + press(&mut a, 0, false, 5000.0); + assert_eq!(tolerance(&a), 100.0, "0 = 100%"); + press(&mut a, 1, false, 10000.0); + assert_eq!(tolerance(&a), 10.0); +} + +#[test] +fn two_quick_digits_set_an_exact_tolerance() { + let mut a = app(Tool::BackgroundEraser); + press(&mut a, 5, false, 0.0); + press(&mut a, 5, false, 300.0); + assert_eq!(tolerance(&a), 55.0); + // A slow second digit starts over. + press(&mut a, 4, false, 5000.0); + press(&mut a, 2, false, 9000.0); + assert_eq!(tolerance(&a), 20.0); +} + +#[test] +fn the_tolerance_is_shown_in_ui_inspect() { + let mut a = app(Tool::BackgroundEraser); + press(&mut a, 3, false, 0.0); + let options = serde_json::to_value(&a.ui.tool_options).unwrap(); + assert_eq!(options["bg_tolerance"], json!(30.0)); +} + +#[test] +fn background_eraser_digits_leave_the_brush_and_the_layer_alone() { + let mut a = app(Tool::BackgroundEraser); + let brush = a.session.tools.brush.clone(); + press(&mut a, 7, false, 0.0); + assert_eq!(a.session.tools.brush, brush); + assert_eq!(layer(&a), (1.0, 1.0)); + assert_eq!(a.ui.tool_options.fill_opacity, 100.0); +} + +#[test] +fn unmeasured_shift_digits_and_double_zero_keep_the_tolerance() { + let mut a = app(Tool::BackgroundEraser); + press(&mut a, 3, true, 0.0); + assert_eq!(tolerance(&a), 50.0, "⇧3 changes nothing"); + press(&mut a, 0, false, 5000.0); + press(&mut a, 0, false, 5100.0); + assert_eq!(tolerance(&a), 100.0, "00 keeps the 100% of the first 0"); +} + +#[test] +fn other_tools_digits_are_unchanged() { + for tool in [Tool::Brush, Tool::Pencil, Tool::Eraser] { + let mut a = app(tool); + press(&mut a, 3, false, 0.0); + assert!((a.session.tools.brush.opacity - 0.3).abs() < 1e-6, "{tool:?}"); + assert_eq!(tolerance(&a), 50.0, "{tool:?}"); + } + for tool in [Tool::Gradient, Tool::PaintBucket] { + let mut a = app(tool); + press(&mut a, 3, false, 0.0); + assert_eq!(a.ui.tool_options.fill_opacity, 30.0, "{tool:?}"); + assert_eq!(tolerance(&a), 50.0, "{tool:?}"); + } + let mut a = app(Tool::Move); + press(&mut a, 3, false, 0.0); + assert!((layer(&a).0 - 0.3).abs() < 1e-6); + assert_eq!(tolerance(&a), 50.0); + // The tools whose number is a strength, exposure or tolerance of their own still ignore the digits. + for tool in [Tool::ColorReplacement, Tool::Dodge, Tool::Smudge, Tool::MagicEraser] { + let mut a = app(tool); + let before = (a.session.tools.brush.clone(), layer(&a), a.ui.tool_options.clone()); + press(&mut a, 3, false, 0.0); + assert_eq!((a.session.tools.brush.clone(), layer(&a), a.ui.tool_options.clone()), before, "{tool:?}"); + } +} + +/// Through the whole app: number keys typed over the canvas (no widget focused). +#[test] +fn number_keys_set_the_tolerance_in_the_app() { + use egui_kittest::Harness; + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app(Tool::BackgroundEraser) + }); + h.run_steps(4); + let key = |h: &mut Harness<'_, PhotocraftApp>, k: Key| { + h.event(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.event(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + }; + key(&mut h, Key::Num3); + assert_eq!(tolerance(h.state()), 30.0); +} diff --git a/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs b/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs new file mode 100644 index 00000000000..acf9d3de159 --- /dev/null +++ b/crates/ui-egui/src/background_eraser_tolerance_field_tests.rs @@ -0,0 +1,310 @@ +//! #11909: the Background Eraser's Tolerance takes whole numbers 1..100; a value typed past the +//! range becomes the closest one and, once the field is left, an alert with OK says so. +//! `paint.backgroundEraser` clamps `tolerance` to 1..100 too, and the options bar's controls carry +//! the reference application's tooltips. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::eraser_ui::TOLERANCE_RANGE_ALERT; +use crate::state::{DialogKind, Tool}; + +fn app_harness() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1600.0, 900.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": 200, "height": 150})).unwrap(); + app.ui.tool = Tool::BackgroundEraser; + app + }); + h.run_steps(5); + h +} + +fn bar(tool: Tool) -> Harness<'static, PhotocraftApp> { + 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| crate::panels::options_bar(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h +} + +/// Types `text` into the Tolerance field, then Tabs out of it. +fn type_tolerance(h: &mut Harness<'static, PhotocraftApp>, text: &str) { + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field") + .click(); + h.run_steps(1); + 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 alert(h: &Harness<'static, PhotocraftApp>) -> Option { + h.state().ui.dialogs.iter().find(|d| d.kind == DialogKind::Error).and_then(|d| d.fields.get("message")).and_then(|m| m.as_str()).map(str::to_owned) +} + +fn tolerance(h: &Harness<'static, PhotocraftApp>) -> f32 { + h.state().ui.tool_options.bg_tolerance +} + +#[test] +fn tolerance_past_the_range_takes_the_closest_value_and_alerts() { + for (typed, want) in [("0", 1.0), ("150", 100.0), ("-5", 1.0)] { + let mut h = app_harness(); + type_tolerance(&mut h, typed); + assert_eq!(tolerance(&h), want, "{typed}"); + assert_eq!(alert(&h).as_deref(), Some(TOLERANCE_RANGE_ALERT), "{typed}"); + h.get_by_label("OK").click(); + h.run_steps(4); + assert_eq!(alert(&h), None, "{typed}: OK closes the alert"); + assert_eq!(tolerance(&h), want, "{typed}: the clamped value is kept"); + } +} + +#[test] +fn the_alert_is_a_modal_the_control_channel_sees() { + let mut h = app_harness(); + type_tolerance(&mut h, "150"); + let dialogs = serde_json::to_value(&h.state().ui.dialogs).unwrap(); + assert!(dialogs.to_string().contains(TOLERANCE_RANGE_ALERT), "{dialogs}"); + assert!(h.query_by_label("OK").is_some(), "the alert has an OK button"); +} + +#[test] +fn tolerance_in_range_stores_a_whole_number_without_an_alert() { + let mut h = app_harness(); + type_tolerance(&mut h, "33.6"); + assert_eq!(tolerance(&h), 34.0); + assert_eq!(alert(&h), None); + type_tolerance(&mut h, "100"); + assert_eq!(tolerance(&h), 100.0); + assert_eq!(alert(&h), None, "the end of the range is in it"); + type_tolerance(&mut h, "abc"); + assert_eq!(tolerance(&h), 100.0, "not a number: the value stays"); + assert_eq!(alert(&h), None); +} + +#[test] +fn a_leading_zero_typed_key_by_key_keeps_the_text() { + let mut h = app_harness(); + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field") + .click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + for c in ["0", "5"] { + h.event(egui::Event::Text(c.into())); + h.run_steps(2); + } + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(tolerance(&h), 5.0, "0 then 5 is 05, not 15"); + assert_eq!(alert(&h), None, "05 is in range"); +} + +#[test] +fn a_tolerance_set_elsewhere_during_an_edit_wins_over_the_typed_text() { + let mut h = app_harness(); + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field") + .click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("150".into())); + h.run_steps(1); + // The bar goes away mid-edit and a preset sets the Tolerance meanwhile. + h.state_mut().ui.panels.options_bar = false; + h.run_steps(2); + h.state_mut().ui.tool_options.bg_tolerance = 30.0; + h.state_mut().ui.panels.options_bar = true; + h.run_steps(1); + // From the first frame back, the field shows the preset's value. + let shown = h + .query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field") + .accesskit_node() + .numeric_value(); + assert_eq!(shown, Some(30.0), "the field shows the preset's value, not the typed 150"); + h.run_steps(2); + assert_eq!(tolerance(&h), 30.0, "the preset's value, not the typed 150"); +} + +#[test] +fn an_expression_committed_with_enter_past_the_range_alerts() { + let mut h = app_harness(); + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field") + .click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("50*4".into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(tolerance(&h), 100.0); + assert_eq!(alert(&h).as_deref(), Some(TOLERANCE_RANGE_ALERT)); +} + +#[test] +fn a_restored_tolerance_out_of_range_shows_as_the_field_stores_it() { + for (saved, want) in [(0.0, 1.0), (33.6, 34.0), (f32::NAN, 50.0)] { + let mut h = app_harness(); + h.state_mut().ui.tool_options.bg_tolerance = saved; + h.run_steps(3); + assert_eq!(tolerance(&h), want, "{saved}"); + assert_eq!(alert(&h), None, "{saved}: no alert for a restored value"); + } +} + +#[test] +fn a_restored_fraction_reaches_the_stroke_as_a_whole_number() { + // The options bar need not have drawn: the stroke's params round it themselves. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + for (saved, want) in [(23.6, 24.0), (0.0, 1.0), (f32::NAN, 50.0)] { + app.ui.tool_options.bg_tolerance = saved; + let p = crate::eraser_ui::background_eraser_params(&app, &[[1.0, 1.0, 1.0]]); + assert_eq!(p["tolerance"].as_f64(), Some(want), "{saved}"); + } +} + +#[test] +fn a_label_scrub_past_100_does_not_alert() { + let mut h = app_harness(); + type_tolerance(&mut h, "90"); + // The field keeps the keyboard while the caption is scrubbed from 90 past the end. + let field = h + .query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field"); + let rect = field.rect(); + field.click(); + h.run_steps(1); + // The caption sits just left of the field. + let from = egui::pos2(rect.left() - 25.0, rect.center().y); + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(1); + for k in 1..=6 { + h.event(egui::Event::PointerMoved(from + vec2(50.0 * k as f32, 0.0))); + h.run_steps(1); + } + let to = from + vec2(300.0, 0.0); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(tolerance(&h), 100.0, "the scrub reached the end"); + assert_eq!(alert(&h), None, "a scrub stops at 100 without an alert"); +} + +#[test] +fn an_edit_cut_off_by_hiding_the_bar_leaves_no_stale_alert() { + let mut h = app_harness(); + let field = |h: &Harness<'static, PhotocraftApp>| { + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")) + .expect("the Tolerance field") + .rect() + .center() + }; + // 150 typed, then the bar goes away before the field is left. + let at = field(&h); + h.event(egui::Event::PointerMoved(at)); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("150".into())); + h.run_steps(1); + h.state_mut().ui.panels.options_bar = false; + h.run_steps(3); + h.state_mut().ui.panels.options_bar = true; + h.run_steps(3); + assert_eq!(tolerance(&h), 100.0); + // Back in the field and out again without a change: nothing to say. + type_tolerance(&mut h, "100"); + assert_eq!(alert(&h), None, "no alert for an edit that was already cut off"); +} + +/// The accessible descriptions (tooltips) of the widgets of `role` named `label`. +fn descriptions(h: &Harness<'static, PhotocraftApp>, role: Option, label: &str) -> Vec { + let of = |n: egui_kittest::Node<'_>| n.accesskit_node().description().map(|d| d.to_string()); + match role { + Some(role) => h.query_all_by_role(role).filter(|n| n.accesskit_node().label().as_deref() == Some(label)).filter_map(of).collect(), + None => h.query_all_by_label(label).filter_map(of).collect(), + } +} + +#[test] +fn the_bar_has_the_reference_tooltips() { + use egui::accesskit::Role; + for (role, label, tip) in [ + (Role::Button, "Brush Preset picker", "Click to open the Brush Preset picker"), + (Role::ComboBox, "Limits:", "How far to let the erasing spread"), + (Role::SpinButton, "Tolerance:", "Erase range of similar colors"), + (Role::CheckBox, "Protect Foreground Color", "Do not erase the foreground swatch color"), + ] { + let mut h = bar(Tool::BackgroundEraser); + assert!(h.query_by_label(tip).is_none(), "{label}: no tooltip before the hover"); + let at = h.query_all_by_role(role).find(|n| n.accesskit_node().label().as_deref() == Some(label)).expect(label).rect().center(); + h.hover_at(at); + h.run_steps(8); + assert!(h.query_by_label(tip).is_some(), "{label}: resting on it shows {tip:?}"); + } +} + +#[test] +fn the_other_tools_keep_their_brush_chip_tooltip() { + for tool in [Tool::Eraser, Tool::Pencil] { + let h = bar(tool); + let found = descriptions(&h, None, "Brush Preset picker"); + assert!(found.iter().any(|d| d == "Brush Preset picker"), "{tool:?}: {found:?}"); + } +} + +/// The alpha at `(x, y)` after a Background Eraser stroke over white and a square one level off +/// white, with `tolerance`. +fn erased_alpha(tolerance: serde_json::Value, (x, y): (i32, i32)) -> f32 { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 40})).unwrap(); + s.execute("select.rect", json!({"x": 30, "y": 0, "width": 34, "height": 40, "antiAlias": false})).unwrap(); + s.execute("edit.fill", json!({"color": "#feffff"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute( + "paint.backgroundEraser", + json!({"points": [[4, 20], [56, 20]], "size": 16, "hardness": 1, "sampling": "once", "limits": "discontiguous", "tolerance": tolerance}), + ) + .unwrap(); + let d = s.active().unwrap(); + d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y)[3] +} + +#[test] +fn the_command_clamps_tolerance_to_1_through_100() { + // Sampled white; the square is 1/255 off it: Tolerance 1 % reaches it, 0 % would not. + let near = (45, 20); + assert!(erased_alpha(json!(1), near) < 1.0, "1 % erases the near-white square"); + assert_eq!(erased_alpha(json!(0), near), erased_alpha(json!(1), near), "0 is taken as 1"); + assert_eq!(erased_alpha(json!(-20), near), erased_alpha(json!(1), near), "below the range is taken as 1"); + assert_eq!(erased_alpha(json!(150), (6, 20)), erased_alpha(json!(100), (6, 20)), "150 is taken as 100"); +} + +#[test] +fn the_params_doc_says_1_through_100() { + let spec = photocraft_engine::commands::find("paint.backgroundEraser").expect("registered"); + assert!(spec.params.contains(r#""tolerance":1..100=50"#), "{}", spec.params); +} diff --git a/crates/ui-egui/src/brush_behind_key_tests.rs b/crates/ui-egui/src/brush_behind_key_tests.rs new file mode 100644 index 00000000000..e7aef596ac1 --- /dev/null +++ b/crates/ui-egui/src/brush_behind_key_tests.rs @@ -0,0 +1,134 @@ +//! Shift+Alt+Q selects Behind for Brush and Pencil (#11894). + +use egui::{Context, Event, Key, Modifiers, RawInput}; +use photocraft_color::BlendMode; +use photocraft_engine::Session; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::brush_mode_menu::{self, Item}; +use crate::state::Tool; + +/// Create a test document for the selected tool, optionally with a transparent layer. +fn app(tool: Tool, transparent_layer: bool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + if transparent_layer { + app.run("layer.new.layer", json!({})).unwrap(); + } + app.ui.tool = tool; + app +} + +/// Send Shift+Alt+Q through the shortcut handler and assert that it consumes the key. +fn press_behind(app: &mut PhotocraftApp, ctx: &Context) { + let modifiers = Modifiers::SHIFT | Modifiers::ALT; + let events = vec![Event::ModifiersChanged(modifiers), Event::Key { key: Key::Q, physical_key: Some(Key::Q), pressed: true, repeat: false, modifiers }]; + ctx.begin_pass(RawInput { events, ..Default::default() }); + crate::shortcuts::handle(app, ctx); + assert!(!ctx.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { key: Key::Q, .. })))); + ctx.end_pass().textures_delta.clear(); +} + +/// Check whether the rendered options bar displays the Behind label. +fn options_show_behind(app: &mut PhotocraftApp, ctx: &Context) -> bool { + /// Find the Behind text label in a shape or its descendants. + fn has_behind(shape: &egui::Shape) -> bool { + match shape { + egui::Shape::Text(text) => text.galley.text() == "Behind", + egui::Shape::Vec(shapes) => shapes.iter().any(has_behind), + _ => false, + } + } + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 300.0))); + let mut out = ctx.run_ui(RawInput { screen_rect, ..Default::default() }, |ui| crate::panels::options_bar(app, ui)); + out.textures_delta.clear(); + out.shapes.iter().any(|shape| has_behind(&shape.shape)) +} + +/// The key leaves Clear, uses one menu-equivalent command, and changes the displayed Mode. +#[test] +fn brush_and_pencil_select_behind_in_one_command() { + for tool in [Tool::Brush, Tool::Pencil] { + let mut app = app(tool, true); + app.ui.tool_options.brush_clear = true; + app.ui.tool_options.pencil_clear = true; + app.session.tools.brush.mode = BlendMode::Multiply; + let ctx = Context::default(); + PhotocraftApp::setup_context(&ctx, Default::default()); + assert!(brush_mode_menu::enabled(&app, Item::Behind)); + let (id, params) = brush_mode_menu::engine_call(&app, tool, Item::Behind).unwrap(); + assert_eq!((id, ¶ms), ("tools.setBrush", &json!({"brush": {"mode": "Normal", "behind": true}}))); + let before = app.session.journal.len(); + press_behind(&mut app, &ctx); + assert_eq!(&app.session.journal[before..], &[(id.to_owned(), params)]); + assert_eq!(app.ui.brush_tool, tool); + assert_eq!(app.ui.tool_options.brush_clear, tool != Tool::Brush); + assert_eq!(app.ui.tool_options.pencil_clear, tool != Tool::Pencil); + let brush = app.session.execute("brush.get", json!({})).unwrap(); + assert_eq!(brush["behind"], true); + assert_eq!(brush["mode"], "Normal"); + assert!(options_show_behind(&mut app, &ctx), "{tool:?}"); + } +} + +/// A shortcut on the start screen must leave both the live brush and its owning tool alone. +#[test] +fn brush_and_pencil_do_not_select_behind_without_a_document() { + for (tool, brush_tool) in [(Tool::Brush, Tool::Pencil), (Tool::Pencil, Tool::Brush)] { + let mut app = PhotocraftApp::new(Session::new(), Default::default()); + assert!(app.session.active().is_none()); + app.ui.tool = tool; + app.ui.brush_tool = brush_tool; + app.ui.tool_options.brush_clear = true; + app.ui.tool_options.pencil_clear = true; + app.session.tools.brush.mode = BlendMode::Multiply; + let brush = app.session.execute("brush.get", json!({})).unwrap(); + let before = app.session.journal.len(); + press_behind(&mut app, &Context::default()); + assert_eq!(app.session.journal.len(), before); + assert_eq!(app.session.execute("brush.get", json!({})).unwrap(), brush); + assert_eq!(app.ui.brush_tool, brush_tool); + assert!(app.ui.tool_options.brush_clear && app.ui.tool_options.pencil_clear); + } +} + +/// The same Background restriction used by the menu applies before the shortcut changes settings. +#[test] +fn background_does_not_accept_behind() { + for tool in [Tool::Brush, Tool::Pencil] { + let mut app = app(tool, false); + app.ui.tool_options.brush_clear = true; + app.ui.tool_options.pencil_clear = true; + let ctx = Context::default(); + assert!(!brush_mode_menu::enabled(&app, Item::Behind)); + let before = app.session.journal.len(); + let brush_tool = app.ui.brush_tool; + let brush = app.session.execute("brush.get", json!({})).unwrap(); + press_behind(&mut app, &ctx); + assert_eq!(app.session.journal.len(), before); + assert_eq!(app.session.execute("brush.get", json!({})).unwrap(), brush); + assert_eq!(app.ui.brush_tool, brush_tool); + assert!(app.ui.tool_options.brush_clear && app.ui.tool_options.pencil_clear); + } +} + +/// The consumed key stays a no-op for layers and for tools outside the measured scope. +#[test] +fn other_tools_keep_the_behind_key_as_a_no_op() { + for tool in [Tool::Move, Tool::CloneStamp, Tool::PatternStamp, Tool::HistoryBrush, Tool::Gradient, Tool::PaintBucket] { + let mut app = app(tool, true); + let ctx = Context::default(); + let before = app.session.journal.len(); + let brush_tool = app.ui.brush_tool; + let active = app.session.active().unwrap(); + let layer = active.active_layer.unwrap(); + let blend = active.doc.layer(layer).unwrap().blend; + press_behind(&mut app, &ctx); + assert_eq!(app.session.journal.len(), before, "{tool:?}"); + assert_eq!(app.ui.tool, tool); + assert_eq!(app.ui.brush_tool, brush_tool); + assert_eq!(app.session.active().unwrap().doc.layer(layer).unwrap().blend, blend); + assert!(!app.session.tools.brush.behind); + } +} diff --git a/crates/ui-egui/src/brush_blend_keys_behind_tests.rs b/crates/ui-egui/src/brush_blend_keys_behind_tests.rs new file mode 100644 index 00000000000..372bd5aca9d --- /dev/null +++ b/crates/ui-egui/src/brush_blend_keys_behind_tests.rs @@ -0,0 +1,122 @@ +//! #11896: Behind is one of the painting modes, so after Behind a Shift+Alt letter or a +//! Shift+minus/plus step that sets another mode leaves Behind, in the same one `tools.setBrush`. +//! With Behind off the keys work as before. + +use egui::{Key, Modifiers}; +use serde_json::{Value, json}; + +use super::*; + +const SHIFT_ALT: Modifiers = Modifiers { alt: true, ctrl: false, shift: true, mac_cmd: false, command: false }; + +/// A 64 × 64 document with a layer filled 220,30,30, black foreground, the Brush. +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#dc1e1e"})).unwrap(); + app.run("tools.setColors", json!({"foreground": "#000000"})).unwrap(); + app.ui.tool = tool; + app +} + +/// One key press with `modifiers` through the app's shortcut handling. +fn key(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: 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); + crate::shortcuts::handle(app, ctx); + ctx.end_pass().textures_delta.clear(); +} + +fn brush(app: &mut PhotocraftApp) -> Value { + app.run("brush.get", json!({})).unwrap() +} + +fn behind_on(app: &mut PhotocraftApp, ctx: &egui::Context) { + key(app, ctx, Key::Q, SHIFT_ALT); + assert_eq!(brush(app)["behind"], true, "Shift+Alt+Q turns Behind on"); +} + +fn journal(app: &PhotocraftApp) -> usize { + app.session.journal.len() +} + +/// The layer's pixel at (x, y) as 8-bit RGB. +fn pixel(app: &PhotocraftApp, x: i32, y: i32) -> [u8; 3] { + let st = app.session.active().unwrap(); + let c = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y); + [0, 1, 2].map(|i| (c[i] * 255.0).round() as u8) +} + +#[test] +fn shift_alt_m_after_behind_multiplies_over_the_pixels() { + let ctx = egui::Context::default(); + let mut app = app(Tool::Brush); + behind_on(&mut app, &ctx); + let n = journal(&app); + key(&mut app, &ctx, Key::M, SHIFT_ALT); + let b = brush(&mut app); + assert_eq!((b["behind"].clone(), b["mode"].clone()), (json!(false), json!("Multiply")), "{b}"); + assert_eq!(journal(&app), n + 1, "one tools.setBrush"); + // A black stroke across the red layer multiplies it to black, not only under transparency. + app.run("paint.stroke", json!({"points": [[4, 32], [60, 32]], "size": 9, "hardness": 100})).unwrap(); + assert_eq!(pixel(&app, 32, 32), [0, 0, 0]); +} + +#[test] +fn shift_alt_n_l_and_r_after_behind_leave_it() { + let ctx = egui::Context::default(); + for (letter, mode) in [(Key::N, "Normal"), (Key::L, "HardMix")] { + let mut app = app(Tool::Brush); + behind_on(&mut app, &ctx); + let n = journal(&app); + key(&mut app, &ctx, letter, SHIFT_ALT); + let b = brush(&mut app); + assert_eq!(b["behind"], false, "{letter:?}: {b}"); + assert_eq!(b["mode"], json!(mode), "{letter:?}: {b}"); + assert_eq!(journal(&app), n + 1, "{letter:?}: one tools.setBrush"); + } + // Clear: Behind off and Clear on. + let mut app = app(Tool::Brush); + behind_on(&mut app, &ctx); + let n = journal(&app); + key(&mut app, &ctx, Key::R, SHIFT_ALT); + assert_eq!(brush(&mut app)["behind"], false); + assert!(app.ui.tool_options.brush_clear, "Clear on"); + assert_eq!(journal(&app), n + 1, "one tools.setBrush"); + // The Pencil too. + let mut app = self::app(Tool::Pencil); + behind_on(&mut app, &ctx); + key(&mut app, &ctx, Key::M, SHIFT_ALT); + assert_eq!(brush(&mut app)["behind"], false); +} + +#[test] +fn a_shift_step_after_behind_leaves_it() { + let ctx = egui::Context::default(); + for step in [Key::Minus, Key::Plus] { + let mut app = app(Tool::Brush); + behind_on(&mut app, &ctx); + let n = journal(&app); + key(&mut app, &ctx, step, Modifiers::SHIFT); + assert_eq!(brush(&mut app)["behind"], false, "{step:?}"); + assert_eq!(journal(&app), n + 1, "{step:?}: one tools.setBrush"); + } +} + +#[test] +fn with_behind_off_the_keys_send_what_they_did() { + let ctx = egui::Context::default(); + let mut app = app(Tool::Brush); + key(&mut app, &ctx, Key::M, SHIFT_ALT); + let (id, params) = app.session.journal.last().cloned().unwrap(); + assert_eq!((id.as_str(), params), ("tools.setBrush", json!({"brush": {"mode": "Multiply"}})), "only the mode, as before"); + // Clear with Behind off sends nothing, as before (Clear is the options' flag). + let n = journal(&app); + key(&mut app, &ctx, Key::R, SHIFT_ALT); + assert_eq!(journal(&app), n); + assert!(app.ui.tool_options.brush_clear); +} diff --git a/crates/ui-egui/src/brush_mode_menu.rs b/crates/ui-egui/src/brush_mode_menu.rs index 431ad7aea20..71259e9f534 100644 --- a/crates/ui-egui/src/brush_mode_menu.rs +++ b/crates/ui-egui/src/brush_mode_menu.rs @@ -166,6 +166,17 @@ pub(crate) fn is_gesture(tool: Tool, mods: egui::Modifiers) -> bool { mods.shift && !mods.alt && !mods.ctrl && !mods.command && !mods.mac_cmd && !items(tool).is_empty() } +/// Does a right-click with `mods` open the menu? The Shift gesture for every tool that has one, +/// and a plain right-click with the Eraser in Block mode, where the Brush Preset picker's Size and +/// Hardness mean nothing (#11937, as in the reference application). +/// A plain right-click opens it only where it would otherwise open the picker: with the Erase +/// preference it still erases. +pub(crate) fn opens_on(app: &PhotocraftApp, tool: Tool, mods: egui::Modifiers) -> bool { + let plain = !mods.shift && !mods.alt && !mods.ctrl && !mods.command && !mods.mac_cmd; + let block = tool == Tool::Eraser && app.ui.tool_options.eraser_mode == "block"; + is_gesture(tool, mods) || (plain && block && !crate::paint_mouse::right_erases(app, tool)) +} + /// The modifiers that decide a right-button gesture: the ones held when the button went down. /// egui reports `secondary_clicked` on release, so letting go of Shift a moment before the button /// must not turn Shift+right-click into a plain right-click (an erase dab, with the Erase @@ -382,3 +393,7 @@ pub(crate) fn show(app: &mut PhotocraftApp, ctx: &Context) { close(app); } } + +#[cfg(test)] +#[path = "eraser_block_right_click_tests.rs"] +mod eraser_block_right_click_tests; diff --git a/crates/ui-egui/src/brush_opacity_minimum_tests.rs b/crates/ui-egui/src/brush_opacity_minimum_tests.rs new file mode 100644 index 00000000000..77f84a7b41c --- /dev/null +++ b/crates/ui-egui/src/brush_opacity_minimum_tests.rs @@ -0,0 +1,146 @@ +//! The Brush's Opacity starts at 1 %, as in Photoshop (#11812): its options-bar field takes whole +//! numbers from 1 to 100, the digit keys `00` set 100 %, and `tools.setBrush` floors opacity at 1 %. + +use egui::{Event, Key, Modifiers, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::opacity_keys::press; +use crate::state::Tool; + +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.tool = tool; + app +} + +fn options_bar(tool: Tool, theme: crate::theme::ThemeKind) -> Harness<'static, PhotocraftApp> { + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + crate::theme::install_fonts(ui.ctx()); + crate::theme::apply(ui.ctx(), theme); + ready = true; + return; + } + crate::panels::options_bar(app, ui); + }, + app(tool), + ); + h.run_steps(4); + h +} + +/// Types `text` into the options bar's Opacity field and presses Enter. +fn type_opacity(h: &mut Harness<'_, PhotocraftApp>, text: &str) -> f32 { + // "Opacity:" in the Photoshop-look themes, "Opacity" in the others. + let field = h + .query_all(egui_kittest::kittest::by().role(egui::accesskit::Role::SpinButton)) + .find(|n| n.accesskit_node().label().is_some_and(|l| l.trim_end_matches(':') == "Opacity")) + .unwrap_or_else(|| panic!("the {:?} Opacity field", h.state().ui.tool)) + .rect(); + h.hover_at(field.center()); + h.run(); + h.drag_at(field.center()); + h.run(); + h.drop_at(field.center()); + h.run_steps(2); + h.event(Event::Key { key: Key::A, physical_key: Some(Key::A), pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); + h.run(); + h.event(Event::Text(text.into())); + h.run_steps(2); + h.event(Event::Key { key: Key::Enter, physical_key: Some(Key::Enter), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + h.state().session.tools.brush.opacity +} + +/// In the Photoshop-look themes and the others (their options bars lay the Brush out apart); the +/// Pencil and Eraser fields take 1-100 whole numbers in Photoshop too. +#[test] +fn the_brush_opacity_field_takes_whole_numbers_from_1_to_100() { + use crate::theme::ThemeKind; + for theme in [ThemeKind::ProMedium, ThemeKind::Studio] { + for tool in [Tool::Brush, Tool::Pencil, Tool::Eraser] { + let mut h = options_bar(tool, theme); + for (typed, want) in [("0", 0.01), ("-10", 0.01), ("150", 1.0), ("45", 0.45), ("33.7", 0.34)] { + let got = type_opacity(&mut h, typed); + assert!((got - want).abs() < 1e-6, "{theme:?} {tool:?} typed {typed}: {got}, want {want}"); + } + } + } +} + +#[test] +fn double_zero_sets_the_brush_to_100_percent() { + let mut a = app(Tool::Brush); + a.run("tools.setBrush", json!({"brush": {"opacity": 0.3}})).unwrap(); + press(&mut a, 0, false, 0.0); + press(&mut a, 0, false, 100.0); + assert_eq!(a.session.tools.brush.opacity, 1.0, "00 = 100%"); + // Other pairs are the exact value; single digits are tens; 0 alone is 100%. + for (digits, want) in [([0, 7], 0.07), ([4, 5], 0.45)] { + press(&mut a, digits[0], false, 10_000.0); + press(&mut a, digits[1], false, 10_100.0); + assert!((a.session.tools.brush.opacity - want).abs() < 1e-6, "{digits:?}"); + } + for (d, want) in [(3, 0.3), (9, 0.9), (0, 1.0), (1, 0.1)] { + press(&mut a, d, false, 20_000.0 + f64::from(d) * 10_000.0); + assert!((a.session.tools.brush.opacity - want).abs() < 1e-6, "{d}"); + } + // With build-up on, ⇧ digits set the opacity: `00` is 100% there too. + a.session.tools.brush.build_up = true; + press(&mut a, 2, true, 200_000.0); + press(&mut a, 0, true, 300_000.0); + press(&mut a, 0, true, 300_100.0); + assert_eq!(a.session.tools.brush.opacity, 1.0); +} + +#[test] +fn double_zero_on_flow_keeps_its_one_percent() { + let mut a = app(Tool::Brush); + press(&mut a, 0, true, 0.0); + press(&mut a, 0, true, 100.0); + assert_eq!(a.session.tools.brush.flow, 0.01, "⇧00 is Flow's 1%, unchanged"); +} + +#[test] +fn set_brush_floors_opacity_at_one_percent() { + let mut a = app(Tool::Brush); + for params in [json!({"brush": {"opacity": 0}}), json!({"opacity": 0.0}), json!({"brush": {"opacity": 0.004}})] { + a.run("tools.setBrush", json!({"brush": {"opacity": 0.5}})).unwrap(); + a.run("tools.setBrush", params.clone()).unwrap(); + assert_eq!(a.session.tools.brush.opacity, 0.01, "{params}"); + } + a.run("tools.setBrush", json!({"brush": {"opacity": 0.37}})).unwrap(); + assert!((a.session.tools.brush.opacity - 0.37).abs() < 1e-6, "values above the floor are kept"); +} + +/// The Pencil's `00` is 100 % too (the Pencil has no Flow, so ⇧ digits are not involved). +#[test] +fn double_zero_sets_the_pencil_to_100_percent() { + let mut a = app(Tool::Pencil); + a.run("tools.setBrush", json!({"brush": {"opacity": 0.3}})).unwrap(); + press(&mut a, 0, false, 0.0); + press(&mut a, 0, false, 100.0); + assert_eq!(a.session.tools.brush.opacity, 1.0, "00 = 100%"); +} + +/// An opacity set outside the field (a `tools.setBrush` fraction, an older saved 0) shows as the +/// whole percent in 1-100 the field can hold. +#[test] +fn the_opacity_field_shows_outside_values_as_whole_percents() { + let mut h = options_bar(Tool::Brush, crate::theme::ThemeKind::ProMedium); + for (stored, shown) in [(0.337, 34.0), (0.0, 1.0), (0.004, 1.0)] { + h.state_mut().session.tools.brush.opacity = stored; + h.run_steps(2); + let value = h + .query_all(egui_kittest::kittest::by().role(egui::accesskit::Role::SpinButton)) + .find(|n| n.accesskit_node().label().is_some_and(|l| l.trim_end_matches(':') == "Opacity")) + .and_then(|n| n.accesskit_node().numeric_value()); + assert_eq!(value, Some(shown), "stored {stored}"); + assert_eq!(h.state().session.tools.brush.opacity, stored, "showing it does not change it"); + } +} diff --git a/crates/ui-egui/src/brush_picker_angle_widget_tests.rs b/crates/ui-egui/src/brush_picker_angle_widget_tests.rs new file mode 100644 index 00000000000..caed875a5b8 --- /dev/null +++ b/crates/ui-egui/src/brush_picker_angle_widget_tests.rs @@ -0,0 +1,167 @@ +//! #11821: the Brush Preset picker shows the tip's angle/roundness widget at its top left, the +//! Brush Settings panel's own widget, and its drags go through `tools.setBrush` as one journaled +//! change each; Escape reverts them as it does a Size or Hardness edit. + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::brush_sections::{EllipseGrab, ellipse_drag, ellipse_grab, roundness_handles}; +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": 400, "height": 300})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("tools.setBrush", json!({"brush": {"size": 20, "hardness": 1.0, "angle": 0.0, "roundness": 1.0}})).unwrap(); + app.ui.tool = Tool::Brush; + 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); + let c = h.state().last_canvas_rect.center(); + let ctx = h.ctx.clone(); + crate::brush_picker::open(&mut h.state_mut().ui, &ctx, [c.x, c.y]); + h.run_steps(3); + h +} + +/// The widget's centre on screen. It is the top block's first item, 84 × 84, and the Size column +/// starts one item spacing right of it, its first row (24 pt tall, the label centred in it) level +/// with the widget's top. +fn widget_centre(h: &Harness<'static, PhotocraftApp>) -> Pos2 { + use egui_kittest::kittest::Queryable; + let picker = h.ctx.memory(|m| m.area_rect(crate::brush_picker::picker_id())).expect("the picker is open"); + let label = h.query_all_by_label(crate::i18n::t("Size")).find(|n| picker.contains(n.rect().center())).expect("the picker's Size label").rect(); + let spacing = h.ctx.global_style().spacing.item_spacing.x; + Pos2::new(label.left() - spacing - 42.0, label.center().y - 12.0 + 42.0) +} + +fn brush(h: &Harness<'static, PhotocraftApp>) -> (f32, f32) { + let b = &h.state().session.tools.brush; + (b.angle, b.roundness) +} + +/// A press at `from`, moves through `path` and the release at its end, a frame each. +fn drag(h: &mut Harness<'static, PhotocraftApp>, from: Pos2, path: &[Pos2]) { + h.event(Event::PointerMoved(from)); + h.run_steps(1); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for p in path { + h.event(Event::PointerMoved(*p)); + h.run_steps(1); + } + let end = path.last().copied().unwrap_or(from); + h.event(Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +#[test] +fn the_widget_sits_at_the_pickers_top_left_left_of_size() { + use egui_kittest::kittest::Queryable; + let h = harness(); + let c = widget_centre(&h); + // Its top left is the picker's content corner: inside the popup frame's margin and stroke. + let picker = h.ctx.memory(|m| m.area_rect(crate::brush_picker::picker_id())).expect("the picker is open"); + let style = h.ctx.global_style(); + let inset = + vec2(f32::from(style.spacing.menu_margin.left), f32::from(style.spacing.menu_margin.top)) + egui::Vec2::splat(style.visuals.window_stroke().width); + let corner = c - vec2(42.0, 42.0); + assert!((corner - (picker.min + inset)).length() <= 2.0, "the widget at the top left: {corner:?} vs {:?}", picker.min + inset); + // The Size field is right of it, in the same top block. + let size = h.get_all_by_role(egui::accesskit::Role::SpinButton).next().expect("the Size field").rect(); + assert!(size.left() > c.x + 42.0 && size.top() < c.y + 42.0, "{size:?} vs centre {c:?}"); +} + +#[test] +fn dragging_the_arrow_sets_the_angle_as_the_panel_widget_does() { + let mut h = harness(); + let c = widget_centre(&h); + let (angle, roundness) = brush(&h); + // On the arrow (angle 0 points right), away from the roundness handles. + let from = c + vec2(28.0, 0.0); + assert_eq!(ellipse_grab(from - c, angle, roundness), EllipseGrab::Angle); + let to = c + vec2(-20.0, -20.0); + let journal = h.state().session.journal.len(); + drag(&mut h, from, &[c + vec2(10.0, -25.0), to]); + let want = ellipse_drag(EllipseGrab::Angle, to - c, angle, roundness); + assert_eq!(brush(&h), want, "the panel widget's values for the same drag"); + assert_eq!(want.0, 135.0); + let added: Vec = h.state().session.journal[journal..].iter().map(|(id, _)| id.clone()).collect(); + assert_eq!(added, ["tools.setBrush"], "one journaled change for the drag"); +} + +#[test] +fn dragging_a_handle_sets_the_roundness_as_the_panel_widget_does() { + let mut h = harness(); + let c = widget_centre(&h); + let (angle, roundness) = brush(&h); + let handle = c + roundness_handles(angle, roundness)[0]; + assert_eq!(ellipse_grab(handle - c, angle, roundness), EllipseGrab::Roundness); + let to = c + vec2(3.0, -12.0); + // The grab is judged where the drag starts: the first move stays within the handle's reach. + drag(&mut h, handle, &[handle + vec2(0.0, 6.5), c + vec2(2.0, -20.0), to]); + let want = ellipse_drag(EllipseGrab::Roundness, to - c, angle, roundness); + assert_eq!(brush(&h), want); + assert!((want.1 - 12.0 / 34.0).abs() < 1e-4, "{want:?}"); + // The Brush Settings panel reads the same brush. + assert_eq!(h.state().session.tools.brush.roundness, want.1); +} + +#[test] +fn escape_reverts_an_angle_edit_as_it_does_a_size_edit() { + let mut h = harness(); + let c = widget_centre(&h); + let opened = brush(&h); + drag(&mut h, c + vec2(28.0, 0.0), &[c + vec2(20.0, -15.0), c + vec2(0.0, -30.0)]); + assert_eq!(brush(&h).0, 90.0); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(brush(&h), opened, "Escape goes back to the values the picker opened with"); + assert_eq!(h.state().ui.brush_picker, None, "and closes it"); +} + +#[test] +fn enter_keeps_an_angle_edit_and_closes() { + let mut h = harness(); + let c = widget_centre(&h); + drag(&mut h, c + vec2(28.0, 0.0), &[c + vec2(20.0, 15.0), c + vec2(0.0, 30.0)]); + assert_eq!(brush(&h).0, -90.0); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert_eq!(brush(&h).0, -90.0); + assert_eq!(h.state().ui.brush_picker, None); +} + +#[test] +fn non_finite_pointer_positions_leave_the_brush_unchanged() { + let mut h = harness(); + let c = widget_centre(&h); + let before = brush(&h); + let handle = c + roundness_handles(before.0, before.1)[0]; + h.event(Event::PointerMoved(handle)); + h.run_steps(1); + h.event(Event::PointerButton { pos: handle, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + // The pointer reports non-finite positions while the handle is held. (egui's own debug checks + // panic once a drag is under way at such a position, or on a release there, so the test stops + // here.) + for p in [Pos2::new(f32::NAN, c.y), Pos2::new(c.x, f32::INFINITY)] { + h.event(Event::PointerMoved(p)); + h.run_steps(1); + let now = brush(&h); + assert!(now.0.is_finite() && now.1.is_finite(), "{p:?}: {now:?}"); + assert_eq!(now, before, "{p:?}"); + } +} diff --git a/crates/ui-egui/src/brush_refusal_alert_tests.rs b/crates/ui-egui/src/brush_refusal_alert_tests.rs new file mode 100644 index 00000000000..7aa6bee9fac --- /dev/null +++ b/crates/ui-egui/src/brush_refusal_alert_tests.rs @@ -0,0 +1,106 @@ +//! #11898: when the Brush can't paint (hidden target layer, Lock All, a group's pixels), a press +//! opens the reference application's alert with OK and paints nothing; other tools keep today's +//! status-bar message, a group's targeted mask paints and Lock transparent pixels paints. + +use egui::Modifiers; +use serde_json::{Value, json}; + +use super::*; +use crate::canvas::{ToolEvent, tool_event}; + +/// A 64 × 64 document with a painted layer active, black foreground, `tool`. +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#dc1e1e"})).unwrap(); + app.run("tools.setColors", json!({"foreground": "#000000"})).unwrap(); + app.ui.tool = tool; + app.sync_views(); + app +} + +fn active(app: &PhotocraftApp) -> u64 { + app.session.active().unwrap().active_layer.unwrap().0 +} + +fn steps(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +/// A press, a short drag and the release with the active tool. +fn stroke(app: &mut PhotocraftApp) { + tool_event(app, ToolEvent::Down { x: 20.0, y: 32.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: 44.0, y: 32.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Up { x: 44.0, y: 32.0 }, Modifiers::NONE); +} + +fn alerts(app: &PhotocraftApp) -> Vec { + app.ui.dialogs.iter().filter(|d| d.kind == DialogKind::Error).filter_map(|d| d.fields.get("message").and_then(Value::as_str).map(str::to_string)).collect() +} + +fn refused(app: &mut PhotocraftApp, want: &str) { + let before = steps(app); + stroke(app); + assert_eq!(alerts(app), [want], "the alert, once"); + assert_eq!(steps(app), before, "nothing painted, no History step"); + // Over the control channel it is an ordinary dialog: `ui.dialog.confirm` closes it. + let id = app.ui.dialogs.iter().find(|d| d.kind == DialogKind::Error).map(|d| d.id).unwrap(); + crate::dialogs::confirm(app, id).unwrap(); + assert!(alerts(app).is_empty()); +} + +#[test] +fn a_hidden_target_layer() { + let mut app = app(Tool::Brush); + let id = active(&app); + app.run("layer.setProps", json!({"layer": id, "visible": false})).unwrap(); + refused(&mut app, "Could not use the brush tool because the target layer is hidden."); + assert!(app.ui.status.is_empty() || !app.ui.status_error, "no status-bar error as well: {:?}", app.ui.status); +} + +#[test] +fn lock_all() { + let mut app = app(Tool::Brush); + app.run("layer.lockLayers", json!({"all": true})).unwrap(); + refused(&mut app, "Could not use the brush tool because the layer is locked."); +} + +#[test] +fn a_group_is_active() { + let mut app = app(Tool::Brush); + app.run("layer.new.group", json!({})).unwrap(); + assert!(app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(active(&app))).unwrap().is_group()); + refused(&mut app, "Could not use the brush tool because the pixels in a group cannot be edited without merging the group."); +} + +#[test] +fn a_groups_targeted_mask_and_lock_transparency_still_paint() { + let mut app = app(Tool::Brush); + app.run("layer.new.group", json!({})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + assert_eq!(crate::canvas::paint_target(&app), json!("mask"), "the new mask is the target"); + let before = steps(&app); + stroke(&mut app); + assert!(alerts(&app).is_empty(), "no alert on the mask"); + assert_eq!(steps(&app), before + 1, "the mask was painted"); + let mut app = self::app(Tool::Brush); + app.run("layer.lockLayers", json!({"transparency": true})).unwrap(); + let before = steps(&app); + stroke(&mut app); + assert!(alerts(&app).is_empty()); + assert_eq!(steps(&app), before + 1, "paints on the existing pixels"); +} + +#[test] +fn other_tools_keep_the_status_bar() { + // Wording not measured for them: today's message, no dialog. + let mut app = app(Tool::Pencil); + let id = active(&app); + app.run("layer.setProps", json!({"layer": id, "visible": false})).unwrap(); + stroke(&mut app); + assert!(alerts(&app).is_empty()); + assert_eq!(app.ui.status, photocraft_engine::hidden_target::HIDDEN); + assert_eq!(tool_name(Tool::Pencil), None); + assert_eq!(tool_name(Tool::Brush), Some("brush tool"), "the one place that names the tool"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ac471a9269d..c8a4edd8e2a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1679,6 +1679,15 @@ fn draw_default_tool_cursor(app: &mut PhotocraftApp, ui: &mut egui::Ui, input: & { crate::tool_cursor::close_path(painter, p) } + // Over an endpoint of an open path, the continue-path pointer (#9356). + Tool::Pen + if { + let [x, y] = xf.to_doc(p); + crate::vector_ui::continues_path_at(app, tool, x, y) + } => + { + crate::tool_cursor::continue_path(painter, p) + } // The pen tools: their own icon with its nib on the point, or the crosshair for // precise Other Cursors (#4639). Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint => { @@ -1988,9 +1997,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: None => paint_dots(ui, rect, app.session.prefs().interface.show_canvas_dots), } let border = app.session.prefs().interface.canvas_border; - // Default-mode Crop changes its camera during input. Defer its image until the new camera - // is known; other tools keep their existing paint order and texture/outline snapshots. + // Default-mode Crop changes its camera during input. Live painting must also process the + // current pointer samples before preparing the image, or every dab appears a frame late. + // Under a dialog the image keeps its place below the dialog's own overlays (Refine Edge). let crop_camera = primary && crate::crop_mode::moves_image(app); + let paint_after_input = crop_camera || (primary && app.ui.dialogs.is_empty() && strokes_live(app.ui.tool)); let paint_input = CanvasPaintInput { ctx: &ctx, rect, @@ -2008,7 +2019,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: target_xf, pixel_grid, }; - let painted = if crop_camera { None } else { Some(paint_canvas(app, ui, &display_view, xf, &paint_input)) }; + let painted = if paint_after_input { None } else { Some(paint_canvas(app, ui, &display_view, xf, &paint_input)) }; let img_rect = xf.doc_rect(doc.bounds()); // Under an open dialog the canvas widget is inert, but the image still pans and zooms. @@ -2029,7 +2040,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: app.stylus.update_for_frame(ui.ctx().cumulative_frame_nr(), &ui.input(|i| i.events.clone())); crate::stylus::Stylus::sync_eraser_tool(app); // A Magnetic Lasso border left behind by a tool or document switch is dropped. - crate::magnetic_lasso_ui::frame(app); + crate::magnetic_lasso_ui::frame(app, ui.input(|i| i.pointer.primary_down()), primary); // A Properties Levels/Curves eyedropper left behind by a tool or layer switch is dropped. crate::layer_picker::prune(app); // Held keys (hold_keys.rs): Space repositions a crop frame, marquee, lasso or shape being @@ -2176,12 +2187,16 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: ctx.request_repaint(); } } - let xf = if crop_camera { + let xf = if paint_after_input { + // This frame's navigation input (wheel pan, pinch zoom) shows in this frame too. + if view.zoom != render_zoom { + display_view.zoom = view.zoom; + } if view.center != target_xf.center { display_view.center = view.center; } display_view.rotation = view.rotation; - ViewXform { center: display_view.center, rotation: display_view.rotation, ..xf }.pixel_aligned_for(app, ppp) + ViewXform { zoom: display_view.zoom / ppp, center: display_view.center, rotation: display_view.rotation, ..xf }.pixel_aligned_for(app, ppp) } else { xf }; @@ -2279,6 +2294,9 @@ mod layer_edges_tests; #[cfg(test)] mod guide_ants_tests; +#[cfg(test)] +mod live_frame_tests; + #[cfg(test)] mod rotation_preview_isolation_tests { use super::*; diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index b2e377e0ec4..6e8b5b2d6c2 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -46,6 +46,15 @@ pub struct CanvasToolMenu { /// had when it opened. #[serde(default)] pub crop: Option>, + /// The Color Sampler menu's sampler: the one under the pointer when it opened (its index), + /// `None` off a sampler, where every row is greyed. + #[serde(default)] + pub sampler: Option, + /// Where that sampler was when the menu opened: the active document's index and the sampler's + /// position. Once a control command deletes, moves or renumbers samplers, or another document + /// becomes active, the index may name a different sampler, so the menu closes and Delete greys. + #[serde(default)] + pub sampler_at: Option<(usize, [f64; 2])>, } /// A misspelled word in the type layer being edited (`edit.checkSpelling`'s `list` entry). @@ -303,6 +312,8 @@ pub fn open_crop(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; guide: false, spelling: None, crop: Some(users), + sampler: None, + sampler_at: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -354,8 +365,8 @@ pub fn cycle_canvas_color(app: &mut PhotocraftApp, backward: bool) -> Result<(), app.run("prefs.set", json!({"values": {"interface.canvasColor": name}})).map(|_| ()) } -/// The Zoom and Hand tools' context menu: the fixed zooms of the View menu (plus the Hand tool's -/// Fill Screen), then the zoom steps last, in Photoshop's order (#6005). +/// The Zoom tool's context menu: the fixed zooms of the View menu (plus Fill Screen), then the +/// zoom steps last, in Photoshop's order (#6005). pub const VIEW_MENU: &[Row] = &[ Some(("Fit on Screen", "view.fitOnScreen")), Some(("Fill Screen", FILL_SCREEN)), @@ -367,6 +378,14 @@ pub const VIEW_MENU: &[Row] = &[ Some(("Zoom Out", "view.zoomOut")), ]; +/// The Hand tool's context menu: only the fixed zooms, as measured in the reference application (#12022). +pub const HAND_MENU: &[Row] = &[ + Some(("Fit on Screen", "view.fitOnScreen")), + Some(("100%", "view.actualPixels")), + Some(("200%", "view.twoHundredPercent")), + Some(("Print Size", "view.printSize")), +]; + /// The view menu's Fill Screen row. Photoshop has no View menu command for it, so the menu sets /// the view itself. pub const FILL_SCREEN: &str = "view.fillScreen"; @@ -383,8 +402,8 @@ pub const SELECTION_MENU: &[Row] = &[ Some(("Save Selection…", "select.saveSelection")), Some(("Make Work Path…", "select.toWorkPath")), None, - Some(("Layer via Copy", "layer.new.layerViaCopy")), - Some(("Layer via Cut", "layer.new.layerViaCut")), + Some(("Layer Via Copy", "layer.new.layerViaCopy")), + Some(("Layer Via Cut", "layer.new.layerViaCut")), Some(("New Layer…", "layer.new.layer")), None, Some(("Free Transform", "edit.freeTransform")), @@ -445,7 +464,50 @@ pub const PEN_MENU: &[Row] = &[ Some(("Disable Symmetry Path", "paint.symmetryDisable")), ]; -/// Tools whose plain canvas right-click offers selection actions. +/// The Color Sampler menu's Delete row: removes the sampler the menu opened on. +pub const SAMPLER_DELETE: &str = "view.colorSamplers.delete"; + +/// The Color Sampler's canvas menu (#11733), in the reference application's order: Delete, then +/// the readout modes for the sampler under the pointer. The readout rows are this menu's own ids +/// and stay greyed until samplers have their own readout mode. +pub const SAMPLER_MENU: &[Row] = &[ + Some(("Delete", SAMPLER_DELETE)), + None, + Some(("Actual Color", "colorSampler.readout.actual")), + Some(("Proof Color", "colorSampler.readout.proof")), + None, + Some(("Grayscale", "colorSampler.readout.gray")), + Some(("RGB Color", "colorSampler.readout.rgb")), + Some(("Web Color", "colorSampler.readout.web")), + Some(("HSB Color", "colorSampler.readout.hsb")), + Some(("HSL Color", "colorSampler.readout.hsl")), + Some(("CMYK Color", "colorSampler.readout.cmyk")), + Some(("Lab Color", "colorSampler.readout.lab")), + None, + Some(("Grayscale 16 bit", "colorSampler.readout.gray16")), + Some(("RGB 16 bit", "colorSampler.readout.rgb16")), + Some(("CMYK 16 bit", "colorSampler.readout.cmyk16")), + Some(("Lab 16 bit", "colorSampler.readout.lab16")), + None, + Some(("Total Ink", "colorSampler.readout.totalInk")), +]; + +/// Does the Color Sampler menu's index still name the sampler it opened on, in the same document? +/// `true` off a sampler, where there is nothing to go stale. +fn sampler_current(app: &PhotocraftApp, menu: &CanvasToolMenu) -> bool { + let Some(i) = menu.sampler else { return true }; + let now = app.session.active_index().zip(app.session.active().and_then(|d| d.color_samplers.get(i).copied())); + now.is_some() && now == menu.sampler_at +} + +/// Is `menu` the Color Sampler's own menu (not the pasteboard, guide or transform menu opened +/// with it)? +fn sampler_menu(menu: &CanvasToolMenu) -> bool { + menu.tool == Tool::ColorSampler && !menu.transform && !menu.pasteboard && !menu.guide && menu.crop.is_none() && menu.anchor.is_none() +} + +/// Tools whose plain canvas right-click opens a menu: the selection tools' actions, the path +/// menu and the Color Sampler's. pub fn applies(tool: Tool) -> bool { matches!( tool, @@ -461,6 +523,7 @@ pub fn applies(tool: Tool) -> bool { // Its brush is set from the options bar's chip; the canvas right-click is the // selection menu, as in Photoshop (#7268). | Tool::QuickSelection + | Tool::ColorSampler ) || path_menu(tool) } @@ -503,13 +566,15 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { } else if menu.transform { TRANSFORM_MENU } else if menu.anchor.is_some() { - VIEW_MENU + if menu.tool == Tool::Hand { HAND_MENU } else { VIEW_MENU } } else if menu.text { TYPE_EDIT_MENU } else if menu.type_layer { TYPE_LAYER_MENU } else if path_menu(menu.tool) { PEN_MENU + } else if sampler_menu(menu) { + SAMPLER_MENU } else { selection_rows(menu.has_selection) } @@ -649,6 +714,8 @@ pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> guide: false, spelling: None, crop: None, + sampler: None, + sampler_at: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -674,6 +741,9 @@ pub fn engine_call(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> _ => (command.into(), json!({})), }; } + if sampler_menu(menu) { + return (command.into(), menu.sampler.map_or(json!({}), |i| json!({"index": i}))); + } 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!({})), @@ -727,6 +797,8 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: guide: false, spelling: None, crop: None, + sampler: None, + sampler_at: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -758,6 +830,8 @@ pub fn open_guide(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, mods: egui guide: true, spelling: None, crop: None, + sampler: None, + sampler_at: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -810,6 +884,10 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) if menu.type_layer { return TYPE_LAYER_MENU.iter().flatten().any(|(_, id)| *id == command) && crate::menus::is_enabled(app, command); } + // Off a sampler every row is greyed. The readout rows wait for per-sampler readout modes. + if sampler_menu(menu) { + return command == SAMPLER_DELETE && menu.sampler.is_some() && sampler_current(app, menu); + } if menu.transform || !path_menu(menu.tool) { return crate::menus::is_enabled(app, command); } @@ -870,6 +948,8 @@ pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2] let has_path = crate::vector_ui::active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); let path_name = crate::vector_ui::active_path_name(app); let spelling = if text { doc.and_then(|d| spelling_at(app, d)) } else { None }; + let sampler = if tool == Tool::ColorSampler { doc.and_then(|d| crate::analysis_ui::sampler_at(app, d)) } else { None }; + let sampler_at = sampler.and_then(|i| Some((app.session.active_index()?, *app.session.active()?.color_samplers.get(i)?))); app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool, @@ -884,6 +964,8 @@ pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2] guide: false, spelling, crop: None, + sampler, + sampler_at, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -911,13 +993,15 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; guide: false, spelling: None, crop: None, + sampler: None, + sampler_at: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true } -/// A view menu row. Zoom In and Zoom Out step about the right-clicked point, as a Zoom tool click -/// does; the other rows run their View menu command. +/// A view menu row. The non-temporary Zoom tool's steps keep the view centre unless clicked-point +/// centring is enabled. Other view-tool cases retain their pressed-point anchoring. fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, command: &str) -> Result<(), String> { let i = app.session.active_index().ok_or("no document")?; if command == FILL_SCREEN { @@ -935,6 +1019,9 @@ fn choose_view(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, co } let (Some((zoom0, c)), Some(d)) = (before, menu.anchor) else { return Ok(()) }; let center_on_point = app.session.prefs().tools.zoom_clicked_point_to_center; + if menu.tool == Tool::Zoom && app.ui.tool == Tool::Zoom && app.ui.spring.is_none() && crate::hold_keys::held_tool(app, ctx).is_none() && !center_on_point { + return Ok(()); + } let Some(v) = app.ui.views.get_mut(i) else { return Ok(()) }; let d = [d[0] as f32, d[1] as f32]; // The view scales about the centre, so the point keeps its screen position when its offset @@ -964,6 +1051,9 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { choose_type(app, ctx, command) } else if path_menu(menu.tool) && !menu.transform { choose_pen(app, ctx, &menu, command) + } else if sampler_menu(&menu) { + let (id, params) = engine_call(app, &menu, command); + app.run(&id, params).map(|_| ()) } else if command == "select.toWorkPath" { // Photoshop's Make Work Path… asks for the tolerance first. spec_dialog(app, command, "Make Work Path", r##"{"tolerance":0.5..10=2}"##, serde_json::Map::new()); @@ -1153,6 +1243,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { || (menu.text && app.ui.text_edit.is_none()) || (menu.type_layer && app.ui.text_edit.is_some()) || (menu.crop.is_some() && !crate::crop_ui::pending(app)) + || (sampler_menu(&menu) && !sampler_current(app, &menu)) { app.ui.canvas_tool_menu = None; return; @@ -1226,3 +1317,6 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { #[cfg(test)] mod tests; + +#[cfg(test)] +mod hand_menu_entries_tests; diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/hand_menu_entries_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/hand_menu_entries_tests.rs new file mode 100644 index 00000000000..52424544c8b --- /dev/null +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/hand_menu_entries_tests.rs @@ -0,0 +1,63 @@ +//! #12022: the Hand tool's canvas right-click lists only Fit on Screen, 100%, 200% and Print Size; +//! the Zoom tool's menu is unchanged. + +use egui::Context; +use serde_json::json; + +use super::{FILL_SCREEN, HAND_MENU, Row, VIEW_MENU, choose, open_view, rows}; +use crate::PhotocraftApp; +use crate::state::Tool; + +fn labels(rows: &[Row]) -> Vec> { + rows.iter().map(|r| r.map(|(label, _)| label)).collect() +} + +/// A 400 × 300 document, optionally with everything selected, and the view menu of `tool` open. +fn menu_app(tool: Tool, selection: bool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + if selection { + app.run("select.all", json!({})).unwrap(); + } + app.sync_views(); + assert!(open_view(&mut app, &Context::default(), tool, [100.0, 100.0], [300.0, 100.0])); + app +} + +#[test] +fn the_hand_menu_lists_only_the_fixed_zooms() { + for selection in [false, true] { + let app = menu_app(Tool::Hand, selection); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + assert_eq!(labels(rows(&menu)), vec![Some("Fit on Screen"), Some("100%"), Some("200%"), Some("Print Size")], "selection {selection}"); + } +} + +#[test] +fn the_zoom_menu_is_unchanged() { + let app = menu_app(Tool::Zoom, false); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + assert!(rows(&menu) == VIEW_MENU); + assert_eq!( + labels(rows(&menu)), + vec![Some("Fit on Screen"), Some("Fill Screen"), Some("100%"), Some("200%"), Some("Print Size"), None, Some("Zoom In"), Some("Zoom Out")] + ); +} + +#[test] +fn the_hand_entries_zoom_and_add_no_history() { + let mut app = menu_app(Tool::Hand, false); + let before = app.session.active().unwrap().history.past_len(); + choose(&mut app, &Context::default(), "view.twoHundredPercent"); + assert_eq!(app.ui.views[0].zoom, 2.0); + assert_eq!(app.session.active().unwrap().history.past_len(), before); + // Rows that left the Hand's menu can't be run through it. + for gone in [FILL_SCREEN, "view.zoomIn", "view.zoomOut"] { + let mut app = menu_app(Tool::Hand, false); + let zoom = app.ui.views[0].zoom; + choose(&mut app, &Context::default(), gone); + assert_eq!(app.ui.views[0].zoom, zoom, "{gone}"); + assert!(!app.ui.views[0].fill_pending, "{gone}"); + } + assert!(HAND_MENU.iter().all(Option::is_some), "no separator"); +} diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs index e5c46b42ae5..41c5c72f9d0 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs @@ -2,6 +2,9 @@ use super::*; use egui::{Modifiers, PointerButton, Pos2, vec2}; use egui_kittest::{Harness, kittest::Queryable}; +#[path = "zoom_menu_anchor_tests.rs"] +mod zoom_menu_anchor_tests; + fn app() -> PhotocraftApp { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); @@ -399,8 +402,8 @@ fn selection_menus_follow_photoshop_order() { Some("Save Selection…"), Some("Make Work Path…"), None, - Some("Layer via Copy"), - Some("Layer via Cut"), + Some("Layer Via Copy"), + Some("Layer Via Cut"), Some("New Layer…"), None, Some("Free Transform"), @@ -535,7 +538,8 @@ fn zoom_and_hand_right_click_opens_the_view_menu() { let inside = screen_of(h.state(), [300.0, 100.0]); right_click(&mut h, inside, Modifiers::NONE); let menu = h.state().ui.canvas_tool_menu.clone().unwrap_or_else(|| panic!("{tool:?}: right-click opens no menu")); - assert!(rows(&menu) == VIEW_MENU, "{tool:?}: the view menu"); + // The Hand's menu has only the fixed zooms (#12022). + assert!(rows(&menu) == if tool == Tool::Hand { HAND_MENU } else { VIEW_MENU }, "{tool:?}: the view menu"); assert_eq!(h.state().ui.views[0].zoom, zoom, "{tool:?}: the right-click alone does not zoom"); assert!(entry_enabled(h.state(), &menu, FILL_SCREEN) && entry_enabled(h.state(), &menu, "view.printSize")); // Outside the image the pasteboard colour menu wins, as for the other tools. @@ -547,11 +551,12 @@ fn zoom_and_hand_right_click_opens_the_view_menu() { } #[test] -fn view_menu_zoom_in_keeps_the_clicked_point_under_the_pointer() { +fn view_menu_zoom_in_keeps_the_view_centre() { let mut app = zoom_app(400, 300); app.ui.tool = Tool::Zoom; let mut h = harness(app); let zoom = h.state().ui.views[0].zoom; + let center = h.state().ui.views[0].center; let doc = [300.0, 100.0]; let p = screen_of(h.state(), doc); right_click(&mut h, p, Modifiers::NONE); @@ -561,8 +566,7 @@ fn view_menu_zoom_in_keeps_the_clicked_point_under_the_pointer() { assert!(h.state().ui.canvas_tool_menu.is_none()); let after = h.state().ui.views[0].zoom; assert_eq!(after, crate::zoom_levels::step(zoom, 1, [400, 300]), "one Zoom In step"); - let s = screen_of(h.state(), doc); - assert!((s - p).length() < 1.0, "the right-clicked point stays put: {s:?} vs {p:?}"); + assert_eq!(h.state().ui.views[0].center, center); assert_eq!(h.state().session.journal.last().map(|(id, _)| id.as_str()), Some("view.zoomIn")); // Zoom Out returns to where it was. right_click(&mut h, p, Modifiers::NONE); @@ -573,12 +577,13 @@ fn view_menu_zoom_in_keeps_the_clicked_point_under_the_pointer() { } #[test] -fn animated_zoom_off_view_menu_keeps_the_anchor_without_a_transition() { +fn animated_zoom_off_view_menu_keeps_the_centre_without_a_transition() { for command in ["view.zoomIn", "view.zoomOut", FILL_SCREEN] { let mut app = zoom_app(400, 300); app.ui.tool = Tool::Zoom; app.run("prefs.set", json!({"values": {"general.animatedZoom": false, "tools.zoomClickedPointToCenter": false}})).unwrap(); let mut h = harness(app); + let center = h.state().ui.views[0].center; let doc = [300.0, 100.0]; let p = screen_of(h.state(), doc); right_click(&mut h, p, Modifiers::NONE); @@ -591,17 +596,18 @@ fn animated_zoom_off_view_menu_keeps_the_anchor_without_a_transition() { assert_eq!((xf.zoom, xf.center), (v.zoom, v.center), "{command}"); assert!(v.zoom_anchor.is_none() && v.zoom_transition.is_none(), "{command}"); if command != FILL_SCREEN { - assert!(screen_of(h.state(), doc).distance(p) < 1.0, "{command}: preserve the clicked point"); + assert_eq!(v.center, center, "{command}: preserve the view centre"); } } } #[test] -fn view_menu_zoom_keeps_its_anchor_during_the_animation() { +fn view_menu_zoom_keeps_its_centre_during_the_animation() { let mut app = zoom_app(400, 300); app.ui.tool = Tool::Zoom; let mut h = harness(app); let zoom = h.state().ui.views[0].zoom; + let center = h.state().ui.views[0].center; let doc = [300.0, 100.0]; let p = screen_of(h.state(), doc); right_click(&mut h, p, Modifiers::NONE); @@ -613,7 +619,7 @@ fn view_menu_zoom_keeps_its_anchor_during_the_animation() { h.step(); let displayed = crate::canvas::ViewXform::active(h.state()).unwrap(); assert!(displayed.zoom > zoom && displayed.zoom < h.state().ui.views[0].zoom); - assert!(displayed.to_screen(doc[0], doc[1]).distance(p) < 1.0); + assert!(displayed.to_screen(center[0], center[1]).distance(displayed.rect.center()) < 1.0); h.input_mut().time = Some(ctx.input(|i| i.time) + 0.3); h.step(); assert!(!h.state().ui.views[0].zoom_animating); @@ -622,7 +628,8 @@ fn view_menu_zoom_keeps_its_anchor_during_the_animation() { #[test] fn view_menu_zoom_in_centres_the_clicked_point_with_the_preference() { let mut app = zoom_app(400, 300); - app.ui.tool = Tool::Hand; + // Zoom In is on the Zoom tool's menu only (#12022). + app.ui.tool = Tool::Zoom; app.run("prefs.set", json!({"path": "tools.zoomClickedPointToCenter", "value": true})).unwrap(); let mut h = harness(app); let doc = [300.0, 100.0]; diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs new file mode 100644 index 00000000000..d5039a6257c --- /dev/null +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/zoom_menu_anchor_tests.rs @@ -0,0 +1,196 @@ +use super::*; + +#[test] +fn zoom_menu_steps_keep_the_view_centre_and_history() { + for animated in [false, true] { + for offset in [vec2(-160.0, -100.0), vec2(140.0, 80.0)] { + let mut app = zoom_app(2400, 1800); + app.ui.tool = Tool::Zoom; + app.run("prefs.set", json!({"values": {"general.animatedZoom": animated, "tools.zoomClickedPointToCenter": false}})).unwrap(); + let mut h = harness(app); + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [928.0, 686.0]; + v.request_zoom_animation(false); + h.step(); + let center = h.state().ui.views[0].center; + let st = h.state().session.active().unwrap(); + let history = (st.history.past_len(), st.history.can_redo(), st.revision); + for (label, zoom) in [("Zoom In", 2.0), ("Zoom Out", 1.0), ("Zoom Out", 2.0 / 3.0)] { + let p = crate::canvas::ViewXform::active(h.state()).unwrap().rect.center() + offset; + right_click(&mut h, p, Modifiers::NONE); + h.get_by_label(label).click(); + h.run_steps(3); + let v = &h.state().ui.views[0]; + assert_eq!(v.zoom, zoom, "{label}: one ladder stop"); + assert_eq!(v.center, center, "{label}: the right-click must not move the view centre"); + let xf = crate::canvas::ViewXform::active(h.state()).unwrap(); + assert!(xf.to_screen(center[0], center[1]).distance(xf.rect.center()) < 1.0); + assert!(h.state().ui.canvas_tool_menu.is_none()); + let st = h.state().session.active().unwrap(); + assert_eq!((st.history.past_len(), st.history.can_redo(), st.revision), history); + } + } + } +} + +#[test] +fn clicked_point_zoom_keeps_its_anchor_and_hand_menu_rejects_step_commands() { + for (tool, center_on_point) in [(Tool::Zoom, true), (Tool::Hand, true), (Tool::Hand, false)] { + for (command, direction) in [("view.zoomIn", 1), ("view.zoomOut", -1)] { + let mut app = zoom_app(400, 300); + app.ui.tool = tool; + app.run("prefs.set", json!({"values": {"tools.zoomClickedPointToCenter": center_on_point, "tools.overscroll": true}})).unwrap(); + let mut h = harness(app); + let before = h.state().ui.views[0].clone(); + let p = screen_of(h.state(), [300.0, 100.0]); + right_click(&mut h, p, Modifiers::NONE); + let doc = h.state().ui.canvas_tool_menu.as_ref().unwrap().anchor.unwrap().map(|n| n as f32); + let ctx = h.ctx.clone(); + if tool == Tool::Hand { + // #12022 gives Hand only fixed zoom rows. An injected step command must not run. + assert!(!available(h.state(), h.state().ui.canvas_tool_menu.as_ref().unwrap(), command)); + choose(h.state_mut(), &ctx, command); + let v = &h.state().ui.views[0]; + assert_eq!((v.zoom, v.center), (before.zoom, before.center), "Hand must reject {command}"); + continue; + } + choose(h.state_mut(), &ctx, command); + let v = &h.state().ui.views[0]; + assert_eq!(v.zoom, crate::zoom_levels::step(before.zoom, direction, [400, 300])); + let expected = if center_on_point { doc } else { std::array::from_fn(|i| doc[i] - (doc[i] - before.center[i]) * before.zoom / v.zoom) }; + assert_eq!(v.center, expected, "{tool:?}: {command}"); + assert!(v.zoom_animating); + assert_eq!(v.zoom_anchor.is_some(), !center_on_point); + } + } +} + +#[test] +fn zoom_click_and_alt_click_still_anchor_at_the_pointer() { + for animated in [false, true] { + for (modifiers, direction) in [(Modifiers::NONE, 1), (Modifiers::ALT, -1)] { + let mut app = zoom_app(400, 300); + app.ui.tool = Tool::Zoom; + app.run("prefs.set", json!({"values": {"general.animatedZoom": animated, "tools.zoomClickedPointToCenter": false, "tools.overscroll": true}})) + .unwrap(); + let mut h = harness(app); + let zoom = h.state().ui.views[0].zoom; + let doc = [300.0, 100.0]; + let p = screen_of(h.state(), doc); + h.event(egui::Event::ModifiersChanged(modifiers)); + h.event(egui::Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers }); + h.step(); + } + h.input_mut().time = Some(h.ctx.input(|i| i.time) + 0.3); + h.step(); + assert_eq!(h.state().ui.views[0].zoom, crate::zoom_levels::step(zoom, direction, [400, 300])); + assert!(screen_of(h.state(), doc).distance(p) < 1.0); + assert!(h.state().ui.canvas_tool_menu.is_none()); + assert_eq!(h.state().session.active().unwrap().history.past_len(), 0); + } + } +} + +#[test] +fn held_zoom_keys_still_open_no_view_menu() { + for tool in [Tool::Brush, Tool::Hand] { + for modifiers in [Modifiers::COMMAND, Modifiers::ALT] { + let mut app = zoom_app(400, 300); + app.ui.tool = tool; + let mut h = harness(app); + let modifiers = + Modifiers { mac_cmd: cfg!(target_os = "macos") && modifiers.command, ctrl: !cfg!(target_os = "macos") && modifiers.command, ..modifiers }; + h.event(egui::Event::ModifiersChanged(modifiers)); + h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers }); + h.step(); + assert!(matches!(crate::hold_keys::held_tool(h.state(), &h.ctx), Some(crate::hold_keys::Temporary::ZoomIn | crate::hold_keys::Temporary::ZoomOut))); + let before = h.state().ui.views[0].clone(); + let p = screen_of(h.state(), [300.0, 100.0]); + right_click(&mut h, p, modifiers); + assert!(h.state().ui.canvas_tool_menu.is_none()); + assert_eq!(h.state().ui.tool, tool); + let v = &h.state().ui.views[0]; + assert_eq!((v.zoom, v.center), (before.zoom, before.center)); + } + } +} + +#[test] +fn held_zoom_with_zoom_selected_keeps_the_menu_pressed_point_anchor() { + for animated in [false, true] { + for (command, direction) in [("view.zoomIn", 1), ("view.zoomOut", -1)] { + let mut app = zoom_app(400, 300); + app.ui.tool = Tool::Zoom; + app.run("prefs.set", json!({"values": {"general.animatedZoom": animated, "tools.zoomClickedPointToCenter": false, "tools.overscroll": true}})) + .unwrap(); + let mut h = harness(app); + h.event(egui::Event::ModifiersChanged(Modifiers::ALT)); + h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::ALT }); + h.step(); + assert_eq!(crate::hold_keys::held_tool(h.state(), &h.ctx), Some(crate::hold_keys::Temporary::ZoomOut)); + let zoom = h.state().ui.views[0].zoom; + let center = h.state().ui.views[0].center; + let doc = [300.0, 100.0]; + let p = screen_of(h.state(), doc); + h.event(egui::Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::ALT }); + h.step(); + } + assert!(h.state().ui.canvas_tool_menu.as_ref().is_some_and(|m| m.tool == Tool::Zoom)); + assert_eq!(crate::hold_keys::held_tool(h.state(), &h.ctx), Some(crate::hold_keys::Temporary::ZoomOut)); + let ctx = h.ctx.clone(); + choose(h.state_mut(), &ctx, command); + assert_eq!(h.state().ui.views[0].zoom, crate::zoom_levels::step(zoom, direction, [400, 300])); + assert_ne!(h.state().ui.views[0].center, center, "{command}: preserve the held tool's pressed-point behavior"); + h.step(); + h.input_mut().time = Some(ctx.input(|i| i.time) + 0.11); + h.step(); + assert!(screen_of(h.state(), doc).distance(p) < 1.0, "{command}: anchor during animation"); + h.input_mut().time = Some(ctx.input(|i| i.time) + 0.3); + h.step(); + assert!(screen_of(h.state(), doc).distance(p) < 1.0, "{command}: settled anchor"); + assert_eq!(h.state().ui.tool, Tool::Zoom); + assert_eq!(h.state().session.active().unwrap().history.past_len(), 0); + } + } +} + +#[test] +fn held_z_menu_keeps_its_existing_pressed_point_anchor() { + for label in ["Zoom In", "Zoom Out"] { + let mut app = zoom_app(400, 300); + app.ui.tool = Tool::Brush; + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default()); + h.run_steps(4); + h.event(egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + assert_eq!(h.state().ui.tool, Tool::Zoom); + assert!(h.state().ui.spring.as_ref().is_some_and(|s| s.previous == Tool::Brush)); + let doc = [300.0, 100.0]; + let p = screen_of(h.state(), doc); + right_click(&mut h, p, Modifiers::NONE); + h.get_by_label(label).click(); + h.run_steps(3); + assert!(screen_of(h.state(), doc).distance(p) < 1.0, "{label}: a held Z keeps today's anchor"); + h.event(egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().ui.tool, Tool::Brush); + } +} diff --git a/crates/ui-egui/src/canvas/cursors.rs b/crates/ui-egui/src/canvas/cursors.rs index e692c4fe070..f5f3b3912cc 100644 --- a/crates/ui-egui/src/canvas/cursors.rs +++ b/crates/ui-egui/src/canvas/cursors.rs @@ -112,10 +112,11 @@ pub(super) fn eyedropper_ring(app: &mut PhotocraftApp, painter: &egui::Painter, const RING_GREY: Color32 = Color32::from_gray(127); /// Tools on which holding ⌥ (Alt) switches to the Eyedropper: a click or drag sets the -/// foreground colour, as with the Eyedropper itself (#417). +/// foreground colour, as with the Eyedropper itself (#417, the Background Eraser #11906, the Color +/// Replacement tool #11914). pub(crate) fn alt_samples(tool: Tool, mods: egui::Modifiers) -> bool { // Control+Alt is the brush-resize drag (`brush_resize`), not sampling. - mods.alt && !mods.ctrl && matches!(tool, Tool::Brush | Tool::Pencil | Tool::Gradient | Tool::PaintBucket) + mods.alt && !mods.ctrl && matches!(tool, Tool::Brush | Tool::Pencil | Tool::Gradient | Tool::PaintBucket | Tool::BackgroundEraser | Tool::ColorReplacement) } /// Preferences › Cursors › Other Cursors asks for the precise crosshair, or Caps Lock swaps the @@ -289,3 +290,11 @@ pub(crate) fn alt_picks(tool: Tool, mods: egui::Modifiers) -> bool { #[cfg(test)] #[path = "../eyedropper_mask_sample_tests.rs"] mod eyedropper_mask_sample_tests; + +#[cfg(test)] +#[path = "../color_replacement_alt_click_tests.rs"] +mod color_replacement_alt_click_tests; + +#[cfg(test)] +#[path = "../background_eraser_alt_click_tests.rs"] +mod background_eraser_alt_click_tests; diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 8be1a51c5d1..fdc718be0f0 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -86,6 +86,12 @@ impl Drag { } } + /// The document and target a vector drag was pressed on are still the current ones + /// ([`VectorGestureOwner`]); false for a drag that captured none. + pub(crate) fn vector_owner_valid(&self, app: &PhotocraftApp) -> bool { + self.vector_owner.as_ref().is_some_and(|owner| owner.valid(app)) + } + /// Reposition: move the start and every point so the last one lands on `to` (same size). pub fn shift_to(&mut self, to: [f64; 2]) { let last = self.points.last().map_or(self.start, |p| [p[0], p[1]]); diff --git a/crates/ui-egui/src/canvas/filter_preview.rs b/crates/ui-egui/src/canvas/filter_preview.rs index 3a6ee54d97a..641fa91720a 100644 --- a/crates/ui-egui/src/canvas/filter_preview.rs +++ b/crates/ui-egui/src/canvas/filter_preview.rs @@ -58,8 +58,10 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e } params = crate::select_mask_view::request_params(&d.fields, params); } + crate::actions::authorize_panel_preview(app, d.id, &d.fields, &cmd, ¶ms).ok()?; let st = app.session.documents().get(idx)?; - let k = filter_preview_factor(app, &st.doc, &cmd, ¶ms); + // Fade previews at full size: it blends one area, like OK does, and has no proxy (#8230). + let k = if cmd == FADE { 1 } else { filter_preview_factor(app, &st.doc, &cmd, ¶ms) }; let request = crate::filter_dialog::FilterPreviewKey { doc: st.doc.id, revision: st.revision, @@ -69,6 +71,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e command: cmd, params, k, + tool_colors: (app.session.tools.foreground, app.session.tools.background), }; let key = request.doc.0 ^ (1u64 << 61); // Every filter's preview computes off the UI thread in the desktop app: Gaussian Blur at @@ -111,19 +114,24 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e let documents: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); let color = app.session.color.settings.clone(); let task = request.clone(); + let fade = fade_job(app, &task); if let Err(error) = app.filter_preview_worker.start(request.clone(), ctx.clone(), move |cancel| { let t0 = crate::gpu_canvas::now_ms(); - let result = crate::filter_dialog::preview_document_for_target( - &doc, - task.active, - &task.command, - &task.params, - task.k, - Some(cancel), - task.target, - &documents, - &color, - ) + let result = match fade { + Some(fade) => fade.and_then(|f| f.render().ok()), + None => crate::filter_dialog::preview_document_for_target( + &doc, + task.active, + &task.command, + &task.params, + task.k, + Some(cancel), + task.target, + &documents, + &color, + Some(task.tool_colors), + ), + } .map(|doc| { let buffer = photocraft_compose::flatten(&doc); (std::sync::Arc::new(doc), buffer) @@ -154,17 +162,21 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e let doc = app.session.documents().get(idx)?.doc.clone(); let documents: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); let t0 = crate::gpu_canvas::now_ms(); - let computed = crate::filter_dialog::preview_document_for_target( - &doc, - request.active, - &request.command, - &request.params, - request.k, - None, - request.target, - &documents, - &app.session.color.settings, - ) + let computed = match fade_job(app, &request) { + Some(fade) => fade.and_then(|f| f.render().ok()), + None => crate::filter_dialog::preview_document_for_target( + &doc, + request.active, + &request.command, + &request.params, + request.k, + None, + request.target, + &documents, + &app.session.color.settings, + Some(request.tool_colors), + ), + } .map(|doc| { let buffer = photocraft_compose::flatten(&doc); (std::sync::Arc::new(doc), buffer) @@ -176,6 +188,15 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e Some((preview.key.k, key, [result.size.width, result.size.height])) } +const FADE: &str = "edit.fade"; + +/// Edit › Fade blends back towards the pixels before the last step, which only the session's +/// history holds, so its preview is captured from the session here (cheap: the copy shares its +/// tiles) and blended where filter previews compute (#8230). `None` for every other command. +fn fade_job(app: &PhotocraftApp, request: &crate::filter_dialog::FilterPreviewKey) -> Option> { + (request.command == FADE).then(|| photocraft_engine::edit_menu_cmds::fade_preview(&app.session, &request.params).ok()) +} + /// Channel overlays must use the filter's current proxy too, including on the CPU canvas. /// The cache key changes with the completed request; unchanged frames reuse the overlay texture. pub(crate) fn filter_channel_preview(app: &mut PhotocraftApp, idx: usize, ctx: &egui::Context) -> Option<(std::sync::Arc, u64)> { diff --git a/crates/ui-egui/src/canvas/gesture.rs b/crates/ui-egui/src/canvas/gesture.rs index b67aec4bebd..0ba47dadd9e 100644 --- a/crates/ui-egui/src/canvas/gesture.rs +++ b/crates/ui-egui/src/canvas/gesture.rs @@ -133,7 +133,9 @@ fn finish(app: &mut PhotocraftApp, mut d: Drag, click: Option) { json!({"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0, "mode": mode, "line": line, "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::Patch if crate::retouch_ui::patch_drags_selection(app, d.start, d.modifiers) => { + crate::retouch_ui::finish_patch(app, d.start, crate::retouch_ui::patch_end(&d, [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]]) } @@ -144,7 +146,12 @@ fn finish(app: &mut PhotocraftApp, mut d: Drag, click: Option) { let o = &app.ui.tool_options; // The Patch and Content-Aware Move tools have no Feather in their options bar. let feather = if d.tool == Tool::Lasso { o.feather } else { 0.0 }; - let made = app.run("select.lasso", json!({"points": pts, "mode": mode, "antiAlias": o.anti_alias, "feather": feather})); + let mut params = json!({"points": pts, "mode": mode, "antiAlias": o.anti_alias, "feather": feather}); + // The Patch tool's lasso is a step of that tool in History; the Lasso tool keeps "Lasso". + if d.tool == Tool::Patch { + params["historyName"] = json!("Patch Tool"); + } + let made = app.run("select.lasso", params); // The lasso only outlines the patch; say that it is dragged next (#1715). if made.is_ok() && d.tool != Tool::Lasso { crate::retouch_ui::patch_hint(app); diff --git a/crates/ui-egui/src/canvas/guide_select/tests.rs b/crates/ui-egui/src/canvas/guide_select/tests.rs index a591f47547e..1038d3000ad 100644 --- a/crates/ui-egui/src/canvas/guide_select/tests.rs +++ b/crates/ui-egui/src/canvas/guide_select/tests.rs @@ -230,6 +230,31 @@ fn edit_selected_guides_opens_new_guide_on_the_guide_and_applies_the_edit() { assert_eq!(selected(&app), Some((true, 1)), "the edited guide stays selected"); } +/// #10440: Edit Selected Guides' position takes arithmetic as New Guide's does, a leading bare +/// number in the ruler unit. +#[test] +fn edit_selected_guides_takes_arithmetic_in_the_position() { + let mut app = app(); + app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "percent"})).unwrap(); + click(&mut app, 50.0, 70.0); + let id = crate::new_guide_ui::open_edit(&mut app).unwrap(); + let mut edited = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + // "30 + 10" with percent rulers is 40 % of the 200 px width. + edited.insert("position".into(), json!("30 + 10")); + assert_eq!(crate::new_guide_ui::params(&app, &edited)["position"], "30 % + 10"); + app.ui.dialogs.retain(|d| d.id != id); + crate::new_guide_ui::confirm(&mut app, &edited).unwrap(); + assert_eq!(guides(&app), (vec![80.0], vec![30.0])); + assert_eq!(selected(&app), Some((true, 0)), "the edited guide stays selected"); + // A unit on the first operand wins: 100 % of the width less 50 px. + let id = crate::new_guide_ui::open_edit(&mut app).unwrap(); + let mut edited = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + edited.insert("position".into(), json!("100% - 50px")); + app.ui.dialogs.retain(|d| d.id != id); + crate::new_guide_ui::confirm(&mut app, &edited).unwrap(); + assert_eq!(guides(&app), (vec![150.0], vec![30.0])); +} + #[test] fn editing_an_uncoloured_guide_keeps_the_preference_colour_unless_one_is_chosen() { let mut app = app(); diff --git a/crates/ui-egui/src/canvas/live_frame_tests.rs b/crates/ui-egui/src/canvas/live_frame_tests.rs new file mode 100644 index 00000000000..a5ccec19eb5 --- /dev/null +++ b/crates/ui-egui/src/canvas/live_frame_tests.rs @@ -0,0 +1,96 @@ +//! Live painting tools show this frame's pointer input in this frame (no extra frame of delay), +//! and the deferred image follows the animated camera. + +use super::*; +use serde_json::json; + +#[test] +fn live_brush_texture_includes_this_frames_press_and_moves() { + for tool in [Tool::Brush, Tool::MixerBrush] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 128, "height": 64, "background": "white"})).unwrap(); + app.run("tools.setColors", json!({"foreground": "#ff0000"})).unwrap(); + app.run("tools.setBrush", json!({"brush": {"size": 12, "hardness": 1.0, "smoothing": {"amount": 0.0}}})).unwrap(); + app.ui.tool = tool; + app.sync_views(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 640.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|fonts| fonts.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + let p = h.state().last_canvas_rect.center(); + h.input_mut().events.push(egui::Event::PointerMoved(p)); + h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + let shown = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>| { + let app = h.state_mut(); + let live = app.live_stroke.as_ref().expect("painting starts on press"); + let key = live.display_key(); + let doc = live.stroke.doc().clone(); + let (_, display) = canvas_display(app, &doc, None); + assert_eq!(app.canvases.values().next().unwrap().tex_preview_key, key ^ display, "{tool:?}: texture includes this frame's input"); + }; + shown(&mut h); + h.input_mut().events.push(egui::Event::PointerMoved(p + vec2(25.0, 0.0))); + h.run_steps(1); + shown(&mut h); + let end = p + vec2(25.0, 0.0); + h.input_mut().events.push(egui::Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + assert!(h.state().live_stroke.is_none()); + // The Brush keeps the shown pixels as its committed stroke; the Mixer Brush commits the + // pixels its live preview rendered (`gesture.rs`). + if tool == Tool::Brush { + assert!(h.state().committed_stroke.is_some(), "release reuses the shown pixels"); + } + let label = h.state().session.active().and_then(|st| st.history.undo_label()).map(str::to_owned); + assert_eq!(label.as_deref(), Some(if tool == Tool::MixerBrush { "Mixer Brush" } else { "Brush Tool" })); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); + } +} + +#[test] +fn live_tools_paint_the_interpolated_camera_during_zoom_animation() { + for tool in [Tool::Brush, Tool::MixerBrush] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap(); + app.run("prefs.set", json!({"values": {"general.animatedZoom": true}})).unwrap(); + app.sync_views(); + app.ui.tool = tool; + let v = &mut app.ui.views[0]; + v.fit_pending = false; + v.zoom = 2.0; + v.center = [32.0, 32.0]; + v.doc_size = [64, 64]; + v.zoom_animating = true; + v.display_camera = Some((1.0, [32.0, 32.0])); + v.zoom_transition = Some(crate::state::ZoomTransition { from: (1.0, [32.0, 32.0]), target: (2.0, [32.0, 32.0]), started: 0.0 }); + let view = v.clone(); + let ctx = egui::Context::default(); + let tex = ensure_texture(&mut app, &ctx, 0, None).unwrap().0; + let rect = Rect::from_min_size(Pos2::ZERO, vec2(500.0, 400.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), time: Some(0.1), ..Default::default() }, |ui| { + canvas_view(&mut app, ui, 0, rect, view.clone(), true); + }); + out.textures_delta.clear(); + let mesh = out + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Mesh(mesh) if mesh.texture_id == tex => Some(mesh), + _ => None, + }) + .unwrap(); + let min = mesh.vertices.iter().map(|v| v.pos.x).fold(f32::INFINITY, f32::min); + let max = mesh.vertices.iter().map(|v| v.pos.x).fold(f32::NEG_INFINITY, f32::max); + let shown = app.ui.views[0].display_camera.unwrap().0; + assert!(shown > 1.0 && shown < 2.0, "{tool:?}: animation is between endpoints"); + assert!(((max - min) - 64.0 * shown).abs() < 0.01, "{tool:?}: image must use the animated camera, width {} at zoom {shown}", max - min); + } +} diff --git a/crates/ui-egui/src/canvas/paint.rs b/crates/ui-egui/src/canvas/paint.rs index a091602313f..7366dafdd33 100644 --- a/crates/ui-egui/src/canvas/paint.rs +++ b/crates/ui-egui/src/canvas/paint.rs @@ -660,7 +660,7 @@ pub(super) fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter // Patch / Content-Aware Move dragging the selection: its outline follows the pointer. Tool::Patch | Tool::ContentAwareMove if crate::retouch_ui::patch_drags_selection(app, d.start, d.modifiers) => { let start = d.start; - let [dx, dy] = crate::retouch_ui::patch_offset(app, start, last); + let [dx, dy] = crate::retouch_ui::patch_offset(app, start, crate::retouch_ui::patch_end(d, last)); if let Some((_, _, segs)) = &app.outline_cache { let moved: Vec = segs.iter().map(|(a, b)| ([a[0] + dx, a[1] + dy], [b[0] + dx, b[1] + dy])).collect(); marching_ants_segments(painter, xf, &moved, painter.ctx().input(|i| i.time)); diff --git a/crates/ui-egui/src/canvas/paint_mouse.rs b/crates/ui-egui/src/canvas/paint_mouse.rs index fa644f35aba..a9d08184348 100644 --- a/crates/ui-egui/src/canvas/paint_mouse.rs +++ b/crates/ui-egui/src/canvas/paint_mouse.rs @@ -41,6 +41,8 @@ pub fn sync_tool_brush(app: &mut PhotocraftApp) { // in Normal mode: each tool's Mode starts at Normal, so a Multiply Brush doesn't make the // first Clone Stamp stroke multiply (or give Healing a mode its menu doesn't have). // The Art History Brush also starts at full opacity (#10263): its Opacity is its own. + // The Background Eraser starts with its own measured tip instead (#11911). + None if tool == Tool::BackgroundEraser => fresh_brush(tool, ¤t), None => { let opacity = if tool == Tool::ArtHistoryBrush { BrushSettings::default().opacity } else { current.opacity }; BrushSettings { smoothing: first_smoothing(), mode: photocraft_color::BlendMode::Normal, opacity, ..current } @@ -67,10 +69,24 @@ fn session_fields(current: &BrushSettings, brush: &BrushSettings) -> BrushSettin BrushSettings { color: current.color, background: current.background, erase: current.erase, seed: current.seed, ..brush.clone() } } -/// A tool's brush as it starts out (Reset Tool, #2825): the default brush at Photoshop's -/// smoothing, with the session's own fields of `current`. -pub(crate) fn fresh_brush(current: &BrushSettings) -> BrushSettings { - session_fields(current, &BrushSettings { smoothing: first_smoothing(), ..BrushSettings::default() }) +/// The Brush tool's tip after Reset Tool, as the reference measures it (#11810): size, hardness +/// and spacing of a 13 px soft round at 25 % spacing. +const BRUSH_TOOL_TIP: (f32, f32, f32) = (13.0, 0.0, 0.25); +/// The Background Eraser's tip after Reset Tool (#11911): a 13 px hard round at 25 % spacing. +const BACKGROUND_ERASER_TIP: (f32, f32, f32) = (13.0, 1.0, 0.25); + +/// `tool`'s brush as it starts out (Reset Tool, #2825): the default brush at Photoshop's +/// smoothing, with the session's own fields of `current`. The Brush tool's tip is +/// [`BRUSH_TOOL_TIP`], the Background Eraser's [`BACKGROUND_ERASER_TIP`]; the other tools keep the +/// default tip. +pub(crate) fn fresh_brush(tool: Tool, current: &BrushSettings) -> BrushSettings { + let mut brush = BrushSettings { smoothing: first_smoothing(), ..BrushSettings::default() }; + match tool { + Tool::Brush => (brush.size, brush.hardness, brush.spacing) = BRUSH_TOOL_TIP, + Tool::BackgroundEraser => (brush.size, brush.hardness, brush.spacing) = BACKGROUND_ERASER_TIP, + _ => {} + } + session_fields(current, &brush) } /// Photoshop's 10 % stroke smoothing, what a tool's brush starts at. @@ -339,7 +355,8 @@ pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool) // ⌘/Ctrl+right-click lists the layers under the pointer instead (layer_pick_ui.rs, #307). let layer_menu = crate::layer_pick_ui::is_gesture(tool, mods); // Decided by the modifiers held when the button went down, not by those of the release frame that reports the click. - let mode_menu = crate::brush_mode_menu::is_gesture(tool, crate::workspace_ui::sticky_mods(app, crate::brush_mode_menu::gesture_mods(&response.ctx, mods))); + let mode_menu = + crate::brush_mode_menu::opens_on(app, tool, crate::workspace_ui::sticky_mods(app, crate::brush_mode_menu::gesture_mods(&response.ctx, mods))); if mode_menu && !resizing && app.drag.is_none() { app.secondary_erase = false; if let Some(p) = right_click_at(response) { @@ -416,7 +433,7 @@ pub fn pointer_secondary(app: &mut PhotocraftApp, ctx: &egui::Context, down: boo app.brush_resize_armed = down && app.drag.is_none(); return true; } - if crate::brush_mode_menu::is_gesture(tool, mods) && app.drag.is_none() && app.brush_resize.is_none() { + if crate::brush_mode_menu::opens_on(app, tool, mods) && app.drag.is_none() && app.brush_resize.is_none() { app.secondary_erase = false; if down { crate::brush_mode_menu::open(app, ctx, at); @@ -472,8 +489,15 @@ pub fn show_picker(app: &mut PhotocraftApp, ctx: &egui::Context) { let before = app.session.tools.brush.clone(); let mut b = before.clone(); let list = &mut app.ui.brush_picker_list; - let frame = egui::Frame::popup(ui.style()) - .show(ui, |ui| crate::brush_picker::body(ui, &mut b, &app.session.tools.presets, app.session.tools.current_preset.as_deref(), list, content)); + let frame = egui::Frame::popup(ui.style()).show(ui, |ui| { + // The Background Eraser has its own compact popup, without presets (#11910). + if app.ui.tool == Tool::BackgroundEraser { + crate::bg_eraser_popup::body(ui, &mut b, &mut app.ui.tool_options.bg_tolerance_control); + Vec::new() + } else { + crate::brush_picker::body(ui, &mut b, &app.session.tools.presets, app.session.tools.current_preset.as_deref(), list, content) + } + }); let picks = frame.inner; // The corner grip resizes the picker, and the size is remembered across opens. if let Some(new) = crate::brush_picker::resize_grip(ui, frame.response.rect, content) { diff --git a/crates/ui-egui/src/canvas/paint_mouse/tests.rs b/crates/ui-egui/src/canvas/paint_mouse/tests.rs index eb2b33048d9..8880183d293 100644 --- a/crates/ui-egui/src/canvas/paint_mouse/tests.rs +++ b/crates/ui-egui/src/canvas/paint_mouse/tests.rs @@ -1027,3 +1027,59 @@ fn a_right_click_moved_and_released_in_one_frame_still_opens_its_menu() { assert_eq!(h.state().ui.brush_picker, None); assert_eq!(h.state().ui.canvas_tool_menu, None); } + +/// #11897: Clear chosen on a pixel layer, then the Background activated: a Brush stroke there +/// paints the foreground, and the Mode reads Normal afterwards. +#[test] +fn chosen_clear_paints_normally_on_the_background_and_the_mode_reads_normal() { + let mut h = harness(None); + h.state_mut().ui.tool_options.brush_clear = true; + let bg = h.state().session.active().unwrap().doc.background().unwrap().id; + h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap(); + h.run_steps(2); + let (a, b) = drag(&mut h, PointerButton::Primary); + assert!(!h.state().ui.tool_options.brush_clear, "the Mode reads Normal"); + let last = strokes(&h).last().cloned().unwrap(); + assert!(last.get("mode").is_none() && !last["erase"].as_bool().unwrap(), "{last}"); + let app = h.state(); + let v = app.ui.views[0].clone(); + let d = ((a + (b - a) * 0.5) - app.last_canvas_rect.center()) / v.zoom; + let st = app.session.active().unwrap(); + let px = st.doc.layer(bg).unwrap().surface().unwrap().rgba((d.x + v.center[0]) as i32, (d.y + v.center[1]) as i32); + assert!(px[2] > 0.9 && px[0] < 0.1, "the foreground blue, not the background colour: {px:?}"); +} + +/// #11897: a Background with Lock All refuses the stroke, so a chosen Clear stays chosen. +#[test] +fn chosen_clear_stays_on_a_locked_background() { + let mut h = harness(None); + h.state_mut().ui.tool_options.brush_clear = true; + let bg = h.state().session.active().unwrap().doc.background().unwrap().id; + h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap(); + h.state_mut().run("layer.lockLayers", json!({"all": true})).unwrap(); + h.run_steps(2); + drag(&mut h, PointerButton::Primary); + assert!(h.state().ui.tool_options.brush_clear, "the Mode still reads Clear"); +} + +/// #11897: a held `~` on the Background keeps erasing to the background colour. +#[test] +fn held_tilde_on_the_background_paints_the_background_colour() { + let mut h = harness(None); + h.state_mut().run("tools.setColors", json!({"background": "#00ff00"})).unwrap(); + let bg = h.state().session.active().unwrap().doc.background().unwrap().id; + h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap(); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::Backtick, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + let (a, b) = drag(&mut h, PointerButton::Primary); + h.event(egui::Event::Key { key: egui::Key::Backtick, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert_eq!(strokes(&h).last().unwrap()["mode"], json!("clear")); + let app = h.state(); + let v = app.ui.views[0].clone(); + let d = ((a + (b - a) * 0.5) - app.last_canvas_rect.center()) / v.zoom; + let st = app.session.active().unwrap(); + let px = st.doc.layer(bg).unwrap().surface().unwrap().rgba((d.x + v.center[0]) as i32, (d.y + v.center[1]) as i32); + assert!(px[1] > 0.9 && px[0] < 0.1 && px[2] < 0.1, "the background green, not the foreground blue: {px:?}"); +} diff --git a/crates/ui-egui/src/canvas/patch_preview.rs b/crates/ui-egui/src/canvas/patch_preview.rs index b669a60ed38..942acc4da4d 100644 --- a/crates/ui-egui/src/canvas/patch_preview.rs +++ b/crates/ui-egui/src/canvas/patch_preview.rs @@ -30,14 +30,14 @@ pub(crate) struct PatchPreview { } /// The offset of the current Patch drag, if one is dragging the patch (not drawing a lasso). -fn drag_offset(app: &mut PhotocraftApp) -> Option<[i32; 2]> { +pub(crate) fn drag_offset(app: &mut PhotocraftApp) -> Option<[i32; 2]> { // Content-Aware patches are background jobs: the canvas shows the result when it is done. if app.ui.tool_options.patch_content_aware { return None; } let d = app.drag.as_ref().filter(|d| d.tool == Tool::Patch)?; let (start, mods) = (d.start, d.modifiers); - let end = d.points.last().map_or(start, |p| [p[0], p[1]]); + let end = crate::retouch_ui::patch_end(d, d.points.last().map_or(start, |p| [p[0], p[1]])); if !crate::retouch_ui::patch_drags_selection(app, start, mods) { return None; } diff --git a/crates/ui-egui/src/canvas/snap_ui.rs b/crates/ui-egui/src/canvas/snap_ui.rs index 989af47e1c9..f1a8917b637 100644 --- a/crates/ui-egui/src/canvas/snap_ui.rs +++ b/crates/ui-egui/src/canvas/snap_ui.rs @@ -146,6 +146,7 @@ fn is_point_tool(t: Tool) -> bool { | Tool::EllipseShape | Tool::Triangle | Tool::Polygon + | Tool::Star | Tool::Line | Tool::Type | Tool::VerticalType @@ -334,7 +335,10 @@ pub(crate) fn snap_click(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers /// the pixel grid. fn pixel_round(app: &PhotocraftApp, p: [f64; 2]) -> [f64; 2] { if app.session.prefs().tools.snap_vector_tools_and_transforms_to_pixel_grid - && matches!(app.ui.tool, Tool::Pen | Tool::CurvaturePen | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line) + && matches!( + app.ui.tool, + Tool::Pen | Tool::CurvaturePen | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Star | Tool::Line + ) { [p[0].round(), p[1].round()] } else { diff --git a/crates/ui-egui/src/canvas/snap_ui/tests.rs b/crates/ui-egui/src/canvas/snap_ui/tests.rs index b42264a1888..bd809a30fc7 100644 --- a/crates/ui-egui/src/canvas/snap_ui/tests.rs +++ b/crates/ui-egui/src/canvas/snap_ui/tests.rs @@ -947,7 +947,7 @@ fn alt_over_the_box_shows_the_reference_point_pointer() { for p in [[55.0, 52.0], [30.0, 30.0], [200.0, 200.0]] { assert_eq!(crate::transform_tool::cursor(&app, &xf, p, alt), Some(egui::CursorIcon::Crosshair), "{p:?}"); } - assert_eq!(crate::transform_tool::cursor(&app, &xf, [55.0, 52.0], egui::Modifiers::NONE), Some(egui::CursorIcon::Move)); + assert_eq!(crate::transform_tool::cursor(&app, &xf, [55.0, 52.0], egui::Modifiers::NONE), Some(egui::CursorIcon::Default)); assert_ne!(crate::transform_tool::cursor(&app, &xf, [40.0, 40.0], alt), Some(egui::CursorIcon::Crosshair), "a corner handle stays a handle"); } diff --git a/crates/ui-egui/src/canvas/start_screen.rs b/crates/ui-egui/src/canvas/start_screen.rs index 64fbe541e5a..b033500d03a 100644 --- a/crates/ui-egui/src/canvas/start_screen.rs +++ b/crates/ui-egui/src/canvas/start_screen.rs @@ -274,13 +274,9 @@ fn render_first_run_content(app: &mut PhotocraftApp, ui: &mut egui::Ui, content_ }); } -/// Wayland has no native file drops (winit 0.30, #386), so the hint offers File › Open and paste. -pub(crate) fn start_screen_drop_hint(app: &PhotocraftApp) -> std::borrow::Cow<'static, str> { - if app.services.is_wayland { - crate::i18n::fmt(tl!("Use File › Open, or paste a copied image with {paste}."), &[("paste", &crate::notices::paste_hint(app))]).into() - } else { - tl!("Drop an image or PSD anywhere to open it.").into() - } +/// Hint displayed on the start screen for opening files. +pub(crate) fn start_screen_drop_hint(_app: &PhotocraftApp) -> std::borrow::Cow<'static, str> { + tl!("Drop an image or PSD anywhere to open it.").into() } /// Recent files listed on the Home screen. diff --git a/crates/ui-egui/src/canvas/tabs.rs b/crates/ui-egui/src/canvas/tabs.rs index 497de354305..631a7dac5b5 100644 --- a/crates/ui-egui/src/canvas/tabs.rs +++ b/crates/ui-egui/src/canvas/tabs.rs @@ -736,7 +736,7 @@ fn drop_slot_line(app: &mut PhotocraftApp, ui: &egui::Ui) { if ui.input(|i| i.raw.hovered_files.is_empty()) { return; } - let at = app.services.cursor_pos.as_mut().and_then(|f| f(ui.ctx())); + let at = app.cursor_pos(ui.ctx()); let crate::file_open::DropTarget::Tabs(slot) = app.drop_target(ui.ctx(), at) else { return }; let Some(tabs) = app.tab_strip.as_ref().map(|s| &s.tabs) else { return }; let Some((r, after)) = tabs.iter().find(|(i, _)| *i == slot).map(|&(_, r)| (r, false)).or_else(|| tabs.last().map(|&(_, r)| (r, true))) else { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 661da5d2773..e652fb536f0 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -276,6 +276,7 @@ fn every_filter_previews_off_the_ui_thread_and_a_newer_value_cancels_the_old() { command: "filter.blur.gaussianBlur".into(), params: json!({"radius": 999.0}), k: 1, + tool_colors: (app.session.tools.foreground, app.session.tools.background), }; let (told, cancelled) = std::sync::mpsc::channel(); app.filter_preview_worker @@ -329,6 +330,7 @@ fn gated_blur_preview(app: &mut PhotocraftApp, ctx: &egui::Context) -> (std::syn command: command.into(), params, k: 1, + tool_colors: (app.session.tools.foreground, app.session.tools.background), }; let doc = app.session.documents()[0].doc.clone(); let documents: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); @@ -347,6 +349,7 @@ fn gated_blur_preview(app: &mut PhotocraftApp, ctx: &egui::Context) -> (std::syn key.target, &documents, &Default::default(), + Some(key.tool_colors), ) .map(|d| { let buffer = photocraft_compose::flatten(&d); @@ -396,6 +399,169 @@ fn a_held_preview_frame_stays_on_the_canvas_when_the_proxy_factor_changes() { assert_eq!((k, size), (1, [2400, 2200])); } +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn indexed_matte_preview_matches_commit_and_refreshes_tool_colors_inline_and_on_worker() { + for background in [false, true] { + for depth in [8, 16, 32] { + for matte in ["foreground", "background"] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.background_jobs = background; + app.run("file.new", json!({"width": 64, "height": 48, "depth": depth, "background": "transparent"})).unwrap(); + app.run("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap(); + app.run("edit.colorSettings", json!({"workingRgb": "display-p3"})).unwrap(); + app.session + .edit("Translucent edge", |doc, active| { + let format = doc.pixel_format(); + doc.layer_mut(active.unwrap()) + .unwrap() + .surface_mut() + .unwrap() + .fill_rect(photocraft_geom::Rect::new(0, 0, 32, 48), &photocraft_raster::from_rgba(&format, [1.0, 0.0, 0.0, 0.5])); + Ok(()) + }) + .unwrap(); + let source = app.session.active().unwrap().doc.clone(); + let params = json!({"palette": "exact", "forced": "none", "dither": "none", "matte": matte}); + crate::filter_dialog::open(&mut app, "image.mode.indexedColor").unwrap(); + app.ui.dialogs.last_mut().unwrap().fields.extend(params.as_object().unwrap().clone()); + let ctx = egui::Context::default(); + let mut previous = None; + for (foreground, background_color) in [("#3366cc", "#cc6633"), ("#26bf59", "#bf26a6")] { + app.session.execute("tools.setColors", json!({"foreground": foreground, "background": background_color})).unwrap(); + let colors = (app.session.tools.foreground, app.session.tools.background); + let shown = ensure_filter_preview(&mut app, 0, &ctx); + if background { + assert!(shown.is_none(), "old tool colors must not remain visible"); + } + drive_until(&mut app, &ctx, "the current matte colors", |app| { + app.filter_preview.as_ref().is_some_and(|p| p.key.tool_colors == colors && p.result.is_some()) + }); + let preview = app.filter_preview.as_ref().unwrap().result.as_ref().unwrap(); + let mut applied = photocraft_engine::Session::new(); + applied.color.settings = app.session.color.settings.clone(); + applied.tools.foreground = colors.0; + applied.tools.background = colors.1; + applied.add_document((*source).clone(), None); + applied.execute("image.mode.indexedColor", params.clone()).unwrap(); + let committed = &applied.active().unwrap().doc; + assert_eq!(preview.icc_profile, source.icc_profile); + assert_eq!(preview.color_table, committed.color_table); + let actual = preview.layers[0].surface().unwrap().read_region(preview.bounds()); + let expected = committed.layers[0].surface().unwrap().read_region(committed.bounds()); + assert_eq!(actual, expected, "{matte}, {depth}-bit, worker={background}"); + if let Some(previous) = previous { + assert_ne!(actual, previous, "changing the selected tool color refreshes the pixels"); + } + previous = Some(actual); + assert_eq!(app.session.active().unwrap().doc.mode, photocraft_doc::ColorMode::Rgb, "preview does not commit"); + } + } + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn cross_scale_indexed_matte_preview_rejects_old_tool_colors_from_cached_and_worker_results() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.background_jobs = true; + app.run("file.new", json!({"width": 2400, "height": 2200, "depth": 8, "background": "transparent"})).unwrap(); + app.run("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap(); + app.run("edit.colorSettings", json!({"workingRgb": "display-p3"})).unwrap(); + app.session + .edit("Translucent edge", |doc, active| { + let format = doc.pixel_format(); + doc.layer_mut(active.unwrap()) + .unwrap() + .surface_mut() + .unwrap() + .fill_rect(photocraft_geom::Rect::new(0, 0, 32, 48), &photocraft_raster::from_rgba(&format, [1.0, 0.0, 0.0, 0.5])); + Ok(()) + }) + .unwrap(); + app.ui.views.get_mut(0).expect("the new document's view").zoom = 0.25; + let params = json!({"palette": "exact", "forced": "none", "dither": "none", "matte": "foreground"}); + crate::filter_dialog::open(&mut app, "image.mode.indexedColor").unwrap(); + app.ui.dialogs.last_mut().unwrap().fields.extend(params.as_object().unwrap().clone()); + app.session.execute("tools.setColors", json!({"foreground": "#3366cc", "background": "#cc6633"})).unwrap(); + let ctx = egui::Context::default(); + drive_until(&mut app, &ctx, "the first indexed preview", |a| a.filter_preview.as_ref().is_some_and(|p| p.result.is_some())); + let old = app.filter_preview.as_ref().expect("the first preview").key.clone(); + assert_eq!(old.k, 2, "the zoomed-out large document uses the reduced preview"); + + app.session.execute("tools.setColors", json!({"foreground": "#26bf59", "background": "#bf26a6"})).unwrap(); + app.ui.views.get_mut(0).expect("the new document's view").zoom = 1.0; + let new_colors = (app.session.tools.foreground, app.session.tools.background); + let new = crate::filter_dialog::FilterPreviewKey { k: 1, tool_colors: new_colors, ..old.clone() }; + assert_ne!(old.tool_colors, new.tool_colors); + assert_ne!(old.k, new.k, "the cache and request use different preview factors"); + + // Keep the current worker pending so the only possible frame is the cached old-color one. + let (release_new, wait_new) = std::sync::mpsc::channel(); + app.filter_preview_worker + .start(new.clone(), ctx.clone(), move |_| { + wait_new.recv_timeout(std::time::Duration::from_secs(60)).expect("the test releases the job"); + crate::dialogs::filter_preview_worker::Computed { result: None, ms: 0.0 } + }) + .expect("the worker starts"); + assert!(ensure_filter_preview(&mut app, 0, &ctx).is_none(), "the cached old-color frame is not painted while the new worker runs"); + release_new.send(()).expect("released"); + drive_until(&mut app, &ctx, "the new worker to finish", |a| !a.filter_preview_worker.busy()); + assert_eq!(app.filter_preview.as_ref().expect("the current request").key, new); + assert!(app.filter_preview.as_ref().unwrap().result.is_none()); + + // Start an old-color worker while its request is current, then move the dialog to a new + // cross-scale request before the old result lands. Its real preview must not replace the + // current request through the loose worker-result gate. + app.session.execute("tools.setColors", json!({"foreground": "#3366cc", "background": "#cc6633"})).unwrap(); + app.ui.views.get_mut(0).expect("the new document's view").zoom = 0.25; + let old_worker = old.clone(); + let doc = app.session.documents()[0].doc.clone(); + let documents: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); + let color = app.session.color.settings.clone(); + let (release_old, wait_old) = std::sync::mpsc::channel(); + app.filter_preview_worker + .start(old_worker.clone(), ctx.clone(), move |_| { + wait_old.recv_timeout(std::time::Duration::from_secs(60)).expect("the test releases the job"); + let result = crate::filter_dialog::preview_document_for_target( + &doc, + old_worker.active, + &old_worker.command, + &old_worker.params, + old_worker.k, + None, + old_worker.target, + &documents, + &color, + Some(old_worker.tool_colors), + ) + .map(|doc| { + let buffer = photocraft_compose::flatten(&doc); + (std::sync::Arc::new(doc), buffer) + }); + crate::dialogs::filter_preview_worker::Computed { result, ms: 0.0 } + }) + .expect("the worker starts"); + assert!(ensure_filter_preview(&mut app, 0, &ctx).is_none(), "the old cached request is not current"); + + app.session.execute("tools.setColors", json!({"foreground": "#26bf59", "background": "#bf26a6"})).unwrap(); + app.ui.views.get_mut(0).expect("the new document's view").zoom = 1.0; + app.ui.dialogs[0].fields.insert("matte".into(), json!("background")); + let mut new_late = new.clone(); + new_late.params.as_object_mut().unwrap().insert("matte".into(), json!("background")); + assert!(ensure_filter_preview(&mut app, 0, &ctx).is_none(), "the current request has no result while the old worker runs"); + release_old.send(()).expect("released"); + drive_until(&mut app, &ctx, "the stale old-color worker to finish", |a| { + if let Some(shown) = &a.filter_preview { + assert_eq!(shown.key.tool_colors, new.tool_colors, "the stale old-color worker result was not painted"); + assert_ne!(shown.key.k, old.k, "the stale old-scale worker result was not painted"); + } + a.filter_preview.as_ref().is_some_and(|p| p.key == new_late && p.result.is_some()) && !a.filter_preview_worker.busy() + }); + assert_eq!(app.filter_preview.as_ref().expect("the current request").key, new_late); +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn a_preview_computed_mid_drag_reaches_the_canvas_and_then_converges() { @@ -502,6 +668,7 @@ fn a_mid_drag_empty_result_leaves_the_preview_alone() { command: "filter.blur.gaussianBlur".into(), params: json!({"radius": moved}), k: 1, + tool_colors: (app.session.tools.foreground, app.session.tools.background), }; let (release_empty, wait) = std::sync::mpsc::channel::<()>(); app.filter_preview_worker @@ -1409,14 +1576,11 @@ fn clone_overlay_is_clipped_to_a_shaped_tip() { } #[test] -fn wayland_start_screen_hint_does_not_claim_file_drop_works() { +fn wayland_start_screen_hint_offers_file_drop() { let x11 = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); let wayland = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services { is_wayland: true, ..Default::default() }); assert!(start_screen_drop_hint(&x11).starts_with("Drop")); - // Pasting a copied image file works on Wayland (#338): the hint says how. - let hint = start_screen_drop_hint(&wayland); - assert!(!hint.contains("Drop"), "{hint}"); - assert!(hint.ends_with(&format!("paste a copied image with {}.", crate::notices::paste_hint(&wayland))), "{hint}"); + assert!(start_screen_drop_hint(&wayland).starts_with("Drop")); } /// A tiling window manager can hand PhotoCraft a canvas narrower than the Home card (460 @@ -3072,3 +3236,53 @@ fn torn_off_document_is_not_drawn_again_in_the_main_window() { assert_eq!(tabs, Some(vec![1]), "{kind:?}: only the docked document stays on the main tab strip"); } } + +/// #8230: Edit › Fade opens with a Preview checkbox, and the canvas shows the layer faded with the +/// dialog's opacity, at full size, on the background worker and the inline path alike. Cancel +/// leaves the document, its history and Fade itself as they were; OK applies what was shown. +#[test] +fn fade_dialog_previews_the_faded_layer_and_cancel_keeps_the_document() { + for background in [false, true] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + app.run("edit.fill", json!({"color": "#000000"})).unwrap(); + app.background_jobs = background; + let ctx = egui::Context::default(); + let st = app.session.active().unwrap(); + let (layer, steps, revision) = (st.active_layer.unwrap(), st.history.past_len(), st.revision); + let id = crate::filter_dialog::open(&mut app, "edit.fade").expect("Fade has a dialog"); + let d = app.ui.dialog_mut(id).expect("the dialog"); + assert_eq!(d.fields.get("__preview"), Some(&json!(true)), "a Preview checkbox, on"); + d.fields.insert("opacity".into(), json!(25)); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let (k, size) = loop { + if let Some((k, _, size)) = ensure_filter_preview(&mut app, 0, &ctx) + && app.filter_preview.as_ref().is_some_and(|p| p.key.params.get("opacity") == Some(&json!(25))) + { + break (k, size); + } + assert!(std::time::Instant::now() < deadline, "background jobs {background}: no preview"); + std::thread::sleep(std::time::Duration::from_millis(1)); + }; + assert_eq!((k, size), (1, [40, 30]), "background jobs {background}: full size"); + let shown = app.filter_preview.as_ref().and_then(|p| p.result.as_ref()).expect("a result").layer(layer).unwrap().surface().unwrap().pixel(3, 3); + assert!((shown[0] - 0.75).abs() < 0.01, "background jobs {background}: {shown:?}"); + + // Cancel: nothing changed, nothing previewed, and Fade is still there to use. + app.ui.close_dialog(id); + assert!(ensure_filter_preview(&mut app, 0, &ctx).is_none(), "background jobs {background}: no preview after Cancel"); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(layer).unwrap().surface().unwrap().pixel(3, 3)[0], 0.0, "background jobs {background}: the layer is untouched"); + assert_eq!((st.history.past_len(), st.revision), (steps, revision), "background jobs {background}: no history step"); + assert!(app.session.is_enabled("edit.fade"), "background jobs {background}: Fade is still available"); + + // OK applies the faded layer as one step. + let id = crate::filter_dialog::open(&mut app, "edit.fade").unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("opacity".into(), json!(25)); + crate::dialogs::confirm(&mut app, id).unwrap(); + let st = app.session.active().unwrap(); + let faded = st.doc.layer(layer).unwrap().surface().unwrap().pixel(3, 3)[0]; + assert!((faded - shown[0]).abs() < 1e-6, "background jobs {background}: OK applies the preview ({faded})"); + assert_eq!(st.history.past_len(), steps + 1); + } +} diff --git a/crates/ui-egui/src/canvas/tool_cursor.rs b/crates/ui-egui/src/canvas/tool_cursor.rs index 84b69b26cf8..c34f4d66229 100644 --- a/crates/ui-egui/src/canvas/tool_cursor.rs +++ b/crates/ui-egui/src/canvas/tool_cursor.rs @@ -111,6 +111,16 @@ pub(crate) fn close_path(painter: &Painter, at: Pos2) -> CursorIcon { outline(painter, at, vec![close_ring()], true) } +/// The Pen's pointer over an endpoint a click would carry the path on from (#9356): the centre +/// cross with a short slash below right, as Photoshop's pen shows one there. +pub(crate) fn continue_path(painter: &Painter, at: Pos2) -> CursorIcon { + outline(painter, at, vec![continue_slash()], true) +} + +fn continue_slash() -> Vec { + [(6.5, 12.5), (11.5, 5.5), (12.5, 6.5), (7.5, 13.5)].into_iter().map(|(x, y)| vec2(x, y)).collect() +} + fn close_ring() -> Vec { (0..16).map(|i| (i as f32 / 16.0 * std::f32::consts::TAU, vec2(9.0, 9.0))).map(|(a, c)| c + vec2(a.cos(), a.sin()) * 3.5).collect() } diff --git a/crates/ui-egui/src/canvas/tool_cursor/tests.rs b/crates/ui-egui/src/canvas/tool_cursor/tests.rs index bd5b40c97ea..ec003d48454 100644 --- a/crates/ui-egui/src/canvas/tool_cursor/tests.rs +++ b/crates/ui-egui/src/canvas/tool_cursor/tests.rs @@ -1140,3 +1140,16 @@ mod benchmark_stats { assert_eq!(percentile(&[4.5], 0.95), 4.5, "one sample"); } } + +/// #9356: the continue-path pointer is the centre cross with a slash below right. +#[test] +fn continue_path_bitmap_has_the_centre_cross_and_a_slash_beside_it() { + for scale in [1.0, 2.0] { + let image = rasterize(&Shape::Outline { rings: vec![continue_slash()].into(), centre: true }, scale).unwrap(); + let [x, y] = image.hotspot; + let at = |dx: f32, dy: f32| alpha(&image, x + (dx * scale) as u16, y + (dy * scale) as u16); + assert!(at(0.0, 0.0) > 230, "the centre cross"); + assert!(at(9.5, 9.5) > 0, "the slash"); + assert_eq!(at(0.0, 9.0), 0, "apart from the cross"); + } +} diff --git a/crates/ui-egui/src/canvas/tool_feedback.rs b/crates/ui-egui/src/canvas/tool_feedback.rs index 6c9c8905712..88aec042e57 100644 --- a/crates/ui-egui/src/canvas/tool_feedback.rs +++ b/crates/ui-egui/src/canvas/tool_feedback.rs @@ -19,6 +19,8 @@ pub enum Badge { Intersect, /// Four arrows: a press drags the point under the pointer (the Curvature Pen over an anchor). Move, + /// One right-pointing arrow: a press drags the patch (the Patch tool over the selection). + Arrow, } impl Badge { @@ -122,9 +124,12 @@ pub fn badge(app: &PhotocraftApp, tool: Tool, m: egui::Modifiers) -> Option Vec<[Pos2; 2]> { } v } + Badge::Arrow => { + let a = h * 0.6; + vec![[c - vec2(h, 0.0), c + vec2(h, 0.0)], [c + vec2(h, 0.0), c + vec2(h - a, a)], [c + vec2(h, 0.0), c + vec2(h - a, -a)]] + } Badge::Intersect => { let d = h * 0.8; vec![[c + vec2(-d, -d), c + vec2(d, d)], [c + vec2(-d, d), c + vec2(d, -d)]] @@ -319,3 +328,7 @@ mod tests { assert!(e.len() >= 16 && e.iter().all(|p| p.x >= -0.01 && p.x <= 100.01 && p.y >= -0.01 && p.y <= 50.01)); } } + +#[cfg(test)] +#[path = "../patch_cursor_badge_tests.rs"] +mod patch_cursor_badge_tests; diff --git a/crates/ui-egui/src/canvas/tool_input.rs b/crates/ui-egui/src/canvas/tool_input.rs index f148b8dfa5c..0f8a04f7707 100644 --- a/crates/ui-egui/src/canvas/tool_input.rs +++ b/crates/ui-egui/src/canvas/tool_input.rs @@ -359,6 +359,42 @@ fn handle_eyedropper_event(app: &mut PhotocraftApp, tool: Tool, ev: ToolEvent, m true } +/// A Mixer Brush press the engine refused: Indexed Color and Bitmap documents answer it with the +/// reference's alert (#11958); anything else goes to the status bar, as before. +fn mixer_press_failed(app: &mut PhotocraftApp, message: String) { + use photocraft_engine::brush_cmds::mixer_live::{BITMAP_REFUSAL, INDEXED_REFUSAL}; + if message == INDEXED_REFUSAL || message == BITMAP_REFUSAL { + // One alert, even if more presses arrive while it is open (`ui.pointer` can send them). + let shown = + app.ui.dialogs.iter().any(|d| d.kind == crate::state::DialogKind::Error && d.fields.get("message").and_then(|m| m.as_str()) == Some(&message)); + if !shown { + let params = json!({ "message": message }).as_object().cloned().unwrap_or_default(); + app.ui.open_dialog(crate::state::DialogKind::Error, params); + } + } else { + app.ui.status = message; + app.ui.status_error = true; + } +} + +/// The Brush's chosen Clear paints as Normal on the Background, and its Mode then reads Normal +/// (#11897): at the press of a Brush stroke on the Background's own pixels, Clear (chosen in the +/// options bar, not a held `~`) is turned off, so the stroke is sent as Normal. A Background whose +/// pixels are locked refuses the stroke, so Clear stays chosen there. +fn clear_off_on_background(app: &mut PhotocraftApp, tool: Tool) { + let on_background = app.session.active().is_some_and(|st| { + st.active_layer.is_some_and(|id| { + st.doc.is_background(id) && { + let locks = st.doc.effective_locks(id); + !locks.pixels && !locks.all + } + }) + }); + if tool == Tool::Brush && app.ui.tool_options.brush_clear && !app.held_erase && on_background && paint_target(app) == serde_json::json!("pixels") { + app.ui.tool_options.brush_clear = false; + } +} + /// Tool state machine. Shared by mouse input and automation. pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { if consumes_cancelled_vector_event(app, ev) { @@ -399,9 +435,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) // (⌥-click with the Clone Stamp or Healing Brush only sets the source). let sets_source = matches!(tool, Tool::CloneStamp | Tool::Healing) && mods.alt; // Painting on or moving a hidden layer is refused at the press, as in Photoshop (#571). - if !sets_source && let Some(why) = hidden_target(app, tool) { - app.ui.status = why.into(); - app.ui.status_error = true; + if !sets_source && press_refused(app, tool) { return; } if !sets_source && crate::rasterize_prompt::intercept(app, tool, x, y, pressure) { @@ -524,6 +558,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) _ => {} } crate::paint_mouse::sync_tool_brush(app); + clear_off_on_background(app, tool); // Clear mode (`~` held, or chosen in the options bar) erases with the tool's own brush. let clear = crate::paint_mouse::clears(app, tool); let erase = std::mem::take(&mut app.secondary_erase) || tool == Tool::Eraser || clear; @@ -563,8 +598,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) && !LiveMixer::defers_to_release(&app.session, &p).is_ok_and(|deferred| deferred) { if let Err(e) = LiveMixer::begin(&app.session, &p) { - app.ui.status = e.to_string(); - app.ui.status_error = true; + mixer_press_failed(app, e.to_string()); } app.drag = None; } @@ -680,6 +714,23 @@ fn hidden_target(app: &PhotocraftApp, tool: Tool) -> Option<&'static str> { photocraft_engine::hidden_target::refusal(&app.session, cmd, &json!({})) } +/// The press-time refusals: a hidden target layer (#571), and for the Brush a locked layer or a +/// group's pixels. The Brush says why in an alert, as in Photoshop (#11898); the other tools say +/// a hidden target in the status bar. True when the press is refused. +fn press_refused(app: &mut PhotocraftApp, tool: Tool) -> bool { + let hidden = hidden_target(app, tool); + if let Some(message) = crate::brush_refusal::alert(app, tool, hidden.is_some()) { + crate::brush_refusal::prompt(app, &message); + return true; + } + if let Some(why) = hidden { + app.ui.status = why.into(); + app.ui.status_error = true; + return true; + } + false +} + /// Is document point `p` inside the active document's selection? fn inside_selection(app: &PhotocraftApp, p: [f64; 2]) -> bool { app.session.active().and_then(|st| st.doc.selection.as_ref()).is_some_and(|sel| covers(sel, p)) diff --git a/crates/ui-egui/src/canvas/transform_controls.rs b/crates/ui-egui/src/canvas/transform_controls.rs index 97325bdb407..90e4e4fb434 100644 --- a/crates/ui-egui/src/canvas/transform_controls.rs +++ b/crates/ui-egui/src/canvas/transform_controls.rs @@ -1,6 +1,6 @@ use super::selection::floating_outline; use super::view_xform::ViewXform; -use super::{Color32, LayerContent, PhotocraftApp, Pos2, Rect, Stroke, Tool, Vec2, pos2, vec2}; +use super::{Color32, LayerContent, PhotocraftApp, Pos2, Rect, Stroke, Tool, Vec2, vec2}; /// View › Show › Layer Edges: the colour of the active layer's outline. pub(crate) const LAYER_EDGES: Color32 = Color32::from_rgb(0x2d, 0x8c, 0xeb); @@ -75,50 +75,53 @@ pub(super) fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Op Some(Rect::from_two_pos(xf.to_screen(b.x0 as f32, b.y0 as f32), xf.to_screen(b.x1 as f32, b.y1 as f32))) } -/// The Move tool shows transform handles even before a Free Transform session exists. -/// Use the same hit areas for pointer-down and hover, so a control never behaves like -/// resize/rotate while showing the ordinary four-arrow Move cursor (#2871). +/// Express the unopened screen rectangle in the same corner order as a transform quad. +fn controls_quad(r: Rect) -> [[f64; 2]; 4] { + [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(|p| [f64::from(p.x), f64::from(p.y)]) +} + +/// Classify screen coordinates with the fixed screen-space tolerance, without a pivot. +fn controls_zone(r: Rect, p: Pos2) -> crate::transform_tool::Hit { + crate::state::TransformSession::box_hit(&controls_quad(r), [f64::from(p.x), f64::from(p.y)], crate::transform_tool::HANDLE_PX) +} + +/// Share the open session's screen-sized zones before the first interaction (#11097). pub(super) fn transform_controls_cursor(r: Rect, p: Pos2) -> Option { - let handles = [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()]; - let grab = crate::transform_tool::HANDLE_PX as f32; - for (i, handle) in handles.into_iter().enumerate() { - if handle.distance(p) <= grab { - return Some(match i { - 0 | 4 => egui::CursorIcon::ResizeNwSe, - 2 | 6 => egui::CursorIcon::ResizeNeSw, - 1 | 5 => egui::CursorIcon::ResizeVertical, - _ => egui::CursorIcon::ResizeHorizontal, - }); - } - } - // The rotation band: the pointer gives way to the curved rotate arrow (`transform_controls_turn`). - in_rotation_band(r, p).then_some(egui::CursorIcon::None) -} - -fn in_rotation_band(r: Rect, p: Pos2) -> bool { - r.expand(18.0).contains(p) && !r.expand(5.0).contains(p) -} - -/// In the rotation band just outside the Move tool's transform controls, the screen direction the -/// curved rotate arrow bends round: into the box along the nearest corner's bisector, as Free -/// Transform draws it (#4658). Before, the band showed a plain alias pointer, so nothing said a -/// drag there rotates. -pub(super) fn transform_controls_turn(r: Rect, p: Pos2) -> Option { - if !in_rotation_band(r, p) - || [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()] - .into_iter() - .any(|h| h.distance(p) <= crate::transform_tool::HANDLE_PX as f32) - { + if !p.is_finite() { return None; } - let corner = pos2(if p.x < r.center().x { r.left() } else { r.right() }, if p.y < r.center().y { r.top() } else { r.bottom() }); - Some(vec2((r.center().x - corner.x).signum(), (r.center().y - corner.y).signum()).normalized() * 16.0) + use crate::transform_tool::Hit; + Some(match controls_zone(r, p) { + Hit::Corner(0 | 2) => egui::CursorIcon::ResizeNwSe, + Hit::Corner(_) => egui::CursorIcon::ResizeNeSw, + Hit::Edge(0 | 2) => egui::CursorIcon::ResizeVertical, + Hit::Edge(_) => egui::CursorIcon::ResizeHorizontal, + Hit::Outside => egui::CursorIcon::None, + Hit::Inside | Hit::Pivot => egui::CursorIcon::Default, + }) } -/// A visible handle starts scaling; the narrow band just outside the box starts rotation. -/// The interior stays the normal Move-tool drag target. +/// At any distance outside the controls, the same edge/corner direction as Free Transform. +pub(super) fn transform_controls_turn(r: Rect, p: Pos2) -> Option { + if controls_zone(r, p) != crate::transform_tool::Hit::Outside { + return None; + } + let k = crate::state::TransformSession::box_turn_direction(&controls_quad(r), [f64::from(p.x), f64::from(p.y)])?; + Some(vec2(k[0] as f32, k[1] as f32) * 16.0) +} + +/// Resize from the shared handle zones and keep Move's existing rotation press band. +/// Outside presses beyond that band still reach the Move tool, even where hover paints a turn arc. pub(super) fn transform_controls_hit(r: Rect, p: Pos2) -> bool { - transform_controls_cursor(r, p).is_some() + if !p.is_finite() { + return false; + } + use crate::transform_tool::Hit; + match controls_zone(r, p) { + Hit::Corner(_) | Hit::Edge(_) => true, + Hit::Outside => r.expand(18.0).contains(p) && !r.expand(5.0).contains(p), + Hit::Inside | Hit::Pivot => false, + } } /// The reference application's alert when a Move-tool transform control is dragged on a frame @@ -177,3 +180,7 @@ pub(super) fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::P painter.rect_stroke(h, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Inside); } } + +#[cfg(test)] +#[path = "../transform_hit_zone_tests.rs"] +mod hit_zone_tests; diff --git a/crates/ui-egui/src/canvas/transform_controls_tests.rs b/crates/ui-egui/src/canvas/transform_controls_tests.rs index ecfbfe056de..2bb92e0d8b9 100644 --- a/crates/ui-egui/src/canvas/transform_controls_tests.rs +++ b/crates/ui-egui/src/canvas/transform_controls_tests.rs @@ -328,21 +328,20 @@ fn move_transform_controls_show_resize_and_rotate_cursors_before_first_interacti (r.center_bottom(), egui::CursorIcon::ResizeVertical), (r.right_center(), egui::CursorIcon::ResizeHorizontal), (r.left_center(), egui::CursorIcon::ResizeHorizontal), - // Between handles: the rotation band (a point above the top-centre handle is that handle), - // where the curved rotate arrow replaces the pointer (#4658). + // Just beyond the outward edge band, the curved rotate arrow replaces the pointer (#4658). (pos2(r.left() + r.width() * 0.25, r.top() - 12.0), egui::CursorIcon::None), ] { assert_eq!(transform_controls_cursor(r, p), Some(expected), "hover at {p:?}"); assert!(transform_controls_hit(r, p), "same region must start a transform"); } - assert_eq!(transform_controls_cursor(r, r.center()), None, "interior stays Move"); - assert_eq!(transform_controls_cursor(r, pos2(r.center().x, r.top() - 30.0)), None); - // The rotate arrow bends round the nearest corner, pointing into the box; handles and the - // interior have none. - let into = |p: Pos2| transform_controls_turn(r, p).map(|v| (v.x.signum(), v.y.signum())); - assert_eq!(into(pos2(r.left() + r.width() * 0.25, r.top() - 12.0)), Some((1.0, 1.0)), "top left"); - assert_eq!(into(pos2(r.right() + 12.0, r.bottom() - r.height() * 0.25)), Some((-1.0, -1.0)), "bottom right"); - assert_eq!(into(pos2(r.left() - 12.0, r.bottom() + 12.0)), Some((1.0, -1.0)), "beyond the bottom-left corner"); + assert_eq!(transform_controls_cursor(r, r.center()), Some(egui::CursorIcon::Default), "interior uses the default arrow"); + assert_eq!(transform_controls_cursor(r, pos2(r.center().x, r.top() - 30.0)), Some(egui::CursorIcon::None)); + // The rotate arrow points into the nearest edge or corner. Handles and the interior have none. + let into = |p: Pos2| transform_controls_turn(r, p); + assert_eq!(into(pos2(r.left() + r.width() * 0.25, r.top() - 12.0)), Some(vec2(0.0, 16.0)), "top edge"); + assert_eq!(into(pos2(r.right() + 12.0, r.bottom() - r.height() * 0.25)), Some(vec2(-16.0, 0.0)), "right edge"); + let corner = into(pos2(r.left() - 12.0, r.bottom() + 12.0)).unwrap(); + assert!(corner.x > 0.0 && corner.y < 0.0, "beyond the bottom-left corner"); assert_eq!(into(r.left_top()), None); assert_eq!(into(r.center()), None); @@ -351,7 +350,7 @@ fn move_transform_controls_show_resize_and_rotate_cursors_before_first_interacti let next = transform_controls_rect(&app, &xf).expect("new active shape controls"); assert_ne!(r, next); assert_eq!(transform_controls_cursor(next, next.right_bottom()), Some(egui::CursorIcon::ResizeNwSe)); - assert_eq!(transform_controls_cursor(next, next.center()), None); + assert_eq!(transform_controls_cursor(next, next.center()), Some(egui::CursorIcon::Default)); } #[test] diff --git a/crates/ui-egui/src/color_replacement_alt_click_tests.rs b/crates/ui-egui/src/color_replacement_alt_click_tests.rs new file mode 100644 index 00000000000..8fd1533a027 --- /dev/null +++ b/crates/ui-egui/src/color_replacement_alt_click_tests.rs @@ -0,0 +1,77 @@ +//! #11914: holding Alt turns the Color Replacement tool into the Eyedropper. Alt+click and +//! Alt+drag set the foreground colour, add no history state and change no pixel; Ctrl+Alt keeps +//! its brush-resize meaning and a plain click still paints. + +use egui::Modifiers; +use serde_json::json; + +use super::*; +use crate::canvas::{ToolEvent, tool_event}; + +/// A 400 × 300 RGB document: a green layer (30,180,60), blue foreground, the Color Replacement tool. +fn app() -> 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(); + app.run("edit.fill", json!({"color": "#1eb43c"})).unwrap(); + app.run("tools.setColors", json!({"foreground": "#0000ff"})).unwrap(); + app.ui.tool = Tool::ColorReplacement; + app.sync_views(); + app +} + +fn steps(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +fn foreground(app: &PhotocraftApp) -> [u8; 3] { + let c = app.session.tools.foreground; + [0, 1, 2].map(|i| (c[i] * 255.0).round() as u8) +} + +fn pixel(app: &PhotocraftApp, x: i32, y: i32) -> [u8; 3] { + let st = app.session.active().unwrap(); + let c = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y); + [0, 1, 2].map(|i| (c[i] * 255.0).round() as u8) +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], mods: Modifiers) { + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, mods); +} + +#[test] +fn alt_click_and_drag_pick_the_foreground() { + let mut app = app(); + let before = steps(&app); + drag(&mut app, [300.0, 60.0], [300.0, 60.0], Modifiers::ALT); + assert_eq!(foreground(&app), [30, 180, 60], "the green under the click"); + assert_eq!(steps(&app), before, "no history state"); + assert_eq!(pixel(&app, 300, 60), [30, 180, 60], "no pixel changed"); + // A drag samples as it goes, as the Eyedropper does. + app.run("tools.setColors", json!({"foreground": "#0000ff"})).unwrap(); + drag(&mut app, [100.0, 100.0], [200.0, 150.0], Modifiers::ALT); + assert_eq!(foreground(&app), [30, 180, 60]); + assert_eq!(steps(&app), before); +} + +#[test] +fn alt_shows_the_eyedropper_and_ctrl_alt_resizes() { + let alt = Modifiers::ALT; + let ctrl_alt = Modifiers { alt: true, ctrl: true, ..Modifiers::NONE }; + assert!(alt_samples(Tool::ColorReplacement, alt)); + assert!(alt_picks(Tool::ColorReplacement, alt), "the cursor shows the eyedropper while Alt is held"); + assert!(!alt_samples(Tool::ColorReplacement, ctrl_alt), "Ctrl+Alt is the brush-resize drag"); + assert!(!alt_samples(Tool::ColorReplacement, Modifiers::NONE)); +} + +#[test] +fn a_plain_click_still_paints() { + let mut app = app(); + let before = steps(&app); + drag(&mut app, [300.0, 60.0], [310.0, 60.0], Modifiers::NONE); + assert_eq!(steps(&app), before + 1, "a Color Replacement state"); + assert_ne!(pixel(&app, 300, 60), [30, 180, 60], "the green was replaced"); + assert_eq!(foreground(&app), [0, 0, 255]); +} diff --git a/crates/ui-egui/src/color_replacement_tests.rs b/crates/ui-egui/src/color_replacement_tests.rs index 64f04f3c739..87adc1f64c7 100644 --- a/crates/ui-egui/src/color_replacement_tests.rs +++ b/crates/ui-egui/src/color_replacement_tests.rs @@ -115,7 +115,8 @@ fn contiguous_limits_skip_a_matching_island_that_discontiguous_recolours() { /// 8 (the live stroke keeps its working pixels in the layer's depth between dabs). #[test] fn a_stroke_is_drawn_live_and_is_one_history_step() { - for depth in [8, 32] { + // Color Replacement refuses 32-bit RGB documents, as the reference application does (#11923). + for depth in [8, 16] { live_stroke_at(depth); } } @@ -147,7 +148,7 @@ fn live_stroke_at(depth: u32) { } let st = app.session.active().unwrap(); assert_eq!(st.history.past_len(), steps + 1, "one History step"); - assert_eq!(st.history.entries().last().map(|e| e.to_string()).as_deref(), Some("Color Replacement")); + assert_eq!(st.history.entries().last().map(|e| e.to_string()).as_deref(), Some("Color Replacement Tool")); app.run("edit.undo", json!({})).unwrap(); assert_eq!(rgba(&app, 20, 20), before, "one undo restores the pixels"); app.run("edit.redo", json!({})).unwrap(); diff --git a/crates/ui-egui/src/control/dispatch.rs b/crates/ui-egui/src/control/dispatch.rs index 3441e194caa..4d0af0d3a70 100644 --- a/crates/ui-egui/src/control/dispatch.rs +++ b/crates/ui-egui/src/control/dispatch.rs @@ -116,7 +116,12 @@ fn execute_command(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRe // params and never open a dialog (an agent would otherwise get a modal instead of a // result). `ui.menu.invoke` behaves like a menu click, so it may open the dialog. if req.method == "engine.execute" && photocraft_engine::commands::find(id).is_some() { - return run_waiting(app, wait, |app| app.run(id, params)); + return run_waiting(app, wait, |app| { + let previous = std::mem::replace(&mut app.programmatic_playback, true); + let result = app.run(id, params); + app.programmatic_playback = previous; + result + }); } run_waiting(app, wait, |app| crate::menus::invoke(app, ctx, id, params)) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 82a1b6a0939..14d20537fd5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -311,6 +311,7 @@ fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: _ if crate::preset_group_prompt::owns(fields) => crate::preset_group_prompt::body(ui, fields), _ if crate::actions::owns_stop_message(fields) => crate::actions::stop_message_body(ui, fields), _ if crate::actions::owns_insert_stop(fields) => crate::actions::insert_stop_body(ui, fields), + _ if crate::actions::menu_item::owns(fields) => crate::actions::menu_item::body(ui, fields), _ if crate::actions::owns_new_set(fields) => crate::actions::new_set_body(ui, fields), _ if crate::actions::playback::owns(fields) => crate::actions::playback::body(ui, fields), _ if crate::new_layer_ui::owns(fields) => crate::new_layer_ui::body(ui, fields), @@ -320,7 +321,6 @@ fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: _ if crate::stack_dialog::owns(fields) => crate::stack_dialog::body(app, ui, fields, d.id), _ if crate::tool_reset::owns(fields) => crate::tool_reset::body(ui), _ if crate::crop_presets::owns(fields) => crate::crop_presets::body(app, ui, fields), - _ if crate::variables_ui::owns(fields) => crate::variables_ui::body(app, ui, fields), _ => return command_body_more(app, ui, d, fields, id), } outcome @@ -330,6 +330,7 @@ fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: fn command_body_more(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: &mut Fields, id: egui::Id) -> Option { let mut outcome: Option = None; match d.kind { + _ if crate::variables_ui::owns(fields) => crate::variables_ui::body(app, ui, fields), _ if merge_channels_ui::owns(fields) => merge_channels_ui::body(ui, fields), _ if crate::file_ui::owns(fields) => crate::file_ui::body(app, ui, fields, d.id), _ if crate::color_picker_ui::owns(fields) => { @@ -886,6 +887,8 @@ const ROUTES: &[fn(&Fields) -> bool] = &[ crate::disk_change_prompt::owns, crate::actions::owns_stop_message, crate::actions::owns_insert_stop, + crate::actions::menu_item::owns, + crate::actions::record_again::owns, crate::actions::owns_new_set, crate::actions::playback::owns, crate::new_layer_ui::owns, @@ -963,7 +966,13 @@ fn confirm_generic_command(app: &mut PhotocraftApp, d: Dialog) -> Result Result edit, Err(e) => { app.filter_preview = None; @@ -1066,6 +1075,12 @@ pub(crate) fn confirm_preflight(app: &mut PhotocraftApp, id: u64) -> Result<(), /// Confirm a command dialog through the first handler whose marker its fields carry; the prompts /// and small editors are here, the rest in `confirm_command_more`. fn confirm_command(app: &mut PhotocraftApp, d: Dialog) -> Result { + if crate::actions::record_again::owns(&d.fields) || crate::actions::record_again::owns_dialog(app, d.id) { + return crate::actions::record_again::confirm(app, d.id, &d.fields); + } + if crate::actions::menu_item::owns(&d.fields) || crate::actions::menu_item::owns_dialog(app, d.id) { + return crate::actions::menu_item::confirm(app, d.id, &d.fields); + } match d.kind { _ if crate::open_prompt::owns_memory(&d.fields) => crate::open_prompt::answer_memory(app, true), _ if crate::open_prompt::owns_damage(&d.fields) => crate::open_prompt::answer_damage(app, true), @@ -1162,6 +1177,8 @@ pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result { crate::color_picker_ui::live_preview::end(app, *picker); } app.ui.dialogs.retain(|d| !pickers.contains(&d.id)); + crate::actions::record_again::cancel(app, id); + crate::actions::menu_item::cancel(app, id); crate::actions::stopped(app, &closed.fields); if crate::guide_layout_ui::owns(&closed.fields) { app.ui.dialogs.retain(|child| child.fields.get("__dialog").and_then(Value::as_u64) != Some(id)); diff --git a/crates/ui-egui/src/dialogs/adjust_dialog.rs b/crates/ui-egui/src/dialogs/adjust_dialog.rs index f20ea8618eb..f435bde1b4f 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog.rs @@ -17,6 +17,8 @@ use crate::tone::{self, HistSource}; const PREFIX: &str = "image.adjustments."; const CURVE_PICKER: &str = "__curvePicker"; const CURVE_PICKER_BASE: &str = "__curvePickerBase"; +/// Set once a Photo Filter dialog has drawn: its editor memory was refreshed for this opening. +const PHOTO_FILTER_FRESH: &str = "__photoFilterFresh"; fn picker(value: Option<&Value>) -> Option { use photocraft_engine::adjust_params::CurvesEyedropper; @@ -282,8 +284,33 @@ pub fn reset(app: &PhotocraftApp, ctx: &egui::Context, fields: &mut Map Option { + let id = open_neutral(app, command)?; + let kind = command.strip_prefix(PREFIX).unwrap_or_default(); + let kept: Vec<(String, Value)> = match app.session.prefs().dialogs.get(command) { + Some(last) => KEEPS_LAST + .iter() + .filter(|(k, _)| *k == kind) + .filter_map(|(_, key)| last.get(*key).filter(|v| v.is_boolean()).map(|v| ((*key).to_owned(), v.clone()))) + .collect(), + None => Vec::new(), + }; + if let Some(d) = app.ui.dialog_mut(id) { + d.fields.extend(kept); + } + Some(id) +} + +/// Opens the dialog for `command` with the kind's neutral settings. +pub fn open_neutral(app: &mut PhotocraftApp, command: &str) -> Option { let kind = command.strip_prefix(PREFIX).filter(|k| adjust_editors::has_editor(k))?; let spec = photocraft_engine::commands::find(command)?; let mode = curve_mode(app, kind); @@ -342,7 +369,7 @@ fn combined_lab_target(fields: &Map) -> bool { /// adjustment's shortcut, ⌥⌘L / ⌥⌘M, #8900), or at the neutral ones when there are none or they /// don't fit the current colour mode. pub fn open_last(app: &mut PhotocraftApp, command: &str) -> Option { - let id = open(app, command)?; + let id = open_neutral(app, command)?; let kind = command.strip_prefix(PREFIX).unwrap_or_default(); let mode = curve_mode(app, kind); let Some(last) = app.session.prefs().dialogs.get(command).cloned() else { return Some(id) }; @@ -372,6 +399,22 @@ pub fn open_last(app: &mut PhotocraftApp, command: &str) -> Option { Some(id) } +/// `params` of the adjustment `command` with the keys the dialog would otherwise fill in wrongly: +/// a recorded Photo Filter that names only its `filter` gets that preset's `color`, not the +/// dialog's neutral one. Every recorded value is kept as it was (a round trip through the engine +/// would rewrite them: float colours as hex, a pencil curve smoothed again). `None` when nothing +/// needs adding, or for params the engine rejects. +pub(crate) fn complete_params(app: &PhotocraftApp, command: &str, params: &Value) -> Option { + let kind = command.strip_prefix(PREFIX).filter(|k| *k == "photoFilter")?; + // A `null` colour reads as none, as the engine reads it. + let recorded = params.as_object().filter(|p| p.contains_key("filter") && p.get("color").is_none_or(Value::is_null))?; + let adjustment = photocraft_engine::adjust_params::from_params(kind, params, None, curve_mode(app, kind)).ok()?; + let color = photocraft_engine::adjust_params::to_params(&adjustment).get("color").cloned()?; + let mut done = recorded.clone(); + done.insert("color".into(), color); + Some(Value::Object(done)) +} + /// Remember an adjustment dialog's settings after a successful OK, for [`open_last`]. Editing a /// smart filter's settings is not a use of the dialog's own (as `filter_dialog::remember`). pub(crate) fn remember(app: &mut PhotocraftApp, fields: &Map) { @@ -430,6 +473,12 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map(mode)), Some(false), "Filter is chosen for the preset colour"); + // That opening is cancelled with Filter chosen; ⌥ brings the custom colour back with Color. + h.state_mut().ui.close_dialog(id); + h.run_steps(1); + let id = open_last(h.state_mut(), cmd).unwrap(); + h.run_steps(2); + assert_eq!(h.state_mut().ui.dialog_mut(id).unwrap().fields["color"], json!("#123456")); + assert_eq!(h.ctx.data(|d| d.get_temp::(mode)), Some(true), "Color is chosen for the custom colour"); +} + +/// #9879 review: a recorded Photo Filter that names only its preset completes to that preset's +/// colour, so editing it as a smart filter doesn't swap in the dialog's neutral colour. Recorded +/// values are kept as they were, and nothing else is rewritten. +#[test] +fn recorded_photo_filter_settings_complete_to_their_preset_colour() { + let app = app_with_image(); + let pf = "image.adjustments.photoFilter"; + let done = complete_params(&app, pf, &json!({"filter": "cooling80", "density": 60, "extra": [1]})).unwrap(); + assert_eq!(done, json!({"filter": "cooling80", "density": 60, "extra": [1], "color": "#006dff"}), "only the colour is added"); + // A recorded colour (here a float triple the engine would rewrite as hex) is left alone. + assert!(complete_params(&app, pf, &json!({"filter": "cooling80", "color": [0.5, 0.0, 0.0]})).is_none()); + let null = complete_params(&app, pf, &json!({"filter": "cooling80", "color": null})).unwrap(); + assert_eq!(null["color"], json!("#006dff"), "a null colour completes from the preset, as the engine reads it"); + assert!(complete_params(&app, pf, &json!({"density": 60})).is_none(), "no preset to take a colour from"); + assert!(complete_params(&app, pf, &json!({"filter": "nope"})).is_none(), "rejected params"); + // Other adjustments keep their recorded values exactly (a pencil curve isn't smoothed again). + let curves = json!({"pencil": true, "smooth": 2, "rgb": [0, 255]}); + assert!(complete_params(&app, "image.adjustments.curves", &curves).is_none()); + assert!(complete_params(&app, "filter.blur.gaussian", &json!({"radius": 2})).is_none(), "not an adjustment"); +} + #[test] fn captured_filter_mask_curves_stay_gray_after_selecting_native_channels() { let mut app = app_with_image(); diff --git a/crates/ui-egui/src/dialogs/adjust_editors.rs b/crates/ui-egui/src/dialogs/adjust_editors.rs index 640991fdb05..c3062fdb7c8 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors.rs @@ -31,6 +31,7 @@ mod levels; mod selective_color; mod session; +pub(crate) use basic::PHOTO_FILTER_MODE; #[cfg(test)] pub(crate) use basic::row_value; #[cfg(test)] @@ -75,6 +76,12 @@ pub fn has_editor(kind: &str) -> bool { KINDS.contains(&kind) } +/// Forget Photo Filter's Filter / Color choice in the editor memory `mem`, so the editor derives it +/// from the colour again: a choice changed and then cancelled must not outlive the dialog (#9879). +pub(crate) fn forget_photo_filter_mode(ctx: &egui::Context, mem: egui::Id) { + ctx.data_mut(|d| d.remove::(mem.with(PHOTO_FILTER_MODE))); +} + /// Kinds that draw a histogram behind their controls. pub fn needs_histogram(kind: &str) -> bool { matches!(kind, "levels" | "curves" | "threshold") diff --git a/crates/ui-egui/src/dialogs/adjust_editors/basic.rs b/crates/ui-egui/src/dialogs/adjust_editors/basic.rs index 6a0e9694b61..e10d32652b0 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/basic.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/basic.rs @@ -194,12 +194,16 @@ pub(super) fn black_white(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Ed // --------------------------------------------------------------------------------------------- // Photo Filter +/// The editor memory key of Photo Filter's Filter / Color choice (unset: Color for a colour that +/// is no preset). +pub(crate) const PHOTO_FILTER_MODE: &str = "pf-mode"; + pub(super) fn photo_filter(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let mut e = Edit::default(); let mut c = rgb_of(v, "color", [236.0 / 255.0, 138.0 / 255.0, 0.0]); let hex = adjust_params::hex(c); let preset = PHOTO_FILTERS.iter().find(|f| adjust_params::hex(f.2.map(|x| f32::from(x) / 255.0)) == hex).map(|f| f.0); - let mode_id = cx.mem.with("pf-mode"); + let mode_id = cx.mem.with(PHOTO_FILTER_MODE); let mut use_color: bool = ui.data(|d| d.get_temp(mode_id)).unwrap_or(preset.is_none()); ui.horizontal(|ui| { if ui.radio(!use_color, tl!("Filter:")).clicked() { diff --git a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs index 7e21fc91fc7..497fea40b65 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs @@ -661,6 +661,57 @@ fn gradient_map_strip_adds_selects_and_removes_stops_without_deleting_the_last_p assert_eq!(layer(&h).1, original, "keep two stops"); } +/// #10742: a Gradient Map stop dragged down off the bar is removed on release (one history step); +/// while it is off the bar the live preview already leaves it out, and dragging it back onto the +/// bar before release puts it back. The last two stops can't be dragged off. +#[test] +fn gradient_map_stop_dragged_down_off_the_bar_is_removed_and_dragging_back_cancels() { + let mut h = app_harness("gradientMap", "rgb", 8); + let (id, _) = layer(&h); + h.state_mut().run("layer.setAdjustment", json!({"layer": id.0, "stops": [[0, "#000000"], [0.5, "#ff0000"], [1, "#ffffff"]]})).unwrap(); + h.run_steps(3); + let three = layer(&h).1; + let before_cancel = h.state().session.active().unwrap().history.past_len(); + let live_stops = |h: &Harness<'_, PhotocraftApp>| h.state().live_adjust.as_ref().and_then(|(_, v)| v["stops"].as_array().map(Vec::len)); + // Off and back: nothing removed. + let start = map_handle(&h, 0.5); + let away = start + vec2(0.0, 30.0); + h.hover_at(start); + h.run_steps(1); + h.drag_at(start); + h.run_steps(1); + h.hover_at(start + vec2(0.0, 15.0)); + h.run_steps(1); + h.hover_at(away); + h.run_steps(1); + assert_eq!(live_stops(&h), Some(2), "off the bar the preview leaves the stop out"); + h.hover_at(start); + h.run_steps(1); + assert_eq!(live_stops(&h), Some(3), "back on the bar the stop returns"); + h.drop_at(start); + h.run_steps(3); + assert_eq!(map_stops(&layer(&h).1), map_stops(&three), "dragging back cancels the removal"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), before_cancel, "a cancelled drag adds no history step"); + // Off and released: removed in one step. + let steps = h.state().session.active().unwrap().history.past_len(); + drag(&mut h, start, away); + assert_eq!(map_stops(&layer(&h).1), vec![(0.0, [0, 0, 0]), (1.0, [255, 255, 255])]); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1); + assert!(h.state().live_adjust.is_none()); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(layer(&h).1, three); + h.state_mut().run("edit.redo", json!({})).unwrap(); + h.run_steps(3); + // Two stops stay. + let two = layer(&h).1; + for at in [0.0, 1.0] { + let from = map_handle(&h, at); + drag(&mut h, from, from + vec2(0.0, 40.0)); + assert_eq!(map_stops(&layer(&h).1), map_stops(&two), "two stops always remain"); + } +} + #[test] fn gradient_map_drag_clamps_at_the_endpoints_and_reverse_keeps_stop_coordinates() { let mut h = app_harness("gradientMap", "rgb", 8); diff --git a/crates/ui-egui/src/dialogs/adjust_preview.rs b/crates/ui-egui/src/dialogs/adjust_preview.rs index 8238ddeb7b7..49cecdc51be 100644 --- a/crates/ui-egui/src/dialogs/adjust_preview.rs +++ b/crates/ui-egui/src/dialogs/adjust_preview.rs @@ -191,7 +191,10 @@ fn dialog(app: &PhotocraftApp) -> Option<(String, Value, bool)> { } let kind = d.fields.get("__adjust")?.as_str()?.to_string(); let preview = d.fields.get("__preview").and_then(Value::as_bool).unwrap_or(true); - Some((kind, crate::filter_dialog::params_of(&d.fields), preview)) + let params = crate::filter_dialog::params_of(&d.fields); + let command = format!("image.adjustments.{kind}"); + crate::actions::authorize_panel_preview(app, d.id, &d.fields, &command, ¶ms).ok()?; + Some((kind, params, preview)) } /// Whether Image › Adjustments `kind` on document `idx` would edit a channel (the Channels panel's diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui.rs b/crates/ui-egui/src/dialogs/camera_raw_ui.rs index 1b71ef33064..c32240b4cb9 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui.rs @@ -30,6 +30,7 @@ use crate::widgets; mod controls; mod convert; +mod filmstrip; mod menu; mod open; mod proxy; @@ -38,6 +39,7 @@ mod sidecar; pub use controls::show; #[cfg(test)] use convert::open_pixels; +pub use filmstrip::HeldRaw; pub use menu::menu; #[cfg(test)] use open::{Coverage, commit_open_raw, document_index, finish_redevelop, open_raw, replace_raw_document}; diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/controls.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/controls.rs index dee34b3cc90..d764ad987f9 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/controls.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/controls.rs @@ -1,4 +1,4 @@ -use super::open::{Target, cancel, commit_open_raw, open_raw, raw_stage}; +use super::open::{Target, cancel, raw_stage}; use super::sidecar::{load_settings, report_file_error, save_settings}; use super::{ Align2, BANDS, CameraRaw, CameraRawDialog, Color32, ERect, FontId, PANEL_W, PhotocraftApp, Pos2, Sense, Stroke, Tokens, ToneZone, Value, Wheel, curve_lut, @@ -69,7 +69,7 @@ pub(super) fn commit(app: &mut PhotocraftApp) -> Result { Target::OpenRaw => { let raw = d.raw_open.clone().ok_or("no raw file is being opened")?; let (params, original) = (d.params.clone(), d.original.clone()); - commit_open_raw(app, ¶ms, &raw, Some(&original))? + super::filmstrip::open_all(app, ¶ms, &raw, &original)? } }; app.camera_raw = None; @@ -188,10 +188,11 @@ fn curve_editor(ui: &mut egui::Ui, p: &mut CameraRaw, dirty: &mut bool, state: & pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.camera_raw.is_none() - && let Some(raw) = app.pending_raw_open.take() - && let Err(e) = open_raw(app, ctx, raw) + && !app.raw_shelf.is_empty() + && let Err(e) = super::filmstrip::open_held(app, ctx, 0) { // The document stays open, developed with the defaults. + app.raw_shelf.remove(0); app.ui.status = e; app.ui.status_error = true; } @@ -241,6 +242,7 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { let screen = ctx.content_rect(); let current_display = app.session.active().and_then(|st| app.session.color.canvas_display(&st.doc).ok()); let mut action: Option<&str> = None; + let mut show_held: Option = None; egui::Area::new(egui::Id::unique("camera-raw-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { let Some(d) = app.camera_raw.as_mut() else { return }; history_keys(d, ctx); @@ -278,7 +280,16 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { let animated_zoom = app.session.prefs().general.animated_zoom; let scope = &mut app.ui.camera_raw_scope; // Clip both pixels and scope overlays before painting the settings dock/footer. - let preview_body = ERect::from_min_max(body.min, pos2((body.right() - PANEL_W).max(body.left() + 1.0), body.bottom())); + // Several raws being opened: the filmstrip on the left. + let film_w = if d.target == Target::OpenRaw && app.raw_shelf.len() > 1 { super::filmstrip::FILM_W } else { 0.0 }; + if film_w > 0.0 { + let film = ERect::from_min_max(body.min, pos2(body.left() + film_w - 1.0, body.bottom())); + painter.rect_filled(film, 0.0, t.dock); + painter.line_segment([film.right_top(), film.right_bottom()], Stroke::new(1.0, t.separator)); + show_held = super::filmstrip::filmstrip(ui, film, &mut app.raw_shelf, d.raw_open.as_ref().map(|r| r.document), &t); + } + let preview_body = + ERect::from_min_max(pos2(body.left() + film_w, body.top()), pos2((body.right() - PANEL_W).max(body.left() + film_w + 1.0), body.bottom())); let view = ERect::from_min_max( preview_body.min + vec2(16.0, 16.0), pos2((preview_body.right() - 16.0).max(preview_body.left() + 17.0), (preview_body.bottom() - 48.0).max(preview_body.top() + 17.0)), @@ -476,6 +487,15 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { ) { action = Some(if role == widgets::ButtonRole::Default { "ok" } else { "cancel" }); } + // Done (open-time dialog): keep the settings in the raws' sidecars, open nothing. + if d.target == Target::OpenRaw + && fu + .add_sized([90.0, 28.0], egui::Button::new(tl!("Done"))) + .on_hover_text(tl!("Save the settings to each raw's sidecar without opening it")) + .clicked() + { + action = Some("done"); + } fu.add_space(12.0); widgets::checkbox(&mut fu, &mut d.show_before, tl!("Before (Y)")); fu.label(egui::RichText::new(format!("{:.0} ms", d.render_ms)).color(t.text_faint)); @@ -496,6 +516,12 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { } }); crate::camera_raw_scope_ui::persist(app, ctx); + if let Some(i) = show_held + && let Err(e) = super::filmstrip::show_raw(app, ctx, i) + { + app.ui.status = e; + app.ui.status_error = true; + } if ctx.input(|i| i.key_pressed(egui::Key::Y)) && let Some(d) = app.camera_raw.as_mut() { @@ -511,6 +537,9 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { } } Some("cancel") => cancel(app), + Some("done") => { + super::filmstrip::done(app); + } Some("save") => { let r = save_settings(app); report_file_error(app, r); diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/convert.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/convert.rs index 908f01e1f14..313110eacab 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/convert.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/convert.rs @@ -48,24 +48,10 @@ pub(super) fn apply_raw_conversions(app: &mut PhotocraftApp, index: usize, d: &p Ok(()) } -pub(super) fn open_pixels( - app: &mut PhotocraftApp, - ctx: &egui::Context, - layer: LayerId, - surf: photocraft_raster::Surface, - params: CameraRaw, - coverage: Coverage, - target: Target, -) -> Result<(), String> { - let st = app.session.active().ok_or("no document")?; - let canvas = st.doc.bounds(); - let name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default(); - let mut params = params; - if params.encoding_gamma == 0.0 { - params.encoding_gamma = photocraft_engine::lens_cmds::encoding_gamma(&st.doc); - } - // Use the engine filter domain, including pixels outside the canvas: spatial Camera Raw - // stages (vignette, grain, local contrast) must agree with the committed result. +/// What the preview develops, and its size: the engine filter domain, including pixels outside the +/// canvas, since spatial Camera Raw stages (vignette, grain, local contrast) must agree with the +/// committed result. +pub(super) fn preview_area(canvas: Rect, surf: &photocraft_raster::Surface) -> Result<(Rect, usize, usize), String> { let area = canvas.union(&surf.content_bounds()); let width = area.x1.checked_sub(area.x0).filter(|n| (1..=1_048_576).contains(n)); let height = area.y1.checked_sub(area.y0).filter(|n| (1..=1_048_576).contains(n)); @@ -76,6 +62,12 @@ pub(super) fn open_pixels( if w.checked_mul(h).is_none_or(|pixels| pixels > 512_000_000) { return Err("Camera Raw preview bounds are too large".into()); } + Ok((area, w, h)) +} + +/// `area` of `surf` (`w`×`h`) box-averaged down to at most [`PROXY_SIDE`] pixels on its long side: +/// the proxy, its size, and the side of the blocks averaged. +pub(super) fn box_proxy(surf: &photocraft_raster::Surface, area: Rect, w: usize, h: usize) -> (Vec<[f32; 4]>, usize, usize, usize) { let k = w.max(h).div_ceil(PROXY_SIDE).max(1); let (pw, ph) = (w.div_ceil(k), h.div_ceil(k)); let mut proxy = vec![[0.0f32; 4]; pw * ph]; @@ -98,6 +90,27 @@ pub(super) fn open_pixels( proxy[py * pw + x] = [a[0] / a[4], a[1] / a[4], a[2] / a[4], a[3] / a[4]]; } } + (proxy, pw, ph, k) +} + +pub(super) fn open_pixels( + app: &mut PhotocraftApp, + ctx: &egui::Context, + layer: LayerId, + surf: photocraft_raster::Surface, + params: CameraRaw, + coverage: Coverage, + target: Target, +) -> Result<(), String> { + let st = app.session.active().ok_or("no document")?; + let canvas = st.doc.bounds(); + let name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default(); + let mut params = params; + if params.encoding_gamma == 0.0 { + params.encoding_gamma = photocraft_engine::lens_cmds::encoding_gamma(&st.doc); + } + let (area, w, h) = preview_area(canvas, &surf)?; + let (proxy, pw, ph, k) = box_proxy(&surf, area, w, h); // Coverage at the centre of each proxy block. let sample = |value: &dyn Fn(i32, i32) -> f32| { (0..ph) diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/filmstrip.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/filmstrip.rs new file mode 100644 index 00000000000..1e35869dbe0 --- /dev/null +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/filmstrip.rs @@ -0,0 +1,290 @@ +//! The open-time dialog's filmstrip: raws opened together wait side by side in one Camera Raw +//! dialog, as in Photoshop's. Each keeps its own settings and demosaic choice when another is +//! shown, Open develops every one and Cancel closes every one. + +use super::open::{Target, commit_open_raw, open_raw_with, return_on_failure}; +use super::sidecar::read_sidecar; +use super::{ + Align2, CameraRaw, CameraRawDialog, Color32, ERect, FontId, PhotocraftApp, RawOpen, Rect, Sense, Stroke, Tokens, Value, json, pos2, vec2, widgets, +}; + +/// The filmstrip column, shown when more than one raw is being opened. +pub(super) const FILM_W: f32 = 150.0; +/// Long side of a filmstrip thumbnail, in pixels. +const THUMB_SIDE: usize = 240; + +/// A raw waiting in the open-time dialog: opened (developed with the defaults) and reviewed +/// before Open keeps it. +pub struct HeldRaw { + pub raw: RawOpen, + /// Its settings and the settings it started from (its sidecar's, or the defaults) once it + /// has been shown and left for another (`None`: not edited yet). + pub(super) kept: Option<(CameraRaw, CameraRaw)>, + /// The filmstrip thumbnail until it is first drawn, then its texture. + pub(super) thumb: Option, + tex: Option, +} + +impl HeldRaw { + pub(super) fn new(app: &PhotocraftApp, raw: RawOpen) -> Self { + let thumb = super::open::document_index(app, raw.document).and_then(|i| thumbnail(app, i)); + HeldRaw { raw, kept: None, thumb, tex: None } + } +} + +/// A filmstrip thumbnail of document `index` (a just-developed raw: its first pixel layer). +fn thumbnail(app: &PhotocraftApp, index: usize) -> Option { + let doc = &app.session.documents().get(index)?.doc; + let surf = doc.layers.iter().find_map(|l| l.surface())?; + let area = doc.bounds(); + let (w, h) = (area.width() as usize, area.height() as usize); + if w == 0 || h == 0 { + return None; + } + let k = w.max(h).div_ceil(THUMB_SIDE).max(1); + let (tw, th) = (w.div_ceil(k), h.div_ceil(k)); + let mut acc = vec![[0.0f32; 5]; tw * th]; + let mut row = vec![[0.0f32; 4]; w]; + for y in (0..h).step_by(k.div_ceil(2).max(1)) { + surf.read_rgba_into(Rect::new(area.x0, area.y0 + y as i32, area.x1, area.y0 + y as i32 + 1), &mut row); + for (x, q) in row.iter().enumerate().step_by(k.div_ceil(2).max(1)) { + let a = &mut acc[(y / k) * tw + x / k]; + for c in 0..4 { + a[c] += q[c]; + } + a[4] += 1.0; + } + } + let px: Vec<[f32; 4]> = acc.iter().map(|a| if a[4] > 0.0 { [a[0] / a[4], a[1] / a[4], a[2] / a[4], a[3] / a[4]] } else { [0.0; 4] }).collect(); + let display = app.session.color.canvas_display(doc).ok()?; + Some(CameraRawDialog::image(&px, tw, th, &display)) +} + +/// `px` (`w`×`h`) box-averaged down to at most [`THUMB_SIDE`] pixels on its long side. +fn shrink(px: &[[f32; 4]], w: usize, h: usize) -> (Vec<[f32; 4]>, usize, usize) { + let k = w.max(h).div_ceil(THUMB_SIDE).max(1); + let (tw, th) = (w.div_ceil(k), h.div_ceil(k)); + let mut acc = vec![[0.0f32; 5]; tw * th]; + for (i, q) in px.iter().enumerate().take(w * h) { + if let Some(a) = acc.get_mut((i / w / k) * tw + (i % w) / k) { + for c in 0..4 { + a[c] += q[c]; + } + a[4] += 1.0; + } + } + (acc.iter().map(|a| if a[4] > 0.0 { [a[0] / a[4], a[1] / a[4], a[2] / a[4], a[3] / a[4]] } else { [0.0; 4] }).collect(), tw, th) +} + +/// Shows raw `i` of the filmstrip, with the settings it was left with (its sidecar's, the +/// Camera Raw Defaults' Auto, or the defaults the first time). +pub(super) fn open_held(app: &mut PhotocraftApp, ctx: &egui::Context, i: usize) -> Result<(), String> { + let held = app.raw_shelf.get(i).ok_or("no raw file at that place in the filmstrip")?; + let (raw, kept) = (held.raw.clone(), held.kept.clone()); + open_raw_with(app, ctx, raw, kept) +} + +/// Shows raw `to` of the filmstrip; the one being left keeps its settings, and its thumbnail +/// shows them. +pub(super) fn show_raw(app: &mut PhotocraftApp, ctx: &egui::Context, to: usize) -> Result<(), String> { + let Some(d) = app.camera_raw.as_ref() else { return Ok(()) }; + let current = d.raw_open.as_ref().map(|r| r.document); + if d.target != Target::OpenRaw || app.raw_shelf.get(to).is_none_or(|h| Some(h.raw.document) == current) { + return Ok(()); + } + keep_settings(app); + // On success this replaces the dialog. On failure the dialog stays on the raw being left, and + // the other keeps its place in the filmstrip. + let active = app.session.active_index(); + let shown = open_held(app, ctx, to); + if shown.is_err() + && let Some(i) = active + { + app.session.set_active(i); + } + shown +} + +/// Stores the dialog's settings (and demosaic choice) on its filmstrip raw, and refreshes the +/// raw's thumbnail from the preview. +pub(super) fn keep_settings(app: &mut PhotocraftApp) { + let Some(d) = app.camera_raw.as_ref() else { return }; + let Some(raw) = &d.raw_open else { return }; + let Some(held) = app.raw_shelf.iter_mut().find(|h| h.raw.document == raw.document) else { return }; + held.kept = Some((d.params.clone(), d.original.clone())); + held.raw.demosaic = raw.demosaic; + // The preview may not have caught up with the last edit: the thumbnail shows what was kept. + let frame = (d.dirty || d.render_pending()).then(|| d.proxy_job().run().px); + let developed = frame.as_deref().unwrap_or(&d.processed); + if !developed.is_empty() { + let (px, w, h) = shrink(developed, d.pw, d.ph); + held.thumb = Some(CameraRawDialog::image(&px, w, h, &d.display)); + held.tex = None; + } +} + +/// The settings Open develops a never-shown filmstrip raw with, and what they started from: +/// its sidecar's, else the Camera Raw Defaults' Auto when that is on, else the defaults. +pub(super) fn first_settings(app: &PhotocraftApp, raw: &RawOpen) -> (CameraRaw, Option) { + if let Some((loaded, _, _)) = raw.path.as_deref().and_then(read_sidecar) { + return (loaded.clone(), Some(loaded)); + } + let mut p = CameraRaw::default(); + // From the proxy the dialog would use, so showing a raw doesn't change what Auto gives it. + if app.session.prefs().raw_defaults.apply_auto_tone + && let Some(st) = super::open::document_index(app, raw.document).and_then(|i| app.session.documents().get(i)) + && let Some(surf) = st.doc.layers.iter().find_map(|l| l.surface()) + && let Ok((area, w, h)) = super::convert::preview_area(st.doc.bounds(), surf) + { + let (proxy, ..) = super::convert::box_proxy(surf, area, w, h); + photocraft_algo::camera_raw::auto_tone(&proxy, &mut p); + } + (p, None) +} + +/// Open with raws in the filmstrip: the shown one as the dialog has it, then every other with +/// its own settings. One reply for one raw, `{"opened": [...]}` for several. A raw leaves the +/// filmstrip once it has opened or its background develop has started: one that fails stays or +/// comes back there with its settings, so the dialog returns for it (or Cancel still closes it). +pub(super) fn open_all(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen, original: &CameraRaw) -> Result { + keep_settings(app); + let others: Vec = app.raw_shelf.iter().map(|h| h.raw.document).filter(|id| *id != raw.document).collect(); + let first = commit_open_raw(app, params, raw, Some(original)); + if let Ok(v) = &first { + return_on_failure(app, v, original); + app.raw_shelf.retain(|h| h.raw.document != raw.document); + } + if others.is_empty() { + return first; + } + let mut opened = Vec::new(); + let mut failed = Vec::new(); + match first { + Ok(v) => opened.push(v), + Err(e) => failed.push(format!("{}: {e}", raw.name)), + } + for id in others { + let Some(held) = app.raw_shelf.iter().find(|h| h.raw.document == id) else { continue }; + let (held_raw, kept) = (held.raw.clone(), held.kept.clone()); + let (p, loaded) = match kept { + Some((p, original)) => (p, Some(original)), + None => first_settings(app, &held_raw), + }; + match commit_open_raw(app, &p, &held_raw, loaded.as_ref()) { + Ok(v) => { + return_on_failure(app, &v, loaded.as_ref().unwrap_or(&CameraRaw::default())); + opened.push(v); + app.raw_shelf.retain(|h| h.raw.document != id); + } + Err(e) => failed.push(format!("{}: {e}", held_raw.name)), + } + } + if !failed.is_empty() { + let e = failed.join("; "); + if opened.is_empty() { + return Err(e); + } + crate::notices::error(app, e); + } + Ok(json!({"opened": opened})) +} + +/// Done: saves each raw's settings to its sidecar, as Camera Raw's Done does, and opens none of +/// them (they close again). +pub(super) fn done(app: &mut PhotocraftApp) -> Value { + use super::sidecar::Stored; + keep_settings(app); + let Some(d) = app.camera_raw.as_ref() else { return Value::Null }; + let Some(raw) = d.raw_open.clone() else { return Value::Null }; + let mut raws = vec![(raw.clone(), d.params.clone())]; + for held in app.raw_shelf.iter().filter(|h| h.raw.document != raw.document) { + let p = match &held.kept { + Some((p, _)) => p.clone(), + None => first_settings(app, &held.raw).0, + }; + raws.push((held.raw.clone(), p)); + } + let stored: Vec = raws.iter().map(|(raw, p)| super::sidecar::store_sidecar(app, raw, p)).collect(); + let saved: Vec<&String> = stored.iter().filter_map(|s| if let Stored::Written(name) = s { Some(name) } else { None }).collect(); + // A failed write (already in a notice) keeps the dialog and every raw open, so no edit is lost. + if stored.contains(&Stored::Failed) { + app.ui.status = tl!("Some settings could not be saved; the raws stay open").to_string(); + app.ui.status_error = true; + return json!({"done": false, "saved": saved}); + } + app.ui.status = if !saved.is_empty() { + crate::i18n::fmt(tl!("Saved Camera Raw settings to {file}"), &[("file", &saved.iter().map(|s| s.as_str()).collect::>().join(", "))]) + } else if stored.iter().all(|s| *s == Stored::Defaults) { + tl!("Nothing to save: these raws are at their default settings").to_string() + } else { + tl!("No settings saved: these raws can't keep a sidecar here").to_string() + }; + app.ui.status_error = saved.is_empty() && stored.contains(&Stored::Skipped); + let saved = json!(saved); + app.camera_raw = None; + app.raw_shelf.clear(); + for (raw, _) in &raws { + if let Some(index) = super::open::document_index(app, raw.document) { + app.session.close(index); + } + } + app.sync_views(); + json!({"done": true, "saved": saved}) +} + +/// The filmstrip of raws being opened: a thumbnail and name each, the shown one outlined, a dot +/// on edited ones. Returns the one clicked. +pub(super) fn filmstrip(ui: &mut egui::Ui, rect: ERect, shelf: &mut [HeldRaw], current: Option, t: &Tokens) -> Option { + let mut clicked = None; + let mut film = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(6.0, 6.0)))); + film.set_clip_rect(rect.intersect(ui.clip_rect())); + egui::ScrollArea::vertical().id_salt("camera-raw-filmstrip").auto_shrink([false, false]).show(&mut film, |ui| { + ui.spacing_mut().item_spacing.y = 6.0; + let w = ui.available_width(); + for (i, held) in shelf.iter_mut().enumerate() { + if held.tex.is_none() + && let Some(img) = held.thumb.take() + { + held.tex = Some(ui.ctx().load_texture(format!("camera-raw-thumb-{}", held.raw.document.0), img, egui::TextureOptions::LINEAR)); + } + let (tile, response) = ui.allocate_exact_size(vec2(w, w * 0.75 + 18.0), Sense::click()); + let frame = ERect::from_min_size(tile.min, vec2(w, w * 0.75)); + let p = ui.painter(); + p.rect_filled(frame, 2.0, if response.hovered() { t.hover } else { t.canvas }); + if let Some(tex) = &held.tex { + let size = tex.size_vec2(); + let fit = ((frame.width() - 8.0) / size.x.max(1.0)).min((frame.height() - 8.0) / size.y.max(1.0)); + let r = ERect::from_center_size(frame.center(), size * fit); + widgets::checker(p, r, 6.0); + p.image(tex.id(), r, ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + if Some(held.raw.document) == current { + p.rect_stroke(frame, 2.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + } + if held.kept.as_ref().is_some_and(|(p, _)| !p.is_identity()) { + p.circle_filled(frame.right_top() + vec2(-7.0, 7.0), 3.0, t.text); + } + let name = ERect::from_min_max(pos2(tile.left(), frame.bottom()), tile.max); + let font = FontId::proportional(10.5); + let label = tail_fitting(p, &held.raw.name, font.clone(), name.width()); + p.with_clip_rect(name).text(name.center(), Align2::CENTER_CENTER, label, font, t.text_dim); + let response = response.on_hover_text(&held.raw.name); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, Some(held.raw.document) == current, &held.raw.name)); + if response.clicked() { + clicked = Some(i); + } + } + }); + clicked +} + +/// `name`, or "…" and as much of its end as fits in `width`: raws from one shoot differ at the +/// end of their names (the frame number). +fn tail_fitting(p: &egui::Painter, name: &str, font: FontId, width: f32) -> String { + let fits = |s: &str| p.layout_no_wrap(s.to_owned(), font.clone(), Color32::WHITE).size().x <= width; + if fits(name) { + return name.to_owned(); + } + let chars: Vec = name.chars().collect(); + (1..chars.len()).map(|start| format!("…{}", chars[start..].iter().collect::())).find(|s| fits(s)).unwrap_or_default() +} diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs index a66e1721585..2866ef5ecbc 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs @@ -54,6 +54,7 @@ fn identity_app() -> (PhotocraftApp, RawOpen, Control) { bytes: Some(bytes), developed_with: Default::default(), demosaic: Default::default(), + crop: None, }; let mut app = PhotocraftApp::new(session, crate::Services::default()); app.sync_views(); @@ -149,7 +150,7 @@ fn queue_identity_targets_an_inactive_raw_without_changing_the_active_document() app.session.set_active(0); assert_eq!(app.session.active().unwrap().doc.id, control.doc.id); queue_open_dialog(&mut app, raw.document, &raw.name, raw.path.as_deref(), raw.bytes.as_deref().map(Vec::as_slice), &[]).unwrap(); - let queued = app.pending_raw_open.as_ref().expect("the explicitly named RAW was queued"); + let queued = app.raw_shelf.first().map(|h| &h.raw).expect("the explicitly named RAW was queued"); assert_eq!(queued.document, raw.document); assert_eq!((&queued.name, &queued.path, &queued.bytes), (&raw.name, &raw.path, &raw.bytes)); assert_eq!(app.session.active().unwrap().doc.id, control.doc.id, "queueing does not activate the target"); @@ -160,12 +161,12 @@ fn queue_identity_targets_an_inactive_raw_without_changing_the_active_document() fn queue_identity_rejects_a_missing_document_without_replacing_the_pending_review() { let (mut app, raw, control) = identity_app(); queue_open_dialog(&mut app, raw.document, &raw.name, raw.path.as_deref(), raw.bytes.as_deref().map(Vec::as_slice), &[]).unwrap(); - let pending = app.pending_raw_open.as_ref().unwrap().clone(); + let pending = app.raw_shelf.first().map(|h| &h.raw).unwrap().clone(); app.session.set_active(0); let missing = photocraft_doc::DocId::fresh(); assert!(!app.session.documents().iter().any(|state| state.doc.id == missing)); assert!(queue_open_dialog(&mut app, missing, "missing.dng", Some("missing.dng"), Some(&[1, 2, 3]), &[]).is_err()); - let queued = app.pending_raw_open.as_ref().expect("the existing review is preserved"); + let queued = app.raw_shelf.first().map(|h| &h.raw).expect("the existing review is preserved"); assert_eq!(queued.document, pending.document); assert_eq!((&queued.name, &queued.path, &queued.bytes), (&pending.name, &pending.path, &pending.bytes)); assert_eq!(app.session.active().unwrap().doc.id, control.doc.id); diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/menu.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/menu.rs index ead69eba2b4..8aed0e56644 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/menu.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/menu.rs @@ -35,15 +35,16 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val for (key, value) in fields { match key.as_str() { "set" | "scope" | "view" | "demosaic" => {} - "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" | "undo" | "redo" | "auto" if value.is_boolean() => {} - "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" | "undo" | "redo" | "auto" => { + "before" | "commit" | "cancel" | "done" | "saveSettings" | "loadSettings" | "undo" | "redo" | "auto" if value.is_boolean() => {} + "before" | "commit" | "cancel" | "done" | "saveSettings" | "loadSettings" | "undo" | "redo" | "auto" => { return Some(Err(format!("Camera Raw {key} must be a boolean"))); } _ => return Some(Err(format!("unknown Camera Raw ui property {key}"))), } } - if ui.get("commit").and_then(Value::as_bool) == Some(true) && ui.get("cancel").and_then(Value::as_bool) == Some(true) { - return Some(Err("Camera Raw cannot commit and cancel together".into())); + let ends = ["commit", "cancel", "done"].into_iter().filter(|k| ui.get(*k).and_then(Value::as_bool) == Some(true)).count(); + if ends > 1 { + return Some(Err("Camera Raw takes one of commit, cancel and done".into())); } for (key, run) in [("saveSettings", save_settings as fn(&mut PhotocraftApp) -> Result), ("loadSettings", load_settings)] { if ui.get(key).and_then(Value::as_bool) == Some(true) { @@ -72,6 +73,9 @@ fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Opti // Stage both filter and presentation updates. No texture, preference or dialog mutation // occurs until every part of the request has passed validation. let d = app.camera_raw.as_ref()?; + if ui.get("done").and_then(Value::as_bool) == Some(true) && d.target != super::open::Target::OpenRaw { + return Some(Err("Done is for raws being opened".into())); + } let mut next_params = d.params.clone(); // Auto (the Light panel's button): the tone sliders from the image's histogram. if ui.get("auto").and_then(Value::as_bool) == Some(true) { @@ -165,5 +169,8 @@ fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Opti if ui.get("commit").and_then(Value::as_bool) == Some(true) { return Some(commit(app)); } + if ui.get("done").and_then(Value::as_bool) == Some(true) { + return Some(Ok(super::filmstrip::done(app))); + } Some(Ok(app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview)).unwrap_or(Value::Null))) } diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs index 8e3a9c9de6d..95da88ae1d9 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs @@ -16,9 +16,9 @@ pub(super) enum Target { /// A raw file opened interactively, shown in Camera Raw before it is kept (as Photoshop opens /// raws in Adobe Camera Raw first). The document is already developed with the defaults; Open /// re-develops it when Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture, -/// Dehaze or the demosaic method changed (those act on the sensor data, see -/// [`photocraft_io::raw::import_raw_tuned`]) and applies the other settings as one Camera Raw -/// step, then marks the document unmodified. Cancel closes it. +/// Dehaze or the demosaic method changed, or the sidecar has a crop (those act on the sensor +/// data, see [`photocraft_io::raw::import_raw_tuned`]) and applies the other settings as one +/// Camera Raw step, then marks the document unmodified. Cancel closes it. #[derive(Clone, Debug)] pub struct RawOpen { pub document: photocraft_doc::DocId, @@ -30,6 +30,9 @@ pub struct RawOpen { pub developed_with: photocraft_io::raw::Demosaic, /// The method chosen in the dialog: Open re-develops when it differs from `developed_with`. pub demosaic: photocraft_io::raw::Demosaic, + /// The crop of the file's sidecar (in the raw's stored orientation): Open re-develops and + /// crops to it. The settings have no crop, so the dialog previews the whole frame. + pub crop: Option, } /// Should an interactive open of `name` show the open-time Camera Raw dialog? `warnings` are @@ -45,7 +48,8 @@ pub fn without_develop_note(warnings: &[String]) -> Vec { } /// Queues the open-time dialog for the document whose import completed, independent of the -/// active tab. A delayed completion for a closed document must not review another photograph. +/// active tab: the dialog shows on the next frame, or adds it to its filmstrip when it is already +/// open. A delayed completion for a closed document must not review another photograph. /// `warnings` are the import's: its note says which demosaic method the open developed with. pub fn queue_open_dialog( app: &mut PhotocraftApp, @@ -61,28 +65,51 @@ pub fn queue_open_dialog( // The method the open actually used (`jobs::import_options` at the job's start), from its // note: the preference may have changed while a background open ran. let method = photocraft_io::raw::developed_demosaic(warnings).unwrap_or_else(|| app.session.prefs().raw_defaults.demosaic.method()); - app.pending_raw_open = Some(RawOpen { + if app.raw_shelf.iter().any(|h| h.raw.document == document) { + return Ok(()); + } + let raw = RawOpen { document, name: name.to_string(), path: path.map(str::to_string), bytes: bytes.map(|b| std::sync::Arc::new(b.to_vec())), developed_with: method, demosaic: method, - }); + // Read now: a filmstrip raw that is never shown still opens with its sidecar's crop. + crop: path.and_then(read_sidecar).and_then(|(_, report, _)| report.crop), + }; + let held = super::filmstrip::HeldRaw::new(app, raw); + app.raw_shelf.push(held); Ok(()) } -/// Opens the queued open-time dialog on its document's (only) layer. +/// Opens the open-time dialog on a queued raw's (only) layer. +#[cfg(test)] pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen) -> Result<(), String> { + open_raw_with(app, ctx, raw, None) +} + +/// [`open_raw`], or with `kept` settings and the settings they started from (a filmstrip raw +/// shown again). +pub(super) fn open_raw_with(app: &mut PhotocraftApp, ctx: &egui::Context, mut raw: RawOpen, kept: Option<(CameraRaw, CameraRaw)>) -> Result<(), String> { let index = document_index(app, raw.document).ok_or("the raw document was closed")?; app.session.set_active(index); let (layer, surf, _) = crate::distort_ui::active_pixels(app)?; + if let Some((params, original)) = kept { + open_pixels(app, ctx, layer, surf, params, Coverage::None, Target::OpenRaw)?; + if let Some(d) = app.camera_raw.as_mut() { + d.original = original; + } + attach_raw(app, raw); + return Ok(()); + } // The file's sidecar (`IMG_0001.xmp` beside it) fills the dialog, as Camera Raw's does; the // status bar names what it could not apply. let mut params = CameraRaw::default(); let mut from_sidecar = false; if let Some((loaded, report, file)) = raw.path.as_deref().and_then(read_sidecar) { params = loaded; + raw.crop = report.crop; from_sidecar = true; app.ui.status = if report.ignored.is_empty() { crate::i18n::fmt(tl!("Settings from {name}"), &[("name", &file)]) @@ -103,6 +130,12 @@ pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpe photocraft_algo::camera_raw::auto_tone(&d.proxy, &mut d.params); d.dirty = true; } + attach_raw(app, raw); + Ok(()) +} + +/// Gives the open dialog its raw: the file name, and the camera tone curve it was developed with. +fn attach_raw(app: &mut PhotocraftApp, raw: RawOpen) { let curve = match (&raw.bytes, &raw.path) { (Some(b), _) => Some(photocraft_io::raw::tone_curve(b)), #[cfg(not(target_arch = "wasm32"))] @@ -117,7 +150,6 @@ pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpe d.raw_curve = curve; d.raw_open = Some(raw); } - Ok(()) } pub(super) fn document_index(app: &PhotocraftApp, id: photocraft_doc::DocId) -> Option { @@ -153,6 +185,9 @@ pub struct Redevelop { filter_step: bool, /// The Camera Raw Defaults the open started with, for the conversions after the filter step. defaults: photocraft_engine::prefs::RawDefaults, + /// The dialog's Open started this, from these settings: if the re-develop fails, the raw goes + /// back to the filmstrip to be tried again or cancelled. + back_to_filmstrip: Option, } /// The sliders a raw open applies while developing, before the tone curve (as Camera Raw does). @@ -179,6 +214,9 @@ pub(super) fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: clarity: params.clarity, texture: params.texture, dehaze: params.dehaze, + // The sidecar's crop: re-developing crops the document, before the Camera Raw step, so + // the post-crop vignette centres on the crop as in Camera Raw. + crop: raw.crop, }; // The open itself: the default settings, developed with the method it was opened with. let as_opened = photocraft_io::raw::RawTuning { demosaic: raw.developed_with, ..Default::default() }; @@ -225,7 +263,7 @@ pub(super) fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: } photocraft_engine::jobs::Started::Job(job) => { let defaults = app.session.prefs().raw_defaults.clone(); - app.raw_redevelop.push(Redevelop { job, params: params.clone(), raw: raw.clone(), filter_step: false, defaults }); + app.raw_redevelop.push(Redevelop { job, params: params.clone(), raw: raw.clone(), filter_step: false, defaults, back_to_filmstrip: None }); app.ui.status = label; app.ui.status_error = false; Ok(json!({"job": job.0, "pending": true})) @@ -233,6 +271,15 @@ pub(super) fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: } } +/// `reply` is [`commit_open_raw`]'s from the dialog's Open: if it started a background re-develop +/// and that fails, the raw comes back to the filmstrip, its settings having started from `original`. +pub(super) fn return_on_failure(app: &mut PhotocraftApp, reply: &Value, original: &CameraRaw) { + let job = reply.get("job").and_then(Value::as_u64); + if let Some(pending) = app.raw_redevelop.iter_mut().find(|r| Some(r.job.0) == job && !r.filter_step) { + pending.back_to_filmstrip = Some(original.clone()); + } +} + /// Swap the default-developed document `doc_id` for the re-developed one, at the same tab /// position and with the same path. Returns `{document, docId, previous, warnings, wb}`: /// `document` is the tab index at apply time, `docId` is the replacement's stable identity. @@ -324,7 +371,7 @@ fn finish_open_raw( && let Some(job) = r.get("job").and_then(Value::as_u64) { let job = photocraft_engine::jobs::JobId(job); - app.raw_redevelop.push(Redevelop { job, params: rest.clone(), raw: raw.clone(), filter_step: true, defaults: d.clone() }); + app.raw_redevelop.push(Redevelop { job, params: rest.clone(), raw: raw.clone(), filter_step: true, defaults: d.clone(), back_to_filmstrip: None }); result["filter"] = r; mark_unmodified(app, index); return Ok(result); @@ -349,9 +396,13 @@ pub fn opened_without_dialog(app: &mut PhotocraftApp, index: usize, name: &str, } let Some(document) = app.session.documents().get(index).map(|st| st.doc.id) else { return }; let method = photocraft_io::raw::developed_demosaic(warnings).unwrap_or_else(|| app.session.prefs().raw_defaults.demosaic.method()); - let raw = RawOpen { document, name: name.to_string(), path: path.map(str::to_string), bytes: None, developed_with: method, demosaic: method }; + let mut raw = + RawOpen { document, name: name.to_string(), path: path.map(str::to_string), bytes: None, developed_with: method, demosaic: method, crop: None }; let mut params = CameraRaw::default(); - let sidecar = path.and_then(read_sidecar).map(|(loaded, _, _)| loaded); + let sidecar = path.and_then(read_sidecar).map(|(loaded, report, _)| { + raw.crop = report.crop; + loaded + }); if let Some(loaded) = &sidecar { params = loaded.clone(); } else if app.session.prefs().raw_defaults.apply_auto_tone @@ -359,7 +410,7 @@ pub fn opened_without_dialog(app: &mut PhotocraftApp, index: usize, name: &str, { photocraft_algo::camera_raw::auto_tone(&px, &mut params); } - let r = if params.is_identity() { + let r = if params.is_identity() && raw.crop.is_none() { let defaults = app.session.prefs().raw_defaults.clone(); finish_open_raw(app, index, params, None, &raw, &defaults) } else { @@ -373,7 +424,7 @@ pub fn opened_without_dialog(app: &mut PhotocraftApp, index: usize, name: &str, /// Every k-th pixel of a surface's content, k chosen so the longer side has about `max_side` /// samples: enough for a histogram (Auto tone) without reading a whole raw. -fn sample_pixels(surf: &photocraft_raster::Surface, max_side: usize) -> Vec<[f32; 4]> { +pub(super) fn sample_pixels(surf: &photocraft_raster::Surface, max_side: usize) -> Vec<[f32; 4]> { let area = surf.content_bounds(); let (w, h) = (area.x1.saturating_sub(area.x0).max(0) as usize, area.y1.saturating_sub(area.y0).max(0) as usize); if w == 0 || h == 0 { @@ -435,7 +486,15 @@ pub fn on_redevelop_event(app: &mut PhotocraftApp, e: &photocraft_engine::jobs:: Err(err) => crate::notices::error(app, format!("{}: {err}", e.label)), }, photocraft_engine::jobs::JobOutcome::Failed(err) => { - // The document stays open, developed with the defaults. + // The document stays open, developed with the defaults; from the dialog, it goes back + // to the filmstrip with its settings, which may have no sidecar to keep them. + if let Some(original) = pending.back_to_filmstrip + && document_index(app, pending.raw.document).is_some() + { + let mut held = super::filmstrip::HeldRaw::new(app, pending.raw); + held.kept = Some((pending.params, original)); + app.raw_shelf.push(held); + } app.sync_views(); crate::notices::error(app, format!("{}: {err}", e.label)); } @@ -447,13 +506,18 @@ pub fn on_redevelop_event(app: &mut PhotocraftApp, e: &photocraft_engine::jobs:: true } -/// Cancel: closes the dialog; for the open-time dialog also the document it was opening. +/// Cancel: closes the dialog; for the open-time dialog also the documents it was opening (every +/// raw in its filmstrip). pub(super) fn cancel(app: &mut PhotocraftApp) { let Some(d) = app.camera_raw.take() else { return }; - if let (Target::OpenRaw, Some(raw)) = (d.target, &d.raw_open) - && let Some(index) = document_index(app, raw.document) - { - app.session.close(index); + if let (Target::OpenRaw, Some(raw)) = (d.target, &d.raw_open) { + let mut opening: Vec<_> = std::mem::take(&mut app.raw_shelf).into_iter().map(|h| h.raw.document).collect(); + opening.push(raw.document); + for id in opening { + if let Some(index) = document_index(app, id) { + app.session.close(index); + } + } app.sync_views(); app.ui.status = crate::i18n::fmt(tl!("Canceled opening {name}"), &[("name", &raw.name)]); app.ui.status_error = false; diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs index dfb03a13b9a..3e5c00f94f5 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs @@ -52,27 +52,50 @@ pub(super) fn write_sidecar(app: &mut PhotocraftApp, raw: &RawOpen, params: &Cam if !app.session.prefs().raw_defaults.write_xmp_sidecars || loaded == Some(params) { return; } + store_sidecar(app, raw, params); +} + +/// What [`store_sidecar`] did. +#[derive(Debug, PartialEq)] +pub(super) enum Stored { + /// Written: the sidecar's name. + Written(String), + /// Nothing to keep: default settings and no sidecar of ours to update. + Defaults, + /// This raw can't keep a sidecar here: an automation request, a DNG or a raw without a path, + /// another application's sidecar, or no file writer. + Skipped, + /// The write failed (already reported in a notice). + Failed, +} + +/// Writes `params` to `raw`'s sidecar under [`write_sidecar`]'s rules, whatever the preference +/// (Done asks for it). +pub(super) fn store_sidecar(app: &mut PhotocraftApp, raw: &RawOpen, params: &CameraRaw) -> Stored { if app.automation_input || app.session.authorize.is_some() { log::info!("{}: sidecar not written for an automation request", raw.name); - return; + return Stored::Skipped; } - let Some(path) = raw.path.as_deref().filter(|p| !is_dng(p)) else { return }; + let Some(path) = raw.path.as_deref().filter(|p| !is_dng(p)) else { return Stored::Skipped }; let sidecar = sidecar_path(path); let name = crate::file_open::display_name(&sidecar); match existing_sidecar_is_ours(&sidecar) { Some(false) => { let kept = crate::i18n::fmt(tl!("Kept {name}: written by another application"), &[("name", &name)]); crate::notices::post(app, kept, Vec::new(), false, None); - return; + return Stored::Skipped; } - None if params.is_identity() => return, + None if params.is_identity() => return Stored::Defaults, _ => {} } - let bytes = photocraft_io::camera_raw_xmp::save(params).into_bytes(); - let Some(write) = app.services.write.as_mut() else { return }; + // The crop it opened with stays in the file, though the dialog has no control for it. + let bytes = photocraft_io::camera_raw_xmp::save_with_crop(params, raw.crop).into_bytes(); + let Some(write) = app.services.write.as_mut() else { return Stored::Skipped }; if let Err(e) = write(&sidecar, &bytes) { crate::notices::error(app, crate::i18n::fmt(tl!("Could not write {name}: {error}"), &[("name", &name), ("error", &e)])); + return Stored::Failed; } + Stored::Written(name) } /// `None` when there is no sidecar at `sidecar`, else whether PhotoCraft wrote it. One that @@ -124,7 +147,12 @@ pub(super) fn load_settings(app: &mut PhotocraftApp) -> Result { let file = crate::file_open::display_name(&name); let d = app.camera_raw.as_mut().ok_or(tl!("Camera Raw isn't open"))?; let mut next = d.params.clone(); - let report = photocraft_io::camera_raw_xmp::load(&mut next, &bytes).map_err(|e| format!("{file}: {e}"))?; + let mut report = photocraft_io::camera_raw_xmp::load(&mut next, &bytes).map_err(|e| format!("{file}: {e}"))?; + // A crop only applies as a raw file opens; the filter has none, so it is not supported. + if report.crop.take().is_some() { + report.applied = report.applied.saturating_sub(1); + report.ignored.extend(photocraft_io::camera_raw_xmp::CROP_KEYS.iter().take(4).map(|k| k.to_string())); + } next.validate_changes(&d.original).map_err(|e| format!("{file}: {e}"))?; if d.params.point_curve != next.point_curve { d.curve_state = Default::default(); diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs index bcae932f5e3..76591f3df1d 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs @@ -1,5 +1,10 @@ use super::*; +/// The first raw waiting for the open-time dialog, taken off its filmstrip. +fn take_queued(app: &mut PhotocraftApp) -> Option { + (!app.raw_shelf.is_empty()).then(|| app.raw_shelf.remove(0).raw) +} + /// #9550: a Color Mixer Hue track runs through the neighbouring bands' hues (Reds: magenta to /// orange, wrapping round), not from grey; Saturation and Luminance keep grey to the colour. #[test] @@ -358,7 +363,7 @@ fn raw_opens_in_camera_raw_and_open_redevelops() { // A raw has no embedded profile (ProPhoto RGB is the space it is developed into), so Ask // When Opening (on by default) has nothing to ask about. assert!(app.ui.dialogs.is_empty(), "no profile prompt for a raw"); - let raw = app.pending_raw_open.take().expect("a raw open queues the dialog"); + let raw = take_queued(&mut app).expect("a raw open queues the dialog"); open_raw(&mut app, &ctx, raw).unwrap(); let d = app.camera_raw.as_ref().unwrap(); assert_eq!(d.target, Target::OpenRaw); @@ -388,7 +393,7 @@ fn the_demosaic_method_is_chosen_at_open() { let ctx = egui::Context::default(); app.open_bytes("IMG_0002.dng", &dng).unwrap(); let ahd = pixels(&app); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); let d = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"demosaic": "bilinear"}})).unwrap().unwrap(); assert_eq!(d["openingRaw"]["demosaic"], "bilinear"); @@ -405,7 +410,7 @@ fn the_demosaic_method_is_chosen_at_open() { app.run("prefs.set", json!({"path": "rawDefaults.demosaic", "value": "bilinear"})).unwrap(); app.open_bytes("IMG_0003.dng", &dng).unwrap(); assert_eq!(pixels(&app), bilinear, "developed with the preference's method"); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); let d = app.camera_raw.as_ref().unwrap().describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview); assert_eq!(d["openingRaw"]["demosaic"], "bilinear"); @@ -419,11 +424,11 @@ fn the_demosaic_method_is_chosen_at_open() { // The preference may change while a background open runs: the dialog starts from the // method the open developed with (its note), not from the newer preference. let warnings = app.open_bytes("IMG_0004.dng", &dng).unwrap(); - app.pending_raw_open.take(); + take_queued(&mut app); app.run("prefs.set", json!({"path": "rawDefaults.demosaic", "value": "ahd"})).unwrap(); let document = app.session.active().unwrap().doc.id; queue_open_dialog(&mut app, document, "IMG_0004.dng", None, Some(&dng), &warnings).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); use photocraft_io::raw::Demosaic; assert_eq!((raw.developed_with, raw.demosaic), (Demosaic::Bilinear, Demosaic::Bilinear)); } @@ -433,7 +438,7 @@ fn shadows_of_a_raw_open_act_on_the_raw_data_and_the_preview_shows_it() { let (mut app, dng) = raw_app(); let ctx = egui::Context::default(); app.open_bytes("IMG_0002.dng", &dng).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); assert!(app.camera_raw.as_ref().unwrap().raw_curve.is_some(), "the preview knows how the raw was developed"); let mean = |px: &[[f32; 4]]| px.iter().map(|p| p[0] + p[1] + p[2]).sum::() / px.len() as f32; @@ -454,7 +459,7 @@ fn clarity_texture_and_dehaze_of_a_raw_open_act_on_the_raw_data() { let (mut app, dng) = raw_app(); let ctx = egui::Context::default(); app.open_bytes("IMG_0003.dng", &dng).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); let before = mean_red(&app); let preview_before = app.camera_raw.as_ref().unwrap().processed.clone(); @@ -476,7 +481,7 @@ fn background_redevelop_keeps_the_tab_position_and_view() { app.open_bytes("mid.dng", &dng).unwrap(); app.run("file.new", json!({"width": 12, "height": 12})).unwrap(); app.sync_views(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); let old_id = raw.document; assert_eq!(document_index(&app, old_id), Some(1)); app.ui.views[1].zoom = 3.0; @@ -516,7 +521,7 @@ fn a_raw_opens_and_redevelops_at_the_raw_defaults_resolution() { app.run("prefs.set", json!({"path": "rawDefaults.resolution", "value": 240})).unwrap(); app.open_bytes("IMG_0004.dng", &dng).unwrap(); assert_eq!(app.session.active().unwrap().doc.resolution_dpi, 240.0); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 1.0}}})).unwrap().unwrap(); let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); @@ -526,19 +531,201 @@ fn a_raw_opens_and_redevelops_at_the_raw_defaults_resolution() { assert_eq!(app.session.active().unwrap().doc.resolution_dpi, 240.0, "automation open"); } +/// Raws opened together share the dialog in a filmstrip, each with its own settings; Open +/// develops them all, Cancel closes them all. +#[test] +fn several_raws_share_the_dialog_and_keep_their_own_settings() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("one.dng", &dng).unwrap(); + app.open_bytes("two.dng", &dng).unwrap(); + app.open_bytes("three.dng", &dng).unwrap(); + assert_eq!(app.raw_shelf.len(), 3); + assert!(app.raw_shelf.iter().all(|h| h.thumb.is_some()), "each has a thumbnail"); + let ids: Vec<_> = app.raw_shelf.iter().map(|h| h.raw.document).collect(); + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 1.0}}})).unwrap().unwrap(); + app.camera_raw.as_mut().unwrap().render(&ctx); + filmstrip::show_raw(&mut app, &ctx, 1).unwrap(); + let d = app.camera_raw.as_ref().unwrap(); + assert_eq!((d.raw_open.as_ref().unwrap().document, d.layer_name.as_str(), d.params.exposure), (ids[1], "two.dng", 0.0)); + assert_eq!(app.raw_shelf[0].kept.as_ref().map(|(p, _)| p.exposure), Some(1.0), "the first keeps its settings"); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 30}}})).unwrap().unwrap(); + filmstrip::show_raw(&mut app, &ctx, 0).unwrap(); + assert_eq!(app.camera_raw.as_ref().unwrap().params.exposure, 1.0, "back to the first, as it was left"); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["opened"].as_array().map(Vec::len), Some(3), "{r}"); + assert!(app.raw_shelf.is_empty() && app.camera_raw.is_none()); + assert_eq!(app.session.documents().len(), 3, "the never-shown third opens too"); + assert!(app.session.documents().iter().all(|st| st.saved_revision == st.revision), "Open leaves them unmodified"); + // Cancel closes every raw in the filmstrip. + app.open_bytes("four.dng", &dng).unwrap(); + app.open_bytes("five.dng", &dng).unwrap(); + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"cancel": true}})).unwrap().unwrap(); + assert!(app.raw_shelf.is_empty()); + assert_eq!(app.session.documents().len(), 3); +} + +/// A raw whose Open fails stays in the filmstrip with its settings, and the dialog comes back +/// for it; the raws that opened leave it. +#[test] +fn a_raw_that_fails_to_open_stays_in_the_filmstrip_with_its_settings() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("one.dng", &dng).unwrap(); + app.open_bytes("two.dng", &dng).unwrap(); + app.open_bytes("three.dng", &dng).unwrap(); + let ids: Vec<_> = app.raw_shelf.iter().map(|h| h.raw.document).collect(); + // Two's file can no longer be developed, and it was left with its own settings. + let mut kept = CameraRaw { exposure: 0.5, ..Default::default() }; + kept.contrast = 20.0; + app.raw_shelf[1].raw.bytes = Some(std::sync::Arc::new(b"not a raw file".to_vec())); + app.raw_shelf[1].kept = Some((kept.clone(), CameraRaw::default())); + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 1.0}}})).unwrap().unwrap(); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["opened"].as_array().map(Vec::len), Some(2), "one and three open: {r}"); + let left: Vec<_> = app.raw_shelf.iter().map(|h| h.raw.document).collect(); + assert_eq!(left, vec![ids[1]], "only the failed raw stays in the filmstrip"); + assert_eq!(app.raw_shelf[0].kept.as_ref().map(|(p, _)| p), Some(&kept), "with its settings"); + // The dialog comes back for it with them; when every raw fails, the dialog and its + // filmstrip stay. + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + let shown = &app.camera_raw.as_ref().unwrap().params; + assert_eq!((shown.exposure, shown.contrast), (0.5, 20.0)); + app.open_bytes("four.dng", &dng).unwrap(); + app.raw_shelf[1].raw.bytes = Some(std::sync::Arc::new(b"not a raw file either".to_vec())); + app.raw_shelf[1].kept = Some((kept.clone(), CameraRaw::default())); + assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().is_err()); + assert_eq!(app.raw_shelf.len(), 2, "both raws stay in the filmstrip"); + assert!(app.camera_raw.is_some(), "and the dialog stays open"); + // Cancel still closes every raw in it. + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"cancel": true}})).unwrap().unwrap(); + assert!(app.raw_shelf.is_empty()); + assert_eq!(app.session.documents().len(), 2, "one and three stay open"); +} + +/// A raw that can't be shown leaves things as they were: the dialog stays on the raw being left, +/// and the other keeps its place in the filmstrip with its settings. +#[test] +fn a_raw_that_cannot_be_shown_keeps_the_dialog_and_the_filmstrip() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("one.dng", &dng).unwrap(); + app.open_bytes("two.dng", &dng).unwrap(); + let ids: Vec<_> = app.raw_shelf.iter().map(|h| h.raw.document).collect(); + let kept = CameraRaw { exposure: 0.5, ..Default::default() }; + app.raw_shelf[1].kept = Some((kept.clone(), CameraRaw::default())); + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 1.0}}})).unwrap().unwrap(); + let two = document_index(&app, ids[1]).unwrap(); + app.session.close(two); + assert!(filmstrip::show_raw(&mut app, &ctx, 1).is_err()); + let d = app.camera_raw.as_ref().expect("the dialog stays"); + assert_eq!((d.raw_open.as_ref().unwrap().document, d.params.exposure), (ids[0], 1.0)); + assert_eq!(app.raw_shelf.len(), 2); + assert_eq!(app.raw_shelf[1].kept.as_ref().map(|(p, _)| p), Some(&kept)); +} + +/// Leaving a raw before its preview has caught up: its thumbnail shows the settings it keeps, not +/// the frame before them. +#[test] +fn a_raw_left_before_its_preview_lands_gets_a_thumbnail_of_its_settings() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("one.dng", &dng).unwrap(); + app.open_bytes("two.dng", &dng).unwrap(); + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + let d = app.camera_raw.as_mut().unwrap(); + d.params.exposure = 2.0; + d.dirty = true; + filmstrip::keep_settings(&mut app); + let thumb = app.raw_shelf[0].thumb.clone().expect("a thumbnail"); + app.camera_raw.as_mut().unwrap().render(&ctx); + filmstrip::keep_settings(&mut app); + assert!(thumb == app.raw_shelf[0].thumb.clone().unwrap(), "the thumbnail matches a finished develop of the same settings"); +} + +/// Open returns once a background develop has started; if that develop then fails, the raw comes +/// back to the filmstrip with its settings, as one that failed at once stays there. +#[test] +fn a_raw_whose_background_develop_fails_comes_back_to_the_filmstrip() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("one.dng", &dng).unwrap(); + app.open_bytes("two.dng", &dng).unwrap(); + let ids: Vec<_> = app.raw_shelf.iter().map(|h| h.raw.document).collect(); + let (kept, from) = (CameraRaw { exposure: 0.5, contrast: 20.0, ..Default::default() }, CameraRaw { contrast: 5.0, ..Default::default() }); + app.raw_shelf[1].raw.bytes = Some(std::sync::Arc::new(b"not a raw file".to_vec())); + app.raw_shelf[1].kept = Some((kept.clone(), from.clone())); + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 1.0}}})).unwrap().unwrap(); + app.background_jobs = true; + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["opened"].as_array().map(Vec::len), Some(2), "both started: {r}"); + assert!(app.raw_shelf.is_empty()); + for _ in 0..3000 { + crate::jobs_ui::tick(&mut app, &ctx); + if app.raw_redevelop.is_empty() && !app.session.has_jobs() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(app.raw_redevelop.is_empty() && !app.session.has_jobs(), "the jobs never finished"); + let left: Vec<_> = app.raw_shelf.iter().map(|h| h.raw.document).collect(); + assert_eq!(left, vec![ids[1]], "only the failed raw comes back"); + assert_eq!(app.raw_shelf[0].kept, Some((kept, from)), "with its settings"); + app.background_jobs = false; + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + assert_eq!(app.camera_raw.as_ref().unwrap().params.contrast, 20.0); +} + +/// Camera Raw Defaults' Auto reads the same pixels whether a raw was shown or not, so Open gives +/// it the same settings either way: a fine pattern that a sparse sample misreads shows it. +#[test] +fn auto_tone_of_a_never_shown_raw_matches_the_dialogs() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + app.run("prefs.set", json!({"path": "rawDefaults.applyAutoTone", "value": true})).unwrap(); + app.run("file.new", json!({"width": 1800, "height": 1200})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let st = app.session.active_mut().unwrap(); + let id = st.doc.layers.iter().find(|l| l.surface().is_some()).unwrap().id; + st.active_layer = Some(id); + let document = st.doc.id; + let Some(photocraft_doc::LayerContent::Raster(s)) = std::sync::Arc::make_mut(&mut st.doc).layer_mut(id).map(|l| &mut l.content) else { + panic!("a pixel layer") + }; + let format = s.format(); + let (dark, light) = (photocraft_raster::from_rgba(&format, [0.1, 0.1, 0.1, 1.0]), photocraft_raster::from_rgba(&format, [0.9, 0.9, 0.9, 1.0])); + for y in 0..1200 { + for x in 0..1800 { + s.write_pixel(x, y, if (x + y) % 2 == 0 { &dark } else { &light }); + } + } + let method = photocraft_io::raw::Demosaic::Ahd; + let raw = RawOpen { document, name: "pattern.dng".into(), path: None, bytes: None, developed_with: method, demosaic: method, crop: None }; + let (unshown, _) = filmstrip::first_settings(&app, &raw); + open_raw(&mut app, &ctx, raw).unwrap(); + let shown = &app.camera_raw.as_ref().unwrap().params; + let tone = |p: &CameraRaw| [p.exposure, p.contrast, p.highlights, p.shadows, p.whites, p.blacks]; + assert_eq!(tone(&unshown), tone(shown)); +} + #[test] fn cancel_closes_the_raw_and_the_preference_skips_the_dialog() { let (mut app, dng) = raw_app(); let ctx = egui::Context::default(); app.open_bytes("a.dng", &dng).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"cancel": true}})).unwrap().unwrap(); assert_eq!(r["cancelled"], true); assert!(app.session.documents().is_empty(), "Cancel closes the raw being opened"); // Unchanged settings: Open keeps the default develop and adds no step. app.open_bytes("b.dng", &dng).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); assert_eq!((r["document"].as_u64(), r["redeveloped"].as_bool(), r.get("filter")), (Some(0), Some(false), None), "{r}"); @@ -546,7 +733,7 @@ fn cancel_closes_the_raw_and_the_preference_skips_the_dialog() { // Preference off: raws open straight away. app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": false})).unwrap(); app.open_bytes("c.dng", &dng).unwrap(); - assert!(app.pending_raw_open.is_none()); + assert!(app.raw_shelf.is_empty()); // A non-raw never queues the dialog; a closed document is an error, not a panic. assert!( open_raw( @@ -558,7 +745,8 @@ fn cancel_closes_the_raw_and_the_preference_skips_the_dialog() { path: None, bytes: None, developed_with: Default::default(), - demosaic: Default::default() + demosaic: Default::default(), + crop: None } ) .is_err() @@ -570,7 +758,7 @@ fn calibration_changes_the_preview_and_round_trips_through_xmp() { let (mut app, dng) = raw_app(); let ctx = egui::Context::default(); app.open_bytes("IMG_0005.dng", &dng).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); let before = app.camera_raw.as_ref().unwrap().processed.clone(); menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"shadowTint": 100, "calibrationHue": [100, 0, 0], "calibrationSat": [0, 0, -100]}}})) @@ -595,7 +783,7 @@ fn auto_sets_the_light_sliders_from_the_image() { let (mut app, dng) = raw_app(); let ctx = egui::Context::default(); app.open_bytes("IMG_0006.dng", &dng).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); assert!(app.camera_raw.as_ref().unwrap().params.is_identity()); menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"auto": true}})).unwrap().unwrap(); @@ -605,7 +793,7 @@ fn auto_sets_the_light_sliders_from_the_image() { // The preference does the same when a raw opens without a sidecar. app.run("prefs.set", json!({"path": "rawDefaults.applyAutoTone", "value": true})).unwrap(); app.open_bytes("IMG_0007.dng", &dng).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); assert_eq!(app.camera_raw.as_ref().unwrap().params, p, "Apply auto tone opens the dialog with Auto's settings"); } @@ -635,7 +823,7 @@ fn a_sidecar_fills_the_dialog_and_open_writes_it_back() { })); let ctx = egui::Context::default(); app.open_bytes("IMG_0008.cr2", &dng).unwrap(); - let mut raw = app.pending_raw_open.take().unwrap(); + let mut raw = take_queued(&mut app).unwrap(); raw.path = Some(raw_path.clone()); open_raw(&mut app, &ctx, raw).unwrap(); let d = app.camera_raw.as_ref().unwrap(); @@ -657,14 +845,14 @@ fn a_sidecar_fills_the_dialog_and_open_writes_it_back() { let dng_path = dir.join("IMG_0009.dng").to_string_lossy().into_owned(); std::fs::write(&dng_path, &dng).unwrap(); app.open_bytes("IMG_0009.dng", &dng).unwrap(); - let mut raw = app.pending_raw_open.take().unwrap(); + let mut raw = take_queued(&mut app).unwrap(); raw.path = Some(dng_path); open_raw(&mut app, &ctx, raw).unwrap(); menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 15}, "commit": true}})).unwrap().unwrap(); assert!(written.lock().unwrap().is_empty(), "no sidecar for a DNG"); app.run("prefs.set", json!({"path": "rawDefaults.writeXmpSidecars", "value": false})).unwrap(); app.open_bytes("IMG_0008.cr2", &dng).unwrap(); - let mut raw = app.pending_raw_open.take().unwrap(); + let mut raw = take_queued(&mut app).unwrap(); raw.path = Some(raw_path.clone()); open_raw(&mut app, &ctx, raw).unwrap(); menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 30}, "commit": true}})).unwrap().unwrap(); @@ -673,7 +861,7 @@ fn a_sidecar_fills_the_dialog_and_open_writes_it_back() { app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": false})).unwrap(); let before = app.session.documents().len(); app.open_file(&raw_path, &dng).unwrap(); - assert!(app.pending_raw_open.is_none()); + assert!(app.raw_shelf.is_empty()); assert_eq!(app.session.documents().len(), before + 1); let st = app.session.active().unwrap(); assert_eq!(st.saved_revision, st.revision, "opening with a sidecar leaves the document unmodified"); @@ -686,7 +874,7 @@ fn camera_raw_defaults_apply_when_a_raw_opens() { let (mut app, dng) = raw_app(); app.run("prefs.set", json!({"values": {"rawDefaults.openInCameraRaw": false, "rawDefaults.colorSpace": "srgb", "rawDefaults.bitDepth": "8", "rawDefaults.resolution": 240.0, "rawDefaults.sharpenFor": "screen", "rawDefaults.openAsSmartObject": true}})).unwrap(); app.open_bytes("IMG_0010.dng", &dng).unwrap(); - assert!(app.pending_raw_open.is_none()); + assert!(app.raw_shelf.is_empty()); let st = app.session.active().unwrap(); assert_eq!(st.doc.depth, photocraft_color::SampleType::U8, "Bit depth 8"); assert_eq!(st.doc.resolution_dpi, 240.0, "Resolution"); @@ -700,7 +888,7 @@ fn camera_raw_defaults_apply_when_a_raw_opens() { .unwrap(); let ctx = egui::Context::default(); app.open_bytes("IMG_0011.dng", &dng).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(&mut app).unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); assert_eq!(r["defaults"], json!(["sharpenFor", "colorSpace"]), "{r}"); @@ -756,7 +944,7 @@ fn raw_with_sidecar(tag: &str, sidecar: Option<&str>) -> (PhotocraftApp, Vec /// Opens the raw at `raw_path` in the open-time dialog, as File › Open does. fn open_dialog_at(app: &mut PhotocraftApp, ctx: &egui::Context, dng: &[u8], raw_path: &str) { app.open_bytes("IMG_0100.cr2", dng).unwrap(); - let mut raw = app.pending_raw_open.take().unwrap(); + let mut raw = take_queued(app).unwrap(); raw.path = Some(raw_path.to_string()); open_raw(app, ctx, raw).unwrap(); } @@ -801,6 +989,74 @@ fn a_rating_only_sidecar_is_left_alone() { let _ = std::fs::remove_dir_all(&dir); } +/// #9985: the crop of a Lightroom sidecar crops the opened raw (with the dialog's Open and +/// without the dialog), and neither its edges nor the raw's file name are named as unsupported. +/// A straightened crop isn't applied and is named. PhotoCraft's own sidecar keeps the crop. +#[test] +fn a_sidecar_crop_crops_the_opened_raw() { + let lightroom = r#""#; + let (mut app, dng, dir, raw_path, written) = raw_with_sidecar("crop", Some(lightroom)); + let ctx = egui::Context::default(); + let size = |app: &PhotocraftApp| app.session.active().map(|st| (st.doc.size.width, st.doc.size.height)); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + assert!(app.ui.status.contains("IMG_0100.xmp") && !app.ui.status.contains("not supported"), "{}", app.ui.status); + assert_eq!(size(&app), Some((48, 32)), "the dialog previews the whole frame"); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(size(&app), Some((24, 16)), "Open crops the 48x32 frame to x 6..30, y 8..24"); + let st = app.session.active().unwrap(); + assert_eq!(st.saved_revision, st.revision, "the crop is part of opening: unmodified"); + assert!(written.lock().unwrap().is_empty(), "Lightroom's sidecar is kept"); + app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": false})).unwrap(); + app.open_file(&raw_path, &dng).unwrap(); + assert_eq!(size(&app), Some((24, 16)), "without the dialog too"); + app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": true})).unwrap(); + // Straightened: the rectangle is measured on the turned frame, so nothing is cropped. + std::fs::write(sidecar_path(&raw_path), lightroom.replace(r#"crs:CropAngle="0""#, r#"crs:CropAngle="1.38""#)).unwrap(); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + assert!(app.ui.status.contains("CropAngle") && !app.ui.status.contains("RawFileName"), "{}", app.ui.status); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(size(&app), Some((48, 32))); + // PhotoCraft's own sidecar: a changed setting rewrites it, with the crop it was opened with. + let crop = photocraft_io::camera_raw_xmp::Crop { top: 0.25, left: 0.125, bottom: 0.75, right: 0.625 }; + std::fs::write(sidecar_path(&raw_path), photocraft_io::camera_raw_xmp::save_with_crop(&CameraRaw::default(), Some(crop))).unwrap(); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 15}, "commit": true}})).unwrap().unwrap(); + assert_eq!(size(&app), Some((24, 16))); + let w = written.lock().unwrap().clone(); + assert_eq!(w.len(), 1); + let mut back = CameraRaw::default(); + let r = photocraft_io::camera_raw_xmp::load(&mut back, &w[0].1).unwrap(); + assert_eq!((back.contrast, r.crop), (15.0, Some(crop))); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #9985 with the filmstrip: raws opened together each open with their own sidecar's crop, also +/// the one that is never shown and the one that was shown and left. +#[test] +fn filmstrip_raws_open_with_their_sidecar_crops() { + let lightroom = r#""#; + let (mut app, dng, dir, raw_path, _) = raw_with_sidecar("filmcrop", Some(lightroom)); + let paths: Vec = (1..3).map(|i| dir.join(format!("IMG_010{i}.cr2")).to_string_lossy().into_owned()).collect(); + for p in &paths { + std::fs::write(p, &dng).unwrap(); + std::fs::write(sidecar_path(p), lightroom).unwrap(); + } + let ctx = egui::Context::default(); + app.open_file(&raw_path, &dng).unwrap(); + for p in &paths { + app.open_file(p, &dng).unwrap(); + } + assert_eq!(app.raw_shelf.len(), 3); + filmstrip::open_held(&mut app, &ctx, 0).unwrap(); + filmstrip::show_raw(&mut app, &ctx, 1).unwrap(); + filmstrip::show_raw(&mut app, &ctx, 0).unwrap(); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["opened"].as_array().map(Vec::len), Some(3), "{r}"); + let sizes: Vec<_> = app.session.documents().iter().map(|st| (st.doc.size.width, st.doc.size.height)).collect(); + assert_eq!(sizes, vec![(24, 16); 3], "shown, shown and left, and never shown: all cropped"); + let _ = std::fs::remove_dir_all(&dir); +} + /// With Open in Camera Raw off, opening a raw develops it with its sidecar and writes nothing /// back: nothing changed. #[test] @@ -808,7 +1064,7 @@ fn opening_without_the_dialog_does_not_rewrite_the_sidecar() { let (mut app, dng, dir, raw_path, written) = raw_with_sidecar("nodialog", Some(LIGHTROOM_SIDECAR)); app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": false})).unwrap(); app.open_file(&raw_path, &dng).unwrap(); - assert!(app.pending_raw_open.is_none()); + assert!(app.raw_shelf.is_empty()); assert_eq!(app.session.documents().len(), 1); assert!(written.lock().unwrap().is_empty(), "just opening never writes the sidecar"); assert_eq!(std::fs::read_to_string(sidecar_path(&raw_path)).unwrap(), LIGHTROOM_SIDECAR); @@ -855,7 +1111,7 @@ fn a_sidecar_write_error_stays_reported() { fn open_with_settings(app: &mut PhotocraftApp, ctx: &egui::Context, dng: &[u8], name: &str) { app.open_bytes(name, dng).unwrap(); - let raw = app.pending_raw_open.take().unwrap(); + let raw = take_queued(app).unwrap(); open_raw(app, ctx, raw).unwrap(); menu(app, ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 0.5, "contrast": 60, "saturation": 40, "vibrance": 30}}})).unwrap().unwrap(); } @@ -1022,3 +1278,60 @@ fn save_and_load_settings_round_trip_through_xmp_files() { frame(&mut app); assert_eq!(app.camera_raw.as_ref().unwrap().params.clarity, 25.0); } + +/// Done keeps the settings in the raw's sidecar and opens nothing, +/// even with the preference that writes sidecars on Open turned off. +#[test] +fn done_saves_the_sidecar_and_opens_nothing() { + let (mut app, dng, dir, raw_path, written) = raw_with_sidecar("done", None); + let ctx = egui::Context::default(); + app.run("prefs.set", json!({"path": "rawDefaults.writeXmpSidecars", "value": false})).unwrap(); + let before = app.session.documents().len(); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + let set = json!({"contrast": 20, "clarity": 15}); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": set}})).unwrap().unwrap(); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"done": true}})).unwrap().unwrap(); + assert_eq!(r["saved"], json!(["IMG_0100.xmp"]), "{r}"); + assert!(app.camera_raw.is_none() && app.raw_shelf.is_empty()); + assert_eq!(app.session.documents().len(), before, "Done opens nothing"); + let w = written.lock().unwrap().clone(); + assert_eq!(w.len(), 1); + let mut back = CameraRaw::default(); + photocraft_io::camera_raw_xmp::load(&mut back, &w[0].1).unwrap(); + assert_eq!((back.contrast, back.clarity), (20.0, 15.0)); + // Done is only for raws being opened, and checked before anything else in the request + // changes the dialog. + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"done": true}})).unwrap().is_err()); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({})).unwrap().unwrap(); + let before = app.camera_raw.as_ref().unwrap().params.clone(); + assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 40}, "done": true}})).unwrap().is_err()); + assert_eq!(app.camera_raw.as_ref().unwrap().params, before, "a refused Done changes nothing"); + for other in ["commit", "cancel"] { + assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"done": true, other: true}})).unwrap().is_err(), "{other}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// A sidecar that can't be written keeps the dialog and the raw open with their settings; +/// default settings are nothing to save, not an error. +#[test] +fn done_keeps_the_raws_open_when_a_sidecar_write_fails() { + let (mut app, dng, dir, raw_path, _) = raw_with_sidecar("done-fails", None); + let ctx = egui::Context::default(); + app.services.write = Some(Box::new(|_: &str, _: &[u8]| Err("read-only volume".to_string()))); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + let docs = app.session.documents().len(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 20}}})).unwrap().unwrap(); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"done": true}})).unwrap().unwrap(); + assert_eq!(r["done"], false, "{r}"); + assert_eq!(app.camera_raw.as_ref().map(|d| d.params.contrast), Some(20.0), "the edit is still there"); + assert_eq!(app.session.documents().len(), docs, "the raw stays open"); + assert!(app.ui.status_error); + // At the defaults there is nothing to write: Done closes, without an error. + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 0}}})).unwrap().unwrap(); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"done": true}})).unwrap().unwrap(); + assert_eq!((r["done"].clone(), r["saved"].clone()), (json!(true), json!([])), "{r}"); + assert!(app.camera_raw.is_none() && !app.ui.status_error, "{}", app.ui.status); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/dialogs/color_picker_ui.rs b/crates/ui-egui/src/dialogs/color_picker_ui.rs index d59bf082c3a..1c76f566fa3 100644 --- a/crates/ui-egui/src/dialogs/color_picker_ui.rs +++ b/crates/ui-egui/src/dialogs/color_picker_ui.rs @@ -271,6 +271,10 @@ fn set_field(f: &mut Map, field: &str, value: Value) -> Result<() /// 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. +/// +/// A dialog can provide `__pickColorDefault` when its field is a non-colour choice that has not +/// yet been replaced by the picked colour. The temporary default seeds the picker without +/// changing the parent field; it is removed before the picker opens. pub fn open_requested(app: &mut PhotocraftApp, id: u64) { let Some(d) = app.ui.dialog_mut(id) else { return }; let (title, field) = match d.fields.remove(REQUEST) { @@ -282,7 +286,11 @@ pub fn open_requested(app: &mut PhotocraftApp, id: u64) { } _ => return, }; - let rgb = field_value(&d.fields, &field).and_then(parse_hex).unwrap_or([0.0; 3]); + let rgb = field_value(&d.fields, &field) + .and_then(parse_hex) + .or_else(|| d.fields.get("__pickColorDefault").and_then(Value::as_str).and_then(parse_hex)) + .unwrap_or([0.0; 3]); + d.fields.remove("__pickColorDefault"); open_for_field(app, &title, rgb, id, &field); } diff --git a/crates/ui-egui/src/dialogs/export_dialog.rs b/crates/ui-egui/src/dialogs/export_dialog.rs index dcd6ba003d7..c2a3fd15af8 100644 --- a/crates/ui-egui/src/dialogs/export_dialog.rs +++ b/crates/ui-egui/src/dialogs/export_dialog.rs @@ -209,7 +209,7 @@ pub fn quick_export_selected_layers_png(app: &mut PhotocraftApp) -> Result = layers.into_iter().map(|id| photocraft_engine::layer_menu_cmds::layer_document(&st.doc, id).map_err(|e| e.to_string())).collect::>()?; let suggested = format!("{}.png", export_name(&st.doc.name)); - app.pick_save(&suggested, move |app, path| { + app.pick_export(&suggested, move |app, path| { if !path.to_ascii_lowercase().ends_with(".png") { return Err("Quick Export as PNG writes .png files".into()); } @@ -1050,7 +1050,7 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result, ids: &[photoc let stem = st.doc.name.rsplit_once('.').map_or(st.doc.name.as_str(), |(a, _)| a); let suggested = format!("{}.{}", export_name(stem), s_fmt(f)); let (f, settings) = (f.clone(), settings(f)); - app.pick_save(&suggested, move |app, path| { + app.pick_export(&suggested, move |app, path| { let mut exported = Vec::new(); for (doc, path) in docs.iter().zip(batch_paths(&path, &docs)) { let out = export_document(doc, &f, None, &source_profile)?; @@ -1151,7 +1151,7 @@ pub fn quick_export_png(app: &mut PhotocraftApp) -> Result { let st = app.session.active().ok_or("no document")?; let stem = st.doc.name.rsplit_once('.').map_or(st.doc.name.as_str(), |(a, _)| a); let (suggested, doc) = (format!("{stem}.{fmt}"), st.doc.id); - return app.pick_save(&suggested, move |app, path| { + return app.pick_export(&suggested, move |app, path| { app.refocus(doc)?; quick_export(app, json!({"path": path})) }); diff --git a/crates/ui-egui/src/dialogs/file_ui/web.rs b/crates/ui-egui/src/dialogs/file_ui/web.rs index d8db1fee042..d4fa5ebf726 100644 --- a/crates/ui-egui/src/dialogs/file_ui/web.rs +++ b/crates/ui-egui/src/dialogs/file_ui/web.rs @@ -584,7 +584,7 @@ pub(super) fn web_confirm(app: &mut PhotocraftApp, f: &Map) -> Re let st = WebSettings::from_params(&p, "file.export.saveForWebLegacy").map_err(|e| e.to_string())?; let base = photocraft_doc::slices::base_name(&doc); let suggested = if has_slices && html { format!("{base}.html") } else { format!("{base}.{}", st.format.ext()) }; - app.pick_save(&suggested, move |app, path| { + app.pick_export(&suggested, move |app, path| { app.refocus(doc.id)?; if has_slices { let dir = path.rfind(['/', '\\']).map(|i| path[..i].to_string()).unwrap_or_else(|| ".".into()); diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs index 540c1bb3b97..2464721efda 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog.rs @@ -6,6 +6,8 @@ use std::sync::Arc; +use photocraft_cms::{ColorSpace, TransformOptions}; +use photocraft_color::ColorMode; use photocraft_doc::Document; use photocraft_engine::color_cmds::ColorSettings; use photocraft_engine::installed_profiles::InstalledProfile; @@ -322,6 +324,8 @@ pub const PREVIEWED: &[&str] = &[ // The preview shows the image through the chosen profile, as Photoshop's Preview does. "edit.assignProfile", "edit.convertToProfile", + // Previewed from the session's history (`canvas::filter_preview`), at full size (#8230). + "edit.fade", ]; pub fn has_dialog(command: &str) -> bool { @@ -429,6 +433,9 @@ fn default_value(app: &PhotocraftApp, command: &str, p: &Param) -> Option /// Command-specific open defaults (ruler straighten, warp from layer, …), not remembered prefs. fn apply_command_defaults(app: &PhotocraftApp, command: &str, fields: &mut Map) { + if command == "image.mode.indexedColor" { + indexed_matte_defaults(app, fields); + } if command == "image.rotation.arbitrary" { straighten_defaults(app, fields); } @@ -857,6 +864,109 @@ fn replace_color_result(ui: &mut egui::Ui, f: &mut Map) { } } +const INDEXED_MATTE_FOREGROUND: &str = "__indexedMatteForeground"; +const INDEXED_MATTE_BACKGROUND: &str = "__indexedMatteBackground"; +const INDEXED_MATTE_COLOR_ERROR: &str = "__indexedMatteColorError"; + +/// Resolve a tool colour into the encoded samples used by Indexed Color's matte. +/// +/// The picker and command fields carry document RGB coordinates. Tool swatches are +/// working-RGB coordinates, so only the named tool choices need this conversion here. +fn indexed_matte_document_color(app: &PhotocraftApp, doc: &Document, color: [f32; 4]) -> Result<[f32; 3], String> { + let destination = match photocraft_engine::color_cmds::mode_space(doc.mode) { + ColorSpace::Rgb | ColorSpace::Gray => photocraft_engine::color_cmds::document_profile(doc), + _ => photocraft_engine::color_cmds::composite_profile(doc), + }; + let source = app.session.color.working(ColorMode::Rgb); + let options = TransformOptions { intent: app.session.color.settings.intent(), bpc: app.session.color.settings.bpc, ..Default::default() }; + let transform = photocraft_cms::transform::cached(&source, &destination, options).map_err(|e| e.to_string())?; + let mut output = [0.0; 16]; + transform.eval(&color[..3], &mut output); + let rgb = if destination.color_space == ColorSpace::Gray { [output[0]; 3] } else { [output[0], output[1], output[2]] }; + if rgb.iter().any(|v| !v.is_finite()) { + return Err("cannot convert matte colour to finite document samples".into()); + } + Ok(rgb.map(|v| v.clamp(0.0, 1.0))) +} + +fn indexed_matte_defaults(app: &PhotocraftApp, fields: &mut Map) { + let Some(doc) = app.session.active().map(|state| state.doc.as_ref()) else { return }; + for (key, color) in [(INDEXED_MATTE_FOREGROUND, app.session.tools.foreground), (INDEXED_MATTE_BACKGROUND, app.session.tools.background)] { + match indexed_matte_document_color(app, doc, color) { + Ok(rgb) => { + fields.insert(key.into(), json!(crate::color_picker_ui::hex(rgb))); + } + Err(error) => { + fields.insert(INDEXED_MATTE_COLOR_ERROR.into(), json!(error)); + } + } + } +} + +fn indexed_matte_seed(fields: &Map, value: &str) -> Option<[f32; 3]> { + match value { + "foreground" => fields.get(INDEXED_MATTE_FOREGROUND).and_then(Value::as_str).and_then(crate::color_picker_ui::parse_hex), + "background" => fields.get(INDEXED_MATTE_BACKGROUND).and_then(Value::as_str).and_then(crate::color_picker_ui::parse_hex), + "black" => Some([0.0; 3]), + "gray" => Some([128.0 / 255.0; 3]), + "netscapeGray" => Some([192.0 / 255.0; 3]), + "none" | "white" => Some([1.0; 3]), + _ => crate::color_picker_ui::parse_hex(value), + } +} + +fn indexed_matte_row(ui: &mut egui::Ui, fields: &mut Map) { + let value = fields.get("matte").and_then(Value::as_str).unwrap_or("none"); + let mut choice = if value.starts_with('#') { "custom" } else { value }; + let color = indexed_matte_seed(fields, value); + let mut changed = false; + let mut pick = false; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Matte")).color(Tokens::get(ui.ctx()).text_dim)); + changed = crate::widgets::dropdown( + ui, + "indexed-matte", + "Matte", + &mut choice, + &[ + ("none", "None"), + ("foreground", "Foreground Color"), + ("background", "Background Color"), + ("white", "White"), + ("black", "Black"), + ("gray", "50% Gray"), + ("netscapeGray", tl!("Netscape Gray")), + ("custom", "Other…"), + ], + 170.0, + ); + if choice == "custom" + && let Some(color) = color + { + let bytes = color.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + pick = crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(bytes[0], bytes[1], bytes[2]), tl!("Matte")).clicked(); + } + }); + if color.is_none() + && let Some(error) = fields.get(INDEXED_MATTE_COLOR_ERROR).and_then(Value::as_str) + { + ui.colored_label(Tokens::get(ui.ctx()).danger, error); + } + let custom = choice == "custom"; + if changed { + if custom && let Some(color) = color { + // Keep the previous matte until the picker confirms; the picker uses this only as + // its initial document-encoded colour when switching from a named matte or None. + fields.insert("__pickColorDefault".into(), json!(crate::color_picker_ui::hex(color))); + } else if !custom { + fields.insert("matte".into(), json!(choice.to_owned())); + } + } + if custom && color.is_some() && (changed || pick) { + crate::color_picker_ui::request_field(fields, "matte", &crate::color_picker_ui::title_for("Matte")); + } +} + /// Dialog body for filter commands. pub fn body(ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); @@ -873,6 +983,10 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { crate::select_mask_view::controls(ui, f); } for p in parse_spec(&spec) { + if cmd == "image.mode.indexedColor" && p.key == "matte" { + indexed_matte_row(ui, f); + continue; + } if zoom_center::owns(&cmd, f) && matches!(p.key.as_str(), "centerX" | "centerY") { if p.key == "centerX" { zoom_center::row(ui, f); @@ -1191,6 +1305,7 @@ pub fn preview_document_with( photocraft_engine::channel_cmds::ChannelTarget::Composite, &[], &ColorSettings::default(), + None, ) } @@ -1198,6 +1313,8 @@ pub fn preview_document_with( /// `documents` are the open documents in session order: document params (Match Color's Source, /// Displace's map) index into them. `color` is the app's Edit › Color Settings: Assign and Convert /// to Profile's `working` is the working space chosen there, not the built-in default. +/// `tool_colors` carries the caller's working-space foreground/background values; standalone +/// previews without a session use the engine's default tool colors. #[allow(clippy::too_many_arguments)] pub(crate) fn preview_document_for_target( doc: &Document, @@ -1209,6 +1326,7 @@ pub(crate) fn preview_document_for_target( target: photocraft_engine::channel_cmds::ChannelTarget, documents: &[Arc], color: &ColorSettings, + tool_colors: Option<([f32; 4], [f32; 4])>, ) -> Option { if command == crate::select_mask_view::COMMAND { return crate::select_mask_view::preview_document(doc, active, params, k, cancel); @@ -1244,6 +1362,10 @@ pub(crate) fn preview_document_for_target( } let mut s = photocraft_engine::Session::new(); s.color.settings = color.clone(); + if let Some((foreground, background)) = tool_colors { + s.tools.foreground = foreground; + s.tools.background = background; + } s.set_inline_job_ctx(cancel.cloned()); let doc_id = proxy.id; s.add_document(proxy, None); @@ -1386,11 +1508,12 @@ pub struct FilterPreviewKey { pub command: String, pub params: Value, pub k: u32, + pub tool_colors: ([f32; 4], [f32; 4]), } impl FilterPreviewKey { /// Everything except the parameters: same dialog, document, revision, layer, channel target, - /// command and proxy factor. + /// command, proxy factor and tool colors. /// /// The canvas shows whatever this preview was computed for, so a result whose parameters are /// a few frames old still belongs on screen while a slider is moving; matching on everything @@ -1412,6 +1535,7 @@ impl FilterPreviewKey { && self.active == other.active && self.target == other.target && self.command == other.command + && self.tool_colors == other.tool_colors } } diff --git a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs index de9f389e234..74e3f806eb2 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs @@ -90,6 +90,34 @@ fn display_names_are_not_split_like_camel_case_keys() { assert_eq!(source_label("centerX"), "Center X"); } +#[test] +fn indexed_matte_tool_seeds_use_document_encoded_rgb() { + use photocraft_cms::Transform; + + let mut session = photocraft_engine::Session::new(); + session.color.settings.working_rgb = "display-p3".into(); + session.execute("file.new", json!({"width": 2, "height": 2, "background": "transparent"})).unwrap(); + session.execute("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap(); + session.execute("tools.setColors", json!({"foreground": "#3366cc", "background": "#cc6633"})).unwrap(); + let app = PhotocraftApp::new(session, crate::Services::default()); + let mut fields = Map::new(); + indexed_matte_defaults(&app, &mut fields); + + let doc = app.session.active().unwrap().doc.clone(); + let source = app.session.color.working(ColorMode::Rgb); + let destination = photocraft_engine::color_cmds::document_profile(&doc); + let transform = Transform::new(&source, &destination, app.session.color.settings.intent(), app.session.color.settings.bpc).unwrap(); + for (key, input) in [(INDEXED_MATTE_FOREGROUND, [0.2, 0.4, 0.8]), (INDEXED_MATTE_BACKGROUND, [0.8, 0.4, 0.2])] { + let mut output = [0.0; 16]; + transform.eval(&input, &mut output); + let expected = crate::color_picker_ui::hex([output[0], output[1], output[2]]); + assert_eq!(fields.get(key).and_then(Value::as_str), Some(expected.as_str()), "{key}"); + assert_eq!( + indexed_matte_seed(&fields, if key == INDEXED_MATTE_FOREGROUND { "foreground" } else { "background" }), + crate::color_picker_ui::parse_hex(&expected) + ); + } +} /// Convert to Profile's and Proof Setup's `bpc` read "Bpc": spelled out. #[test] fn black_point_compensation_is_spelled_out() { @@ -106,6 +134,7 @@ fn key() -> FilterPreviewKey { command: "filter.blur.gaussianBlur".into(), params: json!({"radius": 4.0}), k: 1, + tool_colors: ([0.0, 0.0, 0.0, 1.0], [1.0; 4]), } } @@ -128,6 +157,8 @@ fn same_target_ignores_parameters_but_nothing_else() { |k: &mut FilterPreviewKey| k.target = photocraft_engine::channel_cmds::ChannelTarget::LayerMask, |k: &mut FilterPreviewKey| k.command = "filter.blur.motionBlur".into(), |k: &mut FilterPreviewKey| k.k = 2, + |k: &mut FilterPreviewKey| k.tool_colors.0 = [0.2, 0.4, 0.8, 1.0], + |k: &mut FilterPreviewKey| k.tool_colors.1 = [0.8, 0.4, 0.2, 1.0], ] { let mut other = base.clone(); break_it(&mut other); @@ -653,6 +684,7 @@ fn smart_displace_preview_uses_the_map_document() { photocraft_engine::channel_cmds::ChannelTarget::Composite, &documents, &ColorSettings::default(), + None, ) .unwrap_or_else(|| panic!("map {map}: no preview")); assert!(photocraft_compose::render(&preview, doc.bounds()).px == want, "map {map}: the preview differs from what OK applies"); @@ -695,6 +727,7 @@ fn match_color_preview_uses_the_source_document() { photocraft_engine::channel_cmds::ChannelTarget::Composite, &documents, &ColorSettings::default(), + None, ) .unwrap_or_else(|| panic!("{label}: no preview")); let got = preview.layers[0].surface().unwrap().pixel(24, 16); @@ -711,6 +744,7 @@ fn match_color_preview_uses_the_source_document() { photocraft_engine::channel_cmds::ChannelTarget::Composite, &documents, &ColorSettings::default(), + None, ) .unwrap(); let px = preview.layers[0].surface().unwrap().pixel(12, 8); @@ -1716,3 +1750,80 @@ fn replace_color_localized_color_clusters() { assert!(px(2)[1] > 0.5, "near the point: shifted to green {:?}", px(2)); assert!(px(38)[0] > 0.9 && px(38)[1] < 0.1, "far away: still red {:?}", px(38)); } + +#[test] +fn indexed_matte_dropdown_and_picker_change_the_applied_edge_color() { + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + + for (choice, matte) in [("Black", [0.0, 0.0, 0.0]), ("50% Gray", [128.0 / 255.0; 3]), ("Netscape Gray", [192.0 / 255.0; 3]), ("Other…", [0.0, 0.0, 1.0])] + { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 2, "height": 1, "depth": 32, "background": "transparent"})).unwrap(); + session + .edit("fixture", |doc, active| { + let bounds = doc.bounds(); + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().write_region(bounds, &[1.0, 0.0, 0.0, 0.75, 0.0, 0.0, 0.0, 0.0]); + Ok(()) + }) + .unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let mut harness = Harness::builder().with_size(egui::vec2(900.0, 1000.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), &mut app); + PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(harness.state_mut(), "image.mode.indexedColor").unwrap(); + harness.state_mut().ui.dialog_mut(id).unwrap().fields.insert("palette".into(), json!("exact")); + harness.run_steps(3); + harness.query_all_by_role(Role::ComboBox).find(|node| node.value().as_deref() == Some("None")).expect("Matte dropdown").click(); + harness.run_steps(2); + harness.get_by_label(choice).click(); + harness.run_steps(3); + if choice == "Other…" { + let picker = harness.state().ui.dialogs.last().unwrap().id; + assert_ne!(picker, id); + harness.state_mut().ui.dialog_mut(picker).unwrap().fields.insert("color".into(), json!("#0000ff")); + crate::dialogs::confirm(harness.state_mut(), picker).unwrap(); + harness.state_mut().ui.dialogs.retain(|dialog| dialog.id != picker); + harness.run_steps(3); + } + harness.get_by_label("OK").click(); + harness.run_steps(3); + let doc = &harness.state().session.active().unwrap().doc; + assert_eq!(doc.mode, photocraft_color::ColorMode::Indexed); + let surface = doc.layers[0].surface().unwrap(); + let edge = surface.rgba(0, 0); + let expected = [0.75 + 0.25 * matte[0], 0.25 * matte[1], 0.25 * matte[2]]; + assert!(edge.iter().zip(expected).all(|(got, want)| (got - want).abs() <= 1.0 / 255.0) && edge[3] == 1.0, "{choice}: {edge:?} vs {expected:?}"); + assert_eq!(surface.rgba(1, 0)[3], 0.0); + } +} + +/// Selecting Other… must not replace None until the custom colour picker is confirmed. +#[test] +fn indexed_matte_cancel_keeps_the_previous_choice() { + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 2, "height": 1, "depth": 32, "background": "transparent"})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let mut harness = Harness::builder().with_size(egui::vec2(900.0, 1000.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), &mut app); + PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(harness.state_mut(), "image.mode.indexedColor").unwrap(); + harness.run_steps(3); + harness.query_all_by_role(Role::ComboBox).find(|node| node.value().as_deref() == Some("None")).expect("Matte dropdown").click(); + harness.run_steps(2); + harness.get_by_label("Other…").click(); + harness.run_steps(3); + + let picker = harness.state().ui.dialogs.last().expect("custom matte picker").id; + assert_ne!(picker, id); + let parent = harness.state().ui.dialogs.iter().find(|dialog| dialog.id == id).expect("Indexed Color dialog"); + assert_eq!(parent.fields.get("matte").and_then(Value::as_str), None, "opening Other… does not commit a matte"); + assert_eq!(harness.state().ui.dialogs.last().unwrap().fields["color"], "#ffffff", "the previous default still seeds the picker"); + + crate::dialogs::cancel(harness.state_mut(), picker).unwrap(); + harness.run_steps(3); + let parent = harness.state().ui.dialogs.iter().find(|dialog| dialog.id == id).expect("Indexed Color dialog"); + assert_eq!(parent.fields.get("matte").and_then(Value::as_str), None, "cancel restores the previous None choice"); + assert!(!parent.fields.contains_key("__pickColorDefault")); +} diff --git a/crates/ui-egui/src/dialogs/filter_preview_ui.rs b/crates/ui-egui/src/dialogs/filter_preview_ui.rs index f83a260b0df..7378370984c 100644 --- a/crates/ui-egui/src/dialogs/filter_preview_ui.rs +++ b/crates/ui-egui/src/dialogs/filter_preview_ui.rs @@ -334,6 +334,7 @@ mod tests { ChannelTarget::Alpha(0), &[], &Default::default(), + Some((app.session.tools.foreground, app.session.tools.background)), ) .unwrap(); let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/dialogs/filter_preview_worker.rs b/crates/ui-egui/src/dialogs/filter_preview_worker.rs index 0950e67939c..ad02315a8c7 100644 --- a/crates/ui-egui/src/dialogs/filter_preview_worker.rs +++ b/crates/ui-egui/src/dialogs/filter_preview_worker.rs @@ -123,6 +123,7 @@ mod tests { command: "image.mode.indexedColor".into(), params: json!({"colors": colors}), k: 1, + tool_colors: ([0.0, 0.0, 0.0, 1.0], [1.0; 4]), } } diff --git a/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs b/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs index 8abbefbbd7b..dd0ebca9927 100644 --- a/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs +++ b/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs @@ -23,6 +23,16 @@ pub(super) const KEY: &str = "stops"; /// The dialog field holding the transparency stops. pub(super) const OPACITY_KEY: &str = "opacityStops"; +/// A colour stop being dragged on the page's ramp. +#[derive(Clone, Copy, Debug)] +struct StopDrag { + /// The stop's index (it changes as the stop passes others). + index: usize, + /// The stop while it is pulled off the bar: deleted from the gradient, so the ramp and the + /// canvas show it gone; dragging back onto the bar puts it back. + removed: Option<(f32, Color)>, +} + /// Where the last drawn ramp bar is, for tests that drive it: `(effect id, bar rect)`. pub(crate) fn bar_id() -> egui::Id { egui::Id::unique("fx-gradient-bar") @@ -207,39 +217,67 @@ pub(super) fn row(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map(drag_key)); + ui.data_mut(|d| d.remove::(drag_key)); } } - let dragging: Option = ui.data(|d| d.get_temp(drag_key)); - if let (Some(i), Some(p)) = (dragging, resp.interact_pointer_pos()) { - let away = p.y > outer.bottom() + 24.0 || p.y < bar.top() - 24.0; - if resp.drag_stopped() { - ui.data_mut(|d| d.remove::(drag_key)); - if away && stops.len() > 2 { - edit(app, f, effect, disp, json!({"action": "delete", "index": i})); - ui.data_mut(|d| d.insert_temp(sel_key, 0usize)); + let dragging: Option = ui.data(|d| d.get_temp(drag_key)); + if let (Some(mut drag), Some(p)) = (dragging, resp.interact_pointer_pos()) { + let off = crate::gradient_ui::off_strip(p, bar); + let loc = loc_of(p.x); + // The release frame applies the same rule (its last move may cross the edge), then ends + // the drag. + if resp.dragged() || resp.drag_stopped() { + match drag.removed { + None if off && stops.len() > 2 => { + if let Some(stop) = stops.get(drag.index).copied() + && edit(app, f, effect, disp, json!({"action": "delete", "index": drag.index})).is_some() + { + drag.removed = Some(stop); + ui.data_mut(|d| d.insert_temp(sel_key, 0usize)); + } + } + Some((_, color)) if !off => { + let mut back = stops.clone(); + back.push((loc, color)); + back.sort_by(|a, b| a.0.total_cmp(&b.0)); + let index = back.iter().rposition(|s| s.0 == loc).unwrap_or(0); + let mut fill = fill.clone(); + if let Fill::Gradient { stops, .. } = &mut fill { + *stops = back; + } + commit(f, effect, disp, &fill); + drag = StopDrag { index, removed: None }; + ui.data_mut(|d| d.insert_temp(sel_key, index)); + } + Some(_) => {} + None => { + if (loc - stops.get(drag.index).map_or(loc, |s| s.0)).abs() > 1e-4 + && let Some(at) = edit(app, f, effect, disp, json!({"action": "move", "index": drag.index, "location": loc})) + { + drag.index = at; + ui.data_mut(|d| d.insert_temp(sel_key, at)); + } + } } - } else if resp.dragged() && !away { - let loc = loc_of(p.x); - if (loc - stops.get(i).map_or(loc, |s| s.0)).abs() > 1e-4 - && let Some(at) = edit(app, f, effect, disp, json!({"action": "move", "index": i, "location": loc})) - { - ui.data_mut(|d| d.insert_temp(drag_key, at)); - ui.data_mut(|d| d.insert_temp(sel_key, at)); + if resp.drag_stopped() { + ui.data_mut(|d| d.remove::(drag_key)); + } else { + ui.data_mut(|d| d.insert_temp(drag_key, drag)); } } } else if resp.drag_stopped() { - ui.data_mut(|d| d.remove::(drag_key)); + ui.data_mut(|d| d.remove::(drag_key)); } if resp.double_clicked() && let Some(p) = resp.interact_pointer_pos() @@ -385,6 +423,72 @@ mod tests { assert_eq!(stops_in(h.state(), parent).len(), 2); } + /// #10742: on the Gradient Overlay page a colour stop dragged down off the bar (about a + /// marker's height) disappears while it is off, is gone on release, and comes back when + /// dragged back onto the bar first. + #[test] + fn a_stop_dragged_off_the_ramp_shows_gone_and_dragging_back_cancels() { + let (mut app, parent) = dialog_app(); + let effect = crate::layer_style::effects_of(&app.ui.dialogs[0].fields)[0]["id"].as_str().unwrap().to_string(); + set_param(&mut app.ui.dialog_mut(parent).unwrap().fields, &effect, KEY, json!([[0.0, "#000000"], [0.5, "#ff0000"], [1.0, "#ffffff"]])); + let mut h = harness(app, parent); + let (_, bar): (String, Rect) = h.ctx.data(|d| d.get_temp(bar_id())).expect("the ramp was drawn"); + let mid = pos2(bar.center().x, bar.bottom() + 9.0); + let away = pos2(mid.x, mid.y + 22.0); + press(&mut h, mid); + h.event(egui::Event::PointerMoved(pos2(mid.x, mid.y + 11.0))); + h.run_steps(1); + h.event(egui::Event::PointerMoved(away)); + h.run_steps(1); + assert_eq!(stops_in(h.state(), parent).len(), 2, "off the bar the stop is gone"); + h.event(egui::Event::PointerMoved(mid)); + h.run_steps(1); + let back = stops_in(h.state(), parent); + assert_eq!(back.len(), 3, "back on the bar it returns: {back:?}"); + release(&mut h, mid); + let back = stops_in(h.state(), parent); + assert!(back.len() == 3 && (back[1].0 - 0.5).abs() < 0.01 && super::super::hex(&back[1].1) == "#ff0000", "{back:?}"); + press(&mut h, mid); + h.event(egui::Event::PointerMoved(pos2(mid.x, mid.y + 11.0))); + h.run_steps(1); + h.event(egui::Event::PointerMoved(away)); + h.run_steps(1); + release(&mut h, away); + let stops = stops_in(h.state(), parent); + assert_eq!(stops.iter().map(|s| super::super::hex(&s.1)).collect::>(), ["#000000", "#ffffff"], "{stops:?}"); + } + + /// The release frame counts: a stop that leaves the strip in the frame it is released is + /// removed, and one brought back onto the strip in that frame is kept. + #[test] + fn the_release_frame_decides_whether_a_stop_comes_off_the_ramp() { + for back in [true, false] { + let (mut app, parent) = dialog_app(); + let effect = crate::layer_style::effects_of(&app.ui.dialogs[0].fields)[0]["id"].as_str().unwrap().to_string(); + set_param(&mut app.ui.dialog_mut(parent).unwrap().fields, &effect, KEY, json!([[0.0, "#000000"], [0.5, "#ff0000"], [1.0, "#ffffff"]])); + let mut h = harness(app, parent); + let (_, bar): (String, Rect) = h.ctx.data(|d| d.get_temp(bar_id())).expect("the ramp was drawn"); + let mid = pos2(bar.center().x, bar.bottom() + 9.0); + let away = pos2(mid.x, mid.y + 30.0); + press(&mut h, mid); + h.event(egui::Event::PointerMoved(pos2(mid.x, mid.y + 11.0))); + h.run_steps(1); + if back { + h.event(egui::Event::PointerMoved(away)); + h.run_steps(1); + assert_eq!(stops_in(h.state(), parent).len(), 2, "off the strip the stop is gone"); + } + let to = if back { mid } else { away }; + // The last move and the release in one frame (`Harness::event` gives each its own). + let input = &mut h.input_mut().events; + input.push(egui::Event::PointerMoved(to)); + input.push(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + let stops = stops_in(h.state(), parent); + assert_eq!(stops.len(), if back { 3 } else { 2 }, "back {back}: {stops:?}"); + } + } + /// A preset from the Gradients panel becomes the page's stops; the Delete button and the /// Location field work on the selected stop. #[test] diff --git a/crates/ui-egui/src/dialogs/new_guide_ui.rs b/crates/ui-egui/src/dialogs/new_guide_ui.rs index 794a126da41..e9a3406b86e 100644 --- a/crates/ui-egui/src/dialogs/new_guide_ui.rs +++ b/crates/ui-egui/src/dialogs/new_guide_ui.rs @@ -82,6 +82,34 @@ fn with_unit(value: &str, unit: Unit) -> String { format!("{value} {}", unit.suffix()) } +/// `typed` with `unit` after its first number when that number has none ("300 / 2" with pixel +/// rulers → "300 px / 2"); anything else as typed. Nor when a length later in its term makes it +/// a plain factor ("2 / 2 * 50 px": the term is in px already). +fn unit_on_first_number(typed: &str, unit: Unit) -> String { + let end = typed + .char_indices() + .find(|&(i, c)| !(c.is_ascii_digit() || matches!(c, '.' | ',') || (i == 0 && matches!(c, '-' | '+')))) + .map_or(typed.len(), |(i, _)| i); + let (number, rest) = typed.split_at(end); + let bare = !number.is_empty() && rest.trim_start().starts_with(['+', '-', '*', '/']) && !factor_has_unit(rest); + if bare { format!("{} {}{rest}", number, unit.suffix()) } else { typed.to_string() } +} + +/// Whether an operand multiplied or divided in at the start of `rest` (up to the first `+` or `-` +/// between operands) carries a unit, as the engine splits operands. +fn factor_has_unit(mut rest: &str) -> bool { + while let Some(operand) = rest.trim_start().strip_prefix(['*', '/']).map(str::trim_start) { + // The operand runs to the next operator; a sign at its start is its own. + let end = operand.char_indices().find(|&(i, c)| i > 0 && matches!(c, '+' | '-' | '*' | '/')).map_or(operand.len(), |(i, _)| i); + let (operand, after) = operand.split_at(end); + if Unit::parse_length(operand).is_some_and(|(_, u)| u.is_some()) { + return true; + } + rest = after; + } + false +} + /// `px` (document pixels) in the ruler unit, as the field shows it: "14.11 cm". Percent is of the /// active document's width for a vertical guide and its height for a horizontal one. fn in_ruler_unit(app: &PhotocraftApp, px: f64, vertical: bool) -> String { @@ -212,10 +240,14 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { /// the engine to read (or refuse, keeping the dialog open). Custom… sends its "#rrggbb". pub fn params(app: &PhotocraftApp, f: &Map) -> Value { let orientation = f.get("orientation").and_then(Value::as_str).unwrap_or("horizontal"); - let typed = f.get("position").and_then(Value::as_str).unwrap_or_default().trim(); - let position = match Unit::parse_length(typed) { - Some((_, None)) => with_unit(typed, ruler_unit(app)), - _ => typed.to_string(), + // A Unicode minus (U+2212) is a minus, as the engine reads it. + let typed = f.get("position").and_then(Value::as_str).unwrap_or_default().trim().replace('−', "-"); + let position = match Unit::parse_length(&typed) { + Some((_, None)) => with_unit(&typed, ruler_unit(app)), + Some(_) => typed, + // Arithmetic (#10440): a first number without a unit is in the ruler unit, as in + // Photoshop ("300 / 2" is 150 px with pixel rulers); the engine evaluates the rest. + None => unit_on_first_number(&typed, ruler_unit(app)), }; let color = f.get("color").and_then(Value::as_str).filter(|c| is_color_name(c)).unwrap_or(DEFAULT_COLOR); let color = if color == CUSTOM { custom_color(f) } else { color.to_string() }; @@ -301,3 +333,29 @@ pub fn edit_preview<'a>(app: &'a PhotocraftApp, doc: &photocraft_doc::Document) #[cfg(test)] mod tests; + +#[cfg(test)] +mod arithmetic_tests { + use super::{Unit, unit_on_first_number}; + + /// #10440: a first number without a unit is in the ruler unit, the rest goes to the engine. + #[test] + fn a_leading_bare_number_gets_the_ruler_unit() { + assert_eq!(unit_on_first_number("300 / 2", Unit::Pixels), "300 px / 2"); + assert_eq!(unit_on_first_number("3+1in", Unit::Centimeters), "3 cm+1in"); + assert_eq!(unit_on_first_number("100% / 3", Unit::Pixels), "100% / 3"); + assert_eq!(unit_on_first_number("abc", Unit::Pixels), "abc"); + } + + /// A bare number multiplied or divided within a term that has a length is a plain factor: + /// the length is the term's unit, so the ruler unit isn't added (it would make a length + /// times a length). A term of bare numbers still gets it. + #[test] + fn a_leading_factor_of_a_length_gets_no_ruler_unit() { + for typed in ["2 * 50 px", "2 / 2 * 50 px", "2*50px+10", "4 / 2 * 1in - 3", "2 * -1cm"] { + assert_eq!(unit_on_first_number(typed, Unit::Centimeters), typed); + } + assert_eq!(unit_on_first_number("2 * 3 + 1in", Unit::Centimeters), "2 cm * 3 + 1in"); + assert_eq!(unit_on_first_number("6 / 2 / 3 - 1", Unit::Centimeters), "6 cm / 2 / 3 - 1"); + } +} diff --git a/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs b/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs index c0c732f0f77..5571641527d 100644 --- a/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/new_guide_ui/tests.rs @@ -435,3 +435,29 @@ fn refused_edit_shows_the_error_and_typed_fields_in_the_dialog() { assert!(h.query_by_label("guide edits are denied").is_none()); assert_eq!(guides(h.state()).1, vec![10.0, 20.0]); } + +/// #10440: a leading bare number gets the ruler unit after a Unicode minus (U+2212) as after "-", +/// so "5.08 − 2.54" with centimetre rulers is 2.54 cm, not 2.54 px. +#[test] +fn the_ruler_unit_goes_on_a_bare_number_before_a_unicode_minus() { + let (mut app, ctx) = app("cm"); + let d = open(&mut app, &ctx); + set(&mut app, d, "vertical", "5.08 − 2.54"); + crate::dialogs::confirm(&mut app, d).unwrap(); + let (_, vertical) = guides(&app); + assert!(vertical.len() == 1 && (vertical[0] - 100.0).abs() < 1e-6, "{vertical:?}"); +} + +/// #10440: a leading bare number that multiplies or divides a length is a plain factor, so it +/// doesn't take the ruler unit (CodeRabbit: "2 / 2 * 50 px" became "2 cm / 2 * 50 px"). +#[test] +fn a_leading_factor_of_a_length_is_a_plain_number() { + let (mut app, ctx) = app("cm"); + for (typed, px) in [("2 * 50 px", 100.0), ("2 / 2 * 1in", 100.0), ("2 * 2.54 - 2.54", 100.0)] { + let d = open(&mut app, &ctx); + set(&mut app, d, "vertical", typed); + crate::dialogs::confirm(&mut app, d).unwrap_or_else(|e| panic!("{typed}: {e}")); + let landed = *guides(&app).1.last().unwrap(); + assert!((landed - px).abs() < 1e-6, "{typed}: {landed}"); + } +} diff --git a/crates/ui-egui/src/dialogs/open_prompt.rs b/crates/ui-egui/src/dialogs/open_prompt.rs index 78691112c09..a34eae8ecda 100644 --- a/crates/ui-egui/src/dialogs/open_prompt.rs +++ b/crates/ui-egui/src/dialogs/open_prompt.rs @@ -43,6 +43,9 @@ pub struct Held { /// The Color Settings policy was already applied (a background open added it before it had /// to wait); otherwise opening applies it. pub policy_applied: bool, + /// Held by Open as Smart Object: the document holds the smart object layer already, and is + /// admitted by [`photocraft_engine::file_cmds::admit_as_smart_object`]. + pub smart_object: bool, } /// Dialog field for the high-memory confirmation prompt. @@ -323,7 +326,7 @@ fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result) -> Result "This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type?", Kind::Shape => "This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape?", - Kind::SmartObject => "This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object?", + // As measured in the reference application (#11899): lower-case "smart object". + Kind::SmartObject => { + "This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object?" + } Kind::Fill => "This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer?", } } @@ -249,3 +252,7 @@ pub fn run_pending(app: &mut PhotocraftApp, ctx: &egui::Context) { #[cfg(test)] mod tests; + +#[cfg(test)] +#[path = "../rasterize_prompt_text_tests.rs"] +mod rasterize_prompt_text_tests; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index f6ef5a1e46f..c8d14130ac0 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -17,6 +17,7 @@ fn filter_dialog_with_preview(background: bool) -> (PhotocraftApp, u64) { command: "filter.blur.gaussianBlur".into(), params: serde_json::json!({"radius": 4.0}), k: 1, + tool_colors: (app.session.tools.foreground, app.session.tools.background), }; app.filter_preview = Some(crate::filter_dialog::FilterPreview { key, result: result.map(std::sync::Arc::new), committing: false }); (app, id) diff --git a/crates/ui-egui/src/eraser_block_right_click_tests.rs b/crates/ui-egui/src/eraser_block_right_click_tests.rs new file mode 100644 index 00000000000..73be8c87508 --- /dev/null +++ b/crates/ui-egui/src/eraser_block_right_click_tests.rs @@ -0,0 +1,123 @@ +//! #11937: with the Eraser in Block mode a plain right-click on the canvas opens the Brush · Pencil +//! · Block menu at the pointer instead of the Brush Preset picker; picking an entry sets the mode, +//! adds no history and erases nothing. Brush and Pencil mode keep the picker; Shift+right-click +//! opens the menu in every mode; with the Erase preference a right-click still erases. + +use egui_kittest::Harness; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::control::{ControlRequest, Outcome, handle}; +use crate::state::Tool; + +fn app(mode: &str) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#dc1e1e"})).unwrap(); + app.ui.tool = Tool::Eraser; + app.ui.tool_options.eraser_mode = mode.into(); + app.sync_views(); + app +} + +fn call(app: &mut PhotocraftApp, ctx: &egui::Context, method: &str, params: Value) -> Value { + let (req, _rx) = ControlRequest::new(method, params); + match handle(app, ctx, &req) { + Outcome::Done(v) => v, + _ => panic!("{method}: expected an immediate reply"), + } +} + +fn right_click(app: &mut PhotocraftApp, ctx: &egui::Context, modifiers: Value) { + call( + app, + ctx, + "ui.pointer", + json!({"button": "right", "modifiers": modifiers, "events": [{"kind": "down", "x": 20, "y": 20}, {"kind": "up", "x": 20, "y": 20}]}), + ); +} + +fn steps(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +#[test] +fn block_mode_right_click_opens_the_mode_menu() { + let ctx = egui::Context::default(); + let mut app = app("block"); + let n = steps(&app); + right_click(&mut app, &ctx, json!({})); + assert!(app.ui.brush_mode_menu.is_some(), "the Brush · Pencil · Block menu"); + assert!(app.ui.brush_picker.is_none(), "not the preset picker"); + let inspect = call(&mut app, &ctx, "ui.inspect", json!({})); + assert_eq!(inspect["result"]["brushModeMenu"]["choices"], json!(["Brush", "Pencil", "Block"]), "{inspect}"); + // Picking Pencil sets the mode; nothing is erased and no history is added. + assert_eq!(call(&mut app, &ctx, "ui.context.choose", json!({"id": "Pencil"}))["ok"], true); + assert_eq!(app.ui.tool_options.eraser_mode, "pencil"); + assert_eq!(steps(&app), n); + assert!(app.ui.brush_mode_menu.is_none()); +} + +#[test] +fn brush_and_pencil_mode_keep_the_picker() { + let ctx = egui::Context::default(); + for mode in ["brush", "pencil"] { + let mut app = app(mode); + right_click(&mut app, &ctx, json!({})); + assert!(app.ui.brush_picker.is_some(), "{mode}: the Brush Preset picker"); + assert!(app.ui.brush_mode_menu.is_none(), "{mode}"); + } +} + +#[test] +fn shift_right_click_opens_the_menu_in_every_mode() { + let ctx = egui::Context::default(); + for mode in ["brush", "pencil", "block"] { + let mut app = app(mode); + right_click(&mut app, &ctx, json!({"shift": true})); + assert!(app.ui.brush_mode_menu.is_some(), "{mode}"); + } +} + +#[test] +fn with_the_erase_preference_block_mode_still_erases() { + let ctx = egui::Context::default(); + let mut app = app("block"); + app.run("prefs.set", json!({"path": "tools.rightClickWithPaintingTools", "value": "erase"})).unwrap(); + call(&mut app, &ctx, "ui.pointer", json!({"button": "right", "events": [{"kind": "down", "x": 20, "y": 20}]})); + assert!(app.ui.brush_mode_menu.is_none(), "the preference's erase wins"); + assert!(app.secondary_erase || app.drag.is_some(), "a right-erase started"); +} + +/// The real mouse: a right-click on the canvas in Block mode opens the menu; Escape closes it and +/// nothing is erased. +#[test] +fn a_real_right_click_opens_the_menu_and_escape_closes_it() { + let mut h = Harness::builder().with_size(egui::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("block"), + ); + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let n = steps(h.state()); + let c = h.state().last_canvas_rect.center(); + h.event(egui::Event::PointerMoved(c)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + } + assert!(h.state().ui.brush_mode_menu.is_some(), "the menu at the pointer"); + assert!(h.state().ui.brush_picker.is_none()); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.brush_mode_menu.is_none(), "Escape closes it"); + assert_eq!(steps(h.state()), n, "nothing erased"); +} diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index bddc6796d3d..cfec9c0f05e 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -148,17 +148,37 @@ impl PhotocraftApp { /// (#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 { + self.pick_save_in(suggested, false, then) + } + + /// [`Self::pick_save`] for the export flows (Export As, Quick Export, Save for Web, ...): starts in + /// the folder [`export_start_dir`] picks (#10192), and once `then` has exported, remembers the + /// chosen file's folder in `export.lastFolder`; a failed or cancelled export leaves it alone. + pub(crate) fn pick_export(&mut self, suggested: &str, then: impl FnOnce(&mut Self, String) -> Result + 'static) -> Result { + self.pick_save_in(suggested, true, then) + } + + fn pick_save_in( + &mut self, + suggested: &str, + export: bool, + then: impl FnOnce(&mut Self, String) -> Result + 'static, + ) -> Result { let mut suggested = std::path::PathBuf::from(suggested); 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) = doc + if suggested.parent().is_some_and(|p| p.as_os_str().is_empty()) { + let document_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); + // An export doesn't start in a folder that is gone. + .filter(|dir| !export || folder_exists(dir)) + .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)); + let remembered = export.then(|| self.session.prefs().export.last_folder.clone()); + if let Some(dir) = export_start_dir(remembered.as_deref(), document_dir, folder_exists) { + suggested = dir.join(suggested); + } } // The format comes from the extension, so a name typed without one (common where file // extensions are hidden, #5039) gets the suggested file's extension, as Photoshop's @@ -169,12 +189,20 @@ impl PhotocraftApp { FileDialogAnswer::SaveTo(path) => { let path = saved_path(app, &path, suggested_ext.as_deref()); // 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()); + let dir = doc.zip(Path::new(&path).parent().filter(|p| !p.as_os_str().is_empty()).map(Path::to_path_buf)); + // An export's folder only once it has exported: a failed one doesn't move where + // the next export starts. + if !export && let Some((doc, dir)) = dir.clone() { + app.save_dirs.insert(doc, dir); } - then(app, path) + let exported = then(app, path.clone())?; + if export { + if let Some((doc, dir)) = dir { + app.save_dirs.insert(doc, dir); + } + app.remember_export_folder(&path); + } + Ok(exported) } _ => Err(UNEXPECTED.into()), })?; @@ -184,6 +212,16 @@ impl PhotocraftApp { Ok(asked) } + /// Remember the folder of an export that succeeded as where the next export dialog starts. + /// The web answers a download name, which has no folder, and leaves it alone. + fn remember_export_folder(&mut self, path: &str) { + let Some(dir) = Path::new(path).parent().filter(|p| !p.as_os_str().is_empty()) else { return }; + let dir = dir.to_string_lossy().into_owned(); + if self.session.prefs().export.last_folder != dir { + self.session.prefs.edit(|p| p.export.last_folder = dir); + } + } + /// Whether a save to `path` replaces a file the save dialog didn't ask about: the dialog /// answered `answered`, and [`saved_path`] changed it. A file at `path` that /// [`Services::same_file`](crate::Services::same_file) finds is the same entry as `answered` (the @@ -257,6 +295,30 @@ impl PhotocraftApp { }) } + /// File › Open as Smart Object…: the File › Open dialog (same filters, several files); each + /// chosen file opens as its own document holding one embedded smart object, in name order, + /// reporting each failure as File › Open does. + pub fn open_dialog_as_smart_object(&mut self) -> Result { + let initial_dir = last_used_dir(&self.ui.recent_files); + self.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: None }, |app, answer| { + let FileDialogAnswer::Paths(paths) = answer else { return Err(UNEXPECTED.into()) }; + // Each file is a tracked open job like File › Open's, in name order. No `show_if_open`: + // the file opens as another document even when it is already open. + let mut paths: Vec = paths.iter().map(|p| crate::file_open::absolute(p)).collect(); + paths.sort_by(|a, b| crate::file_open::natural_cmp(a, b)); + let batch = crate::jobs_ui::begin_batch(app); + for (seq, path) in paths.iter().enumerate() { + let order = crate::jobs_ui::OpenOrder { batch, seq }; + if let Err(e) = crate::jobs_ui::start_open_as_smart_object(app, path, Some(order)) { + app.open_failed(&crate::file_open::display_name(path), &e); + } + } + crate::jobs_ui::end_batch(app); + app.sync_views(); + Ok(Value::Null) + }) + } + /// Opens each picked file's contents as its own document, in name order (like /// [`Self::open_paths`]), reporting each failure. fn open_contents(&mut self, mut files: Vec<(String, Vec)>) { @@ -370,6 +432,28 @@ fn saved_path(app: &PhotocraftApp, answered: &str, suggested_ext: Option<&str>) } } +/// Whether `dir` is a folder on disk. The web has no file system, so nothing is remembered there. +#[cfg(not(target_arch = "wasm32"))] +fn folder_exists(dir: &Path) -> bool { + dir.is_dir() +} + +#[cfg(target_arch = "wasm32")] +fn folder_exists(_dir: &Path) -> bool { + false +} + +/// The folder an export dialog starts in (#10192): the `remembered` folder of the last export +/// while `exists` finds it, else the document's own folder (`document_dir`, whatever it is), +/// else none, which leaves the choice to the system dialog. +pub(crate) fn export_start_dir( + remembered: Option<&str>, + document_dir: Option, + exists: impl Fn(&Path) -> bool, +) -> Option { + remembered.filter(|d| !d.is_empty()).map(std::path::PathBuf::from).filter(|d| exists(d)).or(document_dir) +} + /// The folder an open dialog starts in: the directory of the most recent file (UI-217-3). pub(crate) fn last_used_dir(recent: &[String]) -> Option { let dir = std::path::Path::new(recent.first()?).parent()?; diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index abebcac5f41..53ce32d2a22 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -1517,3 +1517,101 @@ fn import_variable_data_sets_menu_asks_for_a_csv_and_imports_it() { app.poll_file_dialog(&ctx, None); assert_eq!(app.session.active().map(|d| d.doc.variables.data_sets.len()), Some(2)); } + +/// #10192: the folder an export dialog starts in, as a pure decision. +#[test] +fn export_start_dir_prefers_an_existing_remembered_folder() { + use std::path::PathBuf; + let doc = Some(PathBuf::from("/proj")); + let all_exist = |_: &Path| true; + let none_exist = |_: &Path| false; + // Remembered and present: used. + assert_eq!(export_start_dir(Some("/renders"), doc.clone(), all_exist), Some(PathBuf::from("/renders"))); + // Remembered but gone: the document's folder. + assert_eq!(export_start_dir(Some("/renders"), doc.clone(), none_exist), doc); + // Nothing remembered (unset or empty): the document's folder, or nothing for an untitled one. + assert_eq!(export_start_dir(None, doc.clone(), all_exist), doc); + assert_eq!(export_start_dir(Some(""), doc.clone(), all_exist), doc); + assert_eq!(export_start_dir(None, None, all_exist), None); + assert_eq!(export_start_dir(Some("/renders"), None, none_exist), None); +} + +/// #10192: exports start in the last successfully exported folder, across documents; a failed or +/// cancelled export, a folder that is gone and other save dialogs change nothing. +#[test] +fn exports_start_in_the_last_exported_folder() { + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + let root = tempfile::tempdir().unwrap(); + let renders = root.path().join("renders"); + std::fs::create_dir(&renders).unwrap(); + let renders_text = renders.to_string_lossy().into_owned(); + 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:?}"), + }; + let remembered = |app: &PhotocraftApp| app.session.prefs().export.last_folder.clone(); + // No export yet: beside the document. Cancelled, nothing is remembered. + app.pick_export("a.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), joined("/proj", "a.png")); + answer(&open, None); + app.poll_file_dialog(&ctx, None); + assert_eq!(remembered(&app), ""); + // The user picks another folder, but the export fails: nothing is remembered. + app.pick_export("a.png", |_, _| Err("disk full".into())).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::SaveTo(joined(&renders_text, "a.png")))); + app.poll_file_dialog(&ctx, None); + assert_eq!(remembered(&app), ""); + // ...and the next export still starts beside the document, not in the failed export's folder. + app.pick_export("a.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), joined("/proj", "a.png")); + answer(&open, None); + app.poll_file_dialog(&ctx, None); + // A successful export is remembered... + app.pick_export("a.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::SaveTo(joined(&renders_text, "a.png")))); + app.poll_file_dialog(&ctx, None); + assert_eq!(remembered(&app), renders_text); + // ...and starts the next export, of any document, even an untitled one. + app.run("file.new", json!({"width": 4, "height": 4})).unwrap(); + app.pick_export("b.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), joined(&renders_text, "b.png")); + // A cancelled dialog changes nothing. + answer(&open, None); + app.poll_file_dialog(&ctx, None); + assert_eq!(remembered(&app), renders_text); + // Other save dialogs keep their own start. + app.pick_save("c.psd", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), "c.psd"); + answer(&open, None); + app.poll_file_dialog(&ctx, None); + // The folder disappears: back to the document's folder. + std::fs::remove_dir(&renders).unwrap(); + app.session.set_active(0); + app.pick_export("d.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), joined("/proj", "d.png")); + answer(&open, None); + app.poll_file_dialog(&ctx, None); +} + +#[test] +fn the_last_export_folder_is_a_preference_that_old_files_lack() { + let prefs = photocraft_engine::prefs::Preferences::default(); + assert_eq!(prefs.export.last_folder, ""); + let mut json = serde_json::to_value(&prefs).unwrap(); + json["export"]["lastFolder"] = "/renders".into(); + let loaded: photocraft_engine::prefs::Preferences = serde_json::from_value(json.clone()).unwrap(); + assert_eq!(loaded.export.last_folder, "/renders"); + // Settings saved before the field existed still load. + json["export"].as_object_mut().unwrap().remove("lastFolder"); + let old: photocraft_engine::prefs::Preferences = serde_json::from_value(json).unwrap(); + assert_eq!(old.export.last_folder, ""); +} diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 865f65d7efe..3932df61b2d 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -334,16 +334,22 @@ impl PhotocraftApp { } } + /// The pointer position, from the OS cursor service when available, falling back to egui's + /// last-known pointer position (e.g. on Wayland). + pub(crate) fn cursor_pos(&mut self, ctx: &egui::Context) -> Option { + self.services.cursor_pos.as_mut().and_then(|f| f(ctx)).or_else(|| ctx.input(|i| i.pointer.latest_pos())) + } + /// Which of the files called `names`, dropped onto the window just now, are placed on the /// active document's canvas rather than opened, for a shell that reads dropped files itself /// (the browser reads them asynchronously, so the web shell can't hand them to /// [`open_dropped`](Self::open_dropped)). It delivers those through /// [`Services::place_inbox`](crate::Services::place_inbox) and the rest through /// [`Services::inbox`](crate::Services::inbox). The pointer comes from - /// [`Services::cursor_pos`](crate::Services::cursor_pos). As with a desktop canvas drop, a Free + /// [`cursor_pos`](Self::cursor_pos). As with a desktop canvas drop, a Free /// Transform still open is applied first (#4833). pub fn dropped_on_canvas(&mut self, ctx: &egui::Context, names: &[String]) -> Vec { - let at = self.services.cursor_pos.as_mut().and_then(|f| f(ctx)); + let at = self.cursor_pos(ctx); let canvas = self.drop_target(ctx, at) == DropTarget::Canvas; let places: Vec = names.iter().map(|n| canvas && !crate::preset_files_ui::is_preset_file(n)).collect(); if places.contains(&true) { @@ -594,12 +600,12 @@ fn digit_run(it: &mut std::iter::Peekable) -> String { } #[cfg(not(target_arch = "wasm32"))] -fn absolute(p: &str) -> String { +pub(crate) fn absolute(p: &str) -> String { std::path::absolute(p).map(|a| a.to_string_lossy().to_string()).unwrap_or_else(|_| p.to_string()) } #[cfg(target_arch = "wasm32")] -fn absolute(p: &str) -> String { +pub(crate) fn absolute(p: &str) -> String { p.to_string() } diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index a617332bd10..38e07dfa85e 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -1694,3 +1694,43 @@ fn queued_drop_waits_for_an_external_document_job() { assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); assert!(app.drop_places.is_empty()); } + +/// File › Open as Smart Object… (#11678) asks with File › Open's dialog and opens each chosen +/// file as its own untitled document holding one embedded smart object. +#[test] +fn open_as_smart_object_dialog_opens_one_document_per_selected_file() { + let (dir, paths) = files("open-as-so", &["one.png", "two.png", "bad.png"], &png_bytes(8, 6)); + std::fs::write(&paths[2], b"not an image").unwrap(); + let (mut app, _) = app_with(vec![Some(FileDialogAnswer::Paths(paths.clone()))]); + + menus::invoke(&mut app, &egui::Context::default(), "file.openAsSmartObject", json!({})).unwrap(); + assert!(app.file_dialog_open(), "the menu item asks for files first"); + answer(&mut app); + + assert_eq!(app.session.documents().iter().map(|d| d.doc.name.as_str()).collect::>(), ["one-1", "two-1"]); + assert!(app.session.documents().iter().all(|d| d.path.is_none() && d.doc.layers.len() == 1)); + assert!(app.ui.notices.iter().any(|n| n.error && n.title.starts_with("Couldn't open bad.png")), "the unreadable file is reported"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// File > Open as Smart Object... runs each file as a tracked background job like File > Open +/// does: a tab per file, the documents landing in name order, untitled, failures reported. +#[test] +fn open_as_smart_object_opens_in_the_background_in_name_order() { + let ctx = egui::Context::default(); + let (dir, paths) = files("open-as-so-bg", &["two.png", "one.png", "bad.png"], &png_bytes(8, 6)); + std::fs::write(&paths[2], b"not an image").unwrap(); + let (mut app, _) = app_with(vec![Some(FileDialogAnswer::Paths(paths.clone()))]); + app.background_jobs = true; + + menus::invoke(&mut app, &ctx, "file.openAsSmartObject", json!({})).unwrap(); + answer(&mut app); + assert_eq!(app.jobs.opens.len(), 3, "a tab for each file while it opens"); + assert!(app.session.documents().is_empty(), "nothing is on the UI thread's critical path"); + + tick_until(&mut app, &ctx, |app| app.jobs.opens.is_empty()); + assert_eq!(app.session.documents().iter().map(|d| d.doc.name.as_str()).collect::>(), ["one-1", "two-1"]); + assert!(app.session.documents().iter().all(|d| d.path.is_none() && d.doc.layers.len() == 1)); + assert!(app.ui.notices.iter().any(|n| n.error && n.title.starts_with("Couldn't open bad.png")), "the unreadable file is reported"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/hidden_layer_tests.rs b/crates/ui-egui/src/hidden_layer_tests.rs index 806aadd8a21..ce69c219433 100644 --- a/crates/ui-egui/src/hidden_layer_tests.rs +++ b/crates/ui-egui/src/hidden_layer_tests.rs @@ -72,7 +72,13 @@ fn painting_a_hidden_layer_is_refused_at_the_press() { let n = steps(&app); tool_event(&mut app, ToolEvent::Down { x: 30.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); assert!(app.drag.is_none() && app.live_stroke.is_none(), "{tool:?}: no stroke started"); - assert!(app.ui.status.contains("target layer is hidden"), "{tool:?}: {}", app.ui.status); + // The Brush says so in its alert (#11898), the other tools in the status bar. + let alert = app.ui.dialogs.iter().any(|d| d.fields.get("message").and_then(|m| m.as_str()).is_some_and(|m| m.contains("target layer is hidden"))); + if tool == Tool::Brush { + assert!(alert, "{tool:?}: {:?}", app.ui.dialogs); + } else { + assert!(app.ui.status.contains("target layer is hidden") && app.ui.dialogs.is_empty(), "{tool:?}: {}", app.ui.status); + } tool_event(&mut app, ToolEvent::Up { x: 40.0, y: 40.0 }, egui::Modifiers::NONE); assert_eq!((bounds(&app, id), steps(&app)), (Rect::new(8, 8, 24, 24), n), "{tool:?}"); // Shown again, the same press paints. diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 1cfced87ac3..b6482560e15 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -325,8 +325,8 @@ Artboard… لوح التصميم… Artboard from Group… لوح تصميم من مجموعة… Artboard from Layers… لوح تصميم من طبقات… - Layer via Copy طبقة عبر النسخ - Layer via Cut طبقة عبر القص + Layer Via Copy طبقة عبر النسخ + Layer Via Cut طبقة عبر القص Layer From Background… طبقة من الخلفية… Layer from Background… طبقة من الخلفية… Background from Layer خلفية من طبقة @@ -3601,6 +3601,7 @@ curves Smooth تنعيم Pick a different document for each channel. اختر مستندًا مختلفًا لكل قناة. A picked document is no longer open. أحد المستندات المختارة لم يعد مفتوحًا. A picked document is no longer a grayscale document of the same size. أحد المستندات المختارة لم يعد مستندًا بتدرج الرمادي بالحجم نفسه. + Netscape Gray رمادي Netscape # Move tool options bar (#11381) Align top edges محاذاة الحواف العلوية Align vertical centers محاذاة المراكز الرأسية diff --git a/crates/ui-egui/src/i18n/az.tsv b/crates/ui-egui/src/i18n/az.tsv index c228ba73d0a..7b8822c982e 100644 --- a/crates/ui-egui/src/i18n/az.tsv +++ b/crates/ui-egui/src/i18n/az.tsv @@ -148,8 +148,8 @@ Duotone… İki ton… Duotone Duoton Trap… Rəng örtüşməsi… - Layer via Copy Kopya vasitəsilə qat - Layer via Cut Kəsərək qat yarat + Layer Via Copy Kopya vasitəsilə qat + Layer Via Cut Kəsərək qat yarat Duplicate Layer… Dublikat qat… Duplicate… Dublikat yarat… Delete Sil @@ -4262,7 +4262,7 @@ curves Smooth Hamarlama The file could not be saved. Faylı saxlamaq mümkün olmadı. Thickness Qalınlıq This Layer: Bu qat: - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Bu Ağıllı Obyekt davam etməzdən əvvəl rasterləşdirilməlidir. Onun məzmunu artıq redaktə edilə bilməyəcək. Ağıllı Obyekt rasterləşdirilsin? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Bu Ağıllı Obyekt davam etməzdən əvvəl rasterləşdirilməlidir. Onun məzmunu artıq redaktə edilə bilməyəcək. Ağıllı Obyekt rasterləşdirilsin? This adjustment ({key}) is kept as imported and can't be edited yet. Bu tənzimləmə ({key}) idxal edilmiş kimi saxlanılır və hələ redaktə edilə bilməz. This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Davam etməzdən əvvəl bu doldurma qatsi rasterləşdirilməlidir. Onun doldurma məzmunu artıq redaktə edilə bilməyəcək. Qat rasterləşdirilsin? This layer must be rasterized before proceeding. Davam etməzdən əvvəl bu qat rasterləşdirilməlidir. diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 3a9cdb81371..27c056d5e39 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -297,8 +297,8 @@ Artboard… Radna ploča… Artboard from Group… Radna ploča iz grupe… Artboard from Layers… Radna ploča iz slojeva… - Layer via Copy Sloj kopiranjem - Layer via Cut Sloj izrezivanjem + Layer Via Copy Sloj kopiranjem + Layer Via Cut Sloj izrezivanjem Layer From Background… Sloj iz pozadine… Layer from Background… Sloj iz pozadine… Background From Layer Pozadina iz sloja @@ -4002,7 +4002,7 @@ layerLabel No Color Bez boje Scroll menu down Pomjeri meni dolje This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Ovaj tekstualni sloj mora se rasterizirati prije nastavka. Njegov tekst više neće biti moguće uređivati. Rasterizirati tekst? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Ovaj sloj oblika mora se rasterizirati prije nastavka. Više se neće moći uređivati kao oblik. Rasterizirati oblik? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Ovaj pametni objekat mora se rasterizirati prije nastavka. Njegov sadržaj više neće biti moguće uređivati. Rasterizirati pametni objekat? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Ovaj pametni objekat mora se rasterizirati prije nastavka. Njegov sadržaj više neće biti moguće uređivati. Rasterizirati pametni objekat? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Ovaj sloj ispune mora se rasterizirati prije nastavka. Njegov sadržaj ispune više neće biti moguće uređivati. Rasterizirati sloj? This layer must be rasterized before proceeding. Ovaj sloj mora se rasterizirati prije nastavka. Color Picker (Background Color) Birač boja (boja pozadine) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 1cbcba228d6..66e47bb783d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -448,8 +448,8 @@ Artboard… Kreslicí plocha… Artboard from Group… Kreslicí plocha ze skupiny… Artboard from Layers… Kreslicí plocha z vrstev… - Layer via Copy Vrstva kopírováním - Layer via Cut Vrstva vyjmutím + Layer Via Copy Vrstva kopírováním + Layer Via Cut Vrstva vyjmutím Layer From Background… Vrstva z pozadí… Layer from Background… Vrstva z pozadí… Background from Layer Pozadí z vrstvy @@ -3807,7 +3807,7 @@ curves Smooth Vyhladit Scroll menu down Posunout nabídku dolů This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Před pokračováním je nutné tuto textovou vrstvu rastrovat. Její text už nebude možné upravovat. Rastrovat text? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Před pokračováním je nutné tuto vrstvu tvaru rastrovat. Už ji nebude možné upravovat jako tvar. Rastrovat tvar? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Před pokračováním je nutné tento chytrý objekt rastrovat. Jeho obsah už nebude možné upravovat. Rastrovat chytrý objekt? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Před pokračováním je nutné tento chytrý objekt rastrovat. Jeho obsah už nebude možné upravovat. Rastrovat chytrý objekt? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Před pokračováním je nutné tuto vrstvu výplně rastrovat. Její výplň už nebude možné upravovat. Rastrovat vrstvu? This layer must be rasterized before proceeding. Před pokračováním je nutné tuto vrstvu rastrovat. Color Picker (Background Color) Výběr barvy (barva pozadí) @@ -4347,6 +4347,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Pro každý kanál vyberte jiný dokument. A picked document is no longer open. Jeden z vybraných dokumentů už není otevřený. A picked document is no longer a grayscale document of the same size. Jeden z vybraných dokumentů už není dokument ve stupních šedi stejné velikosti. + Netscape Gray Šedá Netscape # Move tool options bar (#11381) Align top edges Zarovnat horní okraje Align vertical centers Zarovnat svislé středy @@ -4356,6 +4357,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Zobrazit hranice vrstvy při najetí Expand layer group on click Rozbalit skupinu vrstev kliknutím Show hover bounds from Layers Zobrazit hranice při najetí z panelu Vrstvy + Saved Camera Raw settings to {file} Nastavení Camera Raw uložena do {file} + Save the settings to each raw's sidecar without opening it Uložit nastavení do souboru sidecar každého snímku raw bez jeho otevření + No settings saved: these raws can't keep a sidecar here Nic neuloženo: tyto snímky raw zde nemohou mít soubor sidecar + Some settings could not be saved; the raws stay open Některá nastavení nelze uložit; snímky raw zůstávají otevřené + Nothing to save: these raws are at their default settings Není co ukládat: tyto snímky raw mají výchozí nastavení # Artboard tool options bar (#11385) Distribute Widths Rozmístit šířky Distribute Heights Rozmístit výšky diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index cf9105b8654..16eeb67b88b 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -185,8 +185,8 @@ Trap… Trap… # Layer - Layer via Copy Lag via kopiering - Layer via Cut Lag via udklipning + Layer Via Copy Lag via kopiering + Layer Via Cut Lag via udklipning Duplicate Layer… Duplikér lag… Duplicate… Duplikér… Delete Slet @@ -4493,7 +4493,7 @@ curves Smooth Udjævn The file could not be saved. Filen kunne ikke gemmes. Thickness Tykkelse This Layer: Dette lag: - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Dette smartobjekt skal rasteriseres, før du fortsætter. Dets indhold kan ikke længere redigeres. Vil du rasterisere smartobjektet? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Dette smartobjekt skal rasteriseres, før du fortsætter. Dets indhold kan ikke længere redigeres. Vil du rasterisere smartobjektet? This adjustment ({key}) is kept as imported and can't be edited yet. Denne justering ({key}) bevares som importeret og kan ikke redigeres endnu. This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Dette udfyldningslag skal rasteriseres, før du fortsætter. Dets udfyldningsindhold kan ikke længere redigeres. Vil du rasterisere laget? This layer must be rasterized before proceeding. Dette lag skal rasteriseres, før du fortsætter. @@ -4813,6 +4813,7 @@ cameraRaw White Balance: As Shot Hvidbalance: Som fotograferet Pick a different document for each channel. Vælg et andet dokument til hver kanal. A picked document is no longer open. Et af de valgte dokumenter er ikke længere åbent. A picked document is no longer a grayscale document of the same size. Et af de valgte dokumenter er ikke længere et dokument i gråtoner i samme størrelse. + Netscape Gray Netscape-grå # Move tool options bar (#11381) Align top edges Juster øverste kanter Align vertical centers Juster lodrette midter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c247546ed33..814227eb5a7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -419,8 +419,8 @@ Artboard… Zeichenbereich… Artboard from Group… Zeichenbereich aus Gruppe… Artboard from Layers… Zeichenbereich aus Ebenen… - Layer via Copy Ebene durch Kopieren - Layer via Cut Ebene durch Ausschneiden + Layer Via Copy Ebene durch Kopieren + Layer Via Cut Ebene durch Ausschneiden Layer From Background… Ebene aus Hintergrund… Layer from Background… Ebene aus Hintergrund… Background from Layer Hintergrund aus Ebene @@ -3642,6 +3642,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Wählen Sie für jeden Kanal ein anderes Dokument. A picked document is no longer open. Ein ausgewähltes Dokument ist nicht mehr geöffnet. A picked document is no longer a grayscale document of the same size. Ein ausgewähltes Dokument ist kein Graustufen-Dokument derselben Größe mehr. + Netscape Gray Netscape-Grau # Move tool options bar (#11381) Align top edges Obere Kanten ausrichten Align vertical centers Vertikale Mitten ausrichten @@ -3651,6 +3652,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Ebenenbegrenzung beim Darüberfahren anzeigen Expand layer group on click Ebenengruppe per Klick erweitern Show hover bounds from Layers Begrenzung beim Darüberfahren aus Ebenen anzeigen + Saved Camera Raw settings to {file} Camera-Raw-Einstellungen in {file} gespeichert + Save the settings to each raw's sidecar without opening it Die Einstellungen in der Filialdatei jeder Raw-Datei speichern, ohne sie zu öffnen + No settings saved: these raws can't keep a sidecar here Nichts gespeichert: Diese Raw-Dateien können hier keine Filialdatei haben + Some settings could not be saved; the raws stay open Einige Einstellungen konnten nicht gespeichert werden; die Raw-Dateien bleiben geöffnet + Nothing to save: these raws are at their default settings Nichts zu speichern: Diese Raw-Dateien haben ihre Standardeinstellungen # Artboard tool options bar (#11385) Distribute Widths Breiten verteilen Distribute Heights Höhen verteilen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index cea5860ea8a..8fd84deab9d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1927,8 +1927,8 @@ Group… Ομάδα… Artboard… Πίνακας σχεδίασης… Artboard from Group… Πίνακας σχεδίασης από ομάδα… - Layer via Copy Επίπεδο μέσω αντιγραφής - Layer via Cut Επίπεδο μέσω αποκοπής + Layer Via Copy Επίπεδο μέσω αντιγραφής + Layer Via Cut Επίπεδο μέσω αποκοπής Layer From Background… Επίπεδο από φόντο… Layer from Background… Επίπεδο από φόντο… Background From Layer Φόντο από επίπεδο @@ -4306,6 +4306,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Επιλέξτε διαφορετικό έγγραφο για κάθε κανάλι. A picked document is no longer open. Ένα από τα επιλεγμένα έγγραφα δεν είναι πλέον ανοιχτό. A picked document is no longer a grayscale document of the same size. Ένα από τα επιλεγμένα έγγραφα δεν είναι πλέον έγγραφο σε κλίμακα του γκρι ίδιου μεγέθους. + Netscape Gray Γκρι Netscape # Move tool options bar (#11381) Align top edges Στοίχιση πάνω άκρων Align vertical centers Στοίχιση κατακόρυφων κέντρων @@ -4315,6 +4316,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Εμφάνιση ορίων στρώσης στο πέρασμα του δείκτη Expand layer group on click Ανάπτυξη ομάδας στρώσεων με κλικ Show hover bounds from Layers Εμφάνιση ορίων στο πέρασμα από τις Στρώσεις + Saved Camera Raw settings to {file} Οι ρυθμίσεις Camera Raw αποθηκεύτηκαν στο {file} + Save the settings to each raw's sidecar without opening it Αποθήκευση των ρυθμίσεων στο συνοδευτικό αρχείο κάθε raw χωρίς άνοιγμα + No settings saved: these raws can't keep a sidecar here Δεν αποθηκεύτηκε τίποτα: αυτά τα raw δεν μπορούν να έχουν συνοδευτικό αρχείο εδώ + Some settings could not be saved; the raws stay open Ορισμένες ρυθμίσεις δεν ήταν δυνατό να αποθηκευτούν· τα raw παραμένουν ανοιχτά + Nothing to save: these raws are at their default settings Τίποτα για αποθήκευση: αυτά τα raw έχουν τις προεπιλεγμένες ρυθμίσεις # Artboard tool options bar (#11385) Distribute Widths Κατανομή πλατών Distribute Heights Κατανομή υψών diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8d1ba63697e..75b16e771c9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -385,8 +385,8 @@ Artboard… Mesa de trabajo… Artboard from Group… Mesa de trabajo a partir de grupo… Artboard from Layers… Mesa de trabajo a partir de capas… - Layer via Copy Capa vía copiar - Layer via Cut Capa vía cortar + Layer Via Copy Capa vía copiar + Layer Via Cut Capa vía cortar Layer From Background… Capa a partir del fondo… Layer from Background… Capa a partir del fondo… Background from Layer Fondo a partir de capa @@ -4105,7 +4105,7 @@ layerLabel No Color Sin color Scroll menu down Desplazar menú hacia abajo This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Esta capa de texto debe rasterizarse antes de continuar. Su texto dejará de ser editable. ¿Rasterizar el texto? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Esta capa de forma debe rasterizarse antes de continuar. Dejará de ser editable como forma. ¿Rasterizar la forma? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Este objeto inteligente debe rasterizarse antes de continuar. Su contenido dejará de ser editable. ¿Rasterizar el objeto inteligente? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Este objeto inteligente debe rasterizarse antes de continuar. Su contenido dejará de ser editable. ¿Rasterizar el objeto inteligente? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Esta capa de relleno debe rasterizarse antes de continuar. Su contenido de relleno dejará de ser editable. ¿Rasterizar la capa? This layer must be rasterized before proceeding. Esta capa debe rasterizarse antes de continuar. Color Picker (Background Color) Selector de color (color de fondo) @@ -4506,6 +4506,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Elija un documento distinto para cada canal. A picked document is no longer open. Uno de los documentos elegidos ya no está abierto. A picked document is no longer a grayscale document of the same size. Uno de los documentos elegidos ya no es un documento en escala de grises del mismo tamaño. + Netscape Gray Gris Netscape # Move tool options bar (#11381) Align top edges Alinear bordes superiores Align vertical centers Alinear centros verticales @@ -4515,6 +4516,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Mostrar límites de capa al pasar el puntero Expand layer group on click Expandir grupo de capas al hacer clic Show hover bounds from Layers Mostrar límites al pasar el puntero desde Capas + Saved Camera Raw settings to {file} Ajustes de Camera Raw guardados en {file} + Save the settings to each raw's sidecar without opening it Guardar los ajustes en el archivo sidecar de cada raw sin abrirlo + No settings saved: these raws can't keep a sidecar here No se guardó nada: estos raw no pueden tener un archivo sidecar aquí + Some settings could not be saved; the raws stay open No se pudieron guardar algunos ajustes; los raw siguen abiertos + Nothing to save: these raws are at their default settings Nada que guardar: estos raw tienen los ajustes predeterminados # Artboard tool options bar (#11385) Distribute Widths Distribuir anchos Distribute Heights Distribuir alturas diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index eb99627d4f5..9d67d3414fa 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -186,8 +186,8 @@ Trap… Trappaus… # Layer - Layer via Copy Taso kopioimalla - Layer via Cut Taso leikkaamalla + Layer Via Copy Taso kopioimalla + Layer Via Cut Taso leikkaamalla Duplicate Layer… Monista taso… Duplicate… Monista… Delete Poista @@ -4494,7 +4494,7 @@ curves Smooth Tasoita The file could not be saved. Tiedostoa ei voitu tallentaa. Thickness Paksuus This Layer: Tämä taso: - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Tämä älykäs objekti on rasteroitava ennen jatkamista. Sen sisältöä ei voi enää muokata. Rasteroidaanko älykäs objekti? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Tämä älykäs objekti on rasteroitava ennen jatkamista. Sen sisältöä ei voi enää muokata. Rasteroidaanko älykäs objekti? This adjustment ({key}) is kept as imported and can't be edited yet. Tämä säätö ({key}) säilytetään tuotuna, eikä sitä voi vielä muokata. This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Tämä täyttötaso on rasteroitava ennen jatkamista. Sen täyttösisältöä ei voi enää muokata. Rasteroidaanko taso? This layer must be rasterized before proceeding. Tämä taso on rasteroitava ennen jatkamista. @@ -4814,6 +4814,7 @@ cameraRaw White Balance: As Shot Valkotasapaino: kuvauksen mukainen Pick a different document for each channel. Valitse jokaiselle kanavalle eri asiakirja. A picked document is no longer open. Yksi valituista asiakirjoista ei ole enää auki. A picked document is no longer a grayscale document of the same size. Yksi valituista asiakirjoista ei ole enää samankokoinen harmaasävyasiakirja. + Netscape Gray Netscape-harmaa # Move tool options bar (#11381) Align top edges Tasaa yläreunat Align vertical centers Tasaa pystykeskikohdat diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c58bf7a32a2..439781ff104 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -410,8 +410,8 @@ Artboard… Plan de travail… Artboard from Group… Plan de travail d'après un groupe… Artboard from Layers… Plan de travail d'après des calques… - Layer via Copy Calque par copie - Layer via Cut Calque par coupe + Layer Via Copy Calque par copie + 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 @@ -4333,7 +4333,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded The file could not be saved. Impossible d'enregistrer le fichier. Thickness Épaisseur This Layer: Ce calque : - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Cet objet intelligent doit être rastérisé pour continuer. Son contenu ne sera plus modifiable. Rastériser l'objet intelligent ? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Cet objet intelligent doit être rastérisé pour continuer. Son contenu ne sera plus modifiable. Rastériser l'objet intelligent ? This adjustment ({key}) is kept as imported and can't be edited yet. Cet ajustement ({key}) est conservé tel qu'il a été importé et ne peut pas encore être modifié. This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Ce calque de remplissage doit être rastérisé pour continuer. Son contenu de remplissage ne sera plus modifiable. Rastériser le calque ? This layer must be rasterized before proceeding. Ce calque doit être rastérisé pour continuer. @@ -4629,6 +4629,7 @@ shape Appearance Apparence Pick a different document for each channel. Choisissez un document différent pour chaque canal. A picked document is no longer open. Un des documents choisis n'est plus ouvert. A picked document is no longer a grayscale document of the same size. Un des documents choisis n'est plus un document en niveaux de gris de même taille. + Netscape Gray Gris Netscape # Move tool options bar (#11381) Align top edges Aligner les bords supérieurs Align vertical centers Aligner les centres verticaux @@ -4638,6 +4639,11 @@ shape Appearance Apparence Show layer bounds on hover Afficher les limites du calque au survol Expand layer group on click Développer le groupe de calques au clic Show hover bounds from Layers Afficher les limites au survol depuis Calques + Saved Camera Raw settings to {file} Réglages Camera Raw enregistrés dans {file} + Save the settings to each raw's sidecar without opening it Enregistrer les réglages dans le fichier annexe de chaque raw sans l’ouvrir + No settings saved: these raws can't keep a sidecar here Rien n’a été enregistré : ces raw ne peuvent pas avoir de fichier annexe ici + Some settings could not be saved; the raws stay open Certains réglages n’ont pas pu être enregistrés ; les raw restent ouverts + Nothing to save: these raws are at their default settings Rien à enregistrer : ces raw ont leurs réglages par défaut # Artboard tool options bar (#11385) Distribute Widths Répartir les largeurs Distribute Heights Répartir les hauteurs diff --git a/crates/ui-egui/src/i18n/he.tsv b/crates/ui-egui/src/i18n/he.tsv index d38b62ed3a1..c8f682bf39c 100644 --- a/crates/ui-egui/src/i18n/he.tsv +++ b/crates/ui-egui/src/i18n/he.tsv @@ -311,8 +311,8 @@ Artboard… לוח ציור… Artboard from Group… לוח ציור מקבוצה… Artboard from Layers… לוח ציור משכבות… - Layer via Copy שכבה דרך העתקה - Layer via Cut שכבה דרך גזירה + Layer Via Copy שכבה דרך העתקה + Layer Via Cut שכבה דרך גזירה Layer From Background… שכבה מרקע… Layer from Background… שכבה מרקע… Background from Layer רקע משכבה @@ -4018,7 +4018,7 @@ layerLabel No Color בלי צבע Scroll menu down גלול תפריט למטה This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? שכבת הטקסט הזו חייבת להיות מרוסטרת לפני ההמשך. הטקסט שלה לא יהיה עוד עריך. לרסטר את הטקסט? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? שכבת הצורה הזו חייבת להיות מרוסטרת לפני ההמשך. היא לא תהיה עוד עריכה כצורה. לרסטר את הצורה? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? האובייקט החכם הזה חייב להיות מרוסטר לפני ההמשך. התוכן שלו לא יהיה עוד עריך. לרסטר את האובייקט החכם? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? האובייקט החכם הזה חייב להיות מרוסטר לפני ההמשך. התוכן שלו לא יהיה עוד עריך. לרסטר את האובייקט החכם? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? שכבת המילוי הזו חייבת להיות מרוסטרת לפני ההמשך. תוכן המילוי שלה לא יהיה עוד עריך. לרסטר את השכבה? This layer must be rasterized before proceeding. השכבה הזו חייבת להיות מרוסטרת לפני ההמשך. Color Picker (Background Color) בוחר צבעים (צבע רקע) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 2e6854129a2..6fc8988ef48 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -675,6 +675,7 @@ cameraRaw Yellow पीला Pick a different document for each channel. हर चैनल के लिए अलग डॉक्यूमेंट चुनें। A picked document is no longer open. चुना गया एक डॉक्यूमेंट अब खुला नहीं है। A picked document is no longer a grayscale document of the same size. चुना गया एक डॉक्यूमेंट अब उसी आकार का ग्रेस्केल डॉक्यूमेंट नहीं है। + Netscape Gray Netscape धूसर # Move tool options bar (#11381) Align top edges ऊपरी किनारे संरेखित करें Align vertical centers ऊर्ध्वाधर केंद्र संरेखित करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 855f377466d..bc4edfa9812 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -409,8 +409,8 @@ Layer Content Options… Rétegtartalom beállításai… Layer from Background… Réteg a háttérből… Layer Style Rétegstílus - Layer via Copy Réteg másolással - Layer via Cut Réteg kivágással + Layer Via Copy Réteg másolással + Layer Via Cut Réteg kivágással Layer… Réteg… Left Edges Bal szélek Link Layer Mask Rétegmaszk összekapcsolása @@ -3090,7 +3090,7 @@ cameraRaw Yellow Sárga This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? A folytatáshoz a kitöltőréteget raszterizálni kell. A kitöltés tartalma ezután nem lesz szerkeszthető. Raszterizálod a réteget? This layer must be rasterized before proceeding. A folytatáshoz a réteget raszterizálni kell. This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? A folytatáshoz az alakzatréteget raszterizálni kell. Ezután alakzatként nem lesz szerkeszthető. Raszterizálod az alakzatot? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? A folytatáshoz az intelligens objektumot raszterizálni kell. A tartalma ezután nem lesz szerkeszthető. Raszterizálod az intelligens objektumot? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? A folytatáshoz az intelligens objektumot raszterizálni kell. A tartalma ezután nem lesz szerkeszthető. Raszterizálod az intelligens objektumot? This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? A folytatáshoz a szövegréteget raszterizálni kell. A szöveg ezután nem lesz szerkeszthető. Raszterizálod a szöveget? TIFF Options TIFF-beállítások Top: Felső: @@ -4513,6 +4513,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Minden csatornához másik dokumentumot válasszon. A picked document is no longer open. Az egyik kiválasztott dokumentum már nincs megnyitva. A picked document is no longer a grayscale document of the same size. Az egyik kiválasztott dokumentum már nem azonos méretű szürkeárnyalatos dokumentum. + Netscape Gray Netscape-szürke # Move tool options bar (#11381) Align top edges Felső szélek igazítása Align vertical centers Függőleges középpontok igazítása @@ -4522,6 +4523,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Réteghatárok megjelenítése rámutatáskor Expand layer group on click Rétegcsoport kibontása kattintásra Show hover bounds from Layers Határok megjelenítése a Rétegek panelről rámutatáskor + Saved Camera Raw settings to {file} Camera Raw-beállítások mentve ide: {file} + Save the settings to each raw's sidecar without opening it A beállítások mentése minden raw kísérőfájljába megnyitás nélkül + No settings saved: these raws can't keep a sidecar here Nincs mentve semmi: ezek a raw fájlok itt nem kaphatnak kísérőfájlt + Some settings could not be saved; the raws stay open Néhány beállítást nem sikerült menteni; a raw fájlok nyitva maradnak + Nothing to save: these raws are at their default settings Nincs mit menteni: ezek a raw fájlok alapértelmezett beállításokon vannak # Artboard tool options bar (#11385) Distribute Widths Szélességek elosztása Distribute Heights Magasságok elosztása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index b8071e64267..e35857c4bb8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -403,8 +403,8 @@ Artboard… Artboard… Artboard from Group… Artboard dari Grup… Artboard from Layers… Artboard dari Layer… - Layer via Copy Layer via Salin - Layer via Cut Layer via Potong + Layer Via Copy Layer via Salin + Layer Via Cut Layer via Potong Layer From Background… Layer dari Background… Layer from Background… Layer dari Background… Background From Layer Background dari Layer @@ -3629,6 +3629,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Pilih dokumen yang berbeda untuk setiap channel. A picked document is no longer open. Salah satu dokumen yang dipilih sudah tidak terbuka. A picked document is no longer a grayscale document of the same size. Salah satu dokumen yang dipilih bukan lagi dokumen grayscale dengan ukuran yang sama. + Netscape Gray Abu-abu Netscape # Move tool options bar (#11381) Align top edges Ratakan tepi atas Align vertical centers Ratakan pusat vertikal @@ -3638,6 +3639,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Tampilkan batas layer saat kursor di atasnya Expand layer group on click Perluas grup layer saat diklik Show hover bounds from Layers Tampilkan batas saat kursor di atas dari Layer + Saved Camera Raw settings to {file} Pengaturan Camera Raw disimpan ke {file} + Save the settings to each raw's sidecar without opening it Simpan pengaturan ke file sidecar setiap raw tanpa membukanya + No settings saved: these raws can't keep a sidecar here Tidak ada yang disimpan: raw ini tidak dapat memiliki file sidecar di sini + Some settings could not be saved; the raws stay open Beberapa pengaturan tidak dapat disimpan; raw tetap terbuka + Nothing to save: these raws are at their default settings Tidak ada yang perlu disimpan: raw ini memakai pengaturan default # Artboard tool options bar (#11385) Distribute Widths Distribusi lebar Distribute Heights Distribusi tinggi diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index e5c0c423287..0d482649967 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -431,8 +431,8 @@ Artboard… Tavola da disegno… Artboard from Group… Tavola da gruppo… Artboard from Layers… Tavola da livelli… - Layer via Copy Livello tramite copia - Layer via Cut Livello tramite taglia + Layer Via Copy Livello tramite copia + Layer Via Cut Livello tramite taglia Layer From Background… Livello dallo sfondo… Layer from Background… Livello dallo sfondo… Background from Layer Sfondo da livello @@ -4170,7 +4170,7 @@ layerLabel No Color Nessun colore Scroll menu down Scorri il menu in basso This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Per procedere il livello di testo deve essere rasterizzato. Il testo non sarà più modificabile. Rasterizzare il testo? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Per procedere il livello forma deve essere rasterizzato. Non sarà più modificabile come forma. Rasterizzare la forma? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Per procedere l'oggetto avanzato deve essere rasterizzato. Il contenuto non sarà più modificabile. Rasterizzare l'oggetto avanzato? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Per procedere l'oggetto avanzato deve essere rasterizzato. Il contenuto non sarà più modificabile. Rasterizzare l'oggetto avanzato? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Per procedere il livello di riempimento deve essere rasterizzato. Il riempimento non sarà più modificabile. Rasterizzare il livello? This layer must be rasterized before proceeding. Per procedere il livello deve essere rasterizzato. Color Picker (Background Color) Selettore colore (colore sfondo) @@ -4705,6 +4705,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Scegli un documento diverso per ogni canale. A picked document is no longer open. Uno dei documenti scelti non è più aperto. A picked document is no longer a grayscale document of the same size. Uno dei documenti scelti non è più un documento in scala di grigio delle stesse dimensioni. + Netscape Gray Grigio Netscape # Move tool options bar (#11381) Align top edges Allinea i bordi superiori Align vertical centers Allinea i centri verticali @@ -4714,6 +4715,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Mostra i limiti del livello al passaggio del mouse Expand layer group on click Espandi il gruppo di livelli al clic Show hover bounds from Layers Mostra i limiti al passaggio dal pannello Livelli + Saved Camera Raw settings to {file} Impostazioni Camera Raw salvate in {file} + Save the settings to each raw's sidecar without opening it Salva le impostazioni nel file sidecar di ogni raw senza aprirlo + No settings saved: these raws can't keep a sidecar here Nulla salvato: questi raw non possono avere un file sidecar qui + Some settings could not be saved; the raws stay open Alcune impostazioni non sono state salvate; i raw restano aperti + Nothing to save: these raws are at their default settings Niente da salvare: questi raw hanno le impostazioni predefinite # Artboard tool options bar (#11385) Distribute Widths Distribuisci larghezze Distribute Heights Distribuisci altezze diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index cff8be8d636..9c19da7297a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -441,8 +441,8 @@ Artboard… アートボード… Artboard from Group… グループからアートボード… Artboard from Layers… レイヤーからアートボード… - Layer via Copy コピーしたレイヤー - Layer via Cut 切り取ったレイヤー + Layer Via Copy コピーしたレイヤー + Layer Via Cut 切り取ったレイヤー Layer From Background… 背景からレイヤーへ… Layer from Background… 背景からレイヤーへ… Background From Layer レイヤーから背景 @@ -4277,6 +4277,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. チャンネルごとに別のドキュメントを選択してください。 A picked document is no longer open. 選択したドキュメントの一つが開かれていません。 A picked document is no longer a grayscale document of the same size. 選択したドキュメントの一つが、同じサイズのグレースケールのドキュメントではなくなりました。 + Netscape Gray Netscape グレー # Move tool options bar (#11381) Align top edges 上端を揃える Align vertical centers 垂直方向中央を揃える @@ -4286,6 +4287,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover ホバー時にレイヤーの境界を表示 Expand layer group on click クリックでレイヤーグループを展開 Show hover bounds from Layers レイヤーパネルからのホバー境界を表示 + Saved Camera Raw settings to {file} Camera Raw の設定を {file} に保存しました + Save the settings to each raw's sidecar without opening it 各 Raw のサイドカーファイルに設定を保存し、開かずに閉じます + No settings saved: these raws can't keep a sidecar here 保存されませんでした:これらの Raw にはここでサイドカーファイルを作成できません + Some settings could not be saved; the raws stay open 一部の設定を保存できませんでした。Raw は開いたままです + Nothing to save: these raws are at their default settings 保存するものはありません:これらの Raw は初期設定のままです # Artboard tool options bar (#11385) Distribute Widths 幅を分布 Distribute Heights 高さを分布 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 02e5270e11b..c5085bc9f7b 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -276,8 +276,8 @@ Artboard… სამუშაო დაფა… Artboard from Group… სამუშაო დაფა ჯგუფიდან… Artboard from Layers… სამუშაო დაფა შრეებიდან… - Layer via Copy შრე კოპირებით - Layer via Cut შრე ამოჭრით + Layer Via Copy შრე კოპირებით + Layer Via Cut შრე ამოჭრით Layer From Background… შრე ფონიდან… Layer from Background… შრე ფონიდან… Background From Layer ფონი შრიდან @@ -3949,7 +3949,7 @@ curves Smooth გაგლუვება Scroll menu down მენიუს ქვემოთ გადახვევა This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? გასაგრძელებლად ეს ტექსტის შრე უნდა დარასტრირდეს. მისი ტექსტი აღარ იქნება რედაქტირებადი. დავარასტრირო ტექსტი? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? გასაგრძელებლად ეს ფიგურის შრე უნდა დარასტრირდეს. ის ფიგურად აღარ იქნება რედაქტირებადი. დავარასტრირო ფიგურა? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? გასაგრძელებლად ეს ჭკვიანი ობიექტი უნდა დარასტრირდეს. მისი შიგთავსი აღარ იქნება რედაქტირებადი. დავარასტრირო ჭკვიანი ობიექტი? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? გასაგრძელებლად ეს ჭკვიანი ობიექტი უნდა დარასტრირდეს. მისი შიგთავსი აღარ იქნება რედაქტირებადი. დავარასტრირო ჭკვიანი ობიექტი? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? გასაგრძელებლად ეს შევსების შრე უნდა დარასტრირდეს. მისი შევსება აღარ იქნება რედაქტირებადი. დავარასტრირო შრე? This layer must be rasterized before proceeding. გასაგრძელებლად ეს შრე უნდა დარასტრირდეს. Color Picker (Background Color) ფერის ამრჩევი (ფონის ფერი) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index d1151258774..d5c1a6a3d03 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -407,8 +407,8 @@ Artboard… 아트보드… Artboard from Group… 그룹으로 아트보드 만들기… Artboard from Layers… 레이어로 아트보드 만들기… - Layer via Copy 복사하여 레이어 만들기 - Layer via Cut 잘라내어 레이어 만들기 + Layer Via Copy 복사하여 레이어 만들기 + Layer Via Cut 잘라내어 레이어 만들기 Layer From Background… 배경에서 레이어 만들기… Layer from Background… 배경에서 레이어 만들기… Background from Layer 레이어에서 배경 만들기 @@ -3833,6 +3833,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. 채널마다 다른 문서를 선택하십시오. A picked document is no longer open. 선택한 문서 중 하나가 더 이상 열려 있지 않습니다. A picked document is no longer a grayscale document of the same size. 선택한 문서 중 하나가 더 이상 같은 크기의 회색 음영 문서가 아닙니다. + Netscape Gray Netscape 회색 # Rows that #8185 adds on top of the catalog above. Working CMY Plate 작업용 CMY 판 @@ -4024,7 +4025,7 @@ layerLabel No Color 색상 없음 Scroll menu down 메뉴 아래로 스크롤 This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? 계속하려면 이 문자 레이어를 래스터화해야 합니다. 이후에는 텍스트를 편집할 수 없습니다. 문자를 래스터화할까요? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? 계속하려면 이 도형 레이어를 래스터화해야 합니다. 이후에는 도형으로 편집할 수 없습니다. 도형을 래스터화할까요? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? 계속하려면 이 고급 개체를 래스터화해야 합니다. 이후에는 내용을 편집할 수 없습니다. 고급 개체를 래스터화할까요? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? 계속하려면 이 고급 개체를 래스터화해야 합니다. 이후에는 내용을 편집할 수 없습니다. 고급 개체를 래스터화할까요? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? 계속하려면 이 칠 레이어를 래스터화해야 합니다. 이후에는 칠 내용을 편집할 수 없습니다. 레이어를 래스터화할까요? This layer must be rasterized before proceeding. 계속하려면 이 레이어를 래스터화해야 합니다. Color Picker (Background Color) 색상 선택기(배경색) @@ -4188,6 +4189,11 @@ shape Appearance 화면 모양 Show layer bounds on hover 마우스를 올리면 레이어 경계 표시 Expand layer group on click 클릭하면 레이어 그룹 펼치기 Show hover bounds from Layers 레이어 패널에서 마우스를 올릴 때 경계 표시 + Saved Camera Raw settings to {file} Camera Raw 설정을 {file}에 저장했습니다 + Save the settings to each raw's sidecar without opening it 각 Raw의 사이드카 파일에 설정을 저장하고 열지 않습니다 + No settings saved: these raws can't keep a sidecar here 저장된 항목 없음: 이 Raw 파일은 여기에 사이드카 파일을 둘 수 없습니다 + Some settings could not be saved; the raws stay open 일부 설정을 저장할 수 없습니다. Raw 파일은 열린 상태로 유지됩니다 + Nothing to save: these raws are at their default settings 저장할 항목 없음: 이 Raw 파일은 기본 설정입니다 # Artboard tool options bar (#11385) Distribute Widths 너비 분포 Distribute Heights 높이 분포 diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 83643360f37..6124c806fe2 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -185,8 +185,8 @@ Trap… Overlapping… # Layer - Layer via Copy Lag via kopiering - Layer via Cut Lag via utklipp + Layer Via Copy Lag via kopiering + Layer Via Cut Lag via utklipp Duplicate Layer… Dupliser lag… Duplicate… Dupliser… Delete Slett @@ -4493,7 +4493,7 @@ curves Smooth Jevn ut The file could not be saved. Filen kunne ikke lagres. Thickness Tykkelse This Layer: Dette laget: - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Dette smartobjektet må rasteriseres før du fortsetter. Innholdet kan da ikke lenger redigeres. Rasterisere smartobjektet? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Dette smartobjektet må rasteriseres før du fortsetter. Innholdet kan da ikke lenger redigeres. Rasterisere smartobjektet? This adjustment ({key}) is kept as imported and can't be edited yet. Denne justeringen ({key}) beholdes som importert og kan ikke redigeres ennå. This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Dette fyllaget må rasteriseres før du fortsetter. Fyllinnholdet kan da ikke lenger redigeres. Rasterisere laget? This layer must be rasterized before proceeding. Dette laget må rasteriseres før du fortsetter. @@ -4813,6 +4813,7 @@ cameraRaw White Balance: As Shot Hvitbalanse: som fotografert Pick a different document for each channel. Velg et annet dokument for hver kanal. A picked document is no longer open. Et av de valgte dokumentene er ikke lenger åpent. A picked document is no longer a grayscale document of the same size. Et av de valgte dokumentene er ikke lenger et dokument i gråtoner av samme størrelse. + Netscape Gray Netscape-grå # Move tool options bar (#11381) Align top edges Juster øverste kanter Align vertical centers Juster loddrette midtpunkter diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 739e75a6310..805c7d92d13 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -373,8 +373,8 @@ Artboard… Tekengebied… Artboard from Group… Tekengebied uit groep… Artboard from Layers… Tekengebied uit lagen… - Layer via Copy Laag via kopiëren - Layer via Cut Laag via knippen + Layer Via Copy Laag via kopiëren + Layer Via Cut Laag via knippen Layer From Background… Laag uit achtergrond… Layer from Background… Laag uit achtergrond… Background From Layer Achtergrond uit laag @@ -4360,6 +4360,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Kies voor elk kanaal een ander document. A picked document is no longer open. Een gekozen document is niet meer geopend. A picked document is no longer a grayscale document of the same size. Een gekozen document is geen grijswaardendocument van hetzelfde formaat meer. + Netscape Gray Netscape-grijs # Move tool options bar (#11381) Align top edges Bovenranden uitlijnen Align vertical centers Verticale middens uitlijnen @@ -4369,6 +4370,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Laaggrenzen tonen bij aanwijzen Expand layer group on click Laaggroep uitvouwen bij klikken Show hover bounds from Layers Aanwijsgrenzen tonen vanuit Lagen + Saved Camera Raw settings to {file} Camera Raw-instellingen opgeslagen in {file} + Save the settings to each raw's sidecar without opening it De instellingen in het sidecarbestand van elke raw opslaan zonder die te openen + No settings saved: these raws can't keep a sidecar here Niets opgeslagen: deze raws kunnen hier geen sidecarbestand krijgen + Some settings could not be saved; the raws stay open Sommige instellingen konden niet worden opgeslagen; de raws blijven open + Nothing to save: these raws are at their default settings Niets op te slaan: deze raws staan op hun standaardinstellingen # Artboard tool options bar (#11385) Distribute Widths Breedtes verdelen Distribute Heights Hoogtes verdelen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 277f64255e6..4e34bf6a7d0 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -416,8 +416,8 @@ Artboard… Obszar roboczy… Artboard from Group… Obszar roboczy z grupy… Artboard from Layers… Obszar roboczy z warstw… - Layer via Copy Warstwa przez kopiowanie - Layer via Cut Warstwa przez wycięcie + Layer Via Copy Warstwa przez kopiowanie + 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 @@ -4224,6 +4224,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Wybierz inny dokument dla każdego kanału. A picked document is no longer open. Jeden z wybranych dokumentów nie jest już otwarty. A picked document is no longer a grayscale document of the same size. Jeden z wybranych dokumentów nie jest już dokumentem w skali szarości o tym samym rozmiarze. + Netscape Gray Szarość Netscape # Move tool options bar (#11381) Align top edges Wyrównaj górne krawędzie Align vertical centers Wyrównaj środki w pionie @@ -4233,6 +4234,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Pokaż granice warstwy po najechaniu Expand layer group on click Rozwiń grupę warstw po kliknięciu Show hover bounds from Layers Pokaż granice po najechaniu z panelu Warstwy + Saved Camera Raw settings to {file} Zapisano ustawienia Camera Raw w {file} + Save the settings to each raw's sidecar without opening it Zapisz ustawienia w pliku sidecar każdego pliku raw bez otwierania go + No settings saved: these raws can't keep a sidecar here Nic nie zapisano: te pliki raw nie mogą tu mieć pliku sidecar + Some settings could not be saved; the raws stay open Nie udało się zapisać niektórych ustawień; pliki raw pozostają otwarte + Nothing to save: these raws are at their default settings Nic do zapisania: te pliki raw mają ustawienia domyślne # Artboard tool options bar (#11385) Distribute Widths Rozmieść szerokości równomiernie Distribute Heights Rozmieść wysokości równomiernie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b12f340dabd..17412673c9c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -406,8 +406,8 @@ Artboard… Prancheta… Artboard from Group… Prancheta a partir de grupo… Artboard from Layers… Prancheta a partir de camadas… - Layer via Copy Camada via cópia - Layer via Cut Camada via recorte + Layer Via Copy Camada via cópia + 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 @@ -4321,7 +4321,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded This layer must be rasterized before proceeding. Esta camada precisa ser rasterizada antes de continuar. This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Esta camada de texto precisa ser rasterizada antes de continuar. O texto não poderá mais ser editado. Rasterizar o texto? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Esta camada de forma precisa ser rasterizada antes de continuar. Ela não poderá mais ser editada como forma. Rasterizar a forma? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Este objeto inteligente precisa ser rasterizado antes de continuar. O conteúdo não poderá mais ser editado. Rasterizar o objeto inteligente? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Este objeto inteligente precisa ser rasterizado antes de continuar. O conteúdo não poderá mais ser editado. Rasterizar o objeto inteligente? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Esta camada de preenchimento precisa ser rasterizada antes de continuar. O conteúdo do preenchimento não poderá mais ser editado. Rasterizar a camada? Graphics adapter: {adapter} Adaptador gráfico: {adapter} Backend: {backend} ({type}) Backend: {backend} ({type}) @@ -4520,6 +4520,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Escolha um documento diferente para cada canal. A picked document is no longer open. Um dos documentos escolhidos não está mais aberto. A picked document is no longer a grayscale document of the same size. Um dos documentos escolhidos não é mais um documento em tons de cinza do mesmo tamanho. + Netscape Gray Cinza Netscape # Move tool options bar (#11381) Align top edges Alinhar bordas superiores Align vertical centers Alinhar centros verticais @@ -4529,6 +4530,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Mostrar limites da camada ao passar o cursor Expand layer group on click Expandir grupo de camadas ao clicar Show hover bounds from Layers Mostrar limites ao passar o cursor em Camadas + Saved Camera Raw settings to {file} Configurações do Camera Raw salvas em {file} + Save the settings to each raw's sidecar without opening it Salvar as configurações no arquivo sidecar de cada raw sem abri-lo + No settings saved: these raws can't keep a sidecar here Nada foi salvo: estes raw não podem ter um arquivo sidecar aqui + Some settings could not be saved; the raws stay open Não foi possível salvar algumas configurações; os raw continuam abertos + Nothing to save: these raws are at their default settings Nada para salvar: estes raw estão nas configurações padrão # Artboard tool options bar (#11385) Distribute Widths Distribuir larguras Distribute Heights Distribuir alturas diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 5bf7d924c8a..57539882ab6 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -353,8 +353,8 @@ Artboard… Planșă de lucru… Artboard from Group… Planșă de lucru din grup… Artboard from Layers… Planșă de lucru din straturi… - Layer via Copy Strat prin copiere - Layer via Cut Strat prin decupare + Layer Via Copy Strat prin copiere + Layer Via Cut Strat prin decupare Layer From Background… Strat din fundal… Layer from Background… Strat din fundal… Background from Layer Fundal din strat @@ -4060,7 +4060,7 @@ layerLabel No Color Fără culoare Scroll menu down Derulare meniu jos This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Acest strat de text trebuie rasterizat înainte de a continua. Textul său nu va mai fi editabil. Rasterizați textul? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Acest strat de formă trebuie rasterizat înainte de a continua. Nu va mai fi editabil ca formă. Rasterizați forma? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Acest obiect inteligent trebuie rasterizat înainte de a continua. Conținutul său nu va mai fi editabil. Rasterizați obiectul inteligent? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Acest obiect inteligent trebuie rasterizat înainte de a continua. Conținutul său nu va mai fi editabil. Rasterizați obiectul inteligent? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Acest strat de umplere trebuie rasterizat înainte de a continua. Conținutul său de umplere nu va mai fi editabil. Rasterizați stratul? This layer must be rasterized before proceeding. Acest strat trebuie rasterizat înainte de a continua. Color Picker (Background Color) Selector de culoare (culoare de fundal) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4e6495d3e21..10eea079f63 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -365,8 +365,8 @@ Duotone… Дуотон… Duotone Дуотон Trap… Приводка… - Layer via Copy Скопировать на новый слой - Layer via Cut Вырезать на новый слой + Layer Via Copy Скопировать на новый слой + Layer Via Cut Вырезать на новый слой Duplicate Layer… Дублировать слой… Duplicate… Дублировать… Delete Удалить @@ -4496,7 +4496,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded The file could not be saved. Не удалось сохранить файл. Thickness Толщина This Layer: Этот слой: - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Перед продолжением необходимо растрировать этот смарт-объект. Его содержимое перестанет быть редактируемым. Растрировать смарт-объект? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Перед продолжением необходимо растрировать этот смарт-объект. Его содержимое перестанет быть редактируемым. Растрировать смарт-объект? This adjustment ({key}) is kept as imported and can't be edited yet. Эта коррекция ({key}) сохраняется в импортированном виде и пока не может быть изменена. This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Перед продолжением необходимо растрировать этот слой заливки. Содержимое заливки перестанет быть редактируемым. Растрировать слой? This layer must be rasterized before proceeding. Перед продолжением необходимо растрировать этот слой. @@ -4791,6 +4791,7 @@ shape Appearance Оформление Pick a different document for each channel. Выберите отдельный документ для каждого канала. A picked document is no longer open. Один из выбранных документов больше не открыт. A picked document is no longer a grayscale document of the same size. Один из выбранных документов больше не является документом в градациях серого того же размера. + Netscape Gray Серый Netscape # Move tool options bar (#11381) Align top edges Выровнять верхние края Align vertical centers Выровнять центры по вертикали @@ -4800,6 +4801,11 @@ shape Appearance Оформление Show layer bounds on hover Показывать границы слоя при наведении Expand layer group on click Раскрывать группу слоёв по щелчку Show hover bounds from Layers Показывать границы при наведении из панели «Слои» + Saved Camera Raw settings to {file} Настройки Camera Raw сохранены в {file} + Save the settings to each raw's sidecar without opening it Сохранить настройки в сопроводительный файл каждого raw, не открывая его + No settings saved: these raws can't keep a sidecar here Ничего не сохранено: у этих raw здесь не может быть сопроводительного файла + Some settings could not be saved; the raws stay open Не удалось сохранить некоторые настройки; raw остаются открытыми + Nothing to save: these raws are at their default settings Нечего сохранять: у этих raw настройки по умолчанию # Artboard tool options bar (#11385) Distribute Widths Распределить ширину Distribute Heights Распределить высоту diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 9b9fbe56365..af83474e79f 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -431,8 +431,8 @@ Artboard… Radna tabla… Artboard from Group… Radna tabla od grupe… Artboard from Layers… Radna tabla od slojeva… - Layer via Copy Sloj kopiranjem - Layer via Cut Sloj isecanjem + Layer Via Copy Sloj kopiranjem + Layer Via Cut Sloj isecanjem Layer From Background… Sloj od pozadine… Layer from Background… Sloj od pozadine… Background From Layer Pozadina od sloja @@ -4143,7 +4143,7 @@ layerLabel No Color Bez boje Scroll menu down Pomeri meni nadole This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Ovaj tekstualni sloj mora da se rasterizuje pre nastavka. Njegov tekst više neće moći da se uređuje. Rasterizovati tekst? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Ovaj sloj oblika mora da se rasterizuje pre nastavka. Više neće moći da se uređuje kao oblik. Rasterizovati oblik? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Ovaj pametni objekat mora da se rasterizuje pre nastavka. Njegov sadržaj više neće moći da se uređuje. Rasterizovati pametni objekat? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Ovaj pametni objekat mora da se rasterizuje pre nastavka. Njegov sadržaj više neće moći da se uređuje. Rasterizovati pametni objekat? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Ovaj sloj popune mora da se rasterizuje pre nastavka. Njegov sadržaj popune više neće moći da se uređuje. Rasterizovati sloj? This layer must be rasterized before proceeding. Ovaj sloj mora da se rasterizuje pre nastavka. Color Picker (Background Color) Birač boja (boja pozadine) @@ -4672,6 +4672,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Izaberite drugi dokument za svaki kanal. A picked document is no longer open. Jedan izabrani dokument više nije otvoren. A picked document is no longer a grayscale document of the same size. Jedan izabrani dokument više nije dokument u nijansama sive iste veličine. + Netscape Gray Netscape siva # Move tool options bar (#11381) Align top edges Poravnaj gornje ivice Align vertical centers Poravnaj vertikalne centre @@ -4681,6 +4682,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Prikaži granice sloja pri prelasku miša Expand layer group on click Proširi grupu slojeva klikom Show hover bounds from Layers Prikaži granice pri prelasku iz panela Slojevi + Saved Camera Raw settings to {file} Подешавања Camera Raw сачувана у {file} + Save the settings to each raw's sidecar without opening it Sačuvaj podešavanja u prateću datoteku svakog raw snimka bez otvaranja + No settings saved: these raws can't keep a sidecar here Ništa nije sačuvano: ovi raw snimci ovde ne mogu imati prateću datoteku + Some settings could not be saved; the raws stay open Neka podešavanja nije moguće sačuvati; raw snimci ostaju otvoreni + Nothing to save: these raws are at their default settings Nema šta da se sačuva: ovi raw snimci imaju podrazumevana podešavanja # Artboard tool options bar (#11385) Distribute Widths Raspodeli širine Distribute Heights Raspodeli visine diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 2157f234de0..105b818ae7c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -183,8 +183,8 @@ Trap… Överfyllning… # Layer - Layer via Copy Lager via kopiering - Layer via Cut Lager via urklippning + Layer Via Copy Lager via kopiering + Layer Via Cut Lager via urklippning Duplicate Layer… Duplicera lager… Duplicate… Duplicera… Delete Ta bort @@ -4491,7 +4491,7 @@ curves Smooth Jämna ut The file could not be saved. Det gick inte att spara filen. Thickness Tjocklek This Layer: Det här lagret: - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Det här smarta objektet måste rasteriseras innan du fortsätter. Innehållet går inte längre att redigera. Vill du rasterisera det smarta objektet? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Det här smarta objektet måste rasteriseras innan du fortsätter. Innehållet går inte längre att redigera. Vill du rasterisera det smarta objektet? This adjustment ({key}) is kept as imported and can't be edited yet. Den här justeringen ({key}) behålls som den importerades och kan inte redigeras ännu. This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Det här fyllningslagret måste rasteriseras innan du fortsätter. Fyllningsinnehållet går inte längre att redigera. Vill du rasterisera lagret? This layer must be rasterized before proceeding. Det här lagret måste rasteriseras innan du fortsätter. @@ -4811,6 +4811,7 @@ cameraRaw White Balance: As Shot Vitbalans: som tagen Pick a different document for each channel. Välj ett annat dokument för varje kanal. A picked document is no longer open. Ett av de valda dokumenten är inte längre öppet. A picked document is no longer a grayscale document of the same size. Ett av de valda dokumenten är inte längre ett gråskaledokument i samma storlek. + Netscape Gray Netscape-grå # Move tool options bar (#11381) Align top edges Justera överkanter Align vertical centers Justera vertikala mittpunkter diff --git a/crates/ui-egui/src/i18n/ta.tsv b/crates/ui-egui/src/i18n/ta.tsv index 3c45b9a2184..8131977605d 100644 --- a/crates/ui-egui/src/i18n/ta.tsv +++ b/crates/ui-egui/src/i18n/ta.tsv @@ -305,8 +305,8 @@ Artboard… ஆர்ட்போர்டு… Artboard from Group… குழுவிலிருந்து ஆர்ட்போர்டு… Artboard from Layers… அடுக்குகளிலிருந்து ஆர்ட்போர்டு… - Layer via Copy நகல் வழியாக அடுக்கு - Layer via Cut வெட்டு வழியாக அடுக்கு + Layer Via Copy நகல் வழியாக அடுக்கு + Layer Via Cut வெட்டு வழியாக அடுக்கு Layer From Background… பின்னணியிலிருந்து அடுக்கு… Layer from Background… பின்னணியிலிருந்து அடுக்கு… Background from Layer அடுக்கிலிருந்து பின்னணி @@ -4027,7 +4027,7 @@ layerLabel No Color நிறம் இல்லை Scroll menu down பட்டியைக் கீழே உருட்டு This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? தொடர்வதற்கு முன் இந்த உரை அடுக்கு ராஸ்டராக்கப்பட வேண்டும். அதன் உரையை இனி திருத்த முடியாது. உரையை ராஸ்டராக்கவா? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? தொடர்வதற்கு முன் இந்த வடிவ அடுக்கு ராஸ்டராக்கப்பட வேண்டும். இதை இனி வடிவமாகத் திருத்த முடியாது. வடிவத்தை ராஸ்டராக்கவா? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? தொடர்வதற்கு முன் இந்த திறன்மிகு பொருள் ராஸ்டராக்கப்பட வேண்டும். அதன் உள்ளடக்கத்தை இனி திருத்த முடியாது. திறன்மிகு பொருளை ராஸ்டராக்கவா? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? தொடர்வதற்கு முன் இந்த திறன்மிகு பொருள் ராஸ்டராக்கப்பட வேண்டும். அதன் உள்ளடக்கத்தை இனி திருத்த முடியாது. திறன்மிகு பொருளை ராஸ்டராக்கவா? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? தொடர்வதற்கு முன் இந்த நிரப்பு அடுக்கு ராஸ்டராக்கப்பட வேண்டும். அதன் நிரப்பு உள்ளடக்கத்தை இனி திருத்த முடியாது. அடுக்கை ராஸ்டராக்கவா? This layer must be rasterized before proceeding. தொடர்வதற்கு முன் இந்த அடுக்கு ராஸ்டராக்கப்பட வேண்டும். Color Picker (Background Color) நிறத் தேர்வி (பின்னணி நிறம்) diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index ef34287b6bf..83f23d4fc1c 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -176,8 +176,8 @@ Trap… กับดัก… # Layer - Layer via Copy เลเยอร์ผ่านการคัดลอก - Layer via Cut เลเยอร์ผ่านการตัด + Layer Via Copy เลเยอร์ผ่านการคัดลอก + Layer Via Cut เลเยอร์ผ่านการตัด Duplicate Layer… ทำซ้ำเลเยอร์… Duplicate… ทำสำเนา… Delete ลบ @@ -4472,7 +4472,7 @@ curves Smooth เรียบเนียน The file could not be saved. ไม่สามารถบันทึกไฟล์ได้ Thickness ความหนา This Layer: เลเยอร์นี้: - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? สมาร์ทออบเจ็กต์นี้ต้องได้รับการแรสเตอร์ก่อนดำเนินการต่อ เนื้อหาจะไม่สามารถแก้ไขได้อีกต่อไป แรสเตอร์วัตถุอัจฉริยะหรือไม่ + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? สมาร์ทออบเจ็กต์นี้ต้องได้รับการแรสเตอร์ก่อนดำเนินการต่อ เนื้อหาจะไม่สามารถแก้ไขได้อีกต่อไป แรสเตอร์วัตถุอัจฉริยะหรือไม่ This adjustment ({key}) is kept as imported and can't be edited yet. การปรับเปลี่ยนนี้ ({key}) ถูกเก็บไว้เป็นการนำเข้าและยังไม่สามารถแก้ไขได้ This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? เลเยอร์เติมนี้จะต้องถูกแรสเตอร์ก่อนดำเนินการต่อ เนื้อหาที่กรอกจะไม่สามารถแก้ไขได้อีกต่อไป แรสเตอร์เลเยอร์หรือไม่? This layer must be rasterized before proceeding. เลเยอร์นี้จะต้องถูกแรสเตอร์ก่อนดำเนินการต่อ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 857405e621b..6bcf9a3e8c6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -339,8 +339,8 @@ Artboard… Çalışma Yüzeyi… Artboard from Group… Gruptan Çalışma Yüzeyi… Artboard from Layers… Katmanlardan Çalışma Yüzeyi… - Layer via Copy Kopyalama Yoluyla Katman - Layer via Cut Kesim Yoluyla Katman + Layer Via Copy Kopyalama Yoluyla Katman + Layer Via Cut Kesim Yoluyla Katman Layer From Background… Arka Plandan Katman… Layer from Background… Arka Plandan Katman… Background From Layer Katmandan Arka Plan @@ -4337,6 +4337,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Her kanal için farklı bir belge seçin. A picked document is no longer open. Seçilen belgelerden biri artık açık değil. A picked document is no longer a grayscale document of the same size. Seçilen belgelerden biri artık aynı boyutta gri tonlamalı bir belge değil. + Netscape Gray Netscape Grisi # Move tool options bar (#11381) Align top edges Üst kenarları hizala Align vertical centers Dikey merkezleri hizala @@ -4346,6 +4347,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Üzerine gelince katman sınırlarını göster Expand layer group on click Tıklayınca katman grubunu genişlet Show hover bounds from Layers Katmanlar'dan üzerine gelme sınırlarını göster + Saved Camera Raw settings to {file} Camera Raw ayarları {file} dosyasına kaydedildi + Save the settings to each raw's sidecar without opening it Ayarları her raw dosyasının yan dosyasına kaydet, açmadan + No settings saved: these raws can't keep a sidecar here Hiçbir şey kaydedilmedi: bu raw dosyaları burada yan dosyaya sahip olamaz + Some settings could not be saved; the raws stay open Bazı ayarlar kaydedilemedi; raw dosyaları açık kalıyor + Nothing to save: these raws are at their default settings Kaydedilecek bir şey yok: bu raw dosyaları varsayılan ayarlarda # Artboard tool options bar (#11385) Distribute Widths Genişlikleri Dağıt Distribute Heights Yükseklikleri Dağıt diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a32744d5871..4b57f9c8f0e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -348,8 +348,8 @@ Duotone… Дуплекс… Duotone Дуплекс Trap… Трепінг… - Layer via Copy Шар через копіювання - Layer via Cut Шар через вирізання + Layer Via Copy Шар через копіювання + Layer Via Cut Шар через вирізання Duplicate Layer… Дублювати шар… Duplicate… Дублювати… Delete Видалити @@ -4157,7 +4157,7 @@ curves Smooth Згладити Color Picker (Custom Canvas Color) Вибір кольору (власний колір полотна) This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Щоб продовжити, потрібно растеризувати цей текстовий шар. Після цього його текст більше не можна буде редагувати. Растеризувати текст? This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Щоб продовжити, потрібно растеризувати цей шар фігури. Після цього його більше не можна буде редагувати як фігуру. Растеризувати фігуру? - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Щоб продовжити, потрібно растеризувати цей смартоб’єкт. Після цього його вміст більше не можна буде редагувати. Растеризувати смартоб’єкт? + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? Щоб продовжити, потрібно растеризувати цей смартоб’єкт. Після цього його вміст більше не можна буде редагувати. Растеризувати смартоб’єкт? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Щоб продовжити, потрібно растеризувати цей шар заливки. Після цього його заливку більше не можна буде редагувати. Растеризувати шар? Demosaic Демозаїк How the camera's color filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Як відновлюється мозаїка кольорового фільтра камери. Застосовується під час відкриття файлу; попередній перегляд показує метод, з яким файл було відкрито. @@ -4317,6 +4317,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Виберіть окремий документ для кожного каналу. A picked document is no longer open. Один із вибраних документів більше не відкрито. A picked document is no longer a grayscale document of the same size. Один із вибраних документів більше не є документом у градаціях сірого того самого розміру. + Netscape Gray Сірий Netscape # Move tool options bar (#11381) Align top edges Вирівняти верхні краї Align vertical centers Вирівняти центри по вертикалі @@ -4326,6 +4327,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Показувати межі шару під час наведення Expand layer group on click Розгортати групу шарів за клацанням Show hover bounds from Layers Показувати межі під час наведення з панелі «Шари» + Saved Camera Raw settings to {file} Налаштування Camera Raw збережено в {file} + Save the settings to each raw's sidecar without opening it Зберегти налаштування в супровідний файл кожного raw, не відкриваючи його + No settings saved: these raws can't keep a sidecar here Нічого не збережено: ці raw тут не можуть мати супровідного файлу + Some settings could not be saved; the raws stay open Не вдалося зберегти деякі налаштування; raw залишаються відкритими + Nothing to save: these raws are at their default settings Нічого зберігати: ці raw мають типові налаштування # Artboard tool options bar (#11385) Distribute Widths Розподілити ширину Distribute Heights Розподілити висоту diff --git a/crates/ui-egui/src/i18n/ur.tsv b/crates/ui-egui/src/i18n/ur.tsv index 4d5eaf79613..a6cadc21143 100644 --- a/crates/ui-egui/src/i18n/ur.tsv +++ b/crates/ui-egui/src/i18n/ur.tsv @@ -311,8 +311,8 @@ Artboard… آرٹ بورڈ… Artboard from Group… گروپ سے آرٹ بورڈ… Artboard from Layers… تہوں سے آرٹ بورڈ… - Layer via Copy کاپی کے ذریعے تہہ - Layer via Cut کٹ کے ذریعے تہہ + Layer Via Copy کاپی کے ذریعے تہہ + Layer Via Cut کٹ کے ذریعے تہہ Layer From Background… پس منظر سے تہہ… Layer from Background… پس منظر سے تہہ… Background from Layer تہہ سے پس منظر @@ -4018,7 +4018,7 @@ layerLabel No Color بلا رنگ Scroll menu down مینیو نیچے اسکرول کریں This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? آگے بڑھنے سے پہلے اس متن تہہ کو ریسٹرائز کرنا ضروری ہے۔ اس کا متن مزید قابل ترمیم نہیں رہے گا۔ کیا قسم کو ریسٹرائز کریں؟ This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? آگے بڑھنے سے پہلے اس شکل تہہ کو ریسٹرائز کرنا ضروری ہے۔ یہ مزید شکل کے طور پر قابل ترمیم نہیں رہے گی۔ کیا شکل کو ریسٹرائز کریں؟ - This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? آگے بڑھنے سے پہلے اس سمارٹ آبجیکٹ کو ریسٹرائز کرنا ضروری ہے۔ اس کا مواد مزید قابل ترمیم نہیں رہے گا۔ کیا سمارٹ آبجیکٹ کو ریسٹرائز کریں؟ + This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object? آگے بڑھنے سے پہلے اس سمارٹ آبجیکٹ کو ریسٹرائز کرنا ضروری ہے۔ اس کا مواد مزید قابل ترمیم نہیں رہے گا۔ کیا سمارٹ آبجیکٹ کو ریسٹرائز کریں؟ This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? آگے بڑھنے سے پہلے اس بھرائی تہہ کو ریسٹرائز کرنا ضروری ہے۔ اس کا بھرائی مواد مزید قابل ترمیم نہیں رہے گا۔ کیا تہہ کو ریسٹرائز کریں؟ This layer must be rasterized before proceeding. آگے بڑھنے سے پہلے اس تہہ کو ریسٹرائز کرنا ضروری ہے۔ Color Picker (Background Color) رنگ چنندہ (پس منظر کا رنگ) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4b700c776ff..0651f9000b6 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -458,8 +458,8 @@ Layer from Background… Lớp từ nền… Layer Mask Mặt nạ lớp Layer Style Kiểu lớp - Layer via Copy Lớp bằng sao chép - Layer via Cut Lớp bằng cắt + Layer Via Copy Lớp bằng sao chép + Layer Via Cut Lớp bằng cắt Layer… Lớp… Left Edges Cạnh trái Levels… Mức… @@ -4111,6 +4111,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. Chọn một tài liệu khác cho mỗi kênh. A picked document is no longer open. Một tài liệu đã chọn không còn mở. A picked document is no longer a grayscale document of the same size. Một tài liệu đã chọn không còn là tài liệu thang xám cùng kích thước. + Netscape Gray Xám Netscape # Move tool options bar (#11381) Align top edges Căn các cạnh trên Align vertical centers Căn các tâm dọc @@ -4120,6 +4121,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover Hiện ranh giới lớp khi di chuột Expand layer group on click Mở rộng nhóm lớp khi nhấp Show hover bounds from Layers Hiện ranh giới khi di chuột từ bảng Lớp + Saved Camera Raw settings to {file} Đã lưu cài đặt Camera Raw vào {file} + Save the settings to each raw's sidecar without opening it Lưu thiết lập vào tệp sidecar của từng ảnh raw mà không mở + No settings saved: these raws can't keep a sidecar here Không lưu được gì: các ảnh raw này không thể có tệp sidecar ở đây + Some settings could not be saved; the raws stay open Không thể lưu một số cài đặt; các ảnh raw vẫn mở + Nothing to save: these raws are at their default settings Không có gì để lưu: các ảnh raw này đang ở cài đặt mặc định # Artboard tool options bar (#11385) Distribute Widths Phân bố chiều rộng Distribute Heights Phân bố chiều cao diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 09447e60163..3d8f8659f29 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -423,8 +423,8 @@ Artboard… 画板… Artboard from Group… 从组创建画板… Artboard from Layers… 从图层创建画板… - Layer via Copy 通过复制创建图层 - Layer via Cut 通过剪切创建图层 + Layer Via Copy 通过复制创建图层 + Layer Via Cut 通过剪切创建图层 Layer From Background… 从背景创建图层… Layer from Background… 从背景创建图层… Background From Layer 从图层创建背景 @@ -3298,6 +3298,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. 请为每个通道选择不同的文档。 A picked document is no longer open. 所选的一个文档已不再打开。 A picked document is no longer a grayscale document of the same size. 所选的一个文档已不再是同样大小的灰度文档。 + Netscape Gray Netscape 灰色 # Move tool options bar (#11381) Align top edges 顶端对齐 Align vertical centers 垂直居中对齐 @@ -3307,6 +3308,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover 悬停时显示图层边界 Expand layer group on click 单击时展开图层组 Show hover bounds from Layers 显示来自图层面板的悬停边界 + Saved Camera Raw settings to {file} 已将 Camera Raw 设置保存到 {file} + Save the settings to each raw's sidecar without opening it 将设置保存到每个 Raw 的附属文件,不打开它 + No settings saved: these raws can't keep a sidecar here 未保存任何内容:这些 Raw 在此处无法拥有附属文件 + Some settings could not be saved; the raws stay open 部分设置无法保存;Raw 保持打开 + Nothing to save: these raws are at their default settings 无需保存:这些 Raw 均为默认设置 # Artboard tool options bar (#11385) Distribute Widths 分布宽度 Distribute Heights 分布高度 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6ff035908f8..3eb32cc6ef1 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -422,8 +422,8 @@ Artboard… 工作區域… Artboard from Group… 從群組建立工作區域… Artboard from Layers… 從圖層建立工作區域… - Layer via Copy 拷貝的圖層 - Layer via Cut 剪下的圖層 + Layer Via Copy 拷貝的圖層 + Layer Via Cut 剪下的圖層 Layer From Background… 從背景建立圖層… Layer from Background… 從背景建立圖層… Background from Layer 從圖層建立背景 @@ -3255,6 +3255,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Pick a different document for each channel. 請為每個色版選擇不同的文件。 A picked document is no longer open. 所選的一個文件已不再開啟。 A picked document is no longer a grayscale document of the same size. 所選的一個文件已不再是相同大小的灰階文件。 + Netscape Gray Netscape 灰色 # Move tool options bar (#11381) Align top edges 對齊頂端 Align vertical centers 垂直置中對齊 @@ -3264,6 +3265,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Show layer bounds on hover 滑過時顯示圖層邊界 Expand layer group on click 按一下時展開圖層群組 Show hover bounds from Layers 顯示來自圖層面板的滑過邊界 + Saved Camera Raw settings to {file} 已將 Camera Raw 設定儲存到 {file} + Save the settings to each raw's sidecar without opening it 將設定儲存到每個 Raw 的附屬檔案,不開啟它 + No settings saved: these raws can't keep a sidecar here 未儲存任何內容:這些 Raw 在此處無法擁有附屬檔案 + Some settings could not be saved; the raws stay open 部分設定無法儲存;Raw 保持開啟 + Nothing to save: these raws are at their default settings 無需儲存:這些 Raw 均為預設設定 # Artboard tool options bar (#11385) Distribute Widths 均分寬度 Distribute Heights 均分高度 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 93103b34a36..5b89a68d20e 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -182,6 +182,9 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("type-mask-vertical", include_bytes!("../../../assets/icons/type-mask-vertical.svg")), ("undo-2", include_bytes!("../../../assets/icons/undo-2.svg")), ("unlink", include_bytes!("../../../assets/icons/unlink.svg")), + ("vertical-align-bottom", include_bytes!("../../../assets/icons/vertical-align-bottom.svg")), + ("vertical-align-middle", include_bytes!("../../../assets/icons/vertical-align-middle.svg")), + ("vertical-align-top", include_bytes!("../../../assets/icons/vertical-align-top.svg")), ("wand", include_bytes!("../../../assets/icons/wand.svg")), ("wand-sparkles", include_bytes!("../../../assets/icons/wand-sparkles.svg")), ("x", include_bytes!("../../../assets/icons/x.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c102064a7f1..0a40f7b0343 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -179,6 +179,7 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::EllipseShape => "circle", Tool::Triangle => "triangle", Tool::Polygon => "pentagon", + Tool::Star => "star", Tool::Line => "slash", Tool::CustomShape => "cloud", } diff --git a/crates/ui-egui/src/input/blend_keys.rs b/crates/ui-egui/src/input/blend_keys.rs index 5a126268b79..ebb018b381d 100644 --- a/crates/ui-egui/src/input/blend_keys.rs +++ b/crates/ui-egui/src/input/blend_keys.rs @@ -32,8 +32,7 @@ pub enum Pick { Mode(BlendMode), /// Painting tools only: paint transparency. Clear, - /// Painting tools only: paint behind the layer's pixels. Not implemented yet, so ⇧⌥Q is taken - /// (it must not reach another binding) and does nothing. + /// Brush and Pencil: paint behind the layer's pixels. Behind, } @@ -203,10 +202,19 @@ fn set_tool_mode(app: &mut PhotocraftApp, mode: Option) { _ => {} } crate::paint_mouse::sync_tool_brush(app); + // Behind is one of the painting modes in the reference application: picking any other mode + // (Clear too) leaves it, in the same one command (#11896). With Behind off, as before. + let mut brush = serde_json::Map::new(); if let Some(m) = mode && let Ok(m) = serde_json::to_value(m) { - let _ = app.run("tools.setBrush", json!({ "brush": { "mode": m } })); + brush.insert("mode".into(), m); + } + if app.session.tools.brush.behind { + brush.insert("behind".into(), json!(false)); + } + if !brush.is_empty() { + let _ = app.run("tools.setBrush", json!({ "brush": brush })); } } @@ -265,7 +273,23 @@ pub fn pick_mode(app: &mut PhotocraftApp, pick: Pick) { let want = match pick { Pick::Mode(m) => Some(m), Pick::Clear => None, - Pick::Behind => return, + Pick::Behind => { + let item = crate::brush_mode_menu::Item::Behind; + let tool = app.ui.tool; + if app.session.active().is_none() || !crate::brush_mode_menu::enabled(app, item) { + return; + } + let Some((id, params)) = crate::brush_mode_menu::engine_call(app, tool, item) else { return }; + crate::paint_mouse::sync_tool_brush(app); + if app.run(id, params).is_ok() { + match tool { + Tool::Brush => app.ui.tool_options.brush_clear = false, + Tool::Pencil => app.ui.tool_options.pencil_clear = false, + _ => {} + } + } + return; + } }; match target(app.ui.tool) { Target::Tool(modes) if modes.contains(&want) => set_tool_mode(app, want), @@ -280,3 +304,11 @@ pub fn pick_mode(app: &mut PhotocraftApp, pick: Pick) { #[cfg(test)] mod tests; + +#[cfg(test)] +#[path = "../brush_behind_key_tests.rs"] +mod brush_behind_key_tests; + +#[cfg(test)] +#[path = "../brush_blend_keys_behind_tests.rs"] +mod brush_blend_keys_behind_tests; diff --git a/crates/ui-egui/src/input/opacity_keys.rs b/crates/ui-egui/src/input/opacity_keys.rs index 7f02c0c77b9..55044a26f13 100644 --- a/crates/ui-egui/src/input/opacity_keys.rs +++ b/crates/ui-egui/src/input/opacity_keys.rs @@ -1,8 +1,9 @@ //! Number keys set opacity (#352): 1 = 10% … 9 = 90%, 0 = 100%; two digits -//! typed quickly set an exact value (4 then 5 = 45%, 0 then 0 = 0%). With the Brush, Pencil or +//! typed quickly set an exact value (4 then 5 = 45%; 0 then 0 = 100% for the Brush and the Pencil, #11812). With the Brush, Pencil or //! Eraser they set the brush's Opacity (⇧: Flow; swapped while airbrush build-up is on); with the Gradient or Paint Bucket their //! Opacity; with tools that have no opacity of their own (Move, the selection tools, Type…) the -//! active layer's Opacity (⇧: Fill). Tools whose number is a strength or exposure are left alone. +//! active layer's Opacity (⇧: Fill); with the Background Eraser its Tolerance (#11907). Tools whose +//! number is a strength or exposure are left alone. use egui::{Event, Key}; use serde_json::json; @@ -25,6 +26,8 @@ enum Target { }, /// The Gradient / Paint Bucket opacity option. ToolOpacity, + /// The Background Eraser's Tolerance option. + BgTolerance, /// The active layer's opacity (⇧: fill). Layer, None, @@ -35,6 +38,7 @@ fn target(tool: Tool) -> Target { Tool::Brush | Tool::Eraser | Tool::CloneStamp | Tool::HistoryBrush => Target::Brush { flow: true }, Tool::Pencil => Target::Brush { flow: false }, Tool::Gradient | Tool::PaintBucket => Target::ToolOpacity, + Tool::BackgroundEraser => Target::BgTolerance, // Their options-bar number is a strength, exposure or tolerance, not this opacity. t if t.is_brushlike() || t == Tool::MagicEraser => Target::None, _ => Target::Layer, @@ -107,9 +111,13 @@ fn apply(app: &mut PhotocraftApp, percent: f32, shift: bool) { let shift = shift ^ (build_up && matches!(target(tool), Target::Brush { flow: true })); match (target(app.ui.tool), shift) { (Target::Brush { .. }, false) => { - // Clone Stamp's and History Brush's Opacity fields (and their strokes) start at 1%, so - // `00` sets the 1% they actually paint with rather than showing a 0% they can't do. - let v = if matches!(tool, Tool::CloneStamp | Tool::HistoryBrush) { v.max(0.01) } else { v }; + // The painting tools' Opacity fields (and `tools.setBrush`) start at 1%, so `00` sets the + // 1% they actually paint with rather than a 0% they can't do. The Brush's and the + // Pencil's `00` is 100%, as in Photoshop (#11812); only `00` gives 0 here. + let v = match tool { + Tool::Brush | Tool::Pencil if v <= 0.0 => 1.0, + _ => v.max(0.01), + }; let _ = app.run("tools.setBrush", json!({ "brush": { "opacity": v } })); } // The options bar's Flow field starts at 1%. @@ -117,6 +125,8 @@ fn apply(app: &mut PhotocraftApp, percent: f32, shift: bool) { let _ = app.run("tools.setBrush", json!({ "brush": { "flow": v.max(0.01) } })); } (Target::ToolOpacity, false) => app.ui.tool_options.fill_opacity = percent, + // The Tolerance field starts at 1%; `00` (0%) keeps today's value rather than guessing. + (Target::BgTolerance, false) if percent >= 1.0 => app.ui.tool_options.bg_tolerance = percent, (Target::Layer, _) => { // The locked Background has no opacity or fill. let Some(st) = app.session.active() else { return }; @@ -163,7 +173,7 @@ mod tests { press(&mut a, 0, false, 9000.0); assert_eq!(a.session.tools.brush.opacity, 1.0, "0 = 100%"); press(&mut a, 0, false, 9100.0); - assert_eq!(a.session.tools.brush.opacity, 0.0, "00 = 0%"); + assert_eq!(a.session.tools.brush.opacity, 1.0, "00 = 100%"); press(&mut a, 2, true, 20000.0); assert!((a.session.tools.brush.flow - 0.2).abs() < 1e-6); press(&mut a, 0, true, 30000.0); @@ -224,10 +234,6 @@ mod tests { press(&mut a, 0, false, 100.0); assert!((a.session.tools.brush.opacity - 0.01).abs() < 1e-6, "{tool:?}"); } - let mut a = app(Tool::Brush); - press(&mut a, 0, false, 0.0); - press(&mut a, 0, false, 100.0); - assert!(a.session.tools.brush.opacity.abs() < 1e-6, "the Brush itself still goes to 0%"); } #[test] diff --git a/crates/ui-egui/src/input/shortcuts.rs b/crates/ui-egui/src/input/shortcuts.rs index c3ef3778741..d525dc2a0b3 100644 --- a/crates/ui-egui/src/input/shortcuts.rs +++ b/crates/ui-egui/src/input/shortcuts.rs @@ -907,8 +907,9 @@ fn handle_escape_and_view_keys(app: &mut PhotocraftApp, ctx: &egui::Context) { crate::opacity_keys::handle(app, ctx); } -/// Selects a tool from its letter key. Pressing the key of the current group cycles within it; with -/// the Shift-switch preference only ⇧+key cycles. Returns true when a tool key was handled. +/// Selects a tool from its letter key. Entering a group uses its remembered eligible tool or the +/// first eligible tool in toolbar order. Pressing the key of the current group cycles within it. +/// With the Shift-switch preference, only ⇧+key cycles. Returns true when a tool key was handled. fn select_tool_by_key(app: &mut PhotocraftApp, ctx: &egui::Context, pressed: &impl Fn(Key) -> bool) -> bool { let shift_switch = app.session.prefs().tools.use_shift_key_for_tool_switch; for t in Tool::ALL { @@ -920,15 +921,20 @@ fn select_tool_by_key(app: &mut PhotocraftApp, ctx: &egui::Context, pressed: &im if cycle_shift || pressed(k) { let before = app.ui.tool; let mut group: Vec = Tool::ALL.iter().copied().filter(|x| x.key() == t.key() && crate::tool_shortcuts::uses_default(app, *x)).collect(); - // Shift+key (the cycle and the fallback to the group's first tool) uses the toolbar - // flyout's order; plain keys keep the `Tool::ALL` order. + // Shift+key cycles in toolbar flyout order. if cycle_shift { group.sort_by_key(|x| crate::panels::toolbar::toolbar_position(*x)); } 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 => app.ui.group_tools.iter().copied().find(|last| group.contains(last)).unwrap_or(group[0]), + None => app + .ui + .group_tools + .iter() + .copied() + .find(|last| group.contains(last)) + .unwrap_or_else(|| group.iter().copied().min_by_key(|tool| crate::panels::toolbar::toolbar_position(*tool)).unwrap_or(t)), }; crate::tool_memory::ended_by_tool(app); // `consume_key` ignores extra ⇧ and ⌥: a ⇧/⌥ switch is never spring-loaded. @@ -946,3 +952,7 @@ fn select_tool_by_key(app: &mut PhotocraftApp, ctx: &egui::Context, pressed: &im #[cfg(test)] mod tests; + +#[cfg(test)] +#[path = "tool_key_default_tests.rs"] +mod tool_key_default_tests; diff --git a/crates/ui-egui/src/input/tool_key_default_tests.rs b/crates/ui-egui/src/input/tool_key_default_tests.rs new file mode 100644 index 00000000000..5b846d5e7f2 --- /dev/null +++ b/crates/ui-egui/src/input/tool_key_default_tests.rs @@ -0,0 +1,89 @@ +use super::{Key, Modifiers, PhotocraftApp, Tool, handle}; +use crate::panels::toolbar::TOOL_SECTIONS; +use crate::spring_tools::key_of; +use std::collections::HashSet; + +fn app() -> PhotocraftApp { + PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()) +} + +fn press(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { + let raw = egui::RawInput { + events: vec![ + egui::Event::ModifiersChanged(modifiers), + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ], + ..Default::default() + }; + ctx.begin_pass(raw); + handle(app, ctx); + ctx.end_pass().textures_delta.clear(); +} + +/// A fresh app enters each default letter group at its first toolbar tool under either preference. +#[test] +fn fresh_tool_letters_pick_the_first_tool_in_toolbar_order() { + for shift_switch in [true, false] { + let mut checked = HashSet::new(); + for tool in TOOL_SECTIONS.iter().flat_map(|section| section.iter()).flat_map(|slot| slot.iter()).copied() { + let Some(key) = key_of(tool.key()) else { continue }; + if !checked.insert(key) { + continue; + } + + let mut app = app(); + app.session.edit_prefs(|prefs| prefs.tools.use_shift_key_for_tool_switch = shift_switch); + if app.ui.tool.key() == tool.key() { + app.ui.tool = Tool::Brush; + } + press(&mut app, &egui::Context::default(), key, Modifiers::NONE); + assert_eq!(app.ui.tool, tool, "{key:?}, Shift switch: {shift_switch}"); + } + assert_eq!(checked, Tool::ALL.into_iter().filter_map(|tool| key_of(tool.key())).collect::>()); + } +} + +/// A remembered Magic Wand takes precedence over the fresh W default, even when hidden. +#[test] +fn plain_w_returns_to_the_remembered_magic_wand() { + for hidden in [false, true] { + let mut app = app(); + let ctx = egui::Context::default(); + if hidden { + app.session.edit_prefs(|prefs| prefs.toolbar.hidden = vec!["MagicWand".to_owned()]); + } + app.ui.tool = Tool::MagicWand; + press(&mut app, &ctx, Key::B, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::Brush); + press(&mut app, &ctx, Key::W, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::MagicWand, "hidden: {hidden}"); + } +} + +/// Shift+W follows the flyout order and wraps. With the default preference, plain W keeps +/// the current tool when it already belongs to the group. +#[test] +fn shift_w_cycles_in_toolbar_order() { + let mut app = app(); + let ctx = egui::Context::default(); + app.ui.tool = Tool::ObjectSelection; + + for expected in [Tool::QuickSelection, Tool::MagicWand, Tool::ObjectSelection] { + press(&mut app, &ctx, Key::W, Modifiers::SHIFT); + assert_eq!(app.ui.tool, expected); + } + press(&mut app, &ctx, Key::W, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::ObjectSelection); +} + +#[test] +fn remapped_first_tool_is_skipped_by_its_default_letter() { + let mut app = app(); + let ctx = egui::Context::default(); + app.run("prefs.set", serde_json::json!({"path": "shortcuts.tools.select.objectSelection", "value": "F6"})).unwrap(); + press(&mut app, &ctx, Key::W, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::QuickSelection); + press(&mut app, &ctx, Key::F6, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::ObjectSelection); +} diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index c4ff5397c21..7459f54be6a 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -286,11 +286,38 @@ pub fn start_open( // once. A preflight keeps the slot and set order in its memory prompt. Started::Done(v) => finish_open(app, name, path.as_deref(), &v, slot, order), Started::Job(job) => { - app.jobs.opens.push(OpenTab { job, name: name.to_string(), path, slot, order }); - app.jobs.focus = Some(job); - app.ui.chrome.home = None; - app.ui.status = crate::i18n::fmt(tl!("Opening {name}…"), &[("name", name)]); - app.ui.status_error = false; + track_open(app, OpenTab { job, name: name.to_string(), path, slot, order }); + Ok(()) + } + } +} + +/// A file opening in the background gets its tab: focused, with the status line naming it. +fn track_open(app: &mut PhotocraftApp, tab: OpenTab) { + app.ui.status = crate::i18n::fmt(tl!("Opening {name}…"), &[("name", &tab.name)]); + app.jobs.focus = Some(tab.job); + app.jobs.opens.push(tab); + app.ui.chrome.home = None; + app.ui.status_error = false; +} + +/// File › Open as Smart Object… for one file: `file.openAsSmartObject` as a tracked open job, so +/// the result goes through File › Open's completion ([`finish_open`]: the profile prompt, the +/// Camera Raw open dialog for a raw, the import warnings). The document is untitled, so +/// Open Recent is untouched. Inline (background jobs off, or the web) it finishes at once. +pub(crate) fn start_open_as_smart_object(app: &mut PhotocraftApp, path: &str, order: Option) -> Result<(), String> { + let name = crate::file_open::display_name(path); + // `confirmDamage`: a damaged PSD waits for the user's answer, as in File › Open. + let params = json!({"path": path, "confirmDamage": true}); + let started = if app.background_jobs { + app.session.start("file.openAsSmartObject", params).map_err(|e| e.to_string())? + } else { + Started::Done(app.session.execute("file.openAsSmartObject", params).map_err(|e| e.to_string())?) + }; + match started { + Started::Done(v) => finish_open(app, &name, None, &v, None, order), + Started::Job(job) => { + track_open(app, OpenTab { job, name, path: None, slot: None, order }); Ok(()) } } @@ -608,7 +635,7 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { finish_save(app, ctx, save, &e.label, e.outcome); return; } - if e.command == OPEN_JOB { + if e.command == OPEN_JOB || app.jobs.opens.iter().any(|o| o.job == e.id) { let Some(i) = app.jobs.opens.iter().position(|o| o.job == e.id) else { return }; let tab = app.jobs.opens.remove(i); match e.outcome { @@ -703,6 +730,7 @@ fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Valu order, question, policy_applied, + smart_object: v.get("smartObject").and_then(Value::as_bool).unwrap_or(false), }; // Held back for the profile prompt (Ask When Opening). if let Some(id) = v.get("held").and_then(Value::as_u64) { diff --git a/crates/ui-egui/src/layer_via_label_tests.rs b/crates/ui-egui/src/layer_via_label_tests.rs new file mode 100644 index 00000000000..b8a20c3b277 --- /dev/null +++ b/crates/ui-egui/src/layer_via_label_tests.rs @@ -0,0 +1,63 @@ +//! "Layer Via Copy" and "Layer Via Cut" carry a capital V in every menu, as in the reference +//! application (#12357), and every translation moved with the key. + +use crate::canvas::canvas_tool_menu::SELECTION_MENU; +use crate::menu_catalog::CATALOG; + +const NEW: [(&str, &str); 2] = [("Layer Via Copy", "layer.new.layerViaCopy"), ("Layer Via Cut", "layer.new.layerViaCut")]; +const OLD: [&str; 2] = ["Layer via Copy", "Layer via Cut"]; + +#[test] +fn menu_bar_rows_use_the_capital_v() { + for (label, id) in NEW { + let row = CATALOG.iter().find(|r| r.3 == id).expect("catalog row"); + assert_eq!(row.1, label); + } + assert!(CATALOG.iter().all(|r| !OLD.contains(&r.1))); +} + +#[test] +fn command_labels_use_the_capital_v() { + let specs = photocraft_engine::cmd::command_specs(); + for (label, id) in NEW { + let spec = specs.iter().find(|s| s.id == id).expect("command spec"); + assert_eq!(spec.label, label); + } +} + +#[test] +fn selection_tools_canvas_menu_uses_the_capital_v() { + for (label, id) in NEW { + assert!(SELECTION_MENU.iter().flatten().any(|r| *r == (label, id)), "{label} row"); + } + assert!(SELECTION_MENU.iter().flatten().all(|r| !OLD.contains(&r.0))); +} + +#[test] +fn every_locale_file_keeps_its_translation_under_the_new_keys() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/i18n"); + let mut checked = 0; + for entry in std::fs::read_dir(dir).expect("i18n dir") { + let path = entry.expect("entry").path(); + if path.extension().and_then(|e| e.to_str()) != Some("tsv") { + continue; + } + let text = std::fs::read_to_string(&path).expect("tsv"); + let keys: Vec<&str> = text.lines().filter_map(|l| l.split('\t').nth(1)).collect(); + let name = path.display(); + assert!(OLD.iter().all(|o| !keys.contains(o)), "{name} still has an old key"); + // Hindi has no entries for these menus yet; every other file has both. + if keys.contains(&"Layer Via Copy") || keys.contains(&"Layer Via Cut") { + assert!(NEW.iter().all(|(n, _)| keys.contains(n)), "{name} lacks a new key"); + checked += 1; + } + } + assert!(checked >= 30, "only {checked} locale files carry the keys"); +} + +#[test] +fn a_translation_is_found_under_the_new_key() { + let de = crate::i18n::Lang::from_code("de").expect("German"); + assert_eq!(crate::i18n::tr(de, "Layer Via Copy"), "Ebene durch Kopieren"); + assert_eq!(crate::i18n::tr(de, "Layer Via Cut"), "Ebene durch Ausschneiden"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f19ec41da94..08bb90fa780 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -276,8 +276,10 @@ use tools::align_bar; pub use tools::analysis_ui; pub use tools::anchor_tools; pub use tools::artboard_tool; +pub use tools::bg_eraser_popup; pub(crate) use tools::brush_cursor; pub use tools::brush_picker; +pub use tools::brush_refusal; pub use tools::brush_resize; use tools::cam_transform; pub use tools::cas_transform; @@ -563,6 +565,8 @@ pub struct PhotocraftApp { /// makes shortcut- and dialog-triggered commands pass the same policy as /// direct control calls. pub(crate) automation_input: bool, + /// The direct control-command route keeps Actions playback synchronous and dialog-free. + pub(crate) programmatic_playback: bool, /// How many `run_command` calls are on the stack. Only the outermost one writes the frame's /// command stage: a nested run (`file.save` → `layer.smartObjects.saveContents`, /// `actions.play` → its steps) is already inside the outer call's time. @@ -604,9 +608,9 @@ pub struct PhotocraftApp { pub(crate) gradient: gradient_ui::LiveGradient, /// Filter › Camera Raw Filter dialog (camera_raw_ui). pub(crate) camera_raw: Option, - /// A raw file just opened interactively, waiting for the open-time Camera Raw dialog (shown - /// on the next frame, which has the egui context). - pub(crate) pending_raw_open: Option, + /// Raws just opened interactively, waiting in the open-time Camera Raw dialog (shown on the + /// next frame, which has the egui context); several share it in a filmstrip. + pub(crate) raw_shelf: Vec, /// The open-time re-develops (or their Camera Raw steps) running in the background, one per /// raw being opened. pub(crate) raw_redevelop: Vec, @@ -727,6 +731,8 @@ pub struct PhotocraftApp { pub(crate) prompt_order: std::collections::VecDeque, /// A brush or gradient file waiting on the group-name prompt (see `preset_group_prompt`). pub(crate) held_preset_import: Option, + pub(crate) held_record_again: Option, + pub(crate) held_insert_menu: Option, /// 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`). @@ -771,10 +777,14 @@ impl SyntheticTarget { #[cfg(test)] mod artboard_add_mode_tests; #[cfg(test)] +mod background_eraser_tolerance_field_tests; +#[cfg(test)] mod brush_options_tooltips_tests; #[cfg(test)] mod brush_picker_escape_tests; #[cfg(test)] +mod patch_shift_constrain_tests; +#[cfg(test)] mod shape_bounds_box_tests; #[cfg(test)] @@ -858,6 +868,12 @@ mod transform_type_tests; #[cfg(test)] mod transform_reference_point_tests; +#[cfg(test)] +mod transform_relative_position_tests; + +#[cfg(test)] +mod star_tool_tests; + #[cfg(test)] mod transform_several_tests; @@ -872,6 +888,9 @@ mod save_identity_tests; #[cfg(test)] mod move_auto_select_tests; +#[cfg(test)] +mod move_target_default_tests; + #[cfg(test)] mod move_history_names_tests; #[cfg(test)] @@ -888,6 +907,9 @@ mod color_tab_theme_tests; #[cfg(test)] mod new_layer_button_tests; +#[cfg(test)] +mod layer_via_label_tests; + #[cfg(test)] mod smart_filter_rows_tests; @@ -969,6 +991,8 @@ mod status_bar_tests; #[cfg(test)] mod stroke_timing_tests; +#[cfg(test)] +mod patch_tool_history_names_tests; #[cfg(test)] mod polygon_lasso_step_snap_tests; #[cfg(test)] @@ -997,12 +1021,19 @@ mod pen_path_ownership_tests; #[cfg(test)] mod artboard_options_bar_tests; #[cfg(test)] +mod brush_opacity_minimum_tests; +#[cfg(test)] mod doc_tab_menu_tests; #[cfg(test)] mod layers_empty_area_menu_tests; #[cfg(test)] +mod pencil_auto_erase_tooltip_tests; +#[cfg(test)] mod preferences_submenu_tests; #[cfg(test)] mod selection_move_step_tests; #[cfg(test)] mod view_guides_submenu_tests; + +#[cfg(test)] +mod background_eraser_digit_keys_tests; diff --git a/crates/ui-egui/src/menu_catalog.rs b/crates/ui-egui/src/menu_catalog.rs index 3838a70bcf1..c6e7ad82173 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 as Smart Object…", None, "file.openAsSmartObject"), (&["File"], "Open EXR Parts…", None, "file.openExrParts"), (&["File"], "---", None, "---"), (&["File"], "Close", Some("Cmd+W"), "file.close"), @@ -256,8 +257,8 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Layer", "New"], "Artboard from Layers…", None, "layer.new.artboardFromLayers"), (&["Layer", "New"], "Frame from Layers", None, "layer.new.frameFromLayers"), (&["Layer", "New"], "---", None, "---"), - (&["Layer", "New"], "Layer via Copy", Some("Cmd+J"), "layer.new.layerViaCopy"), - (&["Layer", "New"], "Layer via Cut", Some("Cmd+Shift+J"), "layer.new.layerViaCut"), + (&["Layer", "New"], "Layer Via Copy", Some("Cmd+J"), "layer.new.layerViaCopy"), + (&["Layer", "New"], "Layer Via Cut", Some("Cmd+Shift+J"), "layer.new.layerViaCut"), (&["Layer"], "Duplicate Layer…", None, "layer.duplicate"), (&["Layer", "Delete"], "Layer", None, "layer.delete"), (&["Layer", "Delete"], "Hidden Layers", None, "layer.delete.hiddenLayers"), diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index cd5a7bad5cc..9e2da7e5111 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -208,10 +208,16 @@ fn invoke_file_commands(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, // ⌘↩ / Ctrl+Enter (#306): load the path selected in the Paths panel (or the one being // drawn) as a selection. "path.toSelection" if params.get("name").is_none() => crate::vector_ui::path_to_selection(app, params.clone()), - // ⌘⇧↩ / ⌘⌥↩ load the same path, adding it to or subtracting it from the selection. - "path.addToSelection" | "path.subtractFromSelection" if params.get("name").is_none() => { + // ⌘⇧↩ / ⌘⌥↩ / ⇧⌥⌘↩ load the same path, adding it to, subtracting it from or intersecting + // it with the selection. + "path.addToSelection" | "path.subtractFromSelection" | "path.intersectWithSelection" if params.get("name").is_none() => { let mut p = params.as_object().cloned().unwrap_or_default(); - p.insert("mode".into(), json!(if id == "path.addToSelection" { "add" } else { "subtract" })); + let mode = match id { + "path.addToSelection" => "add", + "path.subtractFromSelection" => "subtract", + _ => "intersect", + }; + p.insert("mode".into(), json!(mode)); crate::vector_ui::path_to_selection(app, Value::Object(p)) } // Layer › Rename Layer from a menu starts in-place renaming in the Layers panel. @@ -451,6 +457,8 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: let dialog = crate::channel_options_ui::open_new_spot(app).ok_or("no document is open")?; Ok(json!({"dialog": dialog})) } + // Select › Find Layers: the Layers panel's Name filter (#10884). + "select.findLayers" => crate::layer_filter_ui::find::invoke(app, ctx, params), // Photoshop's "Select and Mask…" is the engine's select.refineEdge. // Select › Color Range… from the menu: the dialog (with params: the engine directly). "select.colorRange" if params.as_object().is_none_or(|o| o.is_empty()) => Ok(json!({"dialog": crate::color_range_ui::open(app)})), diff --git a/crates/ui-egui/src/menus/view_cmds/dialogs.rs b/crates/ui-egui/src/menus/view_cmds/dialogs.rs index 16cefbd87b3..b2b7d561580 100644 --- a/crates/ui-egui/src/menus/view_cmds/dialogs.rs +++ b/crates/ui-egui/src/menus/view_cmds/dialogs.rs @@ -345,6 +345,7 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para let dialog = |app: &mut PhotocraftApp, fields: Value, choices: Value| Some(Ok(json!({"dialog": form(app, id, label, fields, choices)}))); match id { "file.openAs" => Some(app.open_dialog_file()), + "file.openAsSmartObject" => Some(app.open_dialog_as_smart_object()), "file.openExrParts" => open_exr_parts_dialog(app), "file.saveACopy" => Some(save_a_copy(app)), "file.placeEmbedded" | "file.placeLinked" => { diff --git a/crates/ui-egui/src/menus/view_cmds/dispatch.rs b/crates/ui-egui/src/menus/view_cmds/dispatch.rs index f1e9b2770fe..aef68d2dd6a 100644 --- a/crates/ui-egui/src/menus/view_cmds/dispatch.rs +++ b/crates/ui-egui/src/menus/view_cmds/dispatch.rs @@ -223,6 +223,7 @@ fn wraps(id: &str) -> bool { matches!( id, "file.openAs" + | "file.openAsSmartObject" | "file.openExrParts" | "file.saveACopy" | "file.placeEmbedded" diff --git a/crates/ui-egui/src/mixer_blend_combination_tests.rs b/crates/ui-egui/src/mixer_blend_combination_tests.rs new file mode 100644 index 00000000000..2b4cd127824 --- /dev/null +++ b/crates/ui-egui/src/mixer_blend_combination_tests.rs @@ -0,0 +1,171 @@ +//! The Mixer Brush's Useful mixer brush combinations dropdown (#11946): between the Clean after +//! each stroke toggle and Wet, Custom then the reference's twelve entries in four groups; each +//! sets Wet, Load and Mix in one `tools.setBrush` change and leaves Flow. Reset Tool shows Custom. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use super::{MIXER_COMBINATION_GROUPS, MIXER_COMBINATIONS}; +use crate::PhotocraftApp; +use crate::state::Tool; + +const TIP: &str = "Useful mixer brush combinations"; + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.ui.tool = Tool::MixerBrush; + crate::paint_mouse::sync_tool_brush(&mut app); + app.run("tools.setBrush", json!({"brush": {"mixer": {"flow": 0.42}}})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1800.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + h +} + +fn has_fonts(h: &Harness<'static, PhotocraftApp>) -> bool { + h.ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) +} + +/// The combinations dropdown, by the entry it shows. +fn dropdown_rect(h: &Harness<'static, PhotocraftApp>, shows: &str) -> egui::Rect { + h.get_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| n.accesskit_node().value().as_deref() == Some(shows)) + .unwrap_or_else(|| panic!("no dropdown showing {shows}")) + .rect() +} + +fn open_and_pick(h: &mut Harness<'static, PhotocraftApp>, shows: &str, entry: &str) { + h.get_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.accesskit_node().value().as_deref() == Some(shows)).unwrap().click(); + h.run_steps(3); + entry_node(h, entry).click(); + h.run_steps(3); +} + +/// An entry of the open list (not the Wet field, which shares the "Wet" label). +fn entry_node<'a>(h: &'a Harness<'static, PhotocraftApp>, entry: &'a str) -> egui_kittest::Node<'a> { + h.get_all_by_label(entry).rfind(|n| n.accesskit_node().role() != egui::accesskit::Role::SpinButton).unwrap_or_else(|| panic!("no entry {entry}")) +} + +fn mixer(h: &Harness<'static, PhotocraftApp>) -> (f32, f32, f32, f32) { + let m = &h.state().session.tools.brush.mixer; + (m.wet, m.load, m.mix, m.flow) +} + +fn set_brush_count(h: &Harness<'static, PhotocraftApp>) -> usize { + h.state().session.journal.iter().filter(|(id, _)| id == "tools.setBrush").count() +} + +#[test] +fn the_dropdown_sits_before_wet_with_the_reference_entries_in_order() { + let mut h = harness(); + if !has_fonts(&h) { + return; + } + let combo = dropdown_rect(&h, "Custom"); + let clean = h.get_by_label("Clean the brush after each stroke").rect(); + let wet = h.get_all_by_role(egui::accesskit::Role::SpinButton).find(|n| n.accesskit_node().label().as_deref() == Some("Wet")).unwrap().rect(); + assert!(clean.right() <= combo.left() && combo.right() <= wet.left(), "clean {clean:?}, dropdown {combo:?}, wet {wet:?}"); + assert_eq!( + h.get_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| n.accesskit_node().value().as_deref() == Some("Custom")) + .unwrap() + .accesskit_node() + .label() + .as_deref(), + Some(TIP) + ); + let labels: Vec<&str> = MIXER_COMBINATIONS.iter().map(|(l, _)| *l).collect(); + assert_eq!( + labels, + [ + "Custom", + "Dry", + "Dry, Light Load", + "Dry, Heavy Load", + "Moist", + "Moist, Light Mix", + "Moist, Heavy Mix", + "Wet", + "Wet, Light Mix", + "Wet, Heavy Mix", + "Very Wet", + "Very Wet, Light Mix", + "Very Wet, Heavy Mix" + ] + ); + // Separators above Dry, Moist, Wet and Very Wet. + let groups: Vec<&str> = MIXER_COMBINATION_GROUPS.iter().map(|&i| MIXER_COMBINATIONS[i].0).collect(); + assert_eq!(groups, ["Dry", "Moist", "Wet", "Very Wet"]); + // The open list shows the entries top to bottom in that order. + h.get_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.accesskit_node().value().as_deref() == Some("Custom")).unwrap().click(); + h.run_steps(3); + let tops: Vec = labels[1..].iter().map(|l| entry_node(&h, l).rect().top()).collect(); + assert!(tops.windows(2).all(|w| w[0] < w[1]), "entries out of order: {tops:?}"); +} + +#[test] +fn each_entry_sets_wet_load_and_mix_in_one_change_and_keeps_flow() { + let mut h = harness(); + if !has_fonts(&h) { + return; + } + let expected = [ + ("Dry", (0.0, 0.5, 0.0)), + ("Dry, Light Load", (0.0, 0.05, 0.0)), + ("Dry, Heavy Load", (0.0, 1.0, 0.0)), + ("Moist", (0.1, 0.05, 0.5)), + ("Moist, Light Mix", (0.1, 0.05, 0.0)), + ("Moist, Heavy Mix", (0.1, 0.05, 1.0)), + ("Wet", (0.5, 0.5, 0.5)), + ("Wet, Light Mix", (0.5, 0.5, 0.0)), + ("Wet, Heavy Mix", (0.5, 0.5, 1.0)), + ("Very Wet", (1.0, 0.5, 0.5)), + ("Very Wet, Light Mix", (1.0, 0.5, 0.0)), + ("Very Wet, Heavy Mix", (1.0, 0.5, 1.0)), + ]; + let mut shows = "Custom"; + for (entry, (wet, load, mix)) in expected { + let before = set_brush_count(&h); + open_and_pick(&mut h, shows, entry); + assert_eq!(mixer(&h), (wet, load, mix, 0.42), "{entry}"); + assert_eq!(set_brush_count(&h), before + 1, "{entry}: one tools.setBrush change"); + assert_eq!(h.state().ui.tool_options.mixer_combination, MIXER_COMBINATIONS.iter().position(|(l, _)| *l == entry).unwrap()); + dropdown_rect(&h, entry); + shows = entry; + } +} + +#[test] +fn reset_tool_shows_custom_and_a_typed_value_keeps_the_pick() { + let mut h = harness(); + if !has_fonts(&h) { + return; + } + open_and_pick(&mut h, "Custom", "Wet, Heavy Mix"); + // A typed Wet keeps showing the picked entry (not measured; today's behaviour). + h.get_all_by_role(egui::accesskit::Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(50.0)).expect("Wet field").click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("30".into())); + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(mixer(&h).0, 0.3); + dropdown_rect(&h, "Wet, Heavy Mix"); + // Reset Tool: Custom again. + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "tool.reset", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().ui.tool_options.mixer_combination, 0); + dropdown_rect(&h, "Custom"); +} diff --git a/crates/ui-egui/src/move_auto_select_tests.rs b/crates/ui-egui/src/move_auto_select_tests.rs index 568a3e6aa50..68cc7453c7e 100644 --- a/crates/ui-egui/src/move_auto_select_tests.rs +++ b/crates/ui-egui/src/move_auto_select_tests.rs @@ -51,10 +51,10 @@ fn drag(app: &mut PhotocraftApp, from: [f64; 2], by: [f64; 2], mods: egui::Modif } #[test] -fn auto_select_is_on_by_default_with_the_group_target() { +fn auto_select_is_on_by_default_with_the_layer_target() { let o = ToolOptions::default(); assert!(o.move_auto_select, "Photoshop ships with Auto-Select checked"); - assert_eq!(o.move_target, "group", "Photoshop defaults Auto-Select to Group (#4056)"); + assert_eq!(o.move_target, "layer", "Reset Tool in Photoshop gives Auto-Select: Layer (#11380)"); // A saved tool preset that predates the option gets the default too. let mut v = serde_json::to_value(&o).unwrap(); v.as_object_mut().unwrap().remove("move_auto_select"); diff --git a/crates/ui-egui/src/move_target_default_tests.rs b/crates/ui-egui/src/move_target_default_tests.rs new file mode 100644 index 00000000000..b22b649ecde --- /dev/null +++ b/crates/ui-egui/src/move_target_default_tests.rs @@ -0,0 +1,75 @@ +//! The Move tool's Auto-Select target (#11380): fresh options and Tool › Reset Tool give Layer, +//! as the reference application's Reset Tool does; the dropdown lists Group, then Layer; a saved +//! choice is kept. + +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::{Tool, ToolOptions}; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::Move; + app +} + +#[test] +fn fresh_options_and_reset_tool_give_auto_select_on_with_the_layer_target() { + let o = ToolOptions::default(); + assert!(o.move_auto_select); + assert_eq!(o.move_target, "layer"); + let mut app = app(); + app.ui.tool_options.move_target = "group".into(); + app.ui.tool_options.move_auto_select = false; + crate::menus::invoke(&mut app, &egui::Context::default(), crate::tools::tool_reset::RESET, json!({})).unwrap(); + assert!(app.ui.tool_options.move_auto_select); + assert_eq!(app.ui.tool_options.move_target, "layer", "Reset Tool gives Layer"); +} + +#[test] +fn a_saved_choice_is_kept_and_only_a_missing_key_gets_layer() { + let mut v = serde_json::to_value(ToolOptions::default()).unwrap(); + v["move_target"] = json!("group"); + let back: ToolOptions = serde_json::from_value(v.clone()).unwrap(); + assert_eq!(back.move_target, "group", "a saved Group stays Group"); + v.as_object_mut().unwrap().remove("move_target"); + let back: ToolOptions = serde_json::from_value(v).unwrap(); + assert_eq!(back.move_target, "layer"); +} + +#[test] +fn the_target_dropdown_lists_group_then_layer() { + let mut h = Harness::builder().with_size(vec2(1600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app(), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + let combo = h + .get_all_by_role(Role::ComboBox) + .find(|n| n.accesskit_node().value().as_deref() == Some("Layer") || n.accesskit_node().label().as_deref() == Some("Layer")) + .expect("the Auto-Select target dropdown shows Layer"); + let below = combo.rect().max.y; + combo.click(); + h.run_steps(4); + let item_y = |label: &str| { + h.get_all_by_label(label) + .map(|n| n.rect()) + .filter(|r| r.min.y >= below - 0.5) + .map(|r| r.min.y) + .next() + .unwrap_or_else(|| panic!("a {label} entry in the open list")) + }; + assert!(item_y("Group") < item_y("Layer"), "Group is listed above Layer"); +} diff --git a/crates/ui-egui/src/notices.rs b/crates/ui-egui/src/notices.rs index 32c4d229994..116bd6cfc9c 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -7,7 +7,6 @@ use serde::{Deserialize, Serialize}; use crate::PhotocraftApp; -const WAYLAND_FILE_DROP_DISMISSED: &str = "ui.waylandFileDropGuidanceDismissed"; const CLIPBOARD_UNREADABLE: &str = "Pasting from other apps is unavailable"; /// At most this many notices are kept; older ones drop off. pub const MAX_NOTICES: usize = 3; @@ -74,37 +73,11 @@ fn cap_notices(app: &mut PhotocraftApp) { } /// How to paste in hints: Edit › Paste's effective shortcut (`Ctrl+V`), else the menu path. +#[cfg(test)] pub(crate) fn paste_hint(app: &PhotocraftApp) -> String { crate::shortcuts::shortcut_label(app, "edit.paste").unwrap_or_else(|| tl!("Edit › Paste").to_string()) } -/// Show the Wayland-specific fallback guidance unless the user dismissed it in preferences: winit -/// 0.30 delivers no file drops on Wayland, so point at File › Open, pasting a copied file, and the -/// command that starts this install under XWayland (where drops work), when there is one. -pub fn wayland_file_drop_guidance(app: &mut PhotocraftApp) { - if !app.services.is_wayland - || app.session.prefs().dialogs.get(WAYLAND_FILE_DROP_DISMISSED).and_then(serde_json::Value::as_bool) == Some(true) - || app.ui.notices.iter().any(|notice| notice.dismiss_pref.as_deref() == Some(WAYLAND_FILE_DROP_DISMISSED)) - { - return; - } - // English templates, translated and filled in when drawn, so the notice follows a language change. - let mut lines = vec![ - "Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}." - .to_owned(), - ]; - let mut args = vec![("paste".to_owned(), paste_hint(app))]; - if let Some(command) = &app.services.xwayland_command { - lines.push("To drop files, start PhotoCraft under XWayland: `{command}`".to_owned()); - args.push(("command".to_owned(), command.clone())); - lines.push("Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland.".to_owned()); - } - let id = post(app, "Native file drag-and-drop is unavailable", lines, false, Some(WAYLAND_FILE_DROP_DISMISSED)); - if let Some(notice) = app.ui.notices.iter_mut().find(|notice| notice.id == id) { - notice.args = args; - } -} - /// A paste found no clipboard to read (#5031). In a Flatpak on Wayland the sandbox hides the /// data-control protocol that other apps' images are read through, and there is no X server to /// fall back to, so the clipboard may hold an image all the same. Say so, and give the command @@ -315,16 +288,11 @@ mod tests { use super::*; use crate::{PhotocraftApp, Services}; use photocraft_engine::Session; - use std::sync::{Arc, Mutex}; #[test] - fn wayland_guidance_follows_language_changes_after_startup() { - let command = Some("WAYLAND_DISPLAY= photocraft".to_string()); - let app = PhotocraftApp::new(Session::new(), Services { is_wayland: true, xwayland_command: command, ..Default::default() }); - let notice = &app.ui.notices[0]; - // the notice keeps its English source text and is translated when drawn (`i18n::t` with - // the current language) - assert_eq!(notice.title, "Native file drag-and-drop is unavailable"); + fn wayland_session_does_not_post_unavailable_notice() { + let app = PhotocraftApp::new(Session::new(), Services { is_wayland: true, ..Default::default() }); + assert!(app.ui.notices.is_empty()); } /// #5031: a paste that can't reach the clipboard (a Flatpak on Wayland) says why and how to @@ -362,40 +330,6 @@ mod tests { notice.lines.iter().map(|line| crate::i18n::fmt(line, &args)).collect::>().join(" ") } - #[test] - fn wayland_guidance_is_only_shown_in_wayland_sessions() { - let command = Some("WAYLAND_DISPLAY= '/apps/Photo Craft.AppImage'".to_string()); - let mut app = PhotocraftApp::new(Session::new(), Services { is_wayland: true, xwayland_command: command, ..Default::default() }); - assert_eq!(app.ui.notices.len(), 1); - assert_eq!(app.ui.notices[0].title, "Native file drag-and-drop is unavailable"); - let guidance = english(&app.ui.notices[0]); - assert!(guidance.contains("not supported on Wayland yet")); - assert!(guidance.contains("File › Open")); - // Pasting a copied file works on Wayland (#338), so the notice offers it. - let paste = paste_hint(&app); - assert!(guidance.contains(&format!("paste it with {paste}")), "{guidance}"); - // The relaunch command is the one for this install (an AppImage here), not a guess. - assert!(guidance.contains("under XWayland: `WAYLAND_DISPLAY= '/apps/Photo Craft.AppImage'`"), "{guidance}"); - assert!(guidance.contains("Linux display server to X11"), "{guidance}"); - assert_eq!(app.ui.notices[0].dismiss_pref.as_deref(), Some(WAYLAND_FILE_DROP_DISMISSED)); - for i in 0..MAX_NOTICES { - post(&mut app, format!("Transient {i}"), Vec::new(), false, None); - } - assert_eq!(app.ui.notices.len(), MAX_NOTICES); - assert!(app.ui.notices.iter().any(|notice| notice.dismiss_pref.is_some())); - - let app = PhotocraftApp::new(Session::new(), Services::default()); - assert!(app.ui.notices.is_empty()); - } - - #[test] - fn wayland_guidance_without_an_x_server_does_not_suggest_xwayland() { - let app = PhotocraftApp::new(Session::new(), Services { is_wayland: true, ..Default::default() }); - let guidance = english(&app.ui.notices[0]); - assert!(guidance.contains("File › Open")); - assert!(!guidance.contains("XWayland"), "{guidance}"); - } - #[test] fn paste_hint_follows_the_shortcut_and_falls_back_to_the_menu() { let mut app = PhotocraftApp::new(Session::new(), Services::default()); @@ -406,31 +340,6 @@ mod tests { assert_eq!(paste_hint(&app), "Edit › Paste"); } - #[test] - fn dismissing_wayland_guidance_persists_across_launches() { - let saved = Arc::new(Mutex::new(None::)); - let load_store = saved.clone(); - let save_store = saved.clone(); - let services = Services { - is_wayland: true, - load_prefs: Some(Box::new(move || load_store.lock().unwrap_or_else(|e| e.into_inner()).clone())), - save_prefs: Some(Box::new(move |text| { - *save_store.lock().unwrap_or_else(|e| e.into_inner()) = Some(text.to_owned()); - Ok(()) - })), - ..Default::default() - }; - let mut app = PhotocraftApp::new(Session::new(), services); - let id = app.ui.notices[0].id; - dismiss(&mut app, id); - crate::prefs_ui::tick(&mut app, &egui::Context::default()); - - let services = - Services { is_wayland: true, load_prefs: Some(Box::new(move || saved.lock().unwrap_or_else(|e| e.into_inner()).clone())), ..Default::default() }; - let app = PhotocraftApp::new(Session::new(), services); - assert!(app.ui.notices.is_empty()); - } - #[test] fn preparing_compositor_does_not_warn_about_large_documents() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 0d40e2280b6..c4a86017697 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -80,6 +80,8 @@ use history::history; pub(crate) use history::select_snapshot; pub(crate) use history::selected_snapshot; #[cfg(test)] +pub(crate) use history::step_icon as history_step_icon; +#[cfg(test)] use info::info_panel; pub(crate) use info::{SamplerReadouts, sampler_readouts}; #[cfg(test)] diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index b1d4cd72d5f..73675c83f6d 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -345,6 +345,73 @@ fn stop_step(app: &mut PhotocraftApp, action: &Action, i: usize, p: &Value) -> C Called::Paused(json!({"step": i, "id": id, "dialog": dialog})) } +/// Let the engine handle programmatic playback and stateless items. Interactive Play opens +/// the settings editor and retains its command, document and receiver until confirmation. +fn pause_menu_item(app: &mut PhotocraftApp, action: &str, from: usize, id: &str, params: &Value) -> Result, String> { + use photocraft_engine::atn_cmds::{MENU_ITEM, menu_item_command, menu_item_needs_settings}; + + if id != MENU_ITEM || !playback::is_interactive(app) { + return Ok(None); + } + let command = menu_item_command(params).map_err(|error| error.to_string())?; + if !menu_item_needs_settings(command) { + return Ok(None); + } + let defaults = json!({}); + authorize_owned(app, command, &defaults, app.session.authorize, app.automation_input)?; + app.session.precheck(command, &defaults).map_err(|error| error.to_string())?; + let receiver = app.session.active().and_then(|st| st.active_layer); + let doc = app.session.active().map(|st| st.doc.id); + let dialog = open_step_editor(app, command)?; + let token = app.paused_playbacks.pause(action.to_owned(), from, app.session.authorize); + if let Some(d) = app.ui.dialog_mut(dialog) { + d.fields.insert(RESUME.into(), json!(token)); + } + if let Some(p) = app.paused_playbacks.paused.get_mut(&token) { + p.menu_editor = Some((dialog, command.to_owned())); + p.filter_receiver = Some(receiver); + p.doc = doc; + } + Ok(Some(dialog)) +} + +/// Route playback and Record Again to the same settings editor used by the command. +fn open_step_editor(app: &mut PhotocraftApp, command: &str) -> Result { + if crate::sizing::is_sizing(command) { + crate::sizing::open(app, command).ok_or_else(|| tl!("No document.").into()) + } else if crate::filter_dialog::has_dialog(command) || crate::adjust_dialog::has_dialog(command) { + Ok(crate::dialogs::open_command_dialog(app, command, &label_of(command))) + } else { + crate::filter_dialog::open(app, command).ok_or_else(|| tl!("The step has no command editor.").into()) + } +} + +/// Open the recorded step's editor and retain the receiver for confirmation. +fn pause_recorded_step(app: &mut PhotocraftApp, action: &Action, i: usize, id: &str, p: &Value) -> Value { + let label = label_of(id); + let active_layer = app.session.active().and_then(|st| st.active_layer); + let dialog = crate::dialogs::open_command_dialog(app, id, &label); + let token = app.paused_playbacks.pause(action.name.clone(), i + 1, app.session.authorize); + let mut receiver = None; + if let Some(d) = app.ui.dialog_mut(dialog) { + if let Some(o) = p.as_object() { + // `__` keys are the dialog's own markers (`params_of` drops them), not step values. + d.fields.extend(o.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone()))); + } + d.fields.insert(RESUME.into(), json!(token)); + // Kept for every implicit step, also one opened on a document target: the dialog + // may switch it to a layer-owned one, and `run_step_dialog` checks the receiver + // for the target it confirms. + if p.get("layer").is_none_or(Value::is_null) { + receiver = Some(active_layer); + } + } + if let Some(paused) = app.paused_playbacks.paused.get_mut(&token) { + paused.filter_receiver = receiver; + } + json!({"step": i, "id": id, "dialog": dialog}) +} + /// `suspended` is the recording a paused part of the same call took ([`PausedPlayback::record`]); it comes /// back, with that named call journaled, only when the whole call ends. `inner` are the called /// actions a resumed playback replays first, outermost first, each `(action, from)`: the first is @@ -373,6 +440,7 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option, }) .or(suspended); let mut ran = 0u64; + let interactive = playback::is_interactive(app); let (mut failed, mut paused, mut stopped) = (None, None, None); let ended = match replay_inner(app, &action.name, from, inner) { // The call was step `from - 1`: a paced Play waits after it as after any other step. @@ -402,47 +470,37 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option, } // A Stop shows its message or ends the playback (`stop_step`). Agents keep the headless // semantics of the engine command. - if id == photocraft_engine::atn_cmds::STOP_MESSAGE && !app.automation_input { + if id == photocraft_engine::atn_cmds::STOP_MESSAGE && interactive { end_at(stop_step(app, &action, i, p), &mut ran, &mut failed, &mut paused, &mut stopped); break; } + match pause_menu_item(app, &action.name, i + 1, id, p) { + Ok(Some(dialog)) => { + paused = Some(json!({"step": i, "id": id, "dialog": dialog})); + break; + } + Err(error) => { + failed = Some(json!({"step": i, "id": id, "error": error})); + break; + } + Ok(None) => {} + } // The dialog toggle: show the step's dialog with its recorded values and continue // from the next step when it is confirmed (`resume`). Agents play headlessly. if action.dialogs.contains(&i) - && !app.automation_input + && interactive && (crate::filter_dialog::has_dialog(id) || crate::adjust_dialog::has_dialog(id) || (id == crate::equalize_ui::COMMAND && crate::equalize_ui::asks(app))) { - let label = label_of(id); - let active_layer = app.session.active().and_then(|st| st.active_layer); - let dialog = crate::dialogs::open_command_dialog(app, id, &label); - let token = app.paused_playbacks.pause(action.name.clone(), i + 1, app.session.authorize); - let mut receiver = None; - if let Some(d) = app.ui.dialog_mut(dialog) { - if let Some(o) = p.as_object() { - // `__` keys are the dialog's own markers (`params_of` drops them), not step values. - d.fields.extend(o.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone()))); - } - d.fields.insert(RESUME.into(), json!(token)); - // Kept for every implicit step, also one opened on a document target: the dialog - // may switch it to a layer-owned one, and `run_step_dialog` checks the receiver - // for the target it confirms. - if p.get("layer").is_none_or(Value::is_null) { - receiver = Some(active_layer); - } - } - if let Some(paused) = app.paused_playbacks.paused.get_mut(&token) { - paused.filter_receiver = receiver; - } - paused = Some(json!({"step": i, "id": id, "dialog": dialog})); + paused = Some(pause_recorded_step(app, &action, i, id, p)); break; } // Check Spelling's dialog opens with Check All Layers as recorded; Done continues the action. // While the dialog is open (for another action or by hand), this step fails and the open // one keeps its place and ignored words. // A correction (`action` set) opens no dialog and runs below. - if action.dialogs.contains(&i) && !app.automation_input && id == "edit.checkSpelling" && p.get("action").is_none() { + if action.dialogs.contains(&i) && interactive && id == "edit.checkSpelling" && p.get("action").is_none() { if let Some(open) = app.ui.type_panels.spell.as_ref() { let error = if open.resume.is_some() { "Check Spelling is already open for another action" } else { "Check Spelling is already open" }; failed = Some(json!({"step": i, "id": id, "error": error})); @@ -686,6 +744,8 @@ fn step_label(id: &str, params: &Value) -> String { let cond = params.get("if").and_then(Value::as_str).unwrap_or("?"); let branch = |k: &str| params.get(k).and_then(Value::as_str).unwrap_or("–").to_string(); format!("{}: {cond} → {} / {}", label_of(id), branch("then"), branch("else")) + } else if id == photocraft_engine::atn_cmds::MENU_ITEM { + photocraft_engine::atn_cmds::menu_item_command(params).map_or_else(|_| label_of(id), label_of) } else if id == photocraft_engine::atn_cmds::STOP_MESSAGE { format!("{}: {}", label_of(id), params.get("message").and_then(Value::as_str).unwrap_or_default()) } else if id == photocraft_engine::atn_cmds::PHOTOSHOP_STEP { @@ -1045,7 +1105,9 @@ fn suspended(app: &PhotocraftApp) -> bool { /// action could go away or change place). The engine checks these against `recording`, which /// is `None` then. pub(crate) fn suspended_guard(app: &PhotocraftApp, id: &str) -> Result<(), String> { - if matches!(id, "actions.record" | "actions.delete" | "actions.move") && suspended(app) { + if matches!(id, "actions.record" | "actions.delete" | "actions.move" | "actions.duplicate" | "actions.recordAgain" | "actions.allowToolRecording") + && suspended(app) + { return Err(tl!("Confirm or cancel the open action step dialog first").into()); } Ok(()) @@ -1065,6 +1127,22 @@ pub fn abandoned(app: &mut PhotocraftApp, fields: &serde_json::Map photocraft_engine::Result<()>; +/// Deferred Actions dialogs retain their caller's gate and also honor current permissions. +fn authorize_owned(app: &PhotocraftApp, id: &str, params: &Value, gate: Option, automated: bool) -> Result<(), String> { + if let Some(gate) = gate { + gate(id, params).map_err(|e| e.to_string())?; + } + if let Some(current) = app.session.authorize { + current(id, params).map_err(|e| e.to_string())?; + } + if (automated || app.automation_input || app.session.authorize.is_some()) + && let Some(check) = app.services.automation_command.as_ref() + { + check(id, params)?; + } + Ok(()) +} + /// A function-pointer dispatch gate cannot carry two different policies into nested jobs. /// Changed gates and distinct simultaneous Session/service gates both end this continuation; /// hosts that need both policies must supply one composed gate at both boundaries. @@ -1102,6 +1180,8 @@ pub(crate) struct PausedPlayback { /// An implicit filter/adjustment step resolves this active layer at the pause. The outer Option marks /// that binding even when there was no active layer; explicit-layer and other dialogs omit it. filter_receiver: Option>, + /// Descriptor-free menu playback owns this settings dialog and the concrete command it opened. + menu_editor: Option<(u64, String)>, /// The actions that called this one through Play Action steps, nearest first, each with the /// step it continues from: confirming the dialog finishes `action`, then each of them in turn /// (at most [`MAX_CALLERS`]). @@ -1155,6 +1235,7 @@ impl PausedPlaybacks { ran: 0, doc: None, filter_receiver: None, + menu_editor: None, callers: Vec::new(), allow_continue: false, timed: false, @@ -1176,6 +1257,37 @@ fn document_target(params: &Value) -> bool { params.get("target").is_some_and(|target| target.as_str() == Some("quickMask") || target.get("channel").and_then(Value::as_u64).is_some()) } +/// Before a preview or confirmation evaluates settings, check the actual editor and its permissions. +pub(crate) fn authorize_panel_preview( + app: &PhotocraftApp, + dialog: u64, + fields: &serde_json::Map, + command: &str, + params: &Value, +) -> Result<(), String> { + record_again::authorize_preview(app, dialog, command, params)?; + if let Some((token, paused)) = app.paused_playbacks.paused.iter().find(|(_, p)| p.menu_editor.as_ref().is_some_and(|(id, _)| *id == dialog)) { + if fields.get(RESUME).and_then(Value::as_u64) != Some(*token) { + return Err("the menu item dialog changed".into()); + } + check_menu_editor(app, paused, command, params)?; + authorize_owned(app, command, params, paused.gate, paused.gate.is_some())?; + } + Ok(()) +} + +/// Check that preview and confirmation still target the command and receiver that opened the editor. +fn check_menu_editor(app: &PhotocraftApp, paused: &PausedPlayback, command: &str, params: &Value) -> Result<(), String> { + if let Some((_, original)) = &paused.menu_editor + && (original != command + || app.session.active().map(|st| st.doc.id) != paused.doc + || (!document_target(params) && paused.filter_receiver != Some(app.session.active().and_then(|st| st.active_layer)))) + { + return Err("the menu item command or its document changed".into()); + } + Ok(()) +} + /// Admit the final dialog command before resuming history or starting a worker. The captured /// policy judges the edited parameters; a current live gate still governs dispatch and nested /// work. With no live gate, reinstall the captured gate for job admission and rendering. @@ -1183,6 +1295,7 @@ pub(crate) fn run_step_dialog(app: &mut PhotocraftApp, fields: &serde_json::Map< let Some(token) = fields.get(RESUME) else { return app.run(command, params) }; let token = token.as_u64().ok_or("this dialog doesn't belong to a paused action")?; let paused = app.paused_playbacks.paused.get(&token).ok_or("this dialog doesn't belong to a paused action")?; + check_menu_editor(app, paused, command, ¶ms)?; if let Some(receiver) = paused.filter_receiver && !document_target(¶ms) && app.session.active().and_then(|st| st.active_layer) != receiver @@ -1423,6 +1536,14 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ui.button(tl!("New Set…")).clicked() { act = Some("newSet"); } + let can_duplicate_set = + !recording && app.ui.actions.selected_set.as_ref().is_some_and(|set| photocraft_engine::actions_edit_cmds::set_names(&app.session).contains(set)); + if ui.add_enabled(can_delete || can_duplicate_set, egui::Button::new(tl!("Duplicate"))).clicked() { + act = Some("duplicate"); + } + if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete"))).clicked() { + act = Some("delete"); + } if ui.add_enabled(any, egui::Button::new(tl!("Play"))).clicked() { act = Some("play"); } @@ -1433,18 +1554,30 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else if ui.button(tl!("Start Recording")).clicked() { act = Some("record"); } - if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete"))).clicked() { - act = Some("delete"); + if ui.add_enabled(selected && app.ui.actions.selected_step.is_some() && !recording, egui::Button::new(tl!("Record Again"))).clicked() { + act = Some("recordAgain"); } ui.separator(); + if ui.add_enabled(selected, egui::Button::new(tl!("Insert Menu Item…"))).clicked() { + act = Some("insertMenu"); + } if ui.add_enabled(selected, egui::Button::new(tl!("Insert Stop…"))).clicked() { act = Some("stop"); } + let path_selected = + app.ui.selected_path.as_deref().is_some_and(|name| photocraft_engine::actions_edit_cmds::selected_recording_path(&app.session, name).is_ok()); + if ui.add_enabled(selected && path_selected, egui::Button::new(tl!("Insert Path"))).clicked() { + act = Some("insertPath"); + } ui.separator(); // Enabled with nothing selected, as in the reference application; not while a paced Play waits. if ui.add_enabled(!playback::pending(app), egui::Button::new(tl!("Playback Options…"))).clicked() { act = Some("playbackOptions"); } + let mut tools = app.session.actions.record_tools; + if crate::widgets::checkbox(ui, &mut tools, tl!("Allow Tool Recording")).changed() { + act = Some("toolRecording"); + } ui.separator(); if ui.button(tl!("Load Actions…")).clicked() { act = Some("load"); @@ -1479,6 +1612,14 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { delete_selection(app); Ok(Value::Null) } + "duplicate" => duplicate_selection(app), + "recordAgain" => record_again::open(app), + "toolRecording" => app.run("actions.allowToolRecording", json!({"enabled": tools})), + "insertPath" => app.run( + "actions.insertPath", + json!({"action": app.ui.actions.selected, "at": app.ui.actions.selected_step.map(|i| i.saturating_add(1)), "name": app.ui.selected_path}), + ), + "insertMenu" => menu_item::open(app), "load" => load_dialog(app), "newSet" => new_set_dialog(app), "stop" => insert_stop_dialog(app), @@ -1542,6 +1683,47 @@ fn move_row(app: &mut PhotocraftApp, source: DragRow, to: usize) { } } +/// Keep the selection and disclosure rows on the new copy after the engine edits the list. +fn duplicate_selection(app: &mut PhotocraftApp) -> Result { + if app.ui.actions.selected.is_none() + && let Some(set) = app.ui.actions.selected_set.clone() + { + let count = app.session.actions.list.len(); + let result = app.run("actions.duplicate", json!({"set": set}))?; + if let (Some(at), Some(added)) = (result["index"].as_u64(), result["actions"].as_u64()) + && let (Ok(at), Ok(added)) = (usize::try_from(at), usize::try_from(added)) + && at <= count + { + app.ui.actions.expanded.resize(count, false); + app.ui.actions.expanded.splice(at..at, std::iter::repeat_n(false, added)); + } + app.ui.actions.selected_set = result["set"].as_str().map(str::to_owned); + app.ui.actions.selected = None; + app.ui.actions.selected_step = None; + return Ok(result); + } + let action = app.ui.actions.selected.ok_or_else(|| tl!("Select an action first").to_string())?; + let step = app.ui.actions.selected_step; + let count = app.session.actions.list.len(); + let expanded = app.ui.actions.expanded.get(action).copied().unwrap_or(false); + let result = app.run("actions.duplicate", json!({"action": action, "step": step}))?; + let index = result["index"].as_u64().and_then(|i| usize::try_from(i).ok()).ok_or("the copy has no action index")?; + app.ui.actions.selected = Some(index); + app.ui.actions.selected_step = result.get("step").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + if step.is_none() { + app.ui.actions.expanded.resize(count, false); + if index <= app.ui.actions.expanded.len() { + app.ui.actions.expanded.insert(index, expanded); + } + } else { + app.ui.actions.expanded.resize(count, false); + if let Some(expanded) = app.ui.actions.expanded.get_mut(index) { + *expanded = true; + } + } + Ok(result) +} + fn delete_selection(app: &mut PhotocraftApp) { let Some(action) = app.ui.actions.selected else { return }; let step = app.ui.actions.selected_step; @@ -2099,7 +2281,9 @@ fn draw_step_controls(ui: &mut egui::Ui, rect: Rect, step: usize, label: &str, c (check.on_hover_text(tl!("Include in playback")), dialog.on_hover_text(tl!("Show dialog during playback"))) } +pub(crate) mod menu_item; pub(crate) mod playback; +pub(crate) mod record_again; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panels/actions/menu_item.rs b/crates/ui-egui/src/panels/actions/menu_item.rs new file mode 100644 index 00000000000..d18a5edeb3a --- /dev/null +++ b/crates/ui-egui/src/panels/actions/menu_item.rs @@ -0,0 +1,87 @@ +//! Choose an existing mapped menu event without borrowing current command settings. +use crate::{PhotocraftApp, state::DialogKind}; +use serde_json::{Map, Value, json}; +const COMMAND: &str = "actions.insertMenuItem"; +const MARK: &str = "__insertMenuItem"; + +/// Keep a chooser tied to its original action, library and caller. +pub(crate) struct Held { + dialog: u64, + revision: u64, + target: Value, + gate: Option, + automated: bool, +} + +/// Register the chooser with the existing protected route mechanism. +pub(crate) fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Whether this dialog id still owns a deferred panel operation. +pub(crate) fn owns_dialog(app: &PhotocraftApp, dialog: u64) -> bool { + app.held_insert_menu.as_ref().is_some_and(|held| held.dialog == dialog) +} + +/// Open without recording a step or sampling the selected command's current settings. +pub(super) fn open(app: &mut PhotocraftApp) -> Result { + if app.held_insert_menu.is_some() { + return Err(tl!("Confirm or cancel the pending menu item first.").into()); + } + let action = app.ui.actions.selected.filter(|i| *i < app.session.actions.list.len()).ok_or(tl!("Select an action first."))?; + let target = json!({"action":action,"at":app.ui.actions.selected_step.map(|i|i.saturating_add(1))}); + let gate = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }); + let automated = app.automation_input || gate.is_some(); + let fields = json!({MARK:true,"__command":COMMAND,"__label":"Insert Menu Item","id":"image.imageSize"}); + let dialog = app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); + app.held_insert_menu = Some(Held { dialog, revision: app.session.actions.rev, target, gate, automated }); + Ok(json!({"dialog":dialog})) +} + +/// List only menu identities the existing ATN event table can encode unambiguously. +pub(crate) fn body(ui: &mut egui::Ui, fields: &mut Map) { + ui.label(tl!("Choose a menu command. Playback asks for its settings when needed.")); + let mut id = fields.get("id").and_then(Value::as_str).unwrap_or("image.imageSize").to_string(); + craft_ui::ComboBox::from_id_salt("actions-insert-menu").selected_text(super::label_of(&id)).show_ui(ui, |ui| { + for command in photocraft_engine::atn_cmds::menu_item_commands() { + ui.selectable_value(&mut id, command.to_string(), super::label_of(command)); + } + }); + fields.insert("id".into(), json!(id)); +} + +/// Consent inserts only into the captured action and uses both retained and current authority. +pub(crate) fn confirm(app: &mut PhotocraftApp, dialog: u64, fields: &Map) -> Result { + if app.held_insert_menu.as_ref().is_none_or(|h| h.dialog != dialog) { + return Err(tl!("No menu item belongs to this chooser.").into()); + } + let held = app.held_insert_menu.take().ok_or(tl!("No menu item is waiting."))?; + if held.revision != app.session.actions.rev || fields.get("__command").and_then(Value::as_str) != Some(COMMAND) { + return Err(tl!("The action or insertion command changed. Request Insert Menu Item again.").into()); + } + let mut params = held.target; + let action = params["action"].as_u64().and_then(|index| usize::try_from(index).ok()); + params["id"] = fields.get("id").cloned().unwrap_or(Value::Null); + super::authorize_owned(app, COMMAND, ¶ms, held.gate, held.automated)?; + let saved = app.session.authorize; + if let (Some(old), Some(current)) = (held.gate, saved) + && !std::ptr::fn_addr_eq(old, current) + { + return Err(tl!("The command permissions changed. Request Insert Menu Item again.").into()); + } + app.session.authorize = held.gate.or(saved); + let result = app.run(COMMAND, params); + app.session.authorize = saved; + let result = result?; + app.ui.actions.selected = action; + app.ui.actions.selected_set = None; + app.ui.actions.selected_step = result.get("step").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + Ok(result) +} + +/// Only its own Cancel may release the chooser's target. +pub(crate) fn cancel(app: &mut PhotocraftApp, dialog: u64) { + if app.held_insert_menu.as_ref().is_some_and(|h| h.dialog == dialog) { + app.held_insert_menu = None; + } +} diff --git a/crates/ui-egui/src/panels/actions/playback.rs b/crates/ui-egui/src/panels/actions/playback.rs index da4401ebdbc..340906a0607 100644 --- a/crates/ui-egui/src/panels/actions/playback.rs +++ b/crates/ui-egui/src/panels/actions/playback.rs @@ -5,8 +5,7 @@ //! action like a step dialog does (a [`super::PausedPlayback`] token holding the suspended history and //! recording), and [`tick`] resumes it once the wait is over, so the steps share one history state //! and the canvas redraws between them. Esc, the panel's Stop button, an edit of the action list or a -//! new recording end it there, keeping the steps already applied. Agents (`automation_input`) and -//! headless playback never wait. +//! new recording end it there, keeping the steps already applied. Programmatic playback never waits. use serde_json::{Map, Value, json}; use web_time::{Duration, Instant}; @@ -34,10 +33,15 @@ pub(crate) struct Timer { rev: u64, } +/// Native Play and control-channel menu clicks may open editors and wait between steps. +pub(super) fn is_interactive(app: &PhotocraftApp) -> bool { + !app.automation_input && !app.programmatic_playback +} + /// How long a person's Play waits after a step, or `None` when it goes straight on (Accelerated, /// and always for agents). pub(super) fn step_pause(app: &PhotocraftApp) -> Option { - if app.automation_input { + if !is_interactive(app) { return None; } let a = &app.session.prefs().actions; diff --git a/crates/ui-egui/src/panels/actions/record_again.rs b/crates/ui-egui/src/panels/actions/record_again.rs new file mode 100644 index 00000000000..d5eda238631 --- /dev/null +++ b/crates/ui-egui/src/panels/actions/record_again.rs @@ -0,0 +1,133 @@ +//! One editor owns one original step; previews and confirmation retain its authority. +use photocraft_engine::{ + actions_edit_cmds, + doc::{DocId, LayerId}, +}; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; + +const COMMAND: &str = "actions.recordAgain"; +const MARK: &str = "__recordAgain"; + +/// Selection, document and authority are private rather than editable dialog parameters. +pub(crate) struct Held { + dialog: u64, + selection: Value, + command: String, + document: Option<(DocId, u64, Option)>, + gate: Option, + automated: bool, +} + +/// The marker uses the existing protected route mechanism. +pub(crate) fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Whether this dialog id still owns a deferred panel operation. +pub(crate) fn owns_dialog(app: &PhotocraftApp, dialog: u64) -> bool { + app.held_record_again.as_ref().is_some_and(|held| held.dialog == dialog) +} + +/// Apply captured and current permissions to the actual operation and final settings. +fn authorize(app: &PhotocraftApp, held: &Held, id: &str, params: &Value) -> Result<(), String> { + super::authorize_owned(app, id, params, held.gate, held.automated) +} + +/// Refuse stale selection or a different document before preview or confirmation can edit it. +fn unchanged(app: &PhotocraftApp, held: &Held) -> Result<(), String> { + super::suspended_guard(app, COMMAND)?; + if held.selection.get("revision").and_then(Value::as_u64) != Some(app.session.actions.rev) + || app.session.actions.recording.is_some() + || app.session.actions.playing > 0 + { + return Err(tl!("The actions changed. Request Record Again again.").into()); + } + if app.session.active().map(|s| (s.doc.id, s.revision, s.active_layer)) != held.document { + return Err(tl!("The document changed. Request Record Again again.").into()); + } + Ok(()) +} + +/// Open the existing command editor and seed its controls from this step's recorded values. +pub(super) fn open(app: &mut PhotocraftApp) -> Result { + if app.held_record_again.is_some() { + return Err(tl!("Confirm or cancel the pending Record Again editor first.").into()); + } + let action = app.ui.actions.selected.ok_or(tl!("Select an action step first."))?; + let step = app.ui.actions.selected_step.ok_or(tl!("Select an action step first."))?; + let selection = json!({"action": action, "step": step, "revision": app.session.actions.rev}); + let target = actions_edit_cmds::record_again_target(&app.session, &selection).map_err(|e| e.to_string())?; + let gate = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }); + let mut held = Held { + dialog: 0, + selection, + command: target.command, + document: app.session.active().map(|s| (s.doc.id, s.revision, s.active_layer)), + gate, + automated: app.automation_input || gate.is_some(), + }; + unchanged(app, &held)?; + authorize(app, &held, COMMAND, &held.selection)?; + authorize(app, &held, &held.command, &target.params)?; + let resolved = photocraft_engine::actions_cmds::playback_params(&app.session, &held.command, &target.params).map_err(|e| e.to_string())?; + app.session.precheck(&held.command, &resolved).map_err(|e| e.to_string())?; + let dialog = super::open_step_editor(app, &held.command)?; + let fields = &mut app.ui.dialog_mut(dialog).ok_or(tl!("The step editor could not be opened."))?.fields; + if let Some(params) = target.params.as_object() { + fields.extend(params.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone()))); + } + fields.insert(MARK.into(), json!(true)); + held.dialog = dialog; + app.held_record_again = Some(held); + Ok(json!({"dialog": dialog})) +} + +/// Guard both actual preview routes before parsing LUT files or evaluating a command. +pub(crate) fn authorize_preview(app: &PhotocraftApp, dialog: u64, command: &str, params: &Value) -> Result<(), String> { + let Some(held) = app.held_record_again.as_ref().filter(|h| h.dialog == dialog) else { return Ok(()) }; + unchanged(app, held)?; + if command != held.command { + return Err(tl!("The Record Again command changed.").into()); + } + authorize(app, held, command, params) +} + +/// Apply through the registered engine command, retaining flags and replacing only after success. +pub(crate) fn confirm(app: &mut PhotocraftApp, dialog: u64, fields: &Map) -> Result { + if app.held_record_again.as_ref().is_none_or(|h| h.dialog != dialog) { + return Err(tl!("No recorded step belongs to this editor.").into()); + } + let held = app.held_record_again.take().ok_or(tl!("No step is waiting."))?; + unchanged(app, &held)?; + if fields.get("__command").and_then(Value::as_str) != Some(held.command.as_str()) + || fields.get("__adjust").and_then(Value::as_str).is_some_and(|kind| format!("image.adjustments.{kind}") != held.command) + { + return Err(tl!("The Record Again command changed.").into()); + } + let params = crate::filter_dialog::params_of(fields); + let mut request = held.selection.clone(); + request["params"] = params.clone(); + authorize(app, &held, COMMAND, &request)?; + authorize(app, &held, &held.command, ¶ms)?; + let saved = app.session.authorize; + // Different policy functions must not replace the original caller's nested-command gate. + if let (Some(old), Some(current)) = (held.gate, saved) + && !std::ptr::fn_addr_eq(old, current) + { + return Err(tl!("The command permissions changed. Request Record Again again.").into()); + } + app.session.authorize = held.gate.or(saved); + let result = app.run(COMMAND, request); + app.session.authorize = saved; + app.filter_preview = None; + result +} + +/// A different dialog cannot discard this editor's selection or authority. +pub(crate) fn cancel(app: &mut PhotocraftApp, dialog: u64) { + if app.held_record_again.as_ref().is_some_and(|held| held.dialog == dialog) { + app.held_record_again = None; + } +} diff --git a/crates/ui-egui/src/panels/brush_controls.rs b/crates/ui-egui/src/panels/brush_controls.rs index 9c07f562558..925a10fef74 100644 --- a/crates/ui-egui/src/panels/brush_controls.rs +++ b/crates/ui-egui/src/panels/brush_controls.rs @@ -57,13 +57,69 @@ pub(super) fn smoothing_options(ui: &mut egui::Ui, b: &mut photocraft_engine::Br if brush { ui.label(egui::RichText::new(tl!("Smoothing Options")).strong()); } - widgets::checkbox(ui, &mut s.pulled_string, tl!("Pulled String Mode")); - widgets::checkbox(ui, &mut s.catch_up, tl!("Stroke Catch-up")); - widgets::checkbox(ui, &mut s.catch_up_on_end, tl!("Catch-up on Stroke End")); - widgets::checkbox(ui, &mut s.adjust_for_zoom, tl!("Adjust for Zoom")); + // At 0 % there is no smoothing to tune: the four are greyed and keep their values, as in + // the reference application (#11939). Rounded like the Smoothing field, so a value that + // shows as 0 % greys them too. + let on = (s.amount * 100.0).round() > 0.0; + ui.add_enabled_ui(on, |ui| { + widgets::checkbox(ui, &mut s.pulled_string, tl!("Pulled String Mode")); + widgets::checkbox(ui, &mut s.catch_up, tl!("Stroke Catch-up")); + widgets::checkbox(ui, &mut s.catch_up_on_end, tl!("Catch-up on Stroke End")); + widgets::checkbox(ui, &mut s.adjust_for_zoom, tl!("Adjust for Zoom")); + }); }); } +/// A Useful mixer brush combinations entry: its label and the Wet, Load and Mix it sets. +type MixerCombination = (&'static str, Option<(f32, f32, f32)>); + +/// The Mixer Brush's Useful mixer brush combinations, in the reference's order: each entry's label +/// and the Wet, Load and Mix (0..1) it sets. Custom, the first, sets nothing. +pub(crate) const MIXER_COMBINATIONS: [MixerCombination; 13] = [ + ("Custom", None), + ("Dry", Some((0.0, 0.5, 0.0))), + ("Dry, Light Load", Some((0.0, 0.05, 0.0))), + ("Dry, Heavy Load", Some((0.0, 1.0, 0.0))), + ("Moist", Some((0.1, 0.05, 0.5))), + ("Moist, Light Mix", Some((0.1, 0.05, 0.0))), + ("Moist, Heavy Mix", Some((0.1, 0.05, 1.0))), + ("Wet", Some((0.5, 0.5, 0.5))), + ("Wet, Light Mix", Some((0.5, 0.5, 0.0))), + ("Wet, Heavy Mix", Some((0.5, 0.5, 1.0))), + ("Very Wet", Some((1.0, 0.5, 0.5))), + ("Very Wet, Light Mix", Some((1.0, 0.5, 0.0))), + ("Very Wet, Heavy Mix", Some((1.0, 0.5, 1.0))), +]; + +/// The entries of [`MIXER_COMBINATIONS`] a separator line goes above: Dry, Moist, Wet, Very Wet. +const MIXER_COMBINATION_GROUPS: [usize; 4] = [1, 4, 7, 10]; + +/// Mixer Brush: the Useful mixer brush combinations dropdown (#11946). Picking an entry sets Wet, +/// Load and Mix together (Flow stays) and keeps showing it, also after a value is typed; Custom +/// sets nothing. `picked` is the entry shown. +pub(super) fn mixer_combination_dropdown( + ui: &mut egui::Ui, + picked: &mut usize, + m: &mut photocraft_engine::paint::mixer::MixerSettings, +) -> Option { + const ID: &str = "mixer-combination"; + let opts: Vec<(usize, &str)> = MIXER_COMBINATIONS.iter().enumerate().map(|(i, (label, _))| (i, *label)).collect(); + let mut shown = (*picked).min(MIXER_COMBINATIONS.len() - 1); + let chosen = + widgets::dropdown_wheel_grouped(ui, ID, tl!("Useful mixer brush combinations"), &mut shown, &opts, 132.0, |i| MIXER_COMBINATION_GROUPS.contains(i)).0; + if let Some(&i) = chosen.last() { + *picked = i; + if let Some((wet, load, mix)) = MIXER_COMBINATIONS[i].1 { + (m.wet, m.load, m.mix) = (wet, load, mix); + } + } + ui.ctx().read_response(ui.make_persistent_id(egui::IdSalt::new(ID))) +} + +#[cfg(test)] +#[path = "../mixer_blend_combination_tests.rs"] +mod mixer_blend_combination_tests; + /// Mixer Brush: the Current Brush Load swatch shows the paint the next stroke starts with (an /// empty swatch when the brush is clean). Its menu holds Load Brush and Clean Brush. pub(super) fn mixer_load_swatch(app: &mut PhotocraftApp, ui: &mut egui::Ui) { @@ -125,8 +181,12 @@ pub(super) fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushS ui.painter().text(pos2(c.x, r.bottom() - 5.0), Align2::CENTER_CENTER, format!("{}", b.size.round() as i64), egui::FontId::proportional(9.5), t.text_dim); icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, c.y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Brush Preset picker"))); - // The Brush's own text (#11813); the other tools keep theirs. - let tip = if state.tool == crate::state::Tool::Brush { tl!("Click to open the Brush Preset picker") } else { tl!("Brush Preset picker") }; + // The Brush's and the Background Eraser's own text (#11813, #11909); the other tools keep theirs. + let tip = if matches!(state.tool, crate::state::Tool::Brush | crate::state::Tool::BackgroundEraser) { + tl!("Click to open the Brush Preset picker") + } else { + tl!("Brush Preset picker") + }; if resp.on_hover_text(tip).clicked() { if state.brush_picker.is_some() { crate::brush_picker::close(state); @@ -135,3 +195,7 @@ pub(super) fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushS } } } + +#[cfg(test)] +#[path = "../smoothing_options_greyed_tests.rs"] +mod smoothing_options_greyed_tests; diff --git a/crates/ui-egui/src/panels/brush_panel/tests.rs b/crates/ui-egui/src/panels/brush_panel/tests.rs index e0e15cb4f5d..2fc3f16cf15 100644 --- a/crates/ui-egui/src/panels/brush_panel/tests.rs +++ b/crates/ui-egui/src/panels/brush_panel/tests.rs @@ -1876,3 +1876,152 @@ fn checkboxes_show_a_pressed_state_from_their_label() { assert!(*h.state(), "the release checks it"); assert_eq!(fill(&h), Some(widgets::check_box_fill(true, false, &t))); } + +#[test] +fn spacing_row_edits_route_only_the_changed_fields_through_the_brush_command() { + use egui_kittest::kittest::{NodeT, Queryable}; + + let mut h = harness(0, 0); + h.state_mut().run("tools.setBrush", json!({"brush": {"spacing": 0.427, "spacingEnabled": false}})).unwrap(); + h.run_steps(2); + let journal = h.state().session.journal.len(); + let spacing = h.state().session.tools.brush.spacing; + let checkbox = h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Spacing"); + checkbox.click(); + h.run_steps(2); + assert!(h.state().session.tools.brush.spacing_enabled); + assert_eq!(h.state().session.tools.brush.spacing, spacing); + assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush"); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacingEnabled": true})); + let field = h.query_all_by_role(egui::accesskit::Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(43.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.spacing, 0.44); + assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush"); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacing": 0.44_f32})); + let checkbox = h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Spacing"); + checkbox.click(); + h.run_steps(2); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacingEnabled": false})); + let field = h.query_all_by_role(egui::accesskit::Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(44.0)).unwrap(); + assert!(field.accesskit_node().is_disabled()); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.spacing, 0.44); + assert_eq!(h.state().session.journal.len(), journal + 3, "disabled input adds no command"); + let checkbox = h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Spacing"); + checkbox.click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.spacing, 0.44); + assert_eq!(h.state().session.journal.len(), journal + 4); +} + +#[test] +fn scatter_header_edits_route_to_the_correct_brush_block_and_disabled_sections_ignore_input() { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; + + for section in [2, 4] { + let block = if section == 2 { "scattering" } else { "dualBrush" }; + let mut h = harness(0, section); + let mut initial = json!({}); + initial[block] = if section == 2 { + json!({"enabled": true, "bothAxes": false, "scatter": {"jitter": 2.137, "control": "penPressure"}, "count": 5}) + } else { + json!({"enabled": true, "bothAxes": false, "scatter": 2.137, "count": 7}) + }; + h.state_mut().run("tools.setBrush", json!({"brush": initial})).unwrap(); + h.run_steps(3); + let journal = h.state().session.journal.len(); + let before = h.state().session.tools.brush.clone(); + h.get_by_label("Both Axes").click(); + h.run_steps(2); + let mut expected = json!({}); + expected[block] = json!({"bothAxes": true}); + assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush"); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], expected); + let mut after = before; + if section == 2 { + after.scattering.both_axes = true; + } else { + after.dual_brush.both_axes = true; + } + assert_eq!(h.state().session.tools.brush, after, "toggle changes only the matching axis flag"); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(214.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + expected[block] = if section == 2 { json!({"scatter": {"jitter": 2.15_f32}}) } else { json!({"scatter": 2.15_f32}) }; + assert_eq!(last_journal(h.state()).unwrap().1["brush"], expected); + if section == 2 { + after.scattering.scatter.jitter = 2.15; + } else { + after.dual_brush.scatter = 2.15; + } + assert_eq!(h.state().session.tools.brush, after); + assert_eq!(h.state().session.journal.len(), journal + 2); + let mut off = json!({}); + off[block] = json!({"enabled": false}); + h.state_mut().run("tools.setBrush", json!({"brush": off})).unwrap(); + h.run_steps(2); + let before = h.state().session.tools.brush.clone(); + let journal = h.state().session.journal.len(); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(215.0)).unwrap(); + assert!(field.accesskit_node().is_disabled()); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.event(egui::Event::Text("999".into())); + h.key_press(egui::Key::Enter); + h.get_by_label("Both Axes").click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush, before, "the disabled header retains both values"); + assert_eq!(h.state().session.journal.len(), journal, "disabled edits add no command"); + } +} + +#[test] +fn tip_geometry_edits_and_arrow_drag_route_only_changed_fields_through_the_brush_command() { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; + fn centre(shape: &egui::Shape) -> Option { + match shape { + egui::Shape::Circle(c) if c.radius == 40.0 => Some(c.center), + egui::Shape::Vec(shapes) => shapes.iter().find_map(centre), + _ => None, + } + } + let mut h = harness(0, 0); + h.state_mut().run("tools.setBrush", json!({"brush": {"angle": -23.0, "roundness": 0.473, "hardness": 0.65, "spacing": 0.31, "flipX": true}})).unwrap(); + h.run_steps(3); + let mut expected = h.state().session.tools.brush.clone(); + let journal = h.state().session.journal.len(); + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(-23.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + expected.angle = -22.0; + assert_eq!(h.state().session.tools.brush, expected); + assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush"); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"angle": -22.0_f32})); + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(47.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + expected.roundness = 0.48; + assert_eq!(h.state().session.tools.brush, expected); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"roundness": 0.48_f32})); + let c = h.output().shapes.iter().find_map(|shape| centre(&shape.shape)).unwrap(); + let a = expected.angle.to_radians(); + h.drag_at(c + vec2(a.cos(), -a.sin()) * 35.0); + h.run_steps(1); + h.hover_at(c + vec2(0.0, -35.0)); + h.run_steps(2); + h.drop_at(c + vec2(0.0, -35.0)); + h.run_steps(2); + expected.angle = 90.0; + assert_eq!(h.state().session.tools.brush, expected); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"angle": 90.0_f32})); + assert_eq!(h.state().session.journal.len(), journal + 3, "one command per field edit and arrow gesture"); +} diff --git a/crates/ui-egui/src/panels/brush_sections.rs b/crates/ui-egui/src/panels/brush_sections.rs index da96b7474a9..d7c16283fb6 100644 --- a/crates/ui-egui/src/panels/brush_sections.rs +++ b/crates/ui-egui/src/panels/brush_sections.rs @@ -54,6 +54,33 @@ fn signed_pct(ui: &mut egui::Ui, label: &str, v: &mut f32, min: f32, max: f32) - changed } +/// The checkbox labels the spacing field; toggling it must retain imported fractional values. +fn spacing_row(ui: &mut egui::Ui, b: &mut BrushSettings) { + let mut percent = (b.spacing * 100.0).round(); + let mut changed = false; + ui.horizontal(|ui| { + widgets::checkbox(ui, &mut b.spacing_enabled, tl!("Spacing")).on_hover_text(tl!("Off: the speed of the pointer sets the spacing")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + changed = ui + .add_enabled_ui(b.spacing_enabled, |ui| widgets::value_field(ui, &mut percent, 1.0..=1000.0, "%", 74.0).accessible_name(tl!("Spacing"))) + .inner + .changed(); + }); + }); + changed |= ui + .add_enabled_ui(b.spacing_enabled, |ui| { + let s = widgets::slider(ui, &mut percent, 1.0..=1000.0, None); + s.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(percent), tl!("Spacing"))); + s + }) + .inner + .changed(); + if changed { + b.spacing = (percent.round() / 100.0).clamp(0.01, 10.0); + } + ui.add_space(4.0); +} + fn num(ui: &mut egui::Ui, label: &str, v: &mut f32, range: std::ops::RangeInclusive, suffix: &str) -> bool { let mut x = *v; let changed = widgets::slider_row(ui, label, &mut x, range.clone(), suffix, None).changed(); @@ -186,8 +213,9 @@ pub fn ellipse_drag(grab: EllipseGrab, rel: egui::Vec2, angle: f32, roundness: f } } -/// Photoshop's angle/roundness widget: drag the arrow to rotate, the dots to squash. -fn ellipse_widget(ui: &mut egui::Ui, angle: &mut f32, roundness: &mut f32) -> bool { +/// Photoshop's angle/roundness widget: drag the arrow to rotate, the dots to squash. Also at the +/// Brush Preset picker's top left (#11821). +pub(crate) fn ellipse_widget(ui: &mut egui::Ui, angle: &mut f32, roundness: &mut f32) -> bool { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(84.0, 84.0), Sense::click_and_drag()); let c = rect.center(); @@ -325,36 +353,37 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset], }); ui.add_space(4.0); ui.horizontal(|ui| { - ellipse_widget(ui, &mut b.angle, &mut b.roundness); - ui.add_space(8.0); - ui.vertical(|ui| { + // Reserve the widget's column so both numeric rows share the same right edge. + let fields_width = (ui.available_width() - 84.0 - ui.spacing().item_spacing.x).max(0.0); + ui.allocate_ui_with_layout(vec2(fields_width, 84.0), egui::Layout::top_down(egui::Align::Max), |ui| { ui.add_space(10.0); ui.horizontal(|ui| { - ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| dim(ui, tl!("Angle:"))); - let mut a = b.angle; - if widgets::value_field(ui, &mut a, -180.0..=180.0, "°", 70.0).accessible_name(tl!("Angle:")).changed() { - b.angle = a.round().clamp(-180.0, 180.0); - } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut a = b.angle; + if widgets::value_field(ui, &mut a, -180.0..=180.0, "°", 70.0).accessible_name(tl!("Angle:")).changed() { + b.angle = a.round().clamp(-180.0, 180.0); + } + ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| dim(ui, tl!("Angle:"))); + }); }); ui.add_space(6.0); ui.horizontal(|ui| { - ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| dim(ui, tl!("Roundness:"))); - let mut r = (b.roundness * 100.0).round(); - if widgets::value_field(ui, &mut r, 1.0..=100.0, "%", 70.0).accessible_name(tl!("Roundness:")).changed() { - b.roundness = (r.round() / 100.0).clamp(0.01, 1.0); - } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut r = (b.roundness * 100.0).round(); + if widgets::value_field(ui, &mut r, 1.0..=100.0, "%", 70.0).accessible_name(tl!("Roundness:")).changed() { + b.roundness = (r.round() / 100.0).clamp(0.01, 1.0); + } + ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| dim(ui, tl!("Roundness:"))); + }); }); }); + ellipse_widget(ui, &mut b.angle, &mut b.roundness); }); ui.add_space(6.0); ui.add_enabled_ui(matches!(b.tip, TipShape::Round), |ui| { pct(ui, tl!("Hardness"), &mut b.hardness, 1.0); }); - // Unchecked, the pointer's speed sets the spacing (Photoshop). - widgets::checkbox(ui, &mut b.spacing_enabled, tl!("Spacing")).on_hover_text(tl!("Off: the speed of the pointer sets the spacing")); - ui.add_space(2.0); - let on = b.spacing_enabled; - ui.add_enabled_ui(on, |ui| signed_pct(ui, tl!("Spacing"), &mut b.spacing, 0.01, 10.0)); + spacing_row(ui, b); None } @@ -378,10 +407,35 @@ fn shape_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) { widgets::checkbox(ui, &mut sd.brush_projection, tl!("Brush Projection")).on_hover_text(tl!("Apply the pen's tilt and rotation to the tip shape")); } +/// Both scatter controls put their axis switch between the label and percentage field. +fn scatter_row(ui: &mut egui::Ui, amount: &mut f32, both_axes: &mut bool) { + let t = Tokens::get(ui.ctx()); + let mut percent = (*amount * 100.0).round(); + let mut changed = false; + let label = ui + .horizontal(|ui| { + let label = ui.label(RichText::new(tl!("Scatter")).color(t.text_dim)); + widgets::scrub_label_within(ui, &label, f32::INFINITY); + widgets::checkbox(ui, both_axes, tl!("Both Axes")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + changed = widgets::value_field(ui, &mut percent, 0.0..=1000.0, "%", 74.0).changed(); + }); + label.id + }) + .inner; + // Named by its caption, like every `slider_row` slider. + let slider = widgets::slider(ui, &mut percent, 0.0..=1000.0, None); + slider.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(percent), tl!("Scatter"))); + changed |= slider.labelled_by(label).changed(); + if changed { + *amount = (percent.round() / 100.0).clamp(0.0, 10.0); + } + ui.add_space(4.0); +} + fn scattering(ui: &mut egui::Ui, b: &mut BrushSettings) { let sc = &mut b.scattering; - pct(ui, tl!("Scatter"), &mut sc.scatter.jitter, 10.0); - widgets::checkbox(ui, &mut sc.both_axes, tl!("Both Axes")); + scatter_row(ui, &mut sc.scatter.jitter, &mut sc.both_axes); ui.add_space(2.0); control_row(ui, "brush-scatter-ctl", &mut sc.scatter, false); ui.add_space(6.0); @@ -523,8 +577,7 @@ fn dual_brush(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset]) let orig = d.tip.bitmap_size().map(|(w, h)| w.max(h) as f32); size_row(ui, tl!("Size"), &mut d.size, 2500.0, orig); signed_pct(ui, tl!("Spacing"), &mut d.spacing, 0.01, 10.0); - pct(ui, tl!("Scatter"), &mut d.scatter, 10.0); - widgets::checkbox(ui, &mut d.both_axes, tl!("Both Axes")); + scatter_row(ui, &mut d.scatter, &mut d.both_axes); ui.add_space(4.0); let mut c = d.count as f32; if num(ui, tl!("Count"), &mut c, 1.0..=16.0, "") { diff --git a/crates/ui-egui/src/panels/brush_sections/tests.rs b/crates/ui-egui/src/panels/brush_sections/tests.rs index b9a221f04ed..96338c3464d 100644 --- a/crates/ui-egui/src/panels/brush_sections/tests.rs +++ b/crates/ui-egui/src/panels/brush_sections/tests.rs @@ -25,8 +25,12 @@ fn minimum_brush() -> BrushSettings { } fn section_harness(section: usize, b: BrushSettings) -> Harness<'static, BrushSettings> { + section_harness_width(section, 340.0, b) +} + +fn section_harness_width(section: usize, width: f32, b: BrushSettings) -> Harness<'static, BrushSettings> { let mut ready = false; - let mut h = Harness::builder().with_size(vec2(340.0, 1100.0)).build_ui_state( + let mut h = Harness::builder().with_size(vec2(width, 1100.0)).build_ui_state( move |ui, b: &mut BrushSettings| { if !ready { crate::PhotocraftApp::setup_context(ui.ctx(), theme::ThemeKind::ProMedium); @@ -335,3 +339,264 @@ fn ellipse_widget_drags_angle_and_roundness() { let (_, r) = ellipse_drag(EllipseGrab::Roundness, vec2(500.0, 0.0), 90.0, 0.2); assert_eq!(r, 1.0); } + +#[test] +fn spacing_row_has_one_label_and_a_shared_header_in_every_theme() { + for kind in theme::ThemeKind::ALL { + for enabled in [false, true] { + let b = BrushSettings { spacing: 0.427, spacing_enabled: enabled, ..Default::default() }; + let mut h = section_harness(0, b.clone()); + crate::PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + assert_eq!(h.query_all_by_role_and_label(Role::Label, "Spacing").count(), 0, "{kind:?}: the checkbox is the single label"); + assert_eq!(h.query_all_by_role_and_label(Role::CheckBox, "Spacing").count(), 1); + let checkbox = h.get_by_role_and_label(Role::CheckBox, "Spacing").rect(); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(43.0)).unwrap(); + assert!((checkbox.center().y - field.rect().center().y).abs() < 2.0, "{kind:?}: checkbox and percentage share a row"); + assert_eq!(field.accesskit_node().is_disabled(), !enabled); + let slider = h.get_by_role_and_label(Role::Slider, "Spacing"); + assert_eq!(slider.accesskit_node().numeric_value(), Some(43.0), "{kind:?}: the track is a named slider"); + assert_eq!(slider.accesskit_node().is_disabled(), !enabled); + assert_eq!(h.state(), &b, "rendering does not round the saved spacing"); + } + } +} + +fn check_scatter_header(section: usize) { + for kind in theme::ThemeKind::ALL { + for both_axes in [false, true] { + let mut b = BrushSettings::default(); + b.scattering.scatter.jitter = 2.137; + b.scattering.both_axes = both_axes; + b.dual_brush.scatter = 2.137; + b.dual_brush.both_axes = both_axes; + let mut h = section_harness(section, b.clone()); + crate::PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + let label = caption_rect(&h, "Scatter"); + let axes = h.get_by_label("Both Axes").rect(); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(214.0)).unwrap().rect(); + assert!((label.center().y - axes.center().y).abs() < 2.0, "{kind:?}, section {section}: axes belongs on the Scatter header"); + assert!((axes.center().y - field.center().y).abs() < 2.0, "{kind:?}, section {section}: axes and percentage share a row"); + assert!(label.right() < axes.left() && axes.right() < field.left(), "{kind:?}, section {section}: label, checkbox, percentage order"); + assert_eq!( + h.query_all_by_role_and_label(Role::Slider, "Scatter").count(), + 1, + "{kind:?}, section {section}: the slider is named by its caption, like other slider rows" + ); + assert_eq!(h.state(), &b, "rendering retains fractional scatter and other settings"); + } + } +} + +#[test] +fn spacing_row_retains_disabled_values_and_percentage_editing_limits() { + let mut h = section_harness(0, BrushSettings { spacing: 0.42, spacing_enabled: false, ..Default::default() }); + let before = h.state().clone(); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(42.0)).unwrap(); + let from = pos2(field.rect().left() - 100.0, field.rect().bottom() + 9.0); + let to = pos2(field.rect().right() - 25.0, from.y); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.event(egui::Event::Text("999".into())); + h.key_press(egui::Key::Enter); + h.run_steps(2); + drag(&mut h, from, to); + assert_eq!(h.state(), &before, "Off ignores both field input and a track drag"); + let checkbox = h.get_by_role_and_label(Role::CheckBox, "Spacing"); + checkbox.click(); + h.run_steps(2); + assert!(h.state().spacing_enabled); + assert_eq!(h.state().spacing, 0.42, "enabling keeps the saved value"); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(42.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(h.state().spacing, 0.43, "integer percentage edits become a fraction"); + drag(&mut h, from, to); + assert!(h.state().spacing > 5.0, "the same track is editable when enabled"); + for (spacing, value, key) in [(0.01, 1.0, egui::Key::ArrowDown), (10.0, 1000.0, egui::Key::ArrowUp)] { + h.state_mut().spacing = spacing; + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(value)).unwrap(); + field.click(); + h.key_press(key); + h.run_steps(2); + assert_eq!(h.state().spacing, spacing, "the percentage stays within 1..1000"); + } +} + +#[test] +fn scatter_header_scattering_places_axes_between_label_and_percentage_in_all_themes() { + check_scatter_header(2); +} + +#[test] +fn scatter_header_dual_brush_places_axes_between_label_and_percentage_in_all_themes() { + check_scatter_header(4); +} + +#[test] +fn scatter_header_preserves_values_percentage_limits_and_label_scrubbing() { + for section in [2, 4] { + let mut b = BrushSettings::default(); + b.scattering.scatter.jitter = 2.137; + b.scattering.scatter.control = Control::PenPressure; + b.scattering.count = 5; + b.dual_brush.scatter = 2.137; + b.dual_brush.count = 7; + let mut h = section_harness(section, b.clone()); + h.get_by_label("Both Axes").click(); + h.run_steps(2); + if section == 2 { + b.scattering.both_axes = true; + } else { + b.dual_brush.both_axes = true; + } + assert_eq!(h.state(), &b, "a toggle does not round scatter or change unrelated fields"); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(214.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + if section == 2 { + b.scattering.scatter.jitter = 2.15; + } else { + b.dual_brush.scatter = 2.15; + } + assert_eq!(h.state(), &b, "percentage edits preserve the other brush block"); + let label = caption_rect(&h, "Scatter").center(); + drag(&mut h, label, label + vec2(18.0, 0.0)); + let amount = if section == 2 { h.state().scattering.scatter.jitter } else { h.state().dual_brush.scatter }; + assert!(amount > 2.15, "the label still scrubs the percentage across the checkbox"); + if section == 2 { + b.scattering.scatter.jitter = amount; + } else { + b.dual_brush.scatter = amount; + } + assert_eq!(h.state(), &b, "scrubbing changes only the scatter amount"); + let field = + h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(f64::from((amount * 100.0).round()))).unwrap(); + let from = pos2(field.rect().left() - 100.0, field.rect().bottom() + 9.0); + let to = pos2(field.rect().right() - 25.0, from.y); + drag(&mut h, from, to); + let amount = if section == 2 { h.state().scattering.scatter.jitter } else { h.state().dual_brush.scatter }; + assert!(amount > 5.0, "the existing amount track remains editable"); + if section == 2 { + b.scattering.scatter.jitter = amount; + } else { + b.dual_brush.scatter = amount; + } + assert_eq!(h.state(), &b); + for (amount, value, key) in [(0.0, 0.0, egui::Key::ArrowDown), (10.0, 1000.0, egui::Key::ArrowUp)] { + if section == 2 { + b.scattering.scatter.jitter = amount; + } else { + b.dual_brush.scatter = amount; + } + *h.state_mut() = b.clone(); + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(value)).unwrap(); + field.click(); + h.key_press(key); + h.run_steps(2); + assert_eq!(h.state(), &b, "the percentage stays within 0..1000"); + } + } +} + +fn tip_geometry_center(h: &Harness<'static, BrushSettings>) -> egui::Pos2 { + fn centre(shape: &egui::Shape) -> Option { + match shape { + egui::Shape::Circle(c) if c.radius == 40.0 => Some(c.center), + egui::Shape::Vec(shapes) => shapes.iter().find_map(centre), + _ => None, + } + } + h.output().shapes.iter().find_map(|shape| centre(&shape.shape)).unwrap() +} + +#[test] +fn tip_geometry_fields_precede_the_widget_and_labels_align_in_all_themes() { + for kind in theme::ThemeKind::ALL { + for width in [280.0, 340.0] { + let b = BrushSettings { angle: -23.0, roundness: 0.473, ..Default::default() }; + let mut h = section_harness_width(0, width, b.clone()); + crate::PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + let centre = tip_geometry_center(&h); + let angle = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(-23.0)).unwrap().rect(); + let roundness = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(47.0)).unwrap().rect(); + let angle_label = caption_rect(&h, "Angle:"); + let roundness_label = caption_rect(&h, "Roundness:"); + assert!(angle.right() + 2.0 < centre.x - 40.0 && roundness.right() + 2.0 < centre.x - 40.0, "{kind:?}, {width}: fields belong left of the widget"); + assert!((angle.left() - roundness.left()).abs() < 0.5, "{kind:?}, {width}: one numeric column"); + assert!((angle_label.right() - roundness_label.right()).abs() < 0.5, "{kind:?}, {width}: labels align to the right"); + assert!((angle_label.center().y - angle.center().y).abs() < 2.0 && (roundness_label.center().y - roundness.center().y).abs() < 2.0); + assert_eq!(h.state(), &b, "layout does not round imported values"); + } + } +} + +#[test] +fn tip_geometry_numeric_edits_keep_units_limits_and_other_settings() { + let mut b = BrushSettings { angle: -23.0, roundness: 0.473, hardness: 0.65, spacing: 0.31, flip_x: true, ..Default::default() }; + let mut h = section_harness(0, b.clone()); + let angle = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(-23.0)).unwrap(); + angle.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + b.angle = -22.0; + assert_eq!(h.state(), &b); + let roundness = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(47.0)).unwrap(); + roundness.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + b.roundness = 0.48; + assert_eq!(h.state(), &b, "percentage edits retain degrees and the rest of the brush"); + for (angle, key) in [(-180.0, egui::Key::ArrowDown), (180.0, egui::Key::ArrowUp)] { + b.angle = angle; + *h.state_mut() = b.clone(); + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(f64::from(angle))).unwrap(); + field.click(); + h.key_press(key); + h.run_steps(2); + assert_eq!(h.state(), &b, "angle stays within -180..180 degrees"); + } + for (value, key) in [(1.0, egui::Key::ArrowDown), (100.0, egui::Key::ArrowUp)] { + b.roundness = value / 100.0; + *h.state_mut() = b.clone(); + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(f64::from(value))).unwrap(); + field.click(); + h.key_press(key); + h.run_steps(2); + assert_eq!(h.state(), &b, "roundness stays within 1..100 percent"); + } +} + +#[test] +fn tip_geometry_pointer_drags_keep_angle_and_roundness_independent() { + let mut b = BrushSettings { roundness: 0.5, flip_x: true, ..Default::default() }; + let mut h = section_harness(0, b.clone()); + let c = tip_geometry_center(&h); + drag(&mut h, c + vec2(35.0, 0.0), c + vec2(0.0, -35.0)); + b.angle = 90.0; + assert_eq!(h.state(), &b, "the arrow changes only the angle"); + let handle = c + roundness_handles(b.angle, b.roundness)[0]; + // Acquire the dot before moving beyond its hit radius. + h.hover_at(handle); + h.run_steps(2); + h.drag_at(handle); + h.run_steps(1); + h.hover_at(handle + vec2(-6.5, 0.0)); + h.run_steps(2); + h.hover_at(c + vec2(-ELLIPSE_R * 0.8, 0.0)); + h.run_steps(2); + h.drop_at(c + vec2(-ELLIPSE_R * 0.8, 0.0)); + h.run_steps(2); + b.roundness = 0.8; + assert!((h.state().roundness - b.roundness).abs() < 1e-5, "a real handle drag keeps percentage units"); + b.roundness = h.state().roundness; + assert_eq!(h.state(), &b, "the dot changes only the roundness"); +} diff --git a/crates/ui-egui/src/panels/history.rs b/crates/ui-egui/src/panels/history.rs index 38d37970145..e75dbbb5b90 100644 --- a/crates/ui-egui/src/panels/history.rs +++ b/crates/ui-egui/src/panels/history.rs @@ -62,16 +62,7 @@ pub(super) fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.5)); } - let icon = match e.as_str() { - s if s.contains("Brush") || s.contains("Eraser") => "brush", - // The Layers panel's controls (layer.setProps). - "Opacity Change" | "Fill Opacity Change" | "Blending Change" => "layers", - s if s.contains("Layer") => "layers", - s if s.contains("Select") || s.contains("Marquee") || s.contains("Deselect") || s.contains("Lasso") => "square-dashed", - s if s.contains("Fill") => "paint-bucket", - "Open" => "image", - _ => "sliders-horizontal", - }; + let icon = step_icon(&e); let color = if is_redo { t.text_faint } else { t.text }; icons::paint( ui, @@ -194,6 +185,24 @@ pub(super) fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// The icon of a History step, from its name. +pub(crate) fn step_icon(step: &str) -> &'static str { + match step { + s if s.contains("Brush") || s.contains("Eraser") => "brush", + // Applying a patch edits pixels; only the Patch tool's lasso is a selection step. + "Patch Selection" => "sliders-horizontal", + // The Layers panel's controls (layer.setProps). + "Opacity Change" | "Fill Opacity Change" | "Blending Change" => "layers", + s if s.contains("Layer") => "layers", + s if s.contains("Select") || s.contains("Marquee") || s.contains("Deselect") || s.contains("Lasso") => "square-dashed", + // The selections the Patch tool draws (select.lasso's historyName). + "Patch Tool" => "square-dashed", + s if s.contains("Fill") => "paint-bucket", + "Open" => "image", + _ => "sliders-horizontal", + } +} + /// The History panel's source column at the left of a state or snapshot row: the History Brush /// icon on the source; a click makes the row the source (`history.setSource`). It sits above the /// row, so the click doesn't also step to the state or snapshot. diff --git a/crates/ui-egui/src/panels/layer_filter_ui.rs b/crates/ui-egui/src/panels/layer_filter_ui.rs index 1c439872a3f..ba7898bbec0 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui.rs @@ -323,10 +323,7 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } } - FilterBy::Name => { - let width = ui.available_width().max(48.0); - widgets::text_field(ui, ui.scope_id().with("layer-filter-name"), &mut app.ui.layer_filter_name, tl!("Search"), width, false); - } + FilterBy::Name => find::name_field(app, ui), FilterBy::Effect => effect::controls(app, ui), FilterBy::Mode => { // The 27 layer modes, without Pass Through: a group set to Pass Through is never @@ -369,6 +366,7 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } pub mod effect; +pub mod find; mod group; pub mod selection; @@ -380,6 +378,8 @@ pub mod attribute; #[cfg(test)] mod attribute_tests; #[cfg(test)] +mod find_tests; +#[cfg(test)] mod selection_tests; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panels/layer_filter_ui/find.rs b/crates/ui-egui/src/panels/layer_filter_ui/find.rs new file mode 100644 index 00000000000..ee437906cf6 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/find.rs @@ -0,0 +1,80 @@ +//! #10884: Select › Find Layers (⌥⇧⌘F) filters the Layers panel by Name, as in Photoshop: it shows +//! the Layers panel, switches the filter to Name and puts the caret in its field with the text +//! selected, so typing replaces it. The layer selection is not changed. With a `name` param +//! (scripts and agents) it sets the Name text to it instead of focusing the field. + +use serde_json::{Value, json}; + +use super::FilterBy; +use crate::{PhotocraftApp, widgets}; + +/// The egui memory flag: focus the Name field the next time it is drawn. +fn focus_flag() -> egui::Id { + egui::Id::unique("layer-filter-find-focus") +} + +/// Run `select.findLayers`: the engine checks `params` and lists the layers the Name text matches; +/// then the Name filter is applied. Without a `name` the text is kept, and the Layers panel is +/// shown with the field focused. +pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, params: Value) -> Result { + let given = params.get("name").is_some_and(|n| !n.is_null()); + // `run` keeps the Name text when no `name` is given ([`params_for_run`]) and applies the + // reply's Name filter ([`apply`]). + let reply = app.run("select.findLayers", params)?; + if !given { + // The panel docking layout (when on) shows the Layers panel itself, else the dock group. + if app.ui.docking.is_some() { + if let Some(shown) = crate::panel_docking::command(app, "window.panel.activate", &json!({"panel": "layers"})) { + shown?; + } + } else { + *crate::dock::Group::Layers.tab_mut(&mut app.ui.dock_tabs) = 0; + crate::dock::reveal(app, crate::dock::Group::Layers); + } + // A hidden filter row (Filter Options › Hide) stays hidden, as in Photoshop, and showing it + // later doesn't put the caret in the field: only a drawn row takes the focus. + if app.ui.layers_panel_options.show_filters { + ctx.data_mut(|d| d.insert_temp(focus_flag(), true)); + } + } + Ok(reply) +} + +/// The params `select.findLayers` runs with: without a `name` the panel's Name text is kept, so the +/// reply lists the layers the panel lists however the command runs (the menu, the shortcut, +/// `engine.execute` or an action step). +pub(crate) fn params_for_run(app: &PhotocraftApp, params: Value) -> Value { + if params.get("name").is_some_and(|n| !n.is_null()) { + return params; + } + let mut params = if params.is_object() { params } else { json!({}) }; + params["name"] = json!(app.ui.layer_filter_name); + params +} + +/// Apply the Name filter in a `select.findLayers` reply to the Layers panel: switch to Name and set +/// the text when the reply has one. [`PhotocraftApp::run`] calls it after every Find Layers. +pub(crate) fn apply(app: &mut PhotocraftApp, reply: &Value) { + app.ui.layer_filter_by = FilterBy::Name; + if let Some(name) = reply["layerFilter"]["name"].as_str() { + app.ui.layer_filter_name = name.to_string(); + } +} + +/// The Name filter's text field, taking the focus with its text selected after [`invoke`]. The +/// focus waits for a frame without a pointer press or release: egui drops the focus at the end of +/// a frame in which the pointer was clicked elsewhere, as it is when the menu item is clicked. +pub fn name_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let id = ui.scope_id().with("layer-filter-name"); + let pending = ui.ctx().data(|d| d.get_temp::(focus_flag())).unwrap_or(false); + let clicking = ui.input(|i| i.pointer.any_pressed() || i.pointer.any_released() || i.pointer.any_down()); + if pending && clicking { + ui.ctx().request_repaint(); + } else if pending { + ui.ctx().data_mut(|d| d.remove_temp::(focus_flag())); + ui.memory_mut(|m| m.request_focus(id)); + widgets::field_tab::select_all(ui.ctx(), id, &app.ui.layer_filter_name); + } + let width = ui.available_width().max(48.0); + widgets::text_field(ui, id, &mut app.ui.layer_filter_name, tl!("Search"), width, false); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs new file mode 100644 index 00000000000..8a7a00d8545 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs @@ -0,0 +1,192 @@ +//! #10884: Select › Find Layers opens the Layers panel's Name filter, against the cases measured in +//! Photoshop. + +use egui::vec2; +use egui_kittest::Harness; +use photocraft_doc::LayerId; +use serde_json::json; + +use super::{Filter, FilterBy}; +use crate::PhotocraftApp; + +/// "LinkA", "Other" and "LinkB" over the Background, "Other" selected. +fn harness() -> Harness<'static, PhotocraftApp> { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + for name in ["LinkA", "Other", "LinkB"] { + s.execute("layer.new.layer", json!({"name": name})).unwrap(); + } + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + h.run_steps(8); + let other = id(&h, "Other"); + h.state_mut().session.execute("layer.select", json!({"layer": other.0})).unwrap(); + h.run_steps(4); + h +} + +fn id(h: &Harness<'_, PhotocraftApp>, name: &str) -> LayerId { + h.state() + .session + .active() + .unwrap() + .doc + .walk() + .into_iter() + .find(|(_, _, l)| l.name == name) + .map(|(_, _, l)| l.id) + .unwrap_or_else(|| panic!("no layer {name}")) +} + +fn selected(h: &Harness<'_, PhotocraftApp>) -> Vec { + h.state().session.active().unwrap().selected_layers() +} + +/// The names the panel's filter lists, top to bottom. +fn listed(h: &Harness<'_, PhotocraftApp>) -> Vec { + let f = Filter::of_app(h.state()); + let doc = &h.state().session.active().unwrap().doc; + crate::layer_tree_ui::display_rows(doc, f.active()).into_iter().filter(|(_, l)| f.shows(l)).map(|(_, l)| l.name.clone()).collect() +} + +fn find(h: &mut Harness<'_, PhotocraftApp>, params: serde_json::Value) -> serde_json::Value { + let ctx = h.ctx.clone(); + let reply = crate::menus::invoke(h.state_mut(), &ctx, "select.findLayers", params).unwrap(); + h.run_steps(4); + reply +} + +#[test] +fn the_menu_item_shows_the_layers_panel_and_focuses_the_name_field() { + let mut h = harness(); + h.state_mut().ui.panels.layers = false; + h.state_mut().ui.layer_filter_name = "oth".into(); + h.run_steps(4); + let before = selected(&h); + find(&mut h, json!({})); + assert_eq!(selected(&h), before, "Find Layers does not change the layer selection"); + assert!(h.state().ui.panels.layers, "the Layers panel is shown"); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + assert!(h.ctx.memory(|m| m.focused()).is_some(), "the caret is in the Name field"); + // The existing text is selected, so typing replaces it. + h.event(egui::Event::Text("lin".into())); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "lin"); + assert_eq!(listed(&h), ["LinkB", "LinkA"]); + assert_eq!(selected(&h), [], "the typed filter hides \"Other\", which is deselected (#10879); no match is selected"); +} + +#[test] +fn a_name_param_sets_the_filter_text_without_changing_the_selection() { + let mut h = harness(); + let link_a = id(&h, "LinkA"); + h.state_mut().session.execute("layer.select", json!({"layer": link_a.0})).unwrap(); + h.run_steps(4); + let reply = find(&mut h, json!({"name": "link"})); + assert_eq!((h.state().ui.layer_filter_by, h.state().ui.layer_filter_name.as_str()), (FilterBy::Name, "link")); + assert_eq!(reply["selected"], json!([id(&h, "LinkA").0, id(&h, "LinkB").0]), "the reply lists the matching layers"); + assert_eq!(listed(&h), ["LinkB", "LinkA"]); + assert_eq!(selected(&h), [link_a], "LinkA stays selected; LinkB, also listed, isn't selected"); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "a script does not take the keyboard focus"); +} + +#[test] +fn the_shortcut_runs_find_layers() { + let mut h = harness(); + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::ALT | egui::Modifiers::SHIFT, egui::Key::F); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + assert!(h.ctx.memory(|m| m.focused()).is_some(), "the caret is in the Name field"); +} + +#[test] +fn clicking_the_menu_item_leaves_the_caret_in_the_name_field() { + use egui_kittest::kittest::Queryable; + let mut h = harness(); + h.get_by_label("Select").click(); + h.run_steps(4); + h.get_by_label("Find Layers Ctrl+Alt+Shift+F").click(); + h.run_steps(6); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + h.event(egui::Event::Text("lin".into())); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "lin", "typing goes to the Name field"); +} + +#[test] +fn the_shortcut_shows_a_layers_tab_closed_from_the_window_menu() { + let mut h = harness(); + use egui_kittest::kittest::Queryable; + h.get_by_label("Window").click(); + h.run_steps(4); + h.get_by_label("✔ Layers F7").click(); + h.run_steps(4); + assert!(!h.state().ui.panels.layers, "Window › Layers closed the panel"); + h.state_mut().ui.layer_filter_name = "lin".into(); + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::ALT | egui::Modifiers::SHIFT, egui::Key::F); + h.run_steps(6); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + h.event(egui::Event::Text("other".into())); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "other", "the panel is back, focused, with its text selected"); +} + +#[test] +fn the_menu_item_shows_a_layers_panel_closed_in_the_docking_layout() { + let mut h = harness(); + h.state_mut().run("window.panel.move", json!({"panel": "swatches", "anchor": "layers", "zone": "top"})).unwrap(); + h.state_mut().run("window.panel.close", json!({"panel": "layers"})).unwrap(); + h.run_steps(4); + let shown = |h: &Harness<'_, PhotocraftApp>| h.state().ui.docking.as_ref().is_some_and(|w| w.layout.contains(&"layers".to_string())); + assert!(!shown(&h), "the Layers panel is closed"); + h.state_mut().ui.layer_filter_name = "lin".into(); + find(&mut h, json!({})); + assert!(shown(&h), "Find Layers shows the Layers panel again"); + h.event(egui::Event::Text("other".into())); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "other", "the Name field has the focus with its text selected"); +} + +#[test] +fn a_hidden_filter_row_stays_hidden_and_does_not_take_the_focus_later() { + // Measured in Photoshop: with Filter Options › Hide, Find Layers switches to Name but leaves + // the row hidden, and showing the row afterwards does not put the caret in the field. + let mut h = harness(); + h.state_mut().ui.layers_panel_options.show_filters = false; + h.state_mut().ui.layer_filter_name = "lin".into(); + h.run_steps(4); + find(&mut h, json!({})); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + assert!(!h.state().ui.layers_panel_options.show_filters, "the filter row stays hidden"); + h.state_mut().ui.layers_panel_options.show_filters = true; + h.run_steps(4); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "showing the row later does not focus the Name field"); + assert_eq!(h.state().ui.layer_filter_name, "lin"); +} + +#[test] +fn engine_execute_without_a_name_lists_what_the_panel_lists() { + let mut h = harness(); + h.state_mut().ui.layer_filter_name = "link".into(); + let reply = h.state_mut().run("select.findLayers", json!({})).unwrap(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "link", "the Name text is kept"); + assert_eq!(reply["selected"], json!([id(&h, "LinkA").0, id(&h, "LinkB").0]), "the reply lists the layers the kept text matches"); + assert_eq!(reply["layerFilter"], json!({"by": "name", "name": "link"})); + assert_eq!(listed(&h), ["LinkB", "LinkA"]); +} + +#[test] +fn engine_execute_sets_the_panel_filter_too() { + // `engine.execute` runs engine commands through `PhotocraftApp::run`, not `menus::invoke`. + let mut h = harness(); + h.state_mut().ui.layer_filter_by = FilterBy::Kind; + h.state_mut().run("select.findLayers", json!({"name": "link"})).unwrap(); + h.run_steps(4); + assert_eq!((h.state().ui.layer_filter_by, h.state().ui.layer_filter_name.as_str()), (FilterBy::Name, "link")); + assert_eq!(listed(&h), ["LinkB", "LinkA"]); + assert_eq!(selected(&h), [], "the filter hides \"Other\" (#10879) and selects none of the matches"); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "a script does not take the keyboard focus"); +} diff --git a/crates/ui-egui/src/panels/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs index 34e4042f482..2f590921067 100644 --- a/crates/ui-egui/src/panels/options_bar.rs +++ b/crates/ui-egui/src/panels/options_bar.rs @@ -1,4 +1,6 @@ -use super::brush_controls::{brush_preset_chip, mixer_load_swatch, paint_mode_dropdown, smoothing_field, smoothing_options, tool_mode_dropdown}; +use super::brush_controls::{ + brush_preset_chip, mixer_combination_dropdown, mixer_load_swatch, paint_mode_dropdown, smoothing_field, smoothing_options, tool_mode_dropdown, +}; use super::properties::{select_and_mask_button, selection_mode_buttons}; use super::{BlendMode, Color32, PhotocraftApp, RichText, Sense, Tokens, Tool, Value, icons, json, starts_blend_group, vec2, widgets}; use photocraft_paint::BrushSettings; @@ -179,7 +181,8 @@ fn brush_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, } } ui.add_enabled_ui(!block, |ui| { - tip(percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 62.0), tl!("Set opacity for stroke")); + // A whole number from 1 to 100, as in Photoshop: 0 paints nothing (#11812). + tip(whole_percent_field(ui, tl!("Opacity"), &mut b.opacity, 1.0..=100.0, 62.0), tl!("Set opacity for stroke")); let pressure = pressure_button(ui, b.pressure_opacity, tl!("Always use pressure for opacity"), brush); if tip(pressure, tl!("Always use Pressure for Opacity. When off, Brush Preset controls pressure")).clicked() { b.pressure_opacity = !b.pressure_opacity; @@ -215,11 +218,12 @@ fn brush_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, opt_label(ui, tl!("Mode")); let held = app.held_erase; paint_mode_dropdown(ui, "pencil-mode", b, &mut app.ui.tool_options.pencil_clear, held); - percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, if t.pro { 62.0 } else { 66.0 }); + whole_percent_field(ui, tl!("Opacity"), &mut b.opacity, 1.0..=100.0, if t.pro { 62.0 } else { 66.0 }); opt_label(ui, tl!("Smoothing")); smoothing_field(ui, b, if t.pro { 58.0 } else { 66.0 }); widgets::vline(ui, 22.0); - widgets::checkbox(ui, &mut app.ui.tool_options.pencil_auto_erase, tl!("Auto Erase")); + widgets::checkbox(ui, &mut app.ui.tool_options.pencil_auto_erase, tl!("Auto Erase")) + .on_hover_text(tl!("Draws background color over foreground colors")); } Tool::Brush | Tool::Eraser => { brush_preset_chip(ui, b, &mut app.ui); @@ -248,7 +252,7 @@ fn brush_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, // 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)); - let opacity = ui.add_enabled_ui(!block, |ui| percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 66.0)).inner; + let opacity = ui.add_enabled_ui(!block, |ui| whole_percent_field(ui, tl!("Opacity"), &mut b.opacity, 1.0..=100.0, 66.0)).inner; tip(opacity, tl!("Set opacity for stroke")); let flow = ui.add_enabled_ui(!fixed, |ui| percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 66.0)).inner; tip(flow, tl!("Set flow rate for stroke")); @@ -278,6 +282,9 @@ fn brush_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, *on = !*on; } } + if let Some(r) = mixer_combination_dropdown(ui, &mut app.ui.tool_options.mixer_combination, &mut b.mixer) { + r.on_hover_text(tl!("Useful mixer brush combinations")); + } widgets::vline(ui, 22.0); for (label, value) in [(tl!("Wet"), &mut b.mixer.wet), (tl!("Load"), &mut b.mixer.load), (tl!("Mix"), &mut b.mixer.mix), (tl!("Flow"), &mut b.mixer.flow)] @@ -634,7 +641,7 @@ fn tool_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, "move-target", tl!("Auto-Select:"), &mut o.move_target, - &[("layer".to_string(), tl!("Layer")), ("group".to_string(), tl!("Group"))], + &[("group".to_string(), tl!("Group")), ("layer".to_string(), tl!("Layer"))], 76.0, ); widgets::vline(ui, 22.0); @@ -648,25 +655,13 @@ fn tool_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, // Resize Windows to Fit is Preferences › Zoom Resizes Windows itself, as in // Photoshop: one setting, shown in both places (zoom_window_fit.rs). let mut fit = app.session.prefs().general.zoom_resizes_windows; - if widgets::checkbox(ui, &mut fit, tl!("Resize Windows to Fit")).changed() { + if widgets::checkbox(ui, &mut fit, tl!("Resize Windows to Fit")).on_hover_text(tl!("Resize windows when zooming using the Zoom Tool")).changed() { app.session.prefs.edit(|p| p.general.zoom_resizes_windows = fit); } - widgets::checkbox(ui, &mut app.ui.tool_options.zoom_scrubby, tl!("Scrubby Zoom")); - hint( - ui, - &crate::i18n::fmt( - tl!("Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out"), - &[("key", &crate::shortcuts::pretty("Alt"))], - ), - ); - if widgets::secondary_button(ui, tl!("Fit Screen"), 0.0).clicked() - && let Some(i) = app.session.active_index() - { - app.ui.views[i].fit_pending = true; - app.ui.views[i].request_zoom_animation(app.session.prefs().general.animated_zoom); - app.ui.views[i].zoom_anchor = None; - } - if widgets::secondary_button(ui, "100%", 0.0).clicked() + widgets::checkbox(ui, &mut app.ui.tool_options.zoom_scrubby, tl!("Scrubby Zoom")) + .on_hover_text(tl!("Click and move left or right to scale using the Zoom Tool (OpenGL only)")); + // The same three buttons as the Hand bar, with the reference tooltips (#12068). + if widgets::secondary_button(ui, "100%", 0.0).on_hover_text(tl!("Zoom current window to 1:1")).clicked() && let Some(i) = app.session.active_index() { app.ui.views[i].zoom = 1.0; @@ -675,6 +670,22 @@ fn tool_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, app.ui.views[i].fit_pending = false; app.ui.views[i].fill_pending = false; } + if widgets::secondary_button(ui, tl!("Fit Screen"), 0.0).on_hover_text(tl!("Zoom current window to screen size")).clicked() + && let Some(i) = app.session.active_index() + { + app.ui.views[i].fit_pending = true; + app.ui.views[i].request_zoom_animation(app.session.prefs().general.animated_zoom); + app.ui.views[i].zoom_anchor = None; + app.ui.views[i].fill_pending = false; + } + if widgets::secondary_button(ui, tl!("Fill Screen"), 0.0).on_hover_text(tl!("Zoom current window to fill the screen")).clicked() + && let Some(i) = app.session.active_index() + { + app.ui.views[i].fill_pending = true; + app.ui.views[i].request_zoom_animation(app.session.prefs().general.animated_zoom); + app.ui.views[i].zoom_anchor = None; + app.ui.views[i].fit_pending = false; + } } Tool::RotateView => { hint(ui, tl!("Drag around the center to rotate the view · Shift constrains to 15°")); @@ -726,6 +737,9 @@ fn tool_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, } } +#[cfg(test)] +mod zoom_buttons_tests; + /// Width of the Layers panel's Opacity and Fill fields, with room for their pop-up slider's ▾. pub(super) const LAYER_PCT_W: f32 = 66.0 + widgets::POPUP_ARROW_W; @@ -835,6 +849,18 @@ fn pressure_button(ui: &mut egui::Ui, on: bool, name: &str, own_tip: bool) -> eg icons::button_with_icon_size_tip(ui, "circle-dot", 24.0, (24.0f32 * 0.52).round(), on, name, !own_tip) } +/// [`percent_field`] that keeps whole percents (`33.7` becomes 34 %). A value set elsewhere (a +/// `tools.setBrush` fraction, an older saved 0) shows as the whole percent in range. +fn whole_percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive, width: f32) -> egui::Response { + opt_label(ui, label); + let mut percent = (*value * 100.0).round().max(*range.start()).min(*range.end()); + let r = widgets::value_field_named(ui, label, &mut percent, range.clone(), "%", width); + if r.changed() { + *value = percent.round().max(*range.start()).min(*range.end()) / 100.0; + } + r +} + fn hint(ui: &mut egui::Ui, s: &str) { let t = Tokens::get(ui.ctx()); ui.label(RichText::new(s).color(t.text_faint)); diff --git a/crates/ui-egui/src/panels/options_bar/zoom_buttons_tests.rs b/crates/ui-egui/src/panels/options_bar/zoom_buttons_tests.rs new file mode 100644 index 00000000000..ce00ce0bc72 --- /dev/null +++ b/crates/ui-egui/src/panels/options_bar/zoom_buttons_tests.rs @@ -0,0 +1,69 @@ +//! #12068: the Zoom tool's bar shows 100%, Fit Screen and Fill Screen in that order, with the +//! reference tooltips, and no hint text. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +fn zoom_bar() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 3000, "height": 2000})).unwrap(); + // The bar's fonts are installed after the first frame, so it starts on a tool without "100%". + app.ui.tool = Tool::Move; + let mut h = Harness::builder().with_size(vec2(1600.0, 300.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app| crate::panels::options_bar(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.state_mut().ui.tool = Tool::Zoom; + h.run_steps(4); + h +} + +#[test] +fn the_buttons_are_100_fit_and_fill_in_that_order() { + let h = zoom_bar(); + let x = |label: &str| h.get_by_label(label).rect().center().x; + assert!(x("Scrubby Zoom") < x("100%"), "after the checkboxes"); + assert!(x("100%") < x("Fit Screen") && x("Fit Screen") < x("Fill Screen")); +} + +#[test] +fn each_control_has_the_reference_tooltip() { + for (label, tip) in [ + ("100%", "Zoom current window to 1:1"), + ("Fit Screen", "Zoom current window to screen size"), + ("Fill Screen", "Zoom current window to fill the screen"), + ("Resize Windows to Fit", "Resize windows when zooming using the Zoom Tool"), + ("Scrubby Zoom", "Click and move left or right to scale using the Zoom Tool (OpenGL only)"), + ] { + let mut h = zoom_bar(); + h.get_by_label(label).hover(); + h.run_steps(60); + assert!(h.query_by_label(tip).is_some(), "{label}: no tooltip {tip:?}"); + } +} + +#[test] +fn the_bar_shows_no_hint_text() { + let h = zoom_bar(); + assert!(h.query_by_label_contains("to zoom out").is_none()); + assert!(h.query_by_label_contains("Click to zoom in").is_none()); +} + +#[test] +fn fill_screen_fills_as_on_the_hand_bar() { + let mut h = zoom_bar(); + h.get_by_label("Fit Screen").click(); + h.run_steps(2); + assert!(h.state().ui.views[0].fit_pending); + h.get_by_label("Fill Screen").click(); + h.run_steps(2); + let v = &h.state().ui.views[0]; + assert!(v.fill_pending && !v.fit_pending); + h.get_by_label("100%").click(); + h.run_steps(2); + let v = &h.state().ui.views[0]; + assert!(!v.fill_pending && !v.fit_pending && v.zoom == 1.0); +} diff --git a/crates/ui-egui/src/panels/smart_ui.rs b/crates/ui-egui/src/panels/smart_ui.rs index 34258ab9099..56234d8f51d 100644 --- a/crates/ui-egui/src/panels/smart_ui.rs +++ b/crates/ui-egui/src/panels/smart_ui.rs @@ -496,7 +496,8 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: return; } }; - let Some(id) = crate::adjust_dialog::open(app, &f.command).or_else(|| { + // A smart filter's dialog shows the filter's own settings, never the last-used ones. + let Some(id) = crate::adjust_dialog::open_neutral(app, &f.command).or_else(|| { let id = crate::filter_dialog::has_dialog(&f.command).then(|| crate::filter_dialog::open(app, &f.command)).flatten()?; // The filter dialog opens on the user's last-used values; a recorded filter that lacks a // key must show (and save) the schema default instead. @@ -507,10 +508,13 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: }) else { return; }; + // A recorded Photo Filter that names only its preset gets the preset's colour, which the + // dialog would otherwise fill in with its neutral one; recorded values stay as they were. + let params = crate::adjust_dialog::complete_params(app, &f.command, &f.params).unwrap_or_else(|| f.params.clone()); let mode = app.session.active().map(|st| st.doc.mode); if let Some(d) = app.ui.dialog_mut(id) { d.fields.insert("__smartFilter".into(), target); - if let Value::Object(p) = &f.params { + if let Value::Object(p) = ¶ms { for (k, v) in p { if !k.starts_with("__") { d.fields.insert(k.clone(), v.clone()); diff --git a/crates/ui-egui/src/panels/toolbar.rs b/crates/ui-egui/src/panels/toolbar.rs index 12f85de2c10..da8500f5351 100644 --- a/crates/ui-egui/src/panels/toolbar.rs +++ b/crates/ui-egui/src/panels/toolbar.rs @@ -30,7 +30,7 @@ pub(crate) const TOOL_SECTIONS: &[&[&[Tool]]] = &[ &[Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::AddAnchorPoint, Tool::DeleteAnchorPoint, Tool::ConvertPoint], &[Tool::Type, Tool::VerticalType, Tool::VerticalTypeMask, Tool::HorizontalTypeMask], &[Tool::PathSelection, Tool::DirectSelection], - &[Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line, Tool::CustomShape], + &[Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Star, Tool::Line, Tool::CustomShape], ], &[&[Tool::Hand, Tool::RotateView], &[Tool::Zoom]], ]; diff --git a/crates/ui-egui/src/patch_cursor_badge_tests.rs b/crates/ui-egui/src/patch_cursor_badge_tests.rs new file mode 100644 index 00000000000..e9cf4b1f0ba --- /dev/null +++ b/crates/ui-egui/src/patch_cursor_badge_tests.rs @@ -0,0 +1,119 @@ +//! The Patch tool's cursor shows a right-pointing arrow over the selection (#12130). + +use egui::Modifiers; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::Drag; +use crate::state::Tool; +use crate::tool_feedback::{Badge, badge, badge_glyph}; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("select.rect", json!({"x": 20, "y": 20, "width": 30, "height": 30})).unwrap(); + app +} + +const INSIDE: [f64; 2] = [30.0, 30.0]; +const OUTSIDE: [f64; 2] = [80.0, 80.0]; + +#[test] +fn arrow_over_the_selection_without_modifiers() { + let mut app = app(); + app.hover_doc = Some(INSIDE); + assert_eq!(badge(&app, Tool::Patch, Modifiers::NONE), Some(Badge::Arrow)); + assert_ne!(Badge::Arrow, Badge::Move, "a new single-arrow glyph"); + // Size 4 is the half-length h of the shaft; the barbs fall back by a = 0.6 h. + let (h, a, eps) = (4.0_f32, 2.4_f32, 1e-5_f32); + let glyph = badge_glyph(Badge::Arrow, egui::pos2(0.0, 0.0), h); + assert_eq!(glyph.len(), 3, "one shaft and two barbs"); + let near = |p: egui::Pos2, x: f32, y: f32| (p.x - x).abs() < eps && (p.y - y).abs() < eps; + assert!(near(glyph[0][0], -h, 0.0) && near(glyph[0][1], h, 0.0), "shaft through the centre: {:?}", glyph[0]); + for barb in &glyph[1..] { + assert!(near(barb[0], h, 0.0), "a barb starts at the tip: {barb:?}"); + } + let (up, down) = (glyph[1][1], glyph[2][1]); + assert!((up.x - (h - a)).abs() < eps && (down.x - (h - a)).abs() < eps, "barbs end behind the tip"); + assert!((up.y + down.y).abs() < eps && (up.y.abs() - a).abs() < eps, "barb ends mirror about the shaft: {up:?} {down:?}"); +} + +#[test] +fn the_arrow_stays_during_a_drag_started_inside() { + let mut app = app(); + app.drag = Some(Drag::new(Tool::Patch, INSIDE, vec![[INSIDE[0], INSIDE[1], 1.0]], Modifiers::NONE, false)); + app.hover_doc = Some(OUTSIDE); + assert_eq!(badge(&app, Tool::Patch, Modifiers::NONE), Some(Badge::Arrow)); +} + +#[test] +fn outside_and_with_shift_or_alt_the_badges_are_as_before() { + let mut app = app(); + app.hover_doc = Some(OUTSIDE); + assert_eq!(badge(&app, Tool::Patch, Modifiers::NONE), None); + app.hover_doc = Some(INSIDE); + assert_eq!(badge(&app, Tool::Patch, Modifiers::SHIFT), Some(Badge::Add)); + assert_eq!(badge(&app, Tool::Patch, Modifiers::ALT), Some(Badge::Subtract)); +} + +#[test] +fn a_patch_drag_started_outside_does_not_show_the_arrow_when_it_enters_the_selection() { + let mut app = app(); + app.hover_doc = Some(OUTSIDE); + let plain_outside = badge(&app, Tool::Patch, Modifiers::NONE); + app.drag = Some(Drag::new(Tool::Patch, OUTSIDE, vec![[OUTSIDE[0], OUTSIDE[1], 1.0]], Modifiers::NONE, false)); + app.hover_doc = Some(INSIDE); + assert_eq!(badge(&app, Tool::Patch, Modifiers::NONE), plain_outside); + assert_ne!(badge(&app, Tool::Patch, Modifiers::NONE), Some(Badge::Arrow)); +} + +#[test] +fn content_aware_move_has_no_arrow_even_in_a_drag_started_inside() { + let mut app = app(); + app.drag = Some(Drag::new(Tool::ContentAwareMove, INSIDE, vec![[INSIDE[0], INSIDE[1], 1.0]], Modifiers::NONE, false)); + app.hover_doc = Some(INSIDE); + assert_eq!(badge(&app, Tool::ContentAwareMove, Modifiers::NONE), None); +} + +/// The release reads the press's modifiers, so ⇧ or ⌥ pressed mid-drag changes nothing. +#[test] +fn modifiers_pressed_mid_drag_do_not_change_the_badge() { + let mut app = app(); + app.drag = Some(Drag::new(Tool::Patch, INSIDE, vec![[INSIDE[0], INSIDE[1], 1.0]], Modifiers::NONE, false)); + app.hover_doc = Some(OUTSIDE); + for m in [Modifiers::SHIFT, Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT] { + assert_eq!(badge(&app, Tool::Patch, m), Some(Badge::Arrow), "{m:?}"); + } +} + +#[test] +fn a_drag_pressed_with_shift_or_alt_keeps_that_badge() { + let mut app = app(); + app.hover_doc = Some(INSIDE); + for (pressed, want) in [(Modifiers::SHIFT, Badge::Add), (Modifiers::ALT, Badge::Subtract)] { + app.drag = Some(Drag::new(Tool::Patch, INSIDE, vec![[INSIDE[0], INSIDE[1], 1.0]], pressed, false)); + assert_eq!(badge(&app, Tool::Patch, pressed), Some(want)); + assert_eq!(badge(&app, Tool::Patch, Modifiers::NONE), Some(want), "released keys mid-drag"); + } +} + +#[test] +fn shift_and_alt_together_over_the_selection_intersect() { + let mut app = app(); + app.hover_doc = Some(INSIDE); + assert_eq!(badge(&app, Tool::Patch, Modifiers::SHIFT | Modifiers::ALT), Some(Badge::Intersect)); +} + +#[test] +fn content_aware_move_and_the_curvature_pen_keep_their_cursors() { + let mut app = app(); + app.hover_doc = Some(INSIDE); + assert_eq!(badge(&app, Tool::ContentAwareMove, Modifiers::NONE), None); + // The Curvature Pen over an anchor of the active path: four arrows. + let path = json!({"subpaths": [{"closed": true, "knots": [[40, 40], [160, 40], [160, 160], [40, 160]]}]}); + app.run("path.set", json!({"name": "work", "path": path})).unwrap(); + app.ui.tool = Tool::CurvaturePen; + app.hover_doc = Some([160.0, 40.0]); + assert_eq!(badge(&app, Tool::CurvaturePen, Modifiers::NONE), Some(Badge::Move)); + assert_eq!(badge_glyph(Badge::Move, egui::pos2(0.0, 0.0), 4.0).len(), 10, "four-arrow glyph unchanged"); +} diff --git a/crates/ui-egui/src/patch_sample_all_default_tests.rs b/crates/ui-egui/src/patch_sample_all_default_tests.rs new file mode 100644 index 00000000000..6a78f6111a8 --- /dev/null +++ b/crates/ui-egui/src/patch_sample_all_default_tests.rs @@ -0,0 +1,64 @@ +//! The Patch tool's Content-Aware Sample All Layers starts on (#12125): a fresh start and Reset +//! Tool give the Patch tool Sample All Layers on while other tools stay off, and saved settings +//! keep their stored value. + +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::{SampleAllLayers, Tool, ToolOptions}; + +fn app() -> PhotocraftApp { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + PhotocraftApp::new(s, crate::Services::default()) +} + +#[test] +fn patch_sample_all_layers_starts_on_and_other_tools_start_off() { + let defaults = SampleAllLayers::default(); + assert!(defaults.patch); + assert!(defaults.get(Tool::Patch)); + for tool in Tool::ALL { + if tool != Tool::Patch { + assert!(!defaults.get(tool), "{tool:?} should default to Sample All Layers off"); + } + } + + let app = app(); + assert!(app.ui.tool_options.sample_all.patch); +} + +#[test] +fn reset_tool_restores_patch_sample_all_layers_to_on() { + let mut app = app(); + let ctx = egui::Context::default(); + app.ui.tool = Tool::Patch; + app.ui.tool_options.sample_all.patch = false; + app.ui.tool_options.sample_all.magic_wand = true; + + crate::menus::invoke(&mut app, &ctx, super::RESET, json!({})).unwrap(); + assert!(app.ui.tool_options.sample_all.patch, "Reset Tool restores Patch Sample All Layers to on"); + assert!(app.ui.tool_options.sample_all.magic_wand, "other tools keep their Sample All Layers setting"); +} + +#[test] +fn saved_settings_keep_stored_patch_sample_all_layers_value() { + let saved_off: ToolOptions = serde_json::from_value(json!({ + "sample_all": { + "patch": false, + "magic_wand": true + } + })) + .unwrap(); + assert!(!saved_off.sample_all.patch); + assert!(saved_off.sample_all.magic_wand); + + let missing_patch: ToolOptions = serde_json::from_value(json!({ + "sample_all": { + "magic_wand": true + } + })) + .unwrap(); + assert!(missing_patch.sample_all.patch); + assert!(missing_patch.sample_all.magic_wand); +} diff --git a/crates/ui-egui/src/patch_shift_constrain_tests.rs b/crates/ui-egui/src/patch_shift_constrain_tests.rs new file mode 100644 index 00000000000..590d4ed5b5a --- /dev/null +++ b/crates/ui-egui/src/patch_shift_constrain_tests.rs @@ -0,0 +1,103 @@ +//! #12129: ⇧ pressed during a Patch drag (after the press) keeps the drag horizontal or vertical. +//! Only 0 and 90 degrees snap; ⇧ at the press still adds to the selection. + +use egui::Modifiers; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +/// A 500 x 400 document with 100..200 square selected, the Patch tool active. +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 500, "height": 400, "background": "white"})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 100, "mode": "replace"})).unwrap(); + app.ui.tool = Tool::Patch; + app.ui.tool_options.patch_content_aware = false; + app +} + +fn mv(app: &mut PhotocraftApp, x: f64, y: f64, m: Modifiers) { + tool_event(app, ToolEvent::Move { x, y, pressure: 1.0 }, m); +} + +/// Press at 140,140 without ⇧, move past the first 60 px, then to `to` with `m` held. +fn drag_to(app: &mut PhotocraftApp, to: [f64; 2], m: Modifiers) { + tool_event(app, ToolEvent::Down { x: 140.0, y: 140.0, pressure: 1.0 }, Modifiers::NONE); + mv(app, 200.0, 145.0, Modifiers::NONE); + mv(app, to[0], to[1], m); +} + +fn patch_offsets(app: &PhotocraftApp) -> Vec { + app.session.journal.iter().filter(|(id, _)| id == "paint.patch").map(|(_, p)| p["offset"].clone()).collect() +} + +fn outline_offset(app: &mut PhotocraftApp) -> [i32; 2] { + let (start, end) = { + let d = app.drag.as_ref().unwrap(); + (d.start, crate::retouch_ui::patch_end(d, d.points.last().map_or(d.start, |p| [p[0], p[1]]))) + }; + crate::retouch_ui::patch_offset(app, start, end) +} + +#[test] +fn shift_during_the_drag_snaps_the_offset_to_the_dominant_axis() { + let mut app = app(); + drag_to(&mut app, [380.0, 170.0], Modifiers::SHIFT); + assert_eq!(crate::patch_preview::drag_offset(&mut app), Some([240, 0])); + + let mut app = self::app(); + drag_to(&mut app, [380.0, 170.0], Modifiers::NONE); + assert_eq!(crate::patch_preview::drag_offset(&mut app), Some([240, 30]), "without ⇧ the offset is free"); + + // The dominant axis can be vertical too. + let mut app = self::app(); + drag_to(&mut app, [150.0, 300.0], Modifiers::SHIFT); + assert_eq!(crate::patch_preview::drag_offset(&mut app), Some([0, 160])); +} + +#[test] +fn the_outline_the_preview_and_the_applied_patch_use_the_snapped_offset() { + let mut app = app(); + drag_to(&mut app, [380.0, 170.0], Modifiers::SHIFT); + assert_eq!(outline_offset(&mut app), [240, 0], "outline"); + assert_eq!(crate::patch_preview::drag_offset(&mut app), Some([240, 0]), "preview"); + tool_event(&mut app, ToolEvent::Up { x: 380.0, y: 170.0 }, Modifiers::SHIFT); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert_eq!(patch_offsets(&app), vec![json!([240, 0])], "applied patch"); + + let mut app = self::app(); + drag_to(&mut app, [380.0, 170.0], Modifiers::NONE); + assert_eq!(outline_offset(&mut app), [240, 30]); + tool_event(&mut app, ToolEvent::Up { x: 380.0, y: 170.0 }, Modifiers::NONE); + assert_eq!(patch_offsets(&app), vec![json!([240, 30])]); +} + +#[test] +fn shift_at_mouse_down_still_adds_to_the_selection() { + let mut app = app(); + let m = Modifiers::SHIFT; + tool_event(&mut app, ToolEvent::Down { x: 140.0, y: 140.0, pressure: 1.0 }, m); + for [x, y] in [[300.0, 140.0], [340.0, 140.0], [340.0, 180.0], [300.0, 180.0]] { + mv(&mut app, x, y, m); + } + tool_event(&mut app, ToolEvent::Up { x: 300.0, y: 180.0 }, m); + assert!(patch_offsets(&app).is_empty(), "no patch was applied"); + let sel = app.session.active().unwrap().doc.selection.clone().unwrap(); + assert!(sel.sample_channel(150, 150, 0) > 0.0, "the old selection is kept"); + assert!(sel.sample_channel(320, 160, 0) > 0.0, "the lasso is added"); +} + +#[test] +fn only_horizontal_and_vertical_snap_never_forty_five_degrees() { + // A perfect diagonal still goes to one axis. + let mut app = app(); + drag_to(&mut app, [240.0, 240.0], Modifiers::SHIFT); + let off = crate::patch_preview::drag_offset(&mut app).unwrap(); + assert!(off[0] == 0 || off[1] == 0, "{off:?} is not on an axis"); + // A 160 x 120 drag is not pulled to 45 degrees (140 x 140). + let mut app = self::app(); + drag_to(&mut app, [300.0, 260.0], Modifiers::SHIFT); + assert_eq!(crate::patch_preview::drag_offset(&mut app), Some([160, 0])); +} diff --git a/crates/ui-egui/src/patch_tool_history_names_tests.rs b/crates/ui-egui/src/patch_tool_history_names_tests.rs new file mode 100644 index 00000000000..d0dc3e771d5 --- /dev/null +++ b/crates/ui-egui/src/patch_tool_history_names_tests.rs @@ -0,0 +1,42 @@ +//! The Patch tool's lasso is a History step named "Patch Tool" (#12117); the Lasso tool keeps "Lasso". + +use egui::Modifiers; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +fn app_with(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600, "background": "transparent"})).unwrap(); + app.sync_views(); + app.ui.tool = tool; + app +} + +/// Drag a triangle with the active tool and return the last History step. +fn drag_lasso(app: &mut PhotocraftApp) -> Option { + tool_event(app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, Modifiers::NONE); + for (x, y) in [(200.0, 100.0), (300.0, 100.0), (300.0, 300.0)] { + tool_event(app, ToolEvent::Move { x, y, pressure: 1.0 }, Modifiers::NONE); + } + tool_event(app, ToolEvent::Up { x: 200.0, y: 300.0 }, Modifiers::NONE); + assert!(app.session.active().unwrap().doc.selection.is_some()); + app.session.active().unwrap().history.entries().last().cloned() +} + +#[test] +fn the_patch_tool_lasso_is_named_for_the_tool() { + assert_eq!(drag_lasso(&mut app_with(Tool::Patch)).as_deref(), Some("Patch Tool")); + assert_eq!(drag_lasso(&mut app_with(Tool::Lasso)).as_deref(), Some("Lasso")); +} + +/// A Patch tool lasso keeps the selection icon in the History panel. +#[test] +fn the_patch_tool_step_has_the_selection_icon() { + assert_eq!(crate::panels::history_step_icon("Patch Tool"), crate::panels::history_step_icon("Lasso")); + assert_eq!(crate::panels::history_step_icon("Patch Tool"), "square-dashed"); + // Applying the patch edits pixels, so it does not take the selection icon from "Selection". + assert_ne!(crate::panels::history_step_icon("Patch Selection"), "square-dashed"); +} diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index aefc2bb3c77..2e73604cc9e 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -494,3 +494,119 @@ fn the_pen_does_not_carry_on_a_work_path_hidden_by_make_selection() { assert_eq!(anchors(&saved.path), [vec![[150.0, 150.0], [200.0, 150.0], [200.0, 190.0]]], "the saved path carried on"); assert_eq!(anchors(&work_path(&app)).len(), 1, "the hidden Work Path untouched"); } + +/// #9356: in Shape mode a Pen press on an endpoint of the selected shape layer's open path carries +/// that path on, from either end, and the commit updates the same layer in one History step +/// instead of making a new shape layer. Elsewhere the press starts a new shape as before. +#[test] +fn shape_mode_carries_on_the_selected_shapes_open_path() { + let mut app = app(Tool::Pen); + app.ui.tool_options.vector_mode = "shape".into(); + let layers = |app: &PhotocraftApp| app.session.active().unwrap().doc.layers.len(); + let shape = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + match &st.doc.layer(st.active_layer.unwrap()).unwrap().content { + LayerContent::Shape(sh) => { + sh.path.subpaths.iter().map(|s| s.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect::>()).collect::>() + } + other => panic!("not a shape layer: {other:?}"), + } + }; + let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.past_len(); + click(&mut app, 20.0, 20.0); + click(&mut app, 80.0, 20.0); + crate::vector_ui::pen_commit(&mut app, false); + let (n, before) = (layers(&app), steps(&app)); + assert!(crate::vector_ui::continues_path_at(&app, Tool::Pen, 81.0, 21.0), "the continue pointer over the endpoint"); + assert!(!crate::vector_ui::continues_path_at(&app, Tool::Pen, 50.0, 20.0), "not mid-segment"); + click(&mut app, 80.0, 20.0); + click(&mut app, 80.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(layers(&app), n, "no new shape layer"); + assert_eq!(steps(&app), before + 1, "one History step"); + assert_eq!(shape(&app), [vec![[20.0, 20.0], [80.0, 20.0], [80.0, 80.0]]]); + // From the first anchor, reversed. + click(&mut app, 20.0, 20.0); + click(&mut app, 20.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(shape(&app), [vec![[80.0, 80.0], [80.0, 20.0], [20.0, 20.0], [20.0, 80.0]]]); + // Anywhere else: a new shape layer. + click(&mut app, 150.0, 150.0); + click(&mut app, 200.0, 150.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(layers(&app), n + 1); +} + +/// #9356: with a vector mask targeted, the Pen carries on the mask's open subpath in place. +#[test] +fn a_targeted_vector_mask_carries_on_its_open_path() { + let mut app = app(Tool::Pen); + let id = app.session.active().unwrap().active_layer.unwrap(); + let open = json!({"subpaths": [{"closed": false, "knots": [{"anchor": [20.0, 20.0]}, {"anchor": [80.0, 20.0]}]}]}); + app.run("layer.vectorMask.add", json!({"layer": id.0, "path": open})).unwrap(); + app.ui.vector_mask_target = true; + let mask = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + let p = &st.doc.layer(id).unwrap().vector_mask.as_ref().unwrap().path; + p.subpaths.iter().map(|s| s.knots.len()).collect::>() + }; + assert_eq!(mask(&app), [2]); + click(&mut app, 80.0, 20.0); + click(&mut app, 80.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(mask(&app), [3], "the same subpath, extended"); +} + +/// #9396 review: on a short open path whose two ends are both within reach of the pointer (as +/// when zoomed out), the press under the continue-path pointer carries the nearer end on and never +/// closes the path, which it did when the other end was its first anchor. +#[test] +fn a_press_in_reach_of_both_ends_carries_the_nearer_one_on_and_does_not_close() { + let mut app = app(Tool::Pen); + assert_eq!(app.point_zoom(), 1.0, "a 6 px reach in document pixels"); + // The ends sit beside the first segment, which Auto Add/Delete would put an anchor on. + app.ui.tool_options.pen_auto_add_delete = false; + for (x, y) in [(20.0, 20.0), (60.0, 20.0), (60.0, 40.0), (26.0, 22.0)] { + click(&mut app, x, y); + } + crate::vector_ui::pen_commit(&mut app, false); + let ends = |app: &PhotocraftApp| { + let pen = app.ui.pen.as_ref().expect("a resumed draft"); + assert!(pen.resumes.is_some(), "{pen:?}"); + pen.knots.last().map(|k| k[0]) + }; + // (24, 22): 2 px from the last anchor, 4.5 px from the first. + assert!(crate::vector_ui::continues_path_at(&app, Tool::Pen, 24.0, 22.0)); + click(&mut app, 24.0, 22.0); + assert!(!work_path(&app).subpaths[0].closed, "the press carried the path on, it did not close it"); + assert_eq!(ends(&app), Some([26.0, 22.0]), "from the nearer, last anchor"); + click(&mut app, 100.0, 100.0); + crate::vector_ui::pen_commit(&mut app, false); + let sub = &work_path(&app).subpaths[0]; + assert!(!sub.closed && sub.knots.len() == 5 && sub.knots[4].anchor.x == 100.0, "{sub:?}"); + // (21, 20): 1 px from the first anchor, 5.4 px from the last: carried on from the first. + click(&mut app, 21.0, 20.0); + assert!(!work_path(&app).subpaths[0].closed); + assert_eq!(ends(&app), Some([20.0, 20.0]), "from the nearer, first anchor"); +} + +/// #9396 review: with the ends of two open subpaths both within reach, the press carries on the +/// subpath whose end is nearest the pointer, not the first one listed. +#[test] +fn a_press_in_reach_of_two_subpaths_ends_carries_the_nearest_on() { + let mut app = app(Tool::Pen); + assert_eq!(app.point_zoom(), 1.0, "a 6 px reach in document pixels"); + app.ui.tool_options.pen_auto_add_delete = false; + click(&mut app, 20.0, 20.0); + click(&mut app, 60.0, 20.0); + crate::vector_ui::pen_commit(&mut app, false); + click(&mut app, 100.0, 100.0); + click(&mut app, 64.0, 22.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(work_path(&app).subpaths.len(), 2, "two open subpaths"); + // (63, 21): 3.2 px from the first subpath's last anchor, 1.4 px from the second's. + click(&mut app, 63.0, 21.0); + let pen = app.ui.pen.as_ref().expect("a resumed draft"); + assert_eq!(pen.resumes.as_ref().map(|r| r.index), Some(1), "{pen:?}"); + assert_eq!(pen.knots.last().map(|k| k[0]), Some([64.0, 22.0]), "from the second subpath's nearer end"); +} diff --git a/crates/ui-egui/src/pencil_auto_erase_tooltip_tests.rs b/crates/ui-egui/src/pencil_auto_erase_tooltip_tests.rs new file mode 100644 index 00000000000..7ae125d24a5 --- /dev/null +++ b/crates/ui-egui/src/pencil_auto_erase_tooltip_tests.rs @@ -0,0 +1,34 @@ +//! #11968: hovering the Pencil's Auto Erase checkbox shows "Draws background color over +//! foreground colors", in the Pro and the other themes. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; +use crate::theme::ThemeKind; + +const TIP: &str = "Draws background color over foreground colors"; + +fn bar(theme: ThemeKind) -> Harness<'static, PhotocraftApp> { + 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::Pencil; + let mut h = Harness::builder().with_size(vec2(1600.0, 300.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app| crate::panels::options_bar(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(4); + h +} + +#[test] +fn auto_erase_has_the_reference_tooltip() { + for theme in [ThemeKind::ProMedium, ThemeKind::Classic] { + let mut h = bar(theme); + // Hovering the checkbox's label shows it. + h.get_by_label("Auto Erase").hover(); + h.run_steps(60); + assert!(h.query_by_label(TIP).is_some(), "{theme:?}: no tooltip on hover"); + } +} diff --git a/crates/ui-egui/src/prefs/prefs_ui.rs b/crates/ui-egui/src/prefs/prefs_ui.rs index 36cd3e74c56..127812585a4 100644 --- a/crates/ui-egui/src/prefs/prefs_ui.rs +++ b/crates/ui-egui/src/prefs/prefs_ui.rs @@ -53,6 +53,8 @@ pub struct Runtime { /// Tool options as last remembered in the preferences (see `tool_memory`). pub(crate) tool_options: Option, pub(crate) selected_tool: Option, + pub(crate) tool_colors: Option<[[[f32; 4]; 2]; 2]>, + pub(crate) tool_cmyk_picks: crate::tool_memory::CmykPicks, pub(crate) group_tools: Option>, /// The tool owning the session brush and the other tools' brushes, as last remembered. pub(crate) tool_brushes: Option, diff --git a/crates/ui-egui/src/prefs/prefs_ui/save.rs b/crates/ui-egui/src/prefs/prefs_ui/save.rs index e3917ba9cd9..d236af92b46 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/save.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/save.rs @@ -116,8 +116,16 @@ fn write_prefs(app: &mut PhotocraftApp, ours: &Value) -> Result<(), String> { let stored = app.services.load_prefs.as_mut().and_then(|load| load()).and_then(|t| serde_json::from_str::(&t).ok()); let text = match (stored, &app.prefs_rt.saved_value) { (Some(mut theirs @ Value::Object(_)), Some(base)) => { - let base = &without_stale_migration(base, &theirs); - merge_changes(base, ours, &mut theirs); + let mut base = without_stale_migration(base, &theirs); + // Older files have no toolColors object. Diff the first saved swatches against + // their actual defaults so an unchanged field cannot overwrite another window's edit. + if matches!(base.get("toolColors"), None | Some(Value::Null)) + && ours.get("toolColors").is_some_and(Value::is_object) + && let Some(fields) = base.as_object_mut() + { + fields.insert("toolColors".into(), crate::tool_memory::default_colors()); + } + merge_changes(&base, ours, &mut theirs); serde_json::to_string_pretty(&theirs) } _ => serde_json::to_string_pretty(ours), diff --git a/crates/ui-egui/src/prefs/prefs_ui/tests.rs b/crates/ui-egui/src/prefs/prefs_ui/tests.rs index 9604492d0ea..5865040256f 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/tests.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/tests.rs @@ -1432,7 +1432,7 @@ fn remembered_tool_brushes_survive_damage() { } /// #6212: Reset Tool forgets that tool's remembered options and brush; Reset All Tools -/// forgets every tool's. +/// forgets every tool's: each painting tool keeps only its own starting brush (#11810). #[test] fn reset_tools_clear_the_remembered_options_and_brushes() { use crate::state::Tool; @@ -1471,10 +1471,12 @@ fn reset_tools_clear_the_remembered_options_and_brushes() { tick(&mut app, &ctx); let saved = stored(&store); assert!(saved["toolOptions"].is_null(), "{saved}"); - assert!(saved["toolBrushes"]["brushes"].as_object().is_none_or(|m| m.is_empty()), "{saved}"); + let starting = + |brushes: &[(Tool, photocraft_engine::BrushSettings)]| brushes.iter().all(|(t, b)| *b == crate::paint_mouse::fresh_brush(*t, b) && b.size != 77.0); + assert!(starting(&app.ui.tool_brushes), "{saved}"); let (app2, _) = app_with_saved(store.lock().unwrap().clone()); assert_eq!(app2.ui.tool_options, crate::state::ToolOptions::default()); - assert!(app2.ui.tool_brushes.is_empty()); + assert!(!app2.ui.tool_brushes.is_empty() && starting(&app2.ui.tool_brushes), "remembered across a restart"); } #[test] @@ -2153,6 +2155,203 @@ fn edit_dialogs_open() { let d = crate::menus::invoke(&mut app, &ctx, "edit.presets.presetManager", json!({})).unwrap()["dialog"].as_u64().unwrap(); assert!(crate::dialogs::confirm(&mut app, d).is_ok()); } + +#[test] +fn tool_colors_first_saves_keep_other_instances_separate_edits() { + for initial in + [None, Some("{}".into()), Some(json!({"toolColors": null}).to_string()), Some(json!({"toolColors": crate::tool_memory::default_colors()}).to_string())] + { + for background_first in [false, true] { + let store = Arc::new(Mutex::new(initial.clone())); + let (mut foreground, mut background) = (app_on(&store, Arc::default()), app_on(&store, Arc::default())); + let fg = [0.25, 0.5, 0.75, 1.0]; + let bg = [0.75, 0.5, 0.25, 1.0]; + foreground.run("tools.setColors", json!({"foreground": fg})).unwrap(); + background.run("tools.setColors", json!({"background": bg})).unwrap(); + let (first, second) = if background_first { (&mut background, &mut foreground) } else { (&mut foreground, &mut background) }; + crate::tool_memory::save_on_exit(first).unwrap(); + crate::tool_memory::save_on_exit(second).unwrap(); + // Further saves must still keep the other window's independent field. + save_preferences(first).unwrap(); + save_preferences(second).unwrap(); + let restored = app_on(&store, Arc::default()); + assert_eq!(restored.session.tools.foreground, fg); + assert_eq!(restored.session.tools.background, bg); + assert_eq!(restored.session.tools.other_colors, photocraft_engine::ToolState::MASK_COLORS); + } + } +} + +#[test] +fn tool_colors_survive_restart_before_the_first_frame() { + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + let fg = [0.2, 0.4, 0.6, 0.8]; + let bg = [0.9, 0.7, 0.3, 1.0]; + app.run("tools.setColors", json!({"foreground": fg, "background": bg})).unwrap(); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.foreground, fg); + assert_eq!(restored.session.tools.background, bg); + let rev = app.session.prefs.rev(); + crate::tool_memory::persist(&mut app, &ctx); + assert_eq!(app.session.prefs.rev(), rev, "unchanged colours do not rewrite preferences"); +} + +#[test] +fn tool_colors_survive_restart_after_rejecting_an_invalid_preset() { + for bad_channel in [json!(1e100), json!(null), json!("red"), json!(true), json!({}), json!([])] { + let (mut app, store) = app_with_store(); + let color = [0.2, 0.4, 0.6, 0.8]; + app.run("tools.setColors", json!({"foreground": color})).unwrap(); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + app.run( + "tool.presets.new", + json!({ + "name": "Invalid", "tool": "move", "options": {"foreground": [bad_channel, 0.4, 0.6, 1.0]} + }), + ) + .unwrap(); + assert!(app.run("tool.presets.select", json!({"preset": "Invalid"})).is_err()); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + assert_eq!(stored(&store)["toolColors"]["foreground"], json!(color)); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.foreground, color); + } +} + +#[test] +fn tool_colors_preserve_finite_hdr_components() { + let (mut app, store) = app_with_store(); + let fg = [1.5, -0.1, 0.6, 1.0]; + app.run("tools.setColors", json!({"foreground": fg})).unwrap(); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.foreground, fg); +} + +#[test] +fn tool_colors_restore_cmyk_inks_for_picker_and_fill_after_swapping_and_exiting_on_a_mask() { + let (mut app, store) = app_with_store(); + let cyan = [1.0, 0.0, 0.0, 0.0]; + let magenta = [0.0, 1.0, 0.0, 0.0]; + let fg = photocraft_color::convert::cmyk_to_rgb(cyan).map(|c| c.clamp(0.0, 1.0)); + let bg = photocraft_color::convert::cmyk_to_rgb(magenta).map(|c| c.clamp(0.0, 1.0)); + app.run( + "tools.setColors", + json!({ + "foreground": fg, "foregroundCmyk": cyan, "background": bg, "backgroundCmyk": magenta + }), + ) + .unwrap(); + app.run("tools.swapColors", json!({})).unwrap(); + app.session.tools.target_mask(true); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (mut restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.picked_cmyk(fg), Some(cyan)); + assert_eq!(restored.session.tools.picked_cmyk(bg), Some(magenta)); + for (target, expected) in [("foreground", magenta), ("background", cyan)] { + crate::color_picker_ui::open(&mut restored, target); + assert_eq!(restored.ui.dialogs.last().unwrap().fields["__cmyk"], json!(expected)); + } + restored.run("file.new", json!({"width": 4, "height": 4})).unwrap(); + restored.run("image.mode.cmyk", json!({})).unwrap(); + for (command, expected) in [("edit.fillForeground", magenta), ("edit.fillBackground", cyan)] { + restored.run(command, json!({})).unwrap(); + let st = restored.session.active().unwrap(); + let surface = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let pixel = surface.read_region(photocraft_geom::Rect::new(0, 0, 1, 1)); + assert!(pixel.iter().take(4).zip(expected).all(|(a, b)| (a - b).abs() < 0.01), "{command}: {pixel:?}"); + } +} + +#[test] +fn tool_colors_save_changed_cmyk_inks_even_when_rgba_is_unchanged() { + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + let rgb = [0.2, 0.4, 0.6]; + app.run("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": [1, 0, 0, 0]})).unwrap(); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let rev = app.session.prefs.rev(); + let inks = [0.1, 0.2, 0.3, 0.4]; + app.run("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": inks})).unwrap(); + crate::tool_memory::persist(&mut app, &ctx); + assert!(app.session.prefs.rev() > rev); + tick(&mut app, &ctx); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.picked_cmyk(rgb), Some(inks)); +} + +#[test] +fn tool_colors_restore_only_bounded_valid_cmyk_picks() { + let limit = photocraft_engine::ToolState::CMYK_PICKS; + let mut picks: Vec = (0..limit + 3).map(|i| json!([[i as f32 / 16.0, 0.2, 0.3], [0.1, 0.2, 0.3, 0.4]])).collect(); + picks.extend([json!("bad"), json!([[0, 0, 0], [1, 0, 0]]), json!([[0, 0, 0], [2, 0, 0, 0]]), json!([[1e100, 0, 0], [1, 0, 0, 0]])]); + let (app, _) = app_with_saved(Some( + json!({ + "toolColors": {"foreground": [0.2, 0.4, 0.6, 1.0], "cmykPicks": picks} + }) + .to_string(), + )); + assert_eq!(app.session.tools.foreground, [0.2, 0.4, 0.6, 1.0]); + assert_eq!(app.session.tools.cmyk_picks.len(), limit); + assert_eq!(app.session.tools.picked_cmyk([0.0, 0.2, 0.3]), None); + assert_eq!(app.session.tools.picked_cmyk([(limit + 2) as f32 / 16.0, 0.2, 0.3]), Some([0.1, 0.2, 0.3, 0.4])); +} + +#[test] +fn tool_colors_flush_swaps_and_defaults_on_exit_without_another_frame() { + let (mut app, store) = app_with_store(); + app.run("tools.setColors", json!({"foreground": "#336699", "background": "#cc8844"})).unwrap(); + app.run("tools.swapColors", json!({})).unwrap(); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (mut restored, restored_store) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.foreground, app.session.tools.foreground); + assert_eq!(restored.session.tools.background, app.session.tools.background); + restored.run("tools.defaultColors", json!({})).unwrap(); + crate::tool_memory::save_on_exit(&mut restored).unwrap(); + let (defaults, _) = app_with_saved(restored_store.lock().unwrap().clone()); + assert_eq!(defaults.session.tools.foreground, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(defaults.session.tools.background, [1.0; 4]); +} + +#[test] +fn tool_colors_keep_pixel_and_mask_pairs_when_exiting_on_a_mask() { + let (mut app, store) = app_with_store(); + app.session.tools.foreground = [0.2, 0.4, 0.6, 1.0]; + app.session.tools.background = [0.9, 0.7, 0.3, 1.0]; + let pixels = [app.session.tools.foreground, app.session.tools.background]; + app.session.tools.target_mask(true); + app.session.tools.foreground = [0.5, 0.5, 0.5, 1.0]; + let masks = [app.session.tools.foreground, app.session.tools.background]; + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (mut restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!([restored.session.tools.foreground, restored.session.tools.background], pixels); + restored.session.tools.target_mask(true); + assert_eq!([restored.session.tools.foreground, restored.session.tools.background], masks); +} + +#[test] +fn tool_colors_ignore_invalid_swatches_without_losing_valid_preferences() { + for bad in [json!(null), json!("red"), json!([1, 0, 0]), json!([1e100, 0, 0, 1]), json!([0, "bad", 0, 1])] { + let (app, _) = app_with_saved(Some( + json!({ + "toolColors": {"foreground": bad, "background": [0.2, 0.4, 0.6, 1.0]}, + "toolOptions": {"feather": 12.0} + }) + .to_string(), + )); + assert_eq!(app.session.tools.foreground, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(app.session.tools.background, [0.2, 0.4, 0.6, 1.0]); + assert_eq!(app.ui.tool_options.feather, 12.0); + } + let (app, _) = app_with_saved(Some(json!({"toolOptions": {"feather": 12.0}}).to_string())); + assert_eq!(app.session.tools.foreground, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(app.session.tools.background, [1.0; 4]); +} + #[test] fn reset_section_on_type_keeps_the_favourite_fonts() { // The favourites are the font menu's library, not a Type setting. diff --git a/crates/ui-egui/src/psd_open_tests.rs b/crates/ui-egui/src/psd_open_tests.rs index 02c18313f5a..d09d91e9d51 100644 --- a/crates/ui-egui/src/psd_open_tests.rs +++ b/crates/ui-egui/src/psd_open_tests.rs @@ -569,6 +569,7 @@ fn profile_cancel_cancels_whole_queue_including_memory_prompts() { order: None, question: Some(json!({"missing": false, "working": "sRGB", "embedded": "Adobe RGB"})), policy_applied: false, + smart_object: false, }, ); @@ -600,6 +601,7 @@ fn mixed_prompts_follow_arrival_order_fifo() { order: None, question: None, policy_applied: false, + smart_object: false, }, ); @@ -974,3 +976,34 @@ fn inline_drop_slot_survives_the_memory_prompt() { }); }); } + +/// A damaged PSD opened as a smart object, once confirmed, is admitted as one: the history says +/// "Open As Smart Object" and two same-stem files get distinct names (#12212 review). +#[test] +fn confirmed_damaged_smart_object_opens_are_admitted_as_smart_objects_with_distinct_names() { + let mut app = app(); + let bytes = recovery_psd::make_psd(true); + let dir = std::env::temp_dir().join(format!("photocraft-damaged-so-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut paths = Vec::new(); + for sub in ["a", "b"] { + std::fs::create_dir_all(dir.join(sub)).unwrap(); + let path = dir.join(sub).join("dmg.psd"); + std::fs::write(&path, &bytes).unwrap(); + paths.push(path.to_string_lossy().into_owned()); + } + for path in &paths { + crate::jobs_ui::start_open_as_smart_object(&mut app, path, None).unwrap(); + } + assert!(app.session.documents().is_empty()); + for _ in 0..2 { + let id = damage_dialog(&mut app); + assert_eq!(call(&mut app, "ui.dialog.confirm", json!({"dialog": id}))["ok"], true); + } + let names: Vec<_> = app.session.documents().iter().map(|d| d.doc.name.clone()).collect(); + assert_eq!(names, ["dmg-1", "dmg-2"]); + for st in app.session.documents() { + assert_eq!(st.history.entries(), ["Open As Smart Object"]); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/rasterize_prompt_text_tests.rs b/crates/ui-egui/src/rasterize_prompt_text_tests.rs new file mode 100644 index 00000000000..77a5da88bc2 --- /dev/null +++ b/crates/ui-egui/src/rasterize_prompt_text_tests.rs @@ -0,0 +1,39 @@ +//! #11899: the rasterize prompt for a smart object uses the reference application's wording; +//! the type, shape and fill prompts are unchanged, and the catalogs that translated the old text +//! translate the new one. + +use super::*; +use crate::i18n::{Lang, tr}; + +const OLD: &str = "This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object?"; + +#[test] +fn the_four_prompts_read_as_measured() { + assert_eq!( + Kind::SmartObject.message(), + "This smart object must be rasterized before proceeding. Edit Contents will no longer be available. Rasterize the smart object?" + ); + assert_eq!(Kind::Type.message(), "This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type?"); + assert_eq!(Kind::Shape.message(), "This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape?"); + assert_eq!(Kind::Fill.message(), "This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer?"); + assert!(!Kind::SmartObject.message().contains(" "), "single spaces between sentences"); +} + +#[test] +fn catalogs_that_had_a_translation_keep_one() { + let new = Kind::SmartObject.message(); + let fr = Lang::from_code("fr").expect("French"); + assert_ne!(tr(fr, new), new, "French still translates the prompt"); + // A language without an entry falls back to English. + let de = Lang::from_code("de").expect("German"); + assert_eq!(tr(de, new), new); + // No catalog keeps the old key. + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/i18n"); + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.extension().is_some_and(|e| e == "tsv") { + let text = std::fs::read_to_string(&path).unwrap(); + assert!(!text.contains(OLD), "{} still has the old key", path.display()); + } + } +} diff --git a/crates/ui-egui/src/smoothing_options_greyed_tests.rs b/crates/ui-egui/src/smoothing_options_greyed_tests.rs new file mode 100644 index 00000000000..06cd6d12de9 --- /dev/null +++ b/crates/ui-egui/src/smoothing_options_greyed_tests.rs @@ -0,0 +1,142 @@ +//! #11939: at Smoothing 0 % the gear's four Smoothing Options are greyed and can't be toggled, but +//! keep their values; above 0 % they work again. For the Brush and the Eraser, the tools with this +//! gear. + +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +const OPTIONS: [&str; 4] = ["Pulled String Mode", "Stroke Catch-up", "Catch-up on Stroke End", "Adjust for Zoom"]; + +fn bar(tool: Tool, smoothing: f32) -> Harness<'static, PhotocraftApp> { + bar_with(tool, smoothing, None) +} + +/// [`bar`] with the four options set to `options` (in [`OPTIONS`] order) before the first frame. +fn bar_with(tool: Tool, smoothing: f32, options: Option<[bool; 4]>) -> Harness<'static, PhotocraftApp> { + 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; + crate::paint_mouse::sync_tool_brush(&mut app); + let s = &mut app.session.tools.brush.smoothing; + s.amount = smoothing; + if let Some([a, b, c, d]) = options { + (s.pulled_string, s.catch_up, s.catch_up_on_end, s.adjust_for_zoom) = (a, b, c, d); + } + let mut h = Harness::builder().with_size(vec2(1800.0, 400.0)).build_ui_state(|ui, app| crate::panels::options_bar(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h.get_by_label("Set additional smoothing options").click(); + h.run_steps(3); + h +} + +fn checkbox<'a>(h: &'a Harness<'static, PhotocraftApp>, label: &str) -> egui_kittest::Node<'a> { + h.query_all_by_role(Role::CheckBox).find(|n| n.accesskit_node().label().as_deref() == Some(label)).unwrap_or_else(|| panic!("{label}")) +} + +fn values(h: &Harness<'static, PhotocraftApp>) -> [bool; 4] { + let s = &h.state().session.tools.brush.smoothing; + [s.pulled_string, s.catch_up, s.catch_up_on_end, s.adjust_for_zoom] +} + +#[test] +fn at_zero_the_four_are_greyed_and_keep_their_values() { + for tool in [Tool::Brush, Tool::Eraser] { + let mut h = bar(tool, 0.0); + let before = values(&h); + for label in OPTIONS { + assert!(checkbox(&h, label).accesskit_node().is_disabled(), "{tool:?}: {label} greyed at 0 %"); + } + for label in OPTIONS { + checkbox(&h, label).click(); + h.run_steps(2); + } + assert_eq!(values(&h), before, "{tool:?}: clicks change nothing at 0 %"); + } +} + +#[test] +fn above_zero_they_work_again() { + for tool in [Tool::Brush, Tool::Eraser] { + let mut h = bar(tool, 0.1); + for label in OPTIONS { + assert!(!checkbox(&h, label).accesskit_node().is_disabled(), "{tool:?}: {label} enabled"); + } + let before = values(&h); + checkbox(&h, "Pulled String Mode").click(); + h.run_steps(2); + assert_ne!(values(&h)[0], before[0], "{tool:?}: Pulled String Mode toggles"); + } +} + +#[test] +fn a_smoothing_that_shows_as_zero_greys_them_too() { + // 0.4 % (from a preset or a script) reads 0 % in the field, so the four follow the field. + for tool in [Tool::Brush, Tool::Eraser] { + let h = bar(tool, 0.004); + for label in OPTIONS { + assert!(checkbox(&h, label).accesskit_node().is_disabled(), "{tool:?}: {label} greyed at a displayed 0 %"); + } + } +} + +/// The opposite of each option's default (Pulled String off, Stroke Catch-up on, Catch-up on +/// Stroke End off, Adjust for Zoom on). +const SET: [bool; 4] = [true, false, true, false]; + +/// Types `text` into the options bar's Smoothing field, then Tabs out of it. +fn type_smoothing(h: &mut Harness<'static, PhotocraftApp>, text: &str) { + h.query_all_by_role(Role::SpinButton) + .find(|n| n.accesskit_node().label().is_some_and(|l| l.trim_end_matches(':') == "Smoothing")) + .expect("the Smoothing field") + .click(); + h.run_steps(1); + 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); +} + +#[test] +fn a_smoothing_that_shows_as_one_enables_them() { + // 0.9 % reads 1 % in the field, so the four are on. + for tool in [Tool::Brush, Tool::Eraser] { + let h = bar(tool, 0.009); + for label in OPTIONS { + assert!(!checkbox(&h, label).accesskit_node().is_disabled(), "{tool:?}: {label} enabled at a displayed 1 %"); + } + } +} + +#[test] +fn smoothing_to_zero_and_back_keeps_the_four_values() { + for tool in [Tool::Brush, Tool::Eraser] { + let mut h = bar_with(tool, 0.1, Some(SET)); + type_smoothing(&mut h, "0"); + assert_eq!(h.state().session.tools.brush.smoothing.amount, 0.0, "{tool:?}: Smoothing 0 %"); + assert_eq!(values(&h), SET, "{tool:?}: kept at 0 %"); + type_smoothing(&mut h, "10"); + assert_eq!(values(&h), SET, "{tool:?}: kept back above 0 %"); + } +} + +#[test] +fn at_zero_each_checkbox_shows_its_value() { + use egui::accesskit::Toggled; + for tool in [Tool::Brush, Tool::Eraser] { + for set in [SET, SET.map(|v| !v)] { + let h = bar_with(tool, 0.0, Some(set)); + for (label, on) in OPTIONS.into_iter().zip(set) { + let shown = checkbox(&h, label).accesskit_node().toggled(); + assert_eq!(shown, Some(if on { Toggled::True } else { Toggled::False }), "{tool:?}: {label} shows {on} at 0 %"); + } + } + } +} diff --git a/crates/ui-egui/src/star_tool_tests.rs b/crates/ui-egui/src/star_tool_tests.rs new file mode 100644 index 00000000000..b83bb15d833 --- /dev/null +++ b/crates/ui-egui/src/star_tool_tests.rs @@ -0,0 +1,115 @@ +//! The Star Tool (#11377): in the shape slot between Polygon and Line, on U, and a drag makes a +//! live star shape layer with PhotoCraft's star defaults (5 points, star ratio 0.5). + +use egui::{Key, Modifiers}; +use photocraft_doc::{Color, Fill, LayerContent, LiveShape, ShapeLayer}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::panels::toolbar::TOOL_SECTIONS; +use crate::state::Tool; + +const SHAPES: [Tool; 7] = [Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Star, Tool::Line, Tool::CustomShape]; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app +} + +fn press(app: &mut PhotocraftApp, key: Key, modifiers: Modifiers) { + let ctx = egui::Context::default(); + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], mods: Modifiers) { + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Move { x: (from[0] + to[0]) / 2.0, y: (from[1] + to[1]) / 2.0, pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, mods); +} + +fn shape_layer(app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + match &st.doc.layer(st.active_layer?)?.content { + LayerContent::Shape(s) => Some(s.clone()), + _ => None, + } +} + +fn live_shape(app: &PhotocraftApp) -> Option { + shape_layer(app)?.live +} + +#[test] +fn star_sits_in_the_shape_slot_after_polygon() { + let slot = TOOL_SECTIONS.iter().flat_map(|s| s.iter()).find(|slot| slot.contains(&Tool::Star)).copied(); + assert_eq!(slot, Some(&SHAPES[..])); + let pos = |t: Tool| Tool::ALL.iter().position(|x| *x == t); + assert_eq!(pos(Tool::Star), pos(Tool::Polygon).map(|p| p + 1), "Tool::ALL keeps the slot order"); +} + +#[test] +fn star_is_a_shape_tool_on_u_with_its_label_icon_and_name() { + assert!(crate::tools::vector_ui::is_shape_tool(Tool::Star)); + assert_eq!(Tool::Star.key(), 'U'); + assert_eq!(Tool::Star.label(), "Star Tool"); + assert_eq!(Tool::from_name("Star Tool"), Some(Tool::Star)); + assert_eq!(Tool::from_name("star"), Some(Tool::Star)); + assert_eq!(crate::icons::tool_icon(Tool::Star), "star"); +} + +#[test] +fn shift_u_steps_from_polygon_to_star_to_line() { + let mut app = app(); + app.ui.tool = Tool::Polygon; + press(&mut app, Key::U, Modifiers::SHIFT); + assert_eq!(app.ui.tool, Tool::Star); + press(&mut app, Key::U, Modifiers::SHIFT); + assert_eq!(app.ui.tool, Tool::Line); +} + +#[test] +fn a_drag_makes_a_live_star_shape_layer_with_one_undo_step() { + let mut app = app(); + app.ui.tool = Tool::Star; + app.run("tools.setColors", json!({"foreground": "#ff0000", "background": "#0000ff"})).unwrap(); + app.ui.tool_options.shape_fill = true; + app.ui.tool_options.stroke_width = 5.0; + let before = app.session.active().unwrap().history.past_len(); + drag(&mut app, [20.0, 30.0], [120.0, 90.0], Modifiers::NONE); + match live_shape(&app) { + Some(LiveShape::Polygon { rect, sides, star_ratio, .. }) => { + assert_eq!((sides, star_ratio), (5, 0.5)); + assert_eq!(rect, [20.0, 30.0, 100.0, 60.0]); + } + other => panic!("a live star shape, got {other:?}"), + } + let shape = shape_layer(&app).expect("a star shape layer"); + let solid = |r, g, b| Fill::Solid(Color::rgba(r, g, b, 1.0)); + assert_eq!(shape.fill, Some(solid(1.0, 0.0, 0.0)), "filled with the foreground colour"); + let stroke = shape.stroke.expect("the star keeps the tool's stroke"); + assert_eq!((stroke.paint, stroke.width), (solid(0.0, 0.0, 1.0), 5.0), "stroked with the background colour at the tool's width"); + assert_eq!(app.session.active().unwrap().history.past_len(), before + 1, "one history step"); + app.run("edit.undo", json!({})).unwrap(); + assert!(live_shape(&app).is_none(), "undo takes the star away"); +} + +#[test] +fn shift_and_alt_constrain_and_centre_the_star_like_the_polygon_tool() { + let mut app = app(); + app.ui.tool = Tool::Star; + drag(&mut app, [100.0, 100.0], [140.0, 120.0], Modifiers::SHIFT | Modifiers::ALT); + match live_shape(&app) { + Some(LiveShape::Polygon { rect, .. }) => assert_eq!(rect, [60.0, 60.0, 80.0, 80.0], "a square box drawn from the centre"), + other => panic!("a live star shape, got {other:?}"), + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6a6f1aeca02..5b793318190 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -9,6 +9,9 @@ mod tool; mod tool_options; mod transform; +#[cfg(test)] +mod type_default_size_tests; + pub use color_panel::{ColorPanelMode, ColorPanelState, ColorRamp, DockTabs}; pub use panel_state::{ CameraRawPreviewState, CameraRawScopeState, CurvesEditorState, LayerThumbnailContents, LayerThumbnailSize, LayersPanelDialog, LayersPanelOptions, @@ -766,7 +769,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(), 68); + assert_eq!(Tool::ALL.len(), 69); } fn vector_tool_names_parse() { diff --git a/crates/ui-egui/src/state/tool.rs b/crates/ui-egui/src/state/tool.rs index 8dda7ce2319..66aa7d53f68 100644 --- a/crates/ui-egui/src/state/tool.rs +++ b/crates/ui-egui/src/state/tool.rs @@ -71,6 +71,8 @@ pub enum Tool { EllipseShape, Triangle, Polygon, + /// The shape flyout's Star Tool (#11377): a live polygon shape with a star ratio. + Star, Line, CustomShape, Slice, @@ -79,7 +81,7 @@ pub enum Tool { } impl Tool { - pub const ALL: [Tool; 68] = [ + pub const ALL: [Tool; 69] = [ Tool::Move, Tool::Artboard, Tool::RectMarquee, @@ -143,6 +145,7 @@ impl Tool { Tool::EllipseShape, Tool::Triangle, Tool::Polygon, + Tool::Star, Tool::Line, Tool::CustomShape, Tool::Slice, @@ -218,6 +221,7 @@ impl Tool { Tool::EllipseShape => "Ellipse Tool", Tool::Triangle => "Triangle Tool", Tool::Polygon => "Polygon Tool", + Tool::Star => "Star Tool", Tool::Line => "Line Tool", Tool::CustomShape => "Custom Shape Tool", } @@ -288,7 +292,7 @@ impl Tool { // In the Pen flyout, but P never reaches them. Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint => '', Tool::PathSelection | Tool::DirectSelection => 'A', - Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape => 'U', + Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Star | Tool::Line | Tool::CustomShape => 'U', } } /// Glyph drawn in the toolbar (vector icons come later). diff --git a/crates/ui-egui/src/state/tool_options.rs b/crates/ui-egui/src/state/tool_options.rs index 228d8bac38c..12c9bc4f51e 100644 --- a/crates/ui-egui/src/state/tool_options.rs +++ b/crates/ui-egui/src/state/tool_options.rs @@ -145,8 +145,8 @@ pub struct ToolOptions { #[serde(default = "one")] pub slice_height: f32, /// Move tool: Auto-Select (with "layer" or "group" target) and Show Transform Controls. - /// Auto-Select is on with the "group" target by default, as in current Photoshop; - /// ⌘/Ctrl-click inverts it. + /// Auto-Select is on with the "layer" target by default, as Photoshop's Reset Tool leaves it + /// (measured for #11380; #4056 had asked for "group"); ⌘/Ctrl-click inverts it. #[serde(default = "yes")] pub move_auto_select: bool, #[serde(default = "default_move_target")] @@ -168,6 +168,10 @@ pub struct ToolOptions { /// transforms still turn and scale about it (#11098). #[serde(default)] pub transform_reference_point: bool, + /// Transform options bar: X and Y show the reference point's offset from where the box started + /// instead of its position (Photoshop's relative positioning, off by default, #11679). + #[serde(default)] + pub transform_relative_position: bool, /// Last interpolation selected in the Transform options bar; unset follows General's default. #[serde(default)] pub transform_interpolation: Option, @@ -222,6 +226,9 @@ pub struct ToolOptions { pub bg_limits: String, pub bg_tolerance: f32, pub bg_protect_fg: bool, + /// The Background Eraser popup's "Tolerance:" pen control (#11910): `off`, `penPressure` (the + /// reference's default) or `stylusWheel`. + pub bg_tolerance_control: String, /// Color Replacement: mode (hue | saturation | color | luminosity), sampling (continuous | /// once | backgroundSwatch), limits (discontiguous | contiguous | findEdges), tolerance % and /// anti-alias, as `paint.colorReplacement` takes them. @@ -247,6 +254,9 @@ pub struct ToolOptions { /// `paint.pencil`), as in Photoshop's options bar (#2662). #[serde(default = "default_eraser_mode")] pub eraser_mode: String, + /// Mixer Brush › Useful mixer brush combinations: the entry last picked, an index into + /// `brush_controls::MIXER_COMBINATIONS` (0 = Custom, #11946). + pub mixer_combination: usize, /// 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, @@ -292,7 +302,7 @@ fn default_crop_resolution_unit() -> String { } fn default_move_target() -> String { - "group".into() + "layer".into() } fn default_pen_path_op() -> String { @@ -349,10 +359,10 @@ fn default_eyedropper_sample() -> String { } /// Each tool's Sample All Layers (the Paint Bucket calls it All Layers). Photoshop keeps one per -/// tool, all off by default: turning it on for the Magic Wand must not leave Blur or Smudge -/// copying the composite onto the active layer (#5524). The Mixer Brush keeps its own with the -/// brush (`MixerSettings`). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +/// tool, all off by default except the Patch tool's Content-Aware Sample All Layers (#5524, +/// #12125): turning it on for the Magic Wand must not leave Blur or Smudge copying the composite +/// onto the active layer. The Mixer Brush keeps its own with the brush (`MixerSettings`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct SampleAllLayers { pub magic_wand: bool, @@ -369,6 +379,25 @@ pub struct SampleAllLayers { pub smudge: bool, } +impl Default for SampleAllLayers { + fn default() -> Self { + Self { + magic_wand: false, + quick_selection: false, + object_selection: false, + paint_bucket: false, + magic_eraser: false, + spot_healing: false, + patch: true, + content_aware_move: false, + remove: false, + blur: false, + sharpen: false, + smudge: false, + } + } +} + impl SampleAllLayers { /// `tool`'s setting; false for a tool without the option. pub fn get(mut self, tool: Tool) -> bool { @@ -417,7 +446,7 @@ impl Default for ToolOptions { bucket_clear: false, type_font: "Inter".into(), type_style: "Regular".into(), - type_size: 48.0, + type_size: 50.0, type_aa: "sharp".into(), type_align: "left".into(), type_style_defaults: serde_json::Map::new(), @@ -478,6 +507,7 @@ impl Default for ToolOptions { move_expand_group_on_click: true, move_show_layers_hover_bounds: true, transform_reference_point: false, + transform_relative_position: false, transform_interpolation: None, crop_ratio: String::new(), crop_custom_ratio_width: 0.0, @@ -498,6 +528,7 @@ impl Default for ToolOptions { bg_limits: "contiguous".into(), bg_tolerance: 50.0, bg_protect_fg: false, + bg_tolerance_control: "penPressure".into(), cr_mode: "color".into(), cr_sampling: "continuous".into(), cr_limits: "contiguous".into(), @@ -508,6 +539,7 @@ impl Default for ToolOptions { brush_clear: false, pencil_clear: false, eraser_mode: default_eraser_mode(), + mixer_combination: 0, magnetic_width: 10.0, magnetic_contrast: 10.0, magnetic_frequency: 57.0, diff --git a/crates/ui-egui/src/state/type_default_size_tests.rs b/crates/ui-egui/src/state/type_default_size_tests.rs new file mode 100644 index 00000000000..79b62d862ba --- /dev/null +++ b/crates/ui-egui/src/state/type_default_size_tests.rs @@ -0,0 +1,89 @@ +//! BUG-TYPE-TOOL-DEFAULT-FONT-SIZE (#12051): the Type tool starts at 50 pt. +//! +//! Measured in the reference application: after Reset All Tools the Type tool's size is 50 pt, +//! the same in an 800x600 document at 72 ppi, a 3000x2000 document at 300 ppi and a 100x100 +//! document at 72 ppi, and the scripted tool options report `size=50` in points. A size saved by +//! an older version (which shipped 48 pt) is a remembered choice, not a default, and stays. + +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::{Tool, ToolOptions}; + +/// The size in points the scripted tool options report: `ui.inspect`'s `toolOptions` is +/// `ToolOptions` serialized (`control/inspect.rs`), and its serde name is `type_size`. +fn scripted_size(o: &ToolOptions) -> f64 { + let v = serde_json::to_value(o).unwrap_or_default(); + v["type_size"].as_f64().unwrap_or_default() +} + +/// An app with one document of the given size and resolution. +fn app_with_document(width: u32, height: u32, resolution: f64) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": width, "height": height, "resolution": resolution})).unwrap(); + app.sync_views(); + app +} + +#[test] +fn a_fresh_install_starts_the_type_tool_at_50_pt() { + assert_eq!(ToolOptions::default().type_size, 50.0, "the shipped default is 50 pt, not 48 pt"); + assert_eq!(scripted_size(&ToolOptions::default()), 50.0, "the scripted tool options report size=50"); +} + +/// Reset Tool (the options bar's far-left menu) puts the field back to the default, and the +/// default is 50 pt for every flavour of the Type tool. +#[test] +fn reset_tool_gives_50_pt_for_every_type_tool() { + for tool in [Tool::Type, Tool::VerticalType, Tool::HorizontalTypeMask, Tool::VerticalTypeMask] { + let mut o = ToolOptions { type_size: 12.0, ..ToolOptions::default() }; + crate::tool_reset::reset_options(&mut o, tool); + assert_eq!(o.type_size, 50.0, "{tool:?} › Reset Tool"); + } +} + +#[test] +fn reset_tool_and_reset_all_tools_give_50_pt_in_the_running_app() { + let ctx = egui::Context::default(); + for tool in [Tool::Type, Tool::VerticalType] { + let mut app = app_with_document(800, 600, 72.0); + app.ui.tool = tool; + app.ui.tool_options.type_size = 12.0; + crate::menus::invoke(&mut app, &ctx, crate::tool_reset::RESET, json!({})).unwrap(); + assert_eq!(app.ui.tool_options.type_size, 50.0, "{tool:?} › Reset Tool"); + assert_eq!(scripted_size(&app.ui.tool_options), 50.0, "{tool:?} › Reset Tool, scripted"); + + app.ui.tool_options.type_size = 12.0; + crate::menus::invoke(&mut app, &ctx, crate::tool_reset::RESET_ALL, json!({"confirmed": true})).unwrap(); + assert_eq!(app.ui.tool_options.type_size, 50.0, "{tool:?} › Reset All Tools"); + } +} + +/// The default is a constant of the tool, not a function of the document: the measured reference +/// values are 800x600 at 72 ppi, 3000x2000 at 300 ppi and 100x100 at 72 ppi. +#[test] +fn the_default_size_does_not_depend_on_the_document() { + for (width, height, dpi) in [(800u32, 600u32, 72.0f64), (3000, 2000, 300.0), (100, 100, 72.0)] { + let app = app_with_document(width, height, dpi); + assert_eq!(app.ui.tool_options.type_size, 50.0, "{width}x{height} at {dpi} ppi"); + assert_eq!(scripted_size(&app.ui.tool_options), 50.0, "{width}x{height} at {dpi} ppi: scripted tool options"); + } +} + +/// A tool option saved by an older version is a remembered choice: a saved 48 stays 48. +#[test] +fn a_size_saved_by_an_older_version_keeps_its_value() { + let mut restarted = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + restarted.session.prefs.edit(|p| p.tool_options = json!({ "type_size": 48.0 })); + crate::tool_memory::restore(&mut restarted); + assert_eq!(restarted.ui.tool_options.type_size, 48.0, "a saved 48 pt is not overwritten by the new default"); +} + +/// The default is also the fallback for a damaged remembered value (`tool_memory::sanitize`, +/// the second site #12051 names), so it follows it to 50 pt. +#[test] +fn a_damaged_saved_size_falls_back_to_50_pt() { + let mut o = ToolOptions { type_size: f32::NAN, ..ToolOptions::default() }; + crate::tool_memory::sanitize(&mut o); + assert_eq!(o.type_size, 50.0, "a damaged value takes the default"); +} diff --git a/crates/ui-egui/src/tool_memory.rs b/crates/ui-egui/src/tool_memory.rs index b70974d0b2a..6b31332d50b 100644 --- a/crates/ui-egui/src/tool_memory.rs +++ b/crates/ui-egui/src/tool_memory.rs @@ -1,4 +1,4 @@ -//! Selected tool and tool options remembered between launches, as in Photoshop (#2814). +//! Selected tool, colours and tool options remembered between launches, as in Photoshop (#2814). //! //! `Preferences::tool_options` holds the [`ToolOptions`] fields that differ from their defaults, //! so a default changed in a later release still reaches users who never touched that option. @@ -25,6 +25,9 @@ use crate::state::{Tool, ToolOptions}; /// The tool whose brush the session brush is, and the other painting tools' brushes. pub(crate) type ToolBrushes = (Tool, Vec<(Tool, BrushSettings)>); +/// The sRGB stand-ins and exact inks of recently picked CMYK colours, oldest first. +pub(crate) type CmykPicks = Vec<([f32; 3], [f32; 4])>; + /// Explicit choices override temporary pen and held-key tools, even when selecting the same tool. pub(crate) fn select(app: &mut PhotocraftApp, tool: Tool) { app.stylus.tool_before_eraser = None; @@ -85,6 +88,7 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { ctx.request_repaint(); } } + persist_colors(app, ctx); persist_group_tools(app, ctx, tool); persist_brushes(app); if app.prefs_rt.tool_options.as_ref() == Some(&app.ui.tool_options) { @@ -97,6 +101,83 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// Keep pixel and mask swatches separately, even when the app exits with a mask targeted. +fn colors(app: &PhotocraftApp) -> [[[f32; 4]; 2]; 2] { + let t = &app.session.tools; + let current = [t.foreground, t.background]; + if t.mask_targeted { [t.other_colors, current] } else { [current, t.other_colors] } +} + +fn persist_colors(app: &mut PhotocraftApp, ctx: &egui::Context) { + let now = colors(app); + if app.prefs_rt.tool_colors == Some(now) && app.prefs_rt.tool_cmyk_picks == app.session.tools.cmyk_picks { + return; + } + app.prefs_rt.tool_colors = Some(now); + app.prefs_rt.tool_cmyk_picks = app.session.tools.cmyk_picks.clone(); + let saved = color_value(now, &app.prefs_rt.tool_cmyk_picks); + app.session.prefs.edit(|p| p.tool_colors = saved); + // Panels and shortcuts can change colours after prefs_ui::tick already ran this frame. + ctx.request_repaint(); +} + +fn color_value(pairs: [[[f32; 4]; 2]; 2], cmyk_picks: &CmykPicks) -> Value { + let [[foreground, background], [mask_foreground, mask_background]] = pairs; + json!({ + "foreground": foreground, "background": background, + "maskForeground": mask_foreground, "maskBackground": mask_background, + "cmykPicks": cmyk_picks, + }) +} + +/// A missing legacy value represents these defaults, not an indivisible null leaf. +pub(crate) fn default_colors() -> Value { + let tools = photocraft_engine::ToolState::default(); + color_value([[tools.foreground, tools.background], tools.other_colors], &tools.cmyk_picks) +} + +fn restore_colors(app: &mut PhotocraftApp) { + let saved = &app.session.prefs().tool_colors; + let mut pairs = colors(app); + let [[foreground, background], [mask_foreground, mask_background]] = &mut pairs; + for (key, color) in [("foreground", foreground), ("background", background), ("maskForeground", mask_foreground), ("maskBackground", mask_background)] { + if let Some(v) = saved.get(key) { + match serde_json::from_value::<[f32; 4]>(v.clone()) { + Ok(c) if c.iter().all(|x| x.is_finite()) => *color = c, + _ => log::warn!("Preferences: ignoring the remembered `{key}` colour"), + } + } + } + // Parse only a bounded number of valid entries, newest first, before mutating the session. + // `remember_cmyk` deduplicates entries and enforces the same bound as during a live session. + let picks: Option = saved.get("cmykPicks").and_then(Value::as_array).map(|entries| { + entries + .iter() + .rev() + .filter_map(|v| match serde_json::from_value::<([f32; 3], [f32; 4])>(v.clone()) { + Ok((rgb, inks)) if rgb.iter().all(|x| x.is_finite()) && inks.iter().all(|x| x.is_finite() && (0.0..=1.0).contains(x)) => Some((rgb, inks)), + _ => { + log::warn!("Preferences: ignoring a malformed remembered CMYK colour"); + None + } + }) + .take(photocraft_engine::ToolState::CMYK_PICKS) + .collect() + }); + let [pixels, mask] = pairs; + let (current, other) = if app.session.tools.mask_targeted { (mask, pixels) } else { (pixels, mask) }; + [app.session.tools.foreground, app.session.tools.background] = current; + app.session.tools.other_colors = other; + if let Some(picks) = picks { + app.session.tools.cmyk_picks.clear(); + for (rgb, inks) in picks.into_iter().rev() { + app.session.tools.remember_cmyk(rgb, inks); + } + } + app.prefs_rt.tool_cmyk_picks = app.session.tools.cmyk_picks.clone(); + app.prefs_rt.tool_colors = Some(pairs); +} + /// Remember the tools used last when the list changes, whether or not the selected tool did: a /// tool picked during a drag that ends on the tool it began with changes only the list. fn persist_group_tools(app: &mut PhotocraftApp, ctx: &egui::Context, tool: Tool) { @@ -189,7 +270,7 @@ fn restore_brushes(app: &mut PhotocraftApp) { app.prefs_rt.tool_brushes = Some((owner, app.ui.tool_brushes.clone())); } -/// Flush the final choice on normal exit, even if no further frame ran after the tool changed. +/// Flush the final tool state on normal exit, even if no further frame ran after a change. /// The brush first, as in a frame, so the saved brush owner matches the saved brush. pub(crate) fn save_on_exit(app: &mut PhotocraftApp) -> Result<(), String> { let ctx = egui::Context::default(); @@ -247,6 +328,7 @@ pub fn restore(app: &mut PhotocraftApp) { log::warn!("Preferences: the remembered tool options are not an object; using the defaults"); } app.prefs_rt.tool_options = Some(app.ui.tool_options.clone()); + restore_colors(app); restore_brushes(app); } diff --git a/crates/ui-egui/src/tool_shift_cycle_order_tests.rs b/crates/ui-egui/src/tool_shift_cycle_order_tests.rs index 83c3ca33cc6..e49077c4ff2 100644 --- a/crates/ui-egui/src/tool_shift_cycle_order_tests.rs +++ b/crates/ui-egui/src/tool_shift_cycle_order_tests.rs @@ -160,5 +160,5 @@ fn a_plain_w_from_outside_the_group_keeps_selecting_what_it_selected_before() { ctx.begin_pass(raw); crate::shortcuts::handle(&mut app, &ctx); ctx.end_pass().textures_delta.clear(); - assert_eq!(app.ui.tool, Tool::MagicWand, "the first W tool in Tool::ALL, not the first in the flyout"); + assert_eq!(app.ui.tool, Tool::ObjectSelection, "fresh W selects the first tool in the flyout"); } diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 9878bf41a4e..b921d982682 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -2,8 +2,10 @@ pub(super) mod align_bar; pub mod analysis_ui; pub mod anchor_tools; pub mod artboard_tool; +pub mod bg_eraser_popup; pub(crate) mod brush_cursor; pub mod brush_picker; +pub mod brush_refusal; pub mod brush_resize; pub(super) mod cam_transform; pub mod cas_transform; @@ -56,5 +58,7 @@ pub mod vector_ui; pub mod warp_preview; pub mod zoom_tool; +#[cfg(test)] +mod count_show_extras_tests; #[cfg(test)] mod crop_options_labels_tests; diff --git a/crates/ui-egui/src/tools/analysis_ruler_shift_tests.rs b/crates/ui-egui/src/tools/analysis_ruler_shift_tests.rs new file mode 100644 index 00000000000..301636845f3 --- /dev/null +++ b/crates/ui-egui/src/tools/analysis_ruler_shift_tests.rs @@ -0,0 +1,101 @@ +//! Shift constrains the Ruler to 45° steps without making it longer (#11985): on a diagonal step +//! both legs take min(|dx|, |dy|), on a 0° or 90° step the dominant component stays and the other +//! is 0; the same for an endpoint moved around the other one and for a protractor's second arm. + +use egui::Modifiers; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; +use photocraft_engine::doc::Ruler; + +fn ruler_app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 600})).unwrap(); + app.ui.tool = Tool::Ruler; + app +} + +/// A drag from `from` to `to` with `m` held during the move. +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], press: Modifiers, m: Modifiers) { + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, press); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, m); +} + +fn ruler(app: &PhotocraftApp) -> Ruler { + app.session.active().unwrap().doc.measurement.ruler.expect("a ruler") +} + +fn info(app: &PhotocraftApp) -> Value { + photocraft_engine::analysis_cmds::ruler_info(&ruler(app), &Default::default()) +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 0.01 +} + +#[test] +fn drawing_with_shift_keeps_the_shorter_leg_on_diagonals_and_the_dominant_one_on_axes() { + // (pointer, constrained end, L1 when measured), all from 100,100. + for (to, end, l1) in [ + ([182.0, 200.0], [182.0, 182.0], Some(115.97)), + ([222.0, 220.0], [220.0, 220.0], None), + ([400.0, 130.0], [400.0, 100.0], None), + // Signs are kept: dx −82, dy −100. + ([18.0, 0.0], [18.0, 18.0], None), + ] { + let mut app = ruler_app(); + drag(&mut app, [100.0, 100.0], to, Modifiers::NONE, Modifiers::SHIFT); + let r = ruler(&app); + assert_eq!(r.start, [100.0, 100.0]); + assert!(near(r.end[0], end[0]) && near(r.end[1], end[1]), "to {to:?}: end {:?}, expected {end:?}", r.end); + if let Some(l1) = l1 { + let i = info(&app); + assert!(near(i["w"].as_f64().unwrap().abs(), 82.0) && near(i["h"].as_f64().unwrap().abs(), 82.0), "{i}"); + assert!(near(i["l1"].as_f64().unwrap(), l1), "{i}"); + } + assert_eq!(app.session.active().unwrap().history.past_len(), 0, "no history step"); + } +} + +#[test] +fn moving_an_endpoint_with_shift_snaps_around_the_other_one() { + // The end point, around the start. + let mut app = ruler_app(); + drag(&mut app, [100.0, 100.0], [300.0, 250.0], Modifiers::NONE, Modifiers::NONE); + drag(&mut app, [300.0, 250.0], [382.0, 160.0], Modifiers::NONE, Modifiers::SHIFT); + let r = ruler(&app); + assert_eq!((r.start, r.end), ([100.0, 100.0], [382.0, 100.0]), "W 282, H 0"); + // The start point, around the end. + let mut app = ruler_app(); + drag(&mut app, [100.0, 100.0], [400.0, 300.0], Modifiers::NONE, Modifiers::NONE); + drag(&mut app, [100.0, 100.0], [118.0, 240.0], Modifiers::NONE, Modifiers::SHIFT); + let r = ruler(&app); + assert_eq!((r.start, r.end), ([118.0, 300.0], [400.0, 300.0]), "W 282, H 0"); +} + +#[test] +fn a_protractor_arm_with_shift_keeps_the_shorter_leg() { + let mut app = ruler_app(); + drag(&mut app, [400.0, 300.0], [600.0, 300.0], Modifiers::NONE, Modifiers::NONE); + // Shift+Alt-drag from the vertex: pointer at +62, −80. + drag(&mut app, [400.0, 300.0], [462.0, 220.0], Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT); + let arm = ruler(&app).protractor.expect("a protractor"); + assert!(near(arm[0], 462.0) && near(arm[1], 238.0), "{arm:?}"); + let i = info(&app); + assert!(near(i["l2"].as_f64().unwrap(), 87.68), "{i}"); + // Shift-drag that arm end to dx 122, dy −170 from the vertex. + drag(&mut app, [462.0, 238.0], [522.0, 130.0], Modifiers::NONE, Modifiers::SHIFT); + let arm = ruler(&app).protractor.unwrap(); + assert!(near(arm[0], 522.0) && near(arm[1], 178.0), "{arm:?}"); + assert!(near(info(&app)["l2"].as_f64().unwrap(), 172.53)); +} + +#[test] +fn without_shift_the_endpoint_follows_the_pointer() { + let mut app = ruler_app(); + drag(&mut app, [100.0, 100.0], [182.0, 200.0], Modifiers::NONE, Modifiers::NONE); + assert_eq!(ruler(&app).end, [182.0, 200.0]); +} diff --git a/crates/ui-egui/src/tools/analysis_ui.rs b/crates/ui-egui/src/tools/analysis_ui.rs index ed113909a6b..ad31e534633 100644 --- a/crates/ui-egui/src/tools/analysis_ui.rs +++ b/crates/ui-egui/src/tools/analysis_ui.rs @@ -7,6 +7,10 @@ //! part of the serialisable UI state so the control channel can read and drive it). The Color //! Sampler points themselves live in the engine (`DocState.color_samplers`) so every front end //! shares them; this module only maps pointer input and draws the numbered markers. +//! +//! The Ruler options bar's Straighten Layer button is on when the engine's check says so, and a +//! hidden target layer answers its click with the reference's alert rather than the status line +//! (#12033). use egui::{Align2, Color32, CornerRadius, FontId, Pos2, RichText, Stroke, pos2, vec2}; use photocraft_doc::{Document, Ruler}; @@ -15,7 +19,7 @@ use serde_json::{Map, Value, json}; use crate::PhotocraftApp; use crate::canvas::{ToolEvent, ViewXform}; -use crate::state::Tool; +use crate::state::{DialogKind, Tool}; use crate::theme::Tokens; /// What a pointer drag is doing (not serialised). @@ -29,9 +33,13 @@ pub enum Drag { Count(usize, usize, [f64; 2]), /// Moving note `index`: grab offset from its position. Note(usize, [f64; 2]), - /// Moving Color Sampler point `index` from where the press landed (a click, not a drag, does - /// nothing; dragging off the canvas removes the point). + /// Moving Color Sampler point `index`; the second field is where the pointer is now, where its + /// marker is drawn (#11729). The release moves the point there (a click on it too); releasing + /// off the canvas removes it. Sampler(usize, [f64; 2]), + /// A new Color Sampler point following the pointer, placed where the drag ends if that is on + /// the canvas (#11729). + SamplerNew([f64; 2]), } /// View state of the analysis tools and panels. @@ -64,7 +72,7 @@ impl Default for AnalysisUi { log_selected: Vec::new(), note_selected: None, note_author: String::new(), - note_color: [1.0, 1.0, 0.51], + note_color: photocraft_engine::notes_cmds::DEFAULT_NOTE_COLOR, use_measurement_scale: false, dialog: None, drag: None, @@ -215,15 +223,24 @@ fn dist(a: [f64; 2], b: [f64; 2]) -> f64 { (a[0] - b[0]).hypot(a[1] - b[1]) } -/// Shift constrains to 45° steps around `from`. +/// Shift constrains to 45° steps around `from`, as the reference does (#11985): on a diagonal step +/// both legs take the smaller of |dx| and |dy| (signs kept), on a 0° or 90° step the dominant +/// component stays and the other becomes 0, so the ruler never gets longer than the pointer's legs. fn constrain(from: [f64; 2], to: [f64; 2], shift: bool) -> [f64; 2] { if !shift { return to; } let (dx, dy) = (to[0] - from[0], to[1] - from[1]); - let a = (dy.atan2(dx) / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4; - let l = dx.hypot(dy); - [from[0] + l * a.cos(), from[1] + l * a.sin()] + let step = (dy.atan2(dx) / std::f64::consts::FRAC_PI_4).round() as i64; + let (cx, cy) = match step.rem_euclid(4) { + 0 => (dx, 0.0), + 2 => (0.0, dy), + _ => { + let leg = dx.abs().min(dy.abs()); + (leg.copysign(dx), leg.copysign(dy)) + } + }; + [from[0] + cx, from[1] + cy] } fn set_ruler(app: &mut PhotocraftApp, r: Ruler) { @@ -311,7 +328,10 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> } Some((g, i)) => app.ui.analysis.drag = Some(Drag::Count(g, i, [x, y])), None if !mods.alt => { - let _ = app.run("count.add", json!({"x": x, "y": y})); + // A marker that stays invisible looks like the click did nothing, so adding one + // brings View > Show > Count back. It is view state, not a history step. + let added = app.run("count.add", json!({"x": x, "y": y})).is_ok(); + app.ui.view.show.count |= added; } None => {} } @@ -347,6 +367,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> } } } + // As in Photoshop (#11729): the press only starts a drag and the release decides. A new + // point follows the pointer and lands where the drag ends; a press on a point drags it + // there (a click on it moves it to the clicked pixel); Escape cancels either drag. (Tool::ColorSampler, ToolEvent::Down { x, y, .. }) => { let p = [x, y]; match nearest_sampler(&samplers, p, tol) { @@ -354,34 +377,28 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> Some(i) if mods.alt => { let _ = app.run("view.colorSamplers.delete", json!({"index": i})); } - // A plain click on a point starts a move (committed on release). Some(i) => app.ui.analysis.drag = Some(Drag::Sampler(i, p)), - // ⌥-click on empty space does nothing; a plain click inside places a new point. - None if mods.alt => {} - None if (0.0..f64::from(doc.size.width)).contains(&x) && (0.0..f64::from(doc.size.height)).contains(&y) => { - let r = app.run("view.colorSamplers.add", json!({"x": x, "y": y})); - status_on_err(app, r); - } - // A click outside the canvas places nothing, as in Photoshop. - None => {} + // Off a point (⌥ or not, on the pasteboard too) a new point starts following the pointer. + None => app.ui.analysis.drag = Some(Drag::SamplerNew(p)), + } + } + (Tool::ColorSampler, ToolEvent::Move { x, y, .. }) => { + if let Some(Drag::Sampler(_, at) | Drag::SamplerNew(at)) = &mut app.ui.analysis.drag { + *at = [x, y]; } } (Tool::ColorSampler, ToolEvent::Up { x, y }) => { - if let Some(Drag::Sampler(i, from)) = app.ui.analysis.drag.take() { - if dist(from, [x, y]) <= 0.5 { - return true; // a click on a point, not a drag: nothing to do - } - // Dropped inside the canvas: move it. Dropped outside: remove just that point. - let inside = (0.0..f64::from(doc.size.width)).contains(&x) && (0.0..f64::from(doc.size.height)).contains(&y); - let r = if inside { - app.run("view.colorSamplers.move", json!({"index": i, "x": x, "y": y})) - } else { - app.run("view.colorSamplers.delete", json!({"index": i})) - }; - status_on_err(app, r); - } + let inside = (0.0..f64::from(doc.size.width)).contains(&x) && (0.0..f64::from(doc.size.height)).contains(&y); + let r = match app.ui.analysis.drag.take() { + // Released inside the canvas: move it there. Outside: remove just that point. + Some(Drag::Sampler(i, _)) if inside => app.run("view.colorSamplers.move", json!({"index": i, "x": x, "y": y})), + Some(Drag::Sampler(i, _)) => app.run("view.colorSamplers.delete", json!({"index": i})), + // A new point is placed where the drag ends; ending outside the canvas places none. + Some(Drag::SamplerNew(_)) if inside => app.run("view.colorSamplers.add", json!({"x": x, "y": y})), + _ => return true, + }; + status_on_err(app, r); } - (Tool::ColorSampler, ToolEvent::Move { .. }) => {} _ => {} } true @@ -392,6 +409,18 @@ fn nearest_sampler(points: &[[f64; 2]], at: [f64; 2], tol: f64) -> Option points.iter().enumerate().filter(|(_, q)| dist(**q, at) <= tol).min_by(|a, b| dist(*a.1, at).total_cmp(&dist(*b.1, at))).map(|(i, _)| i) } +/// The Color Sampler point under document point `at` on the active document, by the same hit test +/// a click uses, so the right-click menu acts on the sampler a click would grab. +pub(crate) fn sampler_at(app: &PhotocraftApp, at: [f64; 2]) -> Option { + if !at.iter().all(|v| v.is_finite()) { + return None; + } + nearest_sampler(&app.session.active()?.color_samplers, at, tolerance(app)) +} + +#[cfg(test)] +mod sampler_menu_tests; + /// Show a command error in the status bar without treating the sample-limit message as a fault. fn status_on_err(app: &mut PhotocraftApp, r: Result) { if let Err(e) = r { @@ -478,7 +507,15 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } } // Color Sampler points (#1046): persistent numbered markers, drawn whatever the active tool is. - for (i, p) in app.session.active().map(|d| d.color_samplers.clone()).unwrap_or_default().iter().enumerate() { + // While one is dragged its marker follows the pointer, and a new one shows the next number + // there (#11729), unless the limit is reached and the release cannot place it. + let mut samplers = app.session.active().map(|d| d.color_samplers.clone()).unwrap_or_default(); + match app.ui.analysis.drag { + Some(Drag::Sampler(i, at)) if app.ui.tool == Tool::ColorSampler && i < samplers.len() => samplers[i] = at, + Some(Drag::SamplerNew(at)) if app.ui.tool == Tool::ColorSampler && samplers.len() < photocraft_engine::sampler_cmds::MAX_SAMPLERS => samplers.push(at), + _ => {} + } + for (i, p) in samplers.iter().enumerate() { let at = xf.to_screen(p[0] as f32, p[1] as f32); // A small target reticle, double-stroked so it reads on any pixel. for (w, c) in [(3.0, Color32::from_black_alpha(170)), (1.0, Color32::WHITE)] { @@ -526,8 +563,26 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::vline(ui, 22.0); crate::widgets::checkbox(ui, &mut app.ui.analysis.use_measurement_scale, tl!("Use Measurement Scale")); let has = doc.measurement.ruler.is_some(); - if ui.add_enabled_ui(has, |ui| crate::widgets::secondary_button(ui, tl!("Straighten Layer"), 0.0)).inner.clicked() { - let _ = app.run("image.analysis.straightenLayer", json!({})); + // The engine decides this button's state, so it goes grey for exactly the targets + // Straighten Layer is off for: the Background, several selected layers, a group and a + // layer locked in place (#12033). + let straighten = app.session.is_enabled("image.analysis.straightenLayer"); + if ui.add_enabled_ui(straighten, |ui| crate::widgets::secondary_button(ui, tl!("Straighten Layer"), 0.0)).inner.clicked() { + let hidden = photocraft_engine::analysis_cmds::STRAIGHTEN_HIDDEN_ERROR; + if let Err(why) = app.run("image.analysis.straightenLayer", json!({})) { + // A hidden target layer keeps the button on and answers the click with the + // reference's alert (its message and an OK button), changing nothing (#12033). + if why == hidden { + app.ui.open_dialog(DialogKind::Error, json!({"message": tl!(hidden)}).as_object().cloned().unwrap_or_default()); + } + // Every other refusal goes to the status line, as the eraser tools do. What the + // command itself answered is reported there by `PhotocraftApp::run`, but a + // refusal raised before it runs - the automation gate a control or MCP-driven + // click passes - returns early, and a click that says nothing reads as a dead + // button (#12148). + app.ui.status = why; + app.ui.status_error = true; + } } if ui.add_enabled_ui(has, |ui| crate::widgets::secondary_button(ui, tl!("Clear"), 0.0)).inner.clicked() { let _ = app.run("image.analysis.rulerTool", json!({"clear": true})); @@ -998,6 +1053,9 @@ fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { #[cfg(test)] pub(crate) mod tests; +#[cfg(test)] +mod sampler_drag_tests; + /// Marks the "Delete this note?" alert (#11788). const DELETE_NOTE_MARK: &str = "__deleteNote"; /// Its "Don't show again", kept with the other dialog preferences. @@ -1073,6 +1131,16 @@ pub fn confirm_delete_note(app: &mut PhotocraftApp, fields: &Map) /// layer (Delete Layer) or the pixels (Edit › Clear). Text fields keep the keys. pub fn note_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { use crate::shortcut_dispatch::Focus; + // Escape during a Color Sampler drag cancels it: no new point, or the dragged one stays (#11729). + // A tool picked mid-drag gets the release, so the drag is over: drop it and leave Escape alone. + if matches!(app.ui.analysis.drag, Some(Drag::Sampler(..) | Drag::SamplerNew(_))) { + if app.ui.tool != Tool::ColorSampler { + app.ui.analysis.drag = None; + } else if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + app.ui.analysis.drag = None; + return true; + } + } let Some(index) = app.ui.analysis.note_selected else { return false }; if app.ui.tool != Tool::Note || app.ui.text_edit.is_some() || matches!(Focus::of(ctx), Focus::Text | Focus::DeletingWidget) { return false; @@ -1091,3 +1159,7 @@ pub fn note_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { #[cfg(test)] #[path = "note_delete_key_tests.rs"] mod note_delete_key_tests; + +#[cfg(test)] +#[path = "analysis_ruler_shift_tests.rs"] +mod analysis_ruler_shift_tests; diff --git a/crates/ui-egui/src/tools/analysis_ui/sampler_drag_tests.rs b/crates/ui-egui/src/tools/analysis_ui/sampler_drag_tests.rs new file mode 100644 index 00000000000..4c66548ddc2 --- /dev/null +++ b/crates/ui-egui/src/tools/analysis_ui/sampler_drag_tests.rs @@ -0,0 +1,246 @@ +//! #11729: Color Sampler drag gestures, as in Photoshop. A new point follows the pointer and lands +//! where the drag ends, a press on a point drags it (a click on it moves it to the clicked pixel), +//! ⌥-click off a point adds one, and Escape cancels either drag. + +use super::*; +use egui::Modifiers; + +const NONE: Modifiers = Modifiers::NONE; +const ALT: Modifiers = Modifiers { alt: true, ctrl: false, shift: false, mac_cmd: false, command: false }; + +/// A 200 × 100 document with the Color Sampler tool. +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.ui.tool = Tool::ColorSampler; + app +} + +fn down(app: &mut PhotocraftApp, x: f64, y: f64, m: Modifiers) { + crate::canvas::tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, m); +} + +fn mv(app: &mut PhotocraftApp, x: f64, y: f64) { + crate::canvas::tool_event(app, ToolEvent::Move { x, y, pressure: 1.0 }, NONE); +} + +fn up(app: &mut PhotocraftApp, x: f64, y: f64) { + crate::canvas::tool_event(app, ToolEvent::Up { x, y }, NONE); +} + +fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) { + down(app, from[0], from[1], NONE); + mv(app, (from[0] + to[0]) / 2.0, (from[1] + to[1]) / 2.0); + mv(app, to[0], to[1]); + up(app, to[0], to[1]); +} + +fn samplers(app: &PhotocraftApp) -> Vec<[f64; 2]> { + app.session.active().unwrap().color_samplers.clone() +} + +/// The pixel each point sits on (the stored position floors to it). +fn pixels(app: &PhotocraftApp) -> Vec<[i64; 2]> { + samplers(app).iter().map(|p| [p[0].floor() as i64, p[1].floor() as i64]).collect() +} + +/// Escape through the app's shortcut handling, as the canvas gets it. +fn escape(app: &mut PhotocraftApp) { + let ctx = egui::Context::default(); + let raw = egui::RawInput { + events: vec![ + egui::Event::ModifiersChanged(NONE), + egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: NONE }, + ], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); +} + +#[test] +fn a_new_point_follows_the_pointer_and_lands_where_the_drag_ends() { + let mut app = app(); + down(&mut app, 120.0, 40.0, NONE); + assert!(samplers(&app).is_empty(), "nothing is placed on the press"); + mv(&mut app, 135.0, 50.0); + assert_eq!(app.ui.analysis.drag, Some(Drag::SamplerNew([135.0, 50.0])), "the marker follows the pointer"); + mv(&mut app, 150.0, 60.0); + up(&mut app, 150.0, 60.0); + assert_eq!(pixels(&app), vec![[150, 60]]); + assert_eq!(app.ui.analysis.drag, None); +} + +#[test] +fn a_drag_ending_off_the_canvas_places_nothing_and_one_from_the_pasteboard_places_one() { + let mut app = app(); + drag(&mut app, [150.0, 50.0], [230.0, 50.0]); + assert!(samplers(&app).is_empty(), "ended on the pasteboard"); + drag(&mut app, [-20.0, 50.0], [30.0, 40.0]); + assert_eq!(pixels(&app), vec![[30, 40]], "started on the pasteboard, ended on the canvas"); +} + +#[test] +fn a_plain_click_off_a_point_still_places_one_at_that_pixel() { + let mut app = app(); + down(&mut app, 12.4, 7.9, NONE); + up(&mut app, 12.4, 7.9); + assert_eq!(pixels(&app), vec![[12, 7]]); + // A click on the pasteboard places nothing. + down(&mut app, -5.0, 7.0, NONE); + up(&mut app, -5.0, 7.0); + assert_eq!(samplers(&app).len(), 1); +} + +#[test] +fn dragging_a_point_moves_its_marker_live_and_the_point_on_release() { + let mut app = app(); + app.run("view.colorSamplers.add", json!({"x": 50, "y": 50})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 100, "y": 20})).unwrap(); + let before = samplers(&app); + down(&mut app, before[0][0], before[0][1], NONE); + mv(&mut app, 70.0, 30.0); + assert_eq!(app.ui.analysis.drag, Some(Drag::Sampler(0, [70.0, 30.0])), "the marker follows the pointer"); + assert_eq!(samplers(&app), before, "the point itself moves on release"); + mv(&mut app, 80.0, 20.0); + up(&mut app, 80.0, 20.0); + assert_eq!(pixels(&app), vec![[80, 20], [100, 20]], "#1 moved, #2 untouched, numbering kept"); +} + +#[test] +fn a_click_on_a_point_two_pixels_off_moves_it_to_the_clicked_pixel() { + let mut app = app(); + app.run("view.colorSamplers.add", json!({"x": 50, "y": 50})).unwrap(); + let p = samplers(&app)[0]; + down(&mut app, p[0] + 2.0, p[1] + 1.0, NONE); + up(&mut app, p[0] + 2.0, p[1] + 1.0); + assert_eq!(pixels(&app), vec![[52, 51]]); +} + +#[test] +fn dragging_a_point_off_the_document_still_deletes_it() { + let mut app = app(); + app.run("view.colorSamplers.add", json!({"x": 50, "y": 50})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 100, "y": 20})).unwrap(); + let p = samplers(&app)[0]; + drag(&mut app, p, [-30.0, 50.0]); + assert_eq!(pixels(&app), vec![[100, 20]]); +} + +#[test] +fn alt_click_off_a_point_adds_one_and_on_a_point_deletes_it() { + let mut app = app(); + down(&mut app, 60.0, 30.0, ALT); + up(&mut app, 60.0, 30.0); + assert_eq!(pixels(&app), vec![[60, 30]], "⌥-click off a point adds one, like a plain click"); + let p = samplers(&app)[0]; + down(&mut app, p[0], p[1], ALT); + up(&mut app, p[0], p[1]); + assert!(samplers(&app).is_empty(), "⌥-click on a point deletes it"); +} + +#[test] +fn escape_cancels_either_drag_and_leaves_the_list_unchanged() { + // A new point: Escape mid-drag, then the release places nothing. + let mut app = app(); + down(&mut app, 40.0, 40.0, NONE); + mv(&mut app, 60.0, 50.0); + escape(&mut app); + assert_eq!(app.ui.analysis.drag, None); + up(&mut app, 60.0, 50.0); + assert!(samplers(&app).is_empty(), "no new point"); + // An existing point stays where it was. + app.run("view.colorSamplers.add", json!({"x": 50, "y": 50})).unwrap(); + let before = samplers(&app); + down(&mut app, before[0][0], before[0][1], NONE); + mv(&mut app, 90.0, 10.0); + escape(&mut app); + up(&mut app, 90.0, 10.0); + assert_eq!(samplers(&app), before, "the dragged point stays put"); +} + +#[test] +fn the_dragged_and_the_new_marker_are_drawn_at_the_pointer() { + let mut app = app(); + app.run("view.colorSamplers.add", json!({"x": 50, "y": 50})).unwrap(); + let ctx = egui::Context::default(); + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(200.0, 100.0)), + zoom: 1.0, + center: [100.0, 50.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + // The marker numbers the overlay paints, with where they are. + let numbers_at = |app: &PhotocraftApp| -> Vec<(String, egui::Pos2)> { + let mut o = ctx.run_ui(Default::default(), |ui| draw_overlay(app, ui.painter(), &xf)); + o.textures_delta.clear(); + o.shapes + .into_iter() + .filter_map(|c| match c.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_owned(), t.pos)), + _ => None, + }) + .collect() + }; + let p = samplers(&app)[0]; + down(&mut app, p[0], p[1], NONE); + mv(&mut app, 120.0, 30.0); + let at = xf.to_screen(120.0, 30.0); + let shown = numbers_at(&app); + assert!(shown.iter().any(|(t, q)| t == "1" && (q.x - at.x).abs() < 20.0 && (q.y - at.y).abs() < 20.0), "#1 follows the pointer: {shown:?}"); + up(&mut app, 120.0, 30.0); + down(&mut app, 20.0, 80.0, NONE); + mv(&mut app, 30.0, 70.0); + let at = xf.to_screen(30.0, 70.0); + let shown = numbers_at(&app); + assert!(shown.iter().any(|(t, q)| t == "2" && (q.x - at.x).abs() < 20.0 && (q.y - at.y).abs() < 20.0), "the new #2 follows the pointer: {shown:?}"); +} + +#[test] +fn at_the_ten_point_limit_a_drag_off_a_point_previews_no_new_marker() { + let mut app = app(); + for i in 0..10 { + app.run("view.colorSamplers.add", json!({"x": 10 + 15 * i, "y": 10})).unwrap(); + } + let ctx = egui::Context::default(); + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(200.0, 100.0)), + zoom: 1.0, + center: [100.0, 50.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + down(&mut app, 50.0, 70.0, NONE); + mv(&mut app, 60.0, 80.0); + let mut o = ctx.run_ui(Default::default(), |ui| draw_overlay(&app, ui.painter(), &xf)); + o.textures_delta.clear(); + let numbers: Vec = o + .shapes + .into_iter() + .filter_map(|c| match c.shape { + egui::Shape::Text(t) => Some(t.galley.text().to_owned()), + _ => None, + }) + .collect(); + assert!(!numbers.iter().any(|t| t == "11"), "no 11th marker that the release cannot place: {numbers:?}"); + up(&mut app, 60.0, 80.0); + assert_eq!(samplers(&app).len(), 10); +} + +#[test] +fn a_sampler_drag_whose_release_went_to_another_tool_leaves_escape_alone() { + let mut app = app(); + app.run("select.all", json!({})).unwrap(); + down(&mut app, 40.0, 40.0, NONE); + mv(&mut app, 60.0, 50.0); + // A tool letter mid-drag: the release goes to the new tool. + crate::tool_memory::select(&mut app, Tool::Hand); + up(&mut app, 60.0, 50.0); + escape(&mut app); + assert_eq!(app.ui.analysis.drag, None, "the stale drag is dropped"); + assert!(app.session.active().unwrap().doc.selection.is_none(), "Escape reached Deselect, not the stale sampler drag"); +} diff --git a/crates/ui-egui/src/tools/analysis_ui/sampler_menu_tests.rs b/crates/ui-egui/src/tools/analysis_ui/sampler_menu_tests.rs new file mode 100644 index 00000000000..9388a31f682 --- /dev/null +++ b/crates/ui-egui/src/tools/analysis_ui/sampler_menu_tests.rs @@ -0,0 +1,217 @@ +//! #11733: the Color Sampler's right-click menu on the canvas. + +use egui::{Event, Modifiers, PointerButton, vec2}; +use egui_kittest::Harness; +use serde_json::{Value, json}; + +use crate::canvas_tool_menu::{SAMPLER_DELETE, SAMPLER_MENU}; +use crate::control::{ControlRequest, Outcome, handle}; +use crate::{PhotocraftApp, Tool}; + +/// A 200 x 100 document with the Color Sampler and samplers at (20, 20) and (120, 60). +fn app() -> (PhotocraftApp, egui::Context) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 20, "y": 20})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 120, "y": 60})).unwrap(); + app.ui.tool = Tool::ColorSampler; + (app, egui::Context::default()) +} + +fn control(app: &mut PhotocraftApp, ctx: &egui::Context, method: &str, params: Value) -> Value { + let (req, _rx) = ControlRequest::new(method, params); + let Outcome::Done(v) = handle(app, ctx, &req) else { panic!("{method} replies at once") }; + v +} + +/// An agent's right-click at document point (x, y), as `ui.pointer` sends it. +fn right_click(app: &mut PhotocraftApp, ctx: &egui::Context, x: f64, y: f64) { + let r = control(app, ctx, "ui.pointer", json!({"button": "secondary", "events": [{"kind": "down", "x": x, "y": y}, {"kind": "up", "x": x, "y": y}]})); + assert_eq!(r["ok"], json!(true), "{r}"); +} + +fn entries(app: &mut PhotocraftApp, ctx: &egui::Context) -> Vec { + let r = control(app, ctx, "ui.inspect", json!({})); + r["result"]["canvasToolMenu"]["entries"].as_array().cloned().unwrap_or_else(|| panic!("no canvasToolMenu: {r}")) +} + +fn samplers(app: &PhotocraftApp) -> Vec<[f64; 2]> { + app.session.active().unwrap().color_samplers.clone() +} + +/// The reference application's rows, in order, with `|` for a separator. +const EXPECTED: &[&str] = &[ + "Delete", + "|", + "Actual Color", + "Proof Color", + "|", + "Grayscale", + "RGB Color", + "Web Color", + "HSB Color", + "HSL Color", + "CMYK Color", + "Lab Color", + "|", + "Grayscale 16 bit", + "RGB 16 bit", + "CMYK 16 bit", + "Lab 16 bit", + "|", + "Total Ink", +]; + +fn labels(entries: &[Value]) -> Vec { + entries.iter().map(|e| if e["separator"] == json!(true) { "|".to_string() } else { e["label"].as_str().unwrap().to_string() }).collect() +} + +#[test] +fn right_click_on_a_sampler_lists_the_menu_in_order_with_delete_enabled() { + let (mut app, ctx) = app(); + right_click(&mut app, &ctx, 20.5, 20.5); + let menu = app.ui.canvas_tool_menu.clone().expect("the Color Sampler menu opens"); + assert_eq!(menu.tool, Tool::ColorSampler); + assert_eq!(menu.sampler, Some(0)); + let entries = entries(&mut app, &ctx); + assert_eq!(labels(&entries), EXPECTED); + let enabled: Vec<&str> = entries.iter().filter(|e| e["enabled"] == json!(true)).filter_map(|e| e["id"].as_str()).collect(); + // The readout rows wait for per-sampler readout modes (#11735) and stay greyed until then. + assert_eq!(enabled, [SAMPLER_DELETE]); + for (_, id) in SAMPLER_MENU.iter().flatten() { + assert!(!crate::canvas_tool_menu::mode_checked(&app, &menu, id), "{id} shows no checkmark"); + } + assert_eq!(samplers(&app).len(), 2, "the right-click places no sampler"); +} + +#[test] +fn right_click_off_a_sampler_greys_every_row() { + let (mut app, ctx) = app(); + right_click(&mut app, &ctx, 70.0, 40.0); + let menu = app.ui.canvas_tool_menu.clone().expect("the menu opens off a sampler too"); + assert_eq!(menu.sampler, None); + let entries = entries(&mut app, &ctx); + assert_eq!(labels(&entries), EXPECTED); + assert!(entries.iter().all(|e| e["enabled"] != json!(true)), "every row greyed: {entries:?}"); + let r = control(&mut app, &ctx, "ui.context.choose", json!({"id": SAMPLER_DELETE})); + assert_eq!(r["ok"], json!(false), "a greyed Delete does nothing: {r}"); + assert_eq!(samplers(&app).len(), 2, "nothing deleted, no sampler placed"); +} + +#[test] +fn delete_removes_the_sampler_under_the_pointer_and_renumbers_the_rest() { + let (mut app, ctx) = app(); + app.run("view.colorSamplers.add", json!({"x": 180, "y": 90})).unwrap(); + right_click(&mut app, &ctx, 120.0, 60.0); + assert_eq!(app.ui.canvas_tool_menu.as_ref().and_then(|m| m.sampler), Some(1)); + let r = control(&mut app, &ctx, "ui.context.choose", json!({"id": SAMPLER_DELETE})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert_eq!(samplers(&app), [[20.0, 20.0], [180.0, 90.0]], "#3 became #2"); + assert!(app.ui.canvas_tool_menu.is_none(), "choosing closes the menu"); + let listed = app.run("view.colorSamplers.list", json!({})).unwrap(); + assert_eq!(listed["samplers"][1]["number"], json!(2)); +} + +#[test] +fn delete_never_removes_another_sampler_after_the_list_changes_under_the_menu() { + let (mut app, ctx) = app(); + app.run("view.colorSamplers.add", json!({"x": 180, "y": 90})).unwrap(); + right_click(&mut app, &ctx, 120.0, 60.0); + assert_eq!(app.ui.canvas_tool_menu.as_ref().and_then(|m| m.sampler), Some(1)); + // An agent deletes #1 while the menu is open: index 1 now names the old #3. + app.run("view.colorSamplers.delete", json!({"index": 0})).unwrap(); + let r = control(&mut app, &ctx, "ui.context.choose", json!({"id": SAMPLER_DELETE})); + assert_eq!(r["ok"], json!(false), "Delete greys out: {r}"); + assert_eq!(samplers(&app), [[120.0, 60.0], [180.0, 90.0]], "no other sampler is deleted"); + // The menu itself closes on the next frame. + right_click(&mut app, &ctx, 120.0, 60.0); + app.run("view.colorSamplers.move", json!({"index": 0, "x": 30, "y": 30})).unwrap(); + ctx.run_ui(Default::default(), |ui| crate::canvas_tool_menu::show(&mut app, ui.ctx())).textures_delta.clear(); + assert!(app.ui.canvas_tool_menu.is_none(), "a moved sampler closes the menu"); + // Another document with a sampler at the same index and spot: still not this menu's sampler. + right_click(&mut app, &ctx, 180.0, 90.0); + assert_eq!(app.ui.canvas_tool_menu.as_ref().and_then(|m| m.sampler), Some(1)); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + for (x, y) in [(20, 20), (180, 90)] { + app.run("view.colorSamplers.add", json!({"x": x, "y": y})).unwrap(); + } + let menu = app.ui.canvas_tool_menu.clone().expect("the menu is still open"); + assert!(!crate::canvas_tool_menu::entry_enabled(&app, &menu, SAMPLER_DELETE), "another document's sampler"); +} + +#[test] +fn readout_rows_do_nothing_until_samplers_have_readout_modes() { + let (mut app, ctx) = app(); + right_click(&mut app, &ctx, 20.0, 20.0); + let before = app.session.active().unwrap().revision; + let r = control(&mut app, &ctx, "ui.context.choose", json!({"id": "colorSampler.readout.hsl"})); + assert_eq!(r["ok"], json!(false), "{r}"); + assert_eq!(app.session.active().unwrap().revision, before); + assert_eq!(samplers(&app).len(), 2); +} + +#[test] +fn a_stale_sampler_or_odd_point_fails_gracefully() { + let (mut app, ctx) = app(); + right_click(&mut app, &ctx, 120.0, 60.0); + // The sampler went away while the menu was open: Delete greys out instead of removing another. + app.run("view.colorSamplers.clear", json!({})).unwrap(); + app.run("view.colorSamplers.add", json!({"x": 5, "y": 5})).unwrap(); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + assert!(!crate::canvas_tool_menu::entry_enabled(&app, &menu, SAMPLER_DELETE)); + crate::canvas_tool_menu::choose(&mut app, &ctx, SAMPLER_DELETE); + assert_eq!(samplers(&app), [[5.0, 5.0]]); + for p in [[f64::NAN, 5.0], [f64::INFINITY, 5.0], [5.0, f64::NEG_INFINITY]] { + assert_eq!(super::sampler_at(&app, p), None); + } + // No document: the menu opens greyed, nothing panics. + let mut empty = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + empty.ui.tool = Tool::ColorSampler; + assert_eq!(super::sampler_at(&empty, [1.0, 1.0]), None); + if crate::canvas_tool_menu::open_at(&mut empty, &ctx, Tool::ColorSampler, [10.0, 10.0], Some([1.0, 1.0])) { + let menu = empty.ui.canvas_tool_menu.clone().unwrap(); + assert!(SAMPLER_MENU.iter().flatten().all(|(_, id)| !crate::canvas_tool_menu::entry_enabled(&empty, &menu, id))); + crate::canvas_tool_menu::choose(&mut empty, &ctx, SAMPLER_DELETE); + } +} + +#[test] +fn other_tools_keep_their_menus() { + let (mut app, ctx) = app(); + app.ui.tool = Tool::RectMarquee; + right_click(&mut app, &ctx, 20.0, 20.0); + let menu = app.ui.canvas_tool_menu.clone().expect("the selection menu"); + assert_eq!(crate::canvas_tool_menu::rows(&menu), crate::canvas_tool_menu::NO_SELECTION_MENU); + app.ui.canvas_tool_menu = None; + app.ui.tool = Tool::Pen; + right_click(&mut app, &ctx, 20.0, 20.0); + let menu = app.ui.canvas_tool_menu.clone().expect("the path menu"); + assert_eq!(crate::canvas_tool_menu::rows(&menu), crate::canvas_tool_menu::PEN_MENU); + app.ui.canvas_tool_menu = None; + // Outside the image the Color Sampler keeps the pasteboard menu. + app.ui.tool = Tool::ColorSampler; + right_click(&mut app, &ctx, 250.0, 40.0); + assert!(app.ui.canvas_tool_menu.as_ref().is_some_and(|m| m.pasteboard)); +} + +/// A real right-click on the canvas over sampler #2 opens the menu on it. +#[test] +fn canvas_right_click_opens_the_menu_on_the_sampler() { + 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()); + app().0 + }); + h.run_steps(5); + let xf = crate::canvas::ViewXform::active(h.state()).unwrap(); + let at = xf.to_screen(120.5, 60.5); + h.event(Event::PointerMoved(at)); + h.run_steps(2); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(3); + let menu = h.state().ui.canvas_tool_menu.clone().expect("the Color Sampler menu opens"); + assert_eq!((menu.tool, menu.sampler), (Tool::ColorSampler, Some(1))); + assert_eq!(h.state().session.active().unwrap().color_samplers.len(), 2, "no sampler was placed or moved"); +} diff --git a/crates/ui-egui/src/tools/analysis_ui/tests.rs b/crates/ui-egui/src/tools/analysis_ui/tests.rs index 3ed65a3ffd7..517e7df6494 100644 --- a/crates/ui-egui/src/tools/analysis_ui/tests.rs +++ b/crates/ui-egui/src/tools/analysis_ui/tests.rs @@ -296,3 +296,147 @@ fn count_size_fields_send_whole_numbers_the_engine_applies() { let g = &app.session.active().unwrap().doc.measurement.count_groups[0]; assert_eq!((g.marker_size, g.label_size), (8, 20)); } + +// ---------------------------------------------------------------- Straighten Layer button (#12033) + +/// The Ruler options bar, drawn wide enough for its buttons to show. +fn ruler_bar(mut app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> { + use egui::vec2; + app.ui.tool = Tool::Ruler; + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state( + |ui: &mut egui::Ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro); + h.run_steps(4); + h +} + +/// A ruler line to straighten by, without one the button is off for every target. +fn rule(app: &mut PhotocraftApp) { + app.run("image.analysis.rulerTool", json!({"start": [10, 30], "end": [150, 25]})).unwrap(); +} + +/// The message of every open error dialog, in the order they were opened. +fn alerts(app: &PhotocraftApp) -> Vec<&str> { + app.ui.dialogs.iter().filter(|d| d.kind == DialogKind::Error).filter_map(|d| d.fields.get("message").and_then(|m| m.as_str())).collect() +} + +/// Each case opens one of the targets #12033 measured, on top of the plain document `app()` makes. +fn add_layer(app: &mut PhotocraftApp, name: &str) -> u64 { + app.run("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap() +} + +fn on_a_layer(app: &mut PhotocraftApp) { + add_layer(app, "Paint"); +} + +fn on_the_background(_: &mut PhotocraftApp) {} + +fn on_two_layers(app: &mut PhotocraftApp) { + let a = add_layer(app, "One"); + let b = add_layer(app, "Two"); + app.run("layer.select", json!({"layer": a})).unwrap(); + app.run("layer.select", json!({"layer": b, "mode": "add"})).unwrap(); +} + +fn on_a_group(app: &mut PhotocraftApp) { + add_layer(app, "Inner"); + app.run("layer.groupLayers", json!({})).unwrap(); +} + +fn locked(app: &mut PhotocraftApp, key: &str) { + let l = add_layer(app, "Colors"); + app.run("layer.setProps", json!({"layer": l, "locks": {key: true}})).unwrap(); +} + +fn on_lock_all(app: &mut PhotocraftApp) { + locked(app, "all"); +} + +fn on_lock_position(app: &mut PhotocraftApp) { + locked(app, "position"); +} + +/// #12033: the options bar asks the engine, so the button is greyed out for exactly the targets +/// `image.analysis.straightenLayer` is off for - the Background, two or more selected layers, a +/// group, Lock All and Lock Position - and live for a layer. +#[test] +fn the_straighten_layer_button_greys_out_where_the_command_is_off() { + use egui_kittest::kittest::{NodeT, Queryable}; + type Case = (&'static str, fn(&mut PhotocraftApp), bool); + let cases: [Case; 6] = [ + ("a pixel layer", on_a_layer, true), + ("the Background", on_the_background, false), + ("two selected layers", on_two_layers, false), + ("a group", on_a_group, false), + ("Lock All", on_lock_all, false), + ("Lock Position", on_lock_position, false), + ]; + for (what, prepare, live) in cases { + let (mut app, _) = app(); + prepare(&mut app); + rule(&mut app); + let h = ruler_bar(app); + assert_eq!(h.state().session.is_enabled("image.analysis.straightenLayer"), live, "{what}: the command's own check"); + let button = h.get_by_label("Straighten Layer").accesskit_node(); + assert_eq!(!button.is_disabled(), live, "{what}: the options-bar button follows the command"); + // The other Ruler button is unaffected: a ruler line is there to clear in every state. + assert!(!h.get_by_label("Clear").accesskit_node().is_disabled(), "{what}: Clear stays on"); + } +} + +/// A refused click says so. `PhotocraftApp::run` reports what the *command* answered in the status +/// line, but a refusal raised before it runs - the automation gate a control/MCP-driven click goes +/// through - returns early, so the button has to report it itself. As the toolbar flyout does +/// (`a_failed_edit_toolbar_from_the_flyout_reports_its_error_in_the_status_bar`). +#[test] +fn a_denied_synthetic_click_reports_the_refusal_instead_of_saying_nothing() { + use egui_kittest::kittest::{NodeT, Queryable}; + let (mut app, _) = app(); + on_a_layer(&mut app); + rule(&mut app); + app.automation_input = true; + app.services.automation_command = + Some(Box::new(|id, _| if id == "image.analysis.straightenLayer" { Err("straightening is denied".into()) } else { Ok(()) })); + let mut h = ruler_bar(app); + assert!(!h.get_by_label("Straighten Layer").accesskit_node().is_disabled(), "the layer is a valid target"); + let steps = h.state().session.active().unwrap().history.past_len(); + h.get_by_label("Straighten Layer").click(); + h.run_steps(2); + let st = h.state(); + assert_eq!(st.ui.status, "straightening is denied", "the refusal reaches the status line"); + assert!(st.ui.status_error, "as an error"); + assert!(alerts(st).is_empty(), "the alert stays the hidden layer's own"); + let now = st.session.active().unwrap(); + assert!(now.doc.measurement.ruler.is_some(), "nothing ran: the ruler line is still up"); + assert_eq!(now.history.past_len(), steps, "and nothing was recorded"); +} + +/// #12033: a hidden target layer is the one state that keeps its button. The click answers with the +/// reference's alert - its measured message and an OK button - instead of the status line, and the +/// document, its History and the ruler line stay as they were. +#[test] +fn a_hidden_layer_keeps_the_button_and_the_click_shows_the_alert() { + use egui_kittest::kittest::{NodeT, Queryable}; + let (mut app, _) = app(); + let l = app.run("layer.new.layer", json!({"name": "Gone"})).unwrap()["layer"].as_u64().unwrap(); + app.run("layer.setProps", json!({"layer": l, "visible": false})).unwrap(); + rule(&mut app); + let mut h = ruler_bar(app); + assert!(!h.get_by_label("Straighten Layer").accesskit_node().is_disabled(), "a hidden layer keeps the button on"); + let (before, steps) = (h.state().session.active().unwrap().doc.clone(), h.state().session.active().unwrap().history.past_len()); + h.get_by_label("Straighten Layer").click(); + h.run_steps(2); + let st = h.state(); + assert_eq!(alerts(st), [photocraft_engine::analysis_cmds::STRAIGHTEN_HIDDEN_ERROR], "one alert with the reference's text"); + let now = st.session.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, &now.doc), "the document was not touched"); + assert_eq!(now.history.past_len(), steps, "no History step"); + assert!(now.doc.measurement.ruler.is_some(), "the ruler line is still there"); +} diff --git a/crates/ui-egui/src/tools/bg_eraser_popup.rs b/crates/ui-egui/src/tools/bg_eraser_popup.rs new file mode 100644 index 00000000000..d59c55197e5 --- /dev/null +++ b/crates/ui-egui/src/tools/bg_eraser_popup.rs @@ -0,0 +1,122 @@ +//! The Background Eraser's compact brush popup (#11910): what its options-bar brush picker and a +//! canvas right-click open instead of the Brush Preset picker, as in Photoshop. No preset list and +//! no search field; top to bottom Size, Hardness and Spacing (fields with sliders), the +//! angle/roundness widget, Angle and Roundness fields, then the "Size:" and "Tolerance:" pen +//! dropdowns (Off · Pen Pressure · Stylus Wheel), the latter with the reference's tablet warning. +//! +//! It edits the caller's copy of the brush like the picker does (`paint_mouse` commits it through +//! `tools.setBrush`); the Tolerance control is a tool option (`ToolOptions::bg_tolerance_control`). + +use egui::{RichText, vec2}; +use photocraft_engine::BrushSettings; +use photocraft_engine::paint::{Control, MAX_BRUSH_SIZE, TipShape}; + +use crate::theme::Tokens; +use crate::widgets; + +/// The pen dropdowns' entries, as stored. +pub const OFF: &str = "off"; +pub const PEN_PRESSURE: &str = "penPressure"; +pub const STYLUS_WHEEL: &str = "stylusWheel"; +/// The tooltip of the warning triangle next to "Tolerance:". +pub const PRESSURE_WARNING: &str = "Control by pen pressure requires the use of a pressure sensitive tablet"; + +/// The popup's width. +pub const WIDTH: f32 = 240.0; + +fn pen_options() -> [(String, &'static str); 3] { + [(OFF.into(), tl!("Off")), (PEN_PRESSURE.into(), tl!("Pen Pressure")), (STYLUS_WHEEL.into(), tl!("Stylus Wheel"))] +} + +/// What the brush's "Size:" control is: Pen Pressure is the brush's pressure-for-size switch, +/// Stylus Wheel its Shape Dynamics size control. +pub fn size_control(b: &BrushSettings) -> &'static str { + if b.pressure_size { + PEN_PRESSURE + } else if b.shape_dynamics.enabled && b.shape_dynamics.size.control == Control::StylusWheel { + STYLUS_WHEEL + } else { + OFF + } +} + +/// Set the brush's "Size:" control. +pub fn set_size_control(b: &mut BrushSettings, control: &str) { + b.pressure_size = control == PEN_PRESSURE; + if control == STYLUS_WHEEL { + b.shape_dynamics.enabled = true; + b.shape_dynamics.size.control = Control::StylusWheel; + } else if b.shape_dynamics.size.control == Control::StylusWheel { + b.shape_dynamics.size.control = Control::Off; + } +} + +/// The popup's controls, editing `b` and the tool's Tolerance control. +pub fn body(ui: &mut egui::Ui, b: &mut BrushSettings, tolerance_control: &mut String) { + let t = Tokens::get(ui.ctx()); + ui.set_width(WIDTH); + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Size")).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut size = b.size; + if widgets::value_field(ui, &mut size, 1.0..=MAX_BRUSH_SIZE, "px", 72.0).accessible_name(tl!("Size")).changed() { + b.size = size.round().clamp(1.0, MAX_BRUSH_SIZE); + } + }); + }); + // Logarithmic, as the Brush Preset picker's: small sizes get most of the travel. + let mut lv = b.size.max(1.0).ln(); + if widgets::slider(ui, &mut lv, 0.0..=MAX_BRUSH_SIZE.ln(), None).changed() { + b.size = lv.exp().round().clamp(1.0, MAX_BRUSH_SIZE); + } + if b.tip == TipShape::Round { + let mut hard = b.hardness * 100.0; + if widgets::slider_row(ui, "Hardness", &mut hard, 0.0..=100.0, "%", None).changed() { + b.hardness = (hard / 100.0).clamp(0.0, 1.0); + } + } + let mut spacing = b.spacing * 100.0; + if widgets::slider_row(ui, "Spacing", &mut spacing, 1.0..=1000.0, "%", None).changed() { + b.spacing = (spacing / 100.0).clamp(0.01, 10.0); + } + ui.add_space(4.0); + ui.horizontal(|ui| { + crate::brush_sections::ellipse_widget(ui, &mut b.angle, &mut b.roundness); + ui.vertical(|ui| { + let mut angle = b.angle; + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Angle:")).color(t.text_dim)); + if widgets::value_field(ui, &mut angle, -180.0..=180.0, "°", 64.0).accessible_name(tl!("Angle")).changed() && angle.is_finite() { + b.angle = angle.round().clamp(-180.0, 180.0); + } + }); + let mut round = b.roundness * 100.0; + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Roundness:")).color(t.text_dim)); + if widgets::value_field(ui, &mut round, 0.0..=100.0, "%", 64.0).accessible_name(tl!("Roundness")).changed() && round.is_finite() { + b.roundness = (round / 100.0).clamp(0.0, 1.0); + } + }); + }); + }); + ui.add_space(4.0); + let options = pen_options(); + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Size:")).color(t.text_dim)); + let mut size = size_control(b).to_string(); + if widgets::dropdown(ui, "bg-eraser-size-control", tl!("Size:"), &mut size, &options, 120.0) { + set_size_control(b, &size); + } + }); + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Tolerance:")).color(t.text_dim)); + widgets::dropdown(ui, "bg-eraser-tolerance-control", tl!("Tolerance:"), tolerance_control, &options, 120.0); + let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), egui::Sense::hover()); + crate::icons::paint(ui, r, "triangle-alert", 14.0, t.warning); + resp.on_hover_text(tl!(PRESSURE_WARNING)).widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tl!(PRESSURE_WARNING))); + }); +} + +#[cfg(test)] +#[path = "../background_eraser_brush_popup_tests.rs"] +mod background_eraser_brush_popup_tests; diff --git a/crates/ui-egui/src/tools/brush_picker.rs b/crates/ui-egui/src/tools/brush_picker.rs index ff5a44e062d..298a300a835 100644 --- a/crates/ui-egui/src/tools/brush_picker.rs +++ b/crates/ui-egui/src/tools/brush_picker.rs @@ -91,31 +91,44 @@ pub(crate) fn opened_id() -> egui::Id { picker_id().with("opened-with") } -/// What Escape in the picker goes back to (#11816): the Size and Hardness it opened with (or a -/// picked preset brought), and the values its own fields last typed. Escape reverts a value only -/// while it is still the typed one, so a change from elsewhere (`]`, the options bar) stays. +/// What Escape in the picker goes back to (#11816): the Size, Hardness, Angle and Roundness it +/// opened with (or a picked preset brought), and the values its own controls last set. Escape +/// reverts a value only while it is still the picker's, so a change from elsewhere (`]`, the +/// options bar) stays. Angle and Roundness come from the tip widget (#11821). #[derive(Clone, Debug, Default, PartialEq)] pub(crate) struct Opened { - opened: [f32; 2], - typed: [f32; 2], + opened: [f32; 4], + typed: [f32; 4], +} + +fn picker_values(b: &photocraft_engine::BrushSettings) -> [f32; 4] { + [b.size, b.hardness, b.angle, b.roundness] } impl Opened { pub(crate) fn new(b: &photocraft_engine::BrushSettings) -> Self { - Self { opened: [b.size, b.hardness], typed: [b.size, b.hardness] } + Self { opened: picker_values(b), typed: picker_values(b) } } pub(crate) fn typed(&mut self, b: &photocraft_engine::BrushSettings) { - self.typed = [b.size, b.hardness]; + self.typed = picker_values(b); } pub(crate) fn revert(&self, now: &photocraft_engine::BrushSettings) -> photocraft_engine::BrushSettings { let mut b = now.clone(); - if b.size == self.typed[0] { - b.size = self.opened[0]; + let [size, hardness, angle, roundness] = self.opened; + let [typed_size, typed_hardness, typed_angle, typed_roundness] = self.typed; + if b.size == typed_size { + b.size = size; } - if b.hardness == self.typed[1] { - b.hardness = self.opened[1]; + if b.hardness == typed_hardness { + b.hardness = hardness; + } + if b.angle == typed_angle { + b.angle = angle; + } + if b.roundness == typed_roundness { + b.roundness = roundness; } b } @@ -267,35 +280,45 @@ pub fn body( ui.set_width(size.x); ui.set_min_height(size.y); let top = ui.cursor().min.y; - // The Size and Hardness controls live in one block flush with the left edge, at most - // [`HEAD_MAX_W`] wide: a picker dragged wider leaves the space past the block empty instead of - // stretching the rows across it. - ui.scope(|ui| { - ui.set_max_width(ui.available_width().min(HEAD_MAX_W)); - // Size: value field plus a logarithmic slider (small sizes get most of the travel). - ui.horizontal(|ui| { - ui.label(RichText::new(tl!("Size")).color(t.text_dim)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mut size = b.size; - if widgets::value_field(ui, &mut size, 1.0..=MAX_BRUSH_SIZE, "px", 72.0).accessible_name(tl!("Size")).changed() { - b.size = size.round().clamp(1.0, MAX_BRUSH_SIZE); - } + // The tip's angle/roundness widget at the top left (#11821), then the Size and Hardness + // controls in one block, at most [`HEAD_MAX_W`] wide: a picker dragged wider leaves the space + // past the block empty instead of stretching the rows across it. + ui.horizontal_top(|ui| { + let (angle, roundness) = (b.angle, b.roundness); + crate::brush_sections::ellipse_widget(ui, &mut b.angle, &mut b.roundness); + // A pointer position that isn't finite leaves the tip as it was. + let finite = |p: egui::Pos2| p.x.is_finite() && p.y.is_finite(); + let pointer_ok = ui.input(|i| i.pointer.interact_pos().is_none_or(finite) && i.pointer.latest_pos().is_none_or(finite)); + if !(pointer_ok && b.angle.is_finite() && b.roundness.is_finite()) { + (b.angle, b.roundness) = (angle, roundness); + } + ui.vertical(|ui| { + ui.set_max_width(ui.available_width().min(HEAD_MAX_W)); + // Size: value field plus a logarithmic slider (small sizes get most of the travel). + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Size")).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut size = b.size; + if widgets::value_field(ui, &mut size, 1.0..=MAX_BRUSH_SIZE, "px", 72.0).accessible_name(tl!("Size")).changed() { + b.size = size.round().clamp(1.0, MAX_BRUSH_SIZE); + } + }); }); - }); - let mut lv = b.size.max(1.0).ln(); - let size_slider = widgets::slider(ui, &mut lv, 0.0..=MAX_BRUSH_SIZE.ln(), None); - if size_slider.changed() { - b.size = lv.exp().round().clamp(1.0, MAX_BRUSH_SIZE); - } - dismiss_popup(&size_slider); - if b.tip == TipShape::Round { - let mut hard = b.hardness * 100.0; - let hardness = widgets::slider_row(ui, "Hardness", &mut hard, 0.0..=100.0, "%", None); - if hardness.changed() { - b.hardness = (hard / 100.0).clamp(0.0, 1.0); + let mut lv = b.size.max(1.0).ln(); + let size_slider = widgets::slider(ui, &mut lv, 0.0..=MAX_BRUSH_SIZE.ln(), None); + if size_slider.changed() { + b.size = lv.exp().round().clamp(1.0, MAX_BRUSH_SIZE); } - dismiss_popup(&hardness); - } + dismiss_popup(&size_slider); + if b.tip == TipShape::Round { + let mut hard = b.hardness * 100.0; + let hardness = widgets::slider_row(ui, "Hardness", &mut hard, 0.0..=100.0, "%", None); + if hardness.changed() { + b.hardness = (hard / 100.0).clamp(0.0, 1.0); + } + dismiss_popup(&hardness); + } + }); }); ui.add_space(6.0); widgets::hairline(ui); @@ -567,3 +590,7 @@ fn gear_menu(ui: &mut egui::Ui, current: Option<&str>, presets: &[BrushPreset], #[cfg(test)] #[path = "../brush_picker_gear_menu_tests.rs"] mod gear_menu_tests; + +#[cfg(test)] +#[path = "../brush_picker_angle_widget_tests.rs"] +mod brush_picker_angle_widget_tests; diff --git a/crates/ui-egui/src/tools/brush_refusal.rs b/crates/ui-egui/src/tools/brush_refusal.rs new file mode 100644 index 00000000000..c1919c86398 --- /dev/null +++ b/crates/ui-egui/src/tools/brush_refusal.rs @@ -0,0 +1,55 @@ +//! Photoshop's alerts when a painting tool can't paint (#11898): a press on a hidden target +//! layer, on a layer with Lock All, or with a group active (its pixels, not its mask) opens a +//! modal alert with OK instead of a status-bar message, and paints nothing. Only the tools whose +//! wording was measured have a name here ([`tool_name`]); the others keep today's status bar. + +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::{DialogKind, Tool}; + +/// The alerts; `{tool}` is [`tool_name`]. +pub const HIDDEN: &str = "Could not use the {tool} because the target layer is hidden."; +pub const LOCKED: &str = "Could not use the {tool} because the layer is locked."; +pub const GROUP: &str = "Could not use the {tool} because the pixels in a group cannot be edited without merging the group."; + +/// The tool's name in the alerts, for the tools whose wording was measured: the Brush. +pub fn tool_name(tool: Tool) -> Option<&'static str> { + match tool { + Tool::Brush => Some(tl!("brush tool")), + _ => None, + } +} + +/// The alert a press with `tool` gets, if any. `hidden` is the press-time hidden-target refusal +/// (`canvas::tool_input`), which this only words; the lock and the group are checked here. Lock +/// transparent pixels is no refusal, and a group's targeted mask paints. +pub fn alert(app: &PhotocraftApp, tool: Tool, hidden: bool) -> Option { + let name = tool_name(tool)?; + let template = if hidden { + HIDDEN + } else { + let st = app.session.active()?; + let layer = st.active_layer.and_then(|id| st.doc.layer(id))?; + if crate::canvas::paint_target(app) != json!("pixels") { + return None; + } + if layer.is_group() { + GROUP + } else if st.doc.effective_locks(layer.id).all { + LOCKED + } else { + return None; + } + }; + Some(crate::i18n::fmt(tl!(template), &[("tool", name)])) +} + +/// Opens the alert: ordinary dialog state, listed by `ui.inspect` and closed by `ui.dialog.confirm`. +pub fn prompt(app: &mut PhotocraftApp, message: &str) { + app.ui.open_dialog(DialogKind::Error, json!({ "message": message }).as_object().cloned().unwrap_or_default()); +} + +#[cfg(test)] +#[path = "../brush_refusal_alert_tests.rs"] +mod brush_refusal_alert_tests; diff --git a/crates/ui-egui/src/tools/corner_radius.rs b/crates/ui-egui/src/tools/corner_radius.rs index 6358ffd1394..f5ec3192e9e 100644 --- a/crates/ui-egui/src/tools/corner_radius.rs +++ b/crates/ui-egui/src/tools/corner_radius.rs @@ -150,7 +150,7 @@ pub struct Drag { fn shows(app: &PhotocraftApp) -> bool { app.ui.transform.is_none() && match app.ui.tool { - Tool::PathSelection | Tool::Rectangle | Tool::Triangle | Tool::Polygon => true, + Tool::PathSelection | Tool::Rectangle | Tool::Triangle | Tool::Polygon | Tool::Star => true, Tool::Move => app.ui.tool_options.move_show_transform, _ => false, } diff --git a/crates/ui-egui/src/tools/count_show_extras_tests.rs b/crates/ui-egui/src/tools/count_show_extras_tests.rs new file mode 100644 index 00000000000..773ce74546d --- /dev/null +++ b/crates/ui-egui/src/tools/count_show_extras_tests.rs @@ -0,0 +1,70 @@ +//! #11770: adding a count marker while View > Show > Count is off switches it back on. + +use egui::Modifiers; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.ui.tool = Tool::Count; + app +} + +fn click(app: &mut PhotocraftApp, x: f64, y: f64, m: Modifiers) { + tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x, y }, m); +} + +fn points(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().doc.measurement.count_groups.iter().map(|g| g.points.len()).sum() +} + +fn past(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +#[test] +fn a_click_with_show_count_off_adds_the_marker_and_turns_it_on() { + let mut app = app(); + app.ui.view.show.count = false; + let before = past(&app); + click(&mut app, 40.0, 30.0, Modifiers::NONE); + assert_eq!(points(&app), 1); + assert!(app.ui.view.show.count, "the new marker is visible"); + assert_eq!(past(&app), before + 1, "only the marker's own history state"); +} + +#[test] +fn a_click_with_show_count_on_behaves_as_before() { + let mut app = app(); + app.ui.view.show.count = true; + click(&mut app, 40.0, 30.0, Modifiers::NONE); + assert_eq!(points(&app), 1); + assert!(app.ui.view.show.count); +} + +#[test] +fn a_click_into_a_hidden_group_leaves_the_group_hidden() { + let mut app = app(); + click(&mut app, 10.0, 10.0, Modifiers::NONE); + app.run("count.setGroup", json!({"group": 0, "visible": false})).unwrap(); + app.ui.view.show.count = false; + click(&mut app, 90.0, 60.0, Modifiers::NONE); + assert_eq!(points(&app), 2); + assert!(app.ui.view.show.count); + assert!(!app.session.active().unwrap().doc.measurement.count_groups[0].visible); +} + +#[test] +fn removing_a_marker_does_not_turn_show_count_on() { + let mut app = app(); + click(&mut app, 10.0, 10.0, Modifiers::NONE); + app.ui.view.show.count = false; + click(&mut app, 10.0, 10.0, Modifiers::ALT); + assert_eq!(points(&app), 0); + assert!(!app.ui.view.show.count); +} diff --git a/crates/ui-egui/src/tools/crop_shield.rs b/crates/ui-egui/src/tools/crop_shield.rs index d652ebf9d4e..f09f52f29b7 100644 --- a/crates/ui-egui/src/tools/crop_shield.rs +++ b/crates/ui-egui/src/tools/crop_shield.rs @@ -130,19 +130,19 @@ pub fn hides_outside(app: &PhotocraftApp) -> bool { /// 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, /// also over the untouched default frame the tool shows when picked or after a commit (#6536, -/// #9731). +/// #9731). ⇧H and ⇧P are always the tool keys. pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if app.ui.tool != Tool::Crop || app.ui.crop_rect.is_none() || app.crop.default_frame || app.crop.drag.is_some() { return false; } - if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::H)) { + if ctx.input_mut(|i| consume_unshifted(i, Key::H)) { let s = &mut app.ui.tool_options.crop_shield; 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)) { + if ctx.input_mut(|i| consume_unshifted(i, 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, @@ -153,6 +153,16 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { false } +/// `consume_key(Modifiers::NONE, key)` without the presses that hold ⇧ or ⌥: egui ignores an +/// extra ⇧ and ⌥ there, but ⇧+letter is the tool key and Alt+letter opens the menu with that +/// mnemonic (Alt+H Help, Alt+I Image). Reads each event's own modifiers, which synthetic +/// (control channel) keys carry without changing the frame's. +pub(crate) fn consume_unshifted(i: &mut egui::InputState, key: Key) -> bool { + let before = i.events.len(); + i.events.retain(|e| !matches!(e, egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && !modifiers.shift && !modifiers.alt && modifiers.matches_logically(Modifiers::NONE))); + i.events.len() != before +} + /// The options bar's gear button and its menu. Returns true when Custom… asks for the Color /// Picker ([`pick_custom_color`], which needs the app). pub fn options_button(s: &mut CropShield, ui: &mut egui::Ui) -> bool { @@ -387,6 +397,69 @@ mod tests { assert!(app.ui.tool_options.crop_shield.show_cropped_area); } + /// A crop in progress: a frame changed from the default one, so [`keys`] gets past its + /// early returns (#9731). + fn crop_in_progress_app() -> PhotocraftApp { + let mut app = crop_app(); + app.ui.crop_rect = Some([20.0, 10.0, 120.0, 90.0]); + app.crop.default_frame = false; + app + } + + /// ⇧H and ⇧P are the Hand and Pen tool keys with a crop in progress, where plain H and P are + /// the crop box's: egui's `consume_key` ignores an extra ⇧, so the plain-key checks must refuse + /// it. Over the untouched default frame they are the tool keys too. + #[test] + fn shift_h_and_shift_p_switch_tools_instead_of_crop_options() { + for in_progress in [true, false] { + for (key, tool) in [(Key::H, Tool::Hand), (Key::P, Tool::Pen)] { + let mut app = if in_progress { crop_in_progress_app() } else { crop_app() }; + assert_eq!(app.crop.default_frame, !in_progress); + assert!(app.session.prefs().tools.use_shift_key_for_tool_switch); + let before = app.ui.tool_options.crop_shield.clone(); + let ctx = egui::Context::default(); + ctx.input_mut(|i| { + // Only the event holds ⇧, as with a control-channel `ui.key` (the frame's state lags). + i.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); + }); + crate::shortcuts::handle(&mut app, &ctx); + assert_eq!(app.ui.tool, tool, "{key:?}, crop in progress: {in_progress}"); + assert_eq!(app.ui.tool_options.crop_shield, before, "{key:?} leaves Show Cropped Area and Classic Mode alone"); + } + } + } + + /// The same plain H and P with a crop in progress are still the crop box's keys, so the test + /// above really reaches the key checks. + #[test] + fn plain_p_toggles_classic_mode_while_a_crop_is_in_progress() { + let mut app = crop_in_progress_app(); + let classic = app.ui.tool_options.crop_shield.classic_mode; + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.events.push(egui::Event::Key { key: Key::P, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE })); + crate::shortcuts::handle(&mut app, &ctx); + assert_eq!(app.ui.tool, Tool::Crop, "P doesn't switch to the Pen tool"); + assert_eq!(app.ui.tool_options.crop_shield.classic_mode, !classic); + } + + /// Alt+H, Alt+P and Alt+I are not the crop box's H, P and I (Show Cropped Area, Classic Mode, + /// Front Image): egui's `consume_key` ignores an extra ⌥, so the plain-key checks must refuse + /// it and leave the press for later handlers. + #[test] + fn alt_letters_are_not_the_crop_box_keys() { + for key in [Key::H, Key::P, Key::I] { + let mut app = crop_in_progress_app(); + let (shield, ratio) = (app.ui.tool_options.crop_shield.clone(), app.ui.tool_options.crop_ratio.clone()); + let ctx = egui::Context::default(); + let alt = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::ALT }; + ctx.input_mut(|i| i.events.push(alt.clone())); + assert!(!keys(&mut app, &ctx) && !crate::crop_size::keys(&mut app, &ctx), "{key:?}"); + assert_eq!(app.ui.tool_options.crop_shield, shield, "{key:?}"); + assert_eq!(app.ui.tool_options.crop_ratio, ratio, "{key:?}"); + assert!(ctx.input(|i| i.events.contains(&alt)), "{key:?} is left for later handlers"); + } + } + #[test] fn the_color_picker_sets_the_custom_colour() { let mut app = crop_app(); diff --git a/crates/ui-egui/src/tools/crop_size.rs b/crates/ui-egui/src/tools/crop_size.rs index 3709ec67679..68b07141908 100644 --- a/crates/ui-egui/src/tools/crop_size.rs +++ b/crates/ui-egui/src/tools/crop_size.rs @@ -15,7 +15,7 @@ //! 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 egui::{Stroke, StrokeKind, vec2}; use photocraft_engine::prefs::Unit; use serde_json::{Value, json}; @@ -271,12 +271,12 @@ pub fn chosen(app: &mut PhotocraftApp) { } /// 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. +/// true when the key was used; otherwise I (and always ⇧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)) { + if ctx.input_mut(|i| crate::crop_shield::consume_unshifted(i, egui::Key::I)) { front_image(app); crate::crop_ui::apply_ratio(app); return true; @@ -341,6 +341,7 @@ pub fn fields(o: &mut ToolOptions, ui: &mut egui::Ui) -> bool { #[cfg(test)] mod tests { use super::*; + use egui::Modifiers; use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; #[test] @@ -536,6 +537,32 @@ mod tests { assert!(!keys(&mut app2, &ctx)); } + /// ⇧I is the Eyedropper group's key even with the Crop tool, not Front Image: with no frame + /// yet, over the untouched default frame and with a crop in progress. + #[test] + fn shift_i_with_the_crop_tool_switches_to_the_eyedropper() { + for frame in ["none", "default", "in progress"] { + let mut app = app(SampleType::U8); + if frame != "none" { + crate::crop_ui::ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame, "{frame}"); + } + if frame == "in progress" { + app.ui.crop_rect = Some([20.0, 10.0, 120.0, 90.0]); + app.crop.default_frame = false; + } + let ratio = app.ui.tool_options.crop_ratio.clone(); + let ctx = egui::Context::default(); + ctx.input_mut(|i| { + // Only the event holds ⇧, as with a control-channel `ui.key` (the frame's state lags). + i.events.push(egui::Event::Key { key: egui::Key::I, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); + }); + crate::shortcuts::handle(&mut app, &ctx); + assert_eq!(app.ui.tool, Tool::Eyedropper, "{frame}"); + assert_eq!(app.ui.tool_options.crop_ratio, ratio, "{frame}"); + } + } + /// 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] diff --git a/crates/ui-egui/src/tools/eraser_ui.rs b/crates/ui-egui/src/tools/eraser_ui.rs index ac896eeb0b0..3c5b997cb3d 100644 --- a/crates/ui-egui/src/tools/eraser_ui.rs +++ b/crates/ui-egui/src/tools/eraser_ui.rs @@ -53,7 +53,7 @@ pub(crate) fn background_eraser_params(app: &PhotocraftApp, points: &[[f64; 3]]) "points": points, "sampling": o.bg_sampling, "limits": o.bg_limits, - "tolerance": o.bg_tolerance, + "tolerance": stored_tolerance(o.bg_tolerance), "protectForegroundColor": o.bg_protect_fg, "seed": crate::retouch_ui::stroke_seed(points), }) @@ -125,11 +125,79 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo ("findEdges".to_string(), tl!("Find Edges")), ]; widgets::dropdown(ui, "bg-eraser-limits", tl!("Limits:"), &mut o.bg_limits, &limits, 110.0); + // The dropdown's button, by the id its combo box takes from "bg-eraser-limits". + if let Some(limits) = ui.ctx().read_response(ui.make_persistent_id(egui::IdSalt::new("bg-eraser-limits"))) { + limits.on_hover_text(tl!("How far to let the erasing spread")); + } opt(ui, tl!("Tolerance:")); - widgets::value_field(ui, &mut o.bg_tolerance, 0.0..=100.0, "%", 62.0); - widgets::checkbox(ui, &mut o.bg_protect_fg, tl!("Protect Foreground Color")); + if tolerance_field(ui, &mut o.bg_tolerance) { + open_range_alert(app); + } + let o = &mut app.ui.tool_options; + widgets::checkbox(ui, &mut o.bg_protect_fg, tl!("Protect Foreground Color")).on_hover_text(tl!("Do not erase the foreground swatch color")); } _ => return false, } true } + +/// The alert for a Background Eraser Tolerance typed out of range (#11909). +pub const TOLERANCE_RANGE_ALERT: &str = "An integer between 1% and 100% is required. Closest value inserted."; + +/// The Background Eraser's Tolerance: whole numbers in 1..=100, as the reference application takes +/// them (#11909). A typed fraction becomes the nearest whole number, a value past the range the +/// closest one. Returns true when the field is left with a value typed out of range, so the +/// caller shows [`TOLERANCE_RANGE_ALERT`]. +fn tolerance_field(ui: &mut egui::Ui, value: &mut f32) -> bool { + // A restored setting (an older 0, a fraction) shows and erases as the field would store it. + *value = stored_tolerance(*value); + // While the field has the keyboard it gets back what was typed, not the stored value, so + // its text is left alone mid-entry (`0` on the way to `05` must not turn into `1`). + let draft_id = ui.make_persistent_id(egui::IdSalt::new("bg-eraser-tolerance-typed")); + // The field's own range is wide, so the typed value reaches here unclamped. + // A draft only stands while the setting is still the one it stored: a value set elsewhere + // meanwhile (a tool preset while the bar was hidden) wins. + let draft = ui.ctx().data(|d| d.get_temp::<(f32, f32)>(draft_id)); + let mut typed = draft.filter(|(_, stored)| *stored == *value).map_or(*value, |(typed, _)| typed); + let r = widgets::value_field(ui, &mut typed, -1.0e9..=1.0e9, "%", 62.0).on_hover_text(tl!("Erase range of similar colors")); + let out_id = r.id.with("out-of-range"); + // An edit the field never got to finish (the options bar was hidden while it had the + // keyboard) has nothing left to say once the field is entered again. + if r.gained_focus() { + ui.ctx().data_mut(|d| d.remove_temp::(out_id)); + } + if r.changed() && typed.is_finite() { + *value = typed.round().clamp(1.0, 100.0); + // Only what was typed alerts, also an expression committed as the field is left + // (`50*4` and Enter); a scrub past the ends (the field's or its caption's, even while + // the field has the keyboard) just stops there. + let typing = (r.has_focus() || r.lost_focus()) && ui.ctx().dragged_id().is_none(); + let out = typing && !(1.0..=100.0).contains(&typed); + ui.ctx().data_mut(|d| d.insert_temp(out_id, out)); + if typing && r.has_focus() { + ui.ctx().data_mut(|d| d.insert_temp(draft_id, (typed, *value))); + } else { + ui.ctx().data_mut(|d| d.remove_temp::<(f32, f32)>(draft_id)); + } + } + if !r.has_focus() { + ui.ctx().data_mut(|d| d.remove_temp::<(f32, f32)>(draft_id)); + } + r.lost_focus() && ui.ctx().data_mut(|d| d.remove_temp::(out_id)).unwrap_or(false) +} + +/// A Tolerance as the field stores it: a whole number in 1..=100, the default 50 for a value +/// that is no number. Strokes use it too, so a restored setting erases the same before and after +/// the options bar shows it. +fn stored_tolerance(v: f32) -> f32 { + if v.is_finite() { v.round().clamp(1.0, 100.0) } else { 50.0 } +} + +/// Shows [`TOLERANCE_RANGE_ALERT`]: a modal with OK, once even if more than one pass asks. +fn open_range_alert(app: &mut PhotocraftApp) { + let message = tl!(TOLERANCE_RANGE_ALERT).to_owned(); + let shown = app.ui.dialogs.iter().any(|d| d.kind == crate::state::DialogKind::Error && d.fields.get("message").and_then(|m| m.as_str()) == Some(&message)); + if !shown { + app.ui.open_dialog(crate::state::DialogKind::Error, json!({ "message": message }).as_object().cloned().unwrap_or_default()); + } +} diff --git a/crates/ui-egui/src/tools/gradient_ui.rs b/crates/ui-egui/src/tools/gradient_ui.rs index c3d64441c9b..34373d66b6c 100644 --- a/crates/ui-egui/src/tools/gradient_ui.rs +++ b/crates/ui-egui/src/tools/gradient_ui.rs @@ -890,7 +890,10 @@ fn selection_after_edit(f: &Fill, p: &Value, previous: Option) -> Option } fn stop_strip(ui: &mut egui::Ui, key: egui::Id, f: &Fill, midpoints: bool, pending: Option<&Value>, colors: ([f32; 4], [f32; 4])) -> Option { - let Fill::Gradient { stops, .. } = f else { return None }; + // The stops as committed: a drag edit (and the two-stop minimum) addresses these, not the + // pending preview, which may already leave the dragged stop out. + let Fill::Gradient { stops, opacity_stops, .. } = f else { return None }; + let opacity_count = if opacity_stops.is_empty() { 2 } else { opacity_stops.len() }; let t = Tokens::get(ui.ctx()); let w = ui.available_width().max(120.0); let (outer, resp) = ui.allocate_exact_size(vec2(w, 58.0), Sense::click_and_drag()); @@ -995,19 +998,22 @@ fn stop_strip(ui: &mut egui::Ui, key: egui::Id, f: &Fill, midpoints: bool, pendi let dragging: Option = ui.data(|d| d.get_temp(key)); let mut preview = None; if let (Some(m), Some(pos)) = (dragging, resp.interact_pointer_pos()) { - let off = if off_strip(pos, bar) { 1.0 } else { 0.0 }; + // Pulled off the bar, up or down, the stop is deleted (the preview shows it gone; + // dragging back cancels), while at least two of its kind remain. + let off = off_strip(pos, bar); let loc = loc_of(pos.x); let edit = match m { - Marker::Color(i) if off > 0.0 && stops.len() > 2 => json!({"action": "delete", "index": i}), + Marker::Color(i) if off && stops.len() > 2 => json!({"action": "delete", "index": i}), Marker::Color(i) => json!({"action": "move", "index": i, "location": loc}), - Marker::Opacity(i) if off > 0.0 && ops.len() > 2 => json!({"action": "delete", "kind": "opacity", "index": i}), + Marker::Opacity(i) if off && opacity_count > 2 => json!({"action": "delete", "kind": "opacity", "index": i}), Marker::Opacity(i) => json!({"action": "move", "kind": "opacity", "index": i, "location": loc}), Marker::Mid(i) => { let (a, b) = (sorted.get(i).map_or(0.0, |s| s.0), sorted.get(i + 1).map_or(1.0, |s| s.0)); json!({"action": "midpoint", "index": i, "location": ((loc - a) / (b - a).max(1e-4)).clamp(0.05, 0.95)}) } }; - if resp.dragged() { + // The release frame counts too: its own last move decides whether the stop comes off. + if resp.dragged() || resp.drag_stopped() { preview = Some(edit); } } diff --git a/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs b/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs index 8bc5019ddf0..0882a50ed27 100644 --- a/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs +++ b/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs @@ -144,3 +144,190 @@ fn preset_editor_opacity_fields_do_not_edit_document_history() { assert_eq!(h.state().session.active().unwrap().doc, before); assert_eq!(h.state().session.active().unwrap().history.past_len(), history); } + +/// Press at `points[0]`, move through the rest, then release at the last one; `during` runs while +/// the button is still down at the last point. +fn drag_through(h: &mut Harness, points: &[Pos2], during: impl FnOnce(&mut Harness)) { + let (Some(&from), Some(&to)) = (points.first(), points.last()) else { return }; + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + for &p in &points[1..] { + // Two small steps per leg, as a real pointer reports. + let last = h.ctx.input(|i| i.pointer.latest_pos()).unwrap_or(p); + h.event(egui::Event::PointerMoved(last + (p - last) * 0.5)); + h.run_steps(1); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + } + during(h); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); +} + +/// A stop marker's centre on the strip: colour stops under the bar, opacity stops above it. +fn marker(h: &Harness, location: f32, opacity: bool) -> Pos2 { + let rect = h.get_by_label("Gradient stops").rect(); + pos2(rect.left() + 8.0 + location * (rect.width() - 16.0), rect.top() + if opacity { 6.0 } else { 50.0 }) +} + +fn counts(h: &Harness, sink: Sink) -> (usize, usize) { + let Fill::Gradient { stops, opacity_stops, .. } = sink.fill(h.state()).unwrap() else { panic!() }; + (stops.len(), opacity_stops.len()) +} + +/// The strip as drawn while the pointer is down: the pending edit applied to the gradient. +fn shown(h: &Harness, sink: Sink) -> (usize, usize) { + let f = sink.fill(h.state()).unwrap(); + let pending = match sink { + Sink::Layer(_) => h.state().gradient.panel.as_ref().map(|(.., p)| p.clone()), + Sink::Preset => h.state().gradient.preset_stop.clone(), + }; + let (fg, bg) = (h.state().session.tools.foreground, h.state().session.tools.background); + let f = pending.and_then(|p| cmds::apply_stop(&f, &p, fg, bg).ok()).unwrap_or(f); + let Fill::Gradient { stops, opacity_stops, .. } = f else { panic!() }; + (stops.len(), opacity_stops.len()) +} + +/// #10742: a colour stop dragged down off the bar (by about a marker's height) is removed on +/// release; while it is off the bar the strip already shows it gone; one undo step restores it. +#[test] +fn a_color_stop_dragged_down_off_the_bar_is_removed() { + for layer in [true, false] { + let (mut h, sink) = editor(layer); + let history = h.state().session.active().unwrap().history.past_len(); + let before = sink.fill(h.state()).unwrap(); + let from = marker(&h, 0.5, false); + drag_through(&mut h, &[from, from + vec2(0.0, 22.0)], |h| { + assert_eq!(shown(h, sink), (2, 3), "layer {layer}: the dragged-off stop is gone from the preview"); + assert_eq!(sink.fill(h.state()).unwrap(), before, "layer {layer}: nothing is committed before release"); + }); + let Fill::Gradient { stops, .. } = sink.fill(h.state()).unwrap() else { panic!() }; + assert_eq!(stops.iter().map(|s| s.0).collect::>(), [0.0, 1.0], "layer {layer}: the middle stop is removed"); + assert_eq!(counts(&h, sink), (2, 3), "layer {layer}: opacity stops are untouched"); + if layer { + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1, "one undo step"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(sink.fill(h.state()).unwrap(), before); + } + } +} + +/// Dragging a stop off the bar and back onto it before releasing cancels the removal. +#[test] +fn dragging_a_stop_back_onto_the_bar_cancels_the_removal() { + for layer in [true, false] { + let (mut h, sink) = editor(layer); + for opacity in [false, true] { + let from = marker(&h, 0.5, opacity); + let away = from + vec2(0.0, if opacity { -30.0 } else { 30.0 }); + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(from + (away - from) * 0.5)); + h.run_steps(1); + h.event(egui::Event::PointerMoved(away)); + h.run_steps(1); + assert_eq!(shown(&h, sink), if opacity { (3, 2) } else { (2, 3) }, "layer {layer}: off the bar the stop is gone"); + // Back on the bar before release: the stop returns and stays where it was. + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + assert_eq!(shown(&h, sink), (3, 3), "layer {layer}: back on the bar, the stop is shown"); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + let Fill::Gradient { stops, opacity_stops, .. } = sink.fill(h.state()).unwrap() else { panic!() }; + let locations: Vec = if opacity { opacity_stops.iter().map(|s| s.0).collect() } else { stops.iter().map(|s| s.0).collect() }; + assert_eq!((stops.len(), opacity_stops.len()), (3, 3), "layer {layer} opacity {opacity}: nothing removed"); + assert!((locations[1] - 0.5).abs() < 1e-3, "layer {layer} opacity {opacity}: {locations:?}"); + } + } +} + +/// The last two colour stops stay: dragging one off the bar leaves it where it is. +#[test] +fn the_last_two_color_stops_cannot_be_dragged_off() { + let (mut h, sink) = editor(true); + let from = marker(&h, 0.5, false); + drag_through(&mut h, &[from, from + vec2(0.0, 60.0)], |_| {}); + assert_eq!(counts(&h, sink), (2, 3)); + let after = sink.fill(h.state()).unwrap(); + for location in [0.0, 1.0] { + let from = marker(&h, location, false); + drag_through(&mut h, &[from, from + vec2(0.0, 60.0)], |h| assert_eq!(shown(h, sink).0, 2, "the preview keeps two stops")); + assert_eq!(sink.fill(h.state()).unwrap(), after, "two colour stops always remain"); + } +} + +/// An opacity stop dragged up off the bar is removed while two remain. +#[test] +fn an_opacity_stop_dragged_up_off_the_bar_is_removed() { + for layer in [true, false] { + let (mut h, sink) = editor(layer); + let from = marker(&h, 0.5, true); + drag_through(&mut h, &[from, from - vec2(0.0, 30.0)], |h| assert_eq!(shown(h, sink), (3, 2), "layer {layer}")); + let Fill::Gradient { opacity_stops, stops, .. } = sink.fill(h.state()).unwrap() else { panic!() }; + assert_eq!(opacity_stops.iter().map(|s| s.0).collect::>(), [0.0, 1.0], "layer {layer}"); + assert_eq!(stops.len(), 3, "layer {layer}: colour stops are untouched"); + // Two opacity stops remain: the next one dragged up stays. + let before = sink.fill(h.state()).unwrap(); + let from = marker(&h, 0.0, true); + drag_through(&mut h, &[from, from - vec2(0.0, 30.0)], |_| {}); + assert_eq!(sink.fill(h.state()).unwrap(), before, "layer {layer}: two opacity stops always remain"); + } +} + +/// Either way off the bar takes a stop off, as in the reference application: an opacity stop +/// dragged down past the colour stops and a colour stop dragged up past the opacity stops are +/// removed too. +#[test] +fn a_stop_dragged_the_other_way_comes_off_too() { + for layer in [true, false] { + for opacity in [true, false] { + let (mut h, sink) = editor(layer); + let from = marker(&h, 0.5, opacity); + let to = from + vec2(0.0, if opacity { 70.0 } else { -70.0 }); + let gone = if opacity { (3, 2) } else { (2, 3) }; + drag_through(&mut h, &[from, to], |h| assert_eq!(shown(h, sink), gone, "layer {layer} opacity {opacity}: gone from the preview")); + assert_eq!(counts(&h, sink), gone, "layer {layer} opacity {opacity}: the stop is removed"); + } + } +} + +/// Press on a stop, drag a little along the bar, then move to each of `then` in its own frame; +/// the last move and the release arrive in the same frame, as a quick flick does. +fn flick(h: &mut Harness, from: Pos2, then: &[Pos2]) { + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(from + vec2(10.0, 0.0))); + h.run_steps(1); + let Some((&last, moves)) = then.split_last() else { return }; + for &p in moves { + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + } + // `Harness::event` runs one frame per event, so both go straight into the next frame. + let input = &mut h.input_mut().events; + input.push(egui::Event::PointerMoved(last)); + input.push(egui::Event::PointerButton { pos: last, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); +} + +/// The release frame counts: a stop flicked off the bar and released in the same frame is +/// removed, and one brought back onto the bar in the release frame is kept. +#[test] +fn the_release_frame_decides_whether_the_stop_comes_off() { + for layer in [true, false] { + let (mut h, sink) = editor(layer); + let from = marker(&h, 0.5, false); + let away = from + vec2(10.0, 40.0); + flick(&mut h, from, &[away, from]); + assert_eq!(counts(&h, sink), (3, 3), "layer {layer}: back on the bar at release, the stop is kept"); + let from = marker(&h, 0.5, false); + flick(&mut h, from, &[from + vec2(10.0, 40.0)]); + assert_eq!(counts(&h, sink), (2, 3), "layer {layer}: released off the bar in the same frame, the stop is removed"); + } +} diff --git a/crates/ui-egui/src/tools/magnetic_lasso_ui.rs b/crates/ui-egui/src/tools/magnetic_lasso_ui.rs index 29d3861d9bd..f669f61368c 100644 --- a/crates/ui-egui/src/tools/magnetic_lasso_ui.rs +++ b/crates/ui-egui/src/tools/magnetic_lasso_ui.rs @@ -58,6 +58,8 @@ pub struct Runtime { source: Option, /// Pointer moves were batched (`PhotocraftApp::defer_live_stroke`): trace once at the end. pending: bool, + /// The mouse button is down (pressed in this tool, not yet released): points fasten closer. + held: bool, } /// Detection settings from the options bar, for an event with pen `pressure`. @@ -71,12 +73,26 @@ fn settings(app: &PhotocraftApp, pressure: f32) -> Settings { Settings::new(width, o.magnetic_contrast / 100.0) } -/// How far the border runs before a point fastens by itself (document px): 8 screen points at -/// Frequency 100, 98 at 0, 47 at the default 57. -fn spacing(app: &PhotocraftApp) -> f64 { +/// How far the border runs before a point fastens by itself (document px), as the reference +/// application measures it (#11687). The stored spacing is `floor(40 - 0.35 x Frequency)`: 40 at +/// Frequency 0, 20 at the default 57, 5 at 100. +/// +/// - Button held: the stored value in document px, whatever the zoom (so on screen it grows with +/// the zoom: 40 px at 200 %, 60 at 300 %). Below 100 % the reference is uneven (about 8 to 10 +/// screen px at Frequency 57) and no rule was pinned; the stored document value is kept. +/// - Button up: `4 x stored + 4` screen points at 100 % and above (164, 84 and 24), and half of +/// that below 100 %, at any zoom, so it is divided by the zoom into document px. +fn spacing(app: &PhotocraftApp, held: bool) -> f64 { let f = app.ui.tool_options.magnetic_frequency; let f = if f.is_finite() { f64::from(f.clamp(0.0, 100.0)) } else { 57.0 }; - (8.0 + (100.0 - f) * 0.9) / f64::from(app.point_zoom().max(0.01)) + let stored = (40.0 - 0.35 * f).floor(); + if held { + return stored; + } + let zoom = f64::from(app.point_zoom().max(0.01)); + let screen = 4.0 * stored + 4.0; + let screen = if zoom < 1.0 { screen / 2.0 } else { screen }; + screen / zoom } /// Within this distance of the first point (document px), a click closes the border. @@ -107,14 +123,24 @@ fn trace(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], guide: &[[f64; 2 /// Pointer events while the Magnetic Lasso is the tool (it takes them all). pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { + // A release ends the held-button spacing whoever takes it (the temporary Move included). + if matches!(ev, ToolEvent::Up { .. }) { + app.magnetic.held = false; + } // ⌘ held makes it the Move tool (`hold_keys::cmd_moves`): nothing to take then. if app.active_tool() != Tool::MagneticLasso { return false; } match ev { - ToolEvent::Down { x, y, pressure } if x.is_finite() && y.is_finite() => down(app, [x, y], pressure, mods), + ToolEvent::Down { x, y, pressure } if x.is_finite() && y.is_finite() => { + down(app, [x, y], pressure, mods); + app.magnetic.held = true; + } ToolEvent::Move { x, y, pressure } if x.is_finite() && y.is_finite() => moved(app, [x, y], pressure), - ToolEvent::Up { .. } => end_freehand(app), + ToolEvent::Up { .. } => { + app.magnetic.held = false; + end_freehand(app); + } _ => {} } true @@ -178,7 +204,7 @@ fn moved(app: &mut PhotocraftApp, p: [f64; 2], pressure: f32) { return; } let s = settings(app, pressure); - let step = (spacing(app) / 2.0).max(1.0); + let step = (spacing(app, app.magnetic.held) / 2.0).max(1.0); let last = app.ui.magnetic.trail.last().copied().unwrap_or(p); // A long jump (a fast flick, automation) is walked in steps, so points keep fastening along it. let n = ((dist(last, p) / step).ceil() as usize).clamp(1, 256); @@ -220,7 +246,7 @@ fn extend_trail(app: &mut PhotocraftApp, q: [f64; 2], s: Settings) { fn follow(app: &mut PhotocraftApp, q: [f64; 2], s: Settings, fasten: bool) { extend_trail(app, q, s); let target = snap(app, q, s); - let every = spacing(app); + let every = spacing(app, app.magnetic.held); for _ in 0..64 { let Some(&from) = app.ui.magnetic.path.last() else { return }; let guide = app.ui.magnetic.trail.clone(); @@ -368,8 +394,15 @@ pub fn replaces_selection(app: &PhotocraftApp) -> bool { app.ui.magnetic.active() && app.ui.magnetic.mode == "replace" } -/// Once a frame: a border left behind by a tool or document switch is dropped. -pub fn frame(app: &mut PhotocraftApp) { +/// Once a frame of a document canvas (`primary_down`: its primary pointer button is down; `active_canvas`: +/// it is the canvas of the active document, the one that owns a press in progress): a border left behind by a tool or document switch is dropped. +pub fn frame(app: &mut PhotocraftApp, primary_down: bool, active_canvas: bool) { + // The button is up: the held-button spacing ends even when the release went to the temporary + // Move (Ctrl pressed during the drag) and never reached this tool. Only the active canvas + // says so: another window's button being up says nothing about the drag in this one. + if active_canvas && !primary_down { + app.magnetic.held = false; + } let m = &app.ui.magnetic; if m.active() && (app.ui.tool != Tool::MagneticLasso || app.session.active().map(|d| d.doc.id.0) != Some(m.doc)) { cancel(app); diff --git a/crates/ui-egui/src/tools/magnetic_lasso_ui/tests.rs b/crates/ui-egui/src/tools/magnetic_lasso_ui/tests.rs index 11d51376211..b5e41a5e1b7 100644 --- a/crates/ui-egui/src/tools/magnetic_lasso_ui/tests.rs +++ b/crates/ui-egui/src/tools/magnetic_lasso_ui/tests.rs @@ -25,6 +25,8 @@ fn app() -> PhotocraftApp { }) .unwrap(); app.ui.tool = Tool::MagneticLasso; + // Frequency 100 (a point about every 24 px with the button up, #11687): the 80 x 70 square is small. + app.ui.tool_options.magnetic_frequency = 100.0; app } @@ -77,7 +79,7 @@ fn tracing_around_a_shape_selects_it() { assert!(on_square(m.path[0], 0.01), "the first point snaps onto the edge: {:?}", m.path[0]); hover_along(&mut app, &AROUND[..5]); let m = &app.ui.magnetic; - // Points fastened by themselves on the way (about every 47 px at Frequency 57). + // Points fastened by themselves on the way (about every 24 px at Frequency 100). assert!(m.anchors.len() >= 5, "{:?}", m.anchors); for p in m.path.iter().chain(&m.live) { assert!(on_square(*p, 1.0), "{p:?} is off the square's edge"); @@ -199,16 +201,16 @@ fn agents_see_the_border_in_progress() { fn switching_tools_or_documents_drops_the_border() { let mut app = app(); click(&mut app, 52.0, 37.0, Modifiers::NONE); - frame(&mut app); + frame(&mut app, true, true); assert!(app.ui.magnetic.active()); app.ui.tool = Tool::Lasso; - frame(&mut app); + frame(&mut app, true, true); assert!(!app.ui.magnetic.active()); app.ui.tool = Tool::MagneticLasso; click(&mut app, 52.0, 37.0, Modifiers::NONE); app.run("file.new", json!({"width": 50, "height": 50})).unwrap(); app.sync_views(); - frame(&mut app); + frame(&mut app, true, true); assert!(!app.ui.magnetic.active()); } @@ -244,6 +246,41 @@ fn width_follows_pen_pressure_only_when_asked_and_only_with_a_pen() { assert_eq!(settings(&app, f32::NAN).width, 21.0); } +/// Starts a border at the top edge of the square, presses the button, then lets `release` end the +/// press; walks along the edge in 2 px steps and returns how many fastening points the border has. +fn points_after_walk(release: impl FnOnce(&mut PhotocraftApp)) -> usize { + let mut a = app(); + tool_event(&mut a, ToolEvent::Down { x: 50.0, y: 40.0, pressure: 1.0 }, Modifiers::NONE); + release(&mut a); + for x in (52..=128).step_by(2) { + tool_event(&mut a, ToolEvent::Move { x: f64::from(x), y: 40.0, pressure: 1.0 }, Modifiers::NONE); + } + a.ui.magnetic.anchors.len() +} + +#[test] +fn the_button_up_spacing_applies_after_a_release_the_temporary_move_took() { + // Baseline: an ordinary release, then the button-up spacing (~24 px at Frequency 100). + let plain = points_after_walk(|a| tool_event(a, ToolEvent::Up { x: 50.0, y: 40.0 }, Modifiers::NONE)); + assert!(plain <= 6, "button up: {plain} points"); + // The release arrives while the temporary Move (Ctrl held) is the active tool. + let moved = points_after_walk(|a| { + a.tool_override = Some(Tool::Move); + tool_event(a, ToolEvent::Up { x: 50.0, y: 40.0 }, Modifiers::COMMAND); + a.tool_override = None; + }); + assert!(moved <= 6, "after a release taken by the Move: {moved} points"); + // The canvas frame also clears it: the button is up, whoever saw the release. + let framed = points_after_walk(|a| frame(a, false, true)); + assert!(framed <= 6, "after a frame with the button up: {framed} points"); + // Another (inactive) canvas with its own button up leaves a live drag alone. + let other = points_after_walk(|a| frame(a, false, false)); + assert!(other > 10, "an inactive canvas's frame must not end the hold: {other} points"); + // A frame with the button still down keeps the held-button spacing. + let held = points_after_walk(|a| frame(a, true, true)); + assert!(held > 10, "button held: {held} points"); +} + #[test] fn hostile_input_never_panics() { let mut app = app(); diff --git a/crates/ui-egui/src/tools/path_components.rs b/crates/ui-egui/src/tools/path_components.rs index 33aef52d8da..7fd6fd55f74 100644 --- a/crates/ui-egui/src/tools/path_components.rs +++ b/crates/ui-egui/src/tools/path_components.rs @@ -166,16 +166,27 @@ pub(crate) fn press(app: &mut PhotocraftApp, at: [f64; 2], shift: bool) { /// components, one History step. `None` with none or all of them selected: the whole path moves /// instead, which keeps a live rectangle or ellipse live. pub(crate) fn move_selected(app: &mut PhotocraftApp, d: [f64; 2]) -> Option> { - let (r, path, heads) = selected(app)?; - if target(app).map(|(t, _)| t) != Some(r) || heads.len() >= path.components().len() { - return None; - } + let (r, _) = moving_selected(app)?; let mut params = r.params(app); params["anchors"] = json!(app.ui.direct_selection.anchors); params["move"] = json!(d); Some(run_selected_command(app, "path.moveAnchors", params)) } +/// The path [`move_selected`] moves components of, with some but not all of them selected. +fn moving_selected(app: &PhotocraftApp) -> Option<(PathRef, Path)> { + let (r, path, heads) = selected(app)?; + (target(app).map(|(t, _)| t) == Some(r) && heads.len() < path.components().len()).then_some((r, path)) +} + +/// The path [`move_selected`] leaves after a drag by `d`, for the drag's live preview: the +/// selected components moved with their handles, as `path.moveAnchors` moves them. +pub(crate) fn moved_selected(app: &PhotocraftApp, d: [f64; 2]) -> Option<(PathRef, Path)> { + let (r, mut path) = moving_selected(app)?; + photocraft_vector::edit::move_anchors(&mut path, &app.ui.direct_selection.anchors, d).ok()?; + Some((r, path)) +} + /// Do `+` and `-` pick a Path Operation with `tool`? The tools whose options bar has the menu. fn has_keys(tool: Tool) -> bool { matches!(tool, Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::PathSelection) diff --git a/crates/ui-egui/src/tools/perspective_crop_bar_buttons_tests.rs b/crates/ui-egui/src/tools/perspective_crop_bar_buttons_tests.rs new file mode 100644 index 00000000000..01d4043b5e9 --- /dev/null +++ b/crates/ui-egui/src/tools/perspective_crop_bar_buttons_tests.rs @@ -0,0 +1,72 @@ +//! #11978: the Perspective Crop options bar shows its Cancel and Commit buttons only during a +//! session (a quad, or corners being clicked), and they still commit and cancel. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use super::*; + +const COMMIT: &str = "Commit current crop operation"; +const CANCEL: &str = "Cancel current crop operation"; + +/// A 200 × 150 document with the Perspective Crop tool chosen and no session. +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.ui.tool = Tool::PerspectiveCrop; + app +} + +fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1400.0, 80.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + h +} + +fn shows(h: &Harness<'static, PhotocraftApp>, label: &str) -> bool { + h.query_by_label_contains(label).is_some() +} + +#[test] +fn without_a_session_neither_button_shows() { + let h = harness(app()); + assert!(!shows(&h, COMMIT) && !shows(&h, CANCEL)); + assert!(h.query_by_label("Show Grid").is_some(), "the bar still ends with Show Grid"); +} + +#[test] +fn a_pending_quad_shows_both_and_commit_commits() { + let mut app = app(); + app.ui.perspective_crop = Some([[20.0, 20.0], [180.0, 20.0], [180.0, 130.0], [20.0, 130.0]]); + let mut h = harness(app); + assert!(shows(&h, COMMIT) && shows(&h, CANCEL)); + h.get_by_label_contains(COMMIT).click(); + h.run_steps(3); + let size = h.state().session.active().unwrap().doc.size; + assert_eq!((size.width, size.height), (160, 110), "Commit cropped to the quad"); + assert!(!shows(&h, COMMIT) && !shows(&h, CANCEL), "gone after commit"); +} + +#[test] +fn clicked_corners_show_both_and_cancel_cancels() { + let mut app = app(); + app.pcrop.clicks = vec![[20.0, 20.0], [180.0, 25.0]]; + let mut h = harness(app); + assert!(shows(&h, COMMIT) && shows(&h, CANCEL), "with clicked corners"); + h.get_by_label_contains(CANCEL).click(); + h.run_steps(3); + assert!(h.state().pcrop.clicks.is_empty(), "Cancel dropped the corners"); + assert_eq!(h.state().session.active().unwrap().doc.size.width, 200, "nothing cropped"); + assert!(!shows(&h, COMMIT) && !shows(&h, CANCEL), "gone after cancel"); +} diff --git a/crates/ui-egui/src/tools/perspective_crop_ui.rs b/crates/ui-egui/src/tools/perspective_crop_ui.rs index ed60c90d414..9875916d724 100644 --- a/crates/ui-egui/src/tools/perspective_crop_ui.rs +++ b/crates/ui-egui/src/tools/perspective_crop_ui.rs @@ -305,6 +305,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } crate::widgets::vline(ui, 22.0); crate::widgets::checkbox(ui, &mut app.ui.tool_options.perspective_crop_grid, tl!("Show Grid")); + // Commit and Cancel show only during a session: a quad, clicked corners, or a drag (#11978). + if !pending(app) { + return; + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let commit_tip = crate::i18n::fmt(tl!("Commit current crop operation ({key})"), &[("key", &crate::shortcuts::pretty("Enter"))]); if crate::icons::button(ui, "check", 26.0, false, &commit_tip).clicked() { @@ -316,6 +320,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } +#[cfg(test)] +#[path = "perspective_crop_bar_buttons_tests.rs"] +mod perspective_crop_bar_buttons_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/tools/retouch_ui.rs b/crates/ui-egui/src/tools/retouch_ui.rs index 540cf5879a1..dbfef4bda1a 100644 --- a/crates/ui-egui/src/tools/retouch_ui.rs +++ b/crates/ui-egui/src/tools/retouch_ui.rs @@ -29,9 +29,9 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { let o = &app.ui.tool_options; // The options bar's Mode is the tool's own brush's. let mode = crate::blend_keys::brush_tool_mode(app.ui.tool, app.session.tools.brush.mode); - let mut p = json!({"aligned": o.clone_aligned, "sampleLayer": o.clone_sample, "mode": mode.label()}); + // View › Pattern Preview: both tools carry a stroke on across the canvas edges (#8883). + let mut p = json!({"aligned": o.clone_aligned, "sampleLayer": o.clone_sample, "mode": mode.label(), "wrap": app.ui.view.pattern_preview}); if app.ui.tool == Tool::CloneStamp { - p["wrap"] = json!(app.ui.view.pattern_preview); p["opacity"] = json!(app.session.tools.brush.opacity * 100.0); p["flow"] = json!(app.session.tools.brush.flow * 100.0); } @@ -337,6 +337,17 @@ pub fn cancel_stale(app: &mut PhotocraftApp) { } } +/// Where a Patch drag ends: with ⇧ pressed after the press (⇧ at the press adds to the selection +/// instead, so this never sees it) the end is held to the dominant axis from the start, 0 or 90 +/// degrees only (#12129). Other tools, and a drag without ⇧, end at `end`. +pub fn patch_end(d: &crate::canvas::Drag, end: [f64; 2]) -> [f64; 2] { + if d.tool != Tool::Patch || !d.live.shift { + return end; + } + let (dx, dy) = (end[0] - d.start[0], end[1] - d.start[1]); + if dx.abs() >= dy.abs() { [end[0], d.start[1]] } else { [d.start[0], end[1]] } +} + /// Whole-pixel offset for a drag from `start` to `end`, limited so the dragged outline stays on the /// canvas (the engine rejects a patch or move that leaves it). pub fn patch_offset(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) -> [i32; 2] { diff --git a/crates/ui-egui/src/tools/retouch_ui/tests.rs b/crates/ui-egui/src/tools/retouch_ui/tests.rs index 375f8a410ae..ad2bb0a79a2 100644 --- a/crates/ui-egui/src/tools/retouch_ui/tests.rs +++ b/crates/ui-egui/src/tools/retouch_ui/tests.rs @@ -28,6 +28,44 @@ fn stripes(app: &mut PhotocraftApp, step: usize, target: &str) { } } +#[test] +fn only_clone_stamp_and_healing_brush_wrap_in_pattern_preview() { + // View › Pattern Preview (#8883): a Clone Stamp or Healing Brush stroke across the right edge + // asks its command to go on at the opposite one, and only then. The reference application's + // other retouching tools stop at the edge. + let tools = [ + (Tool::CloneStamp, "paint.cloneStamp", true), + (Tool::Healing, "paint.healingBrush", true), + (Tool::Blur, "paint.blur", false), + (Tool::Sharpen, "paint.sharpen", false), + (Tool::Smudge, "paint.smudge", false), + (Tool::Dodge, "paint.dodge", false), + (Tool::Burn, "paint.burn", false), + (Tool::Sponge, "paint.sponge", false), + (Tool::ColorReplacement, "paint.colorReplacement", false), + (Tool::PatternStamp, "paint.patternStamp", false), + (Tool::HistoryBrush, "paint.historyBrush", false), + (Tool::SpotHealing, "paint.spotHealing", false), + (Tool::BackgroundEraser, "paint.backgroundEraser", false), + ]; + for (tool, cmd, wraps) in tools { + for pattern_preview in [false, true] { + let mut app = app(); + stripes(&mut app, 8, "pixels"); + app.ui.view.pattern_preview = pattern_preview; + app.ui.clone_source = Some([20.0, 30.0]); + app.ui.tool = tool; + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 86.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 112.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x: 112.0, y: 30.0 }, m); + assert!(!app.ui.status_error, "{tool:?}: {}", app.ui.status); + let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == cmd).cloned().unwrap_or_else(|| panic!("{tool:?}: no {cmd}")); + assert_eq!(p.get("wrap").and_then(Value::as_bool).unwrap_or(false), wraps && pattern_preview, "{tool:?}: {p}"); + } + } +} + #[test] fn clone_stamp_in_pattern_preview_wraps_a_dab_across_the_right_edge() { for pattern_preview in [false, true] { @@ -587,7 +625,8 @@ fn patch_cursor_shows_the_selection_mode_badge() { app.ui.selection_mode = 1; assert_eq!(badge(&app, tool, none), Some(Badge::Add), "{tool:?}: the options-bar mode outside the selection"); app.hover_doc = Some([20.0, 30.0]); - assert_eq!(badge(&app, tool, none), None, "{tool:?}: a press over the selection drags the patch"); + let over = (tool == Tool::Patch).then_some(Badge::Arrow); + assert_eq!(badge(&app, tool, none), over, "{tool:?}: a press over the selection drags the patch (Patch shows an arrow, #12130)"); assert_eq!(badge(&app, tool, shift), Some(Badge::Add), "{tool:?}: ⇧ over the selection adds"); } } diff --git a/crates/ui-egui/src/tools/shape_bounds_box.rs b/crates/ui-egui/src/tools/shape_bounds_box.rs index ee137bc5418..d56508bf969 100644 --- a/crates/ui-egui/src/tools/shape_bounds_box.rs +++ b/crates/ui-egui/src/tools/shape_bounds_box.rs @@ -62,7 +62,7 @@ pub struct Drag { fn shows(app: &PhotocraftApp) -> bool { app.ui.transform.is_none() && app.ui.view.shows(app.ui.view.show.target_path) - && matches!(app.ui.tool, Tool::PathSelection | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line) + && matches!(app.ui.tool, Tool::PathSelection | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Star | Tool::Line) } /// The selected shape layers and the box round their paths. `None` unless at least one layer is diff --git a/crates/ui-egui/src/tools/tool_reset.rs b/crates/ui-egui/src/tools/tool_reset.rs index e1da7b5d2a8..d4d7d32127e 100644 --- a/crates/ui-egui/src/tools/tool_reset.rs +++ b/crates/ui-egui/src/tools/tool_reset.rs @@ -13,8 +13,9 @@ //! Smudge). Resetting one of those tools resets the shared field too. Sample All Layers is kept per //! tool, as in Photoshop, so resetting a tool resets only its own. //! [`reset_options`] lists the fields of each tool. The selection tools also go back to New -//! Selection, and the painting tools (the ones with a brush in the options bar) get the default -//! brush, keeping the colours. Tools without options (Hand, Rotate View, Ruler, Note, …) have +//! Selection, and the painting tools (the ones with a brush in the options bar) get their starting +//! brush (`paint_mouse::fresh_brush`: the Brush tool's 13 px soft round, the default brush for the +//! others), keeping the colours. Tools without options (Hand, Rotate View, Ruler, Note, …) have //! nothing to reset. use serde_json::{Map, Value, json}; @@ -96,12 +97,20 @@ fn reset_all(app: &mut PhotocraftApp) { app.ui.tool_options = ToolOptions::default(); app.ui.artboard_tool = Default::default(); app.ui.selection_mode = 0; - app.ui.tool_brushes.clear(); reset_session_brush(app); + // Every other painting tool gets its own starting brush. Left without one, it would take the + // live brush when picked (`sync_tool_brush`), and the tools' starting tips differ (#11810). + let (live, current) = (app.ui.brush_tool, app.session.tools.brush.clone()); + app.ui.tool_brushes = Tool::ALL + .iter() + .filter(|t| **t != live && crate::paint_mouse::has_brush_picker(**t)) + .map(|t| (*t, crate::paint_mouse::fresh_brush(*t, ¤t))) + .collect(); } +/// The live brush is `app.ui.brush_tool`'s: give it that tool's starting brush. fn reset_session_brush(app: &mut PhotocraftApp) { - app.session.tools.brush = crate::paint_mouse::fresh_brush(&app.session.tools.brush); + app.session.tools.brush = crate::paint_mouse::fresh_brush(app.ui.brush_tool, &app.session.tools.brush); app.session.tools.brush_gesture = None; app.session.tools.current_preset = None; app.session.tools.brush_changed(); @@ -150,10 +159,12 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { /// The serialized name of `tool`'s own Sample All Layers inside `sample_all`, if it has one: a /// tool preset stores that one alone, not the other tools' (#5524, #7356). pub fn sample_all_key(tool: Tool) -> Option { - let mut probe = crate::state::SampleAllLayers::default(); - *probe.slot(tool)? = true; - let Ok(Value::Object(m)) = serde_json::to_value(probe) else { return None }; - m.into_iter().find(|(_, v)| v.as_bool() == Some(true)).map(|(k, _)| k) + let base = crate::state::SampleAllLayers::default(); + let mut probe = base; + let slot = probe.slot(tool)?; + *slot = !*slot; + let (Ok(Value::Object(b)), Ok(Value::Object(m))) = (serde_json::to_value(base), serde_json::to_value(probe)) else { return None }; + m.into_iter().find(|(k, v)| b.get(k) != Some(v)).map(|(k, _)| k) } /// The [`ToolOptions`] fields `tool` shows, by their serialized names: what Reset Tool resets and @@ -175,7 +186,8 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { move_expand_group_on_click, move_show_layers_hover_bounds, transform_interpolation, - transform_reference_point + transform_reference_point, + transform_relative_position ), Tool::RectMarquee | Tool::EllipseMarquee => reset!(feather, anti_alias, marquee_style, marquee_width, marquee_height, marquee_unit), // The single-pixel marquees are never anti-aliased: Feather is their only option. @@ -206,7 +218,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { Tool::Pencil => reset!(pencil_auto_erase, pencil_clear), Tool::Eraser => reset!(eraser_mode), Tool::Remove => reset!(remove_new_layer, remove_after_each_stroke), - Tool::BackgroundEraser => reset!(bg_sampling, bg_limits, bg_tolerance, bg_protect_fg), + Tool::BackgroundEraser => reset!(bg_sampling, bg_limits, bg_tolerance, bg_protect_fg, bg_tolerance_control), Tool::ColorReplacement => reset!(cr_mode, cr_sampling, cr_limits, cr_tolerance, cr_anti_alias), Tool::MagicEraser => reset!(tolerance, anti_alias, contiguous, magic_eraser_opacity), Tool::Gradient => reset!(gradient_style, gradient_reverse, gradient_dither, gradient_classic, gradient_blend_mode, fill_opacity), @@ -247,14 +259,14 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { arrow_length, arrow_concavity, ), - Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::CustomShape => { + Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Star | Tool::CustomShape => { reset!(shape_mode, shape_fill, stroke_width, shape_stroke, shape_fill_gradient, shape_stroke_gradient, corner_radius, polygon_sides, line_weight) } + Tool::MixerBrush => reset!(mixer_combination), Tool::ColorSampler | Tool::Ruler | Tool::Note | Tool::Count - | Tool::MixerBrush | Tool::Hand | Tool::RotateView | Tool::HistoryBrush @@ -360,3 +372,7 @@ mod tests { assert_eq!((keys.len(), distinct.len()), (12, 12), "one key per tool with the option"); } } + +#[cfg(test)] +#[path = "../patch_sample_all_default_tests.rs"] +mod patch_sample_all_default_tests; diff --git a/crates/ui-egui/src/tools/transform_fx.rs b/crates/ui-egui/src/tools/transform_fx.rs index 0ad888536db..5be2428c858 100644 --- a/crates/ui-egui/src/tools/transform_fx.rs +++ b/crates/ui-egui/src/tools/transform_fx.rs @@ -106,7 +106,7 @@ pub fn work_layer(layer: &Layer, scale: f64) -> Layer { l.vector_mask = None; l.fill_cache = None; l.blend = BlendMode::Normal; - photocraft_engine::image_cmds::scale_effects(&mut l.effects, scale as f32); + photocraft_engine::image_cmds::scale_effects_preview(&mut l.effects, scale as f32); l.effects.reference = l.effects.reference.map(|(x, y)| (x * scale, y * scale)); l } @@ -468,6 +468,27 @@ mod tests { l } + fn pattern_stroke_scale(l: &Layer) -> Option { + l.effects.items.iter().find_map(|e| match e { + Effect::Stroke(StrokeFx { paint: FxPaint::Pattern { scale, .. }, .. }) => Some(*scale), + _ => None, + }) + } + + /// The preview draws a pattern at the work scale exactly: the dialog's 1..1000 % range is for + /// stored styles, so a 2 % pattern at a quarter of the size is 0.5 %, and an imported 2000 % + /// one stays 2000 % at full size, as the committed style draws them. + #[test] + fn work_layer_scales_patterns_past_the_dialog_range() { + let with_pattern = |scale: f32| { + let Effect::Stroke(mut st) = stroke(3.0) else { panic!("a stroke") }; + st.paint = FxPaint::Pattern { name: "Bubbles".into(), id: String::new(), scale }; + styled(vec![Effect::Stroke(st)]) + }; + assert_eq!(pattern_stroke_scale(&work_layer(&with_pattern(0.02), 0.25)), Some(0.005)); + assert_eq!(pattern_stroke_scale(&work_layer(&with_pattern(20.0), 1.0)), Some(20.0)); + } + fn doc() -> Document { Document::new("t", photocraft_geom::Size::new(200, 200), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8) } diff --git a/crates/ui-egui/src/tools/transform_tool/draw.rs b/crates/ui-egui/src/tools/transform_tool/draw.rs index 544e7a83595..8205381f608 100644 --- a/crates/ui-egui/src/tools/transform_tool/draw.rs +++ b/crates/ui-egui/src/tools/transform_tool/draw.rs @@ -1,11 +1,11 @@ use super::begin::warps_type; use super::drag_modes::{HandleIndicator, handle_indicator}; use super::options_bar::{quad_scale, readout}; -use super::pointer::{Hit, handle_at, rotate_dir, rotation_degrees}; +use super::pointer::{Hit, handle_at, rotation_degrees}; use super::warp::{on_section_edge, quick_split_kind, single_patch}; use super::{ - BezierMesh, Color32, DocId, HANDLE_PX, Homography, LayerId, PhotocraftApp, Pos2, SplitTool, Stroke, TransformPreview, TransformSession, ViewXform, Warp, - WarpDrag, WarpStyle, corners, pos2, vec2, + BezierMesh, Color32, DocId, Homography, LayerId, PhotocraftApp, Pos2, SplitTool, Stroke, TransformPreview, TransformSession, ViewXform, Warp, WarpDrag, + WarpStyle, corners, pos2, vec2, }; /// The distort or skew pointer at screen point `at`, where [`super::cursor`] hid the system @@ -224,14 +224,6 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform painter.line_segment([c - vec2(0.0, 8.0), c + vec2(0.0, 8.0)], Stroke::new(1.0, Color32::WHITE)); } if let Some(at) = painter.ctx().pointer_hover_pos().filter(|p| xf.rect.contains(*p)) { - let p = xf.to_doc(at); - let tol = HANDLE_PX / f64::from(xf.zoom.max(0.01)); - if let Some(k) = rotate_dir(t, Some(pv), p, tol) { - // Through the view, so the arc bends round the corner as it shows under a turned or - // flipped view. - let toward = xf.to_screen((p[0] + k[0] * 16.0) as f32, (p[1] + k[1] * 16.0) as f32) - at; - crate::crop_ui::draw_turn_cursor(painter.ctx(), at, toward); - } // Preferences › Tools › Show Transformation Values off hides it, as in Photoshop (#204). if let Some(r) = live_readout(app).filter(|_| app.session.prefs().tools.show_transformation_values) { draw_live_readout(app, painter.ctx(), at, r); diff --git a/crates/ui-egui/src/tools/transform_tool/options_bar.rs b/crates/ui-egui/src/tools/transform_tool/options_bar.rs index 19b46634e6e..e9ee6958153 100644 --- a/crates/ui-egui/src/tools/transform_tool/options_bar.rs +++ b/crates/ui-egui/src/tools/transform_tool/options_bar.rs @@ -270,6 +270,87 @@ fn reference_point_toggle(ui: &mut egui::Ui, on: &mut bool) -> bool { *on } +/// Where the reference point was when the session opened: the point of the original box that the +/// box's transform has carried to the reference point now. A box that only scales or turns about +/// its reference point leaves it in place; a moved box carries it along. +fn reference_point_origin(rect: [f64; 4], quad: [[f64; 2]; 4], pivot: [f64; 2]) -> [f64; 2] { + if let Some(h) = Homography::rect_to_quad(rect, quad).and_then(|h| h.inverse()) { + let (x, y) = h.apply(pivot[0], pivot[1]); + if x.is_finite() && y.is_finite() { + return [x, y]; + } + } + // A flattened box has no inverse. Fit the map's linear part about the box's centre and use + // its pseudo-inverse: the collapsed direction adds nothing, so a box squashed about its + // reference point reads no offset and one squashed toward an edge reads how far it moved. + let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); + let centre = [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0]; + let mid = [quad.iter().map(|q| q[0]).sum::() / 4.0, quad.iter().map(|q| q[1]).sum::() / 4.0]; + let d = [pivot[0] - mid[0], pivot[1] - mid[1]]; + if !(w.abs() > f64::EPSILON && h.abs() > f64::EPSILON) { + return [centre[0] + d[0], centre[1] + d[1]]; + } + // Columns: where a unit step along the box's width and height goes. + let ex = [(quad[1][0] - quad[0][0] + quad[2][0] - quad[3][0]) / (2.0 * w), (quad[1][1] - quad[0][1] + quad[2][1] - quad[3][1]) / (2.0 * w)]; + let ey = [(quad[3][0] - quad[0][0] + quad[2][0] - quad[1][0]) / (2.0 * h), (quad[3][1] - quad[0][1] + quad[2][1] - quad[1][1]) / (2.0 * h)]; + let det = ex[0] * ey[1] - ey[0] * ex[1]; + let norm2 = ex[0] * ex[0] + ex[1] * ex[1] + ey[0] * ey[0] + ey[1] * ey[1]; + let back = if det.abs() > 1e-9 * norm2.max(1e-12) { + [(ey[1] * d[0] - ey[0] * d[1]) / det, (ex[0] * d[1] - ex[1] * d[0]) / det] + } else if norm2 > 1e-12 { + // Rank one: the pseudo-inverse is the transpose over the squared norm. + [(ex[0] * d[0] + ex[1] * d[1]) / norm2, (ey[0] * d[0] + ey[1] * d[1]) / norm2] + } else { + [0.0, 0.0] + }; + let o = [centre[0] + back[0], centre[1] + back[1]]; + if o[0].is_finite() && o[1].is_finite() { o } else { pivot } +} + +fn shown_at(rect: [f64; 4], quad: [[f64; 2]; 4], pivot: [f64; 2], relative: bool) -> [f64; 2] { + if relative { + let o = reference_point_origin(rect, quad, pivot); + [pivot[0] - o[0], pivot[1] - o[1]] + } else { + pivot + } +} + +/// X and Y as the options bar shows them: the reference point's position, or with relative +/// positioning on (#11679) its offset from where it was when the session opened. +pub(crate) fn position_shown(t: &TransformSession, relative: bool) -> [f64; 2] { + shown_at(t.rect, t.quad, t.pivot, relative) +} + +/// Moves the box (and its reference point) so X and Y read `xy` (see [`position_shown`]). +pub(crate) fn set_position(s: &mut TransformSession, relative: bool, xy: [f64; 2]) { + let now = position_shown(s, relative); + let (dx, dy) = (xy[0] - now[0], xy[1] - now[1]); + s.quad = s.quad.map(|q| [q[0] + dx, q[1] + dy]); + s.pivot = [s.pivot[0] + dx, s.pivot[1] + dy]; +} + +/// Use Relative Positioning for Reference Point: the triangle button Photoshop puts between X and +/// Y (#11679). On, X and Y are the reference point's offset from where the box started. +fn relative_position_toggle(ui: &mut egui::Ui, on: &mut bool) { + let tk = crate::theme::Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::click()); + if resp.clicked() { + *on = !*on; + } + let painter = ui.painter(); + if *on || resp.hovered() { + painter.rect_filled(rect, 3.0, if *on { tk.accent.gamma_multiply(0.35) } else { tk.card_border.gamma_multiply(0.5) }); + } + let c = rect.center(); + let tri = vec![egui::pos2(c.x, c.y - 4.5), egui::pos2(c.x + 5.0, c.y + 4.0), egui::pos2(c.x - 5.0, c.y + 4.0)]; + let color = if *on { tk.accent } else { tk.text_dim }; + painter.add(egui::Shape::closed_line(tri, egui::Stroke::new(1.2, color))); + let label = tl!("Use relative positioning for reference point"); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, *on, label)); + resp.on_hover_text(label); +} + fn anchor_grid_widget(ui: &mut egui::Ui, current_anchor: Option, enabled: bool) -> Option { let size = egui::vec2(16.0, 16.0); // Greyed and inert while Toggle Reference Point is off, as in Photoshop (#11098). @@ -314,18 +395,23 @@ pub(crate) fn position_size_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t pivot = new_p; } ui.add_space(4.0); - let mut px = pivot[0] as f32; - let mut py = pivot[1] as f32; + let relative = app.ui.tool_options.transform_relative_position; + let shown = shown_at(t.rect, t.quad, pivot, relative); + let mut px = shown[0] as f32; + let mut py = shown[1] as f32; lbl(ui, "X:"); let rx = crate::widgets::value_field(ui, &mut px, -POSITION_LIMIT_PX..=POSITION_LIMIT_PX, "px", 72.0).accessible_name("X"); + relative_position_toggle(ui, &mut app.ui.tool_options.transform_relative_position); lbl(ui, "Y:"); let ry = crate::widgets::value_field(ui, &mut py, -POSITION_LIMIT_PX..=POSITION_LIMIT_PX, "px", 72.0).accessible_name("Y"); if (rx.changed() || ry.changed()) && let Some(s) = app.ui.transform.as_mut() { - let (dx, dy) = (px as f64 - s.pivot[0], py as f64 - s.pivot[1]); - s.quad = s.quad.map(|q| [q[0] + dx, q[1] + dy]); - s.pivot = [px as f64, py as f64]; + // An axis the user didn't edit keeps its full-precision value: the field's f32 would + // nudge the box by its rounding. + let x = if rx.changed() { f64::from(px) } else { shown[0] }; + let y = if ry.changed() { f64::from(py) } else { shown[1] }; + set_position(s, relative, [x, y]); } crate::widgets::vline(ui, 22.0); // W and H in the unit picked from their right-click menu: the scale in percent, or the box's diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index 2cd5ba41a8c..2f4d1b973fe 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -3,8 +3,7 @@ use super::commit::{commit, committing, moves_type}; use super::drag_modes::{apply_drag, apply_mode_drag, drag_mods, handle_indicator, mode_allows, mode_mods}; use super::warp::{quick_split_kind, split_cursor, surface_at, warp_hit, warp_pointer}; use super::{ - CursorIcon, HANDLE_PX, Homography, LayerContent, PhotocraftApp, ToolEvent, TransformMode, TransformPreview, TransformSession, ViewXform, WarpStyle, - handle_tolerance, + CursorIcon, HANDLE_PX, Homography, LayerContent, PhotocraftApp, ToolEvent, TransformMode, TransformSession, ViewXform, WarpStyle, handle_tolerance, }; /// Which part of the box a document point hits. @@ -17,8 +16,106 @@ pub(crate) enum Hit { Outside, } -/// The nearest handle within `tol` (on a small box their grab areas overlap), else inside or -/// outside the box. +/// Screen-sized corner and outward edge bands, shared with Move's unopened controls. +impl TransformSession { + /// Classify a document point with screen-sized corner and outward edge zones. + /// On and outward from an edge, the nearest corner or midpoint wins, with the corner + /// reach capped along the edge. Inside and beyond the edge ends use the corner radius. + pub(crate) fn box_hit(quad: &[[f64; 2]; 4], p: [f64; 2], tol: f64) -> Hit { + // `tol` is the existing 12 pt handle tolerance, divided by the view zoom. + let corner_radius = tol * (8.0 / HANDLE_PX); + let edge_width = tol * (11.0 / HANDLE_PX); + // Canvas coordinates round through f32. Treat only sub-pixel projection noise as + // the line itself; a point one screen pixel inside still belongs to the interior. + let line_epsilon = tol.abs() * (1e-3 / HANDLE_PX); + let distance = |q: [f64; 2]| (p[0] - q[0]).hypot(p[1] - q[1]); + let interior = inside(quad, p); + let edge = (0..4) + .filter_map(|i| { + let (u, q) = edge_projection(quad, i, p)?; + let d = distance(q); + (d <= edge_width && (!interior || d <= line_epsilon)).then_some((i, u, d)) + }) + .min_by(|a, b| a.2.total_cmp(&b.2)); + if let Some((i, u, _)) = edge + && (0.0..=1.0).contains(&u) + { + let next = (i + 1) % 4; + let (a, b) = (quad[i], quad[next]); + let length = (b[0] - a[0]).hypot(b[1] - a[1]); + let quarter = length * 0.25; + let tie_epsilon = line_epsilon.min(quarter * 0.5); + // Projected distances rank the handles along this edge. At the measured + // quarter-point ties, prefer the earlier handle in edge order. + let from_start = u * length; + let from_end = (1.0 - u) * length; + if from_start <= quarter + tie_epsilon && from_start <= edge_width + line_epsilon { + return Hit::Corner(i); + } + if from_end < quarter - tie_epsilon && from_end <= edge_width + line_epsilon { + return Hit::Corner(next); + } + return Hit::Edge(i); + } + if let Some((i, _)) = quad.iter().enumerate().map(|(i, &q)| (i, distance(q))).filter(|(_, d)| *d <= corner_radius).min_by(|a, b| a.1.total_cmp(&b.1)) { + return Hit::Corner(i); + } + match edge { + Some((i, _, _)) => Hit::Edge(i), + None if interior => Hit::Inside, + None => Hit::Outside, + } + } + + /// Into the nearest edge or corner: eight orientations for an upright rectangle, following + /// the actual quad when it is rotated, flipped or distorted. + pub(crate) fn box_turn_direction(quad: &[[f64; 2]; 4], p: [f64; 2]) -> Option<[f64; 2]> { + if p.iter().chain(quad.iter().flatten()).any(|v| !v.is_finite()) { + return None; + } + let (i, u, _) = (0..4) + .filter_map(|i| { + let (u, q) = edge_projection(quad, i, p)?; + Some((i, u, (p[0] - q[0]).hypot(p[1] - q[1]))) + }) + .min_by(|a, b| a.2.total_cmp(&b.2))?; + let unit = |v: [f64; 2]| { + let len = v[0].hypot(v[1]); + (len.is_finite() && len > 1e-9).then(|| [v[0] / len, v[1] / len]) + }; + let toward = if u <= 0.0 || u >= 1.0 { + let i = if u >= 1.0 { (i + 1) % 4 } else { i }; + let direction = |j: usize| unit([quad[j][0] - quad[i][0], quad[j][1] - quad[i][1]]); + let (a, b) = (direction((i + 1) % 4)?, direction((i + 3) % 4)?); + [a[0] + b[0], a[1] + b[1]] + } else { + let (a, b) = (quad[i], quad[(i + 1) % 4]); + let normal = [a[1] - b[1], b[0] - a[0]]; + // Winding orients every edge, even when a concave quad's mean corner position + // lies on the wrong side of this edge. Translate before taking the area. + let cross = |i: usize, j: usize| (quad[i][0] - quad[0][0]) * (quad[j][1] - quad[0][1]) - (quad[i][1] - quad[0][1]) * (quad[j][0] - quad[0][0]); + let sign = (cross(1, 2) + cross(2, 3)).signum(); + normal.map(|v| v * sign) + }; + unit(toward) + } +} + +/// The unclamped projection parameter and the nearest point on the segment. Degenerate +/// edges have no band or normal; the other edges and corner handles remain usable. +fn edge_projection(quad: &[[f64; 2]; 4], i: usize, p: [f64; 2]) -> Option<(f64, [f64; 2])> { + let (a, b) = (quad[i], quad[(i + 1) % 4]); + let d = [b[0] - a[0], b[1] - a[1]]; + let len2 = d[0] * d[0] + d[1] * d[1]; + if !len2.is_finite() || len2 <= 1e-18 { + return None; + } + let u = ((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / len2; + let on_segment = u.clamp(0.0, 1.0); + Some((u, [a[0] + on_segment * d[0], a[1] + on_segment * d[1]])) +} + +/// The shared box zones, retaining the nearest pivot's existing grab priority on small boxes. pub(crate) fn hit(t: &TransformSession, p: [f64; 2], tol: f64) -> Hit { hit_with(t, p, tol, true) } @@ -35,22 +132,26 @@ fn hit_with(t: &TransformSession, p: [f64; 2], tol: f64, pivot: bool) -> Hit { let (a, b) = (t.quad[i], t.quad[(i + 1) % 4]); [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0] }; + let h = TransformSession::box_hit(&t.quad, p, tol); + let handle_distance = match h { + Hit::Corner(i) => d(t.quad[i]), + Hit::Edge(i) => d(mid(i)), + _ => f64::INFINITY, + }; + if pivot { + // A shown reference point wins ties with handles, including when it sits on one. + let distance = d(t.pivot); + return if distance < tol && distance <= handle_distance { Hit::Pivot } else { h }; + } // With the reference point hidden, the box centre is a move target in its place, so a press // at the centre of a box smaller than two grab areas moves it instead of grabbing an edge. let centre = [t.quad.iter().map(|q| q[0]).sum::() / 4.0, t.quad.iter().map(|q| q[1]).sum::() / 4.0]; - // A shown reference point comes first: where it sits on a handle (the options bar's locator - // or its pull to the box's points put it there), a press drags the point, not the handle, as - // in the reference application. - let (first, last) = if pivot { (Some((t.pivot, Hit::Pivot)), None) } else { (None, Some((centre, Hit::Inside))) }; - let handles = first.into_iter().chain((0..4).map(|i| (t.quad[i], Hit::Corner(i)))).chain((0..4).map(|i| (mid(i), Hit::Edge(i)))).chain(last); - let nearest = handles.map(|(q, h)| (d(q), h)).filter(|(dist, _)| *dist < tol).min_by(|a, b| a.0.total_cmp(&b.0)); - match nearest { - Some((_, h)) => h, - None if inside(&t.quad, p) => Hit::Inside, - None => Hit::Outside, - } + let distance = d(centre); + if distance < tol && distance < handle_distance { Hit::Inside } else { h } } +/// Even-odd quad membership, independent of winding and valid for concave boxes. +/// Handles and edge-rounding tolerance are classified separately by the caller. fn inside(q: &[[f64; 2]; 4], p: [f64; 2]) -> bool { let mut c = false; for i in 0..4 { @@ -268,16 +369,12 @@ pub(super) fn carry_pivot(quad0: [[f64; 2]; 4], quad: [[f64; 2]; 4], pivot0: [f6 if x.is_finite() && y.is_finite() { [x, y] } else { pivot0 } } -/// True just outside a corner: rotate handle feedback lives here, not over the entire -/// empty canvas. The actual rotation gesture continues to work farther away. -pub(super) fn near_rotate_corner(t: &TransformSession, p: [f64; 2], tol: f64) -> bool { +/// Rotation feedback uses the same hit as the gesture, including the held hit while dragging. +pub(super) fn near_rotate_corner(t: &TransformSession, h: Hit) -> bool { t.warp.is_none() - && t.mode != TransformMode::Distort - && { - let h = hit(t, p, tol); - h == Hit::Outside || (t.mode == TransformMode::Rotate && matches!(h, Hit::Corner(_) | Hit::Edge(_))) - } - && t.quad.iter().any(|q| (p[0] - q[0]).hypot(p[1] - q[1]) <= tol * 3.0) + && t.content_aware.is_none() + && !matches!(t.mode, TransformMode::Distort | TransformMode::Skew) + && (h == Hit::Outside || (t.mode == TransformMode::Rotate && matches!(h, Hit::Corner(_) | Hit::Edge(_)))) } /// Clockwise degrees in canvas coordinates (positive Y points down). @@ -288,40 +385,18 @@ pub(super) fn rotation_degrees(quad: [[f64; 2]; 4]) -> f64 { } /// Where the rotate cursor's arc bends at document point `p`, or `None` outside the rotate zone: -/// just outside a corner, or anywhere while a rotate drag is on. It points into the box along the -/// bisector of the nearest corner, so each corner gets its own arrow and they turn with the box. +/// anywhere outside, or while a rotate drag is on. It follows the nearest edge or corner. pub(crate) fn rotate_cursor_dir(app: &PhotocraftApp, p: [f64; 2]) -> Option<[f64; 2]> { - rotate_dir(app.ui.transform.as_ref()?, app.transform_preview.as_ref(), p, handle_tolerance(app)) + let t = app.ui.transform.as_ref()?; + rotate_dir(t, handle_at(app, t, p, handle_tolerance(app)), p) } -pub(super) fn rotate_dir(t: &TransformSession, pv: Option<&TransformPreview>, p: [f64; 2], tol: f64) -> Option<[f64; 2]> { - // Content-Aware Scale's box only scales, and Skew doesn't rotate: no rotate arrow. - if !(p[0].is_finite() && p[1].is_finite()) - || t.warp.is_some() - || t.content_aware.is_some() - || matches!(t.mode, TransformMode::Distort | TransformMode::Skew) - { +/// Follow the nearest edge or corner during a rotation hover or drag. +fn rotate_dir(t: &TransformSession, h: Hit, p: [f64; 2]) -> Option<[f64; 2]> { + if !near_rotate_corner(t, h) { return None; } - // During a drag the arrow shows only while it rotates: not when a handle that scales, skews - // or distorts strays past a corner (#5405). - match pv.and_then(|pv| pv.gesture) { - Some(g) if g.hit == Hit::Outside || (t.mode == TransformMode::Rotate && matches!(g.hit, Hit::Corner(_) | Hit::Edge(_))) => {} - Some(_) => return None, - None if !near_rotate_corner(t, p, tol) => return None, - None => {} - } - let dist = |q: [f64; 2]| (p[0] - q[0]).hypot(p[1] - q[1]); - let i = (0..4).min_by(|&a, &b| dist(t.quad[a]).total_cmp(&dist(t.quad[b])))?; - let unit = |to: [f64; 2]| { - let d = [to[0] - t.quad[i][0], to[1] - t.quad[i][1]]; - let l = d[0].hypot(d[1]); - (l.is_finite() && l > 1e-9).then(|| [d[0] / l, d[1] / l]) - }; - let (a, b) = (unit(t.quad[(i + 1) % 4])?, unit(t.quad[(i + 3) % 4])?); - let s = [a[0] + b[0], a[1] + b[1]]; - let l = s[0].hypot(s[1]); - (l.is_finite() && l > 1e-9).then(|| [s[0] / l, s[1] / l]) + TransformSession::box_turn_direction(&t.quad, p) } /// The handle a drag in progress holds, else the one under document point `p`. @@ -362,7 +437,7 @@ pub fn cursor(app: &PhotocraftApp, xf: &ViewXform, p: [f64; 2], mods: egui::Modi return Some(CursorIcon::Default); } // A ⌘-drag outside the box still rotates, so ⌘ keeps the curved arrow there. - if rotate_cursor_dir(app, p).is_some() { + if rotate_dir(t, h, p).is_some() { // The canvas draws the curved rotate arrow (`rotate_cursor_dir`), also during the drag. return Some(CursorIcon::None); } @@ -382,7 +457,6 @@ pub fn cursor(app: &PhotocraftApp, xf: &ViewXform, p: [f64; 2], mods: egui::Modi Hit::Corner(i) => crate::tool_cursor::corner_resize(&t.quad, i, xf), Hit::Edge(i) => resize(edge_mid(i + 2), edge_mid(i)), Hit::Pivot => CursorIcon::Crosshair, - Hit::Inside => CursorIcon::Move, - Hit::Outside => CursorIcon::Alias, + Hit::Inside | Hit::Outside => CursorIcon::Default, }) } diff --git a/crates/ui-egui/src/tools/transform_tool/tests.rs b/crates/ui-egui/src/tools/transform_tool/tests.rs index 3721fdeb2a8..08d24d66075 100644 --- a/crates/ui-egui/src/tools/transform_tool/tests.rs +++ b/crates/ui-egui/src/tools/transform_tool/tests.rs @@ -79,14 +79,15 @@ fn a_click_near_rotation_handles_accepts_but_drags_and_option_pivot_do_not() { } #[test] -fn rotate_feedback_only_appears_just_outside_corners() { +fn rotate_feedback_covers_the_outside_zone() { let mut t = session(); - assert!(near_rotate_corner(&t, [-18.0, -18.0], 12.0)); - assert!(!near_rotate_corner(&t, [50.0, 25.0], 12.0), "inside moves the box"); - assert!(!near_rotate_corner(&t, [500.0, 500.0], 12.0), "far outside stays generic"); - assert!(!near_rotate_corner(&t, [2.0, 2.0], 12.0), "corner handle resizes"); + let rotates = |t: &TransformSession, p| near_rotate_corner(t, hit(t, p, 12.0)); + assert!(rotates(&t, [-18.0, -18.0])); + assert!(!rotates(&t, [30.0, 25.0]), "inside moves the box"); + assert!(rotates(&t, [500.0, 500.0]), "far outside also rotates"); + assert!(!rotates(&t, [2.0, 2.0]), "corner handle resizes"); t.mode = TransformMode::Distort; - assert!(!near_rotate_corner(&t, [-18.0, -18.0], 12.0)); + assert!(!rotates(&t, [-18.0, -18.0])); } #[test] @@ -329,7 +330,7 @@ fn bar_harness(setup: impl FnOnce(&mut PhotocraftApp)) -> egui_kittest::Harness< let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.ui.transform = Some(session()); setup(&mut app); - let mut h = Harness::builder().with_size(vec2(900.0, 360.0)).build_ui_state( + let mut h = Harness::builder().with_size(vec2(1000.0, 360.0)).build_ui_state( |ui, app: &mut PhotocraftApp| { if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { return; @@ -1771,7 +1772,7 @@ fn hovering_just_outside_a_corner_asks_for_the_rotate_cursor() { assert_eq!(cursor(&app, &xf, tl, egui::Modifiers::NONE), Some(CursorIcon::None), "the arrow is drawn instead"); assert!(near2(rotate_cursor_dir(&app, tl), [h, h]), "{:?}", rotate_cursor_dir(&app, tl)); assert!(near2(rotate_cursor_dir(&app, br), [-h, -h]), "bends round the bottom-right corner"); - assert_eq!(cursor(&app, &xf, [60.0, 60.0], egui::Modifiers::NONE), Some(CursorIcon::Move)); + assert_eq!(cursor(&app, &xf, [60.0, 60.0], egui::Modifiers::NONE), Some(CursorIcon::Default)); assert_eq!(rotate_cursor_dir(&app, [60.0, 60.0]), None, "inside moves"); assert_eq!(rotate_cursor_dir(&app, [32.0, 32.0]), None, "the corner handle scales"); assert_eq!(rotate_cursor_dir(&app, [f64::NAN, 32.0]), None); @@ -3568,9 +3569,10 @@ fn cursor_during_drag_stays_stable() { // Release drag crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 50.0, y: 60.0 }, command); - // Once released, dragging has ended: far outside returns the commit-on-click cursor, + // Once released, dragging has ended: far outside returns the custom rotate cursor, // and hovering the corner without Command restores normal resize cursor. - assert_eq!(cursor(&app, &xf, [200.0, 200.0], command), Some(CursorIcon::Alias)); + assert_eq!(cursor(&app, &xf, [200.0, 200.0], command), Some(CursorIcon::None)); + assert!(rotate_cursor_dir(&app, [200.0, 200.0]).is_some()); assert_ne!(cursor(&app, &xf, [50.0, 60.0], egui::Modifiers::NONE), Some(CursorIcon::None)); } diff --git a/crates/ui-egui/src/tools/type_tool.rs b/crates/ui-egui/src/tools/type_tool.rs index 42ad346f61d..d0807fe5ddc 100644 --- a/crates/ui-egui/src/tools/type_tool.rs +++ b/crates/ui-egui/src/tools/type_tool.rs @@ -130,6 +130,9 @@ fn byte_range(text: &str, a: usize, b: usize) -> (usize, usize) { (at(a.min(b)), at(a.max(b))) } +#[cfg(test)] +mod vertical_align_buttons_tests; + #[cfg(test)] mod canvas_tests; @@ -137,12 +140,19 @@ mod canvas_tests; mod font_commit_tests; #[cfg(test)] mod font_picker_tests; +#[cfg(test)] +mod options_tooltips_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod mask_replace_selection_tests; + #[cfg(test)] mod commit_tests; #[cfg(test)] mod ime_tests; +#[cfg(test)] +mod mask_escape_tests; diff --git a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs index 091a0a22cbc..435db44e592 100644 --- a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs +++ b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs @@ -795,25 +795,26 @@ fn options_bar_t_icon_scrub_stops_at_the_size_limits() { } /// Rests the pointer on the widget named `label` past the tooltip delay; true when its tooltip -/// (a second node with the same name) shows. -fn tooltip_shows(h: &mut Harness<'static, PhotocraftApp>, role: egui::accesskit::Role, label: &str) -> bool { +/// `tip` shows (one more node with that text). +fn tooltip_shows(h: &mut Harness<'static, PhotocraftApp>, role: egui::accesskit::Role, label: &str, tip: &str) -> bool { + let before = h.query_all_by_label(tip).count(); let at = h.get_by_role_and_label(role, label).rect().center(); h.hover_at(at); h.run_steps(8); - h.query_all_by_label(label).count() == 2 + h.query_all_by_label(tip).count() == before + 1 } -/// The scrubby "T" icon keeps its "Font size" tooltip: the size field's drag area, laid over the -/// icon after it, must not take its hover. +/// The scrubby "T" icon keeps its tooltip ("Set font size", #12057): the size field's drag area, +/// laid over the icon after it, must not take its hover. #[test] fn options_bar_t_icon_shows_its_tooltip() { let mut app = new_app(); app.run("type.create", json!({"text": "Hello", "size": 20, "x": 300, "y": 420})).unwrap(); let mut h = options_harness(app); - assert!(tooltip_shows(&mut h, egui::accesskit::Role::Button, "Set the text color"), "the probe sees an ordinary tooltip"); + assert!(tooltip_shows(&mut h, egui::accesskit::Role::Button, "Set the text color", "Set text color"), "the probe sees an ordinary tooltip"); h.hover_at(egui::pos2(890.0, 55.0)); h.run_steps(4); - assert!(tooltip_shows(&mut h, egui::accesskit::Role::Label, "Font size"), "hovering the \"T\" icon shows \"Font size\""); + assert!(tooltip_shows(&mut h, egui::accesskit::Role::Label, "Font size", "Set font size"), "hovering the \"T\" icon shows \"Set font size\""); } /// A point inside glyph `i` of vertical type: `f` of the way down its advance, right of the @@ -2728,10 +2729,10 @@ fn type_mask_commits_the_glyphs_as_the_selection_in_one_step() { assert!(v.height() > 2 * v.width(), "{v:?}"); } -/// #1055: Escape cancels a Type Mask session whatever "Use Escape to Commit" says: the selection -/// and the layers stay as they were. +/// #1055: Cancel throws a Type Mask session away: the selection and the layers stay as they were. +/// (Escape commits it, as in the reference application: #12070, `mask_escape_tests`.) #[test] -fn type_mask_escape_leaves_the_selection_unchanged() { +fn type_mask_cancel_leaves_the_selection_unchanged() { let mut app = new_app(); app.run("select.all", json!({})).unwrap(); let (before, layers) = (selection_bounds(&app), app.session.active().unwrap().doc.layers.len()); @@ -2741,7 +2742,7 @@ fn type_mask_escape_leaves_the_selection_unchanged() { click(&mut h, p); assert!(h.state().ui.text_edit.as_ref().is_some_and(|ed| ed.mask), "typing a mask"); super::insert(h.state_mut(), "I"); - h.key_press(egui::Key::Escape); + super::cancel(h.state_mut()); h.run_steps(2); assert!(h.state().ui.text_edit.is_none()); assert_eq!(selection_bounds(h.state()), before); @@ -3005,19 +3006,18 @@ fn multi_click_without_a_session_does_nothing() { assert_eq!((ed.anchor, ed.caret), (5, 5)); } -/// #3036: ⇧ on the click that commits a Type Mask adds the glyphs to the selection and ⌥ -/// subtracts them, as in Photoshop; while typing, the working text has no Layers panel row. +/// #3036: while typing, the working text has no Layers panel row. ⇧ or ⌥ on the click that +/// commits a Type Mask no longer combine: the glyphs replace the selection (#12073, +/// `mask_replace_selection_tests`). #[test] -fn type_mask_shift_adds_and_alt_subtracts_without_a_layer_row() { +fn type_mask_shift_and_alt_replace_without_a_layer_row() { use crate::canvas::{ToolEvent, tool_event}; use crate::state::Tool; - use photocraft_geom::Rect; let mut app = new_app(); type_with(&mut app, Tool::HorizontalTypeMask, "I"); let glyphs = selection_bounds(&app).unwrap(); let mut app = new_app(); app.ui.extras.snap = false; // the second text would snap to the first one's selected edge - let rect = Rect::new(1000, 100, 1100, 200); app.run("select.rect", json!({"x": 1000, "y": 100, "width": 100, "height": 100})).unwrap(); // Type, then commit with a click outside the text holding `m`. let type_and_commit = |app: &mut PhotocraftApp, m: Modifiers| { @@ -3032,14 +3032,10 @@ fn type_mask_shift_adds_and_alt_subtracts_without_a_layer_row() { tool_event(app, ToolEvent::Up { x: 1500.0, y: 800.0 }, m); assert!(app.ui.text_edit.is_none()); }; - 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)); - let stem = ((glyphs.x0 + glyphs.x1) / 2, (glyphs.y0 + glyphs.y1) / 2); type_and_commit(&mut app, Modifiers::SHIFT); - assert_eq!(selection_bounds(&app), Some(rect.union(&glyphs)), "⇧ adds"); - assert!(at(&app, stem.0, stem.1) > 0.99 && at(&app, 1050, 150) > 0.99); + assert_eq!(selection_bounds(&app), Some(glyphs), "⇧ replaces"); type_and_commit(&mut app, Modifiers::ALT); - assert!(at(&app, stem.0, stem.1) < 0.01, "⌥ subtracts the glyphs"); - assert!(at(&app, 1050, 150) > 0.99, "and keeps the rest"); + assert_eq!(selection_bounds(&app), Some(glyphs), "⌥ replaces"); } /// #7713: justification needs a paragraph box. On point text the Paragraph panel's four justify diff --git a/crates/ui-egui/src/tools/type_tool/editing.rs b/crates/ui-egui/src/tools/type_tool/editing.rs index f93826c1d19..02ba9913082 100644 --- a/crates/ui-egui/src/tools/type_tool/editing.rs +++ b/crates/ui-egui/src/tools/type_tool/editing.rs @@ -405,26 +405,26 @@ pub(crate) fn commit_shortcut(app: &mut PhotocraftApp, ctx: &egui::Context) -> b true } -/// [`commit`] with the modifiers held while committing: a Type Mask adds its glyphs to the -/// selection with ⇧, subtracts them with ⌥ and intersects with ⇧⌥ (Photoshop); with none it -/// replaces the selection. -pub fn commit_with(app: &mut PhotocraftApp, mods: egui::Modifiers) { +/// [`commit`] from a click, button or key that has modifiers held. They no longer change the +/// result, since a Type Mask always replaces the selection (#12073); the parameter stays so its +/// callers don't change. +pub fn commit_with(app: &mut PhotocraftApp, _mods: egui::Modifiers) { app.ui.type_insertion_style = None; crate::type_font_preview::clear(app); let current = app.session.active_index(); let Some(home) = app.ui.text_edit.as_ref().map(|ed| edit_home(app, ed)) else { - return commit_here(app, mods); + return commit_here(app); }; match home { Some(home) if Some(home) != current => { // An edit left in a background document commits there, in its own history (#6483). app.session.set_active(home); - commit_here(app, mods); + commit_here(app); if let Some(i) = current { app.session.set_active(i); } } - Some(_) => commit_here(app, mods), + Some(_) => commit_here(app), // Its document closed: there is nothing to commit into, and the layer id may name a // layer of another document. None => { @@ -445,7 +445,7 @@ pub(super) fn edit_home(app: &PhotocraftApp, ed: &TextEdit) -> Option { } /// [`commit_with`] in the active document, which is the edit's own. -fn commit_here(app: &mut PhotocraftApp, mods: egui::Modifiers) { +fn commit_here(app: &mut PhotocraftApp) { if app.ui.tool.is_type() { crate::type_transform::finish(app); } @@ -459,11 +459,11 @@ fn commit_here(app: &mut PhotocraftApp, mods: egui::Modifiers) { } let Some(text) = current_text(app, LayerId(ed.layer)) else { return }; if ed.mask { - // Type Mask: the text's glyphs become (or combine with) the selection and the working - // layer goes, all in the session's one history step. No text: nothing changes. - let operation = crate::tool_feedback::selection_mode(crate::state::Tool::HorizontalTypeMask, 0, mods); + // Type Mask: the text's glyphs replace the selection, with ⇧ or ⌥ held too, as in the + // reference application (#12073), and the working layer goes, all in the session's one + // history step. No text: nothing changes. if !text.trim().is_empty() - && let Err(e) = app.run("type.createSelection", json!({"layer": ed.layer, "removeLayer": true, "operation": operation, "coalesce": ed.session})) + && let Err(e) = app.run("type.createSelection", json!({"layer": ed.layer, "removeLayer": true, "operation": "new", "coalesce": ed.session})) { app.ui.status = e; app.ui.status_error = true; @@ -473,6 +473,34 @@ fn commit_here(app: &mut PhotocraftApp, mods: egui::Modifiers) { } return; } + // #12041: an Indexed Color or Bitmap document has no type layer to keep. The session's working + // layer is still edited and shown while typing, as the reference application shows it, and the + // commit makes what a Type Mask tool's commit makes: the glyphs become the selection and the + // layer goes, in the typing session's one history step - which keeps the "New Type Layer" name + // it was opened with, as Photoshop's state there is named. + // `photocraft_engine::type_cmds::commits_as_selection` holds the ticket's scope - the two modes + // and the measured horizontal point text - and why the check belongs to the commit and not to + // the `type.create` command. Three cases are not measured in these modes and keep today's + // behaviour: an edit of a layer the session did not create (`ed.created`), paragraph (box) and + // vertical text, whose editability a coverage mask could not give back, and a commit with no + // text in it, which is the empty-layer clean-up below. + let session_layer = LayerId(ed.layer); + let converts = app + .session + .active() + .is_some_and(|st| text_layer(&st.doc, session_layer).is_some_and(|t| photocraft_engine::type_cmds::commits_as_selection(st.doc.mode, t))); + let leaves_a_selection = ed.created && !text.trim().is_empty() && converts; + if leaves_a_selection { + if let Err(e) = app.run("type.createSelection", json!({"layer": ed.layer, "removeLayer": true, "operation": "new", "coalesce": ed.session})) { + // The refused step left the document alone: keep today's type layer, so what was typed + // is not lost with a selection that was never made. + app.ui.status = e; + app.ui.status_error = true; + return; + } + photocraft_engine::type_cmds::name_selection_commit(&mut app.session); + return; + } if text.trim().is_empty() && ed.created { let _ = app.run("layer.delete", json!({"layer": ed.layer})); } else if ed.created { @@ -526,3 +554,156 @@ fn undo_session(app: &mut PhotocraftApp, ed: &TextEdit) { let _ = app.run("edit.undo", json!({})); } } + +/// #12041: what the Type tool's commit does in a document whose colour mode has no type layers to +/// keep. The engine side - the mode check, the conversion it reuses and the step's name - is pinned +/// in `photocraft-engine`'s `cmd::type_indexed_mode_tests`; this is the tool's own commit, which is +/// where the difference is measured. +#[cfg(test)] +mod indexed_commit_tests { + use super::{cancel, commit, insert}; + use crate::PhotocraftApp; + use crate::state::Tool; + use crate::tools::type_tool::pointer_up; + use photocraft_doc::{ColorMode, LayerContent, LayerId, TextLayer}; + use serde_json::json; + + /// A new transparent document in `mode`: `rgb` keeps today's behaviour, `indexed` and `bitmap` + /// are the two the issue is about. + fn app(mode: &str) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 200, "background": "transparent"})).unwrap(); + match mode { + "indexed" => { + app.run("image.mode.indexedColor", json!({"palette": "exact", "dither": "none", "forced": "none"})).unwrap(); + assert_eq!(doc_mode(&app), ColorMode::Indexed, "the document is Indexed Color"); + } + "bitmap" => { + app.run("image.mode.grayscale", json!({})).unwrap(); + app.run("image.mode.bitmap", json!({"method": "threshold", "frequency": 12, "shape": "diamond"})).unwrap(); + assert_eq!(doc_mode(&app), ColorMode::Bitmap, "the document is Bitmap"); + } + _ => assert_eq!(doc_mode(&app), ColorMode::Rgb, "the document is RGB"), + } + app.sync_views(); + app.ui.tool = Tool::Type; + app + } + + fn doc_mode(app: &PhotocraftApp) -> ColorMode { + app.session.active().unwrap().doc.mode + } + + fn text_of(app: &PhotocraftApp, id: LayerId) -> Option { + let doc = app.session.active().unwrap().doc.clone(); + super::text_layer(&doc, id).cloned() + } + + fn text_layers(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).count() + } + + fn layers(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().doc.walk().len() + } + + fn states(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.entries().len() + } + + fn last_state(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().history.entries().last().cloned() + } + + fn selection(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().doc.selection.as_ref().map(|m| m.content_bounds()) + } + + /// The reference application, in an Indexed Color and in a Bitmap document: clicking and typing + /// works and the text is shown while editing, and the commit leaves no type layer but a + /// selection in the shape of the glyphs, as the one history state named "New Type Layer". + #[test] + fn committing_in_indexed_color_or_bitmap_leaves_the_glyphs_selected() { + // The bounds the same text covers where it does become a layer. + let mut rgb = app("rgb"); + pointer_up(&mut rgb, [80.0, 120.0], [80.0, 120.0]); + insert(&mut rgb, "Hi"); + commit(&mut rgb); + let glyphs = rgb.session.active().unwrap().doc.layers.last().unwrap().surface().unwrap().content_bounds(); + assert_eq!(text_layers(&rgb), 1, "an RGB document keeps its type layer"); + assert!(selection(&rgb).is_none(), "and selects nothing"); + assert_eq!(last_state(&rgb).as_deref(), Some("New Type Layer"), "as the session's one state"); + + for mode in ["indexed", "bitmap"] { + let mut app = app(mode); + let (layers_before, states_before) = (layers(&app), states(&app)); + + // Typing: the working layer is there, and shown, as measured. + pointer_up(&mut app, [80.0, 120.0], [80.0, 120.0]); + insert(&mut app, "Hi"); + assert!(app.ui.text_edit.is_some(), "{mode}: the session is open"); + assert_eq!(text_layers(&app), 1, "{mode}: the text is edited on a layer"); + + commit(&mut app); + assert!(app.ui.text_edit.is_none(), "{mode}: the commit ends the session"); + assert_eq!(text_layers(&app), 0, "{mode}: committing leaves no type layer"); + assert_eq!(layers(&app), layers_before, "{mode}: the working layer went with it"); + assert_eq!(selection(&app), Some(glyphs), "{mode}: the typed glyphs are the selection"); + assert_eq!(states(&app), states_before + 1, "{mode}: one state for the session"); + assert_eq!(last_state(&app).as_deref(), Some("New Type Layer"), "{mode}: and its name"); + + app.run("edit.undo", json!({})).unwrap(); + assert!(selection(&app).is_none(), "{mode}: one undo takes the selection"); + assert_eq!(layers(&app), layers_before, "{mode}: and the whole session"); + } + } + + /// Cancelling such a session is unchanged: no layer and no selection are left, as elsewhere. + #[test] + fn cancelling_in_indexed_color_or_bitmap_leaves_nothing() { + for mode in ["rgb", "indexed", "bitmap"] { + let mut app = app(mode); + let (layers_before, states_before) = (layers(&app), states(&app)); + pointer_up(&mut app, [80.0, 120.0], [80.0, 120.0]); + insert(&mut app, "Hi"); + cancel(&mut app); + assert!(app.ui.text_edit.is_none(), "{mode}: the session ended"); + assert_eq!(text_layers(&app), 0, "{mode}: no layer is left"); + assert_eq!(layers(&app), layers_before, "{mode}: no layer is added"); + assert!(selection(&app).is_none(), "{mode}: nothing is selected"); + assert_eq!(states(&app), states_before, "{mode}: and no history state"); + } + } + + /// #12041 measures the horizontal point text you click and type. Paragraph (box) text and + /// vertical type are on its *Not measured* list, whose rule is that they keep today's + /// behaviour - and today's behaviour is the right one here anyway: a coverage mask cannot bring + /// back a box that reflows or a column that runs down the page, so their editable layer stays. + #[test] + fn paragraph_and_vertical_text_keep_their_type_layer_in_indexed_and_bitmap() { + use photocraft_doc::text::{Orientation, TextShape}; + + for mode in ["indexed", "bitmap"] { + // A dragged paragraph box. + let mut boxed = app(mode); + pointer_up(&mut boxed, [40.0, 60.0], [320.0, 180.0]); + insert(&mut boxed, "Hi"); + let box_layer = LayerId(boxed.ui.text_edit.as_ref().unwrap().layer); + assert!(matches!(text_of(&boxed, box_layer).map(|t| t.shape), Some(TextShape::Box { .. })), "{mode}: the setup made paragraph text"); + commit(&mut boxed); + assert_eq!(text_layers(&boxed), 1, "{mode}: paragraph text keeps its editable layer"); + assert!(selection(&boxed).is_none(), "{mode}: and is not turned into a selection"); + + // Vertical point text. + let mut column_app = app(mode); + column_app.ui.tool = Tool::VerticalType; + pointer_up(&mut column_app, [80.0, 120.0], [80.0, 120.0]); + insert(&mut column_app, "Hi"); + let column = LayerId(column_app.ui.text_edit.as_ref().unwrap().layer); + assert!(text_of(&column_app, column).is_some_and(|t| t.orientation == Orientation::Vertical), "{mode}: the setup made vertical text"); + commit(&mut column_app); + assert_eq!(text_layers(&column_app), 1, "{mode}: vertical text keeps its editable layer"); + assert!(selection(&column_app).is_none(), "{mode}: and is not turned into a selection"); + } + } +} diff --git a/crates/ui-egui/src/tools/type_tool/fonts.rs b/crates/ui-egui/src/tools/type_tool/fonts.rs index 7c8162e3f43..022b978f8b3 100644 --- a/crates/ui-egui/src/tools/type_tool/fonts.rs +++ b/crates/ui-egui/src/tools/type_tool/fonts.rs @@ -338,6 +338,18 @@ pub(super) fn font_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui, current: & Some(style) } +/// The options bar's hover text on the [`font_picker`] just drawn in `ui`: its name field and its +/// arrow, by the ids [`family_picker_in`] gives them. Only the options bar calls it (#12057); the +/// panels sharing the picker keep their texts. +pub(super) fn font_picker_tip(ui: &egui::Ui, tip: &str) { + let field = ui.make_persistent_id(egui::IdSalt::new("type-font")); + for id in [field, field.with("arrow")] { + if let Some(r) = ui.ctx().read_response(id) { + r.on_hover_text(tip); + } + } +} + /// The font list's maximum height: it scrolls inside the menu, under the fixed preview and filters. pub(super) const FONT_LIST_HEIGHT: f32 = 440.0; /// The font list's width: star, style expander, name, format badge and sample. diff --git a/crates/ui-egui/src/tools/type_tool/keys.rs b/crates/ui-egui/src/tools/type_tool/keys.rs index 74c81c2f7de..0da9b40319a 100644 --- a/crates/ui-egui/src/tools/type_tool/keys.rs +++ b/crates/ui-egui/src/tools/type_tool/keys.rs @@ -255,7 +255,11 @@ fn handle_pressed_key( insert(app, "\t"); } Key::Escape if crate::type_transform::active(app) => crate::type_transform::cancel_drag(app), - Key::Escape if edit.edit.mask => cancel(app), + // A Type Mask commits on Escape as on ⌘↩, whatever "Use ESC to commit" says; the options bar's + // Cancel still throws it away (#12070). Escape on an empty mask text, or while an input + // method is composing, was not measured and still cancels. + Key::Escape if edit.edit.mask && (edit.text.is_empty() || edit.edit.preedit.is_some()) => cancel(app), + Key::Escape if edit.edit.mask => commit(app), Key::Escape if app.session.prefs().type_.use_esc_to_commit => commit(app), Key::Escape => cancel(app), Key::A if modifiers.command => { diff --git a/crates/ui-egui/src/tools/type_tool/mask_escape_tests.rs b/crates/ui-egui/src/tools/type_tool/mask_escape_tests.rs new file mode 100644 index 00000000000..30566900b0a --- /dev/null +++ b/crates/ui-egui/src/tools/type_tool/mask_escape_tests.rs @@ -0,0 +1,152 @@ +//! #12070: Escape commits a Type Mask session the way ⌘↩ does, whatever "Use Escape to Commit" +//! says: the text outline becomes the selection in one history state and no layer is added. An +//! empty mask text and a composing input method still cancel, and the Type tool's Escape still +//! follows the preference. (A ⌘ transform drag's Escape arm stays first; masks have no transform +//! box yet, #12082.) Keys go through the app's shortcut handling, as on the canvas. + +use egui_kittest::Harness; +use photocraft_doc::LayerId; +use photocraft_raster::Surface; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +fn new_app(tool: Tool, use_esc_to_commit: bool) -> 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("prefs.set", json!({"path": "type.useEscToCommit", "value": use_esc_to_commit})).unwrap(); + // Away from the text, so New, Add, Subtract and Intersect each leave a different selection. + app.run("select.rect", json!({"x": 300, "y": 150, "width": 60, "height": 30})).unwrap(); + app.sync_views(); + app.ui.tool = tool; + app +} + +/// Start new text with a click and type `s`, as the canvas does. +fn type_new(app: &mut PhotocraftApp, s: &str) -> LayerId { + super::pointer_up(app, [50.0, 100.0], [50.0, 100.0]); + super::insert(app, s); + LayerId(app.ui.text_edit.as_ref().unwrap().layer) +} + +/// One frame of the app's shortcut handling for each of `events`. +fn send(app: PhotocraftApp, events: Vec) -> PhotocraftApp { + let mut h = Harness::builder().build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + // As `canvas_tests`' harness: the shortcuts wait for the app's fonts. + if ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::shortcuts::handle(app, &ctx); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + for event in events { + h.event(event); + h.run_steps(1); + } + h.into_state() +} + +fn key(key: egui::Key, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } +} + +fn escape(app: PhotocraftApp) -> PhotocraftApp { + send(app, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]) +} + +/// The selection (compared pixel for pixel, shown by its bounds), the history (oldest first) and +/// the layer count. +#[derive(PartialEq)] +struct Snapshot { + selection: Option, + history: Vec, + layers: usize, +} + +impl std::fmt::Debug for Snapshot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let bounds = self.selection.as_ref().map(Surface::content_bounds); + write!(f, "selection {bounds:?}, history {:?}, {} layers", self.history, self.layers) + } +} + +fn snapshot(app: &PhotocraftApp) -> Snapshot { + let st = app.session.active().unwrap(); + Snapshot { selection: st.doc.selection.clone(), history: st.history.entries(), layers: st.doc.layers.len() } +} + +#[test] +fn escape_commits_a_type_mask_as_command_return_does() { + for tool in [Tool::HorizontalTypeMask, Tool::VerticalTypeMask] { + for use_esc_to_commit in [true, false] { + let what = format!("{tool:?}, useEscToCommit={use_esc_to_commit}"); + let session = || { + let mut app = new_app(tool, use_esc_to_commit); + let before = snapshot(&app); + type_new(&mut app, "AB"); + (app, before) + }; + let (app, before) = session(); + let escaped = escape(app); + let (app, _) = session(); + let returned = send(app, vec![key(egui::Key::Enter, egui::Modifiers::COMMAND)]); + assert!(escaped.ui.text_edit.is_none(), "{what}: the session ends"); + let after = snapshot(&escaped); + let bounds = after.selection.as_ref().map(Surface::content_bounds); + assert!(bounds.is_some_and(|r| r.x1 < 300), "{what}: the text outline alone is the selection: {bounds:?}"); + assert_eq!(after.history.len(), before.history.len() + 1, "{what}: one history state"); + assert_eq!(after.layers, before.layers, "{what}: no layer is added"); + assert_eq!(after, snapshot(&returned), "{what}: the same as ⌘↩"); + } + } +} + +#[test] +fn escape_with_an_empty_mask_text_changes_nothing() { + for tool in [Tool::HorizontalTypeMask, Tool::VerticalTypeMask] { + for use_esc_to_commit in [true, false] { + let what = format!("{tool:?}, useEscToCommit={use_esc_to_commit}"); + let mut app = new_app(tool, use_esc_to_commit); + let before = snapshot(&app); + type_new(&mut app, ""); + let app = escape(app); + assert!(app.ui.text_edit.is_none(), "{what}"); + assert_eq!(snapshot(&app), before, "{what}"); + } + } +} + +#[test] +fn escape_while_an_input_method_composes_still_cancels_a_type_mask() { + let preedit = || egui::Event::Ime(egui::ImeEvent::Preedit { text: "にほ".into(), active_range_chars: None }); + let session = || { + let mut app = new_app(Tool::HorizontalTypeMask, true); + let before = snapshot(&app); + type_new(&mut app, "AB"); + (app, before) + }; + let (app, _) = session(); + let app = send(app, vec![preedit()]); + assert!(app.ui.text_edit.as_ref().is_some_and(|ed| ed.preedit.is_some()), "composing"); + let (app, before) = session(); + let app = send(app, vec![preedit(), key(egui::Key::Escape, egui::Modifiers::NONE)]); + assert!(app.ui.text_edit.is_none()); + assert_eq!(snapshot(&app), before); +} + +#[test] +fn the_type_tools_escape_still_follows_the_preference() { + for (use_esc_to_commit, kept) in [(true, true), (false, false)] { + let mut app = new_app(Tool::Type, use_esc_to_commit); + let id = type_new(&mut app, "AB"); + let app = escape(app); + assert!(app.ui.text_edit.is_none()); + let layer = app.session.active().unwrap().doc.layer(id).is_some(); + assert_eq!(layer, kept, "useEscToCommit={use_esc_to_commit}: committed, or cancelled and the new layer gone"); + } +} diff --git a/crates/ui-egui/src/tools/type_tool/mask_replace_selection_tests.rs b/crates/ui-egui/src/tools/type_tool/mask_replace_selection_tests.rs new file mode 100644 index 00000000000..dba036ae85b --- /dev/null +++ b/crates/ui-egui/src/tools/type_tool/mask_replace_selection_tests.rs @@ -0,0 +1,135 @@ +//! #12073: committing a Type Mask always replaces the selection with the text outline, with ⇧, +//! ⌥ or ⇧⌥ held too: a click outside the text, the Commit button and ⌘↩ all go through +//! `commit_with`. `type.createSelection` keeps its `operation` param for scripts. + +use egui::Modifiers; +use egui_kittest::Harness; +use photocraft_raster::Surface; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +const COMBOS: [Modifiers; 4] = [Modifiers::NONE, Modifiers::SHIFT, Modifiers::ALT, Modifiers { shift: true, alt: true, ..Modifiers::NONE }]; + +/// A document with a selection away from the text, so Add, Subtract and Intersect would each +/// leave something other than the text outline. +fn new_app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 400})).unwrap(); + app.run("select.rect", json!({"x": 600, "y": 250, "width": 100, "height": 100})).unwrap(); + app.sync_views(); + app.ui.extras.snap = false; + app.ui.tool_options.type_size = 48.0; + app.ui.tool = Tool::HorizontalTypeMask; + app +} + +fn click(app: &mut PhotocraftApp, x: f64, y: f64, mods: Modifiers) { + tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Up { x, y }, mods); +} + +/// Start a mask with a click at `at` and type `s`. +fn mask(app: &mut PhotocraftApp, at: (f64, f64), s: &str) { + mask_with(app, at, s, Modifiers::NONE); +} + +/// [`mask`] started by a click holding `mods`. +fn mask_with(app: &mut PhotocraftApp, at: (f64, f64), s: &str, mods: Modifiers) { + click(app, at.0, at.1, mods); + assert!(app.ui.text_edit.as_ref().is_some_and(|ed| ed.mask), "typing a mask"); + super::insert(app, s); +} + +fn selection(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().doc.selection.clone() +} + +fn bounds(s: &Option) -> Option { + s.as_ref().map(Surface::content_bounds) +} + +/// The selection a plain commit of `s` at `at` leaves: the text outline alone. +fn outline(at: (f64, f64), s: &str) -> Option { + let mut app = new_app(); + mask(&mut app, at, s); + super::commit_with(&mut app, Modifiers::NONE); + let outline = selection(&app); + assert!(bounds(&outline).is_some_and(|r| r.x1 < 600), "the text alone: {:?}", bounds(&outline)); + outline +} + +#[test] +fn every_commit_replaces_the_selection_whatever_modifiers_are_held() { + let outline = outline((100.0, 200.0), "AB"); + for mods in COMBOS { + let mut app = new_app(); + mask(&mut app, (100.0, 200.0), "AB"); + super::commit_with(&mut app, mods); + assert!(app.ui.text_edit.is_none(), "{mods:?}"); + assert!(selection(&app) == outline, "{mods:?}: {:?}, not the outline {:?}", bounds(&selection(&app)), bounds(&outline)); + } +} + +#[test] +fn a_modified_click_outside_the_text_replaces_the_selection() { + let outline = outline((100.0, 200.0), "AB"); + for mods in COMBOS { + let mut app = new_app(); + mask(&mut app, (100.0, 200.0), "AB"); + click(&mut app, 450.0, 60.0, mods); + assert!(app.ui.text_edit.is_none(), "{mods:?}: the click commits"); + assert!(selection(&app) == outline, "{mods:?}: {:?}", bounds(&selection(&app))); + } +} + +#[test] +fn shift_command_return_replaces_the_selection() { + let outline = outline((100.0, 200.0), "AB"); + let mut app = new_app(); + mask(&mut app, (100.0, 200.0), "AB"); + let mut h = Harness::builder().build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + // As `canvas_tests`' harness: the shortcuts wait for the app's fonts. + if ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::shortcuts::handle(app, &ctx); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + h.event(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::SHIFT }); + h.run_steps(1); + let app = h.into_state(); + assert!(app.ui.text_edit.is_none()); + assert!(selection(&app) == outline, "{:?}", bounds(&selection(&app))); +} + +/// The reference application's "AB", then ⇧-clicks to commit it and to start "C", then "C" +/// committed: only "C" is selected. (Here a click outside the text commits without starting new +/// text, so that is two ⇧-clicks.) +#[test] +fn the_next_mask_after_a_shift_click_commit_replaces_the_first() { + let second = outline((300.0, 200.0), "C"); + let mut app = new_app(); + mask(&mut app, (100.0, 200.0), "AB"); + click(&mut app, 450.0, 60.0, Modifiers::SHIFT); + assert!(app.ui.text_edit.is_none(), "the ⇧-click commits"); + mask_with(&mut app, (300.0, 200.0), "C", Modifiers::SHIFT); + click(&mut app, 450.0, 60.0, Modifiers::SHIFT); + assert!(selection(&app) == second, "{:?}, not only \"C\" {:?}", bounds(&selection(&app)), bounds(&second)); +} + +#[test] +fn type_create_selection_still_combines_for_scripts() { + let mut app = new_app(); + let rect = bounds(&selection(&app)).unwrap(); + app.run("type.createSelection", json!({"text": "AB", "x": 100, "y": 200, "size": 48, "operation": "add"})).unwrap(); + let added = bounds(&selection(&app)).unwrap(); + assert_eq!(added.union(&rect), added, "the rectangle is kept"); + assert!(added.x0 < rect.x0, "and the text is added"); +} diff --git a/crates/ui-egui/src/tools/type_tool/options_bar.rs b/crates/ui-egui/src/tools/type_tool/options_bar.rs index 33287d12347..d533e10cd0b 100644 --- a/crates/ui-egui/src/tools/type_tool/options_bar.rs +++ b/crates/ui-egui/src/tools/type_tool/options_bar.rs @@ -1,6 +1,6 @@ use super::defaults::{apply, formatting_params, open_color_picker, shown_scale, target, target_doc, type_unit}; use super::editing::{cancel, commit_with}; -use super::fonts::{font_picker, fonts_ready, selected_style, style_label, styles}; +use super::fonts::{font_picker, font_picker_tip, fonts_ready, selected_style, style_label, styles}; use super::keys::is_vertical; use super::{Color32, LayerId, PhotocraftApp, Stroke, byte_of, insertion_style, json, selected_styles, text_layer}; @@ -43,6 +43,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { None => apply(app, ui.ctx(), json!({"font": fam})), } } + font_picker_tip(ui, tl!("Search and select fonts")); let style_family = if fam.is_empty() { styles_at(app).map(|(c, _)| c.font_family).unwrap_or_default() } else { fam.clone() }; let opts: Vec<(String, String)> = styles(&style_family).into_iter().map(|s| (s.clone(), style_label(&s))).collect(); let opts_ref: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect(); @@ -50,15 +51,19 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.ui.tool_options.type_style = style.clone(); apply(app, ui.ctx(), json!({"fontStyle": style})); } + // The menu's button, by the id its combo box takes from "type-style"; "Style" stays its name. + if let Some(menu) = ui.ctx().read_response(ui.make_persistent_id(egui::IdSalt::new("type-style"))) { + menu.on_hover_text(tl!("Set font style")); + } } let (r, icon) = ui.allocate_exact_size(egui::vec2(18.0, 22.0), egui::Sense::hover()); crate::icons::paint(ui, r, "type", 13.0, t.icon); icon.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tl!("Font size"))); // Photoshop's "T" scrubs the size field beside it, like the Character panel's icons (#6437). - crate::widgets::scrub_option_icon(ui, &icon, tl!("Font size")); + crate::widgets::scrub_option_icon(ui, &icon, tl!("Set font size")); let (unit, label) = type_unit(app); let mut display = size * unit; - if crate::widgets::value_field(ui, &mut display, unit..=1296.0 * unit, label, 66.0).changed() { + if crate::widgets::value_field(ui, &mut display, unit..=1296.0 * unit, label, 66.0).on_hover_text(tl!("Set font size")).changed() { let size = display / unit; app.ui.tool_options.type_size = size; apply(app, ui.ctx(), json!({"size": size, "metricsAsShown": true})); @@ -95,9 +100,24 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }, None => o.type_align.as_str(), }; - for (align, icon, tip) in + // Vertical text shows Top / Center / Bottom for the same left / center / right values, after the + // targeted layer's orientation, or with no text targeted the Vertical Type tool's (#12059). The + // Type Mask tools keep the horizontal buttons (not measured). + let vertical = !app.ui.tool.is_type_mask() + && match super::defaults::target_text(app) { + Some(t) => t.orientation == photocraft_doc::text::Orientation::Vertical, + None => app.ui.tool == crate::state::Tool::VerticalType, + }; + let buttons = if vertical { + [ + ("left", "vertical-align-top", tl!("Top align text")), + ("center", "vertical-align-middle", tl!("Center text")), + ("right", "vertical-align-bottom", tl!("Bottom align text")), + ] + } else { [("left", "align-left", tl!("Left align text")), ("center", "align-center", tl!("Center text")), ("right", "align-right", tl!("Right align text"))] - { + }; + for (align, icon, tip) in buttons { if crate::icons::button(ui, icon, 24.0, shown_align == align, tip).clicked() { app.ui.tool_options.type_align = align.into(); apply(app, ui.ctx(), json!({"align": align})); @@ -128,7 +148,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(q(c[0]), q(c[1]), q(c[2]))); ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside); - let resp = resp.on_hover_text(tl!("Set the text color")); + let resp = resp.on_hover_text(tl!("Set text color")); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Set the text color"))); if resp.clicked() { open_color_picker(app, [c[0], c[1], c[2]]); diff --git a/crates/ui-egui/src/tools/type_tool/options_tooltips_tests.rs b/crates/ui-egui/src/tools/type_tool/options_tooltips_tests.rs new file mode 100644 index 00000000000..a41a0e2f2c5 --- /dev/null +++ b/crates/ui-egui/src/tools/type_tool/options_tooltips_tests.rs @@ -0,0 +1,121 @@ +//! #12057: the Type options bar's font family menu, font style menu, font size icon and field, +//! and text colour swatch carry the reference application's tooltips (also their accessible +//! descriptions). The controls keep their names, and the Character panel's font menu, which +//! shares the family picker, keeps its text. + +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; + +const FAMILY_TIP: &str = "Search and select fonts"; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("type.create", json!({"text": "Sample", "font": "Inter", "size": 18})).unwrap(); + app +} + +/// `draw` in a row, once the app's fonts are set up (the first pass has none). +fn harness(draw: fn(&mut PhotocraftApp, &mut egui::Ui)) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1400.0, 600.0)).build_ui_state( + move |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.horizontal(|ui| draw(app, ui)); + } + }, + app(), + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h +} + +fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + super::options_bar::options_bar(app, ui); +} + +fn character_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + ui.vertical(|ui| super::panel::character_panel(app, ui, false)); +} + +fn description(n: egui_kittest::Node<'_>) -> Option { + n.accesskit_node().description().map(|d| d.to_string()) +} + +fn described_by_label(h: &Harness<'static, PhotocraftApp>, label: &str) -> Vec { + h.query_all_by_label(label).filter_map(description).collect() +} + +fn described_by_role(h: &Harness<'static, PhotocraftApp>, role: Role) -> Vec { + h.query_all_by_role(role).filter_map(description).collect() +} + +/// Every text shape drawn this frame, tooltips included. +fn drawn(h: &Harness<'static, PhotocraftApp>) -> Vec { + fn collect(shape: &egui::Shape, text: &mut Vec) { + match shape { + egui::Shape::Text(shape) => text.push(shape.galley.job.text.clone()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)), + _ => {} + } + } + let mut text = Vec::new(); + for shape in &h.output().shapes { + collect(&shape.shape, &mut text); + } + text +} + +#[test] +fn the_font_family_menu_has_the_reference_tooltip() { + let h = harness(options_bar); + // The name field (the bar's only text input) and its arrow, which keeps the name "Font". + assert_eq!(described_by_role(&h, Role::TextInput), [FAMILY_TIP]); + assert_eq!(described_by_label(&h, "Font"), [FAMILY_TIP]); +} + +#[test] +fn the_font_style_menu_has_the_reference_tooltip_and_keeps_its_name() { + let h = harness(options_bar); + assert_eq!(described_by_label(&h, "Style"), ["Set font style"]); +} + +#[test] +fn the_font_size_field_has_the_reference_tooltip() { + let h = harness(options_bar); + // The bar's only number field. + assert_eq!(described_by_role(&h, Role::SpinButton), ["Set font size"]); +} + +#[test] +fn the_font_size_icon_shows_the_reference_tooltip_and_keeps_its_name() { + let mut h = harness(options_bar); + // The icon itself: the size field beside it is labelled by it, so it is named "Font size" too. + h.get_by_role_and_label(Role::Label, "Font size").hover(); + h.run_steps(8); + let text = drawn(&h); + assert!(text.iter().any(|t| t == "Set font size"), "{text:?}"); + assert!(!text.iter().any(|t| t == "Font size"), "old tooltip: {text:?}"); +} + +#[test] +fn the_text_color_swatch_has_the_reference_tooltip_and_keeps_its_name() { + let h = harness(options_bar); + let found = described_by_label(&h, "Set the text color"); + assert_eq!(found, ["Set text color"]); +} + +/// A guard, so it passes without the fix too: it fails if the tooltip moves into the picker that +/// the panels share. +#[test] +fn the_character_panel_font_menu_keeps_its_text() { + let h = harness(character_panel); + assert!(h.query_by_label("Font").is_some(), "the panel draws its font menu"); + let found: Vec = described_by_label(&h, "Font").into_iter().chain(described_by_role(&h, Role::TextInput)).collect(); + assert!(!found.iter().any(|d| d == FAMILY_TIP), "{found:?}"); +} diff --git a/crates/ui-egui/src/tools/type_tool/pointer.rs b/crates/ui-egui/src/tools/type_tool/pointer.rs index 19bab778a43..68df59059f1 100644 --- a/crates/ui-egui/src/tools/type_tool/pointer.rs +++ b/crates/ui-egui/src/tools/type_tool/pointer.rs @@ -140,7 +140,7 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie } return true; } - // ⇧/⌥ on that click combine a Type Mask with the selection. + // A Type Mask replaces the selection with ⇧ or ⌥ on that click too (#12073). commit_with(app, mods); return true; // Photoshop: a click outside commits without starting new text } diff --git a/crates/ui-egui/src/tools/type_tool/vertical_align_buttons_tests.rs b/crates/ui-egui/src/tools/type_tool/vertical_align_buttons_tests.rs new file mode 100644 index 00000000000..9d3145e9ef8 --- /dev/null +++ b/crates/ui-egui/src/tools/type_tool/vertical_align_buttons_tests.rs @@ -0,0 +1,178 @@ +//! #12059: for vertical text the options bar's alignment buttons are Top / Center / Bottom, after +//! the targeted text layer's orientation, or with no text targeted the Vertical Type tool's. They +//! still set left / center / right, and the lit one follows the targeted paragraph. The Type Mask +//! tools keep the horizontal buttons (not measured). + +use egui::accesskit::{Role, Toggled}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use photocraft_doc::{LayerContent, LayerId, text::TextAlign}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +const HORIZONTAL: [&str; 3] = ["Left align text", "Center text", "Right align text"]; +const VERTICAL: [&str; 3] = ["Top align text", "Center text", "Bottom align text"]; + +fn new_app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 800, "height": 400})).unwrap(); + app.sync_views(); + app.ui.tool = tool; + app +} + +/// A text layer with `orientation`, made the active layer (selected, not edited). +fn text_layer(app: &mut PhotocraftApp, orientation: &str) -> LayerId { + let made = app.run("type.create", json!({"text": "AB CD", "x": 400, "y": 100, "orientation": orientation})).unwrap(); + LayerId(made["layer"].as_u64().unwrap()) +} + +fn bar(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + ui.horizontal(|ui| super::options_bar(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + h +} + +/// Which of the two button sets the bar shows; panics on a mix or neither. +fn shown(h: &Harness<'static, PhotocraftApp>) -> [&'static str; 3] { + let has = |label: &str| h.query_by_role_and_label(Role::Button, label).is_some(); + match (has("Top align text") && has("Bottom align text"), has("Left align text") && has("Right align text")) { + (true, false) => VERTICAL, + (false, true) => HORIZONTAL, + (true, true) => panic!("both the vertical and the horizontal buttons"), + (false, false) => panic!("no alignment buttons"), + } +} + +fn lit(h: &Harness<'static, PhotocraftApp>, label: &str) -> bool { + h.get_by_role_and_label(Role::Button, label).accesskit_node().toggled() == Some(Toggled::True) +} + +fn align_of(app: &PhotocraftApp, id: LayerId) -> TextAlign { + match &app.session.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Text(t) => t.paragraphs[0].style.align, + other => panic!("{}", other.kind_name()), + } +} + +#[test] +fn with_no_text_targeted_the_tool_decides() { + for (tool, buttons) in [(Tool::VerticalType, VERTICAL), (Tool::Type, HORIZONTAL)] { + let h = bar(new_app(tool)); + assert_eq!(shown(&h), buttons, "{tool:?}"); + assert!(lit(&h, buttons[0]), "{tool:?}: the default, left, lights the first button"); + } +} + +#[test] +fn a_selected_text_layer_decides_whatever_the_tool() { + for tool in [Tool::Type, Tool::VerticalType] { + for (orientation, buttons) in [("vertical", VERTICAL), ("horizontal", HORIZONTAL)] { + let mut app = new_app(tool); + text_layer(&mut app, orientation); + assert_eq!(shown(&bar(app)), buttons, "{tool:?}, {orientation} layer selected"); + } + } +} + +#[test] +fn text_being_edited_decides() { + for (orientation, buttons) in [("vertical", VERTICAL), ("horizontal", HORIZONTAL)] { + let mut app = new_app(Tool::Type); + text_layer(&mut app, orientation); + super::edit_active(&mut app).unwrap(); + assert!(app.ui.text_edit.is_some()); + assert_eq!(shown(&bar(app)), buttons, "editing {orientation} text"); + } +} + +#[test] +fn the_vertical_buttons_set_left_center_and_right_and_follow_the_paragraph() { + for (label, align, value) in + [("Bottom align text", TextAlign::Right, "right"), ("Center text", TextAlign::Center, "center"), ("Top align text", TextAlign::Left, "left")] + { + let mut app = new_app(Tool::VerticalType); + let id = text_layer(&mut app, "vertical"); + let mut h = bar(app); + h.get_by_role_and_label(Role::Button, label).click(); + h.run_steps(2); + assert_eq!(align_of(h.state(), id), align, "{label}"); + assert_eq!(h.state().ui.tool_options.type_align, value, "{label}: the tool default too"); + for other in VERTICAL { + assert_eq!(lit(&h, other), other == label, "{label} clicked: {other}"); + } + } +} + +#[test] +fn the_type_mask_tools_keep_the_horizontal_buttons() { + for tool in [Tool::VerticalTypeMask, Tool::HorizontalTypeMask] { + assert_eq!(shown(&bar(new_app(tool))), HORIZONTAL, "{tool:?}, idle"); + let mut app = new_app(tool); + super::pointer_up(&mut app, [400.0, 100.0], [400.0, 100.0]); + assert!(app.ui.text_edit.as_ref().is_some_and(|ed| ed.mask), "{tool:?}: typing a mask"); + assert_eq!(shown(&bar(app)), HORIZONTAL, "{tool:?}, editing"); + } +} + +/// Clicking Toggle text orientation switches the buttons in the same frame (the toggle is drawn +/// before them). +#[test] +fn toggling_the_orientation_switches_the_buttons() { + let mut app = new_app(Tool::Type); + text_layer(&mut app, "horizontal"); + let mut h = bar(app); + assert_eq!(shown(&h), HORIZONTAL); + h.get_by_role_and_label(Role::Button, "Toggle text orientation").click(); + h.run_steps(1); + assert_eq!(shown(&h), VERTICAL, "now vertical"); +} + +/// A text edit left in a background document decides by its own layer, not by a same-id layer of +/// the active document (layer ids repeat across documents; as `styles_at`, #9179). +#[test] +fn a_background_text_edit_decides_by_its_own_document() { + let mut app = new_app(Tool::Type); + let a = app.session.active().unwrap().doc.id.0; + let id = text_layer(&mut app, "vertical"); + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id.0, + document: Some(a), + caret: 0, + anchor: 0, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + // The same file opened twice, its copy of the layer turned horizontal. + let twin = app.session.active().unwrap().doc.as_ref().clone(); + app.session.open_document(twin, None); + app.run("type.orientation.horizontal", json!({"layer": id.0})).unwrap(); + assert_ne!(app.session.active().unwrap().doc.id.0, a, "the other document is active"); + assert_eq!(shown(&bar(app)), VERTICAL, "the edited layer is vertical"); +} + +/// The lit button follows the targeted paragraph, not the tool default. +#[test] +fn the_lit_button_follows_the_paragraph_not_the_tool_default() { + let mut app = new_app(Tool::VerticalType); + app.ui.tool_options.type_align = "center".into(); + let made = app.run("type.create", json!({"text": "AB CD", "x": 400, "y": 100, "orientation": "vertical", "align": "right"})).unwrap(); + let id = LayerId(made["layer"].as_u64().unwrap()); + assert_eq!(align_of(&app, id), TextAlign::Right); + let h = bar(app); + for label in VERTICAL { + assert_eq!(lit(&h, label), label == "Bottom align text", "{label}"); + } +} diff --git a/crates/ui-egui/src/tools/vector_ui.rs b/crates/ui-egui/src/tools/vector_ui.rs index d5a0ada77a5..e8fe67f8bd0 100644 --- a/crates/ui-egui/src/tools/vector_ui.rs +++ b/crates/ui-egui/src/tools/vector_ui.rs @@ -56,7 +56,8 @@ pub(crate) use pen::over_first_anchor; use pen::pen_to_json; pub(crate) use pen::{PATH_OPS, pen_path, pen_replace}; pub use pen::{ - cancel_stale, closes_path_at, freeform_finish, pen_break_handle, pen_commit, pen_down, pen_end, pen_grab_handle, pen_move, pen_undo_last_point, pen_up, + cancel_stale, closes_path_at, continues_path_at, freeform_finish, pen_break_handle, pen_commit, pen_down, pen_end, pen_grab_handle, pen_move, + pen_undo_last_point, pen_up, }; #[cfg(test)] use pen::{closing, pen_path_op}; @@ -201,6 +202,9 @@ impl PenPath { #[cfg(test)] mod tests; +#[cfg(test)] +mod path_drag_preview_tests; + #[cfg(test)] mod path_selection_tests; diff --git a/crates/ui-egui/src/tools/vector_ui/overlay.rs b/crates/ui-egui/src/tools/vector_ui/overlay.rs index 1e50b63b472..e2e8a241c79 100644 --- a/crates/ui-egui/src/tools/vector_ui/overlay.rs +++ b/crates/ui-egui/src/tools/vector_ui/overlay.rs @@ -1,5 +1,5 @@ use super::curvature::curvature_refresh; -use super::path_ops::{active_type_path, targeted_vector_mask, work_path_shown_in}; +use super::path_ops::{MovedPath, active_type_path, path_selection_preview, targeted_vector_mask, work_path_shown_in}; use super::pen::{closing, over_first_anchor, shift_45}; use egui::{Color32, Pos2, Rect, Stroke, pos2, vec2}; use photocraft_doc::vector::Path; @@ -157,14 +157,17 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // a path tool is active, as in Photoshop: a Work Path left unselected (one a PSD arrived // with, or after Ctrl-click Load as Selection) would otherwise cover the marching ants (#6551). let anchors = shows_anchors(tool); + // A Path Selection drag shows its path where the release will put it. + let moving = path_selection_preview(app, doc); + let moved = |which: MovedPath| moving.as_ref().filter(|(m, _)| *m == which).map(|(_, p)| p); if let Some(wp) = doc.work_path.as_ref().filter(|_| work_path_shown_in(app, doc)) && (anchors || app.ui.selected_path.as_deref() == Some("work")) { - draw_path(wp, anchors); + draw_path(moved(MovedPath::Work).unwrap_or(wp), anchors); } // The saved path selected in the Paths panel is the target path (#6550). if let Some(p) = selected_saved_path(app) { - draw_path(p, anchors); + draw_path(moved(MovedPath::Saved).unwrap_or(p), anchors); } let st = app.session.active(); if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))) @@ -188,8 +191,14 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // Path Selection's selected components show solid anchors, as in Photoshop. if tool == Tool::PathSelection && st.is_some_and(|s| s.doc.id == doc.id) - && let Some((_, path, _)) = crate::path_components::selected(app) + && let Some((r, path, _)) = crate::path_components::selected(app) { + let path = match r { + crate::direct_select::PathRef::Work => moved(MovedPath::Work), + crate::direct_select::PathRef::Saved(_) => moved(MovedPath::Saved), + _ => None, + } + .unwrap_or(&path); for k in app.ui.direct_selection.anchors.iter().filter_map(|[s, k]| path.subpaths.get(*s).and_then(|sp| sp.knots.get(*k))) { draw_anchor(painter, to_scr([k.anchor.x, k.anchor.y]), true, accent); } diff --git a/crates/ui-egui/src/tools/vector_ui/path_drag_preview_tests.rs b/crates/ui-egui/src/tools/vector_ui/path_drag_preview_tests.rs new file mode 100644 index 00000000000..436775f6de0 --- /dev/null +++ b/crates/ui-egui/src/tools/vector_ui/path_drag_preview_tests.rs @@ -0,0 +1,167 @@ +//! #9306: a Path Selection drag shows its path following the pointer, where the release will put +//! it, instead of leaving it in place until the release. The document only changes on release. + +use egui::{Modifiers, Pos2}; +use photocraft_doc::vector::Path; +use photocraft_geom::{Affine, Point}; +use serde_json::json; + +use super::path_ops::{MovedPath, path_selection_preview}; +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, ViewXform, tool_event}; +use crate::state::Tool; + +const NAME: &str = "Path 1"; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::PathSelection; + app.ui.extras.snap = false; + app +} + +fn square(x: f64, y: f64, w: f64) -> serde_json::Value { + json!({"closed": true, "knots": [[x, y], [x + w, y], [x + w, y + w], [x, y + w]]}) +} + +fn work(app: &PhotocraftApp) -> Path { + app.session.active().unwrap().doc.work_path.clone().unwrap() +} + +fn revision(app: &PhotocraftApp) -> u64 { + app.session.active().unwrap().revision +} + +fn preview(app: &PhotocraftApp) -> Option<(MovedPath, Path)> { + path_selection_preview(app, &app.session.active().unwrap().doc) +} + +fn press_and_move(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) { + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, Modifiers::NONE); + assert!(app.drag.is_some()); +} + +fn release(app: &mut PhotocraftApp, at: [f64; 2]) { + tool_event(app, ToolEvent::Up { x: at[0], y: at[1] }, Modifiers::NONE); +} + +fn anchors(p: &Path) -> Vec { + p.subpaths.iter().flat_map(|s| s.knots.iter().map(|k| k.anchor)).collect() +} + +/// The centres of the anchor squares the vector overlay draws, document = screen pixels. +fn drawn_anchors(app: &PhotocraftApp) -> Vec { + let xf = ViewXform { + rect: egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(200., 200.)), + zoom: 1.0, + center: [100., 100.], + flip: false, + rotation: 0., + aspect: 1., + }; + let doc = app.session.active().unwrap().doc.clone(); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(Default::default(), |ui| super::draw_overlay(app, ui.painter(), &xf, &doc)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Rect(r) if (r.rect.width() - 6.0).abs() < 0.01 => Some(r.rect.center()), + _ => None, + }) + .collect() +} + +fn near(drawn: &[Pos2], p: Point) -> bool { + drawn.iter().any(|q| (f64::from(q.x) - p.x).abs() < 0.01 && (f64::from(q.y) - p.y).abs() < 0.01) +} + +#[test] +fn a_work_path_drag_shows_the_path_where_the_release_puts_it() { + let mut app = app(); + app.run("path.set", json!({"path": {"subpaths": [square(20.0, 20.0, 40.0)]}})).unwrap(); + let before = work(&app); + let rev = revision(&app); + press_and_move(&mut app, [20.0, 40.0], [30.4, 45.6]); + assert_eq!(work(&app), before, "the drag itself leaves the document alone"); + assert_eq!(revision(&app), rev); + let (which, shown) = preview(&app).expect("the drag previews the work path"); + assert_eq!(which, MovedPath::Work); + // The same whole-pixel offset the release moves by. + assert_eq!(shown, before.transform(&Affine::translate(10.0, 6.0))); + let drawn = drawn_anchors(&app); + assert!(anchors(&shown).iter().all(|p| near(&drawn, *p)), "anchors drawn at the moved path: {drawn:?}"); + assert!(!near(&drawn, Point::new(60.0, 20.0)), "not at the path's old corner: {drawn:?}"); + release(&mut app, [30.4, 45.6]); + assert_eq!(work(&app), shown, "the release lands where the preview showed"); + assert!(preview(&app).is_none()); +} + +#[test] +fn a_click_shows_no_move() { + let mut app = app(); + app.run("path.set", json!({"path": {"subpaths": [square(20.0, 20.0, 40.0)]}})).unwrap(); + press_and_move(&mut app, [20.0, 40.0], [20.2, 40.1]); + assert!(preview(&app).is_none()); +} + +#[test] +fn only_the_selected_component_moves_in_the_preview() { + let mut app = app(); + app.run("path.set", json!({"path": {"subpaths": [square(20.0, 20.0, 40.0), square(120.0, 120.0, 40.0)]}})).unwrap(); + let before = work(&app); + // Pressing the first square's edge selects that component only. + press_and_move(&mut app, [20.0, 40.0], [25.0, 47.0]); + let (which, shown) = preview(&app).expect("the drag previews the component move"); + assert_eq!(which, MovedPath::Work); + let moved = anchors(&shown); + let old = anchors(&before); + assert_eq!(&moved[..4], &old[..4].iter().map(|p| Point::new(p.x + 5.0, p.y + 7.0)).collect::>()[..]); + assert_eq!(&moved[4..], &old[4..], "the other component stays"); + let drawn = drawn_anchors(&app); + assert!(near(&drawn, Point::new(25.0, 27.0)) && near(&drawn, Point::new(120.0, 120.0)), "{drawn:?}"); + release(&mut app, [25.0, 47.0]); + assert_eq!(work(&app), shown); +} + +#[test] +fn the_selected_saved_path_previews_too() { + let mut app = app(); + app.run("path.set", json!({"name": NAME, "path": {"subpaths": [square(40.0, 40.0, 80.0)]}})).unwrap(); + app.ui.selected_path = Some(NAME.into()); + let saved = |app: &PhotocraftApp| app.session.active().unwrap().doc.paths.iter().find(|p| p.name == NAME).unwrap().path.clone(); + let before = saved(&app); + press_and_move(&mut app, [40.0, 80.0], [52.0, 71.0]); + let (which, shown) = preview(&app).expect("the drag previews the saved path"); + assert_eq!(which, MovedPath::Saved); + assert_eq!(saved(&app), before); + release(&mut app, [52.0, 71.0]); + assert_eq!(saved(&app), shown); +} + +#[test] +fn a_shape_layer_drag_previews_no_outline() { + // Its pixels move only on release, so its outline does not run ahead of them. + let mut app = app(); + app.run("shape.create", json!({"kind": "rect", "rect": [20, 20, 60, 60], "fill": "#ff0000"})).unwrap(); + press_and_move(&mut app, [40.0, 40.0], [50.0, 50.0]); + assert!(preview(&app).is_none()); + release(&mut app, [50.0, 50.0]); + let st = app.session.active().unwrap(); + let photocraft_doc::LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("not a shape layer") }; + assert_eq!(sh.path.control_bounds().map(|b| [b.0, b.1]), Some([30.0, 30.0]), "the release still moves it"); +} + +#[test] +fn a_stale_drag_previews_nothing() { + let mut app = app(); + app.run("path.set", json!({"path": {"subpaths": [square(20.0, 20.0, 40.0)]}})).unwrap(); + press_and_move(&mut app, [20.0, 40.0], [30.0, 50.0]); + assert!(preview(&app).is_some()); + // The document changed under the drag, so its release moves nothing either. + app.session.execute("path.set", json!({"name": NAME, "path": {"subpaths": [square(0.0, 0.0, 10.0)]}})).unwrap(); + assert!(preview(&app).is_none()); +} diff --git a/crates/ui-egui/src/tools/vector_ui/path_ops.rs b/crates/ui-egui/src/tools/vector_ui/path_ops.rs index 2d23f6d3a4b..1815ad43fbf 100644 --- a/crates/ui-egui/src/tools/vector_ui/path_ops.rs +++ b/crates/ui-egui/src/tools/vector_ui/path_ops.rs @@ -343,16 +343,61 @@ fn near_outline(path: &Path, at: [f64; 2], tol: f64) -> bool { !(at[0] < x0 - pad || at[0] > x1 + pad || at[1] < y0 - pad || at[1] > y1 + pad) } -pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { +/// How far a Path Selection drag from `start` to `end` moves its path: whole pixels. `None` for +/// a click (under half a pixel of travel), which picks a shape layer instead. +fn path_selection_offset(start: [f64; 2], end: [f64; 2]) -> Option<[f64; 2]> { let (dx, dy) = (end[0] - start[0], end[1] - start[1]); - let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); if dx.abs() + dy.abs() < 0.5 { + return None; + } + Some([dx.round(), dy.round()]) +} + +/// The path a Path Selection drag in progress shows moved. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum MovedPath { + Work, + /// The saved path selected in the Paths panel. + Saved, +} + +/// The work path or selected saved path of a Path Selection drag in progress, moved where its +/// release puts it ([`path_selection_finish`]: the same offset, only the selected components when +/// some but not all are), so the outline follows the pointer instead of jumping on release. +/// `None` without a live drag on `doc`, for a click, and for a shape layer's or vector mask's +/// path, whose pixels move only on release. +pub(crate) fn path_selection_preview(app: &PhotocraftApp, doc: &Document) -> Option<(MovedPath, Path)> { + let d = app.drag.as_ref().filter(|d| d.tool == Tool::PathSelection && d.vector_owner_valid(app))?; + if app.session.active().is_none_or(|st| st.doc.id != doc.id) { + return None; + } + let end = d.points.last().map_or(d.start, |p| [p[0], p[1]]); + let [dx, dy] = path_selection_offset(d.start, end).filter(|o| o.iter().all(|v| v.is_finite()))?; + if let Some((r, path)) = crate::path_components::moved_selected(app, [dx, dy]) { + return match r { + crate::direct_select::PathRef::Work => Some((MovedPath::Work, path)), + crate::direct_select::PathRef::Saved(_) => Some((MovedPath::Saved, path)), + _ => None, + }; + } + let (target, path) = target_path(app)?; + let moved = match target { + PathTarget::Work => MovedPath::Work, + PathTarget::Saved(_) => MovedPath::Saved, + PathTarget::Shape(_) | PathTarget::VectorMask(_) => return None, + }; + Some((moved, path.transform(&Affine::translate(dx, dy)))) +} + +pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { + let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); + let Some([dx, dy]) = path_selection_offset(start, end) else { let Some(st) = app.session.active() else { return }; if let Some(id) = pick_shape_at(&st.doc, start, tol) { let _ = app.run("layer.select", json!({"layer": id.0})); } return; - } + }; if target_path(app).is_none() { let Some(st) = app.session.active() else { return }; if let Some(id) = pick_shape_at(&st.doc, start, tol) { @@ -360,7 +405,7 @@ pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64 } } // Selected components move on their own (`path_components`); with none, the whole path. - if let Some(r) = crate::path_components::move_selected(app, [dx.round(), dy.round()]) { + if let Some(r) = crate::path_components::move_selected(app, [dx, dy]) { if let Err(e) = r { app.ui.status = e; app.ui.status_error = true; @@ -369,17 +414,17 @@ pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64 } let Some((target, path)) = target_path(app) else { return }; let _ = match target { - PathTarget::Shape(id) => app.run("shape.edit", json!({"layer": id, "move": [dx.round(), dy.round()]})), + PathTarget::Shape(id) => app.run("shape.edit", json!({"layer": id, "move": [dx, dy]})), PathTarget::VectorMask(id) => { - let moved = path.transform(&Affine::translate(dx.round(), dy.round())); + let moved = path.transform(&Affine::translate(dx, dy)); app.run("layer.vectorMask.edit", json!({"layer": id, "path": photocraft_engine::vector_cmds::path_json(&moved)})) } PathTarget::Work => { - let moved = path.transform(&Affine::translate(dx.round(), dy.round())); + let moved = path.transform(&Affine::translate(dx, dy)); app.run("path.set", json!({"name": "work", "path": photocraft_engine::vector_cmds::path_json(&moved)})) } PathTarget::Saved(name) => { - let moved = path.transform(&Affine::translate(dx.round(), dy.round())); + let moved = path.transform(&Affine::translate(dx, dy)); app.run("path.set", json!({"name": name, "path": photocraft_engine::vector_cmds::path_json(&moved)})) } }; diff --git a/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs b/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs index d49eeaae497..5b73b9430d3 100644 --- a/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs @@ -537,3 +537,28 @@ fn cmd_shift_and_alt_enter_use_the_path_selected_in_the_paths_panel() { press(&mut h, Key::Enter, Modifiers { alt: true, ..command() }); assert_eq!(selection(&h), Some([40, 40, 20, 20]), "{}", h.state().ui.status); } + +/// The selected area in pixels (partly selected pixels count in part). +fn selected_area(h: &Harness<'_, PhotocraftApp>) -> f32 { + let d = &h.state().session.active().unwrap().doc; + d.selection.as_ref().map_or(0.0, |s| s.read_region(d.bounds()).iter().sum()) +} + +/// #7726: as in Photoshop, ⇧⌘↩ adds the path to the selection, ⌥⌘↩ subtracts it and ⇧⌥⌘↩ +/// intersects them. A 100×60 selection and a 100×20 path overlapping it by 40×20; the areas are +/// Photoshop 2025's for the same document. +#[test] +fn shift_and_option_with_cmd_enter_combine_the_path_with_the_selection() { + let alt = Modifiers { alt: true, ..command() }; + let shift = Modifiers { shift: true, ..command() }; + let both = Modifiers { shift: true, ..alt }; + for (mods, area) in [(shift, 7200.0), (alt, 5200.0), (both, 800.0), (command(), 2000.0)] { + let mut h = harness(); + h.state_mut().run("path.set", json!({"name": "work", "path": rect_path(80.0, 40.0, 100.0, 20.0)})).unwrap(); + h.state_mut().run("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 60, "antiAlias": false})).unwrap(); + h.state_mut().ui.tool = Tool::Brush; + h.run_steps(2); + press(&mut h, Key::Enter, mods); + assert!((selected_area(&h) - area).abs() < 0.5, "{mods:?}: {} px, wanted {area}: {}", selected_area(&h), h.state().ui.status); + } +} diff --git a/crates/ui-egui/src/tools/vector_ui/pen.rs b/crates/ui-egui/src/tools/vector_ui/pen.rs index 563b78455bc..d31dac59a86 100644 --- a/crates/ui-egui/src/tools/vector_ui/pen.rs +++ b/crates/ui-egui/src/tools/vector_ui/pen.rs @@ -1,6 +1,6 @@ use super::curvature::{curvature_hit, curvature_refresh}; use super::options::selected_shape; -use super::path_ops::{active_shape_path, targeted_vector_mask, work_path_shown}; +use super::path_ops::{targeted_vector_mask, targeted_vector_mask_id, work_path_shown}; use super::shapes::{fill_param, stroke_param}; use super::{Document, LayerContent, Path, PenPath, PhotocraftApp, Resumed, Tokens, Tool, ToolOptions, Value, json, vec2}; @@ -34,6 +34,12 @@ pub fn closes_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { } } +/// Would a Pen click at `(x, y)` carry on an open path from its endpoint there (#6593)? The canvas +/// shows the continue-path pointer there (#9356), as the endpoints look like any anchor. +pub fn continues_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { + tool == Tool::Pen && app.ui.pen.as_ref().is_none_or(|p| p.knots.is_empty()) && resume_at(app, x, y, 6.0 / app.point_zoom().max(0.01) as f64).is_some() +} + /// Pen press: close on the first anchor, reshape the last anchor's outgoing handle, /// or add a new anchor (dragging a new anchor pulls symmetrical handles). With ⇧ a new anchor /// lands on the nearest 45° ray from the previous one, at the pointer's projection onto it. @@ -47,10 +53,12 @@ pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { } // A press on an endpoint of an open path carries on drawing it, as in Photoshop (#6593): the // press then acts on its last anchor, so a drag pulls out the next segment's handle. + let mut resuming = false; if app.ui.pen.as_ref().is_none_or(|p| p.knots.is_empty()) && let Some(resumed) = resume_at(app, x, y, tol) { app.ui.pen = Some(resumed); + resuming = true; } let Some(owner) = capture_pen_owner(app) else { return }; let pen = app.ui.pen.get_or_insert_with(PenPath::default); @@ -61,7 +69,10 @@ pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { pen.adjusting_in = false; pen.last_pointer = Some([x, y]); pen.reposition = false; + // The press that carries a path on never closes it, even when its other end is in reach too: + // the canvas showed the continue-path pointer there (`continues_path_at`). if let Some(first) = pen.knots.first().map(|k| k[0]) + && !resuming && on_closing_anchor(pen, x, y, tol) { // Pressing the first anchor closes the path on release. Until then a stand-in for it @@ -512,21 +523,24 @@ fn doc_path<'a>(doc: &'a Document, name: &str) -> Option<&'a Path> { /// The path a Pen path carries on (#6593): a targeted vector mask, else in Shape mode the /// selected shape layer's, else the path a new Pen path would join (the one selected in the Paths /// panel, else the work path). Its Paths-panel name ("layer" for a layer's), layer and path. -fn resume_target(app: &PhotocraftApp) -> Option<(String, Option, Path)> { +fn resume_target(app: &PhotocraftApp) -> Option<(String, Option, &Path)> { let st = app.session.active()?; - if let Some((id, p)) = targeted_vector_mask(app) { - return Some(("layer".to_string(), Some(id), p)); + // Borrowed, not cloned: the canvas asks on every hover (`continues_path_at`). + if let Some(id) = targeted_vector_mask_id(app) { + let mask = st.doc.layer(photocraft_doc::LayerId(id))?.vector_mask.as_ref()?; + return Some(("layer".to_string(), Some(id), &mask.path)); } if app.ui.tool_options.vector_mode == "shape" { - let (id, p) = active_shape_path(app)?; - return Some(("layer".to_string(), Some(id), p)); + let l = st.active_layer.and_then(|id| st.doc.layer(id))?; + let LayerContent::Shape(sh) = &l.content else { return None }; + return Some(("layer".to_string(), Some(l.id.0), &sh.path)); } let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); // A Work Path hidden by Make Selection isn't carried on: a new path replaces it (#6079). if name == "work" && !work_path_shown(app) { return None; } - let p = doc_path(&st.doc, &name)?.clone(); + let p = doc_path(&st.doc, &name)?; Some((name, None, p)) } @@ -550,17 +564,15 @@ fn resumed(doc: u64, name: String, layer: Option, index: usize, sub: &photo pub(super) fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { let doc = app.session.active()?.doc.id.0; let (name, layer, path) = resume_target(app)?; - let near = |k: &photocraft_doc::Knot| (k.anchor.x - x).hypot(k.anchor.y - y) < tol; - path.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).find_map(|(i, s)| { - let reversed = if s.knots.last().is_some_and(near) { - false - } else if s.knots.first().is_some_and(near) { - true - } else { - return None; - }; - Some(resumed(doc, name.clone(), layer, i, s, reversed)) - }) + let dist = |k: &photocraft_doc::Knot| (k.anchor.x - x).hypot(k.anchor.y - y); + // With several ends in reach (a short path, or ends of two subpaths close together, zoomed + // out) the nearest one is carried on; on a tie the last anchor, then the earlier subpath. + let ends = + path.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).flat_map(|(i, s)| { + [(s.knots.last(), false), (s.knots.first(), true)].into_iter().filter_map(move |(k, reversed)| Some((i, s, reversed, dist(k?)))) + }); + let (i, s, reversed, _) = ends.filter(|e| e.3 < tol).reduce(|best, e| if e.3 < best.3 { e } else { best })?; + Some(resumed(doc, name, layer, i, s, reversed)) } /// The endpoint Direct Selection has selected ([`crate::direct_select::selected_open_end`]), as diff --git a/crates/ui-egui/src/tools/vector_ui/shapes.rs b/crates/ui-egui/src/tools/vector_ui/shapes.rs index 7b67544721d..562e700bc3b 100644 --- a/crates/ui-egui/src/tools/vector_ui/shapes.rs +++ b/crates/ui-egui/src/tools/vector_ui/shapes.rs @@ -3,7 +3,7 @@ use super::overlay::path_lines; use super::{Affine, Color32, PhotocraftApp, Tokens, Tool, ToolOptions, Value, ViewXform, json, paint_param}; pub fn is_shape_tool(t: Tool) -> bool { - matches!(t, Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape) + matches!(t, Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Star | Tool::Line | Tool::CustomShape) } fn rgb32(c: [f32; 4]) -> Color32 { @@ -80,6 +80,8 @@ pub(super) fn shape_geometry(o: &ToolOptions, tool: Tool, start: [f64; 2], end: Tool::Rectangle | Tool::CustomShape => json!({"kind": "rect", "rect": rect}), Tool::EllipseShape => json!({"kind": "ellipse", "rect": rect}), Tool::Triangle => json!({"kind": "polygon", "rect": rect, "sides": 3}), + // PhotoCraft's star defaults; the Star Tool's own options are not measured yet (#11377). + Tool::Star => json!({"kind": "star", "rect": rect, "sides": 5, "starRatio": 0.5}), _ => json!({"kind": "polygon", "rect": rect, "sides": o.polygon_sides.max(3)}), } }) diff --git a/crates/ui-egui/src/transform_hit_zone_tests.rs b/crates/ui-egui/src/transform_hit_zone_tests.rs new file mode 100644 index 00000000000..201bb30c33d --- /dev/null +++ b/crates/ui-egui/src/transform_hit_zone_tests.rs @@ -0,0 +1,483 @@ +use super::*; +use crate::state::{ContentAwareOptions, TransformMode, TransformSession}; +use crate::transform_tool::{Hit, cursor, hit, rotate_cursor_dir}; +use egui::{CursorIcon, Modifiers, pos2}; +use egui_kittest::Harness; +use photocraft_doc::LayerId; +use serde_json::json; + +const QUAD: [[f64; 2]; 4] = [[100.0, 100.0], [500.0, 100.0], [500.0, 300.0], [100.0, 300.0]]; + +fn session(quad: [[f64; 2]; 4]) -> TransformSession { + TransformSession { + session: 1, + layer: 1, + rect: [100.0, 100.0, 500.0, 300.0], + quad, + pivot: quad.iter().fold([0.0; 2], |c, p| [c[0] + p[0] / 4.0, c[1] + p[1] / 4.0]), + interpolation: "bicubic".into(), + warp: None, + selection: false, + target: None, + path: None, + made: None, + mode: TransformMode::Free, + content_aware: None, + skew_v: 0.0, + layers: Vec::new(), + } +} + +fn app(quad: [[f64; 2]; 4], zoom: f32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", serde_json::json!({"width": 32, "height": 32})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = zoom; + app.ui.transform = Some(session(quad)); + app +} + +fn view(zoom: f32) -> ViewXform { + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 800.0)), zoom, center: [300.0, 200.0], flip: false, rotation: 0.0, aspect: 1.0 } +} + +fn near(actual: Option<[f64; 2]>, expected: [f64; 2]) { + let actual = actual.expect("rotate direction"); + assert!((actual[0] - expected[0]).hypot(actual[1] - expected[1]) < 1e-6, "{actual:?} != {expected:?}"); +} + +/// Screen-sized zones must survive zoom changes; the interior control rejects an all-sided edge grab area. +#[test] +fn whole_edge_and_corner_priority_table_at_100_and_200_percent() { + for zoom in [1.0, 2.0] { + let q = QUAD.map(|p| p.map(|v| v / zoom)); + let t = session(q); + // Coordinates are screen pixels, so the same table must hold at both document zooms. + for (p, expected) in [ + ([100.0, 100.0], Hit::Corner(0)), + ([105.0, 105.0], Hit::Corner(0)), + ([95.0, 95.0], Hit::Corner(0)), + ([500.0, 300.0], Hit::Corner(2)), + ([180.0, 100.0], Hit::Edge(0)), + ([180.0, 89.0], Hit::Edge(0)), + ([500.0, 150.0], Hit::Edge(1)), + ([511.0, 150.0], Hit::Edge(1)), + ([420.0, 311.0], Hit::Edge(2)), + ([89.0, 250.0], Hit::Edge(3)), + ([180.0, 101.0], Hit::Inside), + ([499.0, 150.0], Hit::Inside), + ([180.0, 180.0], Hit::Inside), + ([180.0, 88.0], Hit::Outside), + ([620.0, 200.0], Hit::Outside), + ] { + assert_eq!(hit(&t, p.map(|v| v / zoom), 12.0 / zoom), expected, "zoom {zoom}, screen {p:?}"); + } + let tiny = session([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]); + assert_eq!(TransformSession::box_hit(&tiny.quad, [1.0, -1.0], 12.0), Hit::Corner(0), "corners beat overlapping edge bands"); + } +} + +/// An overlapping corner zone must not steal a closer painted midpoint on a small box. +#[test] +fn small_boxes_keep_their_painted_mid_edge_handles() { + for side in [8.0, 16.0, 32.0] { + for zoom in [1.0, 2.0] { + let q = [[0.0, 0.0], [side, 0.0], [side, side], [0.0, side]].map(|p| p.map(|v| v / zoom)); + let t = session(q); + for i in 0..4 { + let (a, b) = (q[i], q[(i + 1) % 4]); + let midpoint = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]; + assert_eq!(hit(&t, midpoint, 12.0 / zoom), Hit::Edge(i), "side {side}, zoom {zoom}, edge {i}"); + assert_eq!(hit(&t, a, 12.0 / zoom), Hit::Corner(i)); + } + } + } +} + +/// Match the measured #11097 boundaries and ties on the top edge and one point outside it. +#[test] +fn measured_edge_corner_and_midpoint_zones_at_100_and_200_percent() { + use Hit::{Corner, Edge}; + let matrix: &[(f64, &[(f64, Hit)])] = &[ + (12.0, &[(3.0, Corner(0)), (4.0, Edge(0)), (5.0, Edge(0)), (6.0, Edge(0)), (9.0, Edge(0)), (10.0, Corner(1))]), + (24.0, &[(6.0, Corner(0)), (7.0, Edge(0)), (18.0, Edge(0)), (19.0, Corner(1))]), + (40.0, &[(10.0, Corner(0)), (11.0, Edge(0)), (30.0, Edge(0)), (31.0, Corner(1))]), + (100.0, &[(11.0, Corner(0)), (12.0, Edge(0))]), + ]; + for zoom in [1.0_f32, 2.0] { + let scale = |p: [f64; 2]| p.map(|v| v / f64::from(zoom)); + let xf = view(zoom); + for &(side, samples) in matrix { + let quad = [[0.0, 0.0], [side, 0.0], [side, side], [0.0, side]].map(scale); + let mut app = app(quad, zoom); + app.ui.tool_options.transform_reference_point = false; + let t = app.ui.transform.as_ref().unwrap(); + let rect = Rect::from_min_size(Pos2::ZERO, vec2(side as f32, side as f32)); + for y in [0.0, -1.0] { + for &(x, expected) in samples { + let p = scale([x, y]); + let icon = match expected { + Corner(0) => CursorIcon::ResizeNwSe, + Corner(1) => CursorIcon::ResizeNeSw, + _ => CursorIcon::ResizeVertical, + }; + assert_eq!(crate::transform_tool::box_hit(&app, t, p, 12.0 / f64::from(zoom)), expected, "{side}, {zoom}, {x}, {y}"); + assert_eq!(cursor(&app, &xf, p, Modifiers::NONE), Some(icon)); + assert_eq!(transform_controls_cursor(rect, pos2(x as f32, y as f32)), Some(icon)); + } + } + } + } +} + +/// Reproduce inward rounding through the view transform, with a one-pixel interior control. +#[test] +fn an_edge_survives_the_actual_f32_view_round_trip() { + for zoom in [1.0_f32, 2.0] { + let q = [[100.0, 100.00002], [500.0, 100.00002], [500.0, 300.0], [100.0, 300.0]].map(|p| p.map(|v| v / f64::from(zoom))); + let t = session(q); + let xf = view(zoom); + let p = xf.to_doc(xf.to_screen((180.0 / f64::from(zoom)) as f32, q[0][1] as f32)); + let inward_rounding = (p[1] - q[0][1]) * f64::from(zoom); + assert!(inward_rounding > 0.0 && inward_rounding < 1e-3, "fixture must expose inward f32 rounding: {inward_rounding}"); + assert_eq!(hit(&t, p, 12.0 / f64::from(zoom)), Hit::Edge(0)); + assert_eq!(hit(&t, [p[0], q[0][1] + 1.0 / f64::from(zoom)], 12.0 / f64::from(zoom)), Hit::Inside, "a whole pixel inside still moves"); + } +} + +/// The same small-edge press moves the Distort edge and stretches one axis in legacy unlinked scaling. +#[test] +fn a_small_edge_keeps_distort_and_legacy_aspect_behavior() { + for mode in [TransformMode::Distort, TransformMode::Free] { + let mut app = app(QUAD, 1.0); + app.ui.transform = None; + app.ui.extras.snap = false; + app.ui.transform_unlinked = true; + app.session.edit_prefs(|p| p.general.use_legacy_free_transform = true); + app.session.execute("shape.create", serde_json::json!({"kind":"rect","rect":[8,8,16,16],"fill":"#ff0000"})).unwrap(); + crate::transform_tool::begin(&mut app, &egui::Context::default()).unwrap(); + app.ui.transform.as_mut().unwrap().mode = mode; + let before = app.ui.transform.as_ref().unwrap().quad; + for event in [ + crate::canvas::ToolEvent::Down { x: 24.0, y: 16.0, pressure: 1.0 }, + crate::canvas::ToolEvent::Move { x: 40.0, y: 20.0, pressure: 1.0 }, + crate::canvas::ToolEvent::Up { x: 40.0, y: 20.0 }, + ] { + crate::canvas::tool_event(&mut app, event, Modifiers::NONE); + } + let q = app.ui.transform.as_ref().unwrap().quad; + if mode == TransformMode::Distort { + let mut expected = before; + for corner in [1, 2] { + expected[corner][0] += 16.0; + expected[corner][1] += 4.0; + } + assert_eq!(q, expected, "Distort moves the pressed edge and holds the opposite edge"); + } else { + assert!((q[2][0] - q[0][0] - 32.0).abs() < 1e-6 && (q[2][1] - q[0][1] - 16.0).abs() < 1e-6, "a legacy edge stretches one axis: {q:?}"); + } + } +} + +/// At 45 degrees, corners and edges exchange cursor axis orientation at both tested zooms. +#[test] +fn turned_box_zones_and_resize_cursors_follow_the_quad() { + let turn = |p: [f64; 2]| { + let (x, y) = (p[0] - 300.0, p[1] - 200.0); + let k = std::f64::consts::FRAC_1_SQRT_2; + [300.0 + (x - y) * k, 200.0 + (x + y) * k] + }; + for zoom in [1.0_f32, 2.0] { + let scale = |p: [f64; 2]| turn(p).map(|v| v / f64::from(zoom)); + let q = QUAD.map(scale); + let app = app(q, zoom); + let xf = view(zoom); + let t = app.ui.transform.as_ref().unwrap(); + for (p, expected, icon) in [ + ([500.0, 100.0], Hit::Corner(1), CursorIcon::ResizeHorizontal), + ([111.0, 99.0], Hit::Corner(0), CursorIcon::ResizeVertical), + ([112.0, 99.0], Hit::Edge(0), CursorIcon::ResizeNeSw), + ([180.0, 95.0], Hit::Edge(0), CursorIcon::ResizeNeSw), + ([505.0, 150.0], Hit::Edge(1), CursorIcon::ResizeNwSe), + ([180.0, 101.0], Hit::Inside, CursorIcon::Default), + ([180.0, -20.0], Hit::Outside, CursorIcon::None), + ] { + assert_eq!(hit(t, scale(p), 12.0 / f64::from(zoom)), expected); + assert_eq!(cursor(&app, &xf, scale(p), Modifiers::NONE), Some(icon), "zoom {zoom}, {p:?}"); + } + assert_eq!(hit(t, scale([100.0, 100.0]), 12.0 / f64::from(zoom)), Hit::Corner(0)); + } +} + +/// Skewed edges and reversed winding must use actual segment geometry for hits and turn direction. +#[test] +fn distorted_and_flipped_quads_use_their_edges_not_an_axis_aligned_box() { + let q = [[100.0, 100.0], [500.0, 140.0], [460.0, 300.0], [80.0, 260.0]]; + for q in [q, [q[1], q[0], q[3], q[2]]] { + let t = session(q); + assert_eq!(hit(&t, [220.0, 112.0], 12.0), Hit::Edge(0)); + assert_eq!(hit(&t, [220.0, 107.0], 12.0), Hit::Edge(0)); + assert_eq!(hit(&t, [220.0, 113.0], 12.0), Hit::Inside); + assert_eq!(hit(&t, [220.0, 90.0], 12.0), Hit::Outside); + near(TransformSession::box_turn_direction(&q, [220.0, 90.0]), [-0.1 / 1.01_f64.sqrt(), 1.0 / 1.01_f64.sqrt()]); + } +} + +/// A concave quad whose corner mean is beyond an edge rejects center-based normal orientation. +#[test] +fn concave_quad_edge_direction_uses_winding_in_both_orientations() { + let q = [[0.0, 0.0], [100.0, 0.0], [10.0, 10.0], [0.0, 100.0]]; + for q in [q, [q[1], q[0], q[3], q[2]]] { + near(TransformSession::box_turn_direction(&q, [56.0, 14.0]), [-1.0 / 82.0_f64.sqrt(), -9.0 / 82.0_f64.sqrt()]); + } +} + +/// Each outside position selects its inward turn orientation and hides the system cursor. +#[test] +fn outside_rotation_has_eight_edge_and_corner_orientations() { + let app = app(QUAD, 1.0); + let h = std::f64::consts::FRAC_1_SQRT_2; + for (p, direction) in [ + ([300.0, -20.0], [0.0, 1.0]), + ([620.0, -20.0], [-h, h]), + ([620.0, 200.0], [-1.0, 0.0]), + ([620.0, 420.0], [-h, -h]), + ([300.0, 420.0], [0.0, -1.0]), + ([-20.0, 420.0], [h, -h]), + ([-20.0, 200.0], [1.0, 0.0]), + ([-20.0, -20.0], [h, h]), + ] { + near(rotate_cursor_dir(&app, p), direction); + assert_eq!(cursor(&app, &view(1.0), p, Modifiers::NONE), Some(CursorIcon::None)); + assert_eq!(cursor(&app, &view(1.0), p, Modifiers::COMMAND), Some(CursorIcon::None)); + } +} + +/// A hidden reference point outside the quad must leave both hover and the press free to rotate. +#[test] +fn outside_reference_point_owns_the_pointer_only_when_shown() { + let p = [80.0, 40.0]; + let pivot = [76.0, 40.0]; + let to = [80.0, 48.0]; + for shown in [false, true] { + let mut app = app(QUAD, 1.0); + app.ui.transform = None; + app.ui.extras.snap = false; + app.session.execute("shape.create", serde_json::json!({"kind": "rect", "rect": [4, 4, 16, 16], "fill": "#ff0000"})).unwrap(); + crate::transform_tool::begin(&mut app, &egui::Context::default()).unwrap(); + let t = app.ui.transform.as_mut().unwrap(); + t.pivot = pivot; + let quad = t.quad; + app.ui.tool_options.transform_reference_point = shown; + assert_eq!(rotate_cursor_dir(&app, p).is_some(), !shown); + let icon = if shown { CursorIcon::Crosshair } else { CursorIcon::None }; + assert_eq!(cursor(&app, &view(1.0), p, Modifiers::NONE), Some(icon)); + + let down = crate::canvas::ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }; + crate::transform_tool::pointer(&mut app, down, Modifiers::NONE); + let drag = crate::canvas::ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }; + crate::transform_tool::pointer(&mut app, drag, Modifiers::NONE); + let t = app.ui.transform.as_ref().unwrap(); + if shown { + assert_eq!(t.quad, quad, "the reference-point drag leaves the box in place"); + assert_eq!(t.pivot, to); + } else { + assert_ne!(t.quad, quad, "the outside drag turns the box"); + assert_eq!(t.pivot, pivot); + } + } +} + +/// Compare unopened controls with an active session at identical points, including a free interior. +#[test] +fn unopened_move_controls_share_hover_but_keep_the_move_press_band() { + let app = app(QUAD, 1.0); + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 300.0)); + for (p, starts_transform) in [ + (pos2(100.0, 100.0), true), + (pos2(180.0, 95.0), true), + (pos2(180.0, 89.0), true), + (pos2(505.0, 150.0), true), + (pos2(518.0, 150.0), true), + (pos2(519.0, 150.0), false), + (pos2(300.0, 70.0), false), + (pos2(180.0, 101.0), false), + (pos2(620.0, 200.0), false), + (pos2(-20.0, -20.0), false), + ] { + let d = [f64::from(p.x), f64::from(p.y)]; + assert_eq!(transform_controls_cursor(r, p), cursor(&app, &view(1.0), d, Modifiers::NONE), "{p:?}"); + assert_eq!(transform_controls_hit(r, p), starts_transform, "{p:?}"); + match (transform_controls_turn(r, p), rotate_cursor_dir(&app, d)) { + (None, None) => {} + (Some(a), Some(b)) => near(Some([f64::from(a.x) / 16.0, f64::from(a.y) / 16.0]), b), + pair => panic!("unopened/open rotation disagreement: {pair:?}"), + } + } + assert_eq!(transform_controls_cursor(r, r.center()), Some(CursorIcon::Default)); + assert!(!transform_controls_hit(r, r.center()), "inside remains an ordinary Move drag"); +} + +fn move_controls_fixture() -> (Harness<'static, PhotocraftApp>, [LayerId; 2]) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300, "background": "transparent"})).unwrap(); + let layers = [[60, 60, 80, 60], [230, 150, 40, 40]].map(|rect| { + app.run("shape.create", json!({"kind": "rect", "rect": rect, "fill": "#ff0000"})).unwrap(); + app.session.active().unwrap().active_layer.unwrap() + }); + app.run("layer.select", json!({"layer": layers[0].0, "mode": "replace"})).unwrap(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_show_transform = true; + app.ui.tool_options.move_auto_select = true; + app.ui.tool_options.move_target = "layer".into(); + app.ui.extras.snap = false; + let mut h = Harness::builder().with_size(vec2(1000.0, 800.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + 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, layers) +} + +fn move_pointer(h: &mut Harness<'static, PhotocraftApp>, control: bool, kind: &str, [x, y]: [f32; 2]) { + if control { + use crate::control::{ControlRequest, Outcome}; + let (request, _) = ControlRequest::new("ui.pointer", json!({"events": [{"kind": kind, "x": x, "y": y}]})); + let ctx = h.ctx.clone(); + let Outcome::Done(reply) = crate::control::handle(h.state_mut(), &ctx, &request) else { panic!("expected pointer reply") }; + assert_eq!(reply["ok"], true, "{reply}"); + } else { + let xf = ViewXform::active(h.state()).unwrap(); + let pos = xf.to_screen(x, y); + assert!(xf.rect.contains(pos)); + h.hover_at(pos); + if kind != "move" { + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: kind == "down", modifiers: Modifiers::NONE }); + } + h.run_steps(2); + } +} + +#[test] +fn move_controls_allow_auto_select_on_another_layer_outside_the_press_band() { + for control in [false, true] { + let (mut h, layers) = move_controls_fixture(); + let at = [250.0, 170.0]; + let xf = ViewXform::active(h.state()).unwrap(); + let frame = transform_controls_rect(h.state(), &xf).expect("the first layer has visible transform controls"); + assert!(!frame.expand(18.0).contains(xf.to_screen(at[0], at[1]))); + assert_eq!(h.state().session.active().unwrap().selected_layers(), vec![layers[0]]); + let before = h.state().session.active().unwrap().doc.clone(); + move_pointer(&mut h, control, "down", at); + move_pointer(&mut h, control, "up", at); + let state = h.state().session.active().unwrap(); + assert!(h.state().ui.transform.is_none(), "control={control}: Auto-Select does not start Free Transform"); + assert_eq!(state.selected_layers(), vec![layers[1]], "control={control}"); + assert_eq!(state.doc, before); + } +} + +#[test] +fn move_controls_allow_a_layer_marquee_from_empty_canvas_outside_the_press_band() { + for control in [false, true] { + let (mut h, layers) = move_controls_fixture(); + let start = [190.0, 125.0]; + let end = [285.0, 210.0]; + let xf = ViewXform::active(h.state()).unwrap(); + let frame = transform_controls_rect(h.state(), &xf).expect("the first layer has visible transform controls"); + assert!(!frame.expand(18.0).contains(xf.to_screen(start[0], start[1]))); + assert_eq!(h.state().session.active().unwrap().selected_layers(), vec![layers[0]]); + let before = h.state().session.active().unwrap().doc.clone(); + move_pointer(&mut h, control, "down", start); + move_pointer(&mut h, control, "move", end); + assert!(h.state().layer_box.is_some(), "control={control}: the empty-canvas drag starts a layer marquee"); + move_pointer(&mut h, control, "up", end); + let state = h.state().session.active().unwrap(); + assert!(h.state().ui.transform.is_none(), "control={control}: the marquee does not start Free Transform"); + assert!(h.state().layer_box.is_none(), "the marquee ends on release"); + assert_eq!(state.selected_layers(), vec![layers[1]], "control={control}"); + assert_eq!(state.doc, before); + } +} + +/// A press away from midpoint handles must scale an anchored preview without creating a history edit. +#[test] +fn move_whole_edge_press_enters_scaling_at_both_zooms() { + for zoom in [1.0, 2.0] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", serde_json::json!({"width": 640, "height": 480})).unwrap(); + app.session.execute("shape.create", serde_json::json!({"kind": "rect", "rect": [100, 100, 400, 200], "fill": "#ff0000"})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = zoom; + app.ui.tool = Tool::Move; + app.ui.tool_options.move_show_transform = true; + app.ui.extras.snap = false; + let xf = view(zoom); + let ctx = egui::Context::default(); + let before = app.session.active().unwrap().doc.clone(); + let steps = app.session.active().unwrap().history.past_len(); + // Away from the top edge's midpoint and corners: the native canvas first opens the + // controls here, then forwards the same document-space press to the transform tool. + assert_eq!(begin_transform_controls_at(&mut app, &ctx, &xf, xf.to_screen(180.0, 100.0)), ControlsPress::Started); + for event in [ + crate::canvas::ToolEvent::Down { x: 180.0, y: 100.0, pressure: 1.0 }, + crate::canvas::ToolEvent::Move { x: 180.0, y: 90.0, pressure: 1.0 }, + crate::canvas::ToolEvent::Up { x: 180.0, y: 90.0 }, + ] { + crate::canvas::tool_event(&mut app, event, Modifiers::SHIFT); + } + let t = app.ui.transform.as_ref().expect("a handle release retains the transform session"); + assert!(t.quad[0][1] < 100.0, "the pressed edge scales"); + assert!((t.quad[2][1] - 300.0).abs() < 1e-6, "the opposite edge anchors scaling instead of moving the whole box"); + assert_eq!(app.session.active().unwrap().doc, before, "a preview does not commit pixels"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + } +} + +/// Excluded modes retain cursor ownership, and non-finite coordinates cannot activate controls. +#[test] +fn modes_that_do_not_rotate_and_invalid_points_do_not_hide_the_pointer() { + let mut app = app(QUAD, 1.0); + for mode in [TransformMode::Distort, TransformMode::Skew] { + app.ui.transform.as_mut().unwrap().mode = mode; + assert_eq!(rotate_cursor_dir(&app, [620.0, 200.0]), None); + assert_eq!(cursor(&app, &view(1.0), [620.0, 200.0], Modifiers::NONE), Some(CursorIcon::Default)); + } + app.ui.transform.as_mut().unwrap().mode = TransformMode::Free; + app.ui.transform.as_mut().unwrap().content_aware = Some(ContentAwareOptions::default()); + assert_eq!(rotate_cursor_dir(&app, [620.0, 200.0]), None); + assert_eq!(cursor(&app, &view(1.0), [620.0, 200.0], Modifiers::NONE), Some(CursorIcon::Default)); + app.ui.transform.as_mut().unwrap().content_aware = None; + let t = app.ui.transform.as_mut().unwrap(); + t.warp = Some(photocraft_geom::warp::Warp::none(t.rect)); + assert_eq!(rotate_cursor_dir(&app, [620.0, 200.0]), None); + assert_eq!(cursor(&app, &view(1.0), [620.0, 200.0], Modifiers::NONE), Some(CursorIcon::Crosshair), "Warp retains its own cursor"); + app.ui.transform.as_mut().unwrap().warp = None; + assert_eq!(rotate_cursor_dir(&app, [f64::NAN, 200.0]), None); + assert_eq!(TransformSession::box_turn_direction(&[[0.0, 0.0]; 4], [20.0, 20.0]), None); + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 300.0)); + for p in [pos2(f32::NAN, 200.0), pos2(f32::INFINITY, 200.0)] { + assert_eq!(transform_controls_cursor(r, p), None, "invalid points cannot hide the pointer"); + assert_eq!(transform_controls_turn(r, p), None); + assert!(!transform_controls_hit(r, p), "invalid points cannot activate the overlay"); + } +} + +/// Captured rotation must retain feedback after crossing into the box, independent of hover zones. +#[test] +fn rotate_drag_keeps_the_arrow_when_the_pointer_enters_the_box() { + let mut app = app(QUAD, 1.0); + app.ui.transform = None; + app.session.execute("shape.create", serde_json::json!({"kind": "rect", "rect": [4, 4, 16, 16], "fill": "#ff0000"})).unwrap(); + let ctx = egui::Context::default(); + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + crate::transform_tool::pointer(&mut app, crate::canvas::ToolEvent::Down { x: 80.0, y: 40.0, pressure: 1.0 }, Modifiers::NONE); + crate::transform_tool::pointer(&mut app, crate::canvas::ToolEvent::Move { x: 10.0, y: 10.0, pressure: 1.0 }, Modifiers::NONE); + assert!(rotate_cursor_dir(&app, [10.0, 10.0]).is_some(), "the held rotation gesture owns the arrow even inside"); + assert_eq!(cursor(&app, &view(1.0), [10.0, 10.0], Modifiers::NONE), Some(CursorIcon::None)); +} diff --git a/crates/ui-egui/src/transform_relative_position_tests.rs b/crates/ui-egui/src/transform_relative_position_tests.rs new file mode 100644 index 00000000000..bd6ed4d21f3 --- /dev/null +++ b/crates/ui-egui/src/transform_relative_position_tests.rs @@ -0,0 +1,175 @@ +//! Relative positioning in the transform options bar (#11679): a toggle between X and Y, off by +//! default; on, X and Y show the reference point's offset from where the box started and a typed +//! value moves the box by that much; off, they show its position again. + +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::ToolEvent; + +const TOGGLE: &str = "Use relative positioning for reference point"; + +/// A 200 x 120 document with a layer filled at 40,20 (60 x 40) and Free Transform open on it: +/// the reference point starts at the box's centre, 70,40. +fn transforming() -> 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("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 40, "y": 20, "width": 60, "height": 40})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + crate::transform_tool::begin(&mut app, &egui::Context::default()).unwrap(); + app +} + +fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1600.0, 200.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + h +} + +/// What the X and Y fields show. +fn shown(h: &Harness<'_, PhotocraftApp>) -> (f64, f64) { + let read = |name: &str| h.get_by_role_and_label(Role::SpinButton, name).accesskit_node().numeric_value().unwrap(); + (read("X"), read("Y")) +} + +/// Types `text` into the X field and commits it with Tab, as a user does. +fn type_x(h: &mut Harness<'_, PhotocraftApp>, text: &str) { + h.get_by_role_and_label(Role::SpinButton, "X").click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(1); + for ch in text.chars() { + h.event(egui::Event::Text(ch.to_string())); + h.run_steps(1); + } + h.key_press(egui::Key::Tab); + h.run_steps(3); +} + +fn click_toggle(h: &mut Harness<'_, PhotocraftApp>) { + h.get_by_label(TOGGLE).click(); + h.run_steps(3); +} + +fn quad(h: &Harness<'_, PhotocraftApp>) -> [[f64; 2]; 4] { + h.state().ui.transform.as_ref().unwrap().quad +} + +fn moved(q: [[f64; 2]; 4], dx: f64) -> [[f64; 2]; 4] { + q.map(|[x, y]| [x + dx, y]) +} + +fn same(a: [[f64; 2]; 4], b: [[f64; 2]; 4]) -> bool { + a.iter().flatten().zip(b.iter().flatten()).all(|(a, b)| (a - b).abs() < 1e-6) +} + +#[test] +fn relative_positioning_is_off_by_default() { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + assert!(!app.ui.tool_options.transform_relative_position); + // An older preferences file without the field reads it as off. + let o: crate::state::ToolOptions = serde_json::from_value(json!({})).unwrap(); + assert!(!o.transform_relative_position); +} + +#[test] +fn the_toggle_sits_between_x_and_y_and_turns_a_fresh_box_into_zero() { + let mut h = harness(transforming()); + let x = h.get_by_role_and_label(Role::SpinButton, "X").rect(); + let y = h.get_by_role_and_label(Role::SpinButton, "Y").rect(); + let toggle = h.get_by_label(TOGGLE).rect(); + assert!(x.max.x <= toggle.min.x && toggle.max.x <= y.min.x, "X {x:?}, toggle {toggle:?}, Y {y:?}"); + assert_eq!(shown(&h), (70.0, 40.0), "off: the reference point's position"); + click_toggle(&mut h); + assert!(h.state().ui.tool_options.transform_relative_position); + assert_eq!(shown(&h), (0.0, 0.0), "on: no offset yet"); +} + +#[test] +fn a_typed_x_moves_the_box_by_that_much_and_commits_as_free_transform() { + let mut h = harness(transforming()); + click_toggle(&mut h); + let before = quad(&h); + type_x(&mut h, "20"); + assert!(same(quad(&h), moved(before, 20.0)), "the box moved 20 px right: {:?}", quad(&h)); + assert_eq!(shown(&h), (20.0, 0.0)); + // Typing the same offset again doesn't move it further. + type_x(&mut h, "20"); + assert!(same(quad(&h), moved(before, 20.0)), "still 20 px: {:?}", quad(&h)); + let app = h.state_mut(); + crate::transform_tool::commit(app); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds().x0, 60, "the layer moved 20 px"); + assert_eq!(st.history.entries().last().map(ToString::to_string).as_deref(), Some("Free Transform")); +} + +#[test] +fn a_drag_updates_the_offset_and_turning_it_off_shows_the_position_again() { + let mut h = harness(transforming()); + click_toggle(&mut h); + { + let app = h.state_mut(); + let none = egui::Modifiers::NONE; + crate::transform_tool::pointer(app, ToolEvent::Down { x: 50.0, y: 30.0, pressure: 1.0 }, none); + crate::transform_tool::pointer(app, ToolEvent::Move { x: 55.0, y: 33.0, pressure: 1.0 }, none); + crate::transform_tool::pointer(app, ToolEvent::Up { x: 55.0, y: 33.0 }, none); + } + h.run_steps(3); + assert_eq!(shown(&h), (5.0, 3.0), "the offset follows the drag"); + click_toggle(&mut h); + assert!(!h.state().ui.tool_options.transform_relative_position); + assert_eq!(shown(&h), (75.0, 43.0), "off again: the reference point's position"); +} + +/// Typing X leaves Y exactly where it was, even when Y is a fraction the field's f32 can't hold +/// (CodeRabbit review on options_bar.rs:308): the field's rounding must not nudge the box. +#[test] +fn a_typed_x_leaves_a_fractional_y_exactly_where_it_was() { + let mut app = transforming(); + let t = app.ui.transform.as_mut().unwrap(); + t.quad = t.quad.map(|[x, y]| [x, y + 0.1]); + t.pivot[1] += 0.1; + let mut h = harness(app); + let before = quad(&h); + let pivot_y = h.state().ui.transform.as_ref().unwrap().pivot[1]; + type_x(&mut h, "90"); + let after = quad(&h); + assert!(same(after, moved(before, 20.0)), "the box moved 20 px right: {after:?}"); + assert_eq!(after.map(|p| p[1]), before.map(|p| p[1]), "Y is untouched"); + assert_eq!(h.state().ui.transform.as_ref().unwrap().pivot[1], pivot_y); +} + +/// A box squashed flat has no inverse map, so the offset comes from the map's pseudo-inverse: a +/// box squashed to zero width about its reference point reads 0 / 0, and one squashed onto its +/// left edge, carrying the reference point with it, reads -30 / 0 (CodeRabbit pre-merge check). +#[test] +fn a_flattened_box_reads_the_reference_points_own_offset() { + let mut app = transforming(); + app.ui.tool_options.transform_relative_position = true; + let t = app.ui.transform.as_mut().unwrap(); + t.quad = [[70.0, 20.0], [70.0, 20.0], [70.0, 60.0], [70.0, 60.0]]; + t.pivot = [70.0, 40.0]; + let mut h = harness(app); + assert_eq!(shown(&h), (0.0, 0.0), "squashed about the reference point"); + let t = h.state_mut().ui.transform.as_mut().unwrap(); + t.quad = [[40.0, 20.0], [40.0, 20.0], [40.0, 60.0], [40.0, 60.0]]; + t.pivot = [40.0, 40.0]; + h.run_steps(2); + assert_eq!(shown(&h), (-30.0, 0.0), "squashed onto the left edge"); +} diff --git a/crates/ui-egui/src/transform_type_tests.rs b/crates/ui-egui/src/transform_type_tests.rs index eeb91f44b9e..e179e4f6d52 100644 --- a/crates/ui-egui/src/transform_type_tests.rs +++ b/crates/ui-egui/src/transform_type_tests.rs @@ -56,6 +56,8 @@ fn type_layers_offer_free_transform_but_not_distort_or_perspective() { guide: false, spelling: None, crop: None, + sampler: None, + sampler_at: None, }; assert!(crate::canvas_tool_menu::available(&app, &menu, "edit.freeTransform")); assert!(!crate::canvas_tool_menu::available(&app, &menu, "edit.transform.distort")); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 3b4320dd0d5..be7ac076491 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -57,6 +57,7 @@ pub use panel::{ FOOTER_BAR, chevron_icon, drop_line, footer_height, hairline, panel_footer, panel_menu_button, pill_tab, section_label, style_spectrum_popup_menu, surface, vline, }; +pub(crate) use scrub::scrub_label_within; pub use scrub::{scrub_label, scrub_option_icon, scrub_option_label}; #[cfg(test)] use slider::DOUBLE_CLICK_S; diff --git a/crates/ui-egui/src/widgets/scrub.rs b/crates/ui-egui/src/widgets/scrub.rs index 8602af4e610..fee418fc03d 100644 --- a/crates/ui-egui/src/widgets/scrub.rs +++ b/crates/ui-egui/src/widgets/scrub.rs @@ -63,7 +63,8 @@ fn scrub_option_area(ui: &Ui, label: &Response, tip: Option<&'static str>) { ui.data_mut(|d| d.insert_temp(label_scrub_id(), scrub)); } -pub(super) fn scrub_label_within(ui: &Ui, label: &Response, max_gap: f32) -> Response { +// Shared headers may put a control between the label and its field. +pub(crate) fn scrub_label_within(ui: &Ui, label: &Response, max_gap: f32) -> Response { // Drag only, not focusable: Tab goes to the value field, not to its label as well. let resp = label.interact(Sense::DRAG); let scrub = label_scrub_response(ui, &resp, label.rect, max_gap, Some(label.id)); diff --git a/crates/ui-egui/tests/it/actions_duplicate.rs b/crates/ui-egui/tests/it/actions_duplicate.rs new file mode 100644 index 00000000000..a9f9aeb206b --- /dev/null +++ b/crates/ui-egui/tests/it/actions_duplicate.rs @@ -0,0 +1,53 @@ +//! The panel duplicates the selected action/step and keeps its new row selected. +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_ui_egui::{PhotocraftApp, actions, dialogs}; +use serde_json::json; + +#[test] +fn duplicate_menu_selects_the_new_action_and_then_the_new_step() { + let mut app = PhotocraftApp::new(Session::new(), Default::default()); + let mut a = Action::new("Source", vec![("select.all".into(), json!({}))]); + a.disabled.insert(0); + a.dialogs.insert(0); + app.session.actions.list.push(a); + app.ui.actions.selected = Some(0); + app.ui.actions.expanded = vec![true]; + let mut h = Harness::builder().with_size(egui::vec2(360.0, 460.0)).build_ui_state(|ui, app| actions::panel_menu(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + h.get_by_label("Duplicate").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.list.len(), 2); + assert_eq!(h.state().session.actions.list[1].name, "Source copy"); + assert_eq!(h.state().ui.actions.selected, Some(1)); + assert_eq!(h.state().ui.actions.selected_step, None); + assert_eq!(h.state().ui.actions.expanded, [true, true]); + h.state_mut().ui.actions.selected_step = Some(0); + h.get_by_label("Duplicate").click(); + h.run_steps(3); + assert_eq!(h.state().ui.actions.selected, Some(1)); + assert_eq!(h.state().ui.actions.selected_step, Some(1)); + let copied = &h.state().session.actions.list[1]; + assert_eq!(copied.steps.len(), 2); + assert_eq!(copied.disabled.iter().copied().collect::>(), [0, 1]); + assert_eq!(copied.dialogs.iter().copied().collect::>(), [0, 1]); +} + +#[test] +fn duplicate_cannot_shift_the_owner_of_a_suspended_recording() { + let mut app = PhotocraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 8, "height": 6})).unwrap(); + app.session.actions.list.push(Action::new("Menu", vec![(atn_cmds::MENU_ITEM.into(), json!({"id": "image.imageSize"}))])); + app.run("actions.record", json!({"name": "Owner"})).unwrap(); + let result = app.run("actions.play", json!({"action": "Menu"})).unwrap(); + let dialog = result["paused"]["dialog"].as_u64().unwrap(); + let list = app.session.actions.list.clone(); + assert!(app.session.actions.recording.is_none()); + let error = app.run("actions.duplicate", json!({"action": "Menu"})).unwrap_err(); + assert!(error.contains("Confirm or cancel"), "{error}"); + assert_eq!(app.session.actions.list, list); + dialogs::cancel(&mut app, dialog).unwrap(); + assert_eq!(app.session.actions.recording.map(|(_, i)| i), Some(1)); + app.run("actions.stop", json!({})).unwrap(); +} diff --git a/crates/ui-egui/tests/it/actions_panel_menu.rs b/crates/ui-egui/tests/it/actions_panel_menu.rs new file mode 100644 index 00000000000..5383558bbcc --- /dev/null +++ b/crates/ui-egui/tests/it/actions_panel_menu.rs @@ -0,0 +1,242 @@ +//! Real panel controls compose the remaining commands with consent and retained selection. +use std::sync::atomic::{AtomicUsize, Ordering}; + +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_engine::{EngineError, Session, actions_cmds::Action, actions_edit_cmds, atn_cmds, prefs::PlaybackPerformance}; +use photocraft_ui_egui::{ + PhotocraftApp, actions, canvas, + control::{ControlRequest, Outcome, handle}, + dialogs, +}; +use serde_json::{Value, json}; + +/// A real panel and its dialogs with one selected action in a set. +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(Session::new(), Default::default()); + app.background_jobs = false; + app.run("file.new", json!({"width":16,"height":12})).unwrap(); + let mut a = Action::new("A", vec![]); + a.set = Some("S".into()); + app.session.actions.list.push(a); + app.ui.actions.selected = Some(0); + app.ui.actions.expanded = vec![true]; + let mut h = Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state( + |ui, app| { + actions::panel_menu(app, ui); + dialogs::show(app, ui.ctx()); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + h +} + +/// Click the actual menu entry and return its modal identity. +fn open(h: &mut Harness<'static, PhotocraftApp>, label: &str) -> u64 { + h.get_by_label(label).click(); + h.run_steps(3); + h.state().ui.dialogs.last().unwrap().id +} + +/// The checkbox changes recording policy rather than disabling actual painting. +#[test] +fn allow_tool_recording_checkbox_records_only_enabled_strokes() { + let mut h = harness(); + h.state_mut().run("actions.record", json!({"action":"A"})).unwrap(); + for enabled in [true, false] { + h.get_by_label("Allow Tool Recording").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.record_tools, enabled); + h.state_mut().run("paint.stroke", json!({"points":[[2,3],[10,3]],"size":2,"color":if enabled {"#ff0000"} else {"#00ff00"}})).unwrap(); + } + h.state_mut().run("actions.stop", json!({})).unwrap(); + assert_eq!(h.state().session.actions.list[0].steps.len(), 1); + assert_eq!(h.state().session.actions.list[0].steps[0].1["color"], "#ff0000"); + assert_eq!(h.state().session.journal.iter().filter(|s| s.0 == "paint.stroke").count(), 2); +} + +/// Folder selection dispatches set duplication and keeps the new folder selected, empty ones included. +#[test] +fn duplicate_selected_set_keeps_folder_and_disclosure_ownership() { + let mut h = harness(); + h.state_mut().ui.actions.selected = None; + h.state_mut().ui.actions.selected_set = Some("S".into()); + h.get_by_label("Duplicate").click(); + h.run_steps(3); + assert_eq!(h.state().ui.actions.selected, None); + assert_eq!(h.state().ui.actions.selected_set.as_deref(), Some("S copy")); + assert_eq!(actions_edit_cmds::set_names(&h.state().session), ["S", "S copy"]); + assert_eq!(h.state().ui.actions.expanded, [true, false]); + h.state_mut().run("actions.newSet", json!({"name":"Empty"})).unwrap(); + h.state_mut().ui.actions.selected_set = Some("Empty".into()); + h.get_by_label("Duplicate").click(); + h.run_steps(3); + assert_eq!(h.state().ui.actions.selected_set.as_deref(), Some("Empty copy")); +} + +/// Menu insertion uses an actual chooser and never samples or executes current command settings. +#[test] +fn insert_menu_item_chooser_cancels_or_records_descriptor_free_filter_event() { + let mut h = harness(); + let revision = h.state().session.active().unwrap().revision; + open(&mut h, "Insert Menu Item…"); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(h.state().session.actions.list[0].steps.is_empty()); + let dialog = open(&mut h, "Insert Menu Item…"); + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("id".into(), json!("filter.blur.gaussianBlur")); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.list[0].steps[0], (atn_cmds::MENU_ITEM.into(), json!({"id":"filter.blur.gaussianBlur","eventKind":"name"}))); + assert_eq!(h.state().session.active().unwrap().revision, revision); + assert_eq!(h.state().ui.actions.selected_step, Some(0)); + let played = h.state_mut().run("actions.play", json!({"action":"A"})).unwrap(); + let dialog = played["paused"]["dialog"].as_u64().unwrap(); + assert_eq!(h.state_mut().ui.dialog_mut(dialog).unwrap().fields["__command"], "filter.blur.gaussianBlur"); + dialogs::cancel(h.state_mut(), dialog).unwrap(); + h.state_mut().run("paint.stroke", json!({"points":[[6,6]],"size":3,"hardness":1,"color":"#000000"})).unwrap(); + let original = photocraft_compose::flatten(&h.state().session.active().unwrap().doc).into_px(); + let mut expected = Session::new(); + expected.add_document((*h.state().session.active().unwrap().doc).clone(), None); + expected.execute("filter.blur.gaussianBlur", json!({"radius":2})).unwrap(); + let expected = photocraft_compose::flatten(&expected.active().unwrap().doc).into_px(); + assert_ne!(expected, original, "the Gaussian fixture has a visible effect"); + h.state_mut().session.actions.list[0].steps.push(("select.all".into(), json!({}))); + let paused = h.state_mut().run("actions.play", json!({"action":"A"})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("radius".into(), json!(2)); + dialogs::confirm(h.state_mut(), dialog).unwrap(); + assert_eq!(photocraft_compose::flatten(&h.state().session.active().unwrap().doc).into_px(), expected); + assert!(h.state().session.active().unwrap().doc.selection.is_some(), "confirmation resumes the following step"); +} + +/// The control protocol distinguishes direct playback from a menu click, even with a step gate. +#[test] +fn control_playback_opens_menu_settings_only_for_menu_invocation() { + let mut h = harness(); + let ctx = h.ctx.clone(); + let app = h.state_mut(); + app.run("actions.insertMenuItem", json!({"action":"A","id":"filter.blur.gaussianBlur"})).unwrap(); + let steps = &mut app.session.actions.list[0].steps; + steps.insert(0, ("select.all".into(), json!({}))); + steps.push(("select.deselect".into(), json!({}))); + app.session.edit_prefs(|prefs| prefs.actions.performance = PlaybackPerformance::StepByStep); + + // Starting at the menu step checks the original dialog bug. Starting one step earlier + // also verifies Step by Step cannot defer the menu step past the control request. + for (from, ran) in [(1, 0), (0, 1)] { + let (req, _reply) = ControlRequest::new("engine.execute", json!({"command":"actions.play","params":{"action":"A","from":from}})); + let Outcome::Done(reply) = handle(app, &ctx, &req) else { panic!("direct playback must return immediately") }; + assert_eq!(reply["ok"], true, "{reply}"); + let played = &reply["result"]; + assert_eq!(played["ran"], ran, "{reply}"); + assert_eq!(played["failed"]["step"], 1, "{reply}"); + assert_eq!(played["failed"]["id"], atn_cmds::MENU_ITEM, "{reply}"); + assert!(played["failed"]["error"].as_str().unwrap().contains("interactive-required"), "{reply}"); + assert!(played.get("paused").is_none(), "{reply}"); + assert!(app.ui.dialogs.is_empty()); + } + + app.services.automation_authorize = Some(|_, _| Ok(())); + let (req, _reply) = ControlRequest::new("ui.menu.invoke", json!({"id":"actions.play","params":{"action":"A","from":1}})); + let Outcome::Done(reply) = handle(app, &ctx, &req) else { panic!("menu invocation must return its paused dialog") }; + assert_eq!(reply["ok"], true, "{reply}"); + let dialog = reply["result"]["paused"]["dialog"].as_u64().expect("menu playback opens Gaussian Blur settings"); + assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["__command"], "filter.blur.gaussianBlur"); + dialogs::cancel(app, dialog).unwrap(); + assert!(app.ui.dialogs.is_empty()); + assert!(app.session.active().unwrap().doc.selection.is_some(), "cancel must not run the following step"); +} + +static MENU_PREVIEW_CHECKS: AtomicUsize = AtomicUsize::new(0); +static MENU_PREVIEW_DENIALS: AtomicUsize = AtomicUsize::new(0); + +/// Count actual menu preview checks and deny an edited map path. +fn deny_menu_map_path(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if id == "filter.blur.gaussianBlur" { + MENU_PREVIEW_CHECKS.fetch_add(1, Ordering::SeqCst); + if params.get("mapPath").and_then(Value::as_str).is_some_and(|path| !path.is_empty()) { + MENU_PREVIEW_DENIALS.fetch_add(1, Ordering::SeqCst); + return Err(EngineError::Other("map path denied".into())); + } + } + Ok(()) +} + +/// A descriptor-free step retains its playback gate and the identity of its opened editor. +#[test] +fn paused_menu_preview_keeps_the_requesting_gate_and_rejects_retargeting() { + let mut h = harness(); + h.state_mut().run("actions.insertMenuItem", json!({"action":"A","id":"filter.blur.gaussianBlur"})).unwrap(); + h.state_mut().session.authorize = Some(deny_menu_map_path); + let paused = h.state_mut().run("actions.play", json!({"action":"A"})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + h.state_mut().session.authorize = None; + + let context = h.ctx.clone(); + let checks = MENU_PREVIEW_CHECKS.load(Ordering::SeqCst); + assert!(canvas::ensure_texture(h.state_mut(), &context, 0, None).is_some()); + assert!(MENU_PREVIEW_CHECKS.load(Ordering::SeqCst) > checks, "the retained gate authorizes the real preview"); + + let revision = h.state().session.active().unwrap().revision; + let steps = h.state().session.actions.list[0].steps.clone(); + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("mapPath".into(), json!("/outside/map.psd")); + let denials = MENU_PREVIEW_DENIALS.load(Ordering::SeqCst); + canvas::ensure_texture(h.state_mut(), &context, 0, None); + assert!(MENU_PREVIEW_DENIALS.load(Ordering::SeqCst) > denials, "the retained gate denies the edited path"); + + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__command".into(), json!("filter.distort.displace")); + let error = dialogs::confirm(h.state_mut(), dialog).unwrap_err(); + assert!(error.contains("the menu item command or its document changed"), "{error}"); + assert!(h.state().ui.dialogs.is_empty()); + assert!(!h.state().file_dialog_open()); + assert_eq!(h.state().session.active().unwrap().revision, revision); + assert_eq!(h.state().session.actions.list[0].steps, steps); +} + +/// Deny the default while allowing insertion of another mapped menu item. +fn deny_image_size_choice(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if id == "image.imageSize" || id == "actions.insertMenuItem" && params["id"] == "image.imageSize" { + return Err(EngineError::Other("choice denied".into())); + } + Ok(()) +} + +/// The chooser retains its caller and checks only the choice the caller confirms. +#[test] +fn menu_chooser_authorizes_the_selected_command_without_default_probe() { + let mut h = harness(); + h.state_mut().session.authorize = Some(deny_image_size_choice); + let dialog = open(&mut h, "Insert Menu Item…"); + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("id".into(), json!("filter.blur.gaussianBlur")); + h.state_mut().session.authorize = None; + dialogs::confirm(h.state_mut(), dialog).unwrap(); + let steps = h.state().session.actions.list[0].steps.clone(); + assert_eq!(steps, [(atn_cmds::MENU_ITEM.into(), json!({"id":"filter.blur.gaussianBlur","eventKind":"name"}))]); + + h.state_mut().session.authorize = Some(deny_image_size_choice); + let dialog = open(&mut h, "Insert Menu Item…"); + h.state_mut().session.authorize = None; + let error = dialogs::confirm(h.state_mut(), dialog).unwrap_err(); + assert!(error.contains("choice denied"), "{error}"); + assert_eq!(h.state().session.actions.list[0].steps, steps); +} + +/// The selected path is snapshotted without applying it to the document. +#[test] +fn insert_path_uses_actual_selected_saved_path() { + let mut h = harness(); + h.get_by_label("Insert Path"); + assert!(h.state_mut().run("actions.insertPath", json!({"action":"A"})).is_err()); + h.state_mut().run("path.set", json!({"name":"Curve","path":{"subpaths":[{"closed":false,"knots":[{"anchor":[1,2],"out":[4,5]},[9,8]]}]}})).unwrap(); + h.state_mut().ui.selected_path = Some("Curve".into()); + h.run_steps(3); + let revision = h.state().session.active().unwrap().revision; + h.get_by_label("Insert Path").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.list[0].steps[0].0, "path.set"); + assert_eq!(h.state().session.actions.list[0].steps[0].1["path"]["subpaths"][0]["knots"][0]["out"], json!([4.0, 5.0])); + assert_eq!(h.state().session.active().unwrap().revision, revision); +} diff --git a/crates/ui-egui/tests/it/actions_record_again.rs b/crates/ui-egui/tests/it/actions_record_again.rs new file mode 100644 index 00000000000..dc91c67600e --- /dev/null +++ b/crates/ui-egui/tests/it/actions_record_again.rs @@ -0,0 +1,197 @@ +//! Record Again uses the real editor while retaining the selected step and permissions. +use std::sync::atomic::{AtomicUsize, Ordering}; + +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_engine::{EngineError, Session, actions_cmds::Action}; +use photocraft_ui_egui::{PhotocraftApp, actions, canvas, dialogs, state::DialogKind}; +use serde_json::{Value, json}; + +/// Draw the actual panel and dialogs over one selected command, without a GPU or file service. +fn harness(command: &str, params: Value) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(Session::new(), Default::default()); + app.background_jobs = false; + app.run("file.new", json!({"width":8,"height":6})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let mut action = Action::new("A", vec![(command.into(), params), ("select.all".into(), json!({}))]); + action.disabled.insert(0); + action.dialogs.insert(0); + app.session.actions.list.push(action); + app.ui.actions.selected = Some(0); + app.ui.actions.selected_step = Some(0); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 760.0)).build_ui_state( + |ui, app| { + actions::panel_menu(app, ui); + dialogs::show(app, ui.ctx()); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + h +} + +/// Open by clicking the panel entry, preserving its selected-step and enablement checks. +fn open(h: &mut Harness<'static, PhotocraftApp>) -> u64 { + h.get_by_role_and_label(egui::accesskit::Role::Button, "Record Again").click(); + h.run_steps(3); + h.state().ui.dialogs.last().unwrap().id +} + +/// A resize with different recorded and document sizes exposes seeding from the wrong source. +fn sizing() -> Harness<'static, PhotocraftApp> { + harness("image.imageSize", json!({"width":4,"height":3,"resample":"nearest"})) +} + +/// Cancel preserves the step and document; OK runs only that step and stores its new settings. +#[test] +fn seeded_editor_cancels_or_updates_only_the_original_step() { + let mut h = sizing(); + let original = h.state().session.actions.list.clone(); + let revision = h.state().session.active().unwrap().revision; + let dialog = open(&mut h); + let fields = &h.state().ui.dialogs.last().unwrap().fields; + assert_eq!(fields["__sizing"], true); + assert_eq!(fields["width"].as_f64(), Some(4.0)); + assert_eq!(fields["height"].as_f64(), Some(3.0)); + assert_eq!(fields["__recordAgain"], true); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.list, original); + assert_eq!(h.state().session.active().unwrap().revision, revision); + assert!(h.state().ui.dialogs.iter().all(|d| d.id != dialog)); + let dialog = open(&mut h); + let fields = &mut h.state_mut().ui.dialog_mut(dialog).unwrap().fields; + fields.insert("width".into(), json!(6)); + fields.insert("height".into(), json!(5)); + fields.insert("__constrain".into(), json!(false)); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state().ui.dialogs.is_empty()); + let action = &h.state().session.actions.list[0]; + assert_eq!(action.steps.len(), 2); + assert_eq!(action.steps[0].1["width"].as_f64(), Some(6.0)); + assert!(action.disabled.contains(&0)); + assert!(action.dialogs.contains(&0)); + assert_eq!(h.state().session.active().unwrap().doc.size, photocraft_engine::doc::Size::new(6, 5)); + assert!(h.state().session.active().unwrap().doc.selection.is_none()); +} + +/// Permit initial settings while denying a later edit even if the current gate is removed. +fn limited_resize(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if id == "image.imageSize" && params.get("width").and_then(Value::as_f64).is_some_and(|w| w >= 10.0) { + Err(EngineError::Other("resize denied".into())) + } else { + Ok(()) + } +} + +/// Changed definitions/documents, retained denials, invalid settings and retargeting cannot overwrite. +#[test] +fn stale_denied_or_failed_edit_preserves_the_stored_step() { + for mode in ["action-stale", "document-stale", "denied", "retargeted", "failed"] { + let mut h = sizing(); + if mode == "denied" { + h.state_mut().session.authorize = Some(limited_resize); + } + let dialog = open(&mut h); + match mode { + "action-stale" => { + h.state_mut().run("actions.rename", json!({"action":"A","name":"Changed"})).unwrap(); + } + "document-stale" => { + h.state_mut().run("file.new", json!({"width":3,"height":3})).unwrap(); + } + "denied" => { + h.state_mut().session.authorize = None; + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("width".into(), json!(10)); + } + "retargeted" => { + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__command".into(), json!("select.all")); + } + _ => { + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("percent".into(), json!(-1)); + } + } + let original = h.state().session.actions.list.clone(); + let revision = h.state().session.active().unwrap().revision; + assert!(dialogs::confirm(h.state_mut(), dialog).is_err(), "{mode}"); + assert_eq!(h.state().session.actions.list, original, "{mode}"); + assert_eq!(h.state().session.active().unwrap().revision, revision, "{mode}"); + h.run_steps(3); + let retry = open(&mut h); + dialogs::cancel(h.state_mut(), retry).unwrap(); + } +} + +/// Confirmation and Cancel of an impostor dialog leave the original editor's private state intact. +#[test] +fn another_dialog_cannot_consume_the_selected_step() { + let mut h = sizing(); + let real = open(&mut h); + let fields = h.state().ui.dialogs.last().unwrap().fields.clone(); + let fake = h.state_mut().ui.open_dialog(DialogKind::Command, fields.clone()); + assert!(dialogs::confirm(h.state_mut(), fake).is_err()); + let fake = h.state_mut().ui.open_dialog(DialogKind::Command, fields); + dialogs::cancel(h.state_mut(), fake).unwrap(); + dialogs::confirm(h.state_mut(), real).unwrap(); + assert_eq!(h.state().session.active().unwrap().doc.size, photocraft_engine::doc::Size::new(4, 3)); +} + +static PREVIEW_CHECKS: AtomicUsize = AtomicUsize::new(0); + +/// Count actual preview authorization and refuse LUT paths or edited blur and gamma settings. +fn preview_gate(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if matches!(id, "filter.blur.gaussianBlur" | "image.adjustments.levels" | "image.adjustments.colorLookup") { + PREVIEW_CHECKS.fetch_add(1, Ordering::SeqCst); + if params.get("file").is_some() || ["radius", "gamma"].iter().any(|key| params.get(key).and_then(Value::as_f64).is_some_and(|v| v > 2.0)) { + return Err(EngineError::Other("preview denied".into())); + } + } + Ok(()) +} + +/// Both real preview routes check the retained caller before evaluating changed settings or LUT files. +#[test] +fn filter_and_adjustment_previews_retain_permissions_before_evaluation() { + for command in ["filter.blur.gaussianBlur", "image.adjustments.levels", "image.adjustments.colorLookup"] { + let params = if command.starts_with("filter.") { + json!({"radius":1}) + } else { + photocraft_engine::adjust_params::to_params( + &photocraft_engine::adjust_params::default_for(command.strip_prefix("image.adjustments.").unwrap(), photocraft_engine::doc::ColorMode::Rgb) + .unwrap(), + ) + }; + let mut h = harness(command, params); + h.state_mut().session.authorize = Some(preview_gate); + let dialog = open(&mut h); + h.state_mut().session.authorize = None; + let context = h.ctx.clone(); + PREVIEW_CHECKS.store(0, Ordering::SeqCst); + assert!(canvas::ensure_texture(h.state_mut(), &context, 0, None).is_some()); + assert!(PREVIEW_CHECKS.load(Ordering::SeqCst) > 0, "the permitted real preview request is authorized: {command}"); + if command == "image.adjustments.levels" { + assert!(dialogs::adjust_preview::display_doc(h.state_mut(), 0).is_some(), "the permitted adjustment preview is evaluated"); + } + let original = h.state().session.actions.list.clone(); + let revision = h.state().session.active().unwrap().revision; + let fields = &mut h.state_mut().ui.dialog_mut(dialog).unwrap().fields; + if command.starts_with("filter.") { + fields.insert("radius".into(), json!(8)); + } else if command == "image.adjustments.levels" { + fields.insert("gamma".into(), json!(8)); + // This route parses settings even with Preview off; authorization must precede it. + fields.insert("__preview".into(), json!(false)); + } else { + fields.insert("file".into(), json!("/outside/forbidden.cube")); + } + PREVIEW_CHECKS.store(0, Ordering::SeqCst); + canvas::ensure_texture(h.state_mut(), &context, 0, None); + assert!(PREVIEW_CHECKS.load(Ordering::SeqCst) > 0, "the changed actual request was refused before evaluation: {command}"); + assert_eq!(h.state().session.actions.list, original); + assert_eq!(h.state().session.active().unwrap().revision, revision); + assert!(dialogs::confirm(h.state_mut(), dialog).is_err()); + assert_eq!(h.state().session.actions.list, original); + } +} diff --git a/crates/ui-egui/tests/it/background_eraser_reset_tip.rs b/crates/ui-egui/tests/it/background_eraser_reset_tip.rs new file mode 100644 index 00000000000..f971412bc45 --- /dev/null +++ b/crates/ui-egui/tests/it/background_eraser_reset_tip.rs @@ -0,0 +1,113 @@ +//! #11911: Reset Tool gives the Background Eraser the reference application's tip, a 13 px hard +//! round at 25 % spacing with Size control off, not the 20 px default at 10 %; Reset All Tools and +//! a first use do too. The other painting tools and `paint.stroke` without a `brush` keep theirs. + +use photocraft_ui_egui::control::{ControlRequest, handle}; +use photocraft_ui_egui::{PhotocraftApp, Tool}; +use serde_json::{Value, json}; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app +} + +/// A control request as the control channel sends it, then the frame's sync. +fn control(app: &mut PhotocraftApp, method: &str, params: Value) -> Value { + let ctx = egui::Context::default(); + let (req, rx) = ControlRequest::new(method, params); + handle(app, &ctx, &req); + app.sync_views(); + rx.try_recv().unwrap_or(Value::Null) +} + +/// Pick `tool`, and swap its brush in as a frame does. +fn pick(app: &mut PhotocraftApp, tool: Tool) { + app.ui.tool = tool; + photocraft_ui_egui::paint_mouse::sync_tool_brush(app); + app.sync_views(); +} + +/// Size, hardness, spacing, angle and roundness of the live brush, as `brush.get` reports them. +fn tip(app: &mut PhotocraftApp) -> [f64; 5] { + let b = app.run("brush.get", json!({})).unwrap(); + ["size", "hardness", "spacing", "angle", "roundness"].map(|k| (b[k].as_f64().unwrap() * 1e4).round() / 1e4) +} + +fn pressure_size(app: &mut PhotocraftApp) -> bool { + app.run("brush.get", json!({})).unwrap()["pressureSize"].as_bool().unwrap() +} + +/// A tip that is neither default, so a reset has something to undo. +fn scramble(app: &mut PhotocraftApp) { + let b = &mut app.session.tools.brush; + (b.size, b.hardness, b.spacing, b.angle, b.roundness, b.pressure_size) = (57.0, 0.4, 0.6, 30.0, 0.5, true); +} + +const ERASER_TIP: [f64; 5] = [13.0, 1.0, 0.25, 0.0, 1.0]; +const DEFAULT_TIP: [f64; 5] = [20.0, 1.0, 0.1, 0.0, 1.0]; + +#[test] +fn reset_tool_gives_the_background_eraser_a_13_px_hard_tip() { + let mut app = app(); + pick(&mut app, Tool::BackgroundEraser); + scramble(&mut app); + control(&mut app, "ui.menu.invoke", json!({"id": "tool.reset"})); + assert_eq!(tip(&mut app), ERASER_TIP); + assert!(!pressure_size(&mut app), "Size control Off"); + // The options-bar brush chip shows the brush's size. + assert_eq!(app.session.tools.brush.size.round(), 13.0); +} + +#[test] +fn reset_all_tools_gives_it_the_tip_active_or_picked_later() { + let mut app = app(); + pick(&mut app, Tool::BackgroundEraser); + scramble(&mut app); + control(&mut app, "ui.menu.invoke", json!({"id": "tool.resetAll", "params": {"confirmed": true}})); + assert_eq!(tip(&mut app), ERASER_TIP, "active"); + assert!(!pressure_size(&mut app), "Size control Off, active"); + let mut app = self::app(); + pick(&mut app, Tool::BackgroundEraser); + scramble(&mut app); + pick(&mut app, Tool::Pencil); + control(&mut app, "ui.menu.invoke", json!({"id": "tool.resetAll", "params": {"confirmed": true}})); + pick(&mut app, Tool::BackgroundEraser); + assert_eq!(tip(&mut app), ERASER_TIP, "picked after the reset"); + assert!(!pressure_size(&mut app), "Size control Off, picked after the reset"); +} + +#[test] +fn a_first_use_starts_with_the_tip() { + // No stored tool brushes: the Background Eraser's first pick, after another tool's tip. + let mut app = app(); + pick(&mut app, Tool::Pencil); + scramble(&mut app); + pick(&mut app, Tool::BackgroundEraser); + assert_eq!(tip(&mut app), ERASER_TIP); + assert!(!pressure_size(&mut app)); + // Its own tip is then kept across tool switches. + app.session.tools.brush.size = 42.0; + pick(&mut app, Tool::Pencil); + pick(&mut app, Tool::BackgroundEraser); + assert_eq!(tip(&mut app)[0], 42.0); +} + +#[test] +fn other_tools_and_session_strokes_keep_their_tips() { + for tool in [Tool::Pencil, Tool::Eraser] { + let mut a = app(); + pick(&mut a, tool); + scramble(&mut a); + control(&mut a, "ui.menu.invoke", json!({"id": "tool.reset"})); + assert_eq!(tip(&mut a), DEFAULT_TIP, "{tool:?}"); + } + let mut app = app(); + assert_eq!(tip(&mut app), DEFAULT_TIP, "what `paint.stroke` / `paint.backgroundEraser` paint with when no `brush` is given"); + // A first use of another tool still takes the current tip, as before. + pick(&mut app, Tool::Pencil); + scramble(&mut app); + pick(&mut app, Tool::Eraser); + assert_eq!(tip(&mut app)[0], 57.0); +} diff --git a/crates/ui-egui/tests/it/brush_reset_defaults.rs b/crates/ui-egui/tests/it/brush_reset_defaults.rs new file mode 100644 index 00000000000..0c596a21025 --- /dev/null +++ b/crates/ui-egui/tests/it/brush_reset_defaults.rs @@ -0,0 +1,131 @@ +//! #11810: Reset Tool gives the Brush tool the reference application's tip, a 13 px soft round +//! (hardness 0 %, spacing 25 %), not the 20 px hard default. The other painting tools and +//! `paint.stroke` without a `brush` keep the default tip. + +use photocraft_ui_egui::control::{ControlRequest, handle}; +use photocraft_ui_egui::{PhotocraftApp, Tool}; +use serde_json::{Value, json}; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app +} + +/// A control request as the control channel sends it, then the frame's sync. +fn control(app: &mut PhotocraftApp, method: &str, params: Value) -> Value { + let ctx = egui::Context::default(); + let (req, rx) = ControlRequest::new(method, params); + handle(app, &ctx, &req); + app.sync_views(); + rx.try_recv().unwrap_or(Value::Null) +} + +/// Pick `tool`, and swap its brush in as a frame does. +fn pick(app: &mut PhotocraftApp, tool: Tool) { + app.ui.tool = tool; + photocraft_ui_egui::paint_mouse::sync_tool_brush(app); + app.sync_views(); +} + +/// Size, hardness, spacing, angle and roundness of the live brush, as `brush.get` reports them +/// (rounded: they are `f32`s). +fn tip(app: &mut PhotocraftApp) -> [f64; 5] { + let b = app.run("brush.get", json!({})).unwrap(); + ["size", "hardness", "spacing", "angle", "roundness"].map(|k| (b[k].as_f64().unwrap() * 1e4).round() / 1e4) +} + +/// Give the live brush a tip that is neither default, so a reset has something to undo. +fn scramble(app: &mut PhotocraftApp) { + let b = &mut app.session.tools.brush; + (b.size, b.hardness, b.spacing, b.angle, b.roundness) = (57.0, 0.4, 0.6, 30.0, 0.5); +} + +const BRUSH_TIP: [f64; 5] = [13.0, 0.0, 0.25, 0.0, 1.0]; +const DEFAULT_TIP: [f64; 5] = [20.0, 1.0, 0.1, 0.0, 1.0]; + +#[test] +fn reset_tool_gives_the_brush_a_13_px_soft_tip() { + let mut app = app(); + pick(&mut app, Tool::Brush); + scramble(&mut app); + control(&mut app, "ui.menu.invoke", json!({"id": "tool.reset"})); + assert_eq!(tip(&mut app), BRUSH_TIP); +} + +#[test] +fn reset_all_tools_gives_the_brush_its_tip() { + let mut app = app(); + pick(&mut app, Tool::Brush); + scramble(&mut app); + control(&mut app, "ui.menu.invoke", json!({"id": "tool.resetAll", "params": {"confirmed": true}})); + assert_eq!(tip(&mut app), BRUSH_TIP); +} + +/// Reset All Tools from another painting tool: the Brush gets its own tip when picked, not the +/// live brush. +#[test] +fn reset_all_tools_from_another_tool_gives_the_brush_its_tip() { + let mut app = app(); + pick(&mut app, Tool::Brush); + scramble(&mut app); + pick(&mut app, Tool::Eraser); + scramble(&mut app); + control(&mut app, "ui.menu.invoke", json!({"id": "tool.resetAll", "params": {"confirmed": true}})); + assert_eq!(tip(&mut app), DEFAULT_TIP, "the Eraser"); + pick(&mut app, Tool::Brush); + assert_eq!(tip(&mut app), BRUSH_TIP, "the Brush"); +} + +/// Reset All Tools from the Brush: the other painting tools get the default tip when picked, not +/// the Brush's. +#[test] +fn reset_all_tools_from_the_brush_gives_the_other_tools_the_default_tip() { + let mut app = app(); + pick(&mut app, Tool::Brush); + control(&mut app, "ui.menu.invoke", json!({"id": "tool.resetAll", "params": {"confirmed": true}})); + for tool in [Tool::Pencil, Tool::Eraser] { + pick(&mut app, tool); + assert_eq!(tip(&mut app), DEFAULT_TIP, "{tool:?}"); + } +} + +#[test] +fn other_painting_tools_keep_the_default_reset_tip() { + for tool in [Tool::Pencil, Tool::Eraser] { + let mut a = app(); + pick(&mut a, tool); + scramble(&mut a); + control(&mut a, "ui.menu.invoke", json!({"id": "tool.reset"})); + assert_eq!(tip(&mut a), DEFAULT_TIP, "{tool:?}"); + } +} + +#[test] +fn a_session_brush_keeps_the_default_tip() { + let mut app = app(); + assert_eq!(tip(&mut app), DEFAULT_TIP, "what `paint.stroke` paints with when no `brush` is given"); +} + +/// The reference's click with the reset Brush at 100,100 paints layer bounds 89,89 to 110,110: +/// the soft tip reaches past its nominal diameter. Ours reaches 89,89 to 112,112: the outermost +/// pixels are the stroke's 8-bit dither at 1/255 alpha, which the paint engine owns (not the reset +/// tip), so this allows 2 px. The default 20 px hard tip reaches 88,89 to 113,112. +#[test] +fn a_click_with_the_reset_brush_paints_the_soft_dab() { + let mut app = app(); + pick(&mut app, Tool::Brush); + control(&mut app, "ui.menu.invoke", json!({"id": "tool.reset"})); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("paint.stroke", json!({"points": [[100.5, 100.5, 1.0]]})).unwrap(); + let st = app.session.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let b = surf.content_bounds(); + for (got, want) in [(b.x0, 89), (b.y0, 89), (b.x1, 110), (b.y1, 110)] { + assert!((got - want).abs() <= 2, "bounds {b:?}, want 89,89 to 110,110"); + } + let alpha = |x| (surf.pixel(x, 100)[3] * 255.0).round(); + assert_eq!(alpha(100), 255.0, "full at the centre"); + assert!((50.0..200.0).contains(&alpha(104)), "half-way out the tip is partly covered: {}", alpha(104)); +} diff --git a/crates/ui-egui/tests/it/live_stroke_canvas.rs b/crates/ui-egui/tests/it/live_stroke_canvas.rs index a152f6678c0..6942dba8a45 100644 --- a/crates/ui-egui/tests/it/live_stroke_canvas.rs +++ b/crates/ui-egui/tests/it/live_stroke_canvas.rs @@ -15,6 +15,58 @@ use serde_json::{Value, json}; type Harness = egui_kittest::Harness<'static, PhotocraftApp>; type Pixels = Vec<[u8; 4]>; +#[test] +fn raw_pointer_brush_and_mixer_pixels_appear_in_the_input_frame() { + use photocraft_ui_egui::state::Tool; + let _gpu = photocraft_testkit::gpu_lock(); + for flip in [false, true] { + for tool in [Tool::Brush, Tool::MixerBrush] { + let Some(mut h) = harness() else { return }; + let app = h.state_mut(); + app.run("file.new", json!({"width":300,"height":200,"background":"white"})).unwrap(); + app.run("edit.fill", json!({"color":"#2040df"})).unwrap(); + app.run("tools.setColors", json!({"foreground":"#ef2000"})).unwrap(); + app.run("tools.setBrush", json!({"brush":{"size":24,"hardness":1.0,"pressureSize":false,"smoothing":{"amount":0.0}}})).unwrap(); + app.ui.tool = tool; + app.ui.view.flip_horizontal = flip; + app.sync_views(); + h.run_steps(4); + let at = h.state().last_canvas_rect.center(); + let before = h.render().unwrap().get_pixel(at.x as u32, at.y as u32).0; + h.input_mut().events.push(egui::Event::PointerMoved(at)); + h.input_mut().events.push(egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + let image = h.render().unwrap(); + assert_ne!(image.get_pixel(at.x as u32, at.y as u32).0, before, "{tool:?} flip={flip}: press visible in its own frame"); + let next = at + egui::vec2(40.0, 0.0); + h.input_mut().events.push(egui::Event::PointerMoved(next)); + h.step(); + let image = h.render().unwrap(); + assert_ne!(image.get_pixel(next.x as u32, next.y as u32).0, before, "{tool:?} flip={flip}: move visible in its own frame"); + if tool == Tool::MixerBrush + && !flip + && let Ok(path) = std::env::var("PHOTOCRAFT_TEST_SNAPSHOT") + { + image.save(path).unwrap(); + } + h.input_mut().events.push(egui::Event::PointerButton { + pos: next, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); + assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some(if tool == Tool::MixerBrush { "Mixer Brush" } else { "Brush Tool" })); + } + } +} + fn harness() -> Option { let built = std::panic::catch_unwind(|| { egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 640.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(|cc| { @@ -167,3 +219,102 @@ fn the_canvas_shows_the_committed_stroke_part_2() { fn the_canvas_shows_the_committed_stroke_part_3() { the_canvas_shows_the_committed_stroke_part(3); } + +/// A dialog-owned brush must remain above the image even when a live painting tool is selected. +#[test] +fn refine_edge_brush_stays_visible_above_the_canvas_with_live_tools() { + use photocraft_ui_egui::state::Tool; + let _gpu = photocraft_testkit::gpu_lock(); + for flip in [false, true] { + for tool in [Tool::Brush, Tool::MixerBrush] { + let Some(mut h) = harness() else { return }; + h.state_mut().run("file.new", json!({"width":1200,"height":800,"background":"black"})).unwrap(); + h.state_mut().run("select.all", json!({})).unwrap(); + h.state_mut().ui.tool = tool; + h.state_mut().ui.view.flip_horizontal = flip; + h.run_steps(4); + control(&mut h, "ui.set", json!({"zoom":1.0,"center":[600,400]})); + control(&mut h, "ui.menu.invoke", json!({"command":"select.refineEdge"})); + let fields = &mut h.state_mut().ui.dialogs.last_mut().expect("Select and Mask opens").fields; + fields.insert("__brush".into(), json!(true)); + fields.insert("__preview".into(), json!(false)); + h.run_steps(3); + let canvas = h.state().last_canvas_rect; + let at = egui::pos2(canvas.left() + 32.0, canvas.top() + 80.0); + h.input_mut().events.push(egui::Event::PointerMoved(at)); + h.step(); + let image = h.render().unwrap(); + let cross = image.get_pixel(at.x as u32 + 2, at.y as u32).0; + assert!(cross[0] > 100 && cross[1] > 100 && cross[2] > 100, "{tool:?} flip={flip}: Refine Edge cross is hidden: {cross:?}"); + if tool == Tool::MixerBrush + && !flip + && let Ok(path) = std::env::var("PHOTOCRAFT_DIALOG_SNAPSHOT") + { + image.save(path).unwrap(); + } + } + } +} + +/// Deferred live-tool painting uses the camera changed by this frame's navigation input. +#[test] +fn live_tool_camera_navigation_is_visible_in_the_input_frame() { + use photocraft_ui_egui::state::Tool; + let _gpu = photocraft_testkit::gpu_lock(); + for flip in [false, true] { + for tool in [Tool::Brush, Tool::MixerBrush] { + for zoom in [false, true] { + let Some(mut h) = harness() else { return }; + let app = h.state_mut(); + app.run("file.new", json!({"width":300,"height":200,"background":"white"})).unwrap(); + app.run("paint.stroke", json!({"points":[[40,40],[240,160]],"size":30,"hardness":1.0,"color":"#2050df"})).unwrap(); + app.run("prefs.set", json!({"values":{"tools.overscroll":true,"general.animatedZoom":false,"general.zoomWithScrollWheel":false}})).unwrap(); + app.ui.tool = tool; + app.ui.view.flip_horizontal = flip; + app.sync_views(); + h.run_steps(4); + control(&mut h, "ui.set", json!({"zoom":1.0,"center":[150,100]})); + let at = h.state().last_canvas_rect.center() + egui::vec2(30.0, 10.0); + h.hover_at(at); + h.run_steps(2); + let before = h.state().ui.views[0].clone(); + h.input_mut().events.push(if zoom { + egui::Event::Zoom(1.25) + } else { + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(30.0, 20.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + } + }); + h.step(); + let moved = h.state().ui.views[0].clone(); + assert!(if zoom { moved.zoom != before.zoom } else { moved.center != before.center }, "navigation was applied"); + let immediate = h.render().unwrap(); + let rect = h.state().last_canvas_rect.shrink(8.0); + h.step(); + let settled = h.render().unwrap(); + if tool == Tool::MixerBrush + && flip + && zoom + && let Ok(path) = std::env::var("PHOTOCRAFT_NAV_SNAPSHOT") + { + settled.save(path).unwrap(); + } + assert_eq!(h.state().ui.views[0].zoom, moved.zoom); + assert_eq!(h.state().ui.views[0].center, moved.center); + let mut diff = 0; + for y in rect.top() as u32..rect.bottom() as u32 { + for x in rect.left() as u32..rect.right() as u32 { + for (a, b) in immediate.get_pixel(x, y).0.into_iter().zip(settled.get_pixel(x, y).0) { + diff = diff.max(a.abs_diff(b)); + } + } + } + assert!(diff <= 1, "{tool:?} flip={flip} zoom={zoom}: canvas changes {diff}/255 one frame after navigation"); + } + } + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 4816d6c1dfc..9e160488d40 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -1,8 +1,13 @@ //! The crate's integration tests, linked into one test binary. +mod actions_duplicate; +mod actions_panel_menu; +mod actions_record_again; mod adjust_preview_gpu; mod arbitrary_rotation_preview; +mod background_eraser_reset_tip; mod batch_export_options; +mod brush_reset_defaults; mod camera_raw_histogram; mod camera_raw_localization; mod camera_raw_scope; diff --git a/crates/ui-egui/tests/magnetic_lasso_spacing.rs b/crates/ui-egui/tests/magnetic_lasso_spacing.rs new file mode 100644 index 00000000000..08230ccf8af --- /dev/null +++ b/crates/ui-egui/tests/magnetic_lasso_spacing.rs @@ -0,0 +1,182 @@ +//! #11687: the Magnetic Lasso drops its automatic fastening points as far apart as the reference +//! application does: with the button up about 4 x the stored spacing + 4 screen px, with the +//! button held the stored spacing itself, which is `floor(40 - 0.35 x Frequency)`. +//! +//! Driven through the public `tool_event` entry (the one the mouse and `ui.pointer` share) on a +//! straight edge at 100 % zoom; the spacing is the distance between consecutive fastening points. + +use egui::Modifiers; +use photocraft_engine::Session; +use photocraft_geom::Rect; +use photocraft_ui_egui::canvas::{ToolEvent, tool_event}; +use photocraft_ui_egui::state::Tool; +use photocraft_ui_egui::{PhotocraftApp, Services}; +use serde_json::json; + +const W: i32 = 900; +const H: i32 = 120; +/// The straight edge: white above, black below. +const EDGE_Y: f64 = 60.0; +const START_X: f64 = 20.0; +const END_X: f64 = 880.0; + +/// A white document with a black lower half, the Magnetic Lasso at `frequency`, 100 % zoom. +fn app(frequency: f32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": W, "height": H})).unwrap(); + app.sync_views(); + app.session + .edit("paint", |doc, _| { + let bg = doc.layers[0].surface_mut().unwrap(); + bg.fill_rect(Rect::new(0, 0, W, H), &[1.0, 1.0, 1.0, 1.0]); + bg.fill_rect(Rect::new(0, EDGE_Y as i32, W, H), &[0.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.ui.tool = Tool::MagneticLasso; + app.ui.tool_options.magnetic_frequency = frequency; + assert!((app.point_zoom() - 1.0).abs() < 1e-6, "the test measures at 100 % zoom"); + app +} + +/// Click the first point on the edge, then move along it to the far end in 2 px steps; with +/// `held` the button stays down for the whole walk, otherwise it is released after the click. +fn walk(app: &mut PhotocraftApp, held: bool) { + let m = Modifiers::NONE; + tool_event(app, ToolEvent::Down { x: START_X, y: EDGE_Y, pressure: 1.0 }, m); + if !held { + tool_event(app, ToolEvent::Up { x: START_X, y: EDGE_Y }, m); + } + let mut x = START_X; + while x < END_X { + x = (x + 2.0).min(END_X); + tool_event(app, ToolEvent::Move { x, y: EDGE_Y, pressure: 1.0 }, m); + } +} + +/// The distances between consecutive fastening points of the border so far. +fn gaps(app: &PhotocraftApp) -> Vec { + let m = &app.ui.magnetic; + let pts: Vec<[f64; 2]> = m.anchors.iter().map(|&i| m.path[i]).collect(); + pts.windows(2).map(|w| (w[1][0] - w[0][0]).hypot(w[1][1] - w[0][1])).collect() +} + +/// Every gap is within 10 % of `want`, and there are at least `min` of them. +fn check(frequency: f32, held: bool, want: f64, min: usize) { + let mut a = app(frequency); + walk(&mut a, held); + let g = gaps(&a); + let state = if held { "held" } else { "up" }; + assert!(g.len() >= min, "Frequency {frequency}, button {state}: only {} gaps {g:?}, wanted at least {min} of ~{want}", g.len()); + for (i, d) in g.iter().enumerate() { + assert!((d - want).abs() <= want * 0.10, "Frequency {frequency}, button {state}: gap {i} is {d:.1} px, the reference measures ~{want} ({g:?})"); + } +} + +#[test] +fn button_up_spacing_at_frequency_0() { + check(0.0, false, 166.0, 4); +} + +#[test] +fn button_held_spacing_at_frequency_0() { + check(0.0, true, 39.0, 20); +} + +#[test] +fn button_up_spacing_at_the_default_frequency_57() { + check(57.0, false, 85.0, 9); +} + +#[test] +fn button_held_spacing_at_the_default_frequency_57() { + check(57.0, true, 20.0, 40); +} + +#[test] +fn button_up_spacing_at_frequency_100() { + check(100.0, false, 24.0, 30); +} + +#[test] +fn button_held_spacing_at_frequency_100() { + check(100.0, true, 5.0, 150); +} + +#[test] +fn releasing_the_button_widens_the_spacing_again() { + let mut a = app(57.0); + let m = Modifiers::NONE; + tool_event(&mut a, ToolEvent::Down { x: START_X, y: EDGE_Y, pressure: 1.0 }, m); + for x in (22..=300).step_by(2) { + tool_event(&mut a, ToolEvent::Move { x: f64::from(x), y: EDGE_Y, pressure: 1.0 }, m); + } + let held_points = a.ui.magnetic.anchors.len(); + tool_event(&mut a, ToolEvent::Up { x: 300.0, y: EDGE_Y }, m); + for x in (302..=600).step_by(2) { + tool_event(&mut a, ToolEvent::Move { x: f64::from(x), y: EDGE_Y, pressure: 1.0 }, m); + } + let g = gaps(&a); + let after = &g[held_points - 1..]; + assert!(after.len() >= 2, "points keep fastening after the release: {g:?}"); + assert!(after.iter().skip(1).all(|d| (d - 85.0).abs() <= 8.5), "after the release the gaps are ~85 px: {g:?}"); +} + +#[test] +fn out_of_range_and_non_finite_frequencies_are_clamped() { + // Above 100 behaves as 100, below 0 as 0, and NaN as the default 57 (button up). + check(250.0, false, 24.0, 30); + check(-30.0, false, 166.0, 4); + check(f32::NAN, false, 85.0, 9); +} + +/// Walks the edge like `walk`, at `zoom` (1.0 = 100 %), with the pointer moving 2 screen px a step +/// below 100 % and 4 screen px from 100 % up (coarser steps give alternating gaps at 50 %). +fn walk_at(app: &mut PhotocraftApp, held: bool, zoom: f32) { + app.ui.views[0].zoom = zoom; + assert!((app.point_zoom() - zoom).abs() < 1e-6, "the view is at the zoom under test"); + let m = Modifiers::NONE; + let step = (if zoom < 1.0 { 2.0 } else { 4.0 }) / f64::from(zoom); + tool_event(app, ToolEvent::Down { x: START_X, y: EDGE_Y, pressure: 1.0 }, m); + if !held { + tool_event(app, ToolEvent::Up { x: START_X, y: EDGE_Y }, m); + } + let mut x = START_X; + while x < END_X { + x = (x + step).min(END_X); + tool_event(app, ToolEvent::Move { x, y: EDGE_Y, pressure: 1.0 }, m); + } +} + +/// Frequency 57 at `zoom`: every gap (document px) is within 10 % of `want_doc`. +fn check_at(zoom: f32, held: bool, want_doc: f64, min: usize) { + let mut a = app(57.0); + walk_at(&mut a, held, zoom); + let g = gaps(&a); + let state = if held { "held" } else { "up" }; + assert!(g.len() >= min, "zoom {zoom}, button {state}: only {} gaps {g:?}, wanted at least {min} of ~{want_doc}", g.len()); + for (i, d) in g.iter().enumerate() { + assert!( + (d - want_doc).abs() <= want_doc * 0.10, + "zoom {zoom}, button {state}: gap {i} is {d:.1} document px, the reference measures ~{want_doc} ({g:?})" + ); + } +} + +#[test] +fn button_up_spacing_halves_below_100_percent_in_screen_px() { + check_at(0.5, false, 84.0, 9); // 42 screen px + check_at(2.0 / 3.0, false, 63.0, 12); // 42 screen px +} + +#[test] +fn button_up_spacing_stays_84_screen_px_above_100_percent() { + check_at(2.0, false, 42.0, 18); + check_at(3.0, false, 28.0, 28); +} + +#[test] +fn button_held_spacing_is_the_stored_value_in_document_px_above_100_percent() { + check_at(2.0, true, 20.0, 40); // 40 screen px + check_at(3.0, true, 20.0, 40); // 60 screen px +} diff --git a/docs/architecture.md b/docs/architecture.md index 0125f266939..ee9650cb102 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -183,7 +183,7 @@ photocraft/ 3. **`photocraft-psd` depends on no workspace crate.** The doc ⇄ PSD mapping lives in `io`. This keeps the PSD crate publishable and reusable by other projects. The same holds for every standalone crate, with one documented exception: `codecs` → `heif` (both standalone and publishable; `STANDALONE_EXCEPTIONS` in `xtask/src/layers.rs`). 4. **wasm gate:** every crate in L0–L5 (except feature-gated native backends) must build for `wasm32-unknown-unknown`. CI runs `cargo build -p photocraft-engine --target wasm32-unknown-unknown --no-default-features --features web`. 5. `gpu` is optional for `engine`. Engine features are `gpu` (default on) and `cpu-only`, and `cpu-only` builds are what the headless CLI and CI tests use. -6. **C dependencies** (optional LibRaw, pdfium) only behind features, only in `codecs`, `raw` or `io`, and never on by default for the web target. +6. **C dependencies** (optional LibRaw) only behind features, only in `codecs`, `raw` or `io`, and never on by default for the web target. --- diff --git a/docs/atn.md b/docs/atn.md index 19f2611293a..be7eca3787e 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -88,6 +88,7 @@ row; the action still adds only one Undo entry. | `ClrR` | colorRange | Color Range | `select.colorRange` | | (string ID only) | colorLookup | Color Lookup (embedded 3D LUT forms) | `image.adjustments.colorLookup` | | `Btch` | batch | Batch (supported folder and naming forms) | `file.automate.batch` | +| `CrtD` | (no string ID) | Create Droplet (destination Folder with Override Action "Save As"; other forms stay kept steps) | `file.automate.createDroplet` | | (string ID only) | shapeBlur | Shape Blur (custom shape by installed ID) | `filter.blur.shapeBlur` | | (string ID only) | cafWorkspace | Content-Aware Fill | `edit.contentAwareFill` | | (string ID only) | perspectiveWarpTransform | Perspective Warp (indexed layout and warp planes) | `edit.perspectiveWarp` | @@ -995,9 +996,7 @@ the recorded steps back from the saved Actions panel. Ranges come from the dialo | Convert Mode, several layers, "Don't Flatten" | `T ` as a **class** (`Grys`), `Fltt` false, `Rstr` false | With `Fltt` and `Rstr` the step plays without the flatten question. | | New Group with a colour | `Usng` (`Clr ` label), `layerSectionStart`, `layerSectionEnd`, top-level `Nm ` | | | Shear (`Shr `) | `ShrP` list of `Pnt ` (`Hrzn`, `Vrtc` doubles), `UndA` (`WrpA` wrap around, `RptE` repeat edge pixels), `ShrS` long, `ShrE` long | The dialog writes whole numbers: `Hrzn` -64..63 (dragging past the grid clamps), `Vrtc` 1..128 with the first point at 1 and the last at 128; dragging a point out of the grid deletes it. `ShrS` is 0 and `ShrE` the last point's index (count - 1). Script runs take any value unclamped and crashed on far-off `ShrE`/`ShrS` or a first `Vrtc` below 1. | -| Gradient tool, default mode | two steps: Make (`null` = class `contentLayer`, `Usng` = `Type` gradient layer object) then Set (`null` = `contentLayer` target, `T ` = the same object plus `Algn` false) | The object: `Dthr`, `gradientsInterpolationMethod`, `Angl` angle, `Type`, `Scl ` percent, `Ofst` (`Hrzn`, `Vrtc` percent), `Grad`. The new tool makes a gradient fill layer; Make holds the first click, Set the finished drag. | | Print (`print`) then Print One Copy (`printOneCopy`) | `print`: `null` = reference `printOutput` property of the target document; `printOutput` (`PstS`, `Inte`, `printSixteenBit`, `printerName` text, `printProofSetup`), `printViewProofSetup`, `printOutputOptions` (caption, marks, `Bckg` colour, `BrdT`, `Bld `, `Rslt`, `vectorData`, `PgPs`, `Left`, `Top `, `Scl `, crop rectangle), `osSpecificPrintInfo` raw data, `Cptn` text. `printOneCopy` has no keys. | Pressing Print in the dialog records both steps. | -| Create Droplet (`CrtD`) | `In ` alias: the droplet file; `Fldr` bool (include subfolders); `Usng` reference to the action; then the Batch keys below (destination, file naming, error log) when set | The same settings as Batch, with the droplet file instead of a source folder. | | Sky Replacement (`skyReplacement`) | `shiftEdge` double, `borderSmoothness` long (fade edge, default 50), `Brgh` long, `temperature` long, `harmonizationOpacity` long (35), `foregroundLightingOpacity` long, `lightingMode` blend mode (`Mltp`), `edgeLightingOpacity` long (70), `skyReplacementOutput` (`skyReplacementOutputToNewSheets` new layers, …), `Idnt` text (the sky preset's id), `Nm ` text (its name), `File` alias (the sky image) | Scale and Flip are **not** in the step: with 150 % and Flip on, the returned keys were the same. `foregroundLightingOpacity` is computed from the sky (44 and 29 for two skies). Without a chosen sky the dialog returns no keys. | **Not recordable:** Edit > Purge (no step is recorded; this version's Purge menu has Clipboard, diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 04e0eecc04d..99501ca8d50 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (the eraser end of the pen switches to the Eraser on Windows with Windows Ink on, #2285; no estimate changed; Art History Brush Opacity on the options bar, #10263; no estimate changed; Shift+right-click menu for every painting tool: Edit Brush… and the tool's own Mode list; Brush Shift+right-click Mode picker and Escape dismissal; Art History Brush row added as partial, 8 of 10 styles, TOOL-213-20, #1610; hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; Mode for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; named, stateful Brush Settings locks and visible keyboard focus; independent Brush Pose tilt-axis overrides; Transfer Wetness/Mix tool applicability; dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed; 2026-10-11: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Both Axes on Scatter headers, #3825 partial; Brush Tip Shape Spacing checkbox and percentage share one header, #3825 partial; Angle/Roundness fields left of the Brush Tip Shape widget, labels right-aligned, #3825 partial; the eraser end of the pen switches to the Eraser on Windows with Windows Ink on, #2285; no estimate changed; Art History Brush Opacity on the options bar, #10263; no estimate changed; Shift+right-click menu for every painting tool: Edit Brush… and the tool's own Mode list; Brush Shift+right-click Mode picker and Escape dismissal; Art History Brush row added as partial, 8 of 10 styles, TOOL-213-20, #1610; hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; Mode for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; named, stateful Brush Settings locks and visible keyboard focus; independent Brush Pose tilt-axis overrides; Transfer Wetness/Mix tool applicability; dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed; 2026-10-11: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **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 @@ -19,8 +19,11 @@ tablets against Photoshop side by side. | Section | Status | Evidence | |---|---|---| | Brush Tip Shape: size, angle, roundness, hardness, spacing, flip X/Y | done | `brush.rs` | +| Brush Tip Shape Spacing checkbox and percentage share one header without a duplicated label | done | #3825 (Spacing header only); UI regressions cover all 14 themes, enabled/disabled editing, percentage limits, fractional-value retention and `tools.setBrush` routing. | +| Brush Tip Shape places aligned Angle/Roundness controls left of the interactive widget | done | #3825 (Angle/Roundness layout only); UI regressions cover 14 themes at two panel widths, imported-value retention, numeric limits, actual arrow/handle gestures and existing command routing. | | 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 | | +| Scattering and Dual Brush place Both Axes between the Scatter label and percentage | done | #3825 (Both Axes header placement only); all-theme UI regressions cover both axis values, fractional-value retention, percentage limits, label scrubbing, amount-track edits and existing command routing. | | 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 | | @@ -133,7 +136,10 @@ tablets against Photoshop side by side. | 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction | | 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Hue Jitter uses the same themed amount slider as other jitter controls (#3825, partial). No brush-feel estimate changed. | +| 2026-10-13 | minor | Brush Tip Shape combines the Spacing checkbox and percentage into one header (#3825, partial), retaining the value while disabled. No brush-feel estimate changed. | | 2026-10-13 | minor | Brush-only Shift+right-click Mode menu shares options-bar choices, including Clear; preserves existing right-click gestures and consumes outside dismissal without painting (#4929). No brush-feel estimate changed. | +| 2026-10-13 | minor | Scattering and Dual Brush place Both Axes on the shared Scatter header (#3825, partial), preserving label scrubbing and values. No brush-feel estimate changed. | +| 2026-10-13 | minor | Angle/Roundness controls use right-aligned labels in the left column, with the interactive widget on the right (#3825, partial). Units, limits and gestures retained; no brush-feel estimate changed. | | 2026-10-13 | minor | Transfer Wetness/Mix controls follow the active tool and retain their settings while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-14 | minor | The 12 existing Brush Settings section locks expose translated names and locked state to AccessKit and show themed keyboard focus (#3477 workflow); icons, hit targets and command behavior retained. No brush-feel estimate changed. | | 2026-10-14 | minor | Options-bar Mode for Clone Stamp, Pattern Stamp, History Brush and Healing (kept in each tool's brush) and Paint Bucket (with Clear); each tool's first brush starts in Normal. No brush-feel estimate changed. | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index dc4f36dddbe..7ebf31bbe90 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -56,6 +56,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`. - `ui.set` also accepts `strokeTrace` (`true`, `false`, `"stderr"` or `"off"`): turn the stroke latency trace on or off at runtime; each stroke's summary is in `ui.inspect` `perf.strokeTrace`. A CSV file of every frame is set only at launch (`PHOTOCRAFT_STROKE_TRACE=` / `--stroke-trace `): the control channel doesn't write files outside the automation write root - `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, effect?, mode?, attribute?, color?, smart?}`, any subset. `by` is `kind`, `name`, `effect`, `mode`, `attribute`, `color`, `smartObject`, `artboard` or `group` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `effect` (#10880) is `bevelEmboss`, `stroke`, `innerShadow`, `innerGlow`, `satin`, `overlay` (Color, Gradient or Pattern Overlay), `outerGlow` or `dropShadow`, and lists the layers with that effect turned on; `mode` is one of the 27 layer blend modes (`Multiply`, `Screen`), not Pass Through, which the Mode list doesn't offer (#10878); `attribute` (#10881) is `visible`, `locked`, `empty`, `linked`, `clipped`, `layerMask`, `vectorMask`, `layerEffects` or `advancedBlending`, or one of their opposites `notVisible`, `notLocked`, `notEmpty`, `notLinked`, `notClipped`, `noLayerMask`, `noVectorMask`, `noLayerEffects`, `noAdvancedBlending`; `color` is a layer colour label id (`none`, `red`, `orange`, `yellow`, `green`, `seafoam`, `blue`, `indigo`, `magenta`, `fuchsia`, `violet`, `gray`; #5898); `smart` lists the Smart Object toggles that are on (`linked`: linked to a file, `missing`: linked to a file that was not there when the app last looked, `embedded`). Kind, Name and Smart Object filter only once a toggle is on or text is typed; Effect, Mode, Attribute and Color always filter, by Bevel & Emboss, by Normal, by Visible and by no colour at first, as in Photoshop. Artboard has no field of its own: it lists the artboards that hold the selected layers, with everything inside them (closed groups stay closed), and does not filter when nothing is selected or a selected layer is in no artboard. Group (#10883) has no field of its own either and filters at once: it lists every group that is not an artboard with everything inside it (the groups inside an artboard too), and hides the layers that are in no group; each group stays open or closed as it is. Fields left out keep their value; a bad one is an error and changes nothing. +- `select.findLayers` (#10884, Select › Find Layers) filters the Layers panel by Name and selects none of the matches; as with any filter, a selected layer the filter hides is deselected (#10879). With `name` it sets the Name text; without it the text is kept. Through `engine.execute` or an action step that is all; through the menu, the shortcut or `ui.menu.invoke` without `name`, the Layers panel is also shown and its Name field takes the keyboard focus with the text selected (unless Filter Options › Hide hid the filter row, which stays hidden). The reply lists the ids of the layers the Name text matches in `selected` (trimmed, ignoring case: a blank text lists every layer, a text nothing matches none) and the Name filter applied in `layerFilter` (`{by: "name", name}`). - `ui.set` also accepts the Histogram panel: `histogramPanel` `{view?, channel?, showChannelsInColor?, showStatistics?, source?}` (any subset, kept with the preferences): `view` is `compact`, `expanded` or `allChannels`; `channel` is `composite`, `red`, `green`, `blue`, `cyan`, `magenta`, `yellow`, `black`, `a`, `b`, `luminosity` or `colors` (one the document's mode lacks shows as its composite); `source` is `entireImage`, `selectedLayer` or `adjustmentComposite` (Entire Image is counted in Compact View, when the document has one layer, or when no adjustment layer is selected for Adjustment Composite). `histogramRange` is `[from, to]` (levels 0..255) or `null`: the dragged tonal range whose Count and Percentile the statistics show. `histogramRefresh: true` is Uncached Refresh: the current state is recounted at cache level 1. `ui.inspect` › `histogram` reports the options, the channel and source as they apply, the channels the document offers, whether the shown histogram is `current` (counted for the document's revision, source and cache level the panel wants now), its `cacheLevel`, `maxCacheLevel` (Preferences › Performance › Cache Levels), `preview` (an adjustment's preview is counted) and `stats` (`mean`, `stdDev`, `median`, `pixels`, `level`, `count`, `percentile`, `clipped` percent at 0 and 255). Histograms are counted while the panel is shown. - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. - Brush Pose's `pose.overrideTiltX` and `pose.overrideTiltY` are independent booleans. For example, `engine.execute {"command":"tools.setBrush","params":{"pose":{"enabled":true,"tiltX":45,"overrideTiltX":true,"overrideTiltY":false}}}` fixes X at 45° while Y follows the stylus. Pose tilts are degrees (±90); the Brush Settings panel displays percentages (±100). Old stored `overrideTilt` values migrate to both axes. In command patches, `overrideTilt` sets both axes unless an explicit axis field in the same patch overrides it. `brush.get` includes both axis flags and the legacy combined readback (`overrideTilt` is true only when both flags are true). Axis-only journals omit that derived combined key so replay retains the other axis. Present flag values must be booleans; null and other types return an error. @@ -85,7 +86,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. - Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type. - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows -- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. For a painting tool's Shift+right-click, see the Mode menu exception below. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. +- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out keep the view centre with the non-temporary Zoom tool and Zoom Clicked Point to Center off; other cases retain their pressed-point behaviour; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. For a painting tool's Shift+right-click, see the Mode menu exception below. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. - Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape. - Image › Adjustments › Curves on top, no eyedropper armed (#7325): a `down` with `command: true` adds a point on the curve the dialog shows (its Channel popup), at the pixel's tone (RGB: Rec. 709 luma; Gray: the mean) and at the curve's current output there, so the preview does not change; with `shift: true` as well, one point on each of Red, Green and Blue at that channel's value. It samples the merged image like the eyedroppers, never moves an existing point (a point already within 2 levels is selected instead, a 16-point curve takes no more), and the tool is not driven. Nothing is added with a pencil-drawn curve, in Lab or CMYK, or with a mask, saved channel or Quick Mask targeted. - Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it. @@ -564,7 +565,10 @@ Open also applies the other Camera Raw Defaults (Sharpen For as part of the Came colour space, bit depth and Open as Smart Object, so the step works on the pixels the dialog previewed; Apply auto tone fills the dialog), reported as `defaults` in the commit's reply; the resolution is set when the file opens. With "Open in Camera Raw" off the sidecar and the defaults -apply without the dialog. +apply without the dialog. A sidecar's crop (`crs:HasCrop="True"` with `CropTop` / `CropLeft` / +`CropBottom` / `CropRight`, fractions of the raw as stored, before its Orientation) crops the +document as Open re-develops it, before the Camera Raw step; the dialog previews the whole frame. +A straightened crop (`CropAngle` not 0) is named as not supported and not applied. **Opening a raw file.** An interactive open (File › Open, Open Recent, drag and drop, the command line) of a camera raw developed from its sensor data shows this dialog first, titled diff --git a/docs/development.md b/docs/development.md index 1fa912575f8..998c9b8b95c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -404,6 +404,25 @@ none). A missing translation shows in English and fails nothing: `cargo xtask ci as its last step so the list appears in the log, and only prints a warning if the report itself can't run. +## Panel command routing + +Every panel button must dispatch an engine command by id (AGENTS.md rule 1), so the UI, CLI, +control channel and MCP stay equivalent. `cargo xtask panel-commands` (also a step of +`cargo xtask ci` and of CI) scans `crates/ui-egui/src` outside test code for click handlers: the +block of an `if` whose condition calls `.clicked()`, `.changed()`, `.dragged()` and friends, or a +widget function that takes a `Ui` and returns `bool`. Inside one it fails, with file:line, on a +direct document or session change: `active_mut()`, `Arc::make_mut` on a doc, +`session.undo/redo/select_layer/add_document/close(…)` or an assignment to a session field +(`app.session.tools.foreground = …`). Dispatch the command instead (`app.run`, `menus::invoke`). +A passing scan is not complete coverage. The scanner is lexical: it does not follow calls into +helpers, and it only recognises an `if` block (a `match` arm guard with a block counts) or a +`let` local holding a response, so a `match` on a response without such a guard, a response kept +in a struct field and tested later, or a closure callback is not checked. Preferences +(`edit_prefs`), shell view state (`app.ui`, the `UI_COMMANDS`) and switching the active document +are out of scope. Existing violations are listed in +`ALLOWLIST` in `xtask/src/panel_commands.rs` by file and function, each with its reason; an entry +that matches nothing any more is an error, so the list only shrinks. + ## Testing strategy - **Unit and property tests** in every crate. proptest is used for tile COW, regions and codecs. @@ -661,6 +680,25 @@ except Place, which is a job that `poll_jobs` steps on the UI thread (a *coopera ## Indexed Color timings +The stock Indexed Color dialog exposes Matte: None, Foreground Color, Background Color, +White, Black, 50% Gray, Netscape Gray and Other… (the shared Color Picker). Choosing Other… +opens the picker without replacing the current matte; Cancel leaves that choice unchanged, and +OK commits the picked `#rrggbb` in document RGB coordinates. Automation accepts `matte` as +`none`, `foreground`, `background`, `white`, `black`, `gray` (50% Gray, encoded +`#808080`), `netscapeGray` (Netscape Gray, encoded `#c0c0c0`) or `#rrggbb`. Foreground/background +tool colours are converted from the working RGB profile into the document profile and clipped +to its encodable RGB range before blending. A selected matte blends all +partially transparent edges; with Transparency on, fully clear pixels stay transparent. +With Transparency off, the selected matte also fills clear pixels. None retains the existing +hard-edged transparency/default flattening behavior. Indexed conversion retains the document's +ICC profile and encoded RGB sample representation; matte colours are interpreted in the document +RGB coordinate space. Default Indexed → RGB expands the stored RGB samples without converting +them to the working profile; an explicitly supplied destination profile still requests a CMS +conversion. Inline and worker dialog previews inherit the current foreground/background tool +colors, and changing those colors invalidates both cached frames and pending worker results, +including when the preview proxy factor changes. A same-color frame at the old factor can +remain visible during a drag until the new result arrives. This follows Adobe's documented +[Indexed Color conversion options](https://helpx.adobe.com/photoshop/desktop/adjust-color/color-modes/conversion-options-for-indexed-color-images.html). `cargo run --release -p photocraft-engine --example indexed_color_perf -- image.png` measures palette construction, Floyd–Steinberg diffusion, and the complete full-resolution CPU preview path (proxy copy, engine command, result composition; excludes GPU upload/presentation). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 468365c6f6d..207778cb377 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-06 · **Change:** minor (scripted PSD/PSB saves take `maximizeCompatibility`, and the CLI `--no-maximize-compatibility`, #8064; TIFF Options on Save As and Save a Copy: Image Compression None/LZW/ZIP, Save Image Pyramid, Save Transparency and Discard Layers, also `tiffTransparency`/`tiffPyramid` for scripts, the CLI and MCP, #8827; `.atn`: 145 supported events including Shear after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-08 · **Change:** minor (Camera Raw: an `.xmp` sidecar's crop crops the opened raw, and `crs:RawFileName` is no longer named as unsupported, #9985; CLI `--metadata none|all` for `convert`, `run --out` and `batch`: `none` leaves the document XMP and free-form text out of flat exports, as Export As's Metadata: None, `all` the default, #9466; Hasselblad 3FR/FFF, the X2D II 100C, decodes; scripted PSD/PSB saves take `maximizeCompatibility`, and the CLI `--no-maximize-compatibility`, #8064; TIFF Options on Save As and Save a Copy: Image Compression None/LZW/ZIP, Save Image Pyramid, Save Transparency and Discard Layers, also `tiffTransparency`/`tiffPyramid` for scripts, the CLI and MCP, #8827; `.atn`: 145 supported events including Shear after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -27,11 +27,11 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/it/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open 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, YCbCr read as RGB (JPEG-compressed with subsampled chroma, or uncompressed without subsampling; #10316), BigTIFF, every IFD, banded parallel decode; flat pages up to 300,000 px a side open a group of rows at a time into the layer, with no full-size copy, and are refused with an error when the machine lacks the memory (Linux: free RAM and swap, cgroup limits; #8169); layered TIFF both byte orders, including Photoshop mixed-order type blocks on read (#4758/#4771) and TySh text descriptors written in PSD order (#9098) and Photoshop's 14-byte `LMsk` block (#9095); TIFF Options on Save As and Save a Copy (Image Compression None/LZW/ZIP, Save Image Pyramid, Save Transparency, Discard Layers and Save a Copy; scripts pass `tiffCompression`, `tiffTransparency`, `tiffPyramid`, `tiffLayers`; #8827), no JPEG compression, Pixel Order or Byte Order (the encoder writes interleaved samples in the machine's byte order); undecodable type keeps saved pixels (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); Save Transparency on keeps the transparency as the first extra sample ahead of the channels (a layered file marks it with `MTrn`, as Photoshop does), off mattes on white; unset, it is kept only without alpha or spot channels | -| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. No Matte, progressive Scans (3–5) or size preview | -| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | +| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP (the CLI's `--metadata none` leaves the document XMP out, #9466); Save keeps the opened file's quality (estimated from its quantisation tables, #2399); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. No Matte, progressive Scans (3–5) or size preview | +| PNG | R/W | yes | yes | 8/16-bit, ICC/text (the CLI's `--metadata none` leaves the document XMP and text out, #9466); APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | | BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode and Indexed Color documents save as palette BMPs (no transparency) | -| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399); Save for Web (Legacy) writes it too, lossy at its Quality or lossless, a format Photoshop's legacy dialog lacks (#6391). File › Open keeps an animation's first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the durations (#1198) | +| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP (the CLI's `--metadata none` leaves the document XMP out, #9466); Save keeps an opened file lossy (at its quality) or lossless (#2399); Save for Web (Legacy) writes it too, lossy at its Quality or lossless, a format Photoshop's legacy dialog lacks (#6391). File › Open keeps an animation's first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the durations (#1198) | | 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 | @@ -40,7 +40,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Portable Bit Map (PBM/PGM/PPM/PNM/PFM/PAM) | R/W | yes | yes | 8/16/32f | | FITS | R/W (FITS Liberator plug-in) | yes | yes | 8/16/32f gray or RGB, header cards kept; no fpack, no debayer | | Targa | R/W | yes | yes | 8-bit | -| Photoshop PDF | R/W | no (#2541) | yes, one flattened page (Save As / Save a Copy / CLI, #3401; also print and artboards-to-PDF) | No vector/text PDF, no multi-page save, no PDF presentation, layers are not preserved (FILE-215-10); read still missing (#2541) | +| Photoshop PDF | R/W | yes, raster pages (craft-pdf; up to 16 pages, 8192 px per side, 64M px per page) | yes, one flattened page (Save As / Save a Copy / CLI, #3401; also print and artboards-to-PDF) | Read renders each page as a raster artboard: no editable text/vector layers yet (craft-pdf `interpret` is the follow-up), no PDF forms or annotations; write is still one flattened page, no multi-page save, no PDF presentation, layers are not preserved (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) | @@ -73,8 +73,9 @@ supports many more, including CR3). | 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 | +| Hasselblad 3FR/FFF | uncompressed only; compressed (lossless-JPEG) files: embedded preview only | X2D II 100C uses a measured Bayer phase; other bodies assume it with a warning (`crates/raw/src/hasselblad.rs`) | +| Others (CRW, IIQ, X3F, SRW, MRW, ERF…) | embedded preview only | | +| .xmp sidecars, highlight modes, raw preferences | partial (sidecars and preferences; no highlight modes) | 2026-10-28: a sidecar's crop (`crs:HasCrop` with `CropTop` / `CropLeft` / `CropBottom` / `CropRight`, fractions of the developed frame as stored, mapped through its Orientation) crops the opened raw: Open re-develops and crops before the Camera Raw step, so the post-crop vignette centres on the crop; the dialog previews the whole frame. A straightened crop (`CropAngle` not 0) isn't applied and is named, as are unreadable or empty ones; `HasCrop` missing or `False` means no crop; `RawFileName` is bookkeeping (#9985; `crates/io/src/camera_raw_xmp.rs` tests, `crates/io/tests/it/raw.rs` `camera_raw_tuning_crops_in_the_stored_orientation`, `camera_raw_ui/tests.rs` `a_sidecar_crop_crops_the_opened_raw`). 2026-10-13: sidecars fill the open-time dialog and Open writes them back; Camera Raw Defaults wired. 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) | 2026-10-11: on #2135's embedded-JPEG rendering work, open-time Camera Raw tuning @@ -151,8 +152,12 @@ The original reference entry retains its source date and historical totals; curr | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Photoshop PDF read: pages import as raster artboards through the shared craft-pdf (Hayro) renderer, up to 16 pages, 8192 px per side and 64M px per page; editable text/vectors and PDFium are gone. Write is unchanged. | | 2026-11-06 | minor | Scripted PSD/PSB saves take `maximizeCompatibility` (Save, Save As, Save a Copy, `app.save`, serve `doc.save`, MCP `doc_save`/`doc_export`, Batch, Image Processor, Layers to Files) over Preferences › File Handling for one save, and the CLI `--no-maximize-compatibility` (#8064). Layer channels and the merged image were already RLE (ZIP with prediction at 32-bit), as Photoshop writes them (#8645) | +| 2026-10-24 | minor | Hasselblad 3FR/FFF: the X2D II 100C's 16-bit uncompressed CFA decodes; compressed 3FR still opens as the embedded preview | | 2026-11-02 | minor | Actions Shear (`Shr `) loads, plays and saves the measured curve points and edge mode; unsafe subranges and unrepresentable forms remain kept steps (#10605). | +| 2026-11-03 | minor | CLI `--metadata` (`none` or `all`) for `convert`, `run --out` and `batch`: `none` leaves the document's XMP packet and free-form text out of flat exports (PNG, JPEG, WebP, TIFF, …) through Export As's Metadata: None path; `all`, which keeps them as Save As does, stays the default. The ICC profile, EXIF and resolution are written as they are without the option (a palette PNG carries only its resolution, either way); PSD, PSB and `.pcraft` always keep the XMP (#9466) | +| 2026-11-03 | minor | Camera Raw `.xmp` sidecars: the crop (`crs:HasCrop` with `CropTop` / `CropLeft` / `CropBottom` / `CropRight`, fractions of the raw as stored, mapped through its Orientation) crops the opened raw, with the dialog's Open and without the dialog; out-of-range fractions are clamped, and inverted, unreadable or straightened (`CropAngle` not 0) crops are named as not supported instead of applied; PhotoCraft's own sidecar keeps the crop when it is rewritten; `crs:RawFileName` is no longer named as unsupported (#9985) | | 2026-11-01 | minor | `.atn`: 23 newer measured forms mapped, including explicit Link/Unlink, Smart Object file operations, Make Type to Shape, radius-only Shape Blur, Artboard from Group and the Crop Photos parent; supported in batch 13; Link Layers keeps the measured toggle. Artboard forms with unmeasured settings and generated Crop helper steps remain kept. | | 2026-11-01 | minor | Recorded direct Gradient Map and Lab-range Replace Color load, play and retain their measured settings on save; unsupported forms remain preserved (part of #10607). | | 2026-11-02 | minor | TIFF Options on Save As and Save a Copy (#8827): Image Compression (None, LZW, ZIP), Save Image Pyramid (half-size levels marked reduced-resolution, down to 64 px, alpha-weighted; +45 ms on a 30 MP RGB/8 save), Save Transparency (on: transparency kept ahead of alpha and spot channels, `MTrn` in a layered file; off: matted on white) and Discard Layers and Save a Copy. Scripts, `doc.save`, MCP `save` and the CLI (`--tiff-transparency`, `--tiff-pyramid`) take the same answers. JPEG compression, Pixel Order and Byte Order not offered | diff --git a/docs/gaps.md b/docs/gaps.md index e27e0931291..7a038cf6cf1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (G12: the eraser end of flipped pens selects the Eraser on Windows with Windows Ink on, #2285; no estimate changed; RGB and CMYK documents preview their spot inks as an overprint on the canvas and in the PSD thumbnail, FILE-215-17 narrows to PDF Separation, #10249; G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (G4: Both Axes on Scatter headers, #3825 partial; G4: combined Spacing checkbox and percentage header, #3825 partial; G14: Hasselblad 3FR/FFF, the X2D II 100C's uncompressed CFA, decodes; G4: Angle/Roundness fields left of the Brush Tip Shape widget, #3825 partial; G12: the eraser end of flipped pens selects the Eraser on Windows with Windows Ink on, #2285; no estimate changed; RGB and CMYK documents preview their spot inks as an overprint on the canvas and in the PSD thumbnail, FILE-215-17 narrows to PDF Separation, #10249; G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **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 @@ -269,6 +269,19 @@ what an agent can't finish alone. and 0/49% smoothing. Other presets, explicit spacing and remembered brushes retain their settings; reselect a built-in preset to take its new default. Other brush-feel and latency gaps remain open; percentages and estimates are unchanged. +- **Progress (2026-10-13, #3825 partial):** Brush Tip Shape's Spacing checkbox and + percentage share one header, removing the duplicated label. Disabled controls retain + the saved spacing; toggling does not round fractional imported values. The issue's + remaining layout and control gaps stay open. + +- **Progress (2026-10-13, #3825 partial):** Scattering and Dual Brush place Both Axes + between the Scatter label and percentage field. The existing amount track, label + scrubbing, percentage limits and disabled-section value retention are preserved. + The assessment's remaining layout and control gaps stay open. +- **Progress (2026-10-13, #3825 partial):** Brush Tip Shape places Angle/Roundness + controls in a left column with right-aligned labels and the interactive widget on + the right. Numeric units, limits, imported values and arrow/handle interactions + are retained; the assessment's other layout and control gaps remain open. - **Progress (2026-10-13, #3825 partial):** Transfer’s Wetness Jitter and Mix Jitter blocks are disabled outside the Mixer Brush, retaining their saved settings. Opacity and Flow keep their existing availability; other layout and brush-feel gaps remain open. @@ -465,6 +478,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | G14: Hasselblad 3FR/FFF, the X2D II 100C's uncompressed CFA, decodes; compressed 3FR still opens as the embedded preview | | 2026-11-06 | minor | G12: eraser end of flipped pens on Windows (Windows Ink on) selects the Eraser (#2285); no estimate changed | | 2026-10-29 | minor | G17: web build memory handling (#10024): up-front memory checks for Place, Image Size, smart-object re-render and Save; a low-memory warning; a stopped message when the module traps; PSD save reserves its whole output; no estimate changed | | 2026-11-02 | minor | G11: Batch Esc Continue/Stop, one plan and error log across interruptions, and read-only input previews without a progress bar (#4579); live tab parity remains open, AUTO-219-5 partial; readiness and estimate unchanged | @@ -558,7 +572,10 @@ what an agent can't finish alone. | 2026-10-13 | minor | G8: a Type tool click on the work path or the active shape layer's path starts type on it at the point clicked (TYPE-218-1, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G14: lossy DNG (#5112), `.xmp` sidecars, Camera Raw Defaults wired, Calibration panel and Auto; CR3 sensor decode ruled out under the clean-room rule | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | +| 2026-10-13 | minor | G4: combined Spacing checkbox/percentage header (#3825, partial); disabled-value and percentage-edit behavior retained; no readiness percentage or estimate changed | | 2026-10-13 | minor | G2: Content-Aware Scale (P28) measured: carve 2013 → 556 ms, row 3.28 → 1.64 s on a 4-core VM, output unchanged; baseline, budgets and estimate unchanged | +| 2026-10-13 | minor | G4: Scattering and Dual Brush Both Axes header placement (#3825, partial); label scrubbing and value retention preserved; no readiness percentage or estimate changed | +| 2026-10-13 | minor | G4: Angle/Roundness column and label alignment (#3825, partial); existing values and interactions retained; no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | | 2026-10-12 | minor | G11: `.atn` import maps 112 events (Select and Mask, Spatter, Ripple, Crystallize, Pattern Fill, mask targeting, Step Backward, points, Bitmap) | diff --git a/docs/plugins.md b/docs/plugins.md index 4f582ae9e44..bb2859cef8f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -100,6 +100,9 @@ exports. All integers are `i32` unless noted; pointers are offsets into the modu - `name`: the menu label (1–64 printable characters). Any script works, but control characters and Unicode format characters (bidi overrides and marks, zero-width spaces, soft hyphens) are rejected, except the zero-width joiner and non-joiner, and at least one character must be visible. + Invisible padding at the ends (white space, joiners, Hangul fillers, blank Braille and other + default-ignorable blanks, and variation selectors cut off by them) is removed before these checks, + so it does not count towards the limit; marks that belong to a kept character stay. `kind`: `"filter"` (the only kind in v1). - `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, not `id`/`layer`. The host validates the user's values before calling: missing values take the default, numbers are diff --git a/docs/scorecard.md b/docs/scorecard.md index cb666664b96..31443c19495 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,9 +9,9 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 51 | 85 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 47 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 | | [Tools](#tools) | 36 | 9 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 194 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 199 (target 0) | | [Type](#type) | 5 | 5 | 0 | | -| [Automation](#automation) | 5 | 3 | 3 | 662 of 662 menu items live (100.0%) | +| [Automation](#automation) | 5 | 4 | 2 | 662 of 662 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 5 | 1 | | | [Open bugs](#open-bugs) | 31 | 1 | 0 | | @@ -209,7 +209,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu ## Workspace and UI -**Settings that do nothing: 10 of 194** (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: 10 of 199** (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`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`. @@ -268,7 +268,7 @@ Menu wiring (`docs/parity-checklist.md`): 662 of 662 menu items live (100.0%). T | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds/batch_run.rs and batch_naming.rs: flat subfolder output and last-result-wins names measured in Photoshop 2026; live open-document source; file naming components and serials; UTF-16 LE error log; stop-on-error; CLI and Batch dialog, #4579. Desktop Esc asks Continue/Stop, retaining one plan and error log; a read-only canvas preview shows each scratch-session input without a progress bar. Inputs are not live tabs with Layers panel content, so full window parity remains open. | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | -| AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | +| AUTO-219-8 | xtask check that every panel button routes through a command id | partial | [#219](https://github.com/storytold/photocraft/issues/219) | cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 1 allowlisted function left (panels/color.rs color_field assigns the foreground/background colour instead of tools.setColors) | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/it/agent_tasks.rs runs 10 workflows over MCP | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c3b39eb6144..1ac44f19948 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -475,7 +475,7 @@ filled bar. This extends UI reachability and does not claim Photoshop-authored s | 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); 2026-10-10: Liquify 3500×2200, 800 px brush, visible 1:1 crop 3.20 → 3.63 ms p50 (5 runs) while refreshing ~9× as many pixels | medium-high on rasters | Complex layout documents still laggy (#125/#128); >16384 px GPU tiling in progress (#49). Liquify now displays source-resolution detail above 100% zoom; its ≤1 ms update target is not met (3.73 ms p95 on a Windows run; GPU upload excluded). 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 and compressed RAF still open (#50); Nikon "lossy after split" decoded since 2026-10-31. Non-DNG cameras have no calibration: their colour (matrix + tone curve) is fitted per file to the camera's embedded JPEG, with the neutral sRGB-primaries fallback when the fit doesn't clearly help (2026-10-08: 8 D3200 NEFs, mean ΔE vs the camera JPEG 11.6–26.0 → 1.0–7.5); measured per-model calibration still open. | +| 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); 2026-10-24: uncompressed Hasselblad 3FR/FFF (the X2D II 100C; 105 MP develops in about 1.7 s) | medium | CR3, compressed RAF and compressed 3FR still open (#50); Nikon "lossy after split" decoded since 2026-10-31. Non-DNG cameras have no calibration: their colour (matrix + tone curve) is fitted per file to the camera's embedded JPEG, with the neutral sRGB-primaries fallback when the fit doesn't clearly help (2026-10-08: 8 D3200 NEFs, mean ΔE vs the camera JPEG 11.6–26.0 → 1.0–7.5); measured per-model calibration still open. | | AI / generative | none | ~0% | Deferred by decision (#41). | | Ecosystem | Sandboxed WebAssembly plug-ins instead of .8BF; `.atn` actions load and save (2026-10-12); no ExtendScript/UXP; 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. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index fe8b7ee2728..d605faf62a1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Layer ▸ Smart Objects ▸ Reset Transform and the Layers panel row-menu item for a smart object layer: 100 % again in place with the Warp cleared and centred on the corners' average, #11662; each toolbar slot and tool key keeps its last tool across launches, #3569; Image › Adjustments › Selective Color keeps each colour's own values and applies all nine, #11463; History Options snapshot options, #3634; Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…, #11451; a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (a gradient stop dragged off the bar is removed in every stop editor, Gradient Map and Layer Style included, #10742; Image › Adjustments › Photo Filter… keeps Preserve Luminosity from its last OK and opens at Warming Filter (85), 25 %, as the reference application does, #9879; foreground/background swatches and exact CMYK picks restored across launches, #8485; Layer ▸ Smart Objects ▸ Reset Transform and the Layers panel row-menu item for a smart object layer: 100 % again in place with the Warp cleared and centred on the corners' average, #11662; each toolbar slot and tool key keeps its last tool across launches, #3569; Image › Adjustments › Selective Color keeps each colour's own values and applies all nine, #11463; History Options snapshot options, #3634; Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…, #11451; a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **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, @@ -55,6 +55,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Press-and-hold or click-drag opens the flyout | done | `toolbar.rs`; right-click, long-press, press-drag | | Edit Toolbar customization | done | `edit.toolbar` | | Last selected tool restored after quitting and relaunching; temporary tool switches excluded | done | `tool_memory.rs`; `prefs_ui::tests::selected_tool_survives_a_restart_without_option_changes`, `selected_tool_excludes_temporary_switches` | +| Foreground/background colours restored across launches, including separate mask colours, HDR/alpha and exact CMYK picks | done | `tool_memory.rs`; `prefs/prefs_ui/tests.rs`: `tool_colors_survive_restart_before_the_first_frame`, `tool_colors_keep_pixel_and_mask_pairs_when_exiting_on_a_mask`, `tool_colors_restore_cmyk_inks_for_picker_and_fill_after_swapping_and_exiting_on_a_mask`, `tool_colors_preserve_finite_hdr_components` | ## Free Transform (⌘T) and transform handles @@ -223,6 +224,7 @@ for partial sets; [scope, tests and visual evidence](multi-channel-editing.md). | Paragraph box overflow mark: text past the box shows a + in its lower-right handle | done (2026-10-29): text a box can't hold (dropped layout lines) enlarges the box's lower-right handle and shows a black + in it, as the reference application does; agents read `typeBoxOverflow` in `ui.inspect` (false once the edit session ends). Differs: shown while the inline edit session is on the box, and not while its transform handles are up | `text::TextLayout::overflows`, `canvas_tests.rs` `an_overflowing_box_shows_a_plus_in_its_enlarged_lower_right_handle` | | Image › Adjustments › Selective Color: each of the nine colours keeps its own Cyan, Magenta, Yellow and Black; choosing another colour shows its values; OK and Preview apply all nine | done (#11463): one editor shared with the Properties panel (`adjust_editors/selective_color.rs`, `adjust_dialog/tests.rs`, 2026-11-05). Preset menu and `.asv` files not yet | | | ⌥ + an adjustment's shortcut opens it at its last settings (⌥⌘L Levels, ⌥⌘M Curves, and so ⌥⌘U, ⌥⌘B) | done (#8900): the settings of the last OK, kept in the preferences; Cancel and smart filter edits keep nothing; a key assigned to another command stays that command's. ⌥ while choosing the item from Image › Adjustments does the same | `shortcut_dispatch/tests.rs` `alt_adjustment_shortcut_reopens_the_last_confirmed_settings`, `alt_choosing_an_adjustment_from_the_menu_reopens_the_last_settings` | +| Photo Filter keeps Preserve Luminosity: Image › Adjustments › Photo Filter… opens at Warming Filter (85) and 25 % after any OK, with Preserve Luminosity as last confirmed | done (#9879): measured in the reference application 2026-11-08 (filter, colour and density are not kept); ⌥ reopens at all the last settings; a smart filter's dialog shows that filter's own settings | `adjust_dialog/tests.rs` `photo_filter_keeps_only_preserve_luminosity_from_its_last_ok`, `photo_filter_reopens_with_the_choice_its_colour_implies` | | Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | a guide let go over either ruler is removed, not only off the canvas (#6286) | | Blend mode keys: ⇧⌥ + letter picks a mode (N Normal, I Dissolve, R Clear, P Pass Through, K M B A darken, G S D W lighten, O F H V J Z L contrast, E X difference, U T C Y component), ⇧+ / ⇧− step through them | done (2026-10-14): Brush, Pencil and Gradient take them as their own mode (Clear on Brush, Pencil and Paint Bucket only), any non-painting tool sets every selected layer in one undo step (Pass Through on groups only; the Background is skipped); the typed letter wins, the physical key is the fallback for ⌥ characters on a Mac. ⇧⌥ letters no longer switch tools. While a Free Transform box is open they, and the number keys, still reach the tool (or the layers) without closing it; Puppet Warp and Perspective Warp keep them to themselves. Clone Stamp, Pattern Stamp, History Brush, Healing (its own short list) and Paint Bucket (with Clear) take them as their own mode; tools without a blend mode (the Erasers, Mixer Brush) set the layers. Differs: Behind (⇧⌥Q) is not implemented; Spot Healing, Color Replacement, Blur, Sharpen and Smudge ignore the keys until their options bar has a Mode, and Dodge, Burn and Sponge ignore them until their Range and mode keys (⇧⌥S/M/H, ⇧⌥D/S) exist | `blend_keys.rs` | @@ -243,11 +245,12 @@ Paragraph; the rest open as in-app floating windows. | A colour picked in the Color or Swatches panel or the Color Picker recolours the selection: selected characters while editing; every selected type layer with a Type tool; every selected shape's fill with a shape, Pen or path selection tool; only the foreground with other tools, the Move tool too; a swatch click recolours the selected type layers and shape fills whatever the tool (#9238); one history step (a Color panel drag too) | done: `apply_color.rs`, `apply_color_tests.rs` | | | A Color Picker opened for another dialog's colour (Edit › Fill's Color…, Edit › Stroke, a filter's or adjustment's colour, Replace Color's Result, New Document, New Guide and guide layout colours, channel and spot colours, Preferences) or for the Crop shield's custom colour shows the colour under the cursor there before OK, as in Photoshop, so the dialog's swatch and its own preview follow it; Cancel puts back exactly what was there, OK keeps it without a history step of its own (the dialog's OK makes the one step), and the dialog's ⌘Z history records only the picker's OK (#9886) | partial | `dialogs/color_picker_ui/live_preview.rs`, `dialogs/color_picker_ui/live_preview/tests.rs`; differs: the pickers whose OK runs a command without a preview of its own (text colour and gradient stops, open in #9757; gradient preset stops, artboard background, count group colour) and the pasteboard's custom colour show the colour only after OK | | The Type tool's Color Picker (Text Color) and a gradient fill's Color Picker (Stop Color) preview the picked colour on the canvas before OK (the selected characters, every selected type layer, or the stop); Cancel restores the document with no history step, OK commits the previewed edit in one step (#5530) | done: `dialogs/color_picker_preview.rs`, `dialogs/color_picker_preview/tests.rs` | | +| Gradient stops come off the bar: a colour or opacity stop dragged up or down off the bar is removed on release while two of its kind remain; while it is off the ramp and canvas preview show it gone, and dragging it back onto the bar cancels; the selected stop's Delete button (Remove stop, the × beside the colour, in Gradient Map) does the same. Gradient Editor, Properties, the shape gradient popover, Gradient Map and Layer Style (Gradient Overlay) share one rule (#10742) | done: `tools/gradient_ui.rs` `off_strip`, `tools/gradient_ui/editor_tests.rs`, `adjust_editors/ui_tests.rs`, `layer_style/gradient_field.rs`. Differs: Gradient Map and Layer Style show colour stops only; no Delete-key shortcut for the selected stop | | | Window size and dock width remembered | done | UI-217-2 | | Histogram panel: Compact (the default, of the entire image), Expanded and All Channels views (the channel kept across them); RGB/CMYK/Lab/Gray channels, Luminosity and Colors (overlaid, mixed where they overlap); Show Channels in Color and Show Statistics; Source (Entire Image, Selected Layer, Adjustment Composite) for multilayer documents; Mean, Std Dev, Median, Pixels, Level, Count, Percentile and Cache Level for the pointer level or a dragged range; the cached-data warning, Uncached Refresh (menu, button, warning click, double-click); the adjusted histogram over the original while an adjustment previews; Cache Levels 1–8 in Preferences › Performance; Color Sampler levels marked on the graph | partial | `histogram_panel.rs`, `histogram_panel_tests.rs`, `tone.rs`; differs: filter dialogs that preview on the CPU proxy aren't counted live, a selection doesn't limit the histogram yet, with Source Selected Layer an adjustment dialog's preview isn't counted, and new strings are English-only so far | | Command palette with recents | done | UI-217-7 | | Menu bar from the keyboard: Alt / F10 focus the bar, ← → ↓ ↩ drive it, Alt+letter opens a menu, underlined mnemonics run items | done | `menu_keys.rs`, #4988; macOS uses the native bar | -| Every preference takes effect | partial | 10 of 192 do nothing (scorecard, 2026-11-06) | +| Every preference takes effect | partial | 10 of 195 do nothing (scorecard, 2026-11-07) | | UI scale presets | done | UI-217-9; 75–125% in 5% steps (#2492, #3222) | | History snapshots: camera button, New Snapshot dialog (⌥-click: Name, From), the first snapshot (the document as opened), snapshot rows (click returns and selects, rename, delete; the trash deletes the selected snapshot, and a snapshot or state dragged onto it, #5324), `history.*Snapshot` commands; History Options' snapshot options (Automatically Create First Snapshot, New Snapshot When Saving, Show New Snapshot Dialog by Default) | partial | `history_cmds.rs`, `history_options_cmds.rs`, `snapshot_rows` in `panels.rs`, `snapshot_dialog.rs`; History Brush sources to come (#2966) | | Themes: Photoshop's four greys | partial | five themes; #2661 | @@ -271,6 +274,9 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | Image › Adjustments › Photo Filter… keeps Preserve Luminosity from its last OK; filter, colour and density reopen at Warming Filter (85) and 25 %, measured in the reference application (#9879); no percentage changed | +| 2026-11-07 | minor | Gradient stops dragged off the bar are removed, with a live preview and drag-back cancel, in every stop editor: Gradient Map and Layer Style gained it, the Gradient Editor, Properties and shape popover share the same threshold (#10742); row added (done); no percentage changed | +| 2026-11-07 | minor | Foreground/background colours restored before the first frame (`Preferences.toolColors`, #8485), with separate pixel/mask pairs, HDR/alpha, exact CMYK ink picks and a final normal-exit flush; malformed values are ignored one by one, and first saves from older preferences merge against the default swatches so separate edits in two instances survive. No percentage changed | | 2026-11-07 | minor | Reset Transform on a smart object also clears the **Warp**, and centres on the average of the placement's four corners rather than the point its box centre maps to: measured in the reference application 2026-11-06 (a smart object warped with Arc 50, rotated 20° and scaled to 80 % came back with no warp at all, at its contents' own size, unrotated), which corrects the row below (#11662, discussion in #11675). The corner average is the box centre for an affine placement, so only Distort and Perspective placements move; the Smart Filter mask and linked masks keep following the placement, as under Free Transform. The step also requires its re-render: a visible filter PhotoCraft doesn't implement, or contents that can't be read, refuses it whole (no history state, placement and warp untouched) rather than recording the old warped pixels at the reset placement. No percentage changed | | 2026-11-06 | minor | Layer ▸ Smart Objects ▸ Reset Transform, and the same item in the Layers panel row menu for a smart object layer (#11662): 100 % of the current contents' own size again with rotation, skew, Distort and Perspective cleared and the object left centred where it is, in one undo step; the Warp and the smart filters are untouched, as Photoshop resets the transform and not the warp. No percentage changed | | 2026-11-06 | minor | Channels panel menu (☰): the channel items, not only the rows' context menu; New Channel…, New Spot Channel…, Channel Options… and Merge Channels… open their dialogs (New Channel and Merge Channels are new), and Channel Options… in Quick Mask mode opens a targeted alpha channel's options (#11451). No percentage changed | diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 617914afbdf..0607d992fca 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -63,9 +63,9 @@ note = "No ONNX runtime dependency" [[item]] id = "AUTO-219-8" target = "xtask check that every panel button routes through a command id" -status = "missing" +status = "partial" issue = 219 -note = "No such xtask check" +note = "cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 1 allowlisted function left (panels/color.rs color_field assigns the foreground/background colour instead of tools.setColors)" [[item]] id = "AUTO-219-9" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 49068c3cbe7..78be830a607 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -8,6 +8,7 @@ mod corpus_pins; mod i18n_coverage; mod ico; mod layers; +mod panel_commands; mod perf; mod pinned; mod scorecard; @@ -24,10 +25,12 @@ usage: cargo xtask commands: layers enforce the crate dependency layering (docs/architecture.md §3) + panel-commands fail on a shell click handler that changes the document or session directly instead of + dispatching a command id (allowlist: xtask/src/panel_commands.rs) wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call, plus one per optional web feature) test workspace tests (cargo nextest when installed, else cargo test), then doctests - ci the CI gate: fmt --check, clippy -D warnings, layers, scorecard --check, tests + ci the CI gate: fmt --check, clippy -D warnings, layers, panel-commands, scorecard --check, tests (nextest --profile ci), panic_hunt, doctests and wasm, with --locked --features heif (stops at first failure), then the i18n-coverage report corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --atn | --photoshop | --gimp] [--local] [--update-manifest] @@ -61,6 +64,7 @@ fn main() -> ExitCode { let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect(); let result = match args.first().map(String::as_str) { Some("layers") => cmd_layers(), + Some("panel-commands") => panel_commands::run(&root()), Some("wasm") => cmd_wasm(), Some("test") => cmd_test().and_then(|()| cmd_doctest()), Some("ci") => cmd_ci(), @@ -279,6 +283,7 @@ fn cmd_ci() -> Result<(), String> { }), ), ("layers", Box::new(cmd_layers)), + ("panel-commands", Box::new(|| panel_commands::run(&root()))), ("scorecard", Box::new(|| scorecard::run(&root(), &["--check"]))), ("test", Box::new(|| cmd_gate_tests(false))), ("panic_hunt", Box::new(|| cmd_gate_tests(true))), diff --git a/xtask/src/panel_commands.rs b/xtask/src/panel_commands.rs new file mode 100644 index 00000000000..eb929acfc73 --- /dev/null +++ b/xtask/src/panel_commands.rs @@ -0,0 +1,728 @@ +//! `cargo xtask panel-commands`: every panel button routes through a command id (AGENTS.md rule 1). +//! +//! The UI, CLI, control channel and MCP stay equivalent only while panels dispatch engine commands +//! by id (`app.run`, `menus::invoke`, `session.execute`, the shell's `UI_COMMANDS`). This check +//! scans the egui shell's source for click handlers that change the document or its history +//! directly instead. +//! +//! It is a lexical scanner, not a type checker (std only, no `syn`): +//! +//! * a **click handler** is the block of an `if` whose condition calls `.clicked()` (or +//! `double_clicked`, `secondary_clicked`, `triple_clicked`, `middle_clicked`, `changed`, +//! `dragged`, `drag_stopped`), reads a local bound by `let x = … .clicked() …;` in the same file, +//! or calls a widget function (one that takes a `Ui` and returns `bool`, like `color_readout`); +//! * inside it, a **direct mutation** is `active_mut()` (the mutable document state), +//! `Arc::make_mut` on a `doc`, `session.undo/redo/select_layer/add_document/close(…)`, or an +//! assignment to a session field (`app.session.tools.foreground = …`); +//! * test code is skipped: `*_tests.rs`, `tests.rs`, files under `tests/` and `#[cfg(test)]` items. +//! +//! Out of scope: preferences (`edit_prefs`), shell view state (`app.ui`, the `UI_COMMANDS`) and +//! switching the active document. Calls into helper functions are not followed, so a handler +//! that calls a helper which mutates the document passes; the check catches the inline pattern, +//! which is how such shortcuts start. Only `if` blocks (also a `match` arm guard with a block) +//! and `let` locals count as handlers: a `match` on a response without one, a response kept in a +//! struct field and tested later, or a closure callback is not scanned. +//! +//! Existing violations are listed in [`ALLOWLIST`] by file and enclosing function, each with the +//! reason it is still there. An entry that no longer matches anything is an error, so the list only +//! shrinks: fix the handler (dispatch a command id), then delete its entry. + +use std::path::{Path, PathBuf}; + +/// The shell source the check scans, relative to the workspace root. +pub const SCAN_ROOT: &str = "crates/ui-egui/src"; + +/// A known violation that is tolerated until its handler routes through a command. +#[derive(Debug, Clone, Copy)] +pub struct Allowed { + /// Path relative to the workspace root, with `/` separators. + pub file: &'static str, + /// Enclosing function of the click handler (`` outside any function). + pub func: &'static str, + /// Why it still mutates directly (and what the fix is). + pub reason: &'static str, +} + +/// Existing click handlers that change the document directly. Shrink only: each entry is a +/// follow-up (route it through an engine command), never a place to park new code. +pub const ALLOWLIST: &[Allowed] = &[ + // Window › Color: the colour field and hue strip (and their readout) assign the foreground or + // background colour on every drag frame, so the change never reaches the journal, action + // recording or the control channel's event log. Fix: dispatch `tools.setColors` (it already exists). + Allowed { + file: "crates/ui-egui/src/panels/color.rs", + func: "color_field", + reason: "Color panel field, hue strip and readout assign session.tools.foreground/background; dispatch tools.setColors", + }, +]; + +/// A click handler that changes the document or its history without a command id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Violation { + pub file: String, + pub line: usize, + pub func: String, + pub what: String, +} + +const TRIGGERS: &[&str] = &["clicked", "double_clicked", "secondary_clicked", "triple_clicked", "middle_clicked", "changed", "dragged", "drag_stopped"]; + +/// Session methods that change documents or history behind the command registry's back. +const SESSION_MUTATORS: &[&str] = &["undo", "redo", "select_layer", "add_document", "close"]; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Kind { + Ident(String), + Punct(char), + /// Literals and lifetimes: never part of a pattern. + Other, +} + +#[derive(Debug, Clone)] +struct Token { + kind: Kind, + line: usize, +} + +impl Token { + fn is_ident(&self, s: &str) -> bool { + matches!(&self.kind, Kind::Ident(i) if i == s) + } + fn is_punct(&self, c: char) -> bool { + self.kind == Kind::Punct(c) + } + fn ident(&self) -> Option<&str> { + match &self.kind { + Kind::Ident(i) => Some(i), + _ => None, + } + } +} + +/// Split Rust source into identifiers and punctuation, dropping comments and literals. +fn lex(src: &str) -> Vec { + let c: Vec = src.chars().collect(); + let at = |i: usize| c.get(i).copied().unwrap_or('\0'); + let mut out = Vec::new(); + let mut line = 1; + let mut i = 0; + while i < c.len() { + let ch = at(i); + if ch == '\n' { + line += 1; + i += 1; + } else if ch.is_whitespace() { + i += 1; + } else if ch == '/' && at(i + 1) == '/' { + while i < c.len() && at(i) != '\n' { + i += 1; + } + } else if ch == '/' && at(i + 1) == '*' { + (i, line) = skip_block_comment(&c, i, line); + } else if let Some(hashes) = raw_string_start(&c, i) { + // r"…", r#"…"#, br#"…"#: skip to the quote followed by the same number of hashes. + let start_line = line; + i += hashes.prefix; + while i < c.len() { + if at(i) == '"' && (1..=hashes.count).all(|k| at(i + k) == '#') { + i += 1 + hashes.count; + break; + } + if at(i) == '\n' { + line += 1; + } + i += 1; + } + out.push(Token { kind: Kind::Other, line: start_line }); + } else if ch == '"' || (ch == 'b' && at(i + 1) == '"') || (ch == 'c' && at(i + 1) == '"') { + let start_line = line; + (i, line) = skip_string(&c, i + if ch == '"' { 1 } else { 2 }, line); + out.push(Token { kind: Kind::Other, line: start_line }); + } else if ch == '\'' || (ch == 'b' && at(i + 1) == '\'') { + // Char literal ('a', '\n', b'x') or lifetime/label ('a, 'static). + let q = if ch == 'b' { i + 1 } else { i }; + if at(q + 1) == '\\' { + i = q + 2; + while i < c.len() && at(i) != '\'' && at(i) != '\n' { + i += 1; + } + i += 1; + } else if at(q + 2) == '\'' { + i = q + 3; + } else { + i = q + 1; + while i < c.len() && (at(i).is_alphanumeric() || at(i) == '_') { + i += 1; + } + } + out.push(Token { kind: Kind::Other, line }); + } else if ch.is_alphabetic() || ch == '_' { + let s = i; + while i < c.len() && (at(i).is_alphanumeric() || at(i) == '_') { + i += 1; + } + out.push(Token { kind: Kind::Ident(c.get(s..i).unwrap_or_default().iter().collect()), line }); + } else if ch.is_ascii_digit() { + while i < c.len() && (at(i).is_alphanumeric() || at(i) == '_' || (at(i) == '.' && at(i + 1).is_ascii_digit())) { + i += 1; + } + out.push(Token { kind: Kind::Other, line }); + } else { + out.push(Token { kind: Kind::Punct(ch), line }); + i += 1; + } + } + out +} + +/// Skip a (nested) block comment starting at `i`; returns the index after it and the new line. +fn skip_block_comment(c: &[char], mut i: usize, mut line: usize) -> (usize, usize) { + let at = |k: usize| c.get(k).copied().unwrap_or('\0'); + let mut depth = 0usize; + while i < c.len() { + if at(i) == '/' && at(i + 1) == '*' { + depth += 1; + i += 2; + } else if at(i) == '*' && at(i + 1) == '/' { + depth = depth.saturating_sub(1); + i += 2; + if depth == 0 { + break; + } + } else { + if at(i) == '\n' { + line += 1; + } + i += 1; + } + } + (i, line) +} + +/// Skip a quoted string body starting just after its opening quote; returns the index after the +/// closing quote and the new line. +fn skip_string(c: &[char], mut i: usize, mut line: usize) -> (usize, usize) { + let at = |k: usize| c.get(k).copied().unwrap_or('\0'); + while i < c.len() { + match at(i) { + '\\' => { + // A line continuation (`\` before a newline) still ends a source line. + if at(i + 1) == '\n' { + line += 1; + } + i += 2; + } + '"' => { + i += 1; + break; + } + '\n' => { + line += 1; + i += 1; + } + _ => i += 1, + } + } + (i, line) +} + +struct RawStart { + /// Characters before the body (`r#"` is 3). + prefix: usize, + count: usize, +} + +fn raw_string_start(c: &[char], i: usize) -> Option { + let at = |k: usize| c.get(k).copied().unwrap_or('\0'); + // Not the tail of an identifier such as `for` or `bar`. + if i > 0 && (at(i - 1).is_alphanumeric() || at(i - 1) == '_') { + return None; + } + let mut j = i; + if at(j) == 'b' || at(j) == 'c' { + j += 1; + } + if at(j) != 'r' { + return None; + } + j += 1; + let mut count = 0; + while at(j) == '#' { + count += 1; + j += 1; + } + (at(j) == '"').then_some(RawStart { prefix: j + 1 - i, count }) +} + +/// Index of the bracket closing the one at `open` (`{`, `(` or `[`), or the last token. +fn matching(toks: &[Token], open: usize) -> usize { + let mut depth = 0usize; + for (k, t) in toks.iter().enumerate().skip(open) { + match t.kind { + Kind::Punct('{' | '(' | '[') => depth += 1, + Kind::Punct('}' | ')' | ']') => { + depth = depth.saturating_sub(1); + if depth == 0 { + return k; + } + } + _ => {} + } + } + toks.len().saturating_sub(1) +} + +/// First `{` (or `;`) after `from` outside parentheses and brackets. +fn next_block_or_semi(toks: &[Token], from: usize) -> Option { + let mut depth = 0usize; + for (k, t) in toks.iter().enumerate().skip(from) { + match t.kind { + Kind::Punct('(' | '[') => depth += 1, + Kind::Punct(')' | ']') => depth = depth.saturating_sub(1), + Kind::Punct('{' | ';') if depth == 0 => return Some(k), + _ => {} + } + } + None +} + +/// Drop every item under `#[cfg(test)]` (or a `cfg(all(test, …))`), keeping `cfg(not(test))`. +fn strip_cfg_test(toks: Vec) -> Vec { + let mut out = Vec::with_capacity(toks.len()); + let mut i = 0; + while i < toks.len() { + if is_test_attr(&toks, i) { + // Skip this attribute, any further attributes, then the item itself. + let mut k = i; + while toks.get(k).is_some_and(|t| t.is_punct('#')) && toks.get(k + 1).is_some_and(|t| t.is_punct('[')) { + k = matching(&toks, k + 1) + 1; + } + i = match next_block_or_semi(&toks, k) { + Some(e) if toks.get(e).is_some_and(|t| t.is_punct('{')) => matching(&toks, e) + 1, + Some(e) => e + 1, + None => toks.len(), + }; + continue; + } + if let Some(t) = toks.get(i) { + out.push(t.clone()); + } + i += 1; + } + out +} + +fn is_test_attr(toks: &[Token], i: usize) -> bool { + let (Some(hash), Some(open), Some(cfg)) = (toks.get(i), toks.get(i + 1), toks.get(i + 2)) else { return false }; + if !hash.is_punct('#') || !open.is_punct('[') || !cfg.is_ident("cfg") { + return false; + } + let close = matching(toks, i + 1); + let inner = toks.get(i + 3..close).unwrap_or_default(); + inner.iter().any(|t| t.is_ident("test")) && !inner.iter().any(|t| t.is_ident("not")) +} + +/// `(body open, body close, name)` of every function with a body. +fn functions(toks: &[Token]) -> Vec<(usize, usize, String)> { + let mut out = Vec::new(); + for (i, t) in toks.iter().enumerate() { + if !t.is_ident("fn") { + continue; + } + let Some(name) = toks.get(i + 1).and_then(Token::ident) else { continue }; + if let Some(open) = next_block_or_semi(toks, i + 2).filter(|&o| toks.get(o).is_some_and(|t| t.is_punct('{'))) { + out.push((open, matching(toks, open), name.to_string())); + } + } + out +} + +/// Does `toks` call `.trigger(` for one of the click triggers? +fn calls_trigger(toks: &[Token]) -> bool { + toks.windows(3).any(|w| { + w.first().is_some_and(|t| t.is_punct('.')) + && w.get(1).and_then(Token::ident).is_some_and(|n| TRIGGERS.contains(&n)) + && w.get(2).is_some_and(|t| t.is_punct('(')) + }) +} + +/// Locals bound by `let name = … .clicked() …;`. +fn click_locals(toks: &[Token]) -> Vec { + let mut out = Vec::new(); + for (i, t) in toks.iter().enumerate() { + if !t.is_ident("let") { + continue; + } + let mut n = i + 1; + if toks.get(n).is_some_and(|t| t.is_ident("mut")) { + n += 1; + } + let (Some(name), Some(eq)) = (toks.get(n).and_then(Token::ident), toks.get(n + 1)) else { continue }; + if !eq.is_punct('=') { + continue; + } + let Some(end) = next_block_or_semi(toks, n + 2) else { continue }; + // A `{` before the `;` (a block or closure value) is out of scope for this heuristic. + if toks.get(end).is_some_and(|t| t.is_punct(';')) && calls_trigger(toks.get(n + 2..end).unwrap_or_default()) { + out.push(name.to_string()); + } + } + out +} + +/// Direct document/history mutations in `toks`: `(token index, description)`. +fn mutations(toks: &[Token]) -> Vec<(usize, String)> { + let mut out = Vec::new(); + for (i, t) in toks.iter().enumerate() { + let next = |k: usize| toks.get(i + k); + let Some(name) = t.ident() else { continue }; + let call = next(1).is_some_and(|t| t.is_punct('(')); + if name == "active_mut" && call && i > 0 && toks.get(i - 1).is_some_and(|p| p.is_punct('.')) { + out.push((i, "`active_mut()` changes the document directly".to_string())); + } else if name == "make_mut" && call { + let close = matching(toks, i + 1); + let args = toks.get(i + 2..close).unwrap_or_default(); + if args.iter().any(|a| a.ident().is_some_and(|n| n == "doc" || n.ends_with("_doc") || n == "document")) { + out.push((i, "`Arc::make_mut` on a document changes it directly".to_string())); + } + } else if name == "session" + && next(1).is_some_and(|t| t.is_punct('.')) + && let Some(m) = next(2).and_then(Token::ident).filter(|m| SESSION_MUTATORS.contains(m)) + && next(3).is_some_and(|t| t.is_punct('(')) + { + out.push((i, format!("`session.{m}()` bypasses the command registry"))); + } else if name == "session" + && let Some(field) = assigned_field(toks, i) + { + out.push((i, format!("`session.{field} = …` changes session state directly"))); + } + } + out +} + +/// `a.b` when `toks[i]` (`session`) starts `session.a.b = …` or a compound assignment (`+=`, …). +fn assigned_field(toks: &[Token], i: usize) -> Option { + let mut path = Vec::new(); + let mut k = i + 1; + while toks.get(k).is_some_and(|t| t.is_punct('.')) { + path.push(toks.get(k + 1)?.ident()?.to_string()); + k += 2; + } + let first = toks.get(k)?; + let second = toks.get(k + 1); + let plain = first.is_punct('=') && !second.is_some_and(|t| t.is_punct('=') || t.is_punct('>')); + let compound = matches!(first.kind, Kind::Punct('+' | '-' | '*' | '/' | '|' | '&' | '^')) && second.is_some_and(|t| t.is_punct('=')); + (!path.is_empty() && (plain || compound)).then(|| path.join(".")) +} + +/// Names of functions that draw a widget and report an edit: they take a `Ui` and return `bool` +/// (`fn color_readout(ui: &mut Ui, …) -> bool`). `if widget(…) { … }` is a click handler too. +pub fn widget_fns(src: &str) -> Vec { + let toks = strip_cfg_test(lex(src)); + let mut out = Vec::new(); + for (i, t) in toks.iter().enumerate() { + if !t.is_ident("fn") { + continue; + } + let Some(name) = toks.get(i + 1).and_then(Token::ident) else { continue }; + let Some(open) = toks.iter().enumerate().skip(i + 2).find(|(_, t)| t.is_punct('(')).map(|(k, _)| k) else { continue }; + let close = matching(&toks, open); + let takes_ui = toks.get(open..close).unwrap_or_default().iter().any(|t| t.is_ident("Ui")); + let returns_bool = toks.get(close + 1).is_some_and(|t| t.is_punct('-')) + && toks.get(close + 2).is_some_and(|t| t.is_punct('>')) + && toks.get(close + 3).is_some_and(|t| t.is_ident("bool")); + if takes_ui && returns_bool && !out.iter().any(|n| n == name) { + out.push(name.to_string()); + } + } + out +} + +/// Every click handler in `src` that changes the document directly. `file` labels the results. +/// `widgets` lists the widget functions of the whole tree (see [`widget_fns`]). +pub fn scan(file: &str, src: &str, widgets: &[String]) -> Vec { + let toks = strip_cfg_test(lex(src)); + let fns = functions(&toks); + let locals = click_locals(&toks); + let mut out: Vec = Vec::new(); + for (i, t) in toks.iter().enumerate() { + if !t.is_ident("if") { + continue; + } + let Some(open) = next_block_or_semi(&toks, i + 1) else { continue }; + if !toks.get(open).is_some_and(|t| t.is_punct('{')) { + continue; + } + let cond = toks.get(i + 1..open).unwrap_or_default(); + let is_click = calls_trigger(cond) + || cond.iter().any(|c| c.ident().is_some_and(|n| locals.iter().any(|l| l == n))) + || cond + .windows(2) + .any(|w| w.first().and_then(Token::ident).is_some_and(|n| widgets.iter().any(|f| f == n)) && w.get(1).is_some_and(|t| t.is_punct('('))); + if !is_click { + continue; + } + let close = matching(&toks, open); + let body = toks.get(open..=close).unwrap_or_default(); + for (k, what) in mutations(body) { + let at = open + k; + let line = toks.get(at).map_or(0, |t| t.line); + let func = fns.iter().filter(|(o, c, _)| *o < at && at < *c).min_by_key(|(o, c, _)| c - o).map_or("", |f| f.2.as_str()); + let v = Violation { file: file.to_string(), line, func: func.to_string(), what }; + if !out.contains(&v) { + out.push(v); + } + } + } + out.sort_by_key(|v| v.line); + out +} + +/// Is `path` (relative, `/`-separated) test code? +fn is_test_file(path: &str) -> bool { + path.ends_with("_tests.rs") || path.ends_with("/tests.rs") || path == "tests.rs" || path.contains("/tests/") || path.starts_with("tests/") +} + +/// Violations not covered by `allow`, and allowlist entries that match nothing (stale). +pub fn check<'a>(violations: &[Violation], allow: &'a [Allowed]) -> (Vec, Vec<&'a Allowed>) { + let covered = |v: &Violation, a: &Allowed| v.file == a.file && v.func == a.func; + let new = violations.iter().filter(|v| !allow.iter().any(|a| covered(v, a))).cloned().collect(); + let stale = allow.iter().filter(|a| !violations.iter().any(|v| covered(v, a))).collect(); + (new, stale) +} + +fn rust_files(dir: &Path, out: &mut Vec) -> Result<(), String> { + let entries = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + for e in entries { + let p = e.map_err(|e| format!("{}: {e}", dir.display()))?.path(); + if p.is_dir() { + rust_files(&p, out)?; + } else if p.extension().is_some_and(|x| x == "rs") { + out.push(p); + } + } + Ok(()) +} + +/// Scan the shell source under `root` (the workspace root). +pub fn scan_tree(root: &Path) -> Result, String> { + let base = root.join(SCAN_ROOT); + let mut files = Vec::new(); + rust_files(&base, &mut files)?; + files.sort(); + let mut sources = Vec::new(); + for f in files { + let rel = f.strip_prefix(&base).map_err(|e| e.to_string())?.to_string_lossy().replace('\\', "/"); + if is_test_file(&rel) { + continue; + } + let src = std::fs::read_to_string(&f).map_err(|e| format!("{}: {e}", f.display()))?; + sources.push((format!("{SCAN_ROOT}/{rel}"), src)); + } + let mut widgets = Vec::new(); + for (_, src) in &sources { + for name in widget_fns(src) { + if !widgets.contains(&name) { + widgets.push(name); + } + } + } + Ok(sources.iter().flat_map(|(file, src)| scan(file, src, &widgets)).collect()) +} + +pub fn run(root: &Path) -> Result<(), String> { + let violations = scan_tree(root)?; + let (new, stale) = check(&violations, ALLOWLIST); + println!("Panel command routing (AGENTS.md rule 1): click handlers in {SCAN_ROOT} must dispatch a command id\n"); + println!("{} direct mutation(s) in click handlers, {} allowlisted function(s)", violations.len(), ALLOWLIST.len()); + for v in &new { + println!(" {}:{}: in `{}`: {}; dispatch an engine command by id instead", v.file, v.line, v.func, v.what); + } + for a in ALLOWLIST.iter().filter(|a| !stale.iter().any(|s| s.file == a.file && s.func == a.func)) { + println!(" allowlisted {} `{}`: {}", a.file, a.func, a.reason); + } + for a in &stale { + println!(" stale allowlist entry {} `{}`: no direct mutation left there; delete it from xtask/src/panel_commands.rs", a.file, a.func); + } + if new.is_empty() && stale.is_empty() { + println!("OK: every other click handler routes through a command id."); + Ok(()) + } else { + Err(format!("{} unrouted click handler(s), {} stale allowlist entr(y/ies)", new.len(), stale.len())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lines(src: &str) -> Vec<(usize, String)> { + scan("f.rs", src, &[]).into_iter().map(|v| (v.line, v.func)).collect() + } + + #[test] + fn routed_button_passes() { + let src = r#" +fn panel(app: &mut App, ui: &mut Ui) { + if ui.button("Flatten").clicked() { + let _ = app.run("layer.flatten", json!({})); + } + if ui.button("Undo").clicked() { + menus::invoke(app, ctx, "edit.undo", Value::Null); + } +} +"#; + assert!(scan("f.rs", src, &[]).is_empty()); + } + + #[test] + fn direct_mutation_fails_with_file_line_and_function() { + let src = r#" +fn panel(app: &mut App, ui: &mut Ui) { + if ui.button("Hide").clicked() { + if let Some(st) = app.session.active_mut() { + st.active_layer = None; + } + } +} +"#; + let v = scan("crates/ui-egui/src/x.rs", src, &[]); + assert_eq!(v.len(), 1); + assert_eq!((v[0].file.as_str(), v[0].line, v[0].func.as_str()), ("crates/ui-egui/src/x.rs", 4, "panel")); + assert!(v[0].what.contains("active_mut")); + } + + #[test] + fn line_continuations_in_strings_keep_line_numbers() { + let src = " +fn panel(app: &mut App, ui: &mut Ui) { + ui.label(\"first \\ + second \\ + third\"); + if ui.button(\"Hide\").clicked() { + if let Some(st) = app.session.active_mut() { + st.active_layer = None; + } + } +} +"; + let v = scan("f.rs", src, &[]); + assert_eq!(v.len(), 1); + assert_eq!(v[0].line, 7); + } + + #[test] + fn a_match_arm_guard_with_a_block_is_a_click_handler() { + let src = " +fn panel(app: &mut App, ui: &mut Ui) { + match ui.button(\"Hide\") { + r if r.clicked() => { + app.session.active_mut(); + } + _ => {} + } +} +"; + assert_eq!(lines(src), [(5, "panel".to_string())]); + } + + #[test] + fn every_mutation_kind_and_trigger_is_caught() { + let src = r#" +fn a(app: &mut App, ui: &mut Ui) { + if ui.button("x").secondary_clicked() { app.session.undo(); } + if resp.double_clicked() && ok { Arc::make_mut(&mut st.doc).layers.clear(); } + let pressed = ui.button("y").clicked(); + if pressed { app.session.close(0); } + if slider.changed() { app.session.select_layer(id); } +} +"#; + assert_eq!(lines(src), vec![(3, "a".into()), (4, "a".into()), (6, "a".into()), (7, "a".into())]); + } + + #[test] + fn mutation_outside_click_handlers_test_code_comments_and_strings_pass() { + let src = r##" +fn sync(app: &mut App) { + if let Some(st) = app.session.active_mut() { st.dirty = true; } +} +fn panel(app: &mut App, ui: &mut Ui) { + if ui.button("x").clicked() { + // app.session.active_mut() + let s = "app.session.undo()"; + let r = r#"session.close(0)"#; + let c = '{'; + let _ = app.run("edit.undo", Value::Null); + } + if ui.button("y").clicked() { theme(Arc::make_mut(&mut fonts)); } +} +#[cfg(test)] +mod tests { + fn t(app: &mut App, ui: &mut Ui) { + if ui.button("x").clicked() { app.session.undo(); } + } +} +"##; + assert!(scan("f.rs", src, &[]).is_empty(), "{:?}", scan("f.rs", src, &[])); + } + + #[test] + fn widget_functions_and_session_assignments_count() { + let widgets = widget_fns("fn color_readout(ui: &mut egui::Ui, c: &mut [f32; 4]) -> bool { false }\nfn label(ui: &mut Ui) {}"); + assert_eq!(widgets, vec!["color_readout".to_string()]); + let src = r#" +fn field(app: &mut App, ui: &mut Ui) { + if color_readout(ui, &mut c) { app.session.tools.foreground = c; } + if r.dragged() { app.session.tools.brush.size += 1.0; } + if r.clicked() { let same = app.session.tools.foreground == c; app.ui.show = true; } + if r.clicked() { let _ = app.run("tools.setColors", json!({"foreground": "red"})); } +} +"#; + let v = scan("f.rs", src, &widgets); + assert_eq!(v.iter().map(|v| v.line).collect::>(), vec![3, 4]); + assert!(v[0].what.contains("session.tools.foreground"), "{}", v[0].what); + assert!(v[1].what.contains("session.tools.brush.size"), "{}", v[1].what); + } + + #[test] + fn cfg_not_test_is_scanned_and_lifetimes_do_not_confuse_the_lexer() { + let src = r#" +#[cfg(not(test))] +fn panel<'a>(app: &'a mut App, ui: &mut Ui) { + if ui.button("x").clicked() { app.session.redo(); } +} +"#; + assert_eq!(lines(src), vec![(4, "panel".into())]); + } + + #[test] + fn allowlisted_passes_and_stale_entry_is_reported() { + let v = vec![Violation { file: "a.rs".into(), line: 3, func: "panel".into(), what: "x".into() }]; + let allow = [Allowed { file: "a.rs", func: "panel", reason: "known" }, Allowed { file: "a.rs", func: "gone", reason: "fixed already" }]; + let (new, stale) = check(&v, &allow); + assert!(new.is_empty()); + assert_eq!(stale.len(), 1); + assert_eq!(stale[0].func, "gone"); + let (new, stale) = check(&v, &[]); + assert_eq!(new, v); + assert!(stale.is_empty()); + } + + #[test] + fn test_files_are_recognised() { + assert!(is_test_file("panels_tests.rs")); + assert!(is_test_file("file_dialog/tests.rs")); + assert!(is_test_file("discard_ui/tests/tiff_tests.rs")); + assert!(!is_test_file("panels.rs")); + assert!(!is_test_file("tests_ui.rs")); + } + + #[test] + fn the_allowlist_has_reasons_and_no_duplicates() { + for (i, a) in ALLOWLIST.iter().enumerate() { + assert!(!a.reason.trim().is_empty(), "{} `{}` needs a reason", a.file, a.func); + assert!(a.file.starts_with(SCAN_ROOT), "{} is outside {SCAN_ROOT}", a.file); + assert!(!ALLOWLIST.iter().skip(i + 1).any(|b| b.file == a.file && b.func == a.func), "duplicate {} `{}`", a.file, a.func); + } + } +}