diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c3597f5ee..d0f8c9770d 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 70cbd9e355..e2b8e229a7 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 f11c7f30e0..4b38673804 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 cec58ffb71..186be182eb 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 e01bcf175a..f5fafa868d 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 9ab6b0a1cf..d51db72c91 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 b1b453a318..c1789ab7d2 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 4542a71da9..7640483f44 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 87c0b5599c..2baf000039 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 95e8d4d8e2..5541da11a6 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 05861d7477..1628e8a5cb 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 a0c72c39b2..2cb64e6e55 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 719e8239c9..72548ca638 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 0000000000..09931ffd77 --- /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 0000000000..019f70872d --- /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 0000000000..c5269fe99a --- /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 6137b12466..c7278455ef 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 a2ead57598..5995994db3 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/selection.rs b/crates/algo/src/selection.rs index daf7a2526a..0d8866e18e 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 4e7bdcbe06..5f02b53a58 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 0000000000..6d435e12cf --- /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/compose/src/cmyk/tests.rs b/crates/compose/src/cmyk/tests.rs index 8deaa68de3..952d29be63 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 b351bdef06..30c64c7bdf 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 4e3f32a97f..a71aa883dd 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/engine/src/brush_clear_background_tests.rs b/crates/engine/src/brush_clear_background_tests.rs new file mode 100644 index 0000000000..e295e68c0b --- /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 93bc4654b9..ff40ea8a8b 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 c4addf4c22..34fc19846b 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/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs index 6a18a871f1..ed5da88e7d 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/analysis_cmds.rs b/crates/engine/src/cmd/analysis_cmds.rs index d9d7d1510c..51986b4894 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 dc76470bc9..6978b4d059 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 0000000000..b2d107d7bb --- /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/brush_cmds.rs b/crates/engine/src/cmd/brush_cmds.rs index e09ff2098c..87a4bcdd41 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 bfe409c471..f0abb81995 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 f72a0e207b..3abf932823 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 c7c7942d0b..9fd1762c45 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 0000000000..b9c454fb33 --- /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 0000000000..2cfa357ab4 --- /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/edit_cmds.rs b/crates/engine/src/cmd/edit_cmds.rs index dbf1a9a6f1..d460524c25 100644 --- a/crates/engine/src/cmd/edit_cmds.rs +++ b/crates/engine/src/cmd/edit_cmds.rs @@ -1267,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/eraser_cmds.rs b/crates/engine/src/cmd/eraser_cmds.rs index 1433498078..bf08c18ac2 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 495f9d1892..b1ec4df7d6 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 6121180e22..282eae0e4e 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 0000000000..57bd8ce39f --- /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 0000000000..c205af3d71 --- /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 905f359aaa..c252acc1de 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 d42d333128..33b1f60875 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 fa6a50e0a1..9eff784a42 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 1e0fe5f223..2bc3214c1d 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 c967f86964..0dcd7c2333 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/mode_cmds.rs b/crates/engine/src/cmd/mode_cmds.rs index ea6504f7a8..2adde91685 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 8f190f445c..e5dc8aac33 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 2f589f3390..133417649d 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 0000000000..1db8e5681b --- /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 3adbee6f06..4430f4d1d2 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 0746ca7440..115b8c40f9 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 6a8a6e2b05..66cf276332 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 a260443caf..b26decfd69 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 1cfcd3bfca..1f1c6b023e 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 0000000000..7ed9e54efa --- /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 0000000000..2fcee6e1d7 --- /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 242579198e..63c3b40f07 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 25ebcc8a97..f1980bae0d 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 13737e70bd..619a1af7ae 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 0000000000..51540e3cfd --- /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/transform_cmds/multi_tests.rs b/crates/engine/src/cmd/transform_cmds/multi_tests.rs index a50e4239fd..ec0c817b12 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 d05c05f46a..f1f8aa53c4 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 0000000000..8f6a8058fb --- /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/jobs.rs b/crates/engine/src/jobs.rs index 1390e89902..2197d57f91 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -957,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() } } @@ -2148,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 9bb6ab7693..f89779954b 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 8f28147217..24187b53bd 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 0000000000..8f31a41cd5 --- /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 65f5f735cb..95566aa043 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -570,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, @@ -607,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, @@ -630,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 { @@ -642,6 +655,7 @@ impl Default for Export { webp_quality: 85, metadata: ExportMetadata::Copyright, convert_to_srgb: true, + last_folder: String::new(), } } } diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 49f8205079..b5b82a4753 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 49db9509e5..7aaf58ffcd 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/multi_channel_editing.rs b/crates/engine/tests/multi_channel_editing.rs index 5780c1318e..09cf6edc97 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 202f0216e1..4bda862af1 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 dcd86482db..2090bdb00f 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/camera_raw_xmp.rs b/crates/io/src/camera_raw_xmp.rs index 65df97e898..76fd8bb0ca 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/lib.rs b/crates/io/src/lib.rs index 68b1754e61..c796b7cffa 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/pdf.rs b/crates/io/src/pdf.rs index d693ee941b..86b03b565a 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 e745623e41..247ab99192 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 0000000000..af56f64e21 --- /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 0000000000..b55da30bec --- /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/raw.rs b/crates/io/src/raw.rs index a1ed4aaa01..a1d1c73067 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/tests/it/main.rs b/crates/io/tests/it/main.rs index db6fc6cdf3..476818fa44 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 0000000000..b614f54c97 --- /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 52250c24d7..12a3d362e4 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 323a90036a..e639cbaa75 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 0000000000..ee95dd8c2e --- /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 0000000000..dcbfab1a20 --- /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 20619cd48a..c4157fac81 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 0000000000..caf024fb40 --- /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 133c0fa843..494a4cb6a9 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/text/src/fonts.rs b/crates/text/src/fonts.rs index 416ae7834c..489f2eef5e 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 4a4219e143..75f3348b08 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 e110aa82fa..834bcfc5dd 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -227,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 diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 26ac96f722..8264895665 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 3908d3888f..dafe8a25ef 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_brush_popup_tests.rs b/crates/ui-egui/src/background_eraser_brush_popup_tests.rs new file mode 100644 index 0000000000..3c0dbd2b54 --- /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 0000000000..9cddb5a7eb --- /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 0000000000..acf9d3de15 --- /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_blend_keys_behind_tests.rs b/crates/ui-egui/src/brush_blend_keys_behind_tests.rs new file mode 100644 index 0000000000..372bd5aca9 --- /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 431ad7aea2..71259e9f53 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 0000000000..77f84a7b41 --- /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 0000000000..caed875a5b --- /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 0000000000..7aa6bee9fa --- /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 e5b0f62e08..c8a4edd8e2 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 => { diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index b2e377e0ec..6e8b5b2d6c 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 0000000000..52424544c8 --- /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 e5c46b42ae..41c5c72f9d 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 0000000000..d5039a6257 --- /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 e4f0336b1d..f5f3b3912c 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, the Background Eraser #11906). +/// 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 | Tool::BackgroundEraser) + 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 @@ -290,6 +291,10 @@ pub(crate) fn alt_picks(tool: Tool, mods: egui::Modifiers) -> bool { #[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/filter_preview.rs b/crates/ui-egui/src/canvas/filter_preview.rs index 54679f034d..641fa91720 100644 --- a/crates/ui-egui/src/canvas/filter_preview.rs +++ b/crates/ui-egui/src/canvas/filter_preview.rs @@ -71,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 @@ -128,6 +129,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e task.target, &documents, &color, + Some(task.tool_colors), ), } .map(|doc| { @@ -172,6 +174,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e request.target, &documents, &app.session.color.settings, + Some(request.tool_colors), ), } .map(|doc| { diff --git a/crates/ui-egui/src/canvas/gesture.rs b/crates/ui-egui/src/canvas/gesture.rs index b67aec4beb..0ba47dadd9 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/paint.rs b/crates/ui-egui/src/canvas/paint.rs index a091602313..7366dafdd3 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 2c16ce1fa1..a9d0818434 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 } @@ -70,14 +72,19 @@ fn session_fields(current: &BrushSettings, brush: &BrushSettings) -> BrushSettin /// 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 other tools keep the default tip. +/// [`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() }; - if tool == Tool::Brush { - (brush.size, brush.hardness, brush.spacing) = BRUSH_TOOL_TIP; + 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) } @@ -348,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) { @@ -425,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); @@ -481,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 c31e137e82..d1092867fe 100644 --- a/crates/ui-egui/src/canvas/paint_mouse/tests.rs +++ b/crates/ui-egui/src/canvas/paint_mouse/tests.rs @@ -1023,3 +1023,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 b669a60ed3..942acc4da4 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 989af47e1c..f1a8917b63 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 b42264a188..bd809a30fc 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 64fbe541e5..b033500d03 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 497de35430..631a7dac5b 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 a3b8186e8e..e652fb536f 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 diff --git a/crates/ui-egui/src/canvas/tool_cursor.rs b/crates/ui-egui/src/canvas/tool_cursor.rs index 84b69b26cf..c34f4d6622 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 bd5b40c97e..ec003d4845 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 6c9c890571..88aec042e5 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 f148b8dfa5..0f8a04f770 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 97325bdb40..90e4e4fb43 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 ecfbfe056d..2bb92e0d8b 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 0000000000..8fd1533a02 --- /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 64f04f3c73..87adc1f64c 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/dialogs/adjust_dialog.rs b/crates/ui-egui/src/dialogs/adjust_dialog.rs index f20ea8618e..f435bde1b4 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 9926953689..c1624fe3c9 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 6a0e9694b6..e10d32652b 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 db9412514c..81b5dba8cb 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/camera_raw_ui/identity_tests.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs index b8c75a94e1..2866ef5ecb 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(); 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 dbae1e6ee7..95da88ae1d 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 @@ -72,6 +75,8 @@ pub fn queue_open_dialog( 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); @@ -86,7 +91,7 @@ pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpe /// [`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, raw: RawOpen, kept: Option<(CameraRaw, CameraRaw)>) -> Result<(), String> { +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)?; @@ -104,6 +109,7 @@ pub(super) fn open_raw_with(app: &mut PhotocraftApp, ctx: &egui::Context, raw: R 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)]) @@ -208,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() }; @@ -387,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 @@ -397,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 { 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 9ac2f8c997..3e5c00f94f 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs @@ -88,7 +88,8 @@ pub(super) fn store_sidecar(app: &mut PhotocraftApp, raw: &RawOpen, params: &Cam None if params.is_identity() => return Stored::Defaults, _ => {} } - let bytes = photocraft_io::camera_raw_xmp::save(params).into_bytes(); + // 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)])); @@ -146,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 9c817414bc..76591f3df1 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs @@ -705,7 +705,7 @@ fn auto_tone_of_a_never_shown_raw_matches_the_dialogs() { } } let method = photocraft_io::raw::Demosaic::Ahd; - let raw = RawOpen { document, name: "pattern.dng".into(), path: None, bytes: None, developed_with: method, demosaic: method }; + 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; @@ -745,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() @@ -988,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] diff --git a/crates/ui-egui/src/dialogs/color_picker_ui.rs b/crates/ui-egui/src/dialogs/color_picker_ui.rs index d59bf082c3..1c76f566fa 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 dcd6ba003d..c2a3fd15af 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 d8db1fee04..d4fa5ebf72 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 5923fb4d2d..2464721efd 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; @@ -431,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); } @@ -859,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()); @@ -875,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); @@ -1193,6 +1305,7 @@ pub fn preview_document_with( photocraft_engine::channel_cmds::ChannelTarget::Composite, &[], &ColorSettings::default(), + None, ) } @@ -1200,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, @@ -1211,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); @@ -1246,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); @@ -1388,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 @@ -1414,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 de9f389e23..74e3f806eb 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 f83a260b0d..7378370984 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 0950e67939..ad02315a8c 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 8abbefbbd7..dd0ebca992 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/open_prompt.rs b/crates/ui-egui/src/dialogs/open_prompt.rs index 78691112c0..a34eae8ecd 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 f6ef5a1e46..c8d14130ac 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 0000000000..73be8c8750 --- /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 bddc6796d3..cfec9c0f05 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 abebcac5f4..53ce32d2a2 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 865f65d7ef..3932df61b2 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 a617332bd1..38e07dfa85 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 806aadd8a2..ce69c21943 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 1cfced87ac..b6482560e1 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 c228ba73d0..7b8822c982 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 3a9cdb8137..27c056d5e3 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 eb8d5dc546..66e47bb783 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 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index cf9105b865..16eeb67b88 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 6a970a2c7a..814227eb5a 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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index af6353f692..8fd84deab9 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 Στοίχιση κατακόρυφων κέντρων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e9b758a917..75b16e771c 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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index eb99627d4f..9d67d3414f 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 c0a008b3e4..439781ff10 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 diff --git a/crates/ui-egui/src/i18n/he.tsv b/crates/ui-egui/src/i18n/he.tsv index d38b62ed3a..c8f682bf39 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 2e6854129a..6fc8988ef4 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 ced7e54f43..bc4edfa981 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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 4ba6f52758..e35857c4bb 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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1d671feece..0d48264996 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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c464084b38..9c19da7297 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 垂直方向中央を揃える diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 02e5270e11..c5085bc9f7 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 4d4815caf2..d5c1a6a3d0 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) 색상 선택기(배경색) diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 83643360f3..6124c806fe 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 07088b2d3e..805c7d92d1 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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index b6dd0211f0..4e34bf6a7d 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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 15fdbbdbeb..17412673c9 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 diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 5bf7d924c8..57539882ab 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 65575eaf9a..10eea079f6 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 Выровнять центры по вертикали diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index b2c1d42cab..af83474e79 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 diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 2157f234de..105b818ae7 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 3c45b9a218..8131977605 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 ef34287b6b..83f23d4fc1 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 ee2567d21e..6bcf9a3e8c 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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8035a7f224..4b57f9c8f0 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 Вирівняти центри по вертикалі diff --git a/crates/ui-egui/src/i18n/ur.tsv b/crates/ui-egui/src/i18n/ur.tsv index 4d5eaf7961..a6cadc2114 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 f1e4723709..0651f9000b 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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 28c6119f66..3d8f8659f2 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 垂直居中对齐 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8d2c5697f1..3eb32cc6ef 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 垂直置中對齊 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 93103b34a3..5b89a68d20 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 c102064a7f..0a40f7b034 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 fb3d5d47b5..ebb018b381 100644 --- a/crates/ui-egui/src/input/blend_keys.rs +++ b/crates/ui-egui/src/input/blend_keys.rs @@ -202,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 })); } } @@ -299,3 +308,7 @@ 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 7f02c0c77b..55044a26f1 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 c3ef377874..d525dc2a0b 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 0000000000..5b846d5e7f --- /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 c4ff5397c2..7459f54be6 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 0000000000..b8a20c3b27 --- /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 7d5d2a90e8..08bb90fa78 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; @@ -775,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)] @@ -865,6 +871,9 @@ mod transform_reference_point_tests; #[cfg(test)] mod transform_relative_position_tests; +#[cfg(test)] +mod star_tool_tests; + #[cfg(test)] mod transform_several_tests; @@ -898,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; @@ -979,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)] @@ -1007,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 3838a70bcf..c6e7ad8217 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/view_cmds/dialogs.rs b/crates/ui-egui/src/menus/view_cmds/dialogs.rs index 16cefbd87b..b2b7d56158 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 f1e9b2770f..aef68d2dd6 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 0000000000..2b4cd12782 --- /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/notices.rs b/crates/ui-egui/src/notices.rs index 32c4d22999..116bd6cfc9 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 0d40e2280b..c4a8601769 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/brush_controls.rs b/crates/ui-egui/src/panels/brush_controls.rs index 9c07f56255..925a10fef7 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 164443600c..2fc3f16cf1 100644 --- a/crates/ui-egui/src/panels/brush_panel/tests.rs +++ b/crates/ui-egui/src/panels/brush_panel/tests.rs @@ -1877,6 +1877,110 @@ fn checkboxes_show_a_pressed_state_from_their_label() { 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; diff --git a/crates/ui-egui/src/panels/brush_sections.rs b/crates/ui-egui/src/panels/brush_sections.rs index 7026f83352..d7c16283fb 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(); @@ -355,11 +383,7 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset], 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 } @@ -383,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); @@ -528,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 abed333e02..96338c3464 100644 --- a/crates/ui-egui/src/panels/brush_sections/tests.rs +++ b/crates/ui-egui/src/panels/brush_sections/tests.rs @@ -340,6 +340,170 @@ fn ellipse_widget_drags_angle_and_roundness() { 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 { diff --git a/crates/ui-egui/src/panels/history.rs b/crates/ui-egui/src/panels/history.rs index 38d3797014..e75dbbb5b9 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/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs index 1d29b571a7..2f59092106 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)] @@ -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 0000000000..ce00ce0bc7 --- /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 34258ab909..56234d8f51 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 12f85de2c1..da8500f535 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 0000000000..e9cf4b1f0b --- /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_shift_constrain_tests.rs b/crates/ui-egui/src/patch_shift_constrain_tests.rs new file mode 100644 index 0000000000..590d4ed5b5 --- /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 0000000000..d0dc3e771d --- /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 aefc2bb3c7..2e73604cc9 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 0000000000..7ae125d24a --- /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/psd_open_tests.rs b/crates/ui-egui/src/psd_open_tests.rs index 02c18313f5..d09d91e9d5 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 0000000000..77a5da88bc --- /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 0000000000..06cd6d12de --- /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 0000000000..b83bb15d83 --- /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 256a88bb38..5b79331819 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -769,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 8dda7ce231..66aa7d53f6 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 e5db1af422..12c9bc4f51 100644 --- a/crates/ui-egui/src/state/tool_options.rs +++ b/crates/ui-egui/src/state/tool_options.rs @@ -226,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. @@ -251,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, @@ -522,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(), @@ -532,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/tool_shift_cycle_order_tests.rs b/crates/ui-egui/src/tool_shift_cycle_order_tests.rs index 83c3ca33cc..e49077c4ff 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 9878bf41a4..b921d98268 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 0000000000..301636845f --- /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 86e7d5fa82..ad31e53463 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). @@ -68,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, @@ -219,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) { @@ -315,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 => {} } @@ -393,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 { @@ -535,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})); @@ -1113,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_menu_tests.rs b/crates/ui-egui/src/tools/analysis_ui/sampler_menu_tests.rs new file mode 100644 index 0000000000..9388a31f68 --- /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 3ed65a3ffd..517e7df649 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 0000000000..d59c55197e --- /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 3e59a90444..a09179944e 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 } @@ -260,35 +273,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); @@ -437,3 +460,7 @@ fn gear_menu(ui: &mut egui::Ui, current: Option<&str>, presets: &[BrushPreset], } }); } + +#[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 0000000000..c1919c8639 --- /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 6358ffd139..f5ec3192e9 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 0000000000..773ce74546 --- /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 d652ebf9d4..f09f52f29b 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 3709ec6767..68b0714190 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 ac896eeb0b..3c5b997cb3 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 c3d64441c9..34373d66b6 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 8bc5019ddf..0882a50ed2 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/perspective_crop_bar_buttons_tests.rs b/crates/ui-egui/src/tools/perspective_crop_bar_buttons_tests.rs new file mode 100644 index 0000000000..01d4043b5e --- /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 ed60c90d41..9875916d72 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 540cf5879a..dbfef4bda1 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 375f8a410a..ad2bb0a79a 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 ee137bc541..d56508bf96 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 9ce92dc2bb..d4d7d32127 100644 --- a/crates/ui-egui/src/tools/tool_reset.rs +++ b/crates/ui-egui/src/tools/tool_reset.rs @@ -218,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), @@ -259,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 diff --git a/crates/ui-egui/src/tools/transform_fx.rs b/crates/ui-egui/src/tools/transform_fx.rs index 0ad888536d..5be2428c85 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 544e7a8359..8205381f60 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/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index 2cd5ba41a8..2f4d1b973f 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 96ef000f36..08d24d6607 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] @@ -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 42ad346f61..d0807fe5dd 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 091a0a22cb..435db44e59 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 f93826c1d1..02ba991308 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 7c8162e3f4..022b978f8b 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 74c81c2f7d..0da9b40319 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 0000000000..30566900b0 --- /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 0000000000..dba036ae85 --- /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 33287d1234..d533e10cd0 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 0000000000..a41a0e2f2c --- /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 19bab778a4..68df59059f 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 0000000000..9d3145e9ef --- /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 bbd674c4f4..e8fe67f8bd 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}; diff --git a/crates/ui-egui/src/tools/vector_ui/pen.rs b/crates/ui-egui/src/tools/vector_ui/pen.rs index 563b78455b..d31dac59a8 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 7b67544721..562e700bc3 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 0000000000..201bb30c33 --- /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_type_tests.rs b/crates/ui-egui/src/transform_type_tests.rs index eeb91f44b9..e179e4f6d5 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 3b4320dd0d..be7ac07649 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 8602af4e61..fee418fc03 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/background_eraser_reset_tip.rs b/crates/ui-egui/tests/it/background_eraser_reset_tip.rs new file mode 100644 index 0000000000..f971412bc4 --- /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/main.rs b/crates/ui-egui/tests/it/main.rs index 41d3f3e662..9e160488d4 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -5,6 +5,7 @@ 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; diff --git a/docs/architecture.md b/docs/architecture.md index 0125f26693..ee9650cb10 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/brush-parity.md b/docs/brush-parity.md index acd93b311e..99501ca8d5 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-07 · **Change:** minor (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 +> **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,9 +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 | | @@ -134,7 +136,9 @@ 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. | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5934696f08..7ebf31bbe9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -86,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. @@ -565,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 1fa912575f..998c9b8b95 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 51c000ba19..207778cb37 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 (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 +> **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) | @@ -75,7 +75,7 @@ supports many more, including CR3). | Fujifilm RAF (Bayer and X-Trans) | uncompressed only; no Fuji colour calibration | | | 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 | missing | FILE-215-11 | +| .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 @@ -152,9 +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 49aa80583c..7a038cf6cf 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-07 · **Change:** minor (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) +> **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,15 @@ 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 @@ -563,7 +572,9 @@ 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 | diff --git a/docs/plugins.md b/docs/plugins.md index 4f582ae9e4..bb2859cef8 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 a8d7c8e987..31443c1949 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 195 (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 195** (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/ui-parity.md b/docs/ui-parity.md index a634a3ae9b..9a65b68727 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 (Hue/Saturation range zones drag, #9252; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Hue/Saturation range zones drag, #9252; 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, @@ -224,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` | @@ -244,6 +245,7 @@ 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 | @@ -272,6 +274,8 @@ 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 | diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 617914afbd..0607d992fc 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 49068c3cbe..78be830a60 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 0000000000..eb929acfc7 --- /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); + } + } +}